diff --git a/.changeset/shy-wings-buy.md b/.changeset/shy-wings-buy.md new file mode 100644 index 00000000000..7710365f635 --- /dev/null +++ b/.changeset/shy-wings-buy.md @@ -0,0 +1,5 @@ +--- +'@tanstack/query-core': patch +--- + +Fix bugs where hydrating queries with promises that had already resolved could cause queries to briefly and incorrectly show as pending/fetching diff --git a/packages/query-core/src/__tests__/hydration.test.tsx b/packages/query-core/src/__tests__/hydration.test.tsx index 8bb79ec6e9b..ac02d049da2 100644 --- a/packages/query-core/src/__tests__/hydration.test.tsx +++ b/packages/query-core/src/__tests__/hydration.test.tsx @@ -1385,4 +1385,177 @@ describe('dehydration and rehydration', () => { // error and test will fail await originalPromise }) + + // Companion to the test above: when the query already exists in the cache + // (e.g. after an initial render or a first hydration pass), the same + // synchronous thenable resolution must also produce status: 'success'. + // Previously the if (query) branch would spread status: 'pending' from the + // server state without correcting it for the resolved data. + test('should set status to success when rehydrating an existing pending query with a synchronously resolved promise', async () => { + const key = queryKey() + // --- server --- + + const serverQueryClient = new QueryClient({ + defaultOptions: { + dehydrate: { shouldDehydrateQuery: () => true }, + }, + }) + + let resolvePrefetch: undefined | ((value?: unknown) => void) + const prefetchPromise = new Promise((res) => { + resolvePrefetch = res + }) + // Keep the query pending so it dehydrates with status: 'pending' and a promise + void serverQueryClient.prefetchQuery({ + queryKey: key, + queryFn: () => prefetchPromise, + }) + + const dehydrated = dehydrate(serverQueryClient) + expect(dehydrated.queries[0]?.state.status).toBe('pending') + + // Simulate a synchronous thenable – models a React streaming promise that + // resolved before the second hydrate() call. + resolvePrefetch?.('server data') + // @ts-expect-error + dehydrated.queries[0].promise.then = (cb) => { + cb?.('server data') + // @ts-expect-error + return dehydrated.queries[0].promise + } + + // --- client --- + // Query already exists in the cache in a pending state, as it would after + // a first hydration pass or an initial render. + const clientQueryClient = new QueryClient() + void clientQueryClient.prefetchQuery({ + queryKey: key, + queryFn: () => { + throw new Error('QueryFn on client should not be called') + }, + }) + + const query = clientQueryClient.getQueryCache().find({ queryKey: key })! + expect(query.state.status).toBe('pending') + + hydrate(clientQueryClient, dehydrated) + + expect(clientQueryClient.getQueryData(key)).toBe('server data') + expect(query.state.status).toBe('success') + + clientQueryClient.clear() + serverQueryClient.clear() + }) + + test('should not transition to a fetching/pending state when hydrating an already resolved promise into a new query', async () => { + const key = queryKey() + // --- server --- + const serverQueryClient = new QueryClient({ + defaultOptions: { + dehydrate: { shouldDehydrateQuery: () => true }, + }, + }) + + let resolvePrefetch: undefined | ((value?: unknown) => void) + const prefetchPromise = new Promise((res) => { + resolvePrefetch = res + }) + void serverQueryClient.prefetchQuery({ + queryKey: key, + queryFn: () => prefetchPromise, + }) + const dehydrated = dehydrate(serverQueryClient) + + // Simulate a synchronous thenable – the promise was already resolved + // before we hydrate on the client + resolvePrefetch?.('server data') + // @ts-expect-error + dehydrated.queries[0].promise.then = (cb) => { + cb?.('server data') + // @ts-expect-error + return dehydrated.queries[0].promise + } + + // --- client --- + const clientQueryClient = new QueryClient() + + const states: Array<{ status: string; fetchStatus: string }> = [] + const unsubscribe = clientQueryClient.getQueryCache().subscribe((event) => { + if (event.type === 'updated') { + const { status, fetchStatus } = event.query.state + states.push({ status, fetchStatus }) + } + }) + + hydrate(clientQueryClient, dehydrated) + await vi.advanceTimersByTimeAsync(0) + unsubscribe() + + expect(states).not.toContainEqual( + expect.objectContaining({ fetchStatus: 'fetching' }), + ) + expect(states).not.toContainEqual( + expect.objectContaining({ status: 'pending' }), + ) + + clientQueryClient.clear() + serverQueryClient.clear() + }) + + test('should not transition to a fetching/pending state when hydrating an already resolved promise into an existing query', async () => { + const key = queryKey() + // --- server --- + const serverQueryClient = new QueryClient({ + defaultOptions: { + dehydrate: { shouldDehydrateQuery: () => true }, + }, + }) + + let resolvePrefetch: undefined | ((value?: unknown) => void) + const prefetchPromise = new Promise((res) => { + resolvePrefetch = res + }) + void serverQueryClient.prefetchQuery({ + queryKey: key, + queryFn: () => prefetchPromise, + }) + const dehydrated = dehydrate(serverQueryClient) + + // Simulate a synchronous thenable – the promise was already resolved + // before we hydrate on the client + resolvePrefetch?.('server data') + // @ts-expect-error + dehydrated.queries[0].promise.then = (cb) => { + cb?.('server data') + // @ts-expect-error + return dehydrated.queries[0].promise + } + + // --- client --- + // Pre-populate with old data (updatedAt: 0 ensures dehydratedAt is newer) + const clientQueryClient = new QueryClient() + clientQueryClient.setQueryData(key, 'old data', { updatedAt: 0 }) + + const states: Array<{ status: string; fetchStatus: string }> = [] + const unsubscribe = clientQueryClient.getQueryCache().subscribe((event) => { + if (event.type === 'updated') { + const { status, fetchStatus } = event.query.state + states.push({ status, fetchStatus }) + } + }) + + hydrate(clientQueryClient, dehydrated) + await vi.advanceTimersByTimeAsync(0) + unsubscribe() + + expect(states).not.toContainEqual( + expect.objectContaining({ fetchStatus: 'fetching' }), + ) + expect(states).not.toContainEqual( + expect.objectContaining({ status: 'pending' }), + ) + + clientQueryClient.clear() + serverQueryClient.clear() + }) }) diff --git a/packages/query-core/src/hydration.ts b/packages/query-core/src/hydration.ts index c75d8ee332c..a7715db8d4b 100644 --- a/packages/query-core/src/hydration.ts +++ b/packages/query-core/src/hydration.ts @@ -236,6 +236,15 @@ export function hydrate( query.setState({ ...serializedState, data, + // if data was resolved synchronously, transition to success + // (mirrors the new-query branch below), but preserve fetchStatus + // if the query is already actively fetching + ...(data !== undefined && { + status: 'success' as const, + ...(!existingQueryIsFetching && { + fetchStatus: 'idle' as const, + }), + }), }) } } else { @@ -262,6 +271,9 @@ export function hydrate( if ( promise && + // If the data was synchronously available, there is no need to set up + // a retryer and thus no reason to call fetch + !syncData && !existingQueryIsPending && !existingQueryIsFetching && // Only hydrate if dehydration is newer than any existing data, @@ -270,8 +282,6 @@ export function hydrate( ) { // This doesn't actually fetch - it just creates a retryer // which will re-use the passed `initialPromise` - // Note that we need to call these even when data was synchronously - // available, as we still need to set up the retryer query .fetch(undefined, { // RSC transformed promises are not thenable