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. diff --git a/packages/query-core/src/queryObserver.ts b/packages/query-core/src/queryObserver.ts index 954c969d548..dc8ecb6048d 100644 --- a/packages/query-core/src/queryObserver.ts +++ b/packages/query-core/src/queryObserver.ts @@ -317,7 +317,27 @@ export class QueryObserver< .getQueryCache() .build(this.#client, defaultedOptions) - return query.fetch().then(() => this.createResult(query, defaultedOptions)) + let unsubscribe = () => {} + + return Promise.race([ + query.fetch().then(() => { + return this.createResult(query, defaultedOptions) + }).finally(() => { + unsubscribe() + }), + 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() + }) })