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)