Skip to content
Closed
Show file tree
Hide file tree
Changes from 7 commits
Commits
Show all changes
21 commits
Select commit Hold shift + click to select a range
29eb336
fix(react-query): resolve hydration mismatch in SSR with prefetched q…
joseph0926 Aug 17, 2025
ab2ddad
fix(react-query): use QueryObserverPendingResult type and clarify fie…
joseph0926 Aug 17, 2025
907def0
fix(react-query): refactor clarify field selection
joseph0926 Aug 17, 2025
184be02
fix(react-query): ensure complete pending state invariants in getServ…
joseph0926 Aug 17, 2025
5f14a66
fix(react-query): ensure complete pending state invariants in getServ…
joseph0926 Aug 17, 2025
cb50d25
fix(react-query): add isRefetching field
joseph0926 Aug 17, 2025
25fc95e
fix(react-query): use query state for fetchStatus in SSR hydration ma…
joseph0926 Aug 19, 2025
3429d75
Merge branch 'main' into fix/ssr-hydration-mismatch
joseph0926 Aug 20, 2025
c65c2f9
Merge branch 'main' into fix/ssr-hydration-mismatch
joseph0926 Aug 30, 2025
e69a205
Merge branch 'main' into fix/ssr-hydration-mismatch
joseph0926 Aug 31, 2025
7996780
Merge branch 'main' into fix/ssr-hydration-mismatch
joseph0926 Sep 1, 2025
af8b955
refactor(query-core): use explicit field definitions in getServerResu…
joseph0926 Sep 2, 2025
1bf795b
fix(react-query): add getServerResult support to useQueries hook
joseph0926 Sep 2, 2025
d2feecb
test(react-query): add a test for combine() behavior under SSR snapshots
joseph0926 Sep 2, 2025
261326b
test(react-query): improving tests for combine() behavior in SSR snap…
joseph0926 Sep 2, 2025
a0859fa
test(react-query): clarify test title
joseph0926 Sep 2, 2025
58065ac
Merge branch 'main' into fix/ssr-hydration-mismatch
joseph0926 Sep 3, 2025
d43b020
Merge branch 'main' into fix/ssr-hydration-mismatch
joseph0926 Sep 4, 2025
a8feb9e
Merge branch 'main' into fix/ssr-hydration-mismatch
joseph0926 Sep 4, 2025
e19ca01
Merge branch 'main' into fix/ssr-hydration-mismatch
joseph0926 Sep 6, 2025
253cb40
Merge branch 'main' into fix/ssr-hydration-mismatch
joseph0926 Sep 8, 2025
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
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
Contributor

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 @@ -264,6 +265,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