From 0d5cbbadbc5818d199aaadab6d8f63bff981ca5b Mon Sep 17 00:00:00 2001 From: Dominik Dorfmeister Date: Fri, 11 Nov 2022 23:13:57 +0100 Subject: [PATCH 1/3] feat: suspense for useQueries --- packages/query-core/src/queriesObserver.ts | 4 ++ packages/react-query/src/suspense.ts | 46 ++++++++++++++++++++ packages/react-query/src/useBaseQuery.ts | 29 ++----------- packages/react-query/src/useQueries.ts | 50 ++++++++++++++++++---- 4 files changed, 95 insertions(+), 34 deletions(-) create mode 100644 packages/react-query/src/suspense.ts diff --git a/packages/query-core/src/queriesObserver.ts b/packages/query-core/src/queriesObserver.ts index 1e0062a07c..c9fbc5e164 100644 --- a/packages/query-core/src/queriesObserver.ts +++ b/packages/query-core/src/queriesObserver.ts @@ -117,6 +117,10 @@ export class QueriesObserver extends Subscribable { return this.observers.map((observer) => observer.getCurrentQuery()) } + getObservers() { + return this.observers + } + getOptimisticResult(queries: QueryObserverOptions[]): QueryObserverResult[] { return this.findMatchingObservers(queries).map((match) => match.observer.getOptimisticResult(match.defaultedQueryOptions), diff --git a/packages/react-query/src/suspense.ts b/packages/react-query/src/suspense.ts new file mode 100644 index 0000000000..fea15f157d --- /dev/null +++ b/packages/react-query/src/suspense.ts @@ -0,0 +1,46 @@ +import type { DefaultedQueryObserverOptions } from '@tanstack/query-core' +import type { QueryObserver } from '@tanstack/query-core' +import type { QueryErrorResetBoundaryValue } from './QueryErrorResetBoundary' +import type { QueryObserverResult } from '@tanstack/query-core' + +export const ensureStaleTime = ( + defaultedOptions: DefaultedQueryObserverOptions, +) => { + if (defaultedOptions.suspense) { + // Always set stale time when using suspense to prevent + // fetching again when directly mounting after suspending + if (typeof defaultedOptions.staleTime !== 'number') { + defaultedOptions.staleTime = 1000 + } + } +} + +export const willFetch = ( + result: QueryObserverResult, + isRestoring: boolean, +) => result.isLoading && result.isFetching && !isRestoring + +export const shouldSuspend = ( + defaultedOptions: + | DefaultedQueryObserverOptions + | undefined, + result: QueryObserverResult, + isRestoring: boolean, +) => defaultedOptions?.suspense && willFetch(result, isRestoring) + +export const fetchOptimistic = ( + defaultedOptions: DefaultedQueryObserverOptions, + observer: QueryObserver, + errorResetBoundary: QueryErrorResetBoundaryValue, +) => + observer + .fetchOptimistic(defaultedOptions) + .then(({ data }) => { + defaultedOptions.onSuccess?.(data as TData) + defaultedOptions.onSettled?.(data, null) + }) + .catch((error) => { + errorResetBoundary.clearReset() + defaultedOptions.onError?.(error) + defaultedOptions.onSettled?.(undefined, error) + }) diff --git a/packages/react-query/src/useBaseQuery.ts b/packages/react-query/src/useBaseQuery.ts index 607ee1f74a..df537ab745 100644 --- a/packages/react-query/src/useBaseQuery.ts +++ b/packages/react-query/src/useBaseQuery.ts @@ -12,6 +12,7 @@ import { getHasError, useClearResetErrorBoundary, } from './errorBoundaryUtils' +import { ensureStaleTime, shouldSuspend, fetchOptimistic } from './suspense' export function useBaseQuery< TQueryFnData, @@ -58,14 +59,7 @@ export function useBaseQuery< ) } - if (defaultedOptions.suspense) { - // Always set stale time when using suspense to prevent - // fetching again when directly mounting after suspending - if (typeof defaultedOptions.staleTime !== 'number') { - defaultedOptions.staleTime = 1000 - } - } - + ensureStaleTime(defaultedOptions) ensurePreventErrorBoundaryRetry(defaultedOptions, errorResetBoundary) useClearResetErrorBoundary(errorResetBoundary) @@ -99,23 +93,8 @@ export function useBaseQuery< }, [defaultedOptions, observer]) // Handle suspense - if ( - defaultedOptions.suspense && - result.isLoading && - result.isFetching && - !isRestoring - ) { - throw observer - .fetchOptimistic(defaultedOptions) - .then(({ data }) => { - defaultedOptions.onSuccess?.(data as TData) - defaultedOptions.onSettled?.(data, null) - }) - .catch((error) => { - errorResetBoundary.clearReset() - defaultedOptions.onError?.(error) - defaultedOptions.onSettled?.(undefined, error) - }) + if (shouldSuspend(defaultedOptions, result, isRestoring)) { + throw fetchOptimistic(defaultedOptions, observer, errorResetBoundary) } // Handle error boundary diff --git a/packages/react-query/src/useQueries.ts b/packages/react-query/src/useQueries.ts index ab9a98ab1f..0682d30f1e 100644 --- a/packages/react-query/src/useQueries.ts +++ b/packages/react-query/src/useQueries.ts @@ -12,6 +12,12 @@ import { getHasError, useClearResetErrorBoundary, } from './errorBoundaryUtils' +import { + ensureStaleTime, + shouldSuspend, + fetchOptimistic, + willFetch, +} from './suspense' // This defines the `UseQueryOptions` that are accepted in `QueriesOptions` & `GetOptions`. // - `context` is omitted as it is passed as a root-level option to `useQueries` instead. @@ -170,7 +176,7 @@ export function useQueries({ () => new QueriesObserver(queryClient, defaultedQueries), ) - const result = observer.getOptimisticResult(defaultedQueries) + const optimisticResult = observer.getOptimisticResult(defaultedQueries) useSyncExternalStore( React.useCallback( @@ -194,22 +200,48 @@ export function useQueries({ defaultedQueries.forEach((query) => { ensurePreventErrorBoundaryRetry(query, errorResetBoundary) + ensureStaleTime(query) }) useClearResetErrorBoundary(errorResetBoundary) - const firstSingleResultWhichShouldThrow = result.find((singleResult, index) => - getHasError({ - result: singleResult, - errorResetBoundary, - useErrorBoundary: defaultedQueries[index]?.useErrorBoundary ?? false, - query: observer.getQueries()[index]!, - }), + const shouldAtLeastOneSuspend = optimisticResult.some((result, index) => + shouldSuspend(defaultedQueries[index], result, isRestoring), + ) + + const suspensePromises = shouldAtLeastOneSuspend + ? optimisticResult.flatMap((result, index) => { + const options = defaultedQueries[index] + const queryObserver = observer.getObservers()[index] + + if (options && queryObserver) { + if (shouldSuspend(options, result, isRestoring)) { + return fetchOptimistic(options, queryObserver, errorResetBoundary) + } else if (willFetch(result, isRestoring)) { + void fetchOptimistic(options, queryObserver, errorResetBoundary) + } + } + return [] + }) + : [] + + if (suspensePromises.length > 0) { + throw Promise.all(suspensePromises) + } + + const firstSingleResultWhichShouldThrow = optimisticResult.find( + (result, index) => + getHasError({ + result, + errorResetBoundary, + useErrorBoundary: defaultedQueries[index]?.useErrorBoundary ?? false, + query: observer.getQueries()[index]!, + }), ) if (firstSingleResultWhichShouldThrow?.error) { throw firstSingleResultWhichShouldThrow.error } - return result as QueriesResults + return optimisticResult as QueriesResults } From acab5d6c674855b4b6d9354ece61b8bcbbbbcabd Mon Sep 17 00:00:00 2001 From: Dominik Dorfmeister Date: Fri, 11 Nov 2022 23:22:54 +0100 Subject: [PATCH 2/3] generics for fetchOptimistic --- packages/react-query/src/suspense.ts | 19 ++++++++++++++++--- 1 file changed, 16 insertions(+), 3 deletions(-) diff --git a/packages/react-query/src/suspense.ts b/packages/react-query/src/suspense.ts index fea15f157d..682409e75d 100644 --- a/packages/react-query/src/suspense.ts +++ b/packages/react-query/src/suspense.ts @@ -2,6 +2,7 @@ import type { DefaultedQueryObserverOptions } from '@tanstack/query-core' import type { QueryObserver } from '@tanstack/query-core' import type { QueryErrorResetBoundaryValue } from './QueryErrorResetBoundary' import type { QueryObserverResult } from '@tanstack/query-core' +import type { QueryKey } from '@tanstack/query-core' export const ensureStaleTime = ( defaultedOptions: DefaultedQueryObserverOptions, @@ -28,9 +29,21 @@ export const shouldSuspend = ( isRestoring: boolean, ) => defaultedOptions?.suspense && willFetch(result, isRestoring) -export const fetchOptimistic = ( - defaultedOptions: DefaultedQueryObserverOptions, - observer: QueryObserver, +export const fetchOptimistic = < + TQueryFnData, + TError, + TData, + TQueryData, + TQueryKey extends QueryKey, +>( + defaultedOptions: DefaultedQueryObserverOptions< + TQueryFnData, + TError, + TData, + TQueryData, + TQueryKey + >, + observer: QueryObserver, errorResetBoundary: QueryErrorResetBoundaryValue, ) => observer From e133b0d55968f1c11cce0e402cabe0353b938eb9 Mon Sep 17 00:00:00 2001 From: Dominik Dorfmeister Date: Sat, 12 Nov 2022 07:36:34 +0100 Subject: [PATCH 3/3] tests: useQueries suspense --- .../src/__tests__/suspense.test.tsx | 111 ++++++++++++++++++ 1 file changed, 111 insertions(+) diff --git a/packages/react-query/src/__tests__/suspense.test.tsx b/packages/react-query/src/__tests__/suspense.test.tsx index 220a11b179..3b27c6c90d 100644 --- a/packages/react-query/src/__tests__/suspense.test.tsx +++ b/packages/react-query/src/__tests__/suspense.test.tsx @@ -8,6 +8,7 @@ import { QueryCache, QueryErrorResetBoundary, useInfiniteQuery, + useQueries, useQuery, useQueryErrorResetBoundary, } from '..' @@ -1011,3 +1012,113 @@ describe("useQuery's in Suspense mode", () => { expect(rendered.queryByText('rendered')).not.toBeNull() }) }) + +describe('useQueries with suspense', () => { + const queryClient = createQueryClient() + it('should suspend all queries in parallel', async () => { + const key1 = queryKey() + const key2 = queryKey() + const results: string[] = [] + + function Fallback() { + results.push('loading') + return
loading
+ } + + function Page() { + const result = useQueries({ + queries: [ + { + queryKey: key1, + queryFn: async () => { + results.push('1') + await sleep(10) + return '1' + }, + suspense: true, + }, + { + queryKey: key2, + queryFn: async () => { + results.push('2') + await sleep(20) + return '2' + }, + suspense: true, + }, + ], + }) + return ( +
+

data: {result.map((it) => it.data ?? 'null').join(',')}

+
+ ) + } + + const rendered = renderWithClient( + queryClient, + }> + + , + ) + await waitFor(() => rendered.getByText('loading')) + await waitFor(() => rendered.getByText('data: 1,2')) + + expect(results).toEqual(['1', '2', 'loading']) + }) + + it('should allow to mix suspense with non-suspense', async () => { + const key1 = queryKey() + const key2 = queryKey() + const results: string[] = [] + + function Fallback() { + results.push('loading') + return
loading
+ } + + function Page() { + const result = useQueries({ + queries: [ + { + queryKey: key1, + queryFn: async () => { + results.push('1') + await sleep(10) + return '1' + }, + suspense: true, + }, + { + queryKey: key2, + queryFn: async () => { + results.push('2') + await sleep(20) + return '2' + }, + suspense: false, + }, + ], + }) + return ( +
+

data: {result.map((it) => it.data ?? 'null').join(',')}

+

status: {result.map((it) => it.status).join(',')}

+
+ ) + } + + const rendered = renderWithClient( + queryClient, + }> + + , + ) + await waitFor(() => rendered.getByText('loading')) + await waitFor(() => rendered.getByText('status: success,loading')) + await waitFor(() => rendered.getByText('data: 1,null')) + await waitFor(() => rendered.getByText('data: 1,2')) + + expect(results).toEqual(['1', '2', 'loading']) + }) +})