diff --git a/.changeset/tidy-poems-guess.md b/.changeset/tidy-poems-guess.md
new file mode 100644
index 0000000000..bd8347c341
--- /dev/null
+++ b/.changeset/tidy-poems-guess.md
@@ -0,0 +1,5 @@
+---
+'@tanstack/react-query': patch
+---
+
+Make queries inside a `HydrationBoundary` replay the boundary's server-rendered state during hydration before switching to the live cache. This prevents hydration mismatches when the cache changes before browser hydration, including when a query above the boundary creates an empty cache entry or a streamed promise resolves early.
diff --git a/packages/react-query/src/HydrationBoundary.tsx b/packages/react-query/src/HydrationBoundary.tsx
index ce03eb40c8..f18375c40f 100644
--- a/packages/react-query/src/HydrationBoundary.tsx
+++ b/packages/react-query/src/HydrationBoundary.tsx
@@ -1,15 +1,23 @@
'use client'
import * as React from 'react'
-import { hydrate } from '@tanstack/query-core'
+import { QueryCache, QueryClient, hydrate } from '@tanstack/query-core'
import { useQueryClient } from './QueryClientProvider'
import type {
DehydratedState,
HydrateOptions,
OmitKeyof,
- QueryClient,
} from '@tanstack/query-core'
+/**
+ * Internal context that carries the frozen server snapshot for the nearest
+ * hydration boundary. Hooks use it to replay the result that produced the
+ * server markup instead of reading newer data from the live cache.
+ */
+export const QueryServerSnapshotContext = React.createContext<
+ QueryClient | undefined
+>(undefined)
+
/**
* The props accepted by `HydrationBoundary`.
*/
@@ -85,7 +93,7 @@ export interface HydrationBoundaryProps {
*/
export const HydrationBoundary = ({
children,
- options = {},
+ options,
state,
queryClient,
}: HydrationBoundaryProps) => {
@@ -96,6 +104,53 @@ export const HydrationBoundary = ({
optionsRef.current = options
})
+ // Keep an immutable copy of the query state that produced this boundary's
+ // server markup. We build the queries directly instead of calling `hydrate`
+ // because hydration deliberately changes fetchStatus and may resolve a
+ // streamed promise synchronously.
+ const snapshotClient = React.useMemo(() => {
+ if (!state || typeof state !== 'object') {
+ return undefined
+ }
+
+ const queryCache = new QueryCache()
+ const clientOptions = client.getDefaultOptions().hydrate
+ const boundaryOptions = options?.defaultOptions
+ const frozenClient = new QueryClient({ queryCache })
+ const deserializeData =
+ boundaryOptions?.deserializeData ?? clientOptions?.deserializeData
+
+ // State is supplied from the outside, so handle an invalid shape
+ // gracefully just like the live-cache hydration below.
+ // eslint-disable-next-line @typescript-eslint/no-unnecessary-condition
+ const queries = state.queries || []
+
+ queries.forEach(
+ ({ queryKey, queryHash, state: queryState, meta, queryType }) => {
+ const data =
+ queryState.data === undefined || !deserializeData
+ ? queryState.data
+ : deserializeData(queryState.data)
+
+ queryCache.build(
+ frozenClient,
+ {
+ ...clientOptions?.queries,
+ ...boundaryOptions?.queries,
+ queryKey,
+ queryHash,
+ meta,
+ _type: queryType,
+ },
+ // Copy the state so the caller's dehydrated state remains untouched.
+ { ...queryState, data },
+ )
+ },
+ )
+
+ return frozenClient
+ }, [client, options, state])
+
// This useMemo is for performance reasons only, everything inside it must
// be safe to run in every render and code here should be read as "in render".
//
@@ -167,5 +222,9 @@ export const HydrationBoundary = ({
}
}, [client, hydrationQueue])
- return children as React.ReactElement
+ return (
+
+ {children}
+
+ )
}
diff --git a/packages/react-query/src/__tests__/ssr-hydration.test.tsx b/packages/react-query/src/__tests__/ssr-hydration.test.tsx
index 6299a9560b..57501015ca 100644
--- a/packages/react-query/src/__tests__/ssr-hydration.test.tsx
+++ b/packages/react-query/src/__tests__/ssr-hydration.test.tsx
@@ -4,6 +4,7 @@ import { act } from 'react'
import * as ReactDOMServer from 'react-dom/server'
import { queryKey } from '@tanstack/query-test-utils'
import {
+ HydrationBoundary,
QueryCache,
QueryClient,
QueryClientProvider,
@@ -14,10 +15,14 @@ import {
} from '..'
import { setIsServer } from './utils'
-const ReactHydrate = (element: React.ReactElement, container: Element) => {
+const ReactHydrate = (
+ element: React.ReactElement,
+ container: Element,
+ options?: { onRecoverableError?: (error: unknown) => void },
+) => {
let root: any
act(() => {
- root = hydrateRoot(container, element)
+ root = hydrateRoot(container, element, options)
})
return () => {
root.unmount()
@@ -275,4 +280,364 @@ describe('Server side rendering with de/rehydration', () => {
queryClient.clear()
consoleMock.mockRestore()
})
+
+ it('should not mismatch when a pending prefetched query resolves before client hydration', async () => {
+ const key = queryKey()
+
+ let resolveQuery!: (value: string) => void
+ const pendingQuery = new Promise((resolve) => {
+ resolveQuery = resolve
+ })
+ const queryFn = vi.fn(() => pendingQuery)
+ const renderedStates: Array = []
+
+ // -- Shared part --
+ function SuccessComponent() {
+ const result = useQuery({
+ queryKey: key,
+ queryFn,
+ })
+ const rendered = `SuccessComponent - status:${result.status} fetching:${result.isFetching} data:${result.data}`
+ renderedStates.push(rendered)
+ return rendered
+ }
+
+ // -- Server part --
+ setIsServer(true)
+
+ const prefetchClient = new QueryClient()
+ // Prefetch without awaiting: the query is dehydrated while still pending.
+ prefetchClient.prefetchQuery({ queryKey: key, queryFn }).catch(noop)
+ // Let the retryer start so the pending promise is part of the dehydrated state.
+ await vi.advanceTimersByTimeAsync(1)
+ const dehydratedStateServer = dehydrate(prefetchClient, {
+ shouldDehydrateQuery: () => true,
+ })
+ expect(dehydratedStateServer.queries[0]?.promise).toBeDefined()
+
+ const renderCache = new QueryCache()
+ const renderClient = new QueryClient({ queryCache: renderCache })
+ hydrate(renderClient, dehydratedStateServer)
+ const markup = ReactDOMServer.renderToString(
+
+
+
+
+ ,
+ )
+ renderClient.clear()
+ setIsServer(false)
+
+ const expectedMarkup =
+ 'SuccessComponent - status:pending fetching:true data:undefined'
+
+ expect(markup).toBe(expectedMarkup)
+
+ // -- Client part --
+ renderedStates.length = 0
+ const el = document.createElement('div')
+ el.innerHTML = markup
+
+ const queryCache = new QueryCache()
+ const queryClient = new QueryClient({ queryCache })
+ // Pass the dehydrated promise through (not JSON-serializable) to mimic a
+ // framework streaming the pending query's promise to the browser.
+ hydrate(queryClient, dehydratedStateServer)
+
+ // The streamed promise resolves before React hydrates, so the live cache is
+ // already successful while the server markup shows the pending state.
+ resolveQuery('success!')
+ await vi.advanceTimersByTimeAsync(1)
+
+ expect(queryClient.getQueryData(key)).toBe('success!')
+
+ const onRecoverableError = vi.fn()
+ const unmount = ReactHydrate(
+
+
+
+
+ ,
+ el,
+ { onRecoverableError },
+ )
+
+ // Hydration must not report a mismatch, and the first client render must
+ // replay the frozen server snapshot (pending) even though the live cache is
+ // already successful.
+ expect(onRecoverableError).toHaveBeenCalledTimes(0)
+ expect(renderedStates[0]).toBe(expectedMarkup)
+
+ await vi.advanceTimersByTimeAsync(50)
+ expect(renderedStates.at(-1)).toBe(
+ 'SuccessComponent - status:success fetching:false data:success!',
+ )
+ expect(el.innerHTML).toBe(
+ 'SuccessComponent - status:success fetching:false data:success!',
+ )
+
+ unmount()
+ queryClient.clear()
+ })
+
+ it('should not mismatch when a query above the boundary creates an empty cache entry', async () => {
+ const key = queryKey()
+ const queryFn = () => new Promise(noop)
+ const renderedStates: Array = []
+
+ function Page() {
+ const result = useQuery({ queryKey: key, queryFn })
+ const rendered = `${result.status}:${result.data}`
+ renderedStates.push(rendered)
+ return {rendered}
+ }
+
+ function App({
+ client,
+ state,
+ }: {
+ client: QueryClient
+ state: ReturnType
+ }) {
+ return (
+
+
+
+
+
+
+ )
+ }
+
+ const prefetchClient = new QueryClient()
+ prefetchClient.setQueryData(key, 'server data')
+ const dehydrated = dehydrate(prefetchClient)
+
+ setIsServer(true)
+ const renderClient = new QueryClient()
+ const markup = ReactDOMServer.renderToString(
+ ,
+ )
+ setIsServer(false)
+
+ expect(markup).toBe(
+ 'pending:undefined
success:server data
',
+ )
+
+ renderedStates.length = 0
+ const el = document.createElement('div')
+ el.innerHTML = markup
+ const queryClient = new QueryClient()
+ const onRecoverableError = vi.fn()
+ const unmount = ReactHydrate(
+ ,
+ el,
+ { onRecoverableError },
+ )
+
+ expect(onRecoverableError).toHaveBeenCalledTimes(0)
+ expect(renderedStates.slice(0, 2)).toEqual([
+ 'pending:undefined',
+ 'success:server data',
+ ])
+
+ await vi.advanceTimersByTimeAsync(1)
+ expect(Array.from(el.children, (child) => child.textContent)).toEqual([
+ 'success:server data',
+ 'success:server data',
+ ])
+
+ unmount()
+ queryClient.clear()
+ renderClient.clear()
+ prefetchClient.clear()
+ })
+
+ it.each([
+ ['HydrationBoundary options', 'boundary'],
+ ['client defaults', 'defaults'],
+ ] as const)(
+ 'should deserialize the server snapshot with %s',
+ async (_source, optionSource) => {
+ const key = queryKey()
+ const date = new Date('2024-01-01T00:00:00.000Z')
+ const deserializeData = (data: unknown) => new Date(data as string)
+ const hydrationOptions = {
+ defaultOptions: { deserializeData },
+ }
+
+ function DateComponent() {
+ const { data } = useQuery({
+ queryKey: key,
+ queryFn: () => Promise.resolve(date),
+ staleTime: Infinity,
+ })
+ return data instanceof Date ? data.toISOString() : `serialized:${data}`
+ }
+
+ setIsServer(true)
+ const prefetchClient = new QueryClient()
+ await prefetchClient.prefetchQuery({
+ queryKey: key,
+ queryFn: () => Promise.resolve(date),
+ })
+ const dehydrated = JSON.parse(
+ JSON.stringify(
+ dehydrate(prefetchClient, {
+ serializeData: (data) => (data as Date).toISOString(),
+ }),
+ ),
+ )
+
+ const renderClient = new QueryClient()
+ hydrate(renderClient, dehydrated, hydrationOptions)
+ const markup = ReactDOMServer.renderToString(
+
+
+
+
+ ,
+ )
+ renderClient.clear()
+ setIsServer(false)
+
+ const queryClient = new QueryClient({
+ defaultOptions: {
+ hydrate: {
+ deserializeData:
+ optionSource === 'defaults'
+ ? deserializeData
+ : (data) => `default:${data}`,
+ },
+ },
+ })
+ if (optionSource === 'defaults') {
+ hydrate(queryClient, dehydrated)
+ }
+
+ const el = document.createElement('div')
+ el.innerHTML = markup
+ const onRecoverableError = vi.fn()
+ const unmount = ReactHydrate(
+
+
+
+
+ ,
+ el,
+ { onRecoverableError },
+ )
+
+ expect(markup).toBe(date.toISOString())
+ expect(onRecoverableError).toHaveBeenCalledTimes(0)
+ expect(el.innerHTML).toBe(date.toISOString())
+
+ unmount()
+ queryClient.clear()
+ prefetchClient.clear()
+ },
+ )
+
+ // Adapted from the reproduction in
+ // https://github.com/TanStack/query/issues/9399#issuecomment-4323008704 — the streamed promise
+ // is simulated with a synchronously-resolvable thenable, so the client's `hydrate` resolves it
+ // via `tryResolveSync` during the first render.
+ it('should not mismatch on a query whose streamed promise is synchronously resolved by hydrate', async () => {
+ const key = queryKey()
+ const renderedStates: Array = []
+
+ function SuccessComponent() {
+ const result = useQuery({
+ queryKey: key,
+ queryFn: () => Promise.resolve('success!'),
+ })
+ const rendered = `SuccessComponent - status:${result.status} fetching:${result.isFetching} data:${result.data}`
+ renderedStates.push(rendered)
+ return rendered
+ }
+
+ // -- Server --
+ setIsServer(true)
+ const prefetchClient = new QueryClient({
+ defaultOptions: { dehydrate: { shouldDehydrateQuery: () => true } },
+ })
+ let resolvePrefetch: ((value: string) => void) | undefined
+ const prefetchPromise = new Promise((resolve) => {
+ resolvePrefetch = resolve
+ })
+ void prefetchClient.prefetchQuery({
+ queryKey: key,
+ queryFn: () => prefetchPromise,
+ })
+
+ const dehydrated = dehydrate(prefetchClient)
+ expect(dehydrated.queries[0]?.state.status).toBe('pending')
+
+ const renderClient = new QueryClient()
+ hydrate(renderClient, dehydrated)
+ const markup = ReactDOMServer.renderToString(
+
+
+
+
+ ,
+ )
+ renderClient.clear()
+ setIsServer(false)
+
+ const expectedMarkup =
+ 'SuccessComponent - status:pending fetching:true data:undefined'
+ expect(markup).toBe(expectedMarkup)
+
+ // The promise resolves *between* SSR and client hydration (streamed value arrives).
+ resolvePrefetch?.('success!')
+ const promiseRef = dehydrated.queries[0]?.promise
+ if (promiseRef) {
+ // Synchronously-resolvable thenable, mirroring a streamed React promise.
+ // @ts-expect-error deliberately replacing the native `then` so it resolves synchronously
+ promiseRef.then = (cb?: (value: unknown) => unknown) => {
+ cb?.('success!')
+ return promiseRef
+ }
+ }
+
+ // -- Client --
+ renderedStates.length = 0
+ const el = document.createElement('div')
+ el.innerHTML = markup
+ const queryClient = new QueryClient()
+ hydrate(queryClient, dehydrated)
+
+ expect(queryClient.getQueryData(key)).toBe('success!')
+
+ const onRecoverableError = vi.fn()
+ const unmount = ReactHydrate(
+
+
+
+
+ ,
+ el,
+ { onRecoverableError },
+ )
+
+ // No mismatch, and the first client render replays the pending server snapshot even though
+ // `hydrate` resolved the streamed promise synchronously.
+ expect(onRecoverableError).toHaveBeenCalledTimes(0)
+ expect(renderedStates[0]).toBe(expectedMarkup)
+
+ await vi.advanceTimersByTimeAsync(50)
+ expect(renderedStates.at(-1)).toBe(
+ 'SuccessComponent - status:success fetching:false data:success!',
+ )
+ expect(el.innerHTML).toBe(
+ 'SuccessComponent - status:success fetching:false data:success!',
+ )
+
+ unmount()
+ queryClient.clear()
+ })
})
diff --git a/packages/react-query/src/useBaseQuery.ts b/packages/react-query/src/useBaseQuery.ts
index 30beffc14b..2e1d865298 100644
--- a/packages/react-query/src/useBaseQuery.ts
+++ b/packages/react-query/src/useBaseQuery.ts
@@ -3,6 +3,7 @@ import * as React from 'react'
import { noop, notifyManager } from '@tanstack/query-core'
import { useQueryClient } from './QueryClientProvider'
+import { QueryServerSnapshotContext } from './HydrationBoundary'
import { useQueryErrorResetBoundary } from './QueryErrorResetBoundary'
import {
ensurePreventErrorBoundaryRetry,
@@ -92,10 +93,45 @@ export function useBaseQuery<
)
// note: this must be called before useSyncExternalStore
- const result = observer.getOptimisticResult(defaultedOptions)
+ // The return value is intentionally discarded: the call's purpose is the side effect of priming
+ // the observer's `#currentResult`, which `getSnapshot` (`observer.getCurrentResult()`) reads. The
+ // rendered value comes from the `useSyncExternalStore` result below.
+ observer.getOptimisticResult(defaultedOptions)
+
+ // Result to replay while React is hydrating, matching what the server rendered. Built once on a
+ // throwaway client so the live cache (which may already have advanced) is untouched. Computed in
+ // a lazy initializer rather than a memo because `defaultedOptions` is intentionally mutated above.
+ const snapshotClient = React.useContext(QueryServerSnapshotContext)
+ const [serverSnapshotResult] = React.useState<
+ QueryObserverResult | undefined
+ >(() => {
+ if (!snapshotClient) {
+ return undefined
+ }
+
+ const snapshotQuery = snapshotClient
+ .getQueryCache()
+ .get(defaultedOptions.queryHash)
+
+ if (!snapshotQuery) {
+ return undefined
+ }
+
+ const snapshotObserver = new Observer(snapshotClient, defaultedOptions)
+ const snapshotResult =
+ snapshotObserver.getOptimisticResult(defaultedOptions)
+ snapshotObserver.destroy()
+
+ return snapshotResult
+ })
+
+ const serverSnapshot = React.useCallback(
+ () => serverSnapshotResult ?? observer.getCurrentResult(),
+ [serverSnapshotResult, observer],
+ )
const shouldSubscribe = !isRestoring && subscribed
- React.useSyncExternalStore(
+ const resultToRender = React.useSyncExternalStore(
React.useCallback(
(onStoreChange) => {
const unsubscribe = shouldSubscribe
@@ -111,7 +147,7 @@ export function useBaseQuery<
[observer, shouldSubscribe],
),
() => observer.getCurrentResult(),
- () => observer.getCurrentResult(),
+ serverSnapshot,
)
React.useEffect(() => {
@@ -119,25 +155,25 @@ export function useBaseQuery<
}, [defaultedOptions, observer])
// Handle suspense
- if (shouldSuspend(defaultedOptions, result)) {
+ if (shouldSuspend(defaultedOptions, resultToRender)) {
throw fetchOptimistic(defaultedOptions, observer, errorResetBoundary)
}
// Handle error boundary
if (
getHasError({
- result,
+ result: resultToRender,
errorResetBoundary,
throwOnError: defaultedOptions.throwOnError,
query,
suspense: defaultedOptions.suspense,
})
) {
- throw result.error
+ throw resultToRender.error
}
// Handle result property usage tracking
return !defaultedOptions.notifyOnChangeProps
- ? observer.trackResult(result)
- : result
+ ? observer.trackResult(resultToRender)
+ : resultToRender
}