Skip to content

Commit 0e6ff54

Browse files
authored
fix(solid-query): don't re-create and refetch a removed query (#11360)
The read layer bumps its per-hook version signal on every cache event for its hash, 'removed' included, and the recompute that followed called `queryCache.build()`, putting the entry the caller had just deleted straight back. The resurrection was not passive: the rebuilt entry also re-pointed the still-live observer, whose mount-fetch policy then refetched and repopulated the key, so `removeQueries()` and `clear()` could not be made to stick while any hook observed the key. `query()` now reuses the entry it last read when the cache no longer holds that hash, and builds only when the hash is genuinely new. A removal leaves the cache empty and fires no fetch, while the mounted reader holds its last value until options change or a real entry returns through `setQueryData`, a refetch or a later mount. The resulting cache event sequence, entry count, data and fetch count are identical to react-query's for the same scenario, and to solid-query's at 6.0.0-rc.0. Fixes #11350
1 parent 09f2666 commit 0e6ff54

3 files changed

Lines changed: 128 additions & 1 deletion

File tree

Lines changed: 18 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,18 @@
1+
---
2+
'@tanstack/solid-query': patch
3+
---
4+
5+
fix: stop a mounted observer from re-creating and refetching a removed
6+
query. The read layer bumps its per-hook version signal on every cache
7+
event for its hash, `removed` included, and the recompute that followed
8+
called `queryCache.build()`, which put the entry the caller had just
9+
deleted straight back. The resurrection was not passive: the rebuilt
10+
entry also re-pointed the still-live observer, whose mount-fetch policy
11+
then refetched and repopulated the key, so `removeQueries()` (and
12+
`clear()`) could not be made to stick while any hook observed the key.
13+
`query()` now reuses the entry it last read when the cache no longer
14+
holds that hash, and only builds when the hash is genuinely new, so a
15+
removal leaves the cache empty and fires no fetch, while the mounted
16+
reader holds its last value until options change or a real entry returns
17+
through `setQueryData`, a refetch or a later mount. This is the behavior
18+
of the other adapters, and of solid-query at 6.0.0-rc.0.

‎packages/solid-query/src/__tests__/useQuery-semantics.test.tsx‎

Lines changed: 80 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -510,4 +510,84 @@ describe('useQuery 2.0 read semantics', () => {
510510
expect(rendered.getByText('n: 42')).toBeInTheDocument()
511511
})
512512
})
513+
514+
describe('cache removal', () => {
515+
// `removeQueries()` drops entries instead of refetching them, and a
516+
// mounted observer must not undo that: the read layer recomputes on the
517+
// 'removed' event, and rebuilding the entry there would re-point the
518+
// observer and let its mount-fetch policy repopulate the removed key.
519+
it('leaves a removed query out of the cache while an observer is mounted', async () => {
520+
const key = queryKey()
521+
let fetches = 0
522+
const queryFn = () =>
523+
sleep(10).then(() => (++fetches === 1 ? 'v1' : 'v2'))
524+
const events: Array<string> = []
525+
526+
function Page() {
527+
const state = useQuery(() => ({
528+
queryKey: key,
529+
queryFn,
530+
staleTime: 60_000,
531+
}))
532+
return <span>{state.data}</span>
533+
}
534+
535+
const rendered = renderWithClient(queryClient, () => (
536+
<Loading fallback={<span>loading</span>}>
537+
<Page />
538+
</Loading>
539+
))
540+
541+
await vi.advanceTimersByTimeAsync(10)
542+
expect(rendered.getByText('v1')).toBeInTheDocument()
543+
544+
queryCache.subscribe((event) => events.push(event.type))
545+
queryClient.removeQueries({ queryKey: key })
546+
await vi.advanceTimersByTimeAsync(30)
547+
548+
// No 'added' behind the 'removed', so nothing re-created the entry.
549+
expect(events).toEqual(['removed'])
550+
expect(queryCache.getAll()).toHaveLength(0)
551+
expect(queryClient.getQueryData(key)).toBeUndefined()
552+
// The removal is not a refetch trigger.
553+
expect(fetches).toBe(1)
554+
// The mounted reader holds the value it last had, rather than
555+
// suspending back into <Loading> over a key that no longer exists.
556+
expect(rendered.getByText('v1')).toBeInTheDocument()
557+
})
558+
559+
it('picks up a real entry written after a removal', async () => {
560+
const key = queryKey()
561+
562+
function Page() {
563+
const state = useQuery(() => ({
564+
queryKey: key,
565+
queryFn: () => sleep(10).then(() => 'v1'),
566+
staleTime: 60_000,
567+
}))
568+
return <span>{state.data}</span>
569+
}
570+
571+
const rendered = renderWithClient(queryClient, () => (
572+
<Loading fallback={<span>loading</span>}>
573+
<Page />
574+
</Loading>
575+
))
576+
577+
await vi.advanceTimersByTimeAsync(10)
578+
expect(rendered.getByText('v1')).toBeInTheDocument()
579+
580+
queryClient.removeQueries({ queryKey: key })
581+
await vi.advanceTimersByTimeAsync(0)
582+
expect(queryCache.getAll()).toHaveLength(0)
583+
584+
// The held-over entry must not shadow the cache once it owns the hash
585+
// again: a write rebuilds it, and the reader tracks the new instance.
586+
queryClient.setQueryData(key, 'v2')
587+
await vi.advanceTimersByTimeAsync(0)
588+
589+
expect(queryCache.getAll()).toHaveLength(1)
590+
expect(rendered.getByText('v2')).toBeInTheDocument()
591+
})
592+
})
513593
})

‎packages/solid-query/src/useBaseQuery.ts‎

Lines changed: 30 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -216,6 +216,27 @@ export function useBaseQueryLayer<
216216
* swap re-attaches the rebuilt observer iff its predecessor was attached. */
217217
let shouldAttach = false
218218
let activeClient = untrack(client)
219+
/**
220+
* The entry this hook last read at its current hash, and the fallback for
221+
* `query()` when the cache no longer holds that hash. Removal is the case
222+
* that needs it: `removeQueries()` (or `clear()`) fires 'removed' for a
223+
* hash this hook is mounted on, which bumps `version` and re-runs every
224+
* derived read, and a plain `build()` there would re-create the entry the
225+
* caller just removed. That resurrection is not passive, since the 'added'
226+
* entry also re-points the still-live observer, whose mount-fetch policy
227+
* then refetches and repopulates the key, contrary to `removeQueries`
228+
* being documented to remove entries instead of refetching them.
229+
*
230+
* The removed instance keeps its final state, so reads hold their last
231+
* value until options change or a real entry returns (`setQueryData`, a
232+
* refetch, a later mount). That is what the other adapters do: a React
233+
* observer keeps rendering the removed query's last result while the
234+
* cache stays empty.
235+
*
236+
* Cleared on a client swap, below: the fallback is only meaningful for the
237+
* cache it was read from.
238+
*/
239+
let lastQuery: Query<TQueryFnData, TError, TQueryData, TQueryKey> | undefined
219240

220241
const attach = () => {
221242
if (!disposed && !observerSub && !untrack(isRestoring)) {
@@ -236,6 +257,7 @@ export function useBaseQueryLayer<
236257
const syncClient = (c: QueryClient) => {
237258
if (isServer || c === activeClient) return
238259
activeClient = c
260+
lastQuery = undefined
239261
cacheSub?.()
240262
cacheSub = c.getQueryCache().subscribe(onCacheEvent)
241263
observerSub?.()
@@ -365,7 +387,14 @@ export function useBaseQueryLayer<
365387
version()
366388
const c = client()
367389
syncClient(c)
368-
return c.getQueryCache().build(c, defaultedOptions() as any) as any
390+
const cache = c.getQueryCache()
391+
const opts = defaultedOptions()
392+
const existing = cache.get<TQueryFnData, TError, TQueryData, TQueryKey>(
393+
opts.queryHash,
394+
)
395+
if (existing) return (lastQuery = existing)
396+
if (lastQuery?.queryHash === opts.queryHash) return lastQuery
397+
return (lastQuery = cache.build(c, opts as any) as any)
369398
}
370399

371400
const isEnabled = () => {

0 commit comments

Comments
 (0)