Skip to content

fix(query-db-collection): cancel idle on-demand queries#1573

Open
superposition wants to merge 1 commit into
TanStack:mainfrom
superposition:fix/query-collection-cancel-on-unload
Open

fix(query-db-collection): cancel idle on-demand queries#1573
superposition wants to merge 1 commit into
TanStack:mainfrom
superposition:fix/query-collection-cancel-on-unload

Conversation

@superposition
Copy link
Copy Markdown

@superposition superposition commented Jun 7, 2026

Fixes #350

Summary

  • cancel tracked TanStack Query requests when idle on-demand query collection cleanup drops the last reference
  • preserve the explicit collection cleanup path that already awaits cancel/remove for all tracked query keys
  • add regression coverage proving QueryFunctionContext.signal aborts when the last on-demand live query subscriber cleans up
  • document passing ctx.signal to abortable request clients

Test plan

  • corepack pnpm --filter @tanstack/electric-db-collection... build
  • corepack pnpm --filter @tanstack/query-db-collection build
  • corepack pnpm --filter @tanstack/query-db-collection test -- tests/query.test.ts
  • corepack pnpm test:sherif
  • corepack pnpm exec prettier --check packages/query-db-collection/src/query.ts packages/query-db-collection/tests/query.test.ts docs/collections/query-collection.md .changeset/query-collection-cancel-idle.md && git diff --check
  • corepack pnpm exec eslint packages/query-db-collection/src/query.ts packages/query-db-collection/tests/query.test.ts (no errors; existing warnings only)

Known existing failure:

  • corepack pnpm test:docs fails because docs/reference/index.md links to missing docs/reference/functions/caseWhen.md, unrelated to this change.

Summary by CodeRabbit

  • Documentation

    • Added Query Function Context documentation showing how queryFn receives TanStack Query's signal for proper cleanup in on-demand collections.
  • Improvements

    • On-demand query collection requests are now automatically canceled when no longer referenced, reducing unnecessary resource usage.

@coderabbitai
Copy link
Copy Markdown
Contributor

coderabbitai Bot commented Jun 7, 2026

Review Change Stack

📝 Walkthrough

Walkthrough

The PR implements active request cancellation in query collections: when tracked queries become unused, the library now cancels in-flight TanStack Query requests via their AbortSignal. A new cancelQueryByHash() helper and optional cancellation parameter drive the feature across cleanup paths, with test validation and updated documentation showing signal usage.

Changes

Query Collection Request Cancellation

Layer / File(s) Summary
Request cancellation API and helper
packages/query-db-collection/src/query.ts
Introduces cancelQueryByHash() to abort in-flight requests and extends cleanupQueryInternal() with an optional { cancel?: boolean } parameter (default true) for fine-grained cancellation control.
Cleanup path integrations
packages/query-db-collection/src/query.ts
Applies cancellation in three scenarios: idle cleanup when refcount reaches zero but observers remain, observer teardown after persistence logic, and forceCleanupQuery() which explicitly disables cancellation (cancel: false).
Test validation of cancellation behavior
packages/query-db-collection/tests/query.test.ts
New test captures the queryFn AbortSignal, calls cleanup() to trigger cancellation, asserts the signal is aborted, and confirms the collection is empty after the aborted preload completes.
Behavior documentation and user guidance
packages/query-db-collection/src/query.ts, docs/collections/query-collection.md
Updates inline comment to document that cleanup cancels in-flight requests, and adds user-facing documentation with a TypeScript example showing queryFn: async (ctx) => fetch(..., { signal: ctx.signal }) for proper signal handling.
Changeset metadata
.changeset/query-collection-cancel-idle.md
Records the patch-level change for @tanstack/query-db-collection documenting the request cancellation behavior.

Estimated code review effort

🎯 3 (Moderate) | ⏱️ ~20 minutes

Suggested reviewers

  • samwillis

Poem

A rabbit hops with signals bright,
Canceling queries left and right—
When collections fade away,
The AbortSignal saves the day! 🐰✨

🚥 Pre-merge checks | ✅ 3 | ❌ 2

❌ Failed checks (1 warning, 1 inconclusive)

Check name Status Explanation Resolution
Docstring Coverage ⚠️ Warning Docstring coverage is 0.00% which is insufficient. The required threshold is 80.00%. Write docstrings for the functions missing them to satisfy the coverage threshold.
Linked Issues check ❓ Inconclusive The PR implements key requirements from #350: cancels in-flight requests on cleanup via queryClient.cancelQueries(), documents signal usage in queryFn with examples, and adds test coverage proving signal.abort() is triggered on cleanup. Meta support implementation is not addressed in this PR. Verify whether meta support (from #350 requirements) is intentionally deferred to a separate PR or should be included in this changeset for completeness.
✅ Passed checks (3 passed)
Check name Status Explanation
Title check ✅ Passed The title 'fix(query-db-collection): cancel idle on-demand queries' accurately describes the main change: implementing cancellation of in-flight TanStack Query requests when idle on-demand query collections are cleaned up.
Description check ✅ Passed The PR description is mostly complete, including a summary of changes, test plan, and known limitations. However, it lacks explicit coverage of all checklist items from the template.
Out of Scope Changes check ✅ Passed All changes are directly related to #350 objectives: code changes implement automatic cancellation on cleanup, documentation explains signal usage, test validates abort behavior, and changelog entry records the feature. No unrelated modifications detected.

✏️ Tip: You can configure your own custom pre-merge checks in the settings.

✨ Finishing Touches
🧪 Generate unit tests (beta)
  • Create PR with unit tests

Thanks for using CodeRabbit! It's free for OSS, and your support helps us grow. If you like it, consider giving us a shout-out.

❤️ Share

Comment @coderabbitai help to get the list of available commands and usage tips.

Copy link
Copy Markdown
Contributor

@coderabbitai coderabbitai Bot left a comment

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Actionable comments posted: 1

Caution

Some comments are outside the diff and can’t be posted inline due to platform limitations.

⚠️ Outside diff range comments (1)
packages/query-db-collection/src/query.ts (1)

1581-1590: ⚠️ Potential issue | 🟠 Major

Fix observer reuse after cancellation to avoid immediate AbortError rejection

cleanupQueryIfIdle()’s if (hasListeners) branch cancels via cancelQueryByHash() and returns without removing state.observers for that query key. On a fast remount, createQueryFromOpts() will reuse the existing observer and immediately reject when observer.getCurrentResult().isError is set—so a canceled preload can surface as an immediate error instead of starting a fresh request.

Suggested fix
 const cancelQueryByHash = (hashedQueryKey: string) => {
   const key = hashToQueryKey.get(hashedQueryKey)
   if (!key) {
     return
   }
 
-  void queryClient.cancelQueries({ queryKey: key, exact: true })
+  void queryClient.cancelQueries({ queryKey: key, exact: true, silent: true })
 }

Add regression: preload()cleanup() while in flight → preload() again on the same key before gcTime expires should start a new request and not reject immediately with AbortError.

🤖 Prompt for AI Agents
Verify each finding against current code. Fix only still-valid issues, skip the
rest with a brief reason, keep changes minimal, and validate.

In `@packages/query-db-collection/src/query.ts` around lines 1581 - 1590,
cleanupQueryIfIdle() cancels the in-flight query via
cancelQueryByHash(hashedQueryKey) but leaves the stale observer in
state.observers causing createQueryFromOpts() to reuse an observer whose
observer.getCurrentResult().isError immediately rejects; after
cancelQueryByHash(hashedQueryKey) remove the corresponding observer entry from
state.observers for hashedQueryKey (while still setting
queryRefCounts.set(hashedQueryKey, 0) so invalidate/resubscribe paths still
work) so a subsequent preload() will create a fresh observer instead of reusing
the aborted one.
🤖 Prompt for all review comments with AI agents
Verify each finding against current code. Fix only still-valid issues, skip the
rest with a brief reason, keep changes minimal, and validate.

Inline comments:
In `@packages/query-db-collection/tests/query.test.ts`:
- Around line 5328-5371: The test creates a dedicated QueryClient instance
(customQueryClient) with a long gcTime but never clears it; wrap the test logic
that uses customQueryClient (the block that builds config, options, collection,
query, preloadPromise and awaits cleanup) in a try/finally and call
customQueryClient.clear() in the finally so the client's GC timers are
cancelled; reference the QueryClient constructor usage (customQueryClient), the
QueryCollectionConfig config, and the created collection/query so you clear the
exact client after the test finishes.

---

Outside diff comments:
In `@packages/query-db-collection/src/query.ts`:
- Around line 1581-1590: cleanupQueryIfIdle() cancels the in-flight query via
cancelQueryByHash(hashedQueryKey) but leaves the stale observer in
state.observers causing createQueryFromOpts() to reuse an observer whose
observer.getCurrentResult().isError immediately rejects; after
cancelQueryByHash(hashedQueryKey) remove the corresponding observer entry from
state.observers for hashedQueryKey (while still setting
queryRefCounts.set(hashedQueryKey, 0) so invalidate/resubscribe paths still
work) so a subsequent preload() will create a fresh observer instead of reusing
the aborted one.
🪄 Autofix (Beta)

Fix all unresolved CodeRabbit comments on this PR:

  • Push a commit to this branch (recommended)
  • Create a new PR with the fixes

ℹ️ Review info
⚙️ Run configuration

Configuration used: defaults

Review profile: CHILL

Plan: Pro

Run ID: 07c20a16-3142-463a-9a57-1d136f4a9abe

📥 Commits

Reviewing files that changed from the base of the PR and between 66210a5 and caaf580.

📒 Files selected for processing (4)
  • .changeset/query-collection-cancel-idle.md
  • docs/collections/query-collection.md
  • packages/query-db-collection/src/query.ts
  • packages/query-db-collection/tests/query.test.ts

Comment on lines +5328 to +5371
const customQueryClient = new QueryClient({
defaultOptions: {
queries: {
gcTime: 5 * 60 * 1000,
retry: false,
},
},
})

const config: QueryCollectionConfig<TestItem> = {
id: `in-flight-abort-test`,
queryClient: customQueryClient,
queryKey: baseQueryKey,
queryFn,
getKey,
startSync: true,
syncMode: `on-demand`,
}

const options = queryCollectionOptions(config)
const collection = createCollection(options)

const query = createLiveQueryCollection({
query: (q) => q.from({ item: collection }).select(({ item }) => item),
})

const preloadPromise = query.preload().catch((error) => error)

await vi.waitFor(() => {
expect(queryFn).toHaveBeenCalledTimes(1)
expect(capturedSignal).toBeDefined()
})

expect(capturedSignal!.aborted).toBe(false)

await query.cleanup()

await vi.waitFor(() => {
expect(capturedSignal!.aborted).toBe(true)
})

await preloadPromise
expect(collection.size).toBe(0)
})
Copy link
Copy Markdown
Contributor

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

⚠️ Potential issue | 🟡 Minor | ⚡ Quick win

Clear the dedicated QueryClient in a finally block.

This test creates a separate client with a 5-minute gcTime, but it never clears it. That leaves cache GC timers alive outside the shared afterEach() cleanup and can make this spec leak into later tests.

Suggested fix
-      const customQueryClient = new QueryClient({
+      const customQueryClient = new QueryClient({
         defaultOptions: {
           queries: {
             gcTime: 5 * 60 * 1000,
             retry: false,
           },
         },
       })
-
-      const config: QueryCollectionConfig<TestItem> = {
-        id: `in-flight-abort-test`,
-        queryClient: customQueryClient,
-        queryKey: baseQueryKey,
-        queryFn,
-        getKey,
-        startSync: true,
-        syncMode: `on-demand`,
-      }
-
-      const options = queryCollectionOptions(config)
-      const collection = createCollection(options)
-
-      const query = createLiveQueryCollection({
-        query: (q) => q.from({ item: collection }).select(({ item }) => item),
-      })
-
-      const preloadPromise = query.preload().catch((error) => error)
-
-      await vi.waitFor(() => {
-        expect(queryFn).toHaveBeenCalledTimes(1)
-        expect(capturedSignal).toBeDefined()
-      })
-
-      expect(capturedSignal!.aborted).toBe(false)
-
-      await query.cleanup()
-
-      await vi.waitFor(() => {
-        expect(capturedSignal!.aborted).toBe(true)
-      })
-
-      await preloadPromise
-      expect(collection.size).toBe(0)
+      try {
+        const config: QueryCollectionConfig<TestItem> = {
+          id: `in-flight-abort-test`,
+          queryClient: customQueryClient,
+          queryKey: baseQueryKey,
+          queryFn,
+          getKey,
+          startSync: true,
+          syncMode: `on-demand`,
+        }
+
+        const options = queryCollectionOptions(config)
+        const collection = createCollection(options)
+
+        const query = createLiveQueryCollection({
+          query: (q) => q.from({ item: collection }).select(({ item }) => item),
+        })
+
+        const preloadPromise = query.preload().catch((error) => error)
+
+        await vi.waitFor(() => {
+          expect(queryFn).toHaveBeenCalledTimes(1)
+          expect(capturedSignal).toBeDefined()
+        })
+
+        expect(capturedSignal!.aborted).toBe(false)
+
+        await query.cleanup()
+
+        await vi.waitFor(() => {
+          expect(capturedSignal!.aborted).toBe(true)
+        })
+
+        await preloadPromise
+        expect(collection.size).toBe(0)
+      } finally {
+        customQueryClient.clear()
+      }
📝 Committable suggestion

‼️ IMPORTANT
Carefully review the code before committing. Ensure that it accurately replaces the highlighted code, contains no missing lines, and has no issues with indentation. Thoroughly test & benchmark the code to ensure it meets the requirements.

Suggested change
const customQueryClient = new QueryClient({
defaultOptions: {
queries: {
gcTime: 5 * 60 * 1000,
retry: false,
},
},
})
const config: QueryCollectionConfig<TestItem> = {
id: `in-flight-abort-test`,
queryClient: customQueryClient,
queryKey: baseQueryKey,
queryFn,
getKey,
startSync: true,
syncMode: `on-demand`,
}
const options = queryCollectionOptions(config)
const collection = createCollection(options)
const query = createLiveQueryCollection({
query: (q) => q.from({ item: collection }).select(({ item }) => item),
})
const preloadPromise = query.preload().catch((error) => error)
await vi.waitFor(() => {
expect(queryFn).toHaveBeenCalledTimes(1)
expect(capturedSignal).toBeDefined()
})
expect(capturedSignal!.aborted).toBe(false)
await query.cleanup()
await vi.waitFor(() => {
expect(capturedSignal!.aborted).toBe(true)
})
await preloadPromise
expect(collection.size).toBe(0)
})
const customQueryClient = new QueryClient({
defaultOptions: {
queries: {
gcTime: 5 * 60 * 1000,
retry: false,
},
},
})
try {
const config: QueryCollectionConfig<TestItem> = {
id: `in-flight-abort-test`,
queryClient: customQueryClient,
queryKey: baseQueryKey,
queryFn,
getKey,
startSync: true,
syncMode: `on-demand`,
}
const options = queryCollectionOptions(config)
const collection = createCollection(options)
const query = createLiveQueryCollection({
query: (q) => q.from({ item: collection }).select(({ item }) => item),
})
const preloadPromise = query.preload().catch((error) => error)
await vi.waitFor(() => {
expect(queryFn).toHaveBeenCalledTimes(1)
expect(capturedSignal).toBeDefined()
})
expect(capturedSignal!.aborted).toBe(false)
await query.cleanup()
await vi.waitFor(() => {
expect(capturedSignal!.aborted).toBe(true)
})
await preloadPromise
expect(collection.size).toBe(0)
} finally {
customQueryClient.clear()
}
🤖 Prompt for AI Agents
Verify each finding against current code. Fix only still-valid issues, skip the
rest with a brief reason, keep changes minimal, and validate.

In `@packages/query-db-collection/tests/query.test.ts` around lines 5328 - 5371,
The test creates a dedicated QueryClient instance (customQueryClient) with a
long gcTime but never clears it; wrap the test logic that uses customQueryClient
(the block that builds config, options, collection, query, preloadPromise and
awaits cleanup) in a try/finally and call customQueryClient.clear() in the
finally so the client's GC timers are cancelled; reference the QueryClient
constructor usage (customQueryClient), the QueryCollectionConfig config, and the
created collection/query so you clear the exact client after the test finishes.

Sign up for free to join this conversation on GitHub. Already have an account? Sign in to comment

Labels

None yet

Projects

None yet

Development

Successfully merging this pull request may close these issues.

Expose QueryFunctionContext properties (signal, meta) in query-db-collection

1 participant