From b5f90072486ba93e5a29a0f228daf4a868ce39b3 Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?Fredrik=20H=C3=B6glund?= Date: Fri, 10 Apr 2026 13:50:39 +0200 Subject: [PATCH 1/5] Implement failing test Implements failing test for incorrect pending status when hydrating already resolved promises and query already exists in the cache. --- .../src/__tests__/hydration.test.tsx | 57 +++++++++++++++++++ 1 file changed, 57 insertions(+) diff --git a/packages/query-core/src/__tests__/hydration.test.tsx b/packages/query-core/src/__tests__/hydration.test.tsx index 8bb79ec6e9..41433c6c4e 100644 --- a/packages/query-core/src/__tests__/hydration.test.tsx +++ b/packages/query-core/src/__tests__/hydration.test.tsx @@ -1385,4 +1385,61 @@ 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?.(); + // @ts-expect-error + dehydrated.queries[0].promise.then = (cb) => { + cb?.('server data') + } + + // --- 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() + }) }) From addb7c1a2392a1155c008322fcc0b8e4fd1108dc Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?Fredrik=20H=C3=B6glund?= Date: Fri, 10 Apr 2026 15:06:30 +0200 Subject: [PATCH 2/5] Add failing tests for incorrectly transitioning to fetching/pending on already resolved promises --- .../src/__tests__/hydration.test.tsx | 116 +++++++++++++++++- 1 file changed, 115 insertions(+), 1 deletion(-) diff --git a/packages/query-core/src/__tests__/hydration.test.tsx b/packages/query-core/src/__tests__/hydration.test.tsx index 41433c6c4e..245ccc89ea 100644 --- a/packages/query-core/src/__tests__/hydration.test.tsx +++ b/packages/query-core/src/__tests__/hydration.test.tsx @@ -1416,10 +1416,12 @@ describe('dehydration and rehydration', () => { // Simulate a synchronous thenable – models a React streaming promise that // resolved before the second hydrate() call. - resolvePrefetch?.(); + resolvePrefetch?.('server data'); // @ts-expect-error dehydrated.queries[0].promise.then = (cb) => { cb?.('server data') + // @ts-expect-error + return dehydrated.queries[0].promise } // --- client --- @@ -1442,4 +1444,116 @@ describe('dehydration and rehydration', () => { 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() + }) }) From 7113668b6d42056b6203afddacc60511f1434e3d Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?Fredrik=20H=C3=B6glund?= Date: Fri, 10 Apr 2026 16:13:18 +0200 Subject: [PATCH 3/5] Fix bugs --- packages/query-core/src/hydration.ts | 14 ++++++++++++-- 1 file changed, 12 insertions(+), 2 deletions(-) diff --git a/packages/query-core/src/hydration.ts b/packages/query-core/src/hydration.ts index c75d8ee332..a7715db8d4 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 From 8447440a6740285f83fe5380157f03297a2a8cce Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?Fredrik=20H=C3=B6glund?= Date: Fri, 10 Apr 2026 16:18:25 +0200 Subject: [PATCH 4/5] Add changeset --- .changeset/shy-wings-buy.md | 5 +++++ 1 file changed, 5 insertions(+) create mode 100644 .changeset/shy-wings-buy.md diff --git a/.changeset/shy-wings-buy.md b/.changeset/shy-wings-buy.md new file mode 100644 index 0000000000..7710365f63 --- /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 From 348505224ca6a49837ea4f1fff401d1d200a7366 Mon Sep 17 00:00:00 2001 From: "autofix-ci[bot]" <114827586+autofix-ci[bot]@users.noreply.github.com> Date: Fri, 10 Apr 2026 14:31:38 +0000 Subject: [PATCH 5/5] ci: apply automated fixes --- .../src/__tests__/hydration.test.tsx | 18 ++++++++++-------- 1 file changed, 10 insertions(+), 8 deletions(-) diff --git a/packages/query-core/src/__tests__/hydration.test.tsx b/packages/query-core/src/__tests__/hydration.test.tsx index 245ccc89ea..ac02d049da 100644 --- a/packages/query-core/src/__tests__/hydration.test.tsx +++ b/packages/query-core/src/__tests__/hydration.test.tsx @@ -1401,9 +1401,9 @@ describe('dehydration and rehydration', () => { }, }) - let resolvePrefetch: undefined | ((value?: unknown) => void); + let resolvePrefetch: undefined | ((value?: unknown) => void) const prefetchPromise = new Promise((res) => { - resolvePrefetch = res; + resolvePrefetch = res }) // Keep the query pending so it dehydrates with status: 'pending' and a promise void serverQueryClient.prefetchQuery({ @@ -1416,7 +1416,7 @@ describe('dehydration and rehydration', () => { // Simulate a synchronous thenable – models a React streaming promise that // resolved before the second hydrate() call. - resolvePrefetch?.('server data'); + resolvePrefetch?.('server data') // @ts-expect-error dehydrated.queries[0].promise.then = (cb) => { cb?.('server data') @@ -1430,7 +1430,9 @@ describe('dehydration and rehydration', () => { const clientQueryClient = new QueryClient() void clientQueryClient.prefetchQuery({ queryKey: key, - queryFn: () => { throw new Error('QueryFn on client should not be called') }, + queryFn: () => { + throw new Error('QueryFn on client should not be called') + }, }) const query = clientQueryClient.getQueryCache().find({ queryKey: key })! @@ -1460,13 +1462,13 @@ describe('dehydration and rehydration', () => { }) void serverQueryClient.prefetchQuery({ queryKey: key, - queryFn: () => prefetchPromise + queryFn: () => prefetchPromise, }) const dehydrated = dehydrate(serverQueryClient) // Simulate a synchronous thenable – the promise was already resolved // before we hydrate on the client - resolvePrefetch?.('server data'); + resolvePrefetch?.('server data') // @ts-expect-error dehydrated.queries[0].promise.then = (cb) => { cb?.('server data') @@ -1515,13 +1517,13 @@ describe('dehydration and rehydration', () => { }) void serverQueryClient.prefetchQuery({ queryKey: key, - queryFn: () => prefetchPromise + queryFn: () => prefetchPromise, }) const dehydrated = dehydrate(serverQueryClient) // Simulate a synchronous thenable – the promise was already resolved // before we hydrate on the client - resolvePrefetch?.('server data'); + resolvePrefetch?.('server data') // @ts-expect-error dehydrated.queries[0].promise.then = (cb) => { cb?.('server data')