From fe40478ebd7a036686fb0ffec23b91f822f10f76 Mon Sep 17 00:00:00 2001 From: TkDodo Date: Sat, 25 Apr 2026 12:07:15 +0200 Subject: [PATCH 1/5] useSuspenseQueries combine --- .../src/__tests__/useSuspenseQueries.test.tsx | 66 +++++++++++++++++++ packages/react-query/src/useQueries.ts | 22 +++++-- .../react-query/src/useSuspenseQueries.ts | 1 + 3 files changed, 84 insertions(+), 5 deletions(-) diff --git a/packages/react-query/src/__tests__/useSuspenseQueries.test.tsx b/packages/react-query/src/__tests__/useSuspenseQueries.test.tsx index d8838aee0da..b489135b195 100644 --- a/packages/react-query/src/__tests__/useSuspenseQueries.test.tsx +++ b/packages/react-query/src/__tests__/useSuspenseQueries.test.tsx @@ -274,6 +274,72 @@ describe('useSuspenseQueries', () => { expect(spy).toHaveBeenCalled() }) + it('should not call combine while reset queries are pending again', async () => { + const consoleMock = vi + .spyOn(console, 'error') + .mockImplementation(() => undefined) + const key = queryKey() + let shouldError = false + + function Page() { + const data = useSuspenseQueries({ + queries: [ + { + queryKey: key, + queryFn: () => + sleep(10).then(() => { + if (shouldError) { + throw new Error('Suspense Error Bingo') + } + + return 'data' + }), + retry: false, + }, + ], + combine: (result) => result.map((query) => query.data.toUpperCase()), + }) + + return ( +
+ +
data: {data.join(',')}
+
+ ) + } + + const rendered = renderWithClient( + queryClient, +
error boundary
}> + + + +
, + ) + + expect(rendered.getByText('loading')).toBeInTheDocument() + + await act(() => vi.advanceTimersByTimeAsync(10)) + expect(rendered.getByText('data: DATA')).toBeInTheDocument() + + shouldError = true + + expect(() => { + fireEvent.click(rendered.getByText('reset')) + }).not.toThrow() + + await act(() => vi.advanceTimersByTimeAsync(10)) + expect(rendered.getByText('error boundary')).toBeInTheDocument() + + expect(consoleMock.mock.calls[0]?.[1]).toStrictEqual( + new Error('Suspense Error Bingo'), + ) + + consoleMock.mockRestore() + }) + it('should handle duplicate query keys without infinite loops', async () => { const key = queryKey() const localDuration = 10 diff --git a/packages/react-query/src/useQueries.ts b/packages/react-query/src/useQueries.ts index de179837a5f..d496aeb7313 100644 --- a/packages/react-query/src/useQueries.ts +++ b/packages/react-query/src/useQueries.ts @@ -210,6 +210,7 @@ export function useQueries< >( { queries, + _combineOnlyOnSuccess = false, ...options }: { queries: @@ -217,12 +218,17 @@ export function useQueries< | readonly [...{ [K in keyof T]: GetUseQueryOptionsForUseQueries }] combine?: (result: QueriesResults) => TCombinedResult subscribed?: boolean + _combineOnlyOnSuccess?: boolean }, queryClient?: QueryClient, ): TCombinedResult { const client = useQueryClient(queryClient) const isRestoring = useIsRestoring() const errorResetBoundary = useQueryErrorResetBoundary() + const combine = options.combine + const combineForObserver = _combineOnlyOnSuccess + ? undefined + : (combine as QueriesObserverOptions['combine']) const defaultedQueries = React.useMemo( () => @@ -254,7 +260,7 @@ export function useQueries< new QueriesObserver( client, defaultedQueries, - options as QueriesObserverOptions, + combineForObserver ? { combine: combineForObserver } : undefined, ), ) @@ -262,7 +268,7 @@ export function useQueries< const [optimisticResult, getCombinedResult, trackResult] = observer.getOptimisticResult( defaultedQueries, - (options as QueriesObserverOptions).combine, + combineForObserver, ) const shouldSubscribe = !isRestoring && options.subscribed !== false @@ -281,9 +287,9 @@ export function useQueries< React.useEffect(() => { observer.setQueries( defaultedQueries, - options as QueriesObserverOptions, + combineForObserver ? { combine: combineForObserver } : undefined, ) - }, [defaultedQueries, options, observer]) + }, [combineForObserver, defaultedQueries, observer]) const shouldAtLeastOneSuspend = optimisticResult.some((result, index) => shouldSuspend(defaultedQueries[index], result), @@ -324,5 +330,11 @@ export function useQueries< throw firstSingleResultWhichShouldThrow.error } - return getCombinedResult(trackResult()) + const trackedResult = trackResult() + + if (combine && _combineOnlyOnSuccess) { + return combine(trackedResult as unknown as QueriesResults) + } + + return getCombinedResult(trackedResult) } diff --git a/packages/react-query/src/useSuspenseQueries.ts b/packages/react-query/src/useSuspenseQueries.ts index f014095d01c..9fdfefdd6cd 100644 --- a/packages/react-query/src/useSuspenseQueries.ts +++ b/packages/react-query/src/useSuspenseQueries.ts @@ -190,6 +190,7 @@ export function useSuspenseQueries(options: any, queryClient?: QueryClient) { return useQueries( { ...options, + _combineOnlyOnSuccess: true, queries: options.queries.map((query: any) => { if (process.env.NODE_ENV !== 'production') { if (query.queryFn === skipToken) { From f0fbf591b20bbc35053574f41eb26815f8c0bd20 Mon Sep 17 00:00:00 2001 From: TkDodo Date: Sat, 25 Apr 2026 12:14:29 +0200 Subject: [PATCH 2/5] useSuspenseQueries combine --- .changeset/wild-rabbits-jump.md | 6 +++ .../src/__tests__/queriesObserver.test.tsx | 37 +++++++++++++++++++ packages/query-core/src/queriesObserver.ts | 21 +++++++++-- packages/react-query/src/useQueries.ts | 22 +++-------- .../react-query/src/useSuspenseQueries.ts | 1 - 5 files changed, 66 insertions(+), 21 deletions(-) create mode 100644 .changeset/wild-rabbits-jump.md diff --git a/.changeset/wild-rabbits-jump.md b/.changeset/wild-rabbits-jump.md new file mode 100644 index 00000000000..1d6b19c286b --- /dev/null +++ b/.changeset/wild-rabbits-jump.md @@ -0,0 +1,6 @@ +--- +'@tanstack/react-query': patch +'@tanstack/query-core': patch +--- + +fix(suspense): skip calling combine when queries would suspend diff --git a/packages/query-core/src/__tests__/queriesObserver.test.tsx b/packages/query-core/src/__tests__/queriesObserver.test.tsx index 504f5b8379a..e392eccb167 100644 --- a/packages/query-core/src/__tests__/queriesObserver.test.tsx +++ b/packages/query-core/src/__tests__/queriesObserver.test.tsx @@ -473,6 +473,43 @@ describe('queriesObserver', () => { expect(newCombined.count).toBe(2) }) + it('should skip combine notifications while suspense queries have no data', async () => { + const key = queryKey() + const combine = vi.fn((results: Array) => + results.map((result) => result.data), + ) + const queries = [ + { + queryKey: key, + queryFn: () => sleep(10).then(() => 'data'), + staleTime: Infinity, + suspense: true, + }, + ] + + queryClient.setQueryData(key, 'data') + + const observer = new QueriesObserver>( + queryClient, + queries, + { combine }, + ) + + const [rawResult, getCombinedResult] = observer.getOptimisticResult( + queries, + combine, + ) + expect(getCombinedResult(rawResult)).toEqual(['data']) + expect(combine).toHaveBeenCalledTimes(1) + + const unsubscribe = observer.subscribe(() => undefined) + + void queryClient.resetQueries({ queryKey: key }) + expect(combine).toHaveBeenCalledTimes(1) + + unsubscribe() + }) + it('should handle queries being removed with stable combine reference', () => { const combine = vi.fn((results: Array) => ({ count: results.length, diff --git a/packages/query-core/src/queriesObserver.ts b/packages/query-core/src/queriesObserver.ts index 67dd088f9ae..d8197c20ecf 100644 --- a/packages/query-core/src/queriesObserver.ts +++ b/packages/query-core/src/queriesObserver.ts @@ -249,6 +249,18 @@ export class QueriesObserver< return input as any } + #shouldSkipCombine(): boolean { + return ( + this.#options?.combine !== undefined && + this.#observerMatches.some((match, index) => { + return ( + match.defaultedQueryOptions.suspense && + this.#result[index]?.data === undefined + ) + }) + ) + } + #findMatchingObservers( queries: Array, ): Array { @@ -294,11 +306,14 @@ export class QueriesObserver< #notify(): void { if (this.hasListeners()) { - const previousResult = this.#combinedResult const newTracked = this.#trackResult(this.#result, this.#observerMatches) - const newResult = this.#combineResult(newTracked, this.#options?.combine) + const shouldSkipCombine = this.#shouldSkipCombine() + const previousResult = this.#combinedResult + const newResult = shouldSkipCombine + ? previousResult + : this.#combineResult(newTracked, this.#options?.combine) - if (previousResult !== newResult) { + if (shouldSkipCombine || previousResult !== newResult) { notifyManager.batch(() => { this.listeners.forEach((listener) => { listener(this.#result) diff --git a/packages/react-query/src/useQueries.ts b/packages/react-query/src/useQueries.ts index d496aeb7313..de179837a5f 100644 --- a/packages/react-query/src/useQueries.ts +++ b/packages/react-query/src/useQueries.ts @@ -210,7 +210,6 @@ export function useQueries< >( { queries, - _combineOnlyOnSuccess = false, ...options }: { queries: @@ -218,17 +217,12 @@ export function useQueries< | readonly [...{ [K in keyof T]: GetUseQueryOptionsForUseQueries }] combine?: (result: QueriesResults) => TCombinedResult subscribed?: boolean - _combineOnlyOnSuccess?: boolean }, queryClient?: QueryClient, ): TCombinedResult { const client = useQueryClient(queryClient) const isRestoring = useIsRestoring() const errorResetBoundary = useQueryErrorResetBoundary() - const combine = options.combine - const combineForObserver = _combineOnlyOnSuccess - ? undefined - : (combine as QueriesObserverOptions['combine']) const defaultedQueries = React.useMemo( () => @@ -260,7 +254,7 @@ export function useQueries< new QueriesObserver( client, defaultedQueries, - combineForObserver ? { combine: combineForObserver } : undefined, + options as QueriesObserverOptions, ), ) @@ -268,7 +262,7 @@ export function useQueries< const [optimisticResult, getCombinedResult, trackResult] = observer.getOptimisticResult( defaultedQueries, - combineForObserver, + (options as QueriesObserverOptions).combine, ) const shouldSubscribe = !isRestoring && options.subscribed !== false @@ -287,9 +281,9 @@ export function useQueries< React.useEffect(() => { observer.setQueries( defaultedQueries, - combineForObserver ? { combine: combineForObserver } : undefined, + options as QueriesObserverOptions, ) - }, [combineForObserver, defaultedQueries, observer]) + }, [defaultedQueries, options, observer]) const shouldAtLeastOneSuspend = optimisticResult.some((result, index) => shouldSuspend(defaultedQueries[index], result), @@ -330,11 +324,5 @@ export function useQueries< throw firstSingleResultWhichShouldThrow.error } - const trackedResult = trackResult() - - if (combine && _combineOnlyOnSuccess) { - return combine(trackedResult as unknown as QueriesResults) - } - - return getCombinedResult(trackedResult) + return getCombinedResult(trackResult()) } diff --git a/packages/react-query/src/useSuspenseQueries.ts b/packages/react-query/src/useSuspenseQueries.ts index 9fdfefdd6cd..f014095d01c 100644 --- a/packages/react-query/src/useSuspenseQueries.ts +++ b/packages/react-query/src/useSuspenseQueries.ts @@ -190,7 +190,6 @@ export function useSuspenseQueries(options: any, queryClient?: QueryClient) { return useQueries( { ...options, - _combineOnlyOnSuccess: true, queries: options.queries.map((query: any) => { if (process.env.NODE_ENV !== 'production') { if (query.queryFn === skipToken) { From a9d1a5dc51dc5b7c6aa85639d1a6a01faedb3a96 Mon Sep 17 00:00:00 2001 From: "autofix-ci[bot]" <114827586+autofix-ci[bot]@users.noreply.github.com> Date: Sat, 25 Apr 2026 10:16:55 +0000 Subject: [PATCH 3/5] ci: apply automated fixes --- .../query-core/src/__tests__/queriesObserver.test.tsx | 8 +++----- .../react-query/src/__tests__/useSuspenseQueries.test.tsx | 4 +++- 2 files changed, 6 insertions(+), 6 deletions(-) diff --git a/packages/query-core/src/__tests__/queriesObserver.test.tsx b/packages/query-core/src/__tests__/queriesObserver.test.tsx index e392eccb167..8a57c45de16 100644 --- a/packages/query-core/src/__tests__/queriesObserver.test.tsx +++ b/packages/query-core/src/__tests__/queriesObserver.test.tsx @@ -489,11 +489,9 @@ describe('queriesObserver', () => { queryClient.setQueryData(key, 'data') - const observer = new QueriesObserver>( - queryClient, - queries, - { combine }, - ) + const observer = new QueriesObserver>(queryClient, queries, { + combine, + }) const [rawResult, getCombinedResult] = observer.getOptimisticResult( queries, diff --git a/packages/react-query/src/__tests__/useSuspenseQueries.test.tsx b/packages/react-query/src/__tests__/useSuspenseQueries.test.tsx index b489135b195..6b5b327b6ee 100644 --- a/packages/react-query/src/__tests__/useSuspenseQueries.test.tsx +++ b/packages/react-query/src/__tests__/useSuspenseQueries.test.tsx @@ -302,7 +302,9 @@ describe('useSuspenseQueries', () => { return (
-
data: {data.join(',')}
From d56c610d481935ab78ba69e9c23ce8d3165d4b11 Mon Sep 17 00:00:00 2001 From: "autofix-ci[bot]" <114827586+autofix-ci[bot]@users.noreply.github.com> Date: Sat, 25 Apr 2026 10:16:55 +0000 Subject: [PATCH 4/5] fix: stale data --- .../src/__tests__/queriesObserver.test.tsx | 61 ++++++++++++++++--- packages/query-core/src/queriesObserver.ts | 4 +- 2 files changed, 53 insertions(+), 12 deletions(-) diff --git a/packages/query-core/src/__tests__/queriesObserver.test.tsx b/packages/query-core/src/__tests__/queriesObserver.test.tsx index 8a57c45de16..1adfa6195a6 100644 --- a/packages/query-core/src/__tests__/queriesObserver.test.tsx +++ b/packages/query-core/src/__tests__/queriesObserver.test.tsx @@ -478,23 +478,54 @@ describe('queriesObserver', () => { const combine = vi.fn((results: Array) => results.map((result) => result.data), ) - const queries = [ - { - queryKey: key, - queryFn: () => sleep(10).then(() => 'data'), - staleTime: Infinity, - suspense: true, - }, - ] + const query = { + queryKey: key, + queryFn: () => sleep(10).then(() => 'data'), + staleTime: Infinity, + suspense: true, + } queryClient.setQueryData(key, 'data') - const observer = new QueriesObserver>(queryClient, queries, { + const observer = new QueriesObserver>(queryClient, [query], { combine, }) const [rawResult, getCombinedResult] = observer.getOptimisticResult( - queries, + [query], + combine, + ) + expect(getCombinedResult(rawResult)).toEqual(['data']) + expect(combine).toHaveBeenCalledTimes(1) + + const unsubscribe = observer.subscribe(() => undefined) + + void queryClient.resetQueries({ queryKey: key }) + expect(combine).toHaveBeenCalledTimes(1) + + unsubscribe() + }) + + it('should skip combine notifications after suspense is enabled without structural changes', async () => { + const key = queryKey() + const combine = vi.fn((results: Array) => + results.map((result) => result.data), + ) + const query = { + queryKey: key, + queryFn: () => sleep(10).then(() => 'data'), + staleTime: Infinity, + suspense: false, + } + + queryClient.setQueryData(key, 'data') + + const observer = new QueriesObserver>(queryClient, [query], { + combine, + }) + + const [rawResult, getCombinedResult] = observer.getOptimisticResult( + [query], combine, ) expect(getCombinedResult(rawResult)).toEqual(['data']) @@ -502,6 +533,16 @@ describe('queriesObserver', () => { const unsubscribe = observer.subscribe(() => undefined) + observer.setQueries( + [ + { + ...query, + suspense: true, + }, + ], + { combine }, + ) + void queryClient.resetQueries({ queryKey: key }) expect(combine).toHaveBeenCalledTimes(1) diff --git a/packages/query-core/src/queriesObserver.ts b/packages/query-core/src/queriesObserver.ts index d8197c20ecf..00fed467d31 100644 --- a/packages/query-core/src/queriesObserver.ts +++ b/packages/query-core/src/queriesObserver.ts @@ -252,9 +252,9 @@ export class QueriesObserver< #shouldSkipCombine(): boolean { return ( this.#options?.combine !== undefined && - this.#observerMatches.some((match, index) => { + this.#observers.some((observer, index) => { return ( - match.defaultedQueryOptions.suspense && + observer.options.suspense && this.#result[index]?.data === undefined ) }) From 5cb48ea1746ec704a64414f935cb48ef7b3d38ea Mon Sep 17 00:00:00 2001 From: "autofix-ci[bot]" <114827586+autofix-ci[bot]@users.noreply.github.com> Date: Sat, 25 Apr 2026 10:37:50 +0000 Subject: [PATCH 5/5] ci: apply automated fixes (attempt 3/3) --- packages/query-core/src/queriesObserver.ts | 3 +-- 1 file changed, 1 insertion(+), 2 deletions(-) diff --git a/packages/query-core/src/queriesObserver.ts b/packages/query-core/src/queriesObserver.ts index 00fed467d31..4fcf8e5d41e 100644 --- a/packages/query-core/src/queriesObserver.ts +++ b/packages/query-core/src/queriesObserver.ts @@ -254,8 +254,7 @@ export class QueriesObserver< this.#options?.combine !== undefined && this.#observers.some((observer, index) => { return ( - observer.options.suspense && - this.#result[index]?.data === undefined + observer.options.suspense && this.#result[index]?.data === undefined ) }) )