TanStack / TanStack/db

on-demand store entries are removed while queries still use them when reusing an existing observer

Open
#1,488 0 comments 1 reaction 0 assignees View on GitHub

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:

  1. detail query
where(id == \"mver_a...\")
  1. 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
  1. Start on a detail/overview page that queries a specific row
  2. Reload there
  3. Navigate to a list page that loads a broader query including that same row
  4. Navigate back to the detail/overview page

At this point, the detail query is hitting the existing-observer reuse path.

Then:

  1. 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 true for currentResult.isSuccess, call:
handleQueryResult(currentResult)
  • before returning true for 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

Open the contributing guide

First steps

  1. Read the whole issue, then the project's contributing guide.
  2. Comment on the issue to say you are picking it up — it saves two people doing the same work.
  3. Fork the repository and make your change on a branch.
  4. 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

Get new issues in your inbox

A short digest of beginner-friendly GitHub issues.