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
5 changes: 5 additions & 0 deletions .changeset/query-collection-cancel-idle.md
Original file line number Diff line number Diff line change
@@ -0,0 +1,5 @@
---
'@tanstack/query-db-collection': patch
---

Cancel idle on-demand query collection requests during cleanup.
21 changes: 21 additions & 0 deletions docs/collections/query-collection.md
Original file line number Diff line number Diff line change
Expand Up @@ -64,6 +64,27 @@ The `queryCollectionOptions` function accepts the following options:
- `staleTime`: How long data is considered fresh
- `meta`: Optional metadata that will be passed to the query function context

### Query Function Context

`queryFn` receives TanStack Query's `QueryFunctionContext`, including `signal` and `meta`. Query Collections cancel idle on-demand requests during cleanup, so pass `ctx.signal` to abortable request clients:

```typescript
const todosCollection = createCollection(
queryCollectionOptions({
queryKey: ["todos"],
syncMode: "on-demand",
queryFn: async (ctx) => {
const response = await fetch("/api/todos", {
signal: ctx.signal,
})
return response.json()
},
queryClient,
getKey: (item) => item.id,
})
)
```

### Using with `queryOptions(...)`

If your app already uses TanStack Query's `queryOptions` helper (e.g. from `@tanstack/react-query`), you can spread those options into `queryCollectionOptions`. Note that `queryFn` must be explicitly provided since query collections require it both in types and at runtime:
Expand Down
35 changes: 30 additions & 5 deletions packages/query-db-collection/src/query.ts
Original file line number Diff line number Diff line change
Expand Up @@ -1474,13 +1474,33 @@ export function queryCollectionOptions(
handleQueryResult(observer.getCurrentResult())
})

/**
* Cancel an in-flight TanStack Query request for a tracked query.
* This aborts the QueryFunctionContext signal for queryFns that pass it
* through to fetch, GraphQL clients, or other abortable request APIs.
*/
const cancelQueryByHash = (hashedQueryKey: string) => {
const key = hashToQueryKey.get(hashedQueryKey)
if (!key) {
return
}

void queryClient.cancelQueries({ queryKey: key, exact: true })
}

/**
* Perform row-level cleanup and remove all tracking for a query.
* Callers are responsible for ensuring the query is safe to cleanup.
*/
const cleanupQueryInternal = (hashedQueryKey: string) => {
const cleanupQueryInternal = (
hashedQueryKey: string,
options: { cancel?: boolean } = {},
) => {
unsubscribes.get(hashedQueryKey)?.()
unsubscribes.delete(hashedQueryKey)
if (options.cancel !== false) {
cancelQueryByHash(hashedQueryKey)
}
cancelPersistedRetentionExpiry(hashedQueryKey)
retainedQueriesPendingRevalidation.delete(hashedQueryKey)

Expand Down Expand Up @@ -1559,6 +1579,11 @@ export function queryCollectionOptions(
const hasListeners = observer?.hasListeners() ?? false

if (hasListeners) {
// Refcount is zero, so this query is no longer needed by a live query.
// The remaining listener may be the loadSubset promise waiter; canceling
// here aborts its QueryFunctionContext signal while keeping the observer
// around for invalidateQueries unsubscribe/resubscribe cycles.
cancelQueryByHash(hashedQueryKey)
// During invalidateQueries, TanStack Query keeps internal listeners alive.
// Leave refcount at 0 but keep observer so it can resubscribe.
queryRefCounts.set(hashedQueryKey, 0)
Expand Down Expand Up @@ -1604,6 +1629,7 @@ export function queryCollectionOptions(
}
unsubscribes.get(hashedQueryKey)?.()
unsubscribes.delete(hashedQueryKey)
cancelQueryByHash(hashedQueryKey)
state.observers.delete(hashedQueryKey)
hashToQueryKey.delete(hashedQueryKey)
queryRefCounts.set(hashedQueryKey, 0)
Expand All @@ -1618,7 +1644,7 @@ export function queryCollectionOptions(
* Ignores refcounts/hasListeners and removes everything.
*/
const forceCleanupQuery = (hashedQueryKey: string) => {
cleanupQueryInternal(hashedQueryKey)
cleanupQueryInternal(hashedQueryKey, { cancel: false })
}

// Subscribe to the query client's cache to handle queries that are GCed by tanstack query
Expand Down Expand Up @@ -1685,9 +1711,8 @@ export function queryCollectionOptions(
* - But observer.hasListeners() is still true (TanStack Query's internal listeners)
* - We skip cleanup and reset refcount, allowing resubscribe to succeed
*
* We don't cancel in-flight requests. Unsubscribing from the observer is sufficient
* to prevent late-arriving data from being processed. The request completes and is cached
* by TanStack Query, allowing quick remounts to restore data without refetching.
* When the query is no longer referenced, cleanup cancels in-flight requests
* so the queryFn's AbortSignal is triggered and unnecessary network work stops.
*/
const unloadSubset = (options: LoadSubsetOptions) => {
// 1. Same predicates → 2. Same queryKey
Expand Down
61 changes: 61 additions & 0 deletions packages/query-db-collection/tests/query.test.ts
Original file line number Diff line number Diff line change
Expand Up @@ -5308,6 +5308,67 @@ describe(`QueryCollection`, () => {
// Query was cancelled, this is expected
}
})

it(`should abort in-flight on-demand queries when the last subscriber is cleaned up`, async () => {
const baseQueryKey = [`in-flight-abort-test`]
let capturedSignal: AbortSignal | undefined

const queryFn = vi.fn((ctx: QueryFunctionContext<any>) => {
capturedSignal = ctx.signal

return new Promise<Array<TestItem>>((_resolve, reject) => {
ctx.signal.addEventListener(`abort`, () => {
const error = new Error(`Query aborted`)
error.name = `AbortError`
reject(error)
})
})
})

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)
})
Comment on lines +5328 to +5371

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.

})

describe(`Cache Persistence on Remount`, () => {
Expand Down