Skip to content
Open
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
18 changes: 18 additions & 0 deletions .changeset/solid-removed-query-not-rebuilt.md
Original file line number Diff line number Diff line change
@@ -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.
80 changes: 80 additions & 0 deletions packages/solid-query/src/__tests__/useQuery-semantics.test.tsx
Original file line number Diff line number Diff line change
Expand Up @@ -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<string> = []

function Page() {
const state = useQuery(() => ({
queryKey: key,
queryFn,
staleTime: 60_000,
}))
return <span>{state.data}</span>
}

const rendered = renderWithClient(queryClient, () => (
<Loading fallback={<span>loading</span>}>
<Page />
</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 <Loading> 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 <span>{state.data}</span>
}

const rendered = renderWithClient(queryClient, () => (
<Loading fallback={<span>loading</span>}>
<Page />
</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()
})
})
})
31 changes: 30 additions & 1 deletion packages/solid-query/src/useBaseQuery.ts
Original file line number Diff line number Diff line change
Expand Up @@ -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<TQueryFnData, TError, TQueryData, TQueryKey> | undefined

const attach = () => {
if (!disposed && !observerSub && !untrack(isRestoring)) {
Expand All @@ -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?.()
Expand Down Expand Up @@ -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<TQueryFnData, TError, TQueryData, TQueryKey>(
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 = () => {
Expand Down