Skip to content

Navigation Menu

Sign in
Appearance settings

Search code, repositories, users, issues, pull requests...

Provide feedback

We read every piece of feedback, and take your input very seriously.

Saved searches

Use saved searches to filter your results more quickly

Sign up
Appearance settings

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
joseph0926 wants to merge 20 commits into TanStack:main
base: main
Choose a base branch
Loading
from joseph0926:fix/ssr-hydration-mismatch
Open
Show file tree
Hide file tree
Changes from all commits
Commits
Show all changes
20 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
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
298 changes: 298 additions & 0 deletions packages/query-core/src/__tests__/queriesObserver.test.tsx
View file Open in desktop
Original file line number Diff line number Diff line change
Expand Up @@ -394,4 +394,302 @@ describe('queriesObserver', () => {
{ status: 'success', data: 102 },
])
})

describe('SSR Hydration', () => {
describe('Hydration Mismatch Problem', () => {
test('should demonstrate state divergence between server snapshot and client result for hydrated queries', () => {
const key1 = queryKey()
const key2 = queryKey()

queryClient.setQueryData(key1, { amount: 10 })
queryClient.setQueryData(key2, { amount: 20 })

const query1 = queryClient.getQueryCache().find({ queryKey: key1 })
const query2 = queryClient.getQueryCache().find({ queryKey: key2 })

if (query1) {
query1.state.dataUpdatedAt = 0
query1.state.fetchStatus = 'idle'
}
if (query2) {
query2.state.dataUpdatedAt = 0
query2.state.fetchStatus = 'idle'
}

const observer = new QueriesObserver(queryClient, [
{ queryKey: key1, queryFn: () => ({ amount: 10 }) },
{ queryKey: key2, queryFn: () => ({ amount: 20 }) },
])

const clientResults = observer.getCurrentResult()
const serverResults = observer.getServerResult()

expect(serverResults[0]).toMatchObject({
status: 'pending',
data: undefined,
})
expect(serverResults[1]).toMatchObject({
status: 'pending',
data: undefined,
})

expect(clientResults[0]).toMatchObject({
status: 'success',
data: { amount: 10 },
isLoading: false,
isPending: false,
})
expect(clientResults[1]).toMatchObject({
status: 'success',
data: { amount: 20 },
isLoading: false,
isPending: false,
})
})
})

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

queryClient.setQueryData(key1, { amount: 10 })
queryClient.setQueryData(key2, { amount: 20 })

const query1 = queryClient.getQueryCache().find({ queryKey: key1 })
const query2 = queryClient.getQueryCache().find({ queryKey: key2 })

if (query1) {
query1.state.dataUpdatedAt = 0
query1.state.fetchStatus = 'idle'
}
if (query2) {
query2.state.dataUpdatedAt = 0
query2.state.fetchStatus = 'idle'
}

const observer = new QueriesObserver(queryClient, [
{ queryKey: key1, queryFn: () => ({ amount: 10 }) },
{ queryKey: key2, queryFn: () => ({ amount: 20 }) },
])

const clientResults = observer.getCurrentResult()
const serverResults = observer.getServerResult()

expect(clientResults[0]).toMatchObject({
status: 'success',
data: { amount: 10 },
isLoading: false,
})
expect(serverResults[0]).toMatchObject({
status: 'pending',
data: undefined,
isLoading: false,
isPending: true,
isSuccess: false,
})

expect(clientResults[1]).toMatchObject({
status: 'success',
data: { amount: 20 },
isLoading: false,
})
expect(serverResults[1]).toMatchObject({
status: 'pending',
data: undefined,
isLoading: false,
isPending: true,
isSuccess: false,
})
})

test('should handle mixed hydrated and non-hydrated queries', () => {
const key1 = queryKey()
const key2 = queryKey()

queryClient.setQueryData(key1, { amount: 10 })
queryClient.setQueryData(key2, { amount: 20 })

const query1 = queryClient.getQueryCache().find({ queryKey: key1 })
const query2 = queryClient.getQueryCache().find({ queryKey: key2 })

if (query1) {
query1.state.dataUpdatedAt = 0
query1.state.fetchStatus = 'idle'
}
if (query2) {
// Use a non-zero sentinel to indicate "non-hydrated"
query2.state.dataUpdatedAt = 1
query2.state.fetchStatus = 'idle'
}

const observer = new QueriesObserver(queryClient, [
{ queryKey: key1, queryFn: () => ({ amount: 10 }) },
{ queryKey: key2, queryFn: () => ({ amount: 20 }) },
])

const serverResults = observer.getServerResult()

expect(serverResults[0]).toMatchObject({
status: 'pending',
data: undefined,
isPending: true,
})

expect(serverResults[1]).toMatchObject({
status: 'success',
data: { amount: 20 },
isPending: false,
})
})

test('should handle fetching state during hydration for multiple queries', () => {
const key1 = queryKey()
const key2 = queryKey()

queryClient.setQueryData(key1, { amount: 10 })
queryClient.setQueryData(key2, { amount: 20 })

const query1 = queryClient.getQueryCache().find({ queryKey: key1 })
const query2 = queryClient.getQueryCache().find({ queryKey: key2 })

if (query1) {
query1.state.dataUpdatedAt = 0
query1.state.fetchStatus = 'fetching'
}
if (query2) {
query2.state.dataUpdatedAt = 0
query2.state.fetchStatus = 'idle'
}

const observer = new QueriesObserver(queryClient, [
{ queryKey: key1, queryFn: () => ({ amount: 10 }) },
{ queryKey: key2, queryFn: () => ({ amount: 20 }) },
])

const serverResults = observer.getServerResult()

expect(serverResults[0]).toMatchObject({
status: 'pending',
fetchStatus: 'fetching',
isLoading: true,
isFetching: true,
isPending: true,
})

expect(serverResults[1]).toMatchObject({
status: 'pending',
fetchStatus: 'idle',
isLoading: false,
isPending: true,
})
})

test('should handle combine function with server snapshots', () => {
const key1 = queryKey()
const key2 = queryKey()

queryClient.setQueryData(key1, { amount: 10 })
queryClient.setQueryData(key2, { amount: 20 })

const query1 = queryClient.getQueryCache().find({ queryKey: key1 })
const query2 = queryClient.getQueryCache().find({ queryKey: key2 })

if (query1) {
query1.state.dataUpdatedAt = 0
query1.state.fetchStatus = 'idle'
}
if (query2) {
query2.state.dataUpdatedAt = 0
query2.state.fetchStatus = 'idle'
}

const combineResults = vi.fn((results: Array<QueryObserverResult>) => ({
totalAmount: results.reduce(
(sum, r) => sum + ((r.data as any)?.amount ?? 0),
0,
),
allSuccess: results.every((r) => r.status === 'success'),
allPending: results.every((r) => r.status === 'pending'),
}))

const observer = new QueriesObserver(
queryClient,
[
{ queryKey: key1, queryFn: () => ({ amount: 10 }) },
{ queryKey: key2, queryFn: () => ({ amount: 20 }) },
],
{ combine: combineResults },
)

const clientResults = observer.getCurrentResult()
expect(clientResults).toHaveLength(2)
expect(clientResults[0]).toMatchObject({
status: 'success',
data: { amount: 10 },
})
expect(clientResults[1]).toMatchObject({
status: 'success',
data: { amount: 20 },
})

const serverResults = observer.getServerResult()
expect(serverResults).toHaveLength(2)
expect(serverResults[0]).toMatchObject({
status: 'pending',
data: undefined,
})
expect(serverResults[1]).toMatchObject({
status: 'pending',
data: undefined,
})

const [_, getCombined] = observer.getOptimisticResult(
[
{ queryKey: key1, queryFn: () => ({ amount: 10 }) },
{ queryKey: key2, queryFn: () => ({ amount: 20 }) },
],
combineResults,
)

const combined = getCombined(serverResults)
expect(combined).toEqual({
totalAmount: 0,
allSuccess: false,
allPending: true,
})
})

test('should handle combine with hydrated and missing queries', () => {
const key1 = queryKey()
const key2 = queryKey()

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

const observer = new QueriesObserver(
queryClient,
[
{ queryKey: key1, queryFn: () => ({ amount: 10 }) },
{ queryKey: key2, queryFn: () => ({ amount: 20 }) },
],
{
combine: (results) => ({
hasAllData: results.every((r) => r.data !== undefined),
loadedCount: results.filter((r) => r.isSuccess).length,
}),
},
)

const serverResults = observer.getServerResult()

expect(serverResults[0]).toMatchObject({ status: 'pending' })
expect(serverResults[1]).toMatchObject({ status: 'pending' })
})
})
})
})
Loading
Loading

AltStyle によって変換されたページ (->オリジナル) /