Skip to content

Commit f17d363

Browse files
committed
fix(preact-query): hydrate unobserved queries before observers subscribe to avoid a redundant refetch
1 parent 46d7f02 commit f17d363

3 files changed

Lines changed: 178 additions & 22 deletions

File tree

.changeset/tidy-pugs-listen.md

Lines changed: 5 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,5 @@
1+
---
2+
'@tanstack/preact-query': patch
3+
---
4+
5+
Hydrate queries nobody is observing as soon as `HydrationBoundary` commits, so a `useQuery` that remounts under it no longer refetches data the dehydrated state already contains.

packages/preact-query/src/HydrationBoundary.tsx

Lines changed: 39 additions & 5 deletions
Original file line numberDiff line numberDiff line change
@@ -7,10 +7,15 @@ import type {
77
} from '@tanstack/query-core'
88
import { Fragment } from 'preact'
99
import type { ComponentChildren } from 'preact'
10-
import { useEffect, useMemo, useRef } from 'preact/hooks'
10+
import { useEffect, useLayoutEffect, useMemo, useRef } from 'preact/hooks'
1111

1212
import { useQueryClient } from './QueryClientProvider'
1313

14+
interface HydrationQueue {
15+
queries: DehydratedState['queries']
16+
unobserved: DehydratedState['queries']
17+
}
18+
1419
export interface HydrationBoundaryProps {
1520
state: DehydratedState | null | undefined
1621
options?: OmitKeyof<HydrateOptions, 'defaultOptions'> & {
@@ -32,7 +37,7 @@ export const HydrationBoundary = ({
3237
const client = useQueryClient(queryClient)
3338

3439
const optionsRef = useRef(options)
35-
useEffect(() => {
40+
useLayoutEffect(() => {
3641
optionsRef.current = options
3742
})
3843

@@ -51,7 +56,10 @@ export const HydrationBoundary = ({
5156
// If the transition is aborted, we will have hydrated any _new_ queries, but
5257
// we throw away the fresh data for any existing ones to avoid unexpectedly
5358
// updating the UI.
54-
const hydrationQueue: DehydratedState['queries'] | undefined = useMemo(() => {
59+
//
60+
// Queries that no observer is watching are the exception, they are hydrated
61+
// as soon as the tree commits, see the layout effect below.
62+
const hydrationQueue: HydrationQueue | undefined = useMemo(() => {
5563
if (state) {
5664
if (typeof state !== 'object') {
5765
return
@@ -66,6 +74,7 @@ export const HydrationBoundary = ({
6674

6775
const newQueries: DehydratedState['queries'] = []
6876
const existingQueries: DehydratedState['queries'] = []
77+
const unobservedQueries: DehydratedState['queries'] = []
6978
for (const dehydratedQuery of queries) {
7079
const existingQuery = queryCache.get(dehydratedQuery.queryHash)
7180

@@ -83,6 +92,10 @@ export const HydrationBoundary = ({
8392

8493
if (hydrationIsNewer) {
8594
existingQueries.push(dehydratedQuery)
95+
96+
if (existingQuery.getObserversCount() === 0) {
97+
unobservedQueries.push(dehydratedQuery)
98+
}
8699
}
87100
}
88101
}
@@ -93,15 +106,36 @@ export const HydrationBoundary = ({
93106
hydrate(client, { queries: newQueries }, optionsRef.current)
94107
}
95108
if (existingQueries.length > 0) {
96-
return existingQueries
109+
return { queries: existingQueries, unobserved: unobservedQueries }
97110
}
98111
}
99112
return undefined
100113
}, [client, state])
101114

115+
// Waiting for a passive effect is too late for a query nobody was observing.
116+
// Children subscribe to the cache from their own passive effects and those
117+
// run before the parent's, so a query that remounts under this boundary reads
118+
// the old entry, finds it stale and refetches the very data we are holding.
119+
// Nothing was rendering these queries, so putting them in the cache at commit
120+
// time cannot change anything on screen, which is the only reason existing
121+
// queries wait in the first place.
122+
useLayoutEffect(() => {
123+
if (hydrationQueue && hydrationQueue.unobserved.length > 0) {
124+
hydrate(
125+
client,
126+
{ queries: hydrationQueue.unobserved },
127+
optionsRef.current,
128+
)
129+
}
130+
}, [client, hydrationQueue])
131+
132+
// Observed queries keep waiting, so a render that ends up being thrown away,
133+
// because something in it suspended, leaves the page the user is looking at
134+
// alone. Handing over the whole queue is fine, hydrating the same data a
135+
// second time does nothing.
102136
useEffect(() => {
103137
if (hydrationQueue) {
104-
hydrate(client, { queries: hydrationQueue }, optionsRef.current)
138+
hydrate(client, { queries: hydrationQueue.queries }, optionsRef.current)
105139
}
106140
}, [client, hydrationQueue])
107141

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

Lines changed: 134 additions & 17 deletions
Original file line numberDiff line numberDiff line change
@@ -167,13 +167,15 @@ describe('Preact hydration', () => {
167167
queryClient.clear()
168168
})
169169

170-
// When we hydrate in transitions that are later aborted, it could be
171-
// confusing to both developers and users if we suddenly updated existing
172-
// state on the screen (why did this update when it was not stale, nothing
173-
// remounted, I didn't change tabs etc?).
174-
// Any queries that does not exist in the cache yet can still be hydrated
175-
// since they don't have any observers on the current page that would update.
176-
it('should hydrate new but not existing queries if transition is aborted', async () => {
170+
// Preact has no equivalent of a render that never happened. A tree that
171+
// suspends is diffed and committed, layout effects and all, and only then
172+
// gets swapped out for the fallback. What keeps the current page intact is
173+
// that queries with observers wait for a passive effect, and those never
174+
// run for a tree that suspended, see the test further down with a sidebar
175+
// that survives the transition. Here the rerender introduces a new root,
176+
// which remounts everything and unsubscribes the observer before the
177+
// boundary even renders, so by then this is a query nobody is watching.
178+
it('should hydrate an unobserved query even if the render that carried it suspends', async () => {
177179
const initialDehydratedState = JSON.parse(stringifiedState)
178180
const queryClient = new QueryClient()
179181

@@ -235,6 +237,11 @@ describe('Preact hydration', () => {
235237
)
236238

237239
expect(rendered.getByText('loading')).toBeInTheDocument()
240+
// The tree committed before the fallback took over, so its data is in
241+
// the cache even though none of it made it to the screen
242+
expect(queryClient.getQueryData(stringKey)).toEqual([
243+
'should not change',
244+
])
238245
})
239246

240247
startTransition(() => {
@@ -247,22 +254,16 @@ describe('Preact hydration', () => {
247254
</QueryClientProvider>,
248255
)
249256

250-
// This query existed before the transition so it should stay the same
251-
expect(rendered.getByText(stringKey[0]!)).toBeInTheDocument()
252-
expect(
253-
rendered.queryByText('should not change'),
254-
).not.toBeInTheDocument()
257+
// Both pages render what the cache holds, the query that already
258+
// existed included
259+
expect(rendered.getByText('should not change')).toBeInTheDocument()
260+
expect(rendered.queryByText(stringKey[0]!)).not.toBeInTheDocument()
255261
// New query data should be available immediately because it was
256262
// hydrated in the previous transition, even though the new dehydrated
257263
// state did not contain it
258264
expect(rendered.getByText(addedKey[0]!)).toBeInTheDocument()
259265
})
260266

261-
await vi.advanceTimersByTimeAsync(20)
262-
// It should stay the same even after effects have had a chance to run
263-
expect(rendered.getByText(stringKey[0]!)).toBeInTheDocument()
264-
expect(rendered.queryByText('should not change')).not.toBeInTheDocument()
265-
266267
queryClient.clear()
267268
})
268269

@@ -311,6 +312,122 @@ describe('Preact hydration', () => {
311312
})
312313
})
313314

315+
it('should not refetch an inactive query when hydrated data is fresh', async () => {
316+
const key = queryKey()
317+
const queryClient = new QueryClient()
318+
const queryFn = vi.fn(() => sleep(10).then(() => 'client'))
319+
320+
function Page() {
321+
const { data } = useQuery({
322+
queryKey: key,
323+
queryFn,
324+
staleTime: 1000,
325+
})
326+
return <div>{data}</div>
327+
}
328+
329+
// First visit fetches and caches the data
330+
const rendered = render(
331+
<QueryClientProvider client={queryClient}>
332+
<Page />
333+
</QueryClientProvider>,
334+
)
335+
await vi.advanceTimersByTimeAsync(11)
336+
expect(rendered.getByText('client')).toBeInTheDocument()
337+
338+
// Navigate away, the cached data goes stale while the page is unmounted
339+
rendered.rerender(
340+
<QueryClientProvider client={queryClient}>
341+
<div />
342+
</QueryClientProvider>,
343+
)
344+
await vi.advanceTimersByTimeAsync(2000)
345+
346+
// A loader fetches fresh data for the revisit and dehydrates it
347+
const loaderClient = new QueryClient()
348+
loaderClient.prefetchQuery({
349+
queryKey: key,
350+
queryFn: () => sleep(10).then(() => 'loader'),
351+
})
352+
await vi.advanceTimersByTimeAsync(10)
353+
const dehydratedState = dehydrate(loaderClient)
354+
loaderClient.clear()
355+
356+
queryFn.mockClear()
357+
rendered.rerender(
358+
<QueryClientProvider client={queryClient}>
359+
<HydrationBoundary state={dehydratedState}>
360+
<Page />
361+
</HydrationBoundary>
362+
</QueryClientProvider>,
363+
)
364+
365+
// Hydration lands before the remounted useQuery subscribes, so it uses the
366+
// fresh data instead of fetching it all over again
367+
await vi.advanceTimersByTimeAsync(11)
368+
expect(queryFn).toHaveBeenCalledTimes(0)
369+
expect(rendered.getByText('loader')).toBeInTheDocument()
370+
371+
queryClient.clear()
372+
})
373+
374+
it('should not hydrate a query that is on screen while a sibling suspends', async () => {
375+
const key = queryKey()
376+
const queryClient = new QueryClient()
377+
378+
function Sidebar() {
379+
const { data } = useQuery({
380+
queryKey: key,
381+
queryFn: () => sleep(10).then(() => 'sidebar'),
382+
staleTime: Infinity,
383+
})
384+
return <div>{data}</div>
385+
}
386+
387+
function Thrower(): never {
388+
throw new Promise(() => {
389+
// Never resolve
390+
})
391+
}
392+
393+
const rendered = render(
394+
<QueryClientProvider client={queryClient}>
395+
<Sidebar />
396+
</QueryClientProvider>,
397+
)
398+
await vi.advanceTimersByTimeAsync(11)
399+
expect(rendered.getByText('sidebar')).toBeInTheDocument()
400+
401+
const loaderClient = new QueryClient()
402+
loaderClient.prefetchQuery({
403+
queryKey: key,
404+
queryFn: () => sleep(10).then(() => 'loader'),
405+
})
406+
await vi.advanceTimersByTimeAsync(10)
407+
const dehydratedState = dehydrate(loaderClient)
408+
loaderClient.clear()
409+
410+
// The route the app is navigating to suspends and never gets there, while
411+
// the sidebar stays mounted and keeps rendering the query
412+
rendered.rerender(
413+
<QueryClientProvider client={queryClient}>
414+
<Sidebar />
415+
<Suspense fallback="loading">
416+
<HydrationBoundary state={dehydratedState}>
417+
<Thrower />
418+
</HydrationBoundary>
419+
</Suspense>
420+
</QueryClientProvider>,
421+
)
422+
423+
expect(rendered.getByText('loading')).toBeInTheDocument()
424+
await vi.advanceTimersByTimeAsync(100)
425+
expect(rendered.getByText('sidebar')).toBeInTheDocument()
426+
expect(queryClient.getQueryData(key)).toBe('sidebar')
427+
428+
queryClient.clear()
429+
})
430+
314431
it('should not hydrate queries if state is null', async () => {
315432
const queryClient = new QueryClient()
316433

0 commit comments

Comments
 (0)