Skip to content
Closed
Show file tree
Hide file tree
Changes from 2 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
175 changes: 175 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,179 @@ 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
Copy Markdown
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')
})
})
})
})
26 changes: 26 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,31 @@ export class QueryObserver<
return this.#currentResult
}

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

const isHydratedData =
currentResult.status === 'success' &&
this.#currentQuery.state.dataUpdatedAt === 0

if (isHydratedData) {
return {
...currentResult,
status: 'pending' as const,
isPending: true,
isSuccess: false,
data: undefined,
isLoading: currentResult.fetchStatus === 'fetching',
isInitialLoading: currentResult.fetchStatus === 'fetching',
isPlaceholderData: false,
Comment on lines +276 to +296

Copy link
Copy Markdown
Collaborator

Choose a reason for hiding this comment

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

how did you come up with this list of fields to change? For example, why aren’t fields like isFetched or isStale being re-set?

I’m wondering how we can make sure to keep this list in-sync if we add new properties to QueryObserverResult...

@joseph0926 joseph0926 Aug 17, 2025 •

Copy link
Copy Markdown
Contributor Author

Choose a reason for hiding this comment

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

Thank you for your feedback!

In conclusion, the criteria for selecting the specified fields was “only change fields that directly affect SSR hydration inconsistencies.”

I believe that intentionally maintaining the currentResult value for the remaining fields will be beneficial during subsequent maintenance processes.

However, based on coderabbitai's feedback, I realized that I had overlooked isRefetching, so I added it.
Since the status is fixed as pending, I added it to explicitly state that refetching is not possible.

cb50d25

}
Comment on lines +274 to +301

Copy link
Copy Markdown
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

Verify tests assert the key pending invariants

Please confirm tests cover isRefetching === false, isFetched === false, and isFetchedAfterMount === false for hydrated snapshots (and optionally that failureCount/reset fields are as intended).

Run:


🏁 Script executed:

#!/bin/bash
# Assert server-snapshot tests exist
rg -n -C2 "getServerResult|server snapshot|SSR|hydration" packages/query-core/src/__tests__ -g '*.ts' -g '*.tsx'

# Check for invariants on hydrated server results
rg -n -C2 "isRefetching\W*toBe\((false|0)\)|isFetched\W*toBe\((false|0)\)|isFetchedAfterMount\W*toBe\((false|0)\)" \
  packages/query-core/src/__tests__ -g '*.ts' -g '*.tsx'

Length of output: 46044


Add missing invariant assertions in SSR hydration tests

  • In packages/query-core/src/__tests__/queryObserver.test.tsx under the “Solution with getServerResult” SSR Hydration suite, add:
    expect(serverResult.isFetched).toBe(false)
    expect(serverResult.isFetchedAfterMount).toBe(false)
  • (Optional) Also assert that failureCount, errorUpdatedAt, etc., reset to 0/null as intended.
🤖 Prompt for AI Agents
In packages/query-core/src/queryObserver.ts around lines 274 to 301, update the
SSR hydration tests (packages/query-core/src/__tests__/queryObserver.test.tsx
under the “Solution with getServerResult” suite) to include invariant assertions
that serverResult properties reset after hydration: add
expect(serverResult.isFetched).toBe(false) and
expect(serverResult.isFetchedAfterMount).toBe(false); optionally also assert
that numeric/timestamp and error fields are reset (failureCount === 0,
errorUpdatedAt === 0, error === null, failureReason === null) to ensure the
pendingResult shape matches the expected initial state.

}

return currentResult
}
Comment on lines +268 to +307

Copy link
Copy Markdown
Contributor

Choose a reason for hiding this comment

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

🛠️ Refactor suggestion

⚠️ Potential issue

Fix inconsistent flags for pending server snapshot (isRefetching, error flags, fetched flags).

When transforming a hydrated success result into a pending server snapshot, we spread currentResult and override a few fields. This leaves inconsistencies:

  • If fetchStatus === 'fetching', currentResult.isRefetching was true (because success + fetching), but a pending result must have isRefetching: false (as computed in createResult: isRefetching = isFetching && !isPending).
  • Pending semantics also guarantee isError: false, error: null, isLoadingError: false, isRefetchError: false, and it’s safer to mark isFetched/isFetchedAfterMount as false to avoid mixed semantics leaking from the success snapshot.

Recommend explicitly overriding these to match QueryObserverPendingResult invariants and make the branch type-safe.

Apply this diff inside getServerResult:

   if (isHydratedData) {
-    return {
-      ...currentResult,
-      status: 'pending' as const,
-      isPending: true,
-      isSuccess: false,
-      data: undefined,
-      isLoading: currentResult.fetchStatus === 'fetching',
-      isInitialLoading: currentResult.fetchStatus === 'fetching',
-      isPlaceholderData: false,
-    }
+    return {
+      ...currentResult,
+      status: 'pending' as const,
+      isPending: true,
+      isSuccess: false,
+      // pending invariants
+      isError: false,
+      error: null,
+      isLoadingError: false,
+      isRefetchError: false,
+      // data is not available for pending
+      data: undefined,
+      // compute loading flags from fetchStatus
+      isLoading: currentResult.fetchStatus === 'fetching',
+      isInitialLoading: currentResult.fetchStatus === 'fetching',
+      // pending cannot be "refetching"
+      isRefetching: false,
+      // do not report placeholder data
+      isPlaceholderData: false,
+      // avoid leaking fetched flags from the success snapshot
+      isFetched: false,
+      isFetchedAfterMount: false,
+    } as QueryObserverPendingResult<TData, TError>
   }
🤖 Prompt for AI Agents
In packages/query-core/src/queryObserver.ts around lines 268 to 291, when
converting a hydrated success result into a pending server snapshot we currently
spread currentResult but leave several flags inconsistent; explicitly override
the following fields to match QueryObserverPendingResult invariants: set
isRefetching to false, isError to false, error to null, isLoadingError to false,
isRefetchError to false, isFetched to false, and isFetchedAfterMount to false
(retain the existing overrides for
status/isPending/isSuccess/data/isLoading/isInitialLoading/isPlaceholderData).
Ensure these fields are explicitly assigned in the returned object so the
pending snapshot has consistent semantics and is type-safe.

💡 Verification agent

🧩 Analysis chain

Add test coverage for isRefetching and fetched flags in server snapshot.

To prevent regressions, assert in tests that:

  • getServerResult() for hydrated data sets isRefetching === false even if fetchStatus === 'fetching'.
  • isFetched === false and isFetchedAfterMount === false.

[offer_assistance]

If missing, I can provide a focused test case to assert these invariants.


🏁 Script executed:

#!/bin/bash
# Locate existing tests around getServerResult and hydration
rg -n -C3 "getServerResult|server snapshot|hydration" --type=ts --type=tsx

# Check if isRefetching/isFetched assertions exist
rg -n -C2 "isRefetching|isFetchedAfterMount|isFetched" packages/query-core/src/__tests__/queryObserver.test.tsx

Length of output: 218


🏁 Script executed:

#!/bin/bash
# Retry pattern search without relying on --type (tsx not recognized)
rg -n -C3 "getServerResult|server snapshot|hydration" -g '*.ts' -g '*.tsx'
rg -n -C2 "isRefetching|isFetchedAfterMount|isFetched" -g '*.ts' -g '*.tsx'

Length of output: 120039


Add missing assertions for isRefetching, isFetched, and isFetchedAfterMount in server snapshot tests

The existing hydration tests in packages/query-core/src/__tests__/queryObserver.test.tsx verify status, data, and fetchStatus, but don’t cover these derived flags. To prevent regressions, extend the tests under the Solution with getServerResult block:

• After the “should return pending state for hydrated data” test (around line 1530), add:

expect(serverResult.isFetched).toBe(false)
expect(serverResult.isFetchedAfterMount).toBe(false)
expect(serverResult.isRefetching).toBe(false)

• After the “should handle fetching state during hydration” test (around line 1575), also assert:

expect(serverResult.isFetched).toBe(false)
expect(serverResult.isFetchedAfterMount).toBe(false)
expect(serverResult.isRefetching).toBe(false)

This will ensure that hydrated snapshots never report fetched or refetching states on the server.

🤖 Prompt for AI Agents
In packages/query-core/src/queryObserver.ts around lines 268 to 291: the server
result produced for hydrated data is missing corresponding unit-test assertions;
update the tests in packages/query-core/src/__tests__/queryObserver.test.tsx
(after the "should return pending state for hydrated data" test near line ~1530
and after the "should handle fetching state during hydration" test near line
~1575) to add assertions that serverResult.isFetched is false,
serverResult.isFetchedAfterMount is false, and serverResult.isRefetching is
false so hydrated server snapshots never report fetched or refetching states.


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