Skip to content
Closed
Show file tree
Hide file tree
Changes from 1 commit
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
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')
})
})
})
})
28 changes: 28 additions & 0 deletions packages/query-core/src/queryObserver.ts
Original file line number Diff line number Diff line change
Expand Up @@ -264,6 +264,34 @@ export class QueryObserver<
return this.#currentResult
}

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

if (
currentResult.status === 'success' &&
this.#currentQuery.state.dataUpdatedAt === 0
) {
const pendingResult: QueryObserverResult<TData, TError> = {
Copy link
Collaborator

Choose a reason for hiding this comment

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

should this be of type QueryObserverPendingResult to make sure we’re at least always in-sync with what this type guarantees ?

Copy link
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 pointing out the type > QueryObserverPendingResult, which I had overlooked.
I agree that applying this will improve type stability.
However, since currentResult must be of type QueryObserverResult, I handled it as a union.

ab2ddad

...currentResult,
status: 'pending',
isPending: true,
isSuccess: false,
isError: false,
isLoading: currentResult.fetchStatus === 'fetching',
isInitialLoading: currentResult.fetchStatus === 'fetching',
data: undefined,
error: null,
isLoadingError: false,
isRefetchError: false,
isPlaceholderData: false,
Copy link
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...

Copy link
Contributor Author

@joseph0926 joseph0926 Aug 17, 2025

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

} as QueryObserverResult<TData, TError>

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