Skip to content
Open
Show file tree
Hide file tree
Changes from 15 commits
Commits
Show all changes
19 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
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
299 changes: 299 additions & 0 deletions packages/query-core/src/__tests__/queriesObserver.test.tsx
Original file line number Diff line number Diff line change
Expand Up @@ -347,4 +347,303 @@ describe('queriesObserver', () => {
expect(queryFn1).toHaveBeenCalledTimes(1)
expect(queryFn2).toHaveBeenCalledTimes(1)
})

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 mixed hydrated and non-hydrated 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