From a604a6a623ff872df9b65e79c3bd310471598550 Mon Sep 17 00:00:00 2001 From: ColemanDunn <42652642+ColemanDunn@users.noreply.github.com> Date: Sat, 21 Feb 2026 01:40:40 -0700 Subject: [PATCH 1/5] prevent registered useQueries from skipping hydration --- packages/query-core/src/hydration.ts | 9 ++- .../react-query/src/HydrationBoundary.tsx | 6 ++ .../src/__tests__/HydrationBoundary.test.tsx | 66 +++++++++++++++++++ 3 files changed, 79 insertions(+), 2 deletions(-) diff --git a/packages/query-core/src/hydration.ts b/packages/query-core/src/hydration.ts index c75d8ee332c..4eb3b50be59 100644 --- a/packages/query-core/src/hydration.ts +++ b/packages/query-core/src/hydration.ts @@ -217,6 +217,11 @@ export function hydrate( let query = queryCache.get(queryHash) const existingQueryIsPending = query?.state.status === 'pending' const existingQueryIsFetching = query?.state.fetchStatus === 'fetching' + const existingQueryIsUndefinedOrIsIdleUseQuery = + !query || + (query.state.dataUpdatedAt === 0 && + query.state.status === 'pending' && + query.state.fetchStatus === 'idle') // Do not hydrate if an existing query exists with newer data if (query) { @@ -262,8 +267,8 @@ export function hydrate( if ( promise && - !existingQueryIsPending && - !existingQueryIsFetching && + (existingQueryIsUndefinedOrIsIdleUseQuery || + (!existingQueryIsPending && !existingQueryIsFetching)) && // Only hydrate if dehydration is newer than any existing data, // this is always true for new queries (dehydratedAt === undefined || dehydratedAt > query.state.dataUpdatedAt) diff --git a/packages/react-query/src/HydrationBoundary.tsx b/packages/react-query/src/HydrationBoundary.tsx index 901c8e9686c..ec3590f9635 100644 --- a/packages/react-query/src/HydrationBoundary.tsx +++ b/packages/react-query/src/HydrationBoundary.tsx @@ -68,9 +68,15 @@ export const HydrationBoundary = ({ const existingQueries: DehydratedState['queries'] = [] for (const dehydratedQuery of queries) { const existingQuery = queryCache.get(dehydratedQuery.queryHash) + const existingQueryIsIdleUseQuery = + existingQuery?.state.dataUpdatedAt === 0 && + existingQuery.state.status === 'pending' && + existingQuery.state.fetchStatus === 'idle' if (!existingQuery) { newQueries.push(dehydratedQuery) + } else if (existingQueryIsIdleUseQuery) { + newQueries.push(dehydratedQuery) } else { const hydrationIsNewer = dehydratedQuery.state.dataUpdatedAt > diff --git a/packages/react-query/src/__tests__/HydrationBoundary.test.tsx b/packages/react-query/src/__tests__/HydrationBoundary.test.tsx index 67b409c6ad1..5794b1fdb43 100644 --- a/packages/react-query/src/__tests__/HydrationBoundary.test.tsx +++ b/packages/react-query/src/__tests__/HydrationBoundary.test.tsx @@ -7,8 +7,10 @@ import { HydrationBoundary, QueryClient, QueryClientProvider, + defaultShouldDehydrateQuery, dehydrate, useQuery, + useSuspenseQuery, } from '..' import type { hydrate } from '@tanstack/query-core' @@ -481,6 +483,70 @@ describe('React hydration', () => { clientQueryClient.clear() }) + test('should hydrate pending idle queries in render to avoid suspense refetches', async () => { + const queryKey = ['string'] as const + + const makeQueryClient = () => + new QueryClient({ + defaultOptions: { + dehydrate: { + shouldDehydrateQuery: (query) => + defaultShouldDehydrateQuery(query) || + query.state.status === 'pending', + shouldRedactErrors: () => false, + }, + }, + }) + + const prefetchClient = makeQueryClient() + void prefetchClient.prefetchQuery({ + queryKey, + queryFn: () => Promise.resolve(['stringCached']), + staleTime: Infinity, + }) + const dehydratedState = dehydrate(prefetchClient) + + const queryFn = vi.fn(() => Promise.resolve(['string'])) + const suspenseQueryFn = vi.fn(() => Promise.resolve(['string'])) + const queryClient = new QueryClient() + + function Header() { + useQuery({ + queryKey, + queryFn, + }) + return null + } + + function Page() { + const { data } = useSuspenseQuery({ + queryKey, + queryFn: suspenseQueryFn, + }) + return
{data}
+ } + + render( + +
+ + + + + + , + ) + + await Promise.resolve() + await Promise.resolve() + await Promise.resolve() + await vi.advanceTimersByTimeAsync(1) + expect(queryClient.getQueryData(queryKey)).toEqual(['stringCached']) + expect(suspenseQueryFn).toHaveBeenCalledTimes(0) + + queryClient.clear() + }) + test('should not refetch when query has enabled set to false', async () => { const queryFn = vi.fn() const queryClient = new QueryClient() From de847108baa2aa2f3640eea7ed9a34bc58e8b2c9 Mon Sep 17 00:00:00 2001 From: ColemanDunn <42652642+ColemanDunn@users.noreply.github.com> Date: Sat, 21 Feb 2026 01:40:40 -0700 Subject: [PATCH 2/5] Document changeset instructions --- .changeset/moody-cities-stand.md | 6 ++++++ 1 file changed, 6 insertions(+) create mode 100644 .changeset/moody-cities-stand.md diff --git a/.changeset/moody-cities-stand.md b/.changeset/moody-cities-stand.md new file mode 100644 index 00000000000..d38419d14b4 --- /dev/null +++ b/.changeset/moody-cities-stand.md @@ -0,0 +1,6 @@ +--- +'@tanstack/react-query': patch +'@tanstack/query-core': patch +--- + +prevent registered useQueries from skipping hydration From 36edbba7f63505aa24e2b4e89e0d4304a1009101 Mon Sep 17 00:00:00 2001 From: ColemanDunn <42652642+ColemanDunn@users.noreply.github.com> Date: Sat, 21 Feb 2026 01:48:29 -0700 Subject: [PATCH 3/5] Diagnose push errors --- packages/react-query/src/HydrationBoundary.tsx | 4 +--- 1 file changed, 1 insertion(+), 3 deletions(-) diff --git a/packages/react-query/src/HydrationBoundary.tsx b/packages/react-query/src/HydrationBoundary.tsx index ec3590f9635..dc7d8f3bd2f 100644 --- a/packages/react-query/src/HydrationBoundary.tsx +++ b/packages/react-query/src/HydrationBoundary.tsx @@ -73,9 +73,7 @@ export const HydrationBoundary = ({ existingQuery.state.status === 'pending' && existingQuery.state.fetchStatus === 'idle' - if (!existingQuery) { - newQueries.push(dehydratedQuery) - } else if (existingQueryIsIdleUseQuery) { + if (!existingQuery || existingQueryIsIdleUseQuery) { newQueries.push(dehydratedQuery) } else { const hydrationIsNewer = From ac2716703aa24d6fdd913b3e17d0557c45ef4a8f Mon Sep 17 00:00:00 2001 From: ColemanDunn <42652642+ColemanDunn@users.noreply.github.com> Date: Sat, 25 Apr 2026 10:26:30 -0600 Subject: [PATCH 4/5] Handle sync hydration for idle query shells --- packages/query-core/src/hydration.ts | 21 ++--- .../src/__tests__/HydrationBoundary.test.tsx | 80 +++++++++++++++++++ 2 files changed, 91 insertions(+), 10 deletions(-) diff --git a/packages/query-core/src/hydration.ts b/packages/query-core/src/hydration.ts index fb86fb2f475..91a71004c3d 100644 --- a/packages/query-core/src/hydration.ts +++ b/packages/query-core/src/hydration.ts @@ -225,13 +225,13 @@ export function hydrate( const data = rawData === undefined ? rawData : deserializeData(rawData) let query = queryCache.get(queryHash) + const existingQueryIsUndefined = !query const existingQueryIsPending = query?.state.status === 'pending' const existingQueryIsFetching = query?.state.fetchStatus === 'fetching' - const existingQueryIsUndefinedOrIsIdleUseQuery = - !query || - (query.state.dataUpdatedAt === 0 && - query.state.status === 'pending' && - query.state.fetchStatus === 'idle') + const existingQueryIsIdleUseQuery = + query?.state.dataUpdatedAt === 0 && + query.state.status === 'pending' && + query.state.fetchStatus === 'idle' // Do not hydrate if an existing query exists with newer data if (query) { @@ -295,11 +295,12 @@ 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 && - (existingQueryIsUndefinedOrIsIdleUseQuery || - (!existingQueryIsPending && !existingQueryIsFetching)) && + (existingQueryIsIdleUseQuery || + // If the data was synchronously available, there is no need to set up + // a retryer and thus no reason to call fetch + (!syncData && + (existingQueryIsUndefined || + (!existingQueryIsPending && !existingQueryIsFetching)))) && // Only hydrate if dehydration is newer than any existing data, // this is always true for new queries (dehydratedAt === undefined || dehydratedAt > query.state.dataUpdatedAt) diff --git a/packages/react-query/src/__tests__/HydrationBoundary.test.tsx b/packages/react-query/src/__tests__/HydrationBoundary.test.tsx index a904a43a607..a809466a97d 100644 --- a/packages/react-query/src/__tests__/HydrationBoundary.test.tsx +++ b/packages/react-query/src/__tests__/HydrationBoundary.test.tsx @@ -514,6 +514,7 @@ describe('React hydration', () => { useQuery({ queryKey, queryFn, + enabled: false, }) return null } @@ -547,6 +548,85 @@ describe('React hydration', () => { queryClient.clear() }) + it('should hydrate synchronously resolved pending idle queries in render', () => { + const queryKey = ['string'] as const + + const makeQueryClient = () => + new QueryClient({ + defaultOptions: { + dehydrate: { + shouldDehydrateQuery: (query) => + defaultShouldDehydrateQuery(query) || + query.state.status === 'pending', + shouldRedactErrors: () => false, + }, + }, + }) + + const prefetchClient = makeQueryClient() + let resolvePrefetch: undefined | ((value: Array) => void) + const prefetchPromise = new Promise>((resolve) => { + resolvePrefetch = resolve + }) + void prefetchClient.prefetchQuery({ + queryKey, + queryFn: () => prefetchPromise, + staleTime: Infinity, + }) + const dehydratedState = dehydrate(prefetchClient) + + resolvePrefetch?.(['stringCached']) + // Simulate a synchronously resolved thenable, like React can provide for + // an already resolved streamed promise. + // @ts-expect-error + dehydratedState.queries[0].promise.then = (cb) => { + cb?.(['stringCached']) + // @ts-expect-error + return dehydratedState.queries[0].promise + } + + const queryFn = vi.fn(() => Promise.resolve(['string'])) + const suspenseQueryFn = vi.fn(() => Promise.resolve(['string'])) + const queryClient = new QueryClient() + + function Header() { + useQuery({ + queryKey, + queryFn, + enabled: false, + }) + return null + } + + function Page() { + const { data } = useSuspenseQuery({ + queryKey, + queryFn: suspenseQueryFn, + }) + return
{data}
+ } + + const rendered = render( + +
+ + + + + + , + ) + + expect(rendered.queryByText('loading')).not.toBeInTheDocument() + expect(rendered.getByText('stringCached')).toBeInTheDocument() + expect(queryClient.getQueryData(queryKey)).toEqual(['stringCached']) + expect(queryFn).toHaveBeenCalledTimes(0) + expect(suspenseQueryFn).toHaveBeenCalledTimes(0) + + queryClient.clear() + prefetchClient.clear() + }) + it('should not refetch when query has enabled set to false', async () => { const queryFn = vi.fn() const queryClient = new QueryClient() From 03b08abfa1e8671260691c425a4e27e9f486e6fa Mon Sep 17 00:00:00 2001 From: ColemanDunn <42652642+ColemanDunn@users.noreply.github.com> Date: Sat, 25 Apr 2026 17:34:19 -0600 Subject: [PATCH 5/5] Handle sync hydration for idle pending queries --- .../src/__tests__/hydration.test.tsx | 73 +++++++++++++++++++ packages/query-core/src/hydration.ts | 34 +++++---- 2 files changed, 91 insertions(+), 16 deletions(-) diff --git a/packages/query-core/src/__tests__/hydration.test.tsx b/packages/query-core/src/__tests__/hydration.test.tsx index c64cb10da53..9170e5a77d7 100644 --- a/packages/query-core/src/__tests__/hydration.test.tsx +++ b/packages/query-core/src/__tests__/hydration.test.tsx @@ -2,6 +2,7 @@ import { afterEach, beforeEach, describe, expect, it, vi } from 'vitest' import { queryKey, sleep } from '@tanstack/query-test-utils' import { QueryClient } from '../queryClient' import { QueryCache } from '../queryCache' +import { QueryObserver } from '../queryObserver' import { dehydrate, hydrate } from '../hydration' import { MutationCache } from '../mutationCache' import { executeMutation, mockOnlineManagerIsOnline } from './utils' @@ -1804,4 +1805,76 @@ describe('dehydration and rehydration', () => { clientQueryClient.clear() serverQueryClient.clear() }) + + it('should not transition to a fetching/pending state when hydrating an already resolved promise into an idle pending 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') + Object.assign(dehydrated.queries[0]!.promise!, { + then: (cb: ((value: unknown) => unknown) | undefined) => { + cb?.('server data') + return dehydrated.queries[0]!.promise! + }, + }) + + // --- client --- + // This matches a useQuery({ enabled: false }) shell that exists before + // HydrationBoundary hydrates the streamed query. + const clientQueryClient = new QueryClient() + const observer = new QueryObserver(clientQueryClient, { + queryKey: key, + enabled: false, + }) + const unsubscribeObserver = observer.subscribe(() => undefined) + const query = clientQueryClient.getQueryCache().find({ queryKey: key })! + expect(query.state).toMatchObject({ + dataUpdatedAt: 0, + status: 'pending', + fetchStatus: 'idle', + }) + + const states: Array<{ status: string; fetchStatus: string }> = [] + const unsubscribeCache = 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) + unsubscribeCache() + unsubscribeObserver() + + expect(clientQueryClient.getQueryData(key)).toBe('server data') + 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 91a71004c3d..15c5fc32921 100644 --- a/packages/query-core/src/hydration.ts +++ b/packages/query-core/src/hydration.ts @@ -223,6 +223,8 @@ export function hydrate( const syncData = promise ? tryResolveSync(promise) : undefined const rawData = state.data === undefined ? syncData?.data : state.data const data = rawData === undefined ? rawData : deserializeData(rawData) + const pendingQueryResolvedSync = + state.status === 'pending' && data !== undefined let query = queryCache.get(queryHash) const existingQueryIsUndefined = !query @@ -255,14 +257,14 @@ export function hydrate( // // Since you can opt into dehydrating failed queries, and those can have data from // previous successful fetches, we make sure we only do this for pending queries. - ...(state.status === 'pending' && - data !== undefined && { - status: 'success' as const, - // Preserve existing fetchStatus if the existing query is actively fetching. - ...(!existingQueryIsFetching && { - fetchStatus: 'idle' as const, - }), + ...(pendingQueryResolvedSync && { + status: 'success' as const, + dataUpdatedAt: dehydratedAt ?? Date.now(), + // Preserve existing fetchStatus if the existing query is actively fetching. + ...(!existingQueryIsFetching && { + fetchStatus: 'idle' as const, }), + }), }) } } else { @@ -285,22 +287,22 @@ export function hydrate( fetchStatus: 'idle', // Like above, if the query was pending at the moment of dehydration but has data, // we can assume it should be hydrated as successful. - status: - state.status === 'pending' && data !== undefined - ? 'success' - : state.status, + status: pendingQueryResolvedSync ? 'success' : state.status, + ...(pendingQueryResolvedSync && { + dataUpdatedAt: dehydratedAt ?? Date.now(), + }), }, ) } 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 && (existingQueryIsIdleUseQuery || - // If the data was synchronously available, there is no need to set up - // a retryer and thus no reason to call fetch - (!syncData && - (existingQueryIsUndefined || - (!existingQueryIsPending && !existingQueryIsFetching)))) && + existingQueryIsUndefined || + (!existingQueryIsPending && !existingQueryIsFetching)) && // Only hydrate if dehydration is newer than any existing data, // this is always true for new queries (dehydratedAt === undefined || dehydratedAt > query.state.dataUpdatedAt)