From caaf58064a3a6e89ecdef657a03f3ffb4875f9a0 Mon Sep 17 00:00:00 2001 From: Eric Manganaro Date: Sun, 7 Jun 2026 03:57:08 -0400 Subject: [PATCH] fix(query-db-collection): cancel idle on-demand queries --- .changeset/query-collection-cancel-idle.md | 5 ++ docs/collections/query-collection.md | 21 +++++++ packages/query-db-collection/src/query.ts | 35 +++++++++-- .../query-db-collection/tests/query.test.ts | 61 +++++++++++++++++++ 4 files changed, 117 insertions(+), 5 deletions(-) create mode 100644 .changeset/query-collection-cancel-idle.md diff --git a/.changeset/query-collection-cancel-idle.md b/.changeset/query-collection-cancel-idle.md new file mode 100644 index 000000000..539196fcd --- /dev/null +++ b/.changeset/query-collection-cancel-idle.md @@ -0,0 +1,5 @@ +--- +'@tanstack/query-db-collection': patch +--- + +Cancel idle on-demand query collection requests during cleanup. diff --git a/docs/collections/query-collection.md b/docs/collections/query-collection.md index 73f3511ed..22c4a25c8 100644 --- a/docs/collections/query-collection.md +++ b/docs/collections/query-collection.md @@ -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: diff --git a/packages/query-db-collection/src/query.ts b/packages/query-db-collection/src/query.ts index b29aac873..f115a1c7c 100644 --- a/packages/query-db-collection/src/query.ts +++ b/packages/query-db-collection/src/query.ts @@ -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) @@ -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) @@ -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) @@ -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 @@ -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 diff --git a/packages/query-db-collection/tests/query.test.ts b/packages/query-db-collection/tests/query.test.ts index 113fc0e1e..4918ee685 100644 --- a/packages/query-db-collection/tests/query.test.ts +++ b/packages/query-db-collection/tests/query.test.ts @@ -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) => { + capturedSignal = ctx.signal + + return new Promise>((_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 = { + 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) + }) }) describe(`Cache Persistence on Remount`, () => {