Skip to content

Commit 9d9aea5

Browse files
authored
feat(react-query): suspense for useQueries (#4498)
* feat: suspense for useQueries * generics for fetchOptimistic * tests: useQueries suspense
1 parent 41e2af2 commit 9d9aea5

File tree

5 files changed

+219
-34
lines changed

5 files changed

+219
-34
lines changed

packages/query-core/src/queriesObserver.ts

Lines changed: 4 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -117,6 +117,10 @@ export class QueriesObserver extends Subscribable<QueriesObserverListener> {
117117
return this.observers.map((observer) => observer.getCurrentQuery())
118118
}
119119

120+
getObservers() {
121+
return this.observers
122+
}
123+
120124
getOptimisticResult(queries: QueryObserverOptions[]): QueryObserverResult[] {
121125
return this.findMatchingObservers(queries).map((match) =>
122126
match.observer.getOptimisticResult(match.defaultedQueryOptions),

packages/react-query/src/__tests__/suspense.test.tsx

Lines changed: 111 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -8,6 +8,7 @@ import {
88
QueryCache,
99
QueryErrorResetBoundary,
1010
useInfiniteQuery,
11+
useQueries,
1112
useQuery,
1213
useQueryErrorResetBoundary,
1314
} from '..'
@@ -1011,3 +1012,113 @@ describe("useQuery's in Suspense mode", () => {
10111012
expect(rendered.queryByText('rendered')).not.toBeNull()
10121013
})
10131014
})
1015+
1016+
describe('useQueries with suspense', () => {
1017+
const queryClient = createQueryClient()
1018+
it('should suspend all queries in parallel', async () => {
1019+
const key1 = queryKey()
1020+
const key2 = queryKey()
1021+
const results: string[] = []
1022+
1023+
function Fallback() {
1024+
results.push('loading')
1025+
return <div>loading</div>
1026+
}
1027+
1028+
function Page() {
1029+
const result = useQueries({
1030+
queries: [
1031+
{
1032+
queryKey: key1,
1033+
queryFn: async () => {
1034+
results.push('1')
1035+
await sleep(10)
1036+
return '1'
1037+
},
1038+
suspense: true,
1039+
},
1040+
{
1041+
queryKey: key2,
1042+
queryFn: async () => {
1043+
results.push('2')
1044+
await sleep(20)
1045+
return '2'
1046+
},
1047+
suspense: true,
1048+
},
1049+
],
1050+
})
1051+
return (
1052+
<div>
1053+
<h1>data: {result.map((it) => it.data ?? 'null').join(',')}</h1>
1054+
</div>
1055+
)
1056+
}
1057+
1058+
const rendered = renderWithClient(
1059+
queryClient,
1060+
<React.Suspense fallback={<Fallback />}>
1061+
<Page />
1062+
</React.Suspense>,
1063+
)
1064+
await waitFor(() => rendered.getByText('loading'))
1065+
await waitFor(() => rendered.getByText('data: 1,2'))
1066+
1067+
expect(results).toEqual(['1', '2', 'loading'])
1068+
})
1069+
1070+
it('should allow to mix suspense with non-suspense', async () => {
1071+
const key1 = queryKey()
1072+
const key2 = queryKey()
1073+
const results: string[] = []
1074+
1075+
function Fallback() {
1076+
results.push('loading')
1077+
return <div>loading</div>
1078+
}
1079+
1080+
function Page() {
1081+
const result = useQueries({
1082+
queries: [
1083+
{
1084+
queryKey: key1,
1085+
queryFn: async () => {
1086+
results.push('1')
1087+
await sleep(10)
1088+
return '1'
1089+
},
1090+
suspense: true,
1091+
},
1092+
{
1093+
queryKey: key2,
1094+
queryFn: async () => {
1095+
results.push('2')
1096+
await sleep(20)
1097+
return '2'
1098+
},
1099+
suspense: false,
1100+
},
1101+
],
1102+
})
1103+
return (
1104+
<div>
1105+
<h1>data: {result.map((it) => it.data ?? 'null').join(',')}</h1>
1106+
<h2>status: {result.map((it) => it.status).join(',')}</h2>
1107+
</div>
1108+
)
1109+
}
1110+
1111+
const rendered = renderWithClient(
1112+
queryClient,
1113+
<React.Suspense fallback={<Fallback />}>
1114+
<Page />
1115+
</React.Suspense>,
1116+
)
1117+
await waitFor(() => rendered.getByText('loading'))
1118+
await waitFor(() => rendered.getByText('status: success,loading'))
1119+
await waitFor(() => rendered.getByText('data: 1,null'))
1120+
await waitFor(() => rendered.getByText('data: 1,2'))
1121+
1122+
expect(results).toEqual(['1', '2', 'loading'])
1123+
})
1124+
})
Lines changed: 59 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,59 @@
1+
import type { DefaultedQueryObserverOptions } from '@tanstack/query-core'
2+
import type { QueryObserver } from '@tanstack/query-core'
3+
import type { QueryErrorResetBoundaryValue } from './QueryErrorResetBoundary'
4+
import type { QueryObserverResult } from '@tanstack/query-core'
5+
import type { QueryKey } from '@tanstack/query-core'
6+
7+
export const ensureStaleTime = (
8+
defaultedOptions: DefaultedQueryObserverOptions<any, any, any, any, any>,
9+
) => {
10+
if (defaultedOptions.suspense) {
11+
// Always set stale time when using suspense to prevent
12+
// fetching again when directly mounting after suspending
13+
if (typeof defaultedOptions.staleTime !== 'number') {
14+
defaultedOptions.staleTime = 1000
15+
}
16+
}
17+
}
18+
19+
export const willFetch = (
20+
result: QueryObserverResult<any, any>,
21+
isRestoring: boolean,
22+
) => result.isLoading && result.isFetching && !isRestoring
23+
24+
export const shouldSuspend = (
25+
defaultedOptions:
26+
| DefaultedQueryObserverOptions<any, any, any, any, any>
27+
| undefined,
28+
result: QueryObserverResult<any, any>,
29+
isRestoring: boolean,
30+
) => defaultedOptions?.suspense && willFetch(result, isRestoring)
31+
32+
export const fetchOptimistic = <
33+
TQueryFnData,
34+
TError,
35+
TData,
36+
TQueryData,
37+
TQueryKey extends QueryKey,
38+
>(
39+
defaultedOptions: DefaultedQueryObserverOptions<
40+
TQueryFnData,
41+
TError,
42+
TData,
43+
TQueryData,
44+
TQueryKey
45+
>,
46+
observer: QueryObserver<TQueryFnData, TError, TData, TQueryData, TQueryKey>,
47+
errorResetBoundary: QueryErrorResetBoundaryValue,
48+
) =>
49+
observer
50+
.fetchOptimistic(defaultedOptions)
51+
.then(({ data }) => {
52+
defaultedOptions.onSuccess?.(data as TData)
53+
defaultedOptions.onSettled?.(data, null)
54+
})
55+
.catch((error) => {
56+
errorResetBoundary.clearReset()
57+
defaultedOptions.onError?.(error)
58+
defaultedOptions.onSettled?.(undefined, error)
59+
})

packages/react-query/src/useBaseQuery.ts

Lines changed: 4 additions & 25 deletions
Original file line numberDiff line numberDiff line change
@@ -12,6 +12,7 @@ import {
1212
getHasError,
1313
useClearResetErrorBoundary,
1414
} from './errorBoundaryUtils'
15+
import { ensureStaleTime, shouldSuspend, fetchOptimistic } from './suspense'
1516

1617
export function useBaseQuery<
1718
TQueryFnData,
@@ -58,14 +59,7 @@ export function useBaseQuery<
5859
)
5960
}
6061

61-
if (defaultedOptions.suspense) {
62-
// Always set stale time when using suspense to prevent
63-
// fetching again when directly mounting after suspending
64-
if (typeof defaultedOptions.staleTime !== 'number') {
65-
defaultedOptions.staleTime = 1000
66-
}
67-
}
68-
62+
ensureStaleTime(defaultedOptions)
6963
ensurePreventErrorBoundaryRetry(defaultedOptions, errorResetBoundary)
7064

7165
useClearResetErrorBoundary(errorResetBoundary)
@@ -99,23 +93,8 @@ export function useBaseQuery<
9993
}, [defaultedOptions, observer])
10094

10195
// Handle suspense
102-
if (
103-
defaultedOptions.suspense &&
104-
result.isLoading &&
105-
result.isFetching &&
106-
!isRestoring
107-
) {
108-
throw observer
109-
.fetchOptimistic(defaultedOptions)
110-
.then(({ data }) => {
111-
defaultedOptions.onSuccess?.(data as TData)
112-
defaultedOptions.onSettled?.(data, null)
113-
})
114-
.catch((error) => {
115-
errorResetBoundary.clearReset()
116-
defaultedOptions.onError?.(error)
117-
defaultedOptions.onSettled?.(undefined, error)
118-
})
96+
if (shouldSuspend(defaultedOptions, result, isRestoring)) {
97+
throw fetchOptimistic(defaultedOptions, observer, errorResetBoundary)
11998
}
12099

121100
// Handle error boundary

packages/react-query/src/useQueries.ts

Lines changed: 41 additions & 9 deletions
Original file line numberDiff line numberDiff line change
@@ -12,6 +12,12 @@ import {
1212
getHasError,
1313
useClearResetErrorBoundary,
1414
} from './errorBoundaryUtils'
15+
import {
16+
ensureStaleTime,
17+
shouldSuspend,
18+
fetchOptimistic,
19+
willFetch,
20+
} from './suspense'
1521

1622
// This defines the `UseQueryOptions` that are accepted in `QueriesOptions` & `GetOptions`.
1723
// - `context` is omitted as it is passed as a root-level option to `useQueries` instead.
@@ -170,7 +176,7 @@ export function useQueries<T extends any[]>({
170176
() => new QueriesObserver(queryClient, defaultedQueries),
171177
)
172178

173-
const result = observer.getOptimisticResult(defaultedQueries)
179+
const optimisticResult = observer.getOptimisticResult(defaultedQueries)
174180

175181
useSyncExternalStore(
176182
React.useCallback(
@@ -194,22 +200,48 @@ export function useQueries<T extends any[]>({
194200

195201
defaultedQueries.forEach((query) => {
196202
ensurePreventErrorBoundaryRetry(query, errorResetBoundary)
203+
ensureStaleTime(query)
197204
})
198205

199206
useClearResetErrorBoundary(errorResetBoundary)
200207

201-
const firstSingleResultWhichShouldThrow = result.find((singleResult, index) =>
202-
getHasError({
203-
result: singleResult,
204-
errorResetBoundary,
205-
useErrorBoundary: defaultedQueries[index]?.useErrorBoundary ?? false,
206-
query: observer.getQueries()[index]!,
207-
}),
208+
const shouldAtLeastOneSuspend = optimisticResult.some((result, index) =>
209+
shouldSuspend(defaultedQueries[index], result, isRestoring),
210+
)
211+
212+
const suspensePromises = shouldAtLeastOneSuspend
213+
? optimisticResult.flatMap((result, index) => {
214+
const options = defaultedQueries[index]
215+
const queryObserver = observer.getObservers()[index]
216+
217+
if (options && queryObserver) {
218+
if (shouldSuspend(options, result, isRestoring)) {
219+
return fetchOptimistic(options, queryObserver, errorResetBoundary)
220+
} else if (willFetch(result, isRestoring)) {
221+
void fetchOptimistic(options, queryObserver, errorResetBoundary)
222+
}
223+
}
224+
return []
225+
})
226+
: []
227+
228+
if (suspensePromises.length > 0) {
229+
throw Promise.all(suspensePromises)
230+
}
231+
232+
const firstSingleResultWhichShouldThrow = optimisticResult.find(
233+
(result, index) =>
234+
getHasError({
235+
result,
236+
errorResetBoundary,
237+
useErrorBoundary: defaultedQueries[index]?.useErrorBoundary ?? false,
238+
query: observer.getQueries()[index]!,
239+
}),
208240
)
209241

210242
if (firstSingleResultWhichShouldThrow?.error) {
211243
throw firstSingleResultWhichShouldThrow.error
212244
}
213245

214-
return result as QueriesResults<T>
246+
return optimisticResult as QueriesResults<T>
215247
}

0 commit comments

Comments
 (0)