From e2dd3f50645b7d93a4cb338dc7cab8a41b9874cb Mon Sep 17 00:00:00 2001 From: Rizwan Saleem Date: Tue, 1 Sep 2026 20:44:10 +0100 Subject: [PATCH] fix(solid-query): don't re-create and refetch a removed query 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 --- .changeset/solid-removed-query-not-rebuilt.md | 18 +++++ .../src/__tests__/useQuery-semantics.test.tsx | 80 +++++++++++++++++++ packages/solid-query/src/useBaseQuery.ts | 31 ++++++- 3 files changed, 128 insertions(+), 1 deletion(-) create mode 100644 .changeset/solid-removed-query-not-rebuilt.md diff --git a/.changeset/solid-removed-query-not-rebuilt.md b/.changeset/solid-removed-query-not-rebuilt.md new file mode 100644 index 0000000000..1ba1452064 --- /dev/null +++ b/.changeset/solid-removed-query-not-rebuilt.md @@ -0,0 +1,18 @@ +--- +'@tanstack/solid-query': patch +--- + +fix: stop a mounted observer from re-creating and refetching a removed +query. 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()`, which put 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 only builds when the hash is genuinely new, so 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. This is the behavior +of the other adapters, and of solid-query at 6.0.0-rc.0. diff --git a/packages/solid-query/src/__tests__/useQuery-semantics.test.tsx b/packages/solid-query/src/__tests__/useQuery-semantics.test.tsx index f85e85a3cd..ae6130f8d9 100644 --- a/packages/solid-query/src/__tests__/useQuery-semantics.test.tsx +++ b/packages/solid-query/src/__tests__/useQuery-semantics.test.tsx @@ -510,4 +510,84 @@ describe('useQuery 2.0 read semantics', () => { expect(rendered.getByText('n: 42')).toBeInTheDocument() }) }) + + describe('cache removal', () => { + // `removeQueries()` drops entries instead of refetching them, and a + // mounted observer must not undo that: the read layer recomputes on the + // 'removed' event, and rebuilding the entry there would re-point the + // observer and let its mount-fetch policy repopulate the removed key. + it('leaves a removed query out of the cache while an observer is mounted', async () => { + const key = queryKey() + let fetches = 0 + const queryFn = () => + sleep(10).then(() => (++fetches === 1 ? 'v1' : 'v2')) + const events: Array = [] + + function Page() { + const state = useQuery(() => ({ + queryKey: key, + queryFn, + staleTime: 60_000, + })) + return {state.data} + } + + const rendered = renderWithClient(queryClient, () => ( + loading}> + + + )) + + await vi.advanceTimersByTimeAsync(10) + expect(rendered.getByText('v1')).toBeInTheDocument() + + queryCache.subscribe((event) => events.push(event.type)) + queryClient.removeQueries({ queryKey: key }) + await vi.advanceTimersByTimeAsync(30) + + // No 'added' behind the 'removed', so nothing re-created the entry. + expect(events).toEqual(['removed']) + expect(queryCache.getAll()).toHaveLength(0) + expect(queryClient.getQueryData(key)).toBeUndefined() + // The removal is not a refetch trigger. + expect(fetches).toBe(1) + // The mounted reader holds the value it last had, rather than + // suspending back into over a key that no longer exists. + expect(rendered.getByText('v1')).toBeInTheDocument() + }) + + it('picks up a real entry written after a removal', async () => { + const key = queryKey() + + function Page() { + const state = useQuery(() => ({ + queryKey: key, + queryFn: () => sleep(10).then(() => 'v1'), + staleTime: 60_000, + })) + return {state.data} + } + + const rendered = renderWithClient(queryClient, () => ( + loading}> + + + )) + + await vi.advanceTimersByTimeAsync(10) + expect(rendered.getByText('v1')).toBeInTheDocument() + + queryClient.removeQueries({ queryKey: key }) + await vi.advanceTimersByTimeAsync(0) + expect(queryCache.getAll()).toHaveLength(0) + + // The held-over entry must not shadow the cache once it owns the hash + // again: a write rebuilds it, and the reader tracks the new instance. + queryClient.setQueryData(key, 'v2') + await vi.advanceTimersByTimeAsync(0) + + expect(queryCache.getAll()).toHaveLength(1) + expect(rendered.getByText('v2')).toBeInTheDocument() + }) + }) }) diff --git a/packages/solid-query/src/useBaseQuery.ts b/packages/solid-query/src/useBaseQuery.ts index d152a68534..2ca10f2a9e 100644 --- a/packages/solid-query/src/useBaseQuery.ts +++ b/packages/solid-query/src/useBaseQuery.ts @@ -216,6 +216,27 @@ export function useBaseQueryLayer< * swap re-attaches the rebuilt observer iff its predecessor was attached. */ let shouldAttach = false let activeClient = untrack(client) + /** + * The entry this hook last read at its current hash, and the fallback for + * `query()` when the cache no longer holds that hash. Removal is the case + * that needs it: `removeQueries()` (or `clear()`) fires 'removed' for a + * hash this hook is mounted on, which bumps `version` and re-runs every + * derived read, and a plain `build()` there would re-create the entry the + * caller just removed. That resurrection is not passive, since the 'added' + * entry also re-points the still-live observer, whose mount-fetch policy + * then refetches and repopulates the key, contrary to `removeQueries` + * being documented to remove entries instead of refetching them. + * + * The removed instance keeps its final state, so reads hold their last + * value until options change or a real entry returns (`setQueryData`, a + * refetch, a later mount). That is what the other adapters do: a React + * observer keeps rendering the removed query's last result while the + * cache stays empty. + * + * Cleared on a client swap, below: the fallback is only meaningful for the + * cache it was read from. + */ + let lastQuery: Query | undefined const attach = () => { if (!disposed && !observerSub && !untrack(isRestoring)) { @@ -236,6 +257,7 @@ export function useBaseQueryLayer< const syncClient = (c: QueryClient) => { if (isServer || c === activeClient) return activeClient = c + lastQuery = undefined cacheSub?.() cacheSub = c.getQueryCache().subscribe(onCacheEvent) observerSub?.() @@ -365,7 +387,14 @@ export function useBaseQueryLayer< version() const c = client() syncClient(c) - return c.getQueryCache().build(c, defaultedOptions() as any) as any + const cache = c.getQueryCache() + const opts = defaultedOptions() + const existing = cache.get( + opts.queryHash, + ) + if (existing) return (lastQuery = existing) + if (lastQuery?.queryHash === opts.queryHash) return lastQuery + return (lastQuery = cache.build(c, opts as any) as any) } const isEnabled = () => {