Skip to content

Commit 3cba6d8

Browse files
EphemTkDodo
andauthored
Hydrate existing queries in a useEffect (#5989)
* feat(react-query): improve hydration timing BREAKING CHANGE: * hydrate queries that already exist in the cache in an effect instead of in render * HydrationBoundary no longer hydrates mutations * fix(react-query): remove mutations from HydrationBoundary options type --------- Co-authored-by: Dominik Dorfmeister <[email protected]>
1 parent 2d41d98 commit 3cba6d8

File tree

2 files changed

+189
-15
lines changed

2 files changed

+189
-15
lines changed

packages/react-query/src/HydrationBoundary.tsx

Lines changed: 78 additions & 8 deletions
Original file line numberDiff line numberDiff line change
@@ -3,11 +3,17 @@ import * as React from 'react'
33

44
import { hydrate } from '@tanstack/query-core'
55
import { useQueryClient } from './QueryClientProvider'
6-
import type { HydrateOptions, QueryClient } from '@tanstack/query-core'
6+
import type {
7+
DehydratedState,
8+
HydrateOptions,
9+
QueryClient,
10+
} from '@tanstack/query-core'
711

812
export interface HydrationBoundaryProps {
913
state?: unknown
10-
options?: HydrateOptions
14+
options?: Omit<HydrateOptions, 'defaultOptions'> & {
15+
defaultOptions?: Omit<HydrateOptions['defaultOptions'], 'mutations'>
16+
}
1117
children?: React.ReactNode
1218
queryClient?: QueryClient
1319
}
@@ -19,19 +25,83 @@ export const HydrationBoundary = ({
1925
queryClient,
2026
}: HydrationBoundaryProps) => {
2127
const client = useQueryClient(queryClient)
28+
const [hydrationQueue, setHydrationQueue] = React.useState<
29+
DehydratedState['queries'] | undefined
30+
>()
2231

2332
const optionsRef = React.useRef(options)
2433
optionsRef.current = options
2534

26-
// Running hydrate again with the same queries is safe,
27-
// it wont overwrite or initialize existing queries,
28-
// relying on useMemo here is only a performance optimization.
29-
// hydrate can and should be run *during* render here for SSR to work properly
35+
// This useMemo is for performance reasons only, everything inside it _must_
36+
// be safe to run in every render and code here should be read as "in render".
37+
//
38+
// This code needs to happen during the render phase, because after initial
39+
// SSR, hydration needs to happen _before_ children render. Also, if hydrating
40+
// during a transition, we want to hydrate as much as is safe in render so
41+
// we can prerender as much as possible.
42+
//
43+
// For any queries that already exist in the cache, we want to hold back on
44+
// hydrating until _after_ the render phase. The reason for this is that during
45+
// transitions, we don't want the existing queries and observers to update to
46+
// the new data on the current page, only _after_ the transition is committed.
47+
// If the transition is aborted, we will have hydrated any _new_ queries, but
48+
// we throw away the fresh data for any existing ones to avoid unexpectedly
49+
// updating the UI.
3050
React.useMemo(() => {
3151
if (state) {
32-
hydrate(client, state, optionsRef.current)
52+
if (typeof state !== 'object') {
53+
return
54+
}
55+
56+
const queryCache = client.getQueryCache()
57+
// eslint-disable-next-line @typescript-eslint/no-unnecessary-condition
58+
const queries = (state as DehydratedState).queries || []
59+
60+
const newQueries: DehydratedState['queries'] = []
61+
const existingQueries: DehydratedState['queries'] = []
62+
for (const dehydratedQuery of queries) {
63+
const existingQuery = queryCache.get(dehydratedQuery.queryHash)
64+
65+
if (!existingQuery) {
66+
newQueries.push(dehydratedQuery)
67+
} else {
68+
const hydrationIsNewer =
69+
dehydratedQuery.state.dataUpdatedAt >
70+
existingQuery.state.dataUpdatedAt
71+
const queryAlreadyQueued = hydrationQueue?.find(
72+
(query) => query.queryHash === dehydratedQuery.queryHash,
73+
)
74+
75+
if (
76+
hydrationIsNewer &&
77+
(!queryAlreadyQueued ||
78+
dehydratedQuery.state.dataUpdatedAt >
79+
queryAlreadyQueued.state.dataUpdatedAt)
80+
) {
81+
existingQueries.push(dehydratedQuery)
82+
}
83+
}
84+
}
85+
86+
if (newQueries.length > 0) {
87+
// It's actually fine to call this with queries/state that already exists
88+
// in the cache, or is older. hydrate() is idempotent for queries.
89+
hydrate(client, { queries: newQueries }, optionsRef.current)
90+
}
91+
if (existingQueries.length > 0) {
92+
setHydrationQueue((prev) =>
93+
prev ? [...prev, ...existingQueries] : existingQueries,
94+
)
95+
}
96+
}
97+
}, [client, hydrationQueue, state])
98+
99+
React.useEffect(() => {
100+
if (hydrationQueue) {
101+
hydrate(client, { queries: hydrationQueue }, optionsRef.current)
102+
setHydrationQueue(undefined)
33103
}
34-
}, [client, state])
104+
}, [client, hydrationQueue])
35105

36106
return children as React.ReactElement
37107
}

packages/react-query/src/__tests__/HydrationBoundary.test.tsx

Lines changed: 111 additions & 7 deletions
Original file line numberDiff line numberDiff line change
@@ -137,8 +137,8 @@ describe('React hydration', () => {
137137
queryFn: () => dataQuery(['should change']),
138138
})
139139
await intermediateClient.prefetchQuery({
140-
queryKey: ['added string'],
141-
queryFn: () => dataQuery(['added string']),
140+
queryKey: ['added'],
141+
queryFn: () => dataQuery(['added']),
142142
})
143143
const dehydrated = dehydrate(intermediateClient)
144144
intermediateClient.clear()
@@ -147,17 +147,121 @@ describe('React hydration', () => {
147147
<QueryClientProvider client={queryClient}>
148148
<HydrationBoundary state={dehydrated}>
149149
<Page queryKey={['string']} />
150-
<Page queryKey={['added string']} />
150+
<Page queryKey={['added']} />
151151
</HydrationBoundary>
152152
</QueryClientProvider>,
153153
)
154154

155-
// Existing query data should be overwritten if older,
156-
// so this should have changed
155+
// Existing observer should not have updated at this point,
156+
// as that would indicate a side effect in the render phase
157+
rendered.getByText('string')
158+
// New query data should be available immediately
159+
rendered.getByText('added')
160+
157161
await sleep(10)
162+
// After effects phase has had time to run, the observer should have updated
163+
expect(rendered.queryByText('string')).toBeNull()
158164
rendered.getByText('should change')
159-
// New query data should be available immediately
160-
rendered.getByText('added string')
165+
166+
queryClient.clear()
167+
})
168+
169+
// When we hydrate in transitions that are later aborted, it could be
170+
// confusing to both developers and users if we suddenly updated existing
171+
// state on the screen (why did this update when it was not stale, nothing
172+
// remounted, I didn't change tabs etc?).
173+
// Any queries that does not exist in the cache yet can still be hydrated
174+
// since they don't have any observers on the current page that would update.
175+
test('should hydrate new but not existing queries if transition is aborted', async () => {
176+
const initialDehydratedState = JSON.parse(stringifiedState)
177+
const queryCache = new QueryCache()
178+
const queryClient = createQueryClient({ queryCache })
179+
180+
function Page({ queryKey }: { queryKey: [string] }) {
181+
const { data } = useQuery({
182+
queryKey,
183+
queryFn: () => dataQuery(queryKey),
184+
})
185+
return (
186+
<div>
187+
<h1>{data}</h1>
188+
</div>
189+
)
190+
}
191+
192+
const rendered = render(
193+
<QueryClientProvider client={queryClient}>
194+
<HydrationBoundary state={initialDehydratedState}>
195+
<Page queryKey={['string']} />
196+
</HydrationBoundary>
197+
</QueryClientProvider>,
198+
)
199+
200+
await rendered.findByText('string')
201+
202+
const intermediateCache = new QueryCache()
203+
const intermediateClient = createQueryClient({
204+
queryCache: intermediateCache,
205+
})
206+
await intermediateClient.prefetchQuery({
207+
queryKey: ['string'],
208+
queryFn: () => dataQuery(['should not change']),
209+
})
210+
await intermediateClient.prefetchQuery({
211+
queryKey: ['added'],
212+
queryFn: () => dataQuery(['added']),
213+
})
214+
const newDehydratedState = dehydrate(intermediateClient)
215+
intermediateClient.clear()
216+
217+
function Thrower() {
218+
throw new Promise(() => {
219+
// Never resolve
220+
})
221+
222+
// @ts-ignore
223+
return null
224+
}
225+
226+
React.startTransition(() => {
227+
rendered.rerender(
228+
<React.Suspense fallback="loading">
229+
<QueryClientProvider client={queryClient}>
230+
<HydrationBoundary state={newDehydratedState}>
231+
<Page queryKey={['string']} />
232+
<Page queryKey={['added']} />
233+
<Thrower />
234+
</HydrationBoundary>
235+
</QueryClientProvider>
236+
</React.Suspense>,
237+
)
238+
239+
rendered.getByText('loading')
240+
})
241+
242+
React.startTransition(() => {
243+
rendered.rerender(
244+
<QueryClientProvider client={queryClient}>
245+
<HydrationBoundary state={initialDehydratedState}>
246+
<Page queryKey={['string']} />
247+
<Page queryKey={['added']} />
248+
</HydrationBoundary>
249+
</QueryClientProvider>,
250+
)
251+
252+
// This query existed before the transition so it should stay the same
253+
rendered.getByText('string')
254+
expect(rendered.queryByText('should not change')).toBeNull()
255+
// New query data should be available immediately because it was
256+
// hydrated in the previous transition, even though the new dehydrated
257+
// state did not contain it
258+
rendered.getByText('added')
259+
})
260+
261+
await sleep(10)
262+
// It should stay the same even after effects have had a chance to run
263+
rendered.getByText('string')
264+
expect(rendered.queryByText('should not change')).toBeNull()
161265

162266
queryClient.clear()
163267
})

0 commit comments

Comments
 (0)