Skip to content

fix(react-query): resolve hydration mismatch in SSR with prefetched queries #9572

New issue

Have a question about this project? Sign up for a free GitHub account to open an issue and contact its maintainers and the community.

By clicking “Sign up for GitHub”, you agree to our terms of service and privacy statement. We’ll occasionally send you account related emails.

Already on GitHub? Sign in to your account

Open
wants to merge 8 commits into
base: main
Choose a base branch
from
280 changes: 280 additions & 0 deletions packages/query-core/src/__tests__/queryObserver.test.tsx
Original file line number Diff line number Diff line change
Expand Up @@ -1494,4 +1494,284 @@ describe('queryObserver', () => {
unsubscribe2()
})
})

describe('SSR Hydration', () => {
describe('Hydration Mismatch Problem', () => {
test('should demonstrate hydration mismatch issue (before fix)', () => {
const key = queryKey()

queryClient.setQueryData(key, { amount: 10 })
const cache = queryClient.getQueryCache().find({ queryKey: key })
if (cache) {
cache.state.dataUpdatedAt = 0
cache.state.fetchStatus = 'idle'
}

const observer = new QueryObserver(queryClient, {
queryKey: key,
queryFn: () => ({ amount: 10 }),
})

const clientResult = observer.getCurrentResult()

expect(clientResult).toMatchObject({
status: 'success',
data: { amount: 10 },
isLoading: false,
isPending: false,
})
})
})

describe('Solution with getServerResult', () => {
test('getServerResult should return pending state for hydrated data', () => {
const key = queryKey()

queryClient.setQueryData(key, { amount: 10 })
const cache = queryClient.getQueryCache().find({ queryKey: key })
if (cache) {
cache.state.dataUpdatedAt = 0
cache.state.fetchStatus = 'idle'
}

const observer = new QueryObserver(queryClient, {
queryKey: key,
queryFn: () => ({ amount: 10 }),
})

const clientResult = observer.getCurrentResult()
expect(clientResult).toMatchObject({
status: 'success',
data: { amount: 10 },
isLoading: false,
})

const serverResult = observer.getServerResult()
expect(serverResult).toMatchObject({
status: 'pending',
data: undefined,
isLoading: false,
isPending: true,
isSuccess: false,
})
})

test('should handle fetching state during hydration', () => {
const key = queryKey()

queryClient.setQueryData(key, { amount: 10 })
const cache = queryClient.getQueryCache().find({ queryKey: key })
if (cache) {
cache.state.dataUpdatedAt = 0
cache.state.fetchStatus = 'fetching'
}

const observer = new QueryObserver(queryClient, {
queryKey: key,
queryFn: () => ({ amount: 10 }),
})

const serverResult = observer.getServerResult()
expect(serverResult).toMatchObject({
status: 'pending',
fetchStatus: 'fetching',
isLoading: true,
isPending: true,
})
})

test('should return normal result for non-hydrated data', () => {
const key = queryKey()

queryClient.setQueryData(key, { amount: 10 })
const cache = queryClient.getQueryCache().find({ queryKey: key })
if (cache) {
cache.state.dataUpdatedAt = Date.now()
}
Comment on lines +1589 to +1590
Copy link

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

💡 Verification agent

🧩 Analysis chain

Use a non-zero constant (or set system time) to avoid flakiness with fake timers

Under fake timers, Date.now() may be 0 unless you set the system time. That would unintentionally mark this as “hydrated” (dataUpdatedAt === 0), causing getServerResult to mask the result and fail the “non-hydrated” expectations.

Minimal, deterministic fix:

-          cache.state.dataUpdatedAt = Date.now()
+          cache.setState({ dataUpdatedAt: 1 })

Alternatively, set the mocked clock first:

vi.setSystemTime(1)
cache.setState({ dataUpdatedAt: Date.now() })

🏁 Script executed:

#!/bin/bash
# Locate usages of dataUpdatedAt to see surrounding test logic
rg -n "dataUpdatedAt" -C3 packages/query-core/src/__tests__/queryObserver.test.tsx

# Find direct Date.now() calls in the test file
rg -n "Date.now" -C3 packages/query-core/src/__tests__/queryObserver.test.tsx

# Check for any mocked clock setup or system time manipulation
rg -n "setSystemTime" -C3 packages/query-core/src/__tests__/queryObserver.test.tsx

# Search for fake-timer setup (vi.useFakeTimers or jest.useFakeTimers)
rg -nP "useFakeTimers|fakeTimers" -C3 packages/query-core/src/__tests__/queryObserver.test.tsx

Length of output: 3741


Use a non-zero constant for dataUpdatedAt to avoid flakiness under fake timers

Under vi.useFakeTimers(), Date.now() returns 0 unless you explicitly advance the clock—making your “fresh” timestamp indistinguishable from the unhydrated state. Update the assignment in packages/query-core/src/__tests__/queryObserver.test.tsx to use a non-zero literal (or advance the fake clock first):

• Minimal, consistent with other direct state mutations:

- cache.state.dataUpdatedAt = Date.now()
+ cache.state.dataUpdatedAt = 1

• Alternative (advance the fake clock, then read):

vi.setSystemTime(1)
cache.state.dataUpdatedAt = Date.now()
🤖 Prompt for AI Agents
In packages/query-core/src/__tests__/queryObserver.test.tsx around lines
1589-1590, the test sets cache.state.dataUpdatedAt = Date.now() while using
vi.useFakeTimers(), which makes Date.now() return 0 and causes flakiness; change
the assignment to use a non-zero literal (e.g. set dataUpdatedAt to 1) or
advance the fake timers first by calling vi.setSystemTime(1) and then assign
Date.now() so the "fresh" timestamp is non-zero and consistent under fake
timers.


const observer = new QueryObserver(queryClient, {
queryKey: key,
queryFn: () => ({ amount: 10 }),
})

const clientResult = observer.getCurrentResult()
const serverResult = observer.getServerResult()

expect(serverResult.status).toBe(clientResult.status)
expect(serverResult.data).toBe(clientResult.data)
expect(serverResult.isLoading).toBe(clientResult.isLoading)
})

test('should handle error state correctly', () => {
const key = queryKey()
const error = new Error('fetch error')

queryClient.getQueryCache().build(
queryClient,
{
queryKey: key,
queryFn: () => Promise.reject(error),
},
{
status: 'error',
error,
data: undefined,
dataUpdatedAt: 0,
dataUpdateCount: 0,
errorUpdateCount: 1,
errorUpdatedAt: Date.now(),
fetchFailureCount: 1,
fetchFailureReason: error,
fetchMeta: null,
fetchStatus: 'idle',
isInvalidated: false,
},
)

const observer = new QueryObserver(queryClient, {
queryKey: key,
queryFn: () => Promise.reject(error),
})

const serverResult = observer.getServerResult()

expect(serverResult).toMatchObject({
status: 'error',
error,
isError: true,
})
})
})

describe('Integration with useSyncExternalStore', () => {
test('should provide different snapshots for server and client', () => {
const key = queryKey()

queryClient.setQueryData(key, 'hydrated')
const cache = queryClient.getQueryCache().find({ queryKey: key })
if (cache) {
cache.state.dataUpdatedAt = 0
}

const observer = new QueryObserver(queryClient, {
queryKey: key,
queryFn: () => 'data',
})

const getSnapshot = () => observer.getCurrentResult()
const getServerSnapshot = () => observer.getServerResult()

const clientSnapshot = getSnapshot()
const serverSnapshot = getServerSnapshot()

expect(clientSnapshot.status).toBe('success')
expect(serverSnapshot.status).toBe('pending')
})
})

describe('Future-proof status handling', () => {
test('should handle unknown status types gracefully', () => {
const key = queryKey()

queryClient.getQueryCache().build(
queryClient,
{ queryKey: key, queryFn: () => 'data' },
{
status: 'idle' as any, // Assuming that in future versions such as @tanstack/react-query v6, statuses other than “error,” “pending,” and “success” will be introduced.
data: { amount: 10 },
dataUpdatedAt: 0,
fetchStatus: 'idle',
dataUpdateCount: 1,
errorUpdateCount: 0,
errorUpdatedAt: 0,
error: null,
fetchFailureCount: 0,
fetchFailureReason: null,
fetchMeta: null,
isInvalidated: false,
},
)

const observer = new QueryObserver(queryClient, {
queryKey: key,
queryFn: () => ({ amount: 10 }),
})

const serverResult = observer.getServerResult()

expect(serverResult.status).toBe('idle')
// Indicates a possible problem where data exists even though the status is “idle.”
expect(serverResult.data).toEqual({ amount: 10 })
})
})

describe('Field consistency in edge cases', () => {
test('should handle isRefetching consistency', () => {
const key = queryKey()

queryClient.setQueryData(key, { amount: 10 })
const cache = queryClient.getQueryCache().find({ queryKey: key })
if (cache) {
cache.state.dataUpdatedAt = 0
cache.state.fetchStatus = 'fetching'
}

const observer = new QueryObserver(queryClient, {
queryKey: key,
queryFn: () => ({ amount: 10 }),
})

const clientResult = observer.getCurrentResult()
const serverResult = observer.getServerResult()

expect(clientResult.isRefetching).toBe(true)
expect(clientResult.isPending).toBe(false)

// Even if the client status is `isRefetching: true` and `isPending: false`, the masking of `isRefetching: false` and `isPending: true` in `getServerResult` must be maintained.
expect(serverResult.isRefetching).toBe(false)
expect(serverResult.isPending).toBe(true)
})
})

describe('Concurrency and race conditions', () => {
test('should handle state changes during hydration', async () => {
const key = queryKey()

queryClient.setQueryData(key, { amount: 10 })
const cache = queryClient.getQueryCache().find({ queryKey: key })
if (cache) {
cache.state.dataUpdatedAt = 0
cache.state.fetchStatus = 'idle'
}

const observer = new QueryObserver(queryClient, {
queryKey: key,
queryFn: async () => {
await sleep(10)
return { amount: 20 }
},
})

const initialServerResult = observer.getServerResult()
expect(initialServerResult.status).toBe('pending')

const refetchPromise = observer.refetch()
await Promise.resolve()

// Verify that hydration masking works correctly even during refetch.

const midServerResult = observer.getServerResult()
expect(midServerResult.status).toBe('pending')
expect(midServerResult.isLoading).toBe(true)

await vi.advanceTimersByTimeAsync(10)

const finalServerResult = observer.getServerResult()
expect(finalServerResult.status).toBe('success')
expect(finalServerResult.data).toEqual({ amount: 20 })

await refetchPromise
})
})
})
})
33 changes: 33 additions & 0 deletions packages/query-core/src/queryObserver.ts
Original file line number Diff line number Diff line change
Expand Up @@ -23,6 +23,7 @@ import type {
QueryKey,
QueryObserverBaseResult,
QueryObserverOptions,
QueryObserverPendingResult,
QueryObserverResult,
QueryOptions,
RefetchOptions,
Expand Down Expand Up @@ -259,6 +260,38 @@ export class QueryObserver<
return this.#currentResult
}

getServerResult():
| QueryObserverPendingResult<TData, TError>
| QueryObserverResult<TData, TError> {
const currentResult = this.#currentResult
const queryState = this.#currentQuery.state

if (
currentResult.status === 'success' &&
this.#currentQuery.state.dataUpdatedAt === 0
) {
const pendingResult: QueryObserverPendingResult<TData, TError> = {
...currentResult,
status: 'pending',
isPending: true,
isSuccess: false,
isError: false,
isLoading: queryState.fetchStatus === 'fetching',
isInitialLoading: queryState.fetchStatus === 'fetching',
data: undefined,
error: null,
isLoadingError: false,
isRefetchError: false,
isPlaceholderData: false,
isRefetching: false,
}

return pendingResult
}

return currentResult
}

trackResult(
result: QueryObserverResult<TData, TError>,
onPropTracked?: (key: keyof QueryObserverResult) => void,
Expand Down
2 changes: 1 addition & 1 deletion packages/react-query/src/useBaseQuery.ts
Original file line number Diff line number Diff line change
Expand Up @@ -109,7 +109,7 @@ export function useBaseQuery<
[observer, shouldSubscribe],
),
() => observer.getCurrentResult(),
() => observer.getCurrentResult(),
() => observer.getServerResult(),
)

React.useEffect(() => {
Expand Down
Loading