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..f94aeb2314c 100644 --- a/packages/query-core/src/queryObserver.ts +++ b/packages/query-core/src/queryObserver.ts @@ -317,7 +317,44 @@ export class QueryObserver< .getQueryCache() .build(this.#client, defaultedOptions) - return query.fetch().then(() => this.createResult(query, defaultedOptions)) + let unsubscribe = () => {} + let resolveEarly: + | ((result: QueryObserverResult) => void) + | undefined + + const cachePromise = new Promise>( + (resolve) => { + resolveEarly = 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)) + } + }) + }, + ) + + return Promise.race([ + query + .fetch() + .then(() => { + const result = this.createResult(query, defaultedOptions) + // Settle the subscriber promise so both branches always settle. + // This value is ignored by Promise.race since the fetch branch already won. + if (resolveEarly) { + resolveEarly(result) + } + return result + }) + .finally(() => { + unsubscribe() + }), + cachePromise, + ]) } protected fetch( diff --git a/packages/react-query/src/__tests__/useSuspenseQuery.test.tsx b/packages/react-query/src/__tests__/useSuspenseQuery.test.tsx index 10583797f3d..a636b6108bc 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,143 @@ 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() + }) + + it('should NOT release suspense when setQueryData is called with undefined', 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 undefined')) + await act(() => vi.advanceTimersByTimeAsync(0)) + + // Suspense should NOT release — setQueryData(undefined) doesn't satisfy + // the query.state.data !== undefined guard in fetchOptimistic + expect(rendered.getByText('loading')).toBeInTheDocument() + }) })