on-demand store entries are removed while queries still use them when reusing an existing observer
Nobody has claimed this yet.
- Dominant language
- TypeScript
- Stars
- 3.9k
- Forks
- 266
- Avg merge
- 1d 4h
- Merged PRs (30d)
- 55
Description
- I've validated the bug against the latest version of DB packages
Describe the bug
With @tanstack/query-db-collection in syncMode: \"on-demand\", reusing an existing observer/query can return cached or success data without re-registering row ownership for that query.
Later, when another query for the same row goes inactive and is cleaned up, row-level cleanup can delete the row from the collection even though the reused query is still active and still reading that row.
This causes rows to disappear from the store incorrectly.
Relevant source
The bug appears to be in the existing-observer reuse path in packages/query-db-collection/src/query.ts.
Relevant areas:
Why this seems to happen
In createQueryFromOpts, when an observer already exists for a hashedQueryKey, the code increments the refcount and returns early based on:
observer.getCurrentResult().isSuccess- or
queryClient.getQueryData(key) !== undefined
But in those early-return paths, it does not run the normal success handling path that calls applySuccessfulResult(...), so row ownership is not re-added for that query.
That means the query is active from the app's perspective, but rowToQueries still does not include it.
When a different query that previously owned the same row is cleaned up, cleanupQueryInternal(...) sees no remaining owners and deletes the row.
Reproduction pattern
I hit this consistently with an on-demand moduleVersions collection with useLiveQuery with gcTime set to a value. This bug happens when that gcTime is hit.
Two different on-demand queries can reference the same row:
- detail query
where(id == \"mver_a...\")
- list query
where(id in [\"mver_a\", \"mver_b\", \"mver_c\"])
The same module version row can appear in both.
Navigation sequence that reproduces it
- Start on a detail/overview page that queries a specific row
- Reload there
- Navigate to a list page that loads a broader query including that same row
- Navigate back to the detail/overview page
At this point, the detail query is hitting the existing-observer reuse path.
Then:
- The list query becomes inactive and is cleaned up
Actual result
The cleanup of the list query deletes the shared row from the collection/store, even though the detail query is active and still using that row.
Expected result
The row should remain in the collection because the detail query is still active and should still be listed as an owner.
Root cause hypothesis
This appears to be the problematic shape in createQueryFromOpts:
if (state.observers.has(hashedQueryKey)) {
queryRefCounts.set(hashedQueryKey, (queryRefCounts.get(hashedQueryKey) || 0) + 1)
const observer = state.observers.get(hashedQueryKey)!
const currentResult = observer.getCurrentResult()
if (currentResult.isSuccess) {
return true
} else if (currentResult.isError) {
return Promise.reject(currentResult.error)
} else {
const cachedData = queryClient.getQueryData(key)
if (cachedData !== undefined) {
return true
}
...
}
}
The reused-observer fast path returns before applySuccessfulResult(...) runs, so ownership is not refreshed for that query.
Cleanup later happens in cleanupQueryInternal, which uses the tracked owners to decide whether a row should be deleted.
Local fix
What fixed it locally was:
- in the
state.observers.has(hashedQueryKey)fast path - create
const handleQueryResult = makeQueryResultHandler(key) - before returning
trueforcurrentResult.isSuccess, call:
handleQueryResult(currentResult)
- before returning
truefor cached query data, synthesize a success result and call:
handleQueryResult({
...currentResult,
data: cachedData,
error: null,
isError: false,
isLoading: false,
isPending: false,
isSuccess: true,
status: "success",
})
This re-registers row ownership, so later cleanup of overlapping queries does not incorrectly delete the row.
Environment
Example environment where this reproduced:
@tanstack/query-db-collection:1.0.36@tanstack/react-db:0.1.83@tanstack/query-core:5.99.0- Browser: Chrome
- OS: macOS
Related but different issues
I checked these before filing:
- #1385
- #998
- #1434
They seem related to on-demand/query reuse behavior, but not this specific ownership-cleanup bug.
Contributor guide
First steps
- Read the whole issue, then the project's contributing guide.
- Comment on the issue to say you are picking it up — it saves two people doing the same work.
- Fork the repository and make your change on a branch.
- Open a pull request that references the issue number.
Research direction
Start in packages/query-db-collection/src/query.ts, reading createQueryFromOpts, applySuccessfulResult, and cleanupQueryInternal, then reproduce the on-demand observer reuse sequence with gcTime. Trace rowToQueries through reuse and cleanup; done means an active reused query remains an owner so overlapping-query cleanup does not remove its row.
Written by the indexing model from the issue text.
Assessment
- Tech stack
- typescript
- Domain
- database
- Issue type
- Bug
- Difficulty
- 3/5
- Estimated time
- 1-2 days
- Activity status
- Quiet
- Clarity
- Clearly specified
- Newbie friendliness
- 72/100