From d136de40a5c493be7169c3c9c7242a00641e7bdd Mon Sep 17 00:00:00 2001 From: Tanner Linsley Date: Sun, 11 Oct 2020 17:05:35 -0600 Subject: [PATCH 1/4] feat: add placeholderData to queryObserver --- src/core/queryObserver.ts | 20 +++++++++++++++++++- src/core/types.ts | 2 ++ 2 files changed, 21 insertions(+), 1 deletion(-) diff --git a/src/core/queryObserver.ts b/src/core/queryObserver.ts index 775111521b..2eba71efd5 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' @@ -256,6 +262,18 @@ 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 as TResult) + + if (typeof placeholderData !== 'undefined') { + status = QueryStatus.Success + data = placeholderData + } + } + this.currentResult = { ...getStatusProps(status), canFetchMore: state.canFetchMore, diff --git a/src/core/types.ts b/src/core/types.ts index af7c9879cf..986d97c712 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 @@ -174,6 +175,7 @@ export interface ResolvedQueryConfig queryKey: ArrayQueryKey queryKeySerializerFn: QueryKeySerializerFunction staleTime: number + placeholderData?: TResult | PlaceholderDataFunction | unknown } export type IsFetchingMoreValue = 'previous' | 'next' | false From 167385bc112fd6dc2d89bdc39ed507537dc6da60 Mon Sep 17 00:00:00 2001 From: Tanner Linsley Date: Mon, 12 Oct 2020 09:54:03 -0600 Subject: [PATCH 2/4] Add docs and a few tests (one failing) --- docs/src/pages/docs/api.md | 7 +++++++ src/core/tests/queryCache.test.tsx | 29 +++++++++++++++++++++++++++++ src/core/types.ts | 2 +- src/react/tests/useQuery.test.tsx | 25 +++++++++++++++++++++++++ 4 files changed, 62 insertions(+), 1 deletion(-) diff --git a/docs/src/pages/docs/api.md b/docs/src/pages/docs/api.md index 72844fa922..7b7735dff7 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` 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 986d97c712..4ede909f07 100644 --- a/src/core/types.ts +++ b/src/core/types.ts @@ -50,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. @@ -175,7 +176,6 @@ export interface ResolvedQueryConfig queryKey: ArrayQueryKey queryKeySerializerFn: QueryKeySerializerFunction staleTime: number - placeholderData?: TResult | PlaceholderDataFunction | unknown } export type IsFetchingMoreValue = 'previous' | 'next' | false diff --git a/src/react/tests/useQuery.test.tsx b/src/react/tests/useQuery.test.tsx index 2083bdf717..35305a2ea6 100644 --- a/src/react/tests/useQuery.test.tsx +++ b/src/react/tests/useQuery.test.tsx @@ -2361,4 +2361,29 @@ describe('useQuery', () => { await waitFor(() => rendered.getByText('data')) expect(queryFn).toHaveBeenCalledTimes(1) }) + + it('should use placeholder data while the query loads', async () => { + const key1 = queryKey() + + function Page() { + const first = useQuery(key1, () => 'data', { + placeholderData: 'placeholder', + }) + + return ( +
+

Data: {first.data}

+
Status: {first.status}
+
+ ) + } + + const rendered = render() + + rendered.getByText('Data: placeholder') + rendered.getByText('Data: data') + + await waitFor(() => rendered.getByText('Data: data')) + rendered.getByText('Status: success') + }) }) From 30f66eff9cb59b23b7d6e7ffbed46c103f4d59d6 Mon Sep 17 00:00:00 2001 From: Tanner Linsley Date: Tue, 13 Oct 2020 08:59:15 -0600 Subject: [PATCH 3/4] Add isPlaceholderData --- src/core/queryObserver.ts | 5 ++- src/core/types.ts | 1 + src/react/tests/useInfiniteQuery.test.tsx | 40 +++++++++++++--------- src/react/tests/usePaginatedQuery.test.tsx | 2 ++ src/react/tests/useQuery.test.tsx | 33 +++++++++++++----- 5 files changed, 55 insertions(+), 26 deletions(-) diff --git a/src/core/queryObserver.ts b/src/core/queryObserver.ts index 2eba71efd5..a33bdb5937 100644 --- a/src/core/queryObserver.ts +++ b/src/core/queryObserver.ts @@ -249,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 ( @@ -266,11 +267,12 @@ export class QueryObserver { const placeholderData = typeof this.config.placeholderData === 'function' ? (this.config.placeholderData as PlaceholderDataFunction)() - : (this.config.placeholderData as TResult) + : this.config.placeholderData if (typeof placeholderData !== 'undefined') { status = QueryStatus.Success data = placeholderData + isPlaceholderData = true } } @@ -288,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/types.ts b/src/core/types.ts index 4ede909f07..296d405f23 100644 --- a/src/core/types.ts +++ b/src/core/types.ts @@ -206,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 35305a2ea6..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), @@ -2365,25 +2370,37 @@ describe('useQuery', () => { it('should use placeholder data while the query loads', async () => { const key1 = queryKey() + const states: QueryResult[] = [] + function Page() { - const first = useQuery(key1, () => 'data', { + const state = useQuery(key1, () => 'data', { placeholderData: 'placeholder', }) + states.push(state) + return (
-

Data: {first.data}

-
Status: {first.status}
+

Data: {state.data}

+
Status: {state.status}
) } const rendered = render() - - rendered.getByText('Data: placeholder') - rendered.getByText('Data: data') - await waitFor(() => rendered.getByText('Data: data')) - rendered.getByText('Status: success') + + expect(states).toMatchObject([ + { + isSuccess: true, + isPlaceholderData: true, + data: 'placeholder', + }, + { + isSuccess: true, + isPlaceholderData: false, + data: 'data', + }, + ]) }) }) From c927c4d89c42a678d57ac5138a10c146af6846af Mon Sep 17 00:00:00 2001 From: Tanner Linsley Date: Tue, 13 Oct 2020 10:34:29 -0600 Subject: [PATCH 4/4] Update api.md --- docs/src/pages/docs/api.md | 2 ++ 1 file changed, 2 insertions(+) diff --git a/docs/src/pages/docs/api.md b/docs/src/pages/docs/api.md index 7b7735dff7..9fac836127 100644 --- a/docs/src/pages/docs/api.md +++ b/docs/src/pages/docs/api.md @@ -183,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.