From 816df3e17149a66bd940fb81eca8b7ae7d482ecc Mon Sep 17 00:00:00 2001 From: ColumbusLabs <287001685+ColumbusLabs@users.noreply.github.com> Date: Fri, 24 Jul 2026 16:00:16 -0400 Subject: [PATCH 1/3] docs: document query cancellation semantics --- docs/collections/query-collection.md | 48 ++++++++++++++++++++++++++++ 1 file changed, 48 insertions(+) diff --git a/docs/collections/query-collection.md b/docs/collections/query-collection.md index e55fb9413..201ea1960 100644 --- a/docs/collections/query-collection.md +++ b/docs/collections/query-collection.md @@ -241,6 +241,54 @@ Some TanStack Query fields are owned or reinterpreted by Query Collection and ar `placeholderData` is intentionally unsupported. TanStack Query treats placeholder data as observer-local presentation state rather than cached Query data. Materializing it would expose temporary UI data as collection-wide normalized rows. Render placeholders in the consuming UI instead. +### Request Cancellation with `QueryFunctionContext.signal` + +TanStack Query passes an `AbortSignal` to `queryFn` through the query function +context. Forward `ctx.signal` to `fetch` or another abortable client to make the +request cancellable: + +```typescript +const todosCollection = createCollection( + queryCollectionOptions({ + queryKey: ["todos"], + queryFn: async (ctx) => { + const response = await fetch("/api/todos", { + signal: ctx.signal, + }) + + if (!response.ok) { + throw new Error("Failed to fetch todos") + } + + return response.json() as Promise> + }, + queryClient, + getKey: (todo) => todo.id, + }), +) +``` + +Explicit collection cleanup cancels collection-owned queries before removing +them from the Query cache: + +```typescript +await todosCollection.cleanup() +``` + +The underlying request is aborted only when its client consumes `ctx.signal`. +A client that ignores the signal may continue its request even though the +collection has been cleaned up. + +On-demand subset unloading has different semantics. When the last subscriber +unloads a subset, Query Collection does **not** cancel its in-flight request. +TanStack Query can cache the completed response, preserving cache reuse and a +fast remount if the same subset is requested again. + +Broader cancellation and lease-policy changes are being designed in +[RFC #1657](https://github.com/TanStack/db/issues/1657) and +[PR #1573](https://github.com/TanStack/db/pull/1573). Those proposals are not +the current behavior described here. + ### Using with `queryOptions(...)` If your app already uses TanStack Query's `queryOptions` helper (e.g. from `@tanstack/react-query`), you can spread compatible top-level options into `queryCollectionOptions`. Note that `queryFn` must be explicitly provided since query collections require it both in types and at runtime, and Query Collection's `select` option is for row extraction rather than TanStack Query observer-level selection: From 9d567c7224a2163213a9d4ef8b4f7e1669f1b678 Mon Sep 17 00:00:00 2001 From: Kyle Mathews Date: Fri, 24 Jul 2026 15:31:02 -0600 Subject: [PATCH 2/3] docs: correct query cancellation semantics --- docs/collections/query-collection.md | 23 +++++++++++++---------- 1 file changed, 13 insertions(+), 10 deletions(-) diff --git a/docs/collections/query-collection.md b/docs/collections/query-collection.md index 201ea1960..7389c77ca 100644 --- a/docs/collections/query-collection.md +++ b/docs/collections/query-collection.md @@ -268,8 +268,8 @@ const todosCollection = createCollection( ) ``` -Explicit collection cleanup cancels collection-owned queries before removing -them from the Query cache: +Explicit collection cleanup cancels each exact Query key the collection is +currently tracking before removing it from the Query cache: ```typescript await todosCollection.cleanup() @@ -279,15 +279,18 @@ The underlying request is aborted only when its client consumes `ctx.signal`. A client that ignores the signal may continue its request even though the collection has been cleaned up. -On-demand subset unloading has different semantics. When the last subscriber -unloads a subset, Query Collection does **not** cancel its in-flight request. -TanStack Query can cache the completed response, preserving cache reuse and a -fast remount if the same subset is requested again. +An unloaded on-demand subset is no longer tracked. A later explicit collection +cleanup does not revisit its Query key. -Broader cancellation and lease-policy changes are being designed in -[RFC #1657](https://github.com/TanStack/db/issues/1657) and -[PR #1573](https://github.com/TanStack/db/pull/1573). Those proposals are not -the current behavior described here. +Query cache entries are shared within a `QueryClient`. Explicit cleanup can +affect other consumers using the same exact Query keys. + +On-demand subset unloading does not explicitly call +`queryClient.cancelQueries()`. It removes the subset's Query observer. If this +was the final observer and the query function consumed `ctx.signal`, TanStack +Query aborts the request. If the signal was ignored, or another observer still +uses the same exact Query key, the request may finish and remain cached until +`gcTime`. ### Using with `queryOptions(...)` From 50be907882056aa9ba2594fb159f4e6d3d21c1d8 Mon Sep 17 00:00:00 2001 From: Kyle Mathews Date: Fri, 24 Jul 2026 15:39:45 -0600 Subject: [PATCH 3/3] docs: update query collection skills --- .../skills/db-core/collection-setup/SKILL.md | 14 ++++-- .../references/query-adapter.md | 38 +++++++++++++-- .../db-core/mutations-optimistic/SKILL.md | 23 ++++----- packages/db/skills/meta-framework/SKILL.md | 47 ++++++++++++++++--- 4 files changed, 97 insertions(+), 25 deletions(-) diff --git a/packages/db/skills/db-core/collection-setup/SKILL.md b/packages/db/skills/db-core/collection-setup/SKILL.md index 26d76501b..bdbd6e20f 100644 --- a/packages/db/skills/db-core/collection-setup/SKILL.md +++ b/packages/db/skills/db-core/collection-setup/SKILL.md @@ -51,8 +51,8 @@ const todoSchema = z.object({ const todoCollection = createCollection( queryCollectionOptions({ queryKey: ['todos'], - queryFn: async () => { - const res = await fetch('/api/todos') + queryFn: async (ctx) => { + const res = await fetch('/api/todos', { signal: ctx.signal }) return res.json() }, queryClient, @@ -60,16 +60,13 @@ const todoCollection = createCollection( schema: todoSchema, onInsert: async ({ transaction }) => { await api.todos.create(transaction.mutations[0].modified) - await todoCollection.utils.refetch() }, onUpdate: async ({ transaction }) => { const mut = transaction.mutations[0] await api.todos.update(mut.key, mut.changes) - await todoCollection.utils.refetch() }, onDelete: async ({ transaction }) => { await api.todos.delete(transaction.mutations[0].key) - await todoCollection.utils.refetch() }, }), ) @@ -105,6 +102,13 @@ queryCollectionOptions({ | `on-demand` | Search, catalogs, large tables | >50k rows | | `progressive` | Collaborative apps needing instant first paint (Electric only) | Any | +Calling `collection.preload()` on an on-demand collection is a no-op. Create +the live query for the required subset and call `liveQuery.preload()` instead. + +For Query Collection request cancellation, cleanup boundaries, and shared +`QueryClient` behavior, read +[the Query adapter reference](references/query-adapter.md#request-cancellation-and-cleanup). + ## Indexing Indexing is opt-in. The `autoIndex` option defaults to `"off"`. To enable automatic indexing, set `autoIndex: "eager"` and provide a `defaultIndexType`: diff --git a/packages/db/skills/db-core/collection-setup/references/query-adapter.md b/packages/db/skills/db-core/collection-setup/references/query-adapter.md index dc979223c..b5d31c356 100644 --- a/packages/db/skills/db-core/collection-setup/references/query-adapter.md +++ b/packages/db/skills/db-core/collection-setup/references/query-adapter.md @@ -17,7 +17,8 @@ const queryClient = new QueryClient() const collection = createCollection( queryCollectionOptions({ queryKey: ['todos'], - queryFn: async () => fetch('/api/todos').then((r) => r.json()), + queryFn: async (ctx) => + fetch('/api/todos', { signal: ctx.signal }).then((r) => r.json()), queryClient, getKey: (item) => item.id, }), @@ -50,7 +51,7 @@ const collection = createCollection( ```typescript onInsert: async ({ transaction }) => { await api.createTodos(transaction.mutations.map((m) => m.modified)) - // return nothing or { refetch: true } to trigger refetch + // Query Collection automatically refetches and awaits the result. // return { refetch: false } to skip refetch }, onUpdate: async ({ transaction }) => { @@ -80,6 +81,33 @@ collection.utils.writeBatch(() => { }) ``` +## Request Cancellation and Cleanup + +TanStack Query passes an `AbortSignal` through the query function context. +Forward it to `fetch` or another abortable client: + +```typescript +queryFn: async (ctx) => { + const response = await fetch('/api/todos', { signal: ctx.signal }) + return response.json() +}, +``` + +Explicit `collection.cleanup()` cancels each exact Query key the collection is +currently tracking, then removes it from the Query cache. The underlying client +stops work only when it consumes `ctx.signal`. + +An unloaded on-demand subset is no longer tracked, so later collection cleanup +does not revisit its Query key. Unloading does not explicitly call +`queryClient.cancelQueries()`: it removes the subset's Query observer. If that +was the final observer and the query function consumed `ctx.signal`, Query Core +aborts the request. If the signal was ignored, or another observer still uses +the same exact key, the request may finish and remain cached until `gcTime`. + +Query cache entries are shared within a `QueryClient`. Explicit cleanup can +cancel or remove entries used by another collection or Query consumer with the +same exact key. + ## Predicate Push-Down (syncMode: "on-demand") Query predicates (where, orderBy, limit, offset) passed to `queryFn` via `ctx.meta.loadSubsetOptions`. @@ -151,7 +179,9 @@ const productsCollection = createCollection( ) } if (limit) params.set('limit', String(limit)) - return fetch(`/api/products?${params}`).then((r) => r.json()) + return fetch(`/api/products?${params}`, { + signal: ctx.signal, + }).then((r) => r.json()) }, onInsert: async ({ transaction }) => { const serverItems = await api.createProducts( @@ -213,3 +243,5 @@ When using a function-based `queryKey`, all derived keys must share the base key - `queryFn` result is treated as **complete state** -- missing items are deleted - Empty array from `queryFn` deletes all items - Direct writes update TQ cache but are overridden by subsequent `queryFn` results +- Persistence handlers automatically refetch unless they return `{ refetch: false }` +- On-demand `collection.preload()` is a no-op; preload the live query instead diff --git a/packages/db/skills/db-core/mutations-optimistic/SKILL.md b/packages/db/skills/db-core/mutations-optimistic/SKILL.md index eced51773..64be12d5a 100644 --- a/packages/db/skills/db-core/mutations-optimistic/SKILL.md +++ b/packages/db/skills/db-core/mutations-optimistic/SKILL.md @@ -217,7 +217,7 @@ a good fit for draft-style flows where local state updates immediately but the server call waits for Save/Blur; call `tx.rollback()` to discard the optimistic changes. -### 4. Mutation handler with refetch (QueryCollection pattern) +### 4. Mutation handlers with automatic refetch (QueryCollection pattern) ```ts const todoCollection = createCollection( @@ -229,8 +229,7 @@ const todoCollection = createCollection( await Promise.all( transaction.mutations.map((m) => api.todos.create(m.modified)), ) - // IMPORTANT: handler must not resolve until server state is synced back - // QueryCollection auto-refetches after handler completes + // Query Collection refetches after the handler completes and awaits it. }, onUpdate: async ({ transaction }) => { await Promise.all( @@ -341,26 +340,28 @@ If an item with the same key already exists (synced or optimistic), throws `DuplicateKeyError`. Always generate a unique key (e.g. `crypto.randomUUID()`) or check before inserting. -### HIGH: Not awaiting refetch after mutation in query collection handler +### HIGH: Manually refetching inside a Query Collection handler -The optimistic state is held only until the handler resolves. If the handler -returns before server state has synced back, optimistic state is dropped and -users see a flash of missing data. +Query Collection automatically refetches after `onInsert`, `onUpdate`, and +`onDelete` complete, and waits for that refetch before the mutation finishes. +Calling `utils.refetch()` inside the handler sends a redundant request. ```ts -// WRONG -- optimistic state dropped before new server state arrives +// WRONG -- causes one manual and one automatic refetch onInsert: async ({ transaction }) => { await api.createTodo(transaction.mutations[0].modified) - // missing: await collection.utils.refetch() + await collection.utils.refetch() } -// CORRECT +// CORRECT -- automatic refetch is awaited after this returns onInsert: async ({ transaction }) => { await api.createTodo(transaction.mutations[0].modified) - await collection.utils.refetch() } ``` +When the handler writes the confirmed server result with direct-write utilities, +return `{ refetch: false }` to skip the automatic refetch. + --- ## Tension: Optimistic Speed vs. Data Consistency diff --git a/packages/db/skills/meta-framework/SKILL.md b/packages/db/skills/meta-framework/SKILL.md index 04046f689..e93dc019d 100644 --- a/packages/db/skills/meta-framework/SKILL.md +++ b/packages/db/skills/meta-framework/SKILL.md @@ -3,10 +3,10 @@ name: meta-framework description: > Integrating TanStack DB with meta-frameworks (TanStack Start, Next.js, Remix, Nuxt, SvelteKit). Client-side only: SSR is NOT supported — routes - must disable SSR. Preloading collections in route loaders with - collection.preload(). Pattern: ssr: false + await collection.preload() in - loader. Multiple collection preloading with Promise.all. Framework-specific - loader APIs. + must disable SSR. Preloading eager collections in route loaders with + collection.preload(). On-demand Query Collections require preloading the + live query because source collection preload is a no-op. Multiple collection + preloading with Promise.all. Framework-specific loader APIs. type: composition library: db library_version: '0.6.0' @@ -28,7 +28,7 @@ This skill builds on db-core. Read it first for collection setup and query build TanStack DB collections are **client-side only**. SSR is not implemented. Routes using TanStack DB **must disable SSR**. The setup pattern is: 1. Set `ssr: false` on the route -2. Call `collection.preload()` in the route loader +2. Preload the eager collection, or preload the live query for an on-demand source 3. Use `useLiveQuery` in the component ## TanStack Start @@ -73,6 +73,35 @@ function TodoPage() { } ``` +### On-demand Query Collection preload + +Calling `preload()` on an on-demand source collection is a no-op. Define the +live query once, preload it in the loader, and pass that same collection to the +framework hook: + +```tsx +import { createLiveQueryCollection, eq } from '@tanstack/db' +import { useLiveQuery } from '@tanstack/react-db' + +const activeTodos = createLiveQueryCollection((q) => + q + .from({ todo: todoCollection }) + .where(({ todo }) => eq(todo.completed, false)), +) + +export const Route = createFileRoute('/todos')({ + ssr: false, + loader: async () => { + await activeTodos.preload() + return null + }, + component: () => { + const { data } = useLiveQuery(activeTodos) + // ... + }, +}) +``` + ### Multiple collection preloading ```tsx @@ -227,7 +256,9 @@ export const ssr = false ### What preload() does -`collection.preload()` starts the sync process and returns a promise that resolves when the collection reaches "ready" status. This means: +For eager collections, `collection.preload()` starts the sync process and +returns a promise that resolves when the collection reaches "ready" status. +This means: 1. The sync function connects to the backend 2. Initial data is fetched and written to the collection @@ -236,6 +267,10 @@ export const ssr = false Subsequent calls to `preload()` on an already-ready collection return immediately. +For on-demand collections, source `collection.preload()` warns and does +nothing because no subset has been requested. Create the required live query +and await `liveQuery.preload()`. + ### Collection module pattern Define collections in a shared module, import in both loaders and components: