Skip to content

Commit 9f5fba3

Browse files
EphemTkDodo
andauthored
fix(core): fix promise hydration bugs (#9157)
* test(hydration): implement missing hydration tests * Do not hydrate older promises * Do not infinite loop on hydrating failed promises * Hydrate already resolved promises immediately * fix(hydration): fix promise hydration bugs * Include dehydratedAt and ignore hydrating old promises * This is an approximation of dataUpdatedAt, but is not perfect * Refactor checks for if we should hydrate promises or not to fix infinite loop bug on failed queries * Hydrate already resolved promises synchronously * call query.fetch even if sync data was available * use noop function in tryResolveSync .catch --------- Co-authored-by: Dominik Dorfmeister <[email protected]>
1 parent 09a55bf commit 9f5fba3

File tree

5 files changed

+307
-69
lines changed

5 files changed

+307
-69
lines changed

packages/query-core/src/__tests__/hydration.test.tsx

Lines changed: 122 additions & 11 deletions
Original file line numberDiff line numberDiff line change
@@ -473,10 +473,12 @@ describe('dehydration and rehydration', () => {
473473
const serverAddTodo = vi
474474
.fn()
475475
.mockImplementation(() => Promise.reject(new Error('offline')))
476-
const serverOnMutate = vi.fn().mockImplementation((variables) => {
477-
const optimisticTodo = { id: 1, text: variables.text }
478-
return { optimisticTodo }
479-
})
476+
const serverOnMutate = vi
477+
.fn()
478+
.mockImplementation((variables: { text: string }) => {
479+
const optimisticTodo = { id: 1, text: variables.text }
480+
return { optimisticTodo }
481+
})
480482
const serverOnSuccess = vi.fn()
481483

482484
const serverClient = new QueryClient()
@@ -511,13 +513,17 @@ describe('dehydration and rehydration', () => {
511513
const parsed = JSON.parse(stringified)
512514
const client = new QueryClient()
513515

514-
const clientAddTodo = vi.fn().mockImplementation((variables) => {
515-
return { id: 2, text: variables.text }
516-
})
517-
const clientOnMutate = vi.fn().mockImplementation((variables) => {
518-
const optimisticTodo = { id: 1, text: variables.text }
519-
return { optimisticTodo }
520-
})
516+
const clientAddTodo = vi
517+
.fn()
518+
.mockImplementation((variables: { text: string }) => {
519+
return { id: 2, text: variables.text }
520+
})
521+
const clientOnMutate = vi
522+
.fn()
523+
.mockImplementation((variables: { text: string }) => {
524+
const optimisticTodo = { id: 1, text: variables.text }
525+
return { optimisticTodo }
526+
})
521527
const clientOnSuccess = vi.fn()
522528

523529
client.setMutationDefaults(['addTodo'], {
@@ -1116,6 +1122,60 @@ describe('dehydration and rehydration', () => {
11161122
serverQueryClient.clear()
11171123
})
11181124

1125+
test('should not overwrite query in cache if existing query is newer (with promise)', async () => {
1126+
// --- server ---
1127+
1128+
const serverQueryClient = new QueryClient({
1129+
defaultOptions: {
1130+
dehydrate: {
1131+
shouldDehydrateQuery: () => true,
1132+
},
1133+
},
1134+
})
1135+
1136+
const promise = serverQueryClient.prefetchQuery({
1137+
queryKey: ['data'],
1138+
queryFn: async () => {
1139+
await sleep(10)
1140+
return 'server data'
1141+
},
1142+
})
1143+
1144+
const dehydrated = dehydrate(serverQueryClient)
1145+
1146+
await vi.advanceTimersByTimeAsync(10)
1147+
await promise
1148+
1149+
// Pretend the output of this server part is cached for a long time
1150+
1151+
// --- client ---
1152+
1153+
await vi.advanceTimersByTimeAsync(10_000) // Arbitrary time in the future
1154+
1155+
const clientQueryClient = new QueryClient()
1156+
1157+
clientQueryClient.setQueryData(['data'], 'newer data', {
1158+
updatedAt: Date.now(),
1159+
})
1160+
1161+
hydrate(clientQueryClient, dehydrated)
1162+
1163+
// If the query was hydrated in error, it would still take some time for it
1164+
// to end up in the cache, so for the test to fail properly on regressions,
1165+
// wait for the fetchStatus to be idle
1166+
await vi.waitFor(() =>
1167+
expect(clientQueryClient.getQueryState(['data'])?.fetchStatus).toBe(
1168+
'idle',
1169+
),
1170+
)
1171+
await vi.waitFor(() =>
1172+
expect(clientQueryClient.getQueryData(['data'])).toBe('newer data'),
1173+
)
1174+
1175+
clientQueryClient.clear()
1176+
serverQueryClient.clear()
1177+
})
1178+
11191179
test('should overwrite data when a new promise is streamed in', async () => {
11201180
const serializeDataMock = vi.fn((data: any) => data)
11211181
const deserializeDataMock = vi.fn((data: any) => data)
@@ -1291,4 +1351,55 @@ describe('dehydration and rehydration', () => {
12911351
process.env.NODE_ENV = originalNodeEnv
12921352
consoleMock.mockRestore()
12931353
})
1354+
1355+
// When React hydrates promises across RSC/client boundaries, it passes
1356+
// them as special ReactPromise types. There are situations where the
1357+
// promise might have time to resolve before we end up hydrating it, in
1358+
// which case React will have made it a special synchronous thenable where
1359+
// .then() resolves immediately.
1360+
// In these cases it's important we hydrate the data synchronously, or else
1361+
// the data in the cache wont match the content that was rendered on the server.
1362+
// What can end up happening otherwise is that the content is visible from the
1363+
// server, but the client renders a Suspense fallback, only to immediately show
1364+
// the data again.
1365+
test('should rehydrate synchronous thenable immediately', async () => {
1366+
// --- server ---
1367+
1368+
const serverQueryClient = new QueryClient({
1369+
defaultOptions: {
1370+
dehydrate: {
1371+
shouldDehydrateQuery: () => true,
1372+
},
1373+
},
1374+
})
1375+
const originalPromise = serverQueryClient.prefetchQuery({
1376+
queryKey: ['data'],
1377+
queryFn: () => null,
1378+
})
1379+
1380+
const dehydrated = dehydrate(serverQueryClient)
1381+
1382+
// --- server end ---
1383+
1384+
// Simulate a synchronous thenable
1385+
// @ts-expect-error
1386+
dehydrated.queries[0].promise.then = (cb) => {
1387+
cb?.('server data')
1388+
}
1389+
1390+
// --- client ---
1391+
1392+
const clientQueryClient = new QueryClient()
1393+
hydrate(clientQueryClient, dehydrated)
1394+
1395+
// If data is already resolved, it should end up in the cache immediately
1396+
expect(clientQueryClient.getQueryData(['data'])).toBe('server data')
1397+
1398+
// Need to await the original promise or else it will get a cancellation
1399+
// error and test will fail
1400+
await originalPromise
1401+
1402+
clientQueryClient.clear()
1403+
serverQueryClient.clear()
1404+
})
12941405
})

packages/query-core/src/hydration.ts

Lines changed: 73 additions & 46 deletions
Original file line numberDiff line numberDiff line change
@@ -1,3 +1,4 @@
1+
import { tryResolveSync } from './thenable'
12
import type {
23
DefaultError,
34
MutationKey,
@@ -46,6 +47,10 @@ interface DehydratedQuery {
4647
state: QueryState
4748
promise?: Promise<unknown>
4849
meta?: QueryMeta
50+
// This is only optional because older versions of Query might have dehydrated
51+
// without it which we need to handle for backwards compatibility.
52+
// This should be changed to required in the future.
53+
dehydratedAt?: number
4954
}
5055

5156
export interface DehydratedState {
@@ -74,6 +79,7 @@ function dehydrateQuery(
7479
shouldRedactErrors: (error: unknown) => boolean,
7580
): DehydratedQuery {
7681
return {
82+
dehydratedAt: Date.now(),
7783
state: {
7884
...query.state,
7985
...(query.state.data !== undefined && {
@@ -189,52 +195,73 @@ export function hydrate(
189195
)
190196
})
191197

192-
queries.forEach(({ queryKey, state, queryHash, meta, promise }) => {
193-
let query = queryCache.get(queryHash)
194-
195-
const data =
196-
state.data === undefined ? state.data : deserializeData(state.data)
197-
198-
// Do not hydrate if an existing query exists with newer data
199-
if (query) {
200-
if (query.state.dataUpdatedAt < state.dataUpdatedAt) {
201-
// omit fetchStatus from dehydrated state
202-
// so that query stays in its current fetchStatus
203-
const { fetchStatus: _ignored, ...serializedState } = state
204-
query.setState({
205-
...serializedState,
206-
data,
198+
queries.forEach(
199+
({ queryKey, state, queryHash, meta, promise, dehydratedAt }) => {
200+
const syncData = promise ? tryResolveSync(promise) : undefined
201+
const rawData = state.data === undefined ? syncData?.data : state.data
202+
const data = rawData === undefined ? rawData : deserializeData(rawData)
203+
204+
let query = queryCache.get(queryHash)
205+
const existingQueryIsPending = query?.state.status === 'pending'
206+
207+
// Do not hydrate if an existing query exists with newer data
208+
if (query) {
209+
const hasNewerSyncData =
210+
syncData &&
211+
// We only need this undefined check to handle older dehydration
212+
// payloads that might not have dehydratedAt
213+
dehydratedAt !== undefined &&
214+
dehydratedAt > query.state.dataUpdatedAt
215+
if (
216+
state.dataUpdatedAt > query.state.dataUpdatedAt ||
217+
hasNewerSyncData
218+
) {
219+
// omit fetchStatus from dehydrated state
220+
// so that query stays in its current fetchStatus
221+
const { fetchStatus: _ignored, ...serializedState } = state
222+
query.setState({
223+
...serializedState,
224+
data,
225+
})
226+
}
227+
} else {
228+
// Restore query
229+
query = queryCache.build(
230+
client,
231+
{
232+
...client.getDefaultOptions().hydrate?.queries,
233+
...options?.defaultOptions?.queries,
234+
queryKey,
235+
queryHash,
236+
meta,
237+
},
238+
// Reset fetch status to idle to avoid
239+
// query being stuck in fetching state upon hydration
240+
{
241+
...state,
242+
data,
243+
fetchStatus: 'idle',
244+
status: data !== undefined ? 'success' : state.status,
245+
},
246+
)
247+
}
248+
249+
if (
250+
promise &&
251+
!existingQueryIsPending &&
252+
// Only hydrate if dehydration is newer than any existing data,
253+
// this is always true for new queries
254+
(dehydratedAt === undefined || dehydratedAt > query.state.dataUpdatedAt)
255+
) {
256+
// This doesn't actually fetch - it just creates a retryer
257+
// which will re-use the passed `initialPromise`
258+
// Note that we need to call these even when data was synchronously
259+
// available, as we still need to set up the retryer
260+
void query.fetch(undefined, {
261+
// RSC transformed promises are not thenable
262+
initialPromise: Promise.resolve(promise).then(deserializeData),
207263
})
208264
}
209-
} else {
210-
// Restore query
211-
query = queryCache.build(
212-
client,
213-
{
214-
...client.getDefaultOptions().hydrate?.queries,
215-
...options?.defaultOptions?.queries,
216-
queryKey,
217-
queryHash,
218-
meta,
219-
},
220-
// Reset fetch status to idle to avoid
221-
// query being stuck in fetching state upon hydration
222-
{
223-
...state,
224-
data,
225-
fetchStatus: 'idle',
226-
},
227-
)
228-
}
229-
230-
if (promise) {
231-
// Note: `Promise.resolve` required cause
232-
// RSC transformed promises are not thenable
233-
const initialPromise = Promise.resolve(promise).then(deserializeData)
234-
235-
// this doesn't actually fetch - it just creates a retryer
236-
// which will re-use the passed `initialPromise`
237-
void query.fetch(undefined, { initialPromise })
238-
}
239-
})
265+
},
266+
)
240267
}

packages/query-core/src/thenable.ts

Lines changed: 29 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -7,6 +7,8 @@
77
* @see https://github.com/facebook/react/blob/4f604941569d2e8947ce1460a0b2997e835f37b9/packages/react-debug-tools/src/ReactDebugHooks.js#L224-L227
88
*/
99

10+
import { noop } from './utils'
11+
1012
interface Fulfilled<T> {
1113
status: 'fulfilled'
1214
value: T
@@ -80,3 +82,30 @@ export function pendingThenable<T>(): PendingThenable<T> {
8082

8183
return thenable
8284
}
85+
86+
/**
87+
* This function takes a Promise-like input and detects whether the data
88+
* is synchronously available or not.
89+
*
90+
* It does not inspect .status, .value or .reason properties of the promise,
91+
* as those are not always available, and the .status of React's promises
92+
* should not be considered part of the public API.
93+
*/
94+
export function tryResolveSync(promise: Promise<unknown> | Thenable<unknown>) {
95+
let data: unknown
96+
97+
promise
98+
.then((result) => {
99+
data = result
100+
return result
101+
})
102+
// .catch can be unavailable on certain kinds of thenable's
103+
// eslint-disable-next-line @typescript-eslint/no-unnecessary-condition
104+
?.catch(noop)
105+
106+
if (data !== undefined) {
107+
return { data }
108+
}
109+
110+
return undefined
111+
}

packages/react-query/src/HydrationBoundary.tsx

Lines changed: 7 additions & 12 deletions
Original file line numberDiff line numberDiff line change
@@ -24,13 +24,6 @@ export interface HydrationBoundaryProps {
2424
queryClient?: QueryClient
2525
}
2626

27-
const hasProperty = <TKey extends string>(
28-
obj: unknown,
29-
key: TKey,
30-
): obj is { [k in TKey]: unknown } => {
31-
return typeof obj === 'object' && obj !== null && key in obj
32-
}
33-
3427
export const HydrationBoundary = ({
3528
children,
3629
options = {},
@@ -45,7 +38,7 @@ export const HydrationBoundary = ({
4538
const optionsRef = React.useRef(options)
4639
optionsRef.current = options
4740

48-
// This useMemo is for performance reasons only, everything inside it _must_
41+
// This useMemo is for performance reasons only, everything inside it must
4942
// be safe to run in every render and code here should be read as "in render".
5043
//
5144
// This code needs to happen during the render phase, because after initial
@@ -80,10 +73,11 @@ export const HydrationBoundary = ({
8073
} else {
8174
const hydrationIsNewer =
8275
dehydratedQuery.state.dataUpdatedAt >
83-
existingQuery.state.dataUpdatedAt || // RSC special serialized then-able chunks
84-
(hasProperty(dehydratedQuery.promise, 'status') &&
85-
hasProperty(existingQuery.promise, 'status') &&
86-
dehydratedQuery.promise.status !== existingQuery.promise.status)
76+
existingQuery.state.dataUpdatedAt ||
77+
(dehydratedQuery.promise &&
78+
existingQuery.state.status !== 'pending' &&
79+
dehydratedQuery.dehydratedAt !== undefined &&
80+
dehydratedQuery.dehydratedAt > existingQuery.state.dataUpdatedAt)
8781

8882
const queryAlreadyQueued = hydrationQueue?.find(
8983
(query) => query.queryHash === dehydratedQuery.queryHash,
@@ -116,6 +110,7 @@ export const HydrationBoundary = ({
116110
React.useEffect(() => {
117111
if (hydrationQueue) {
118112
hydrate(client, { queries: hydrationQueue }, optionsRef.current)
113+
// eslint-disable-next-line @eslint-react/hooks-extra/no-direct-set-state-in-use-effect
119114
setHydrationQueue(undefined)
120115
}
121116
}, [client, hydrationQueue])

0 commit comments

Comments
 (0)