From 07313f1013b156fc5e6f5a9521d4fbcd597f16d8 Mon Sep 17 00:00:00 2001 From: DeepSeek V4 Pro agent Date: Wed, 8 Jul 2026 20:20:15 +0300 Subject: [PATCH 1/3] fix(query-core): resolve suspense when query data is set programmatically When useSuspenseQuery is used and setQueryData populates the cache while a fetch is in-flight, the suspense boundary stays stuck on the original fetch promise. This prevents streamedQuery and manual cache updates from releasing suspense. Modify fetchOptimistic in queryObserver to use Promise.race between the fetch promise and a cache subscriber that listens for 'updated' events. When data appears in the cache via setQueryData or a streamed chunk, the race resolves immediately without aborting the fetch. This allows the suspense boundary to release and show the available data while the fetch continues in the background. Closes #10924 Co-authored-by: atlarix-agent --- packages/query-core/src/queryObserver.ts | 21 +++- .../src/__tests__/useSuspenseQuery.test.tsx | 102 ++++++++++++++++++ 2 files changed, 122 insertions(+), 1 deletion(-) diff --git a/packages/query-core/src/queryObserver.ts b/packages/query-core/src/queryObserver.ts index 954c969d548..22457831788 100644 --- a/packages/query-core/src/queryObserver.ts +++ b/packages/query-core/src/queryObserver.ts @@ -317,7 +317,26 @@ export class QueryObserver< .getQueryCache() .build(this.#client, defaultedOptions) - return query.fetch().then(() => this.createResult(query, defaultedOptions)) + let unsubscribe = () => {} + + return Promise.race([ + query.fetch().then(() => { + unsubscribe() + return this.createResult(query, defaultedOptions) + }), + new Promise>((resolve) => { + unsubscribe = this.#client.getQueryCache().subscribe((event) => { + if ( + event.type === 'updated' && + event.query.queryHash === query.queryHash && + query.state.data !== undefined + ) { + unsubscribe() + resolve(this.createResult(query, defaultedOptions)) + } + }) + }), + ]) } protected fetch( diff --git a/packages/react-query/src/__tests__/useSuspenseQuery.test.tsx b/packages/react-query/src/__tests__/useSuspenseQuery.test.tsx index 10583797f3d..bafe731362b 100644 --- a/packages/react-query/src/__tests__/useSuspenseQuery.test.tsx +++ b/packages/react-query/src/__tests__/useSuspenseQuery.test.tsx @@ -7,6 +7,7 @@ import { QueryCache, QueryClient, QueryErrorResetBoundary, + experimental_streamedQuery, skipToken, useQueryErrorResetBoundary, useSuspenseInfiniteQuery, @@ -996,4 +997,105 @@ describe('useSuspenseQuery', () => { consoleErrorSpy.mockRestore() process.env.NODE_ENV = envCopy }) + + it('should release suspense when setQueryData is called while fetch is in-flight', async () => { + const key = queryKey() + + function Content() { + const { data } = useSuspenseQuery({ + queryKey: key, + queryFn: () => sleep(10000).then(() => 'fetched'), + }) + return
data: {data}
+ } + + function Page() { + return ( +
+ + + + +
+ ) + } + + const rendered = renderWithClient(queryClient, ) + + expect(rendered.getByText('loading')).toBeInTheDocument() + + fireEvent.click(rendered.getByText('set data')) + await act(() => vi.advanceTimersByTimeAsync(0)) + + expect(rendered.getByText('data: manual data')).toBeInTheDocument() + }) + + it('should release suspense when streamedQuery receives first chunk', async () => { + const key = queryKey() + + async function* numberGenerator() { + await sleep(10) + yield 'chunk1' + await sleep(10) + yield 'chunk2' + } + + function Page() { + const { data } = useSuspenseQuery({ + queryKey: key, + queryFn: experimental_streamedQuery({ + streamFn: () => numberGenerator(), + }), + }) + return
data: {data}
+ } + + const streamedClient = new QueryClient({ + queryCache, + defaultOptions: { + queries: { + experimental_prefetchInRender: true, + }, + }, + }) + + const rendered = renderWithClient( + streamedClient, + + + , + ) + + expect(rendered.getByText('loading')).toBeInTheDocument() + + await act(() => vi.advanceTimersByTimeAsync(10)) + + expect(rendered.getByText('data: chunk1')).toBeInTheDocument() + }) + + it('should release suspense when setQueryData is called before component mounts', async () => { + const key = queryKey() + queryClient.setQueryData(key, 'preloaded') + + function Page() { + const { data } = useSuspenseQuery({ + queryKey: key, + queryFn: () => sleep(10000).then(() => 'fetched'), + }) + return
data: {data}
+ } + + const rendered = renderWithClient( + queryClient, + + + , + ) + + await act(() => vi.advanceTimersByTimeAsync(0)) + + expect(rendered.getByText('data: preloaded')).toBeInTheDocument() + }) }) From 60a34d70024ff76ac3cde991c7926f5856d39f82 Mon Sep 17 00:00:00 2001 From: DeepSeek V4 Pro agent Date: Wed, 8 Jul 2026 20:26:37 +0300 Subject: [PATCH 2/3] chore: add changeset for suspense setQueryData fix Co-authored-by: atlarix-agent --- .changeset/resolve-suspense-setquerydata.md | 5 +++++ 1 file changed, 5 insertions(+) create mode 100644 .changeset/resolve-suspense-setquerydata.md diff --git a/.changeset/resolve-suspense-setquerydata.md b/.changeset/resolve-suspense-setquerydata.md new file mode 100644 index 00000000000..6cf4f2334d7 --- /dev/null +++ b/.changeset/resolve-suspense-setquerydata.md @@ -0,0 +1,5 @@ +--- +"@tanstack/query-core": patch +--- + +Resolve suspense when query data is set programmatically via setQueryData or streamedQuery. Previously, fetchOptimistic returned only the fetch promise, which left the Suspense boundary stuck until the queryFn completed — even when data already existed in the cache. The fix uses Promise.race with a cache subscriber to release suspense as soon as data becomes available. From 8bf5144f0bb440ad5d968ac8c77bfd2daba01238 Mon Sep 17 00:00:00 2001 From: DeepSeek V4 Pro agent Date: Thu, 9 Jul 2026 08:23:31 +0300 Subject: [PATCH 3/3] fix(query-core): prevent subscription leak in fetchOptimistic on fetch rejection MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit unsubscribe() was only called in the .then() success path. If query.fetch() rejected before the cache subscriber resolved the race, the subscription leaked — staying registered on QueryCache indefinitely and checking a stale queryHash on every 'updated' event. Use .finally() to ensure unsubscribe() runs on both success and rejection, preserving the original promise type through the chain. Co-authored-by: atlarix-agent --- packages/query-core/src/queryObserver.ts | 3 ++- 1 file changed, 2 insertions(+), 1 deletion(-) diff --git a/packages/query-core/src/queryObserver.ts b/packages/query-core/src/queryObserver.ts index 22457831788..dc8ecb6048d 100644 --- a/packages/query-core/src/queryObserver.ts +++ b/packages/query-core/src/queryObserver.ts @@ -321,8 +321,9 @@ export class QueryObserver< return Promise.race([ query.fetch().then(() => { - unsubscribe() return this.createResult(query, defaultedOptions) + }).finally(() => { + unsubscribe() }), new Promise>((resolve) => { unsubscribe = this.#client.getQueryCache().subscribe((event) => {