diff --git a/docs/src/pages/docs/api.md b/docs/src/pages/docs/api.md index 72844fa922..9fac836127 100644 --- a/docs/src/pages/docs/api.md +++ b/docs/src/pages/docs/api.md @@ -26,6 +26,7 @@ const { enabled, initialData, initialStale, + placeholderData, isDataEqual, keepPreviousData, notifyOnStatusChange, @@ -137,6 +138,12 @@ const queryInfo = useQuery({ - Optional - If set, this will mark any `initialData` provided as stale and will likely cause it to be refetched on mount - If a function is passed, it will be called only when appropriate to resolve the `initialStale` value. This can be useful if your `initialStale` value is costly to calculate. + - `initialData` **is persisted** to the cache +- `placeholderData: any | Function() => any` + - Optional + - If set, this value will be used as the placeholder data for this particular query instance while the query is still in the `loading` data and no initialData has been provided. + - If set to a function, the function will be called **once** during the shared/root query initialization, and be expected to synchronously return the initialData + - `placeholderData` is **not persisted** to the cache - `keepPreviousData: Boolean` - Optional - Defaults to `false` @@ -176,6 +183,8 @@ const queryInfo = useQuery({ - Will be `true` if the cache data is stale. - `isPreviousData: Boolean` - Will be `true` when `keepPreviousData` is set and data from the previous query is returned. +- `isPlaceholderData: Boolean` + - Will be `true` if and when the query's `data` is equal to the result of the `placeholderData` option. - `isFetchedAfterMount: Boolean` - Will be `true` if the query has been fetched after the component mounted. - This property can be used to not show any previously cached data. diff --git a/src/core/queryObserver.ts b/src/core/queryObserver.ts index 775111521b..a33bdb5937 100644 --- a/src/core/queryObserver.ts +++ b/src/core/queryObserver.ts @@ -6,7 +6,13 @@ import { noop, } from './utils' import { notifyManager } from './notifyManager' -import type { QueryConfig, QueryResult, ResolvedQueryConfig } from './types' +import type { + QueryConfig, + QueryResult, + ResolvedQueryConfig, + PlaceholderDataFunction, +} from './types' +import { QueryStatus } from './types' import type { Query, Action, FetchMoreOptions, RefetchOptions } from './query' import { DEFAULT_CONFIG, isResolvedQueryConfig } from './config' @@ -243,6 +249,7 @@ export class QueryObserver { const { state } = this.currentQuery let { data, status, updatedAt } = state let isPreviousData = false + let isPlaceholderData = false // Keep previous data if needed if ( @@ -256,6 +263,19 @@ export class QueryObserver { isPreviousData = true } + if (status === 'loading' && this.config.placeholderData) { + const placeholderData = + typeof this.config.placeholderData === 'function' + ? (this.config.placeholderData as PlaceholderDataFunction)() + : this.config.placeholderData + + if (typeof placeholderData !== 'undefined') { + status = QueryStatus.Success + data = placeholderData + isPlaceholderData = true + } + } + this.currentResult = { ...getStatusProps(status), canFetchMore: state.canFetchMore, @@ -270,6 +290,7 @@ export class QueryObserver { isFetchingMore: state.isFetchingMore, isInitialData: state.isInitialData, isPreviousData, + isPlaceholderData, isStale: this.isStale, refetch: this.refetch, remove: this.remove, diff --git a/src/core/tests/queryCache.test.tsx b/src/core/tests/queryCache.test.tsx index 32b91e6746..3910d2d01d 100644 --- a/src/core/tests/queryCache.test.tsx +++ b/src/core/tests/queryCache.test.tsx @@ -847,4 +847,33 @@ describe('queryCache', () => { consoleMock.mockRestore() }) }) + + describe('QueryObserver', () => { + test('uses placeholderData as non-cache data when loading a query with no data', async () => { + const key = queryKey() + const cache = new QueryCache() + const observer = cache.watchQuery(key, { placeholderData: 'placeholder' }) + + expect(observer.getCurrentResult()).toMatchObject({ + status: 'success', + data: 'placeholder', + }) + + const results: QueryResult[] = [] + + observer.subscribe(x => { + results.push(x) + }) + + await cache.fetchQuery(key, async () => { + await sleep(100) + return 'data' + }) + + expect(results[0].data).toBe('data') + + observer.unsubscribe() + cache.clear() + }) + }) }) diff --git a/src/core/types.ts b/src/core/types.ts index af7c9879cf..296d405f23 100644 --- a/src/core/types.ts +++ b/src/core/types.ts @@ -26,6 +26,7 @@ export type TypedQueryFunction< export type TypedQueryFunctionArgs = readonly [unknown, ...unknown[]] export type InitialDataFunction = () => TResult | undefined +export type PlaceholderDataFunction = () => TResult | undefined export type InitialStaleFunction = () => boolean @@ -49,6 +50,7 @@ export interface BaseQueryConfig { queryKeySerializerFn?: QueryKeySerializerFunction queryFnParamsFilter?: (args: ArrayQueryKey) => ArrayQueryKey initialData?: TResult | InitialDataFunction + placeholderData?: TResult | InitialDataFunction infinite?: true /** * Set this to `false` to disable structural sharing between query results. @@ -204,6 +206,7 @@ export interface QueryResultBase { isInitialData: boolean isLoading: boolean isPreviousData: boolean + isPlaceholderData: boolean isStale: boolean isSuccess: boolean refetch: (options?: RefetchOptions) => Promise diff --git a/src/react/tests/useInfiniteQuery.test.tsx b/src/react/tests/useInfiniteQuery.test.tsx index 9ebbbc91a9..439c8f1818 100644 --- a/src/react/tests/useInfiniteQuery.test.tsx +++ b/src/react/tests/useInfiniteQuery.test.tsx @@ -20,7 +20,11 @@ const initialItems = (page: number): Result => { } } -const fetchItems = async (page: number, ts: number, nextId?: any): Promise => { +const fetchItems = async ( + page: number, + ts: number, + nextId?: any +): Promise => { await sleep(10) return { items: [...new Array(10)].fill(null).map((_, d) => page * pageSize + d), @@ -74,6 +78,7 @@ describe('useInfiniteQuery', () => { isInitialData: true, isLoading: true, isPreviousData: false, + isPlaceholderData: false, isStale: true, isSuccess: false, refetch: expect.any(Function), @@ -104,6 +109,7 @@ describe('useInfiniteQuery', () => { isInitialData: false, isLoading: false, isPreviousData: false, + isPlaceholderData: false, isStale: true, isSuccess: true, refetch: expect.any(Function), @@ -1067,7 +1073,7 @@ describe('useInfiniteQuery', () => { it('should compute canFetchMore correctly for falsy getFetchMore return value on refetching', async () => { const key = queryKey() const MAX = 2 - + function Page() { const fetchCountRef = React.useRef(0) const [isRemovedLastPage, setIsRemovedLastPage] = React.useState( @@ -1096,7 +1102,7 @@ describe('useInfiniteQuery', () => { getFetchMore: (lastGroup, _allGroups) => lastGroup.nextId, } ) - + return (

Pagination

@@ -1145,39 +1151,39 @@ describe('useInfiniteQuery', () => {
) } - + const rendered = render() - + rendered.getByText('Loading...') - + await waitFor(() => { rendered.getByText('Item: 9') rendered.getByText('Page 0: 0') }) - + fireEvent.click(rendered.getByText('Load More')) - + await waitFor(() => rendered.getByText('Loading more...')) - + await waitFor(() => { rendered.getByText('Item: 19') rendered.getByText('Page 0: 0') rendered.getByText('Page 1: 1') }) - + fireEvent.click(rendered.getByText('Load More')) - + await waitFor(() => rendered.getByText('Loading more...')) - + await waitFor(() => { rendered.getByText('Item: 29') rendered.getByText('Page 0: 0') rendered.getByText('Page 1: 1') rendered.getByText('Page 2: 2') }) - + rendered.getByText('Nothing more to load') - + fireEvent.click(rendered.getByText('Remove Last Page')) await waitForMs(10) @@ -1185,15 +1191,15 @@ describe('useInfiniteQuery', () => { fireEvent.click(rendered.getByText('Refetch')) await waitFor(() => rendered.getByText('Background Updating...')) - + await waitFor(() => { rendered.getByText('Page 0: 3') rendered.getByText('Page 1: 4') }) - + expect(rendered.queryByText('Item: 29')).toBeNull() expect(rendered.queryByText('Page 2: 5')).toBeNull() - + rendered.getByText('Nothing more to load') }) }) diff --git a/src/react/tests/usePaginatedQuery.test.tsx b/src/react/tests/usePaginatedQuery.test.tsx index 2e3f9e9072..a671ffb35a 100644 --- a/src/react/tests/usePaginatedQuery.test.tsx +++ b/src/react/tests/usePaginatedQuery.test.tsx @@ -44,6 +44,7 @@ describe('usePaginatedQuery', () => { isInitialData: true, isLoading: true, isPreviousData: false, + isPlaceholderData: false, isStale: true, isSuccess: false, latestData: undefined, @@ -70,6 +71,7 @@ describe('usePaginatedQuery', () => { isInitialData: false, isLoading: false, isPreviousData: false, + isPlaceholderData: false, isStale: true, isSuccess: true, latestData: 1, diff --git a/src/react/tests/useQuery.test.tsx b/src/react/tests/useQuery.test.tsx index 2083bdf717..c0944215e7 100644 --- a/src/react/tests/useQuery.test.tsx +++ b/src/react/tests/useQuery.test.tsx @@ -136,6 +136,7 @@ describe('useQuery', () => { isInitialData: true, isLoading: true, isPreviousData: false, + isPlaceholderData: false, isStale: true, isSuccess: false, refetch: expect.any(Function), @@ -160,6 +161,7 @@ describe('useQuery', () => { isInitialData: false, isLoading: false, isPreviousData: false, + isPlaceholderData: false, isStale: true, isSuccess: true, refetch: expect.any(Function), @@ -214,6 +216,7 @@ describe('useQuery', () => { isInitialData: true, isLoading: true, isPreviousData: false, + isPlaceholderData: false, isStale: true, isSuccess: false, refetch: expect.any(Function), @@ -238,6 +241,7 @@ describe('useQuery', () => { isInitialData: true, isLoading: true, isPreviousData: false, + isPlaceholderData: false, isStale: true, isSuccess: false, refetch: expect.any(Function), @@ -262,6 +266,7 @@ describe('useQuery', () => { isInitialData: true, isLoading: false, isPreviousData: false, + isPlaceholderData: false, isStale: true, isSuccess: false, refetch: expect.any(Function), @@ -2361,4 +2366,41 @@ describe('useQuery', () => { await waitFor(() => rendered.getByText('data')) expect(queryFn).toHaveBeenCalledTimes(1) }) + + it('should use placeholder data while the query loads', async () => { + const key1 = queryKey() + + const states: QueryResult[] = [] + + function Page() { + const state = useQuery(key1, () => 'data', { + placeholderData: 'placeholder', + }) + + states.push(state) + + return ( +
+

Data: {state.data}

+
Status: {state.status}
+
+ ) + } + + const rendered = render() + await waitFor(() => rendered.getByText('Data: data')) + + expect(states).toMatchObject([ + { + isSuccess: true, + isPlaceholderData: true, + data: 'placeholder', + }, + { + isSuccess: true, + isPlaceholderData: false, + data: 'data', + }, + ]) + }) })