From 5bd4525da9ae0a7a97fcea61b2832a35df4870d3 Mon Sep 17 00:00:00 2001 From: Lucas Weng Date: Sun, 14 Sep 2025 09:59:46 +0800 Subject: [PATCH 1/6] feat: implement exact targeting for refetching queries to prevent unintended cascading effects --- packages/query-db-collection/src/query.ts | 1 + .../query-db-collection/tests/query.test.ts | 75 +++++++++++++++++++ 2 files changed, 76 insertions(+) diff --git a/packages/query-db-collection/src/query.ts b/packages/query-db-collection/src/query.ts index 832f6b755..239452cb9 100644 --- a/packages/query-db-collection/src/query.ts +++ b/packages/query-db-collection/src/query.ts @@ -601,6 +601,7 @@ export function queryCollectionOptions( return queryClient.refetchQueries( { queryKey: queryKey, + exact: true, }, { throwOnError: opts?.throwOnError, diff --git a/packages/query-db-collection/tests/query.test.ts b/packages/query-db-collection/tests/query.test.ts index 9a380547b..916e31cd1 100644 --- a/packages/query-db-collection/tests/query.test.ts +++ b/packages/query-db-collection/tests/query.test.ts @@ -1958,6 +1958,81 @@ describe(`QueryCollection`, () => { }) }) + it(`should use exact targeting when refetching to avoid unintended cascading of related queries`, async () => { + // Create multiple collections with related but distinct query keys + const queryKey = [`todos`] + const queryKey1 = [`todos`, `project-1`] + const queryKey2 = [`todos`, `project-2`] + + const mockItems = [{ id: `1`, name: `Item 1` }] + const queryFn = vi.fn().mockResolvedValue(mockItems) + const queryFn1 = vi.fn().mockResolvedValue(mockItems) + const queryFn2 = vi.fn().mockResolvedValue(mockItems) + + const config: QueryCollectionConfig = { + id: `all-todos`, + queryClient, + queryKey: queryKey, + queryFn: queryFn, + getKey, + startSync: true, + } + const config1: QueryCollectionConfig = { + id: `project-1-todos`, + queryClient, + queryKey: queryKey1, + queryFn: queryFn1, + getKey, + startSync: true, + } + const config2: QueryCollectionConfig = { + id: `project-2-todos`, + queryClient, + queryKey: queryKey2, + queryFn: queryFn2, + getKey, + startSync: true, + } + + const options = queryCollectionOptions(config) + const options1 = queryCollectionOptions(config1) + const options2 = queryCollectionOptions(config2) + + const collection = createCollection(options) + const collection1 = createCollection(options1) + const collection2 = createCollection(options2) + + // Wait for initial queries to complete + await vi.waitFor(() => { + expect(queryFn).toHaveBeenCalledTimes(1) + expect(queryFn1).toHaveBeenCalledTimes(1) + expect(queryFn2).toHaveBeenCalledTimes(1) + expect(collection.status).toBe(`ready`) + }) + + // Reset call counts to test refetch behavior + queryFn.mockClear() + queryFn1.mockClear() + queryFn2.mockClear() + + // Refetch the target collection with key ['todos', 'project-1'] + await collection1.utils.refetch() + + // Verify that only the target query was refetched + await vi.waitFor(() => { + expect(queryFn1).toHaveBeenCalledTimes(1) + expect(queryFn).not.toHaveBeenCalled() + expect(queryFn2).not.toHaveBeenCalled() + }) + + // Cleanup + await Promise.all([ + collection.cleanup(), + collection1.cleanup(), + collection2.cleanup(), + ]) + }) + describe(`Error Handling`, () => { // Helper to create test collection with common configuration const createErrorHandlingTestCollection = ( From 9f34ce8cb20100659a62fe01e6bfd188a4e4467f Mon Sep 17 00:00:00 2001 From: Lucas Weng Date: Sun, 14 Sep 2025 20:42:07 +0800 Subject: [PATCH 2/6] feat: add refetchType option for more granular refetching control --- docs/collections/query-collection.md | 14 +- packages/query-db-collection/src/query.ts | 14 ++ .../query-db-collection/tests/query.test.ts | 220 ++++++++++++++++++ 3 files changed, 246 insertions(+), 2 deletions(-) diff --git a/docs/collections/query-collection.md b/docs/collections/query-collection.md index 6f641dbf3..5b77b53fb 100644 --- a/docs/collections/query-collection.md +++ b/docs/collections/query-collection.md @@ -57,6 +57,14 @@ The `queryCollectionOptions` function accepts the following options: - `select`: Function that lets extract array items when they’re wrapped with metadata - `enabled`: Whether the query should automatically run (default: `true`) +- `refetchType`: The type of refetch to perform (default: `all`) + - `all`: Refetch this collection regardless of observer state + - `active`: Refetch only when there is an active observer + - `inactive`: Refetch only when there is no active observer + - Notes: + - Refetch only targets queries that already exist in the TanStack Query cache for the exact `queryKey` + - If `enabled: false`, `utils.refetch()` is a no-op for all `refetchType` values + - An "active observer" exists while the collection is syncing (e.g. when `startSync: true` or once started manually) - `refetchInterval`: Refetch interval in milliseconds - `retry`: Retry configuration for failed queries - `retryDelay`: Delay between retries @@ -135,7 +143,9 @@ This is useful when: The collection provides these utility methods via `collection.utils`: -- `refetch()`: Manually trigger a refetch of the query +- `refetch(opts?)`: Manually trigger a refetch of the query + - `opts.throwOnError`: Whether to throw an error if the refetch fails (default: `false`) + - Targets only the exact `queryKey` and respects `refetchType` (`'all' | 'active' | 'inactive'`). ## Direct Writes @@ -348,4 +358,4 @@ All direct write methods are available on `collection.utils`: - `writeDelete(keys)`: Delete one or more items directly - `writeUpsert(data)`: Insert or update one or more items directly - `writeBatch(callback)`: Perform multiple operations atomically -- `refetch()`: Manually trigger a refetch of the query +- `refetch(opts?)`: Manually trigger a refetch of the query diff --git a/packages/query-db-collection/src/query.ts b/packages/query-db-collection/src/query.ts index 239452cb9..8e2c5957d 100644 --- a/packages/query-db-collection/src/query.ts +++ b/packages/query-db-collection/src/query.ts @@ -77,6 +77,18 @@ export interface QueryCollectionConfig< // Query-specific options /** Whether the query should automatically run (default: true) */ enabled?: boolean + /** + * The type of refetch to perform (default: all) + * - `all`: Refetch this collection regardless of observer state + * - `active`: Refetch only when there is an active observer + * - `inactive`: Refetch only when there is no active observer + * + * Notes: + * - Refetch only targets queries that already exist in the TanStack Query cache for the exact `queryKey` + * - If `enabled: false`, `utils.refetch()` is a no-op for all `refetchType` values + * - An "active observer" exists while the collection is syncing (e.g. when `startSync: true` or once started manually) + */ + refetchType?: `active` | `inactive` | `all` refetchInterval?: QueryObserverOptions< Array, TError, @@ -381,6 +393,7 @@ export function queryCollectionOptions( select, queryClient, enabled, + refetchType = `all`, refetchInterval, retry, retryDelay, @@ -602,6 +615,7 @@ export function queryCollectionOptions( { queryKey: queryKey, exact: true, + type: refetchType, }, { throwOnError: opts?.throwOnError, diff --git a/packages/query-db-collection/tests/query.test.ts b/packages/query-db-collection/tests/query.test.ts index 916e31cd1..c031f2e2b 100644 --- a/packages/query-db-collection/tests/query.test.ts +++ b/packages/query-db-collection/tests/query.test.ts @@ -2033,6 +2033,226 @@ describe(`QueryCollection`, () => { ]) }) + describe(`refetchType`, () => { + it(`should refetch for 'all' when no observers exist`, async () => { + const mockItems: Array = [{ id: `1`, name: `Item 1` }] + const queryKey = [`refetch-all-test-query`] + const queryFn = vi.fn().mockResolvedValue(mockItems) + + // TanStack Query only refetches queries that already exist in the cache + await queryClient.prefetchQuery({ queryKey, queryFn }) + expect(queryFn).toHaveBeenCalledTimes(1) + + const collection = createCollection( + queryCollectionOptions({ + id: `refetch-all-test-query`, + queryClient, + queryKey, + queryFn, + getKey, + refetchType: `all`, + // Do not start sync: no observers -> inactive + startSync: false, + }) + ) + + // Clear mock to test refetch behavior + queryFn.mockClear() + + await collection.utils.refetch() + expect(queryFn).toHaveBeenCalledTimes(1) + }) + + it(`should refetch for 'all' when an active observer exists`, async () => { + const queryKey = [`refetch-all-test-query`] + const queryFn = vi.fn().mockResolvedValue([{ id: `1`, name: `A` }]) + + // TanStack Query only refetches queries that already exist in the cache + await queryClient.prefetchQuery({ queryKey, queryFn }) + + const collection = createCollection( + queryCollectionOptions({ + id: `refetch-all-test-query`, + queryClient, + queryKey, + queryFn, + getKey, + refetchType: `all`, + startSync: true, + }) + ) + + // Clear mock to test refetch behavior + queryFn.mockClear() + + await collection.utils.refetch() + expect(queryFn).toHaveBeenCalledTimes(1) + }) + + it(`should be no-op for 'active' when no observers exist`, async () => { + const mockItems: Array = [{ id: `1`, name: `Item 1` }] + const queryKey = [`refetch-active-test-query`] + const queryFn = vi.fn().mockResolvedValue(mockItems) + + // TanStack Query only refetches queries that already exist in the cache + await queryClient.prefetchQuery({ queryKey, queryFn }) + expect(queryFn).toHaveBeenCalledTimes(1) + + const collection = createCollection( + queryCollectionOptions({ + id: `refetch-active-test-query`, + queryClient, + queryKey, + queryFn, + getKey, + refetchType: `active`, + // Do not start sync: no observers -> inactive + startSync: false, + }) + ) + + // Clear mock to test refetch behavior + queryFn.mockClear() + + await collection.utils.refetch() + expect(queryFn).not.toHaveBeenCalled() + }) + + it(`should refetch for 'active' when an active observer exists`, async () => { + const queryKey = [`refetch-active-test-query`] + const queryFn = vi.fn().mockResolvedValue([{ id: `1`, name: `A` }]) + + // TanStack Query only refetches queries that already exist in the cache + await queryClient.prefetchQuery({ queryKey, queryFn }) + + const collection = createCollection( + queryCollectionOptions({ + id: `refetch-active-test-query`, + queryClient, + queryKey, + queryFn, + getKey, + refetchType: `active`, + startSync: true, // observer exists but query is disabled + }) + ) + + // Clear mock to test refetch behavior + queryFn.mockClear() + + await collection.utils.refetch() + expect(queryFn).toHaveBeenCalledTimes(1) + }) + + it(`should refetch for 'inactive' when no observers exist`, async () => { + const mockItems: Array = [{ id: `1`, name: `Item 1` }] + const queryKey = [`refetch-inactive-test-query`] + const queryFn = vi.fn().mockResolvedValue(mockItems) + + // TanStack Query only refetches queries that already exist in the cache + await queryClient.prefetchQuery({ queryKey, queryFn }) + expect(queryFn).toHaveBeenCalledTimes(1) + + const collection = createCollection( + queryCollectionOptions({ + id: `refetch-inactive-test-query`, + queryClient, + queryKey, + queryFn, + getKey, + refetchType: `inactive`, + // Do not start sync: no observers -> inactive + startSync: false, + }) + ) + + // Clear mock to test refetch behavior + queryFn.mockClear() + + await collection.utils.refetch() + expect(queryFn).toHaveBeenCalledTimes(1) + }) + + it(`should be no-op for 'inactive' when an active observer exists`, async () => { + const queryKey = [`refetch-inactive-test-query`] + const queryFn = vi.fn().mockResolvedValue([{ id: `1`, name: `A` }]) + + // TanStack Query only refetches queries that already exist in the cache + await queryClient.prefetchQuery({ queryKey, queryFn }) + + const collection = createCollection( + queryCollectionOptions({ + id: `refetch-inactive-test-query`, + queryClient, + queryKey, + queryFn, + getKey, + refetchType: `inactive`, + startSync: true, + }) + ) + + // Clear mock to test refetch behavior + queryFn.mockClear() + + await collection.utils.refetch() + expect(queryFn).not.toHaveBeenCalled() + }) + + it(`should be no-op for all refetchType values when query is not in cache`, async () => { + const base = `no-cache-refetch-test-query` + for (const type of [`active`, `inactive`, `all`] as const) { + const queryKey = [base, type] + const queryFn = vi.fn().mockResolvedValue([{ id: `1`, name: `A` }]) + + const collection = createCollection( + queryCollectionOptions({ + id: `no-cache-refetch-test-query-${type}`, + queryClient, + queryKey, + queryFn, + getKey, + refetchType: type, + startSync: false, // no observer; also do not prefetch + }) + ) + + await collection.utils.refetch() + expect(queryFn).not.toHaveBeenCalled() + } + }) + + it(`should be no-op for all refetchType values when query is disabled`, async () => { + const base = `refetch-test-query` + for (const type of [`active`, `inactive`, `all`] as const) { + const queryKey = [base, type] + const queryFn = vi.fn().mockResolvedValue([{ id: `1`, name: `A` }]) + + // TanStack Query only refetches queries that already exist in the cache + await queryClient.prefetchQuery({ queryKey, queryFn }) + + const collection = createCollection( + queryCollectionOptions({ + id: `no-cache-refetch-test-query-${type}`, + queryClient, + queryKey, + queryFn, + getKey, + refetchType: type, + startSync: true, + enabled: false, + }) + ) + + // Clear mock to test refetch behavior + queryFn.mockClear() + + await collection.utils.refetch() + expect(queryFn).not.toHaveBeenCalled() + } + }) + }) + describe(`Error Handling`, () => { // Helper to create test collection with common configuration const createErrorHandlingTestCollection = ( From 41588aea69dfd32e3cad47b3fe71a2de38256535 Mon Sep 17 00:00:00 2001 From: Lucas Weng Date: Sun, 14 Sep 2025 20:50:50 +0800 Subject: [PATCH 3/6] chore: add changeset --- .changeset/soft-doodles-cover.md | 5 +++++ 1 file changed, 5 insertions(+) create mode 100644 .changeset/soft-doodles-cover.md diff --git a/.changeset/soft-doodles-cover.md b/.changeset/soft-doodles-cover.md new file mode 100644 index 000000000..93a6ce36e --- /dev/null +++ b/.changeset/soft-doodles-cover.md @@ -0,0 +1,5 @@ +--- +"@tanstack/query-db-collection": patch +--- + +Implement exact query key targeting to prevent unintended cascading refetches of related queries, and add refetchType option to query collections for granular refetch control with 'all', 'active', and 'inactive' modes From 3438c8445702ffbc9000b767a04c6b037f3d8056 Mon Sep 17 00:00:00 2001 From: Kyle Mathews Date: Fri, 24 Oct 2025 09:12:04 -0600 Subject: [PATCH 4/6] refactor: make utils.refetch() bypass enabled: false and remove refetchType MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Changes: - Use queryObserver.refetch() for all refetch calls (both utils and internal handlers) - Bypasses enabled: false to support manual fetch patterns (matches TanStack Query hook behavior) - Fixes clearError() to work even when enabled: false - Return QueryObserverResult instead of void for better DX - Remove refetchType option - not needed with exact targeting via observer - Add tests for clearError() exact targeting and throwOnError behavior - Update docs to clarify refetch semantics With exact targeting via queryObserver, refetchType filtering doesn't add value. Users always want their collection data refetched, whether from utils.refetch() or internal mutation handlers. 🤖 Generated with [Claude Code](https://claude.com/claude-code) Co-Authored-By: Claude --- .changeset/soft-doodles-cover.md | 4 +- docs/collections/query-collection.md | 135 ++++---- packages/query-db-collection/src/query.ts | 61 ++-- .../query-db-collection/tests/query.test.ts | 314 ++++++++---------- 4 files changed, 254 insertions(+), 260 deletions(-) diff --git a/.changeset/soft-doodles-cover.md b/.changeset/soft-doodles-cover.md index 93a6ce36e..102b18448 100644 --- a/.changeset/soft-doodles-cover.md +++ b/.changeset/soft-doodles-cover.md @@ -2,4 +2,6 @@ "@tanstack/query-db-collection": patch --- -Implement exact query key targeting to prevent unintended cascading refetches of related queries, and add refetchType option to query collections for granular refetch control with 'all', 'active', and 'inactive' modes +**Behavior change**: `utils.refetch()` now uses exact query key targeting (previously used prefix matching). This prevents unintended cascading refetches of related queries. For example, refetching `['todos', 'project-1']` will no longer trigger refetches of `['todos']` or `['todos', 'project-2']`. + +Additionally, `utils.refetch()` now bypasses `enabled: false` to support manual/imperative refetch patterns (matching TanStack Query hook behavior) and returns `QueryObserverResult` instead of `void` for better DX. diff --git a/docs/collections/query-collection.md b/docs/collections/query-collection.md index fb088af96..91a0f7dea 100644 --- a/docs/collections/query-collection.md +++ b/docs/collections/query-collection.md @@ -9,6 +9,7 @@ Query collections provide seamless integration between TanStack DB and TanStack ## Overview The `@tanstack/query-db-collection` package allows you to create collections that: + - Automatically sync with remote data via TanStack Query - Support optimistic updates with automatic rollback on errors - Handle persistence through customizable mutation handlers @@ -23,17 +24,17 @@ npm install @tanstack/query-db-collection @tanstack/query-core @tanstack/db ## Basic Usage ```typescript -import { QueryClient } from '@tanstack/query-core' -import { createCollection } from '@tanstack/db' -import { queryCollectionOptions } from '@tanstack/query-db-collection' +import { QueryClient } from "@tanstack/query-core" +import { createCollection } from "@tanstack/db" +import { queryCollectionOptions } from "@tanstack/query-db-collection" const queryClient = new QueryClient() const todosCollection = createCollection( queryCollectionOptions({ - queryKey: ['todos'], + queryKey: ["todos"], queryFn: async () => { - const response = await fetch('/api/todos') + const response = await fetch("/api/todos") return response.json() }, queryClient, @@ -55,16 +56,8 @@ The `queryCollectionOptions` function accepts the following options: ### Query Options -- `select`: Function that lets extract array items when they’re wrapped with metadata +- `select`: Function that lets extract array items when they're wrapped with metadata - `enabled`: Whether the query should automatically run (default: `true`) -- `refetchType`: The type of refetch to perform (default: `all`) - - `all`: Refetch this collection regardless of observer state - - `active`: Refetch only when there is an active observer - - `inactive`: Refetch only when there is no active observer - - Notes: - - Refetch only targets queries that already exist in the TanStack Query cache for the exact `queryKey` - - If `enabled: false`, `utils.refetch()` is a no-op for all `refetchType` values - - An "active observer" exists while the collection is syncing (e.g. when `startSync: true` or once started manually) - `refetchInterval`: Refetch interval in milliseconds - `retry`: Retry configuration for failed queries - `retryDelay`: Delay between retries @@ -91,30 +84,30 @@ You can define handlers that are called when mutations occur. These handlers can ```typescript const todosCollection = createCollection( queryCollectionOptions({ - queryKey: ['todos'], + queryKey: ["todos"], queryFn: fetchTodos, queryClient, getKey: (item) => item.id, - + onInsert: async ({ transaction }) => { - const newItems = transaction.mutations.map(m => m.modified) + const newItems = transaction.mutations.map((m) => m.modified) await api.createTodos(newItems) // Returning nothing or { refetch: true } will trigger a refetch // Return { refetch: false } to skip automatic refetch }, - + onUpdate: async ({ transaction }) => { - const updates = transaction.mutations.map(m => ({ + const updates = transaction.mutations.map((m) => ({ id: m.key, - changes: m.changes + changes: m.changes, })) await api.updateTodos(updates) }, - + onDelete: async ({ transaction }) => { - const ids = transaction.mutations.map(m => m.key) + const ids = transaction.mutations.map((m) => m.key) await api.deleteTodos(ids) - } + }, }) ) ``` @@ -127,14 +120,15 @@ You can control this behavior by returning an object with a `refetch` property: ```typescript onInsert: async ({ transaction }) => { - await api.createTodos(transaction.mutations.map(m => m.modified)) - + await api.createTodos(transaction.mutations.map((m) => m.modified)) + // Skip the automatic refetch return { refetch: false } } ``` This is useful when: + - You're confident the server state matches what you sent - You want to avoid unnecessary network requests - You're handling state updates through other mechanisms (like WebSockets) @@ -145,7 +139,8 @@ The collection provides these utility methods via `collection.utils`: - `refetch(opts?)`: Manually trigger a refetch of the query - `opts.throwOnError`: Whether to throw an error if the refetch fails (default: `false`) - - Targets only the exact `queryKey` and respects `refetchType` (`'all' | 'active' | 'inactive'`). + - Bypasses `enabled: false` to support imperative/manual refetching patterns (similar to hook `refetch()` behavior) + - Returns `QueryObserverResult` for inspecting the result ## Direct Writes @@ -154,10 +149,12 @@ Direct writes are intended for scenarios where the normal query/mutation flow do ### Understanding the Data Stores Query Collections maintain two data stores: + 1. **Synced Data Store** - The authoritative state synchronized with the server via `queryFn` 2. **Optimistic Mutations Store** - Temporary changes that are applied optimistically before server confirmation Normal collection operations (insert, update, delete) create optimistic mutations that are: + - Applied immediately to the UI - Sent to the server via persistence handlers - Rolled back automatically if the server request fails @@ -168,6 +165,7 @@ Direct writes bypass this system entirely and write directly to the synced data ### When to Use Direct Writes Direct writes should be used when: + - You need to sync real-time updates from WebSockets or server-sent events - You're dealing with large datasets where refetching everything is too expensive - You receive incremental updates or server-computed field updates @@ -177,19 +175,28 @@ Direct writes should be used when: ```typescript // Insert a new item directly to the synced data store -todosCollection.utils.writeInsert({ id: '1', text: 'Buy milk', completed: false }) +todosCollection.utils.writeInsert({ + id: "1", + text: "Buy milk", + completed: false, +}) // Update an existing item in the synced data store -todosCollection.utils.writeUpdate({ id: '1', completed: true }) +todosCollection.utils.writeUpdate({ id: "1", completed: true }) // Delete an item from the synced data store -todosCollection.utils.writeDelete('1') +todosCollection.utils.writeDelete("1") // Upsert (insert or update) in the synced data store -todosCollection.utils.writeUpsert({ id: '1', text: 'Buy milk', completed: false }) +todosCollection.utils.writeUpsert({ + id: "1", + text: "Buy milk", + completed: false, +}) ``` These operations: + - Write directly to the synced data store - Do NOT create optimistic mutations - Do NOT trigger automatic query refetches @@ -202,10 +209,10 @@ The `writeBatch` method allows you to perform multiple operations atomically. An ```typescript todosCollection.utils.writeBatch(() => { - todosCollection.utils.writeInsert({ id: '1', text: 'Buy milk' }) - todosCollection.utils.writeInsert({ id: '2', text: 'Walk dog' }) - todosCollection.utils.writeUpdate({ id: '3', completed: true }) - todosCollection.utils.writeDelete('4') + todosCollection.utils.writeInsert({ id: "1", text: "Buy milk" }) + todosCollection.utils.writeInsert({ id: "2", text: "Walk dog" }) + todosCollection.utils.writeUpdate({ id: "3", completed: true }) + todosCollection.utils.writeDelete("4") }) ``` @@ -213,17 +220,17 @@ todosCollection.utils.writeBatch(() => { ```typescript // Handle real-time updates from WebSocket without triggering full refetches -ws.on('todos:update', (changes) => { +ws.on("todos:update", (changes) => { todosCollection.utils.writeBatch(() => { - changes.forEach(change => { + changes.forEach((change) => { switch (change.type) { - case 'insert': + case "insert": todosCollection.utils.writeInsert(change.data) break - case 'update': + case "update": todosCollection.utils.writeUpdate(change.data) break - case 'delete': + case "delete": todosCollection.utils.writeDelete(change.id) break } @@ -239,13 +246,13 @@ When the server returns computed fields (like server-generated IDs or timestamps ```typescript const todosCollection = createCollection( queryCollectionOptions({ - queryKey: ['todos'], + queryKey: ["todos"], queryFn: fetchTodos, queryClient, getKey: (item) => item.id, onInsert: async ({ transaction }) => { - const newItems = transaction.mutations.map(m => m.modified) + const newItems = transaction.mutations.map((m) => m.modified) // Send to server and get back items with server-computed fields const serverItems = await api.createTodos(newItems) @@ -253,7 +260,7 @@ const todosCollection = createCollection( // Sync server-computed fields (like server-generated IDs, timestamps, etc.) // to the collection's synced data store todosCollection.utils.writeBatch(() => { - serverItems.forEach(serverItem => { + serverItems.forEach((serverItem) => { todosCollection.utils.writeInsert(serverItem) }) }) @@ -264,26 +271,26 @@ const todosCollection = createCollection( }, onUpdate: async ({ transaction }) => { - const updates = transaction.mutations.map(m => ({ + const updates = transaction.mutations.map((m) => ({ id: m.key, - changes: m.changes + changes: m.changes, })) const serverItems = await api.updateTodos(updates) // Sync server-computed fields from the update response todosCollection.utils.writeBatch(() => { - serverItems.forEach(serverItem => { + serverItems.forEach((serverItem) => { todosCollection.utils.writeUpdate(serverItem) }) }) return { refetch: false } - } + }, }) ) // Usage is just like a regular collection -todosCollection.insert({ text: 'Buy milk', completed: false }) +todosCollection.insert({ text: "Buy milk", completed: false }) ``` ### Example: Large Dataset Pagination @@ -292,10 +299,10 @@ todosCollection.insert({ text: 'Buy milk', completed: false }) // Load additional pages without refetching existing data const loadMoreTodos = async (page) => { const newTodos = await api.getTodos({ page, limit: 50 }) - + // Add new items without affecting existing ones todosCollection.utils.writeBatch(() => { - newTodos.forEach(todo => { + newTodos.forEach((todo) => { todosCollection.utils.writeInsert(todo) }) }) @@ -328,31 +335,33 @@ Since the query collection expects `queryFn` to return the complete state, you c ```typescript const todosCollection = createCollection( queryCollectionOptions({ - queryKey: ['todos'], + queryKey: ["todos"], queryFn: async ({ queryKey }) => { // Get existing data from cache const existingData = queryClient.getQueryData(queryKey) || [] - + // Fetch only new/updated items (e.g., changes since last sync) - const lastSyncTime = localStorage.getItem('todos-last-sync') - const newData = await fetch(`/api/todos?since=${lastSyncTime}`).then(r => r.json()) - + const lastSyncTime = localStorage.getItem("todos-last-sync") + const newData = await fetch(`/api/todos?since=${lastSyncTime}`).then( + (r) => r.json() + ) + // Merge new data with existing data - const existingMap = new Map(existingData.map(item => [item.id, item])) - + const existingMap = new Map(existingData.map((item) => [item.id, item])) + // Apply updates and additions - newData.forEach(item => { + newData.forEach((item) => { existingMap.set(item.id, item) }) - + // Handle deletions if your API provides them if (newData.deletions) { - newData.deletions.forEach(id => existingMap.delete(id)) + newData.deletions.forEach((id) => existingMap.delete(id)) } - + // Update sync time - localStorage.setItem('todos-last-sync', new Date().toISOString()) - + localStorage.setItem("todos-last-sync", new Date().toISOString()) + // Return the complete merged state return Array.from(existingMap.values()) }, @@ -363,6 +372,7 @@ const todosCollection = createCollection( ``` This pattern allows you to: + - Fetch only incremental changes from your API - Merge those changes with existing data - Return the complete state that the collection expects @@ -373,6 +383,7 @@ This pattern allows you to: Direct writes update the collection immediately and also update the TanStack Query cache. However, they do not prevent the normal query sync behavior. If your `queryFn` returns data that conflicts with your direct writes, the query data will take precedence. To handle this properly: + 1. Use `{ refetch: false }` in your persistence handlers when using direct writes 2. Set appropriate `staleTime` to prevent unnecessary refetches 3. Design your `queryFn` to be aware of incremental updates (e.g., only fetch new data) diff --git a/packages/query-db-collection/src/query.ts b/packages/query-db-collection/src/query.ts index 46a998536..9ea6d2e57 100644 --- a/packages/query-db-collection/src/query.ts +++ b/packages/query-db-collection/src/query.ts @@ -11,6 +11,7 @@ import type { QueryFunctionContext, QueryKey, QueryObserverOptions, + QueryObserverResult, } from "@tanstack/query-core" import type { BaseCollectionConfig, @@ -77,18 +78,6 @@ export interface QueryCollectionConfig< // Query-specific options /** Whether the query should automatically run (default: true) */ enabled?: boolean - /** - * The type of refetch to perform (default: all) - * - `all`: Refetch this collection regardless of observer state - * - `active`: Refetch only when there is an active observer - * - `inactive`: Refetch only when there is no active observer - * - * Notes: - * - Refetch only targets queries that already exist in the TanStack Query cache for the exact `queryKey` - * - If `enabled: false`, `utils.refetch()` is a no-op for all `refetchType` values - * - An "active observer" exists while the collection is syncing (e.g. when `startSync: true` or once started manually) - */ - refetchType?: `active` | `inactive` | `all` refetchInterval?: QueryObserverOptions< Array, TError, @@ -143,8 +132,11 @@ export interface QueryCollectionConfig< /** * Type for the refetch utility function + * Returns the QueryObserverResult from TanStack Query */ -export type RefetchFn = (opts?: { throwOnError?: boolean }) => Promise +export type RefetchFn = (opts?: { + throwOnError?: boolean +}) => Promise | void> /** * Utility methods available on Query Collections for direct writes and manual operations. @@ -393,7 +385,6 @@ export function queryCollectionOptions( select, queryClient, enabled, - refetchType = `all`, refetchInterval, retry, retryDelay, @@ -433,6 +424,8 @@ export function queryCollectionOptions( let errorCount = 0 /** The timestamp for when the query most recently returned the status as "error" */ let lastErrorUpdatedAt = 0 + /** Reference to the QueryObserver for imperative refetch */ + let queryObserver: QueryObserver, any, Array, Array, any> const internalSync: SyncConfig[`sync`] = (params) => { const { begin, write, commit, markReady, collection } = params @@ -465,6 +458,9 @@ export function queryCollectionOptions( any >(queryClient, observerOptions) + // Store reference for imperative refetch + queryObserver = localObserver + let isSubscribed = false let actualUnsubscribeFn: (() => void) | null = null @@ -608,19 +604,32 @@ export function queryCollectionOptions( /** * Refetch the query data - * @returns Promise that resolves when the refetch is complete + * + * Uses queryObserver.refetch() because: + * - Bypasses `enabled: false` to support manual/imperative refetch patterns (e.g., button-triggered fetch) + * - Ensures clearError() works even when enabled: false + * - Always refetches THIS specific collection (exact targeting via observer) + * - Respects retry, retryDelay, and other observer options + * + * This matches TanStack Query's hook behavior where refetch() bypasses enabled: false. + * See: https://tanstack.com/query/latest/docs/framework/react/guides/disabling-queries + * + * Used by both: + * - utils.refetch() - for explicit user-triggered refetches + * - Internal handlers (onInsert/onUpdate/onDelete) - after mutations to get fresh data + * + * @returns Promise that resolves when the refetch is complete, with QueryObserverResult */ - const refetch: RefetchFn = (opts) => { - return queryClient.refetchQueries( - { - queryKey: queryKey, - exact: true, - type: refetchType, - }, - { - throwOnError: opts?.throwOnError, - } - ) + const refetch: RefetchFn = async (opts) => { + // Observer is created when sync starts. If never synced, nothing to refetch. + // eslint-disable-next-line @typescript-eslint/no-unnecessary-condition + if (!queryObserver) { + return + } + // Return the QueryObserverResult for users to inspect + return queryObserver.refetch({ + throwOnError: opts?.throwOnError, + }) } // Create write context for manual write operations diff --git a/packages/query-db-collection/tests/query.test.ts b/packages/query-db-collection/tests/query.test.ts index c5c8e1872..b94c73be7 100644 --- a/packages/query-db-collection/tests/query.test.ts +++ b/packages/query-db-collection/tests/query.test.ts @@ -669,52 +669,78 @@ describe(`QueryCollection`, () => { const onInsertDefault = vi.fn().mockResolvedValue(undefined) // Default behavior should refetch const onInsertFalse = vi.fn().mockResolvedValue({ refetch: false }) // No refetch - // Create a spy on the refetch function itself - const refetchSpy = vi.fn().mockResolvedValue(undefined) - // Create configs with the handlers + const queryFnDefault = vi + .fn() + .mockResolvedValue([{ id: `1`, name: `Item 1` }]) + const queryFnFalse = vi + .fn() + .mockResolvedValue([{ id: `1`, name: `Item 1` }]) + const configDefault: QueryCollectionConfig = { id: `test-default`, queryClient, queryKey: [`refetchTest`, `default`], - queryFn: vi.fn().mockResolvedValue([{ id: `1`, name: `Item 1` }]), + queryFn: queryFnDefault, getKey, onInsert: onInsertDefault, + startSync: true, } const configFalse: QueryCollectionConfig = { id: `test-false`, queryClient, queryKey: [`refetchTest`, `false`], - queryFn: vi.fn().mockResolvedValue([{ id: `1`, name: `Item 1` }]), + queryFn: queryFnFalse, getKey, onInsert: onInsertFalse, + startSync: true, } - // Mock the queryClient.refetchQueries method which is called by collection.utils.refetch() - vi.spyOn(queryClient, `refetchQueries`).mockImplementation(refetchSpy) - // Test case 1: Default behavior (undefined return) should trigger refetch const optionsDefault = queryCollectionOptions(configDefault) + const collectionDefault = createCollection(optionsDefault) + + // Wait for initial sync + await vi.waitFor(() => { + expect(collectionDefault.status).toBe(`ready`) + }) + + // Clear initial call + queryFnDefault.mockClear() + await optionsDefault.onInsert!(insertMockParams) - // Verify handler was called and refetch was triggered + // Verify handler was called and refetch was triggered (queryFn called again) expect(onInsertDefault).toHaveBeenCalledWith(insertMockParams) - expect(refetchSpy).toHaveBeenCalledTimes(1) - - // Reset mocks - refetchSpy.mockClear() + await vi.waitFor(() => { + expect(queryFnDefault).toHaveBeenCalledTimes(1) + }) // Test case 2: Explicit { refetch: false } should not trigger refetch const optionsFalse = queryCollectionOptions(configFalse) + const collectionFalse = createCollection(optionsFalse) + + // Wait for initial sync + await vi.waitFor(() => { + expect(collectionFalse.status).toBe(`ready`) + }) + + // Clear initial call + queryFnFalse.mockClear() + await optionsFalse.onInsert!(insertMockParams) - // Verify handler was called but refetch was NOT triggered + // Verify handler was called but refetch was NOT triggered (queryFn not called) expect(onInsertFalse).toHaveBeenCalledWith(insertMockParams) - expect(refetchSpy).not.toHaveBeenCalled() + // Wait a bit to ensure no refetch happens + await new Promise((resolve) => setTimeout(resolve, 50)) + expect(queryFnFalse).not.toHaveBeenCalled() - // Restore original function - vi.restoreAllMocks() + await Promise.all([ + collectionDefault.cleanup(), + collectionFalse.cleanup(), + ]) }) }) @@ -2033,223 +2059,169 @@ describe(`QueryCollection`, () => { ]) }) - describe(`refetchType`, () => { - it(`should refetch for 'all' when no observers exist`, async () => { - const mockItems: Array = [{ id: `1`, name: `Item 1` }] - const queryKey = [`refetch-all-test-query`] - const queryFn = vi.fn().mockResolvedValue(mockItems) - - // TanStack Query only refetches queries that already exist in the cache - await queryClient.prefetchQuery({ queryKey, queryFn }) - expect(queryFn).toHaveBeenCalledTimes(1) + it(`should use exact targeting when clearError() refetches to avoid unintended cascading`, async () => { + const queryKey1 = [`todos`, `project-1`] + const queryKey2 = [`todos`, `project-2`] - const collection = createCollection( - queryCollectionOptions({ - id: `refetch-all-test-query`, - queryClient, - queryKey, - queryFn, - getKey, - refetchType: `all`, - // Do not start sync: no observers -> inactive - startSync: false, - }) - ) + const testError = new Error(`Test error`) + const mockItems = [{ id: `1`, name: `Item 1` }] + const queryFn1 = vi + .fn() + .mockRejectedValueOnce(testError) + .mockResolvedValue(mockItems) + const queryFn2 = vi.fn().mockResolvedValue(mockItems) - // Clear mock to test refetch behavior - queryFn.mockClear() + const config1: QueryCollectionConfig = { + id: `project-1-todos-clear-error`, + queryClient, + queryKey: queryKey1, + queryFn: queryFn1, + getKey, + startSync: true, + retry: false, + } + const config2: QueryCollectionConfig = { + id: `project-2-todos-clear-error`, + queryClient, + queryKey: queryKey2, + queryFn: queryFn2, + getKey, + startSync: true, + retry: false, + } - await collection.utils.refetch() - expect(queryFn).toHaveBeenCalledTimes(1) - }) + const options1 = queryCollectionOptions(config1) + const options2 = queryCollectionOptions(config2) - it(`should refetch for 'all' when an active observer exists`, async () => { - const queryKey = [`refetch-all-test-query`] - const queryFn = vi.fn().mockResolvedValue([{ id: `1`, name: `A` }]) + const collection1 = createCollection(options1) + const collection2 = createCollection(options2) - // TanStack Query only refetches queries that already exist in the cache - await queryClient.prefetchQuery({ queryKey, queryFn }) + await vi.waitFor(() => { + expect(collection1.utils.isError()).toBe(true) + expect(collection2.status).toBe(`ready`) + }) - const collection = createCollection( - queryCollectionOptions({ - id: `refetch-all-test-query`, - queryClient, - queryKey, - queryFn, - getKey, - refetchType: `all`, - startSync: true, - }) - ) + queryFn1.mockClear() + queryFn2.mockClear() - // Clear mock to test refetch behavior - queryFn.mockClear() + await collection1.utils.clearError() - await collection.utils.refetch() - expect(queryFn).toHaveBeenCalledTimes(1) + await vi.waitFor(() => { + expect(queryFn1).toHaveBeenCalledTimes(1) + expect(queryFn2).not.toHaveBeenCalled() }) - it(`should be no-op for 'active' when no observers exist`, async () => { - const mockItems: Array = [{ id: `1`, name: `Item 1` }] - const queryKey = [`refetch-active-test-query`] - const queryFn = vi.fn().mockResolvedValue(mockItems) + await Promise.all([collection1.cleanup(), collection2.cleanup()]) + }) - // TanStack Query only refetches queries that already exist in the cache - await queryClient.prefetchQuery({ queryKey, queryFn }) - expect(queryFn).toHaveBeenCalledTimes(1) + it(`should propagate errors when throwOnError is true in refetch`, async () => { + const testError = new Error(`Refetch error`) + const queryKey = [`throw-on-error-test`] + const queryFn = vi.fn().mockRejectedValue(testError) - const collection = createCollection( - queryCollectionOptions({ - id: `refetch-active-test-query`, - queryClient, - queryKey, - queryFn, - getKey, - refetchType: `active`, - // Do not start sync: no observers -> inactive - startSync: false, - }) - ) + await queryClient.prefetchQuery({ queryKey, queryFn }) - // Clear mock to test refetch behavior - queryFn.mockClear() + const collection = createCollection( + queryCollectionOptions({ + id: `throw-on-error-test`, + queryClient, + queryKey, + queryFn, + getKey, + retry: false, + startSync: true, + }) + ) - await collection.utils.refetch() - expect(queryFn).not.toHaveBeenCalled() + await vi.waitFor(() => { + expect(collection.utils.isError()).toBe(true) }) - it(`should refetch for 'active' when an active observer exists`, async () => { - const queryKey = [`refetch-active-test-query`] - const queryFn = vi.fn().mockResolvedValue([{ id: `1`, name: `A` }]) + await expect( + collection.utils.refetch({ throwOnError: true }) + ).rejects.toThrow(testError) - // TanStack Query only refetches queries that already exist in the cache - await queryClient.prefetchQuery({ queryKey, queryFn }) + // Should not throw when throwOnError is false + await collection.utils.refetch({ throwOnError: false }) + + await collection.cleanup() + }) + + describe(`refetch() behavior`, () => { + it(`should refetch when collection is syncing (startSync: true)`, async () => { + const queryKey = [`refetch-test-syncing`] + const queryFn = vi.fn().mockResolvedValue([{ id: `1`, name: `A` }]) const collection = createCollection( queryCollectionOptions({ - id: `refetch-active-test-query`, + id: `refetch-test-syncing`, queryClient, queryKey, queryFn, getKey, - refetchType: `active`, - startSync: true, // observer exists but query is disabled + startSync: true, }) ) - // Clear mock to test refetch behavior + await vi.waitFor(() => { + expect(collection.status).toBe(`ready`) + }) + queryFn.mockClear() await collection.utils.refetch() expect(queryFn).toHaveBeenCalledTimes(1) + + await collection.cleanup() }) - it(`should refetch for 'inactive' when no observers exist`, async () => { + it(`should refetch even when enabled: false (imperative refetch pattern)`, async () => { const mockItems: Array = [{ id: `1`, name: `Item 1` }] - const queryKey = [`refetch-inactive-test-query`] + const queryKey = [`manual-fetch-test`] const queryFn = vi.fn().mockResolvedValue(mockItems) - // TanStack Query only refetches queries that already exist in the cache - await queryClient.prefetchQuery({ queryKey, queryFn }) - expect(queryFn).toHaveBeenCalledTimes(1) - const collection = createCollection( queryCollectionOptions({ - id: `refetch-inactive-test-query`, + id: `manual-fetch-test`, queryClient, queryKey, queryFn, getKey, - refetchType: `inactive`, - // Do not start sync: no observers -> inactive - startSync: false, + enabled: false, + startSync: true, }) ) - // Clear mock to test refetch behavior - queryFn.mockClear() + // Query should not auto-fetch due to enabled: false + expect(queryFn).not.toHaveBeenCalled() + // But manual refetch should work await collection.utils.refetch() expect(queryFn).toHaveBeenCalledTimes(1) + + await collection.cleanup() }) - it(`should be no-op for 'inactive' when an active observer exists`, async () => { - const queryKey = [`refetch-inactive-test-query`] + it(`should be no-op when sync has not started (no observer created)`, async () => { + const queryKey = [`refetch-test-no-sync`] const queryFn = vi.fn().mockResolvedValue([{ id: `1`, name: `A` }]) - // TanStack Query only refetches queries that already exist in the cache - await queryClient.prefetchQuery({ queryKey, queryFn }) - const collection = createCollection( queryCollectionOptions({ - id: `refetch-inactive-test-query`, + id: `refetch-test-no-sync`, queryClient, queryKey, queryFn, getKey, - refetchType: `inactive`, - startSync: true, + startSync: false, }) ) - // Clear mock to test refetch behavior - queryFn.mockClear() - + // Refetch should be no-op because observer doesn't exist yet await collection.utils.refetch() expect(queryFn).not.toHaveBeenCalled() - }) - - it(`should be no-op for all refetchType values when query is not in cache`, async () => { - const base = `no-cache-refetch-test-query` - for (const type of [`active`, `inactive`, `all`] as const) { - const queryKey = [base, type] - const queryFn = vi.fn().mockResolvedValue([{ id: `1`, name: `A` }]) - - const collection = createCollection( - queryCollectionOptions({ - id: `no-cache-refetch-test-query-${type}`, - queryClient, - queryKey, - queryFn, - getKey, - refetchType: type, - startSync: false, // no observer; also do not prefetch - }) - ) - await collection.utils.refetch() - expect(queryFn).not.toHaveBeenCalled() - } - }) - - it(`should be no-op for all refetchType values when query is disabled`, async () => { - const base = `refetch-test-query` - for (const type of [`active`, `inactive`, `all`] as const) { - const queryKey = [base, type] - const queryFn = vi.fn().mockResolvedValue([{ id: `1`, name: `A` }]) - - // TanStack Query only refetches queries that already exist in the cache - await queryClient.prefetchQuery({ queryKey, queryFn }) - - const collection = createCollection( - queryCollectionOptions({ - id: `no-cache-refetch-test-query-${type}`, - queryClient, - queryKey, - queryFn, - getKey, - refetchType: type, - startSync: true, - enabled: false, - }) - ) - - // Clear mock to test refetch behavior - queryFn.mockClear() - - await collection.utils.refetch() - expect(queryFn).not.toHaveBeenCalled() - } + await collection.cleanup() }) }) @@ -2644,7 +2616,7 @@ describe(`QueryCollection`, () => { const queryFn = vi.fn().mockResolvedValue(items) - const onDelete = vi.fn(async ({ transaction, collection }) => { + const onDelete = vi.fn(({ transaction, collection }) => { const deletedItem = transaction.mutations[0]?.original // Call writeDelete inside onDelete handler - this should work without throwing collection.utils.writeDelete(deletedItem.id) From 401e7c708c272f79e4d68bd6ca85909d4b270e67 Mon Sep 17 00:00:00 2001 From: Kyle Mathews Date: Fri, 24 Oct 2025 09:20:44 -0600 Subject: [PATCH 5/6] fix: clearError should return Promise not QueryObserverResult --- packages/query-db-collection/src/query.ts | 4 ++-- 1 file changed, 2 insertions(+), 2 deletions(-) diff --git a/packages/query-db-collection/src/query.ts b/packages/query-db-collection/src/query.ts index 9ea6d2e57..bbfd6db56 100644 --- a/packages/query-db-collection/src/query.ts +++ b/packages/query-db-collection/src/query.ts @@ -723,11 +723,11 @@ export function queryCollectionOptions( lastError: () => lastError, isError: () => !!lastError, errorCount: () => errorCount, - clearError: () => { + clearError: async () => { lastError = undefined errorCount = 0 lastErrorUpdatedAt = 0 - return refetch({ throwOnError: true }) + await refetch({ throwOnError: true }) }, }, } From d199a7abd5e0ace4693278d23157a4ccfc711da9 Mon Sep 17 00:00:00 2001 From: Lucas Weng Date: Sat, 25 Oct 2025 13:06:03 +0800 Subject: [PATCH 6/6] fix: type error in query.test --- packages/query-db-collection/tests/query.test.ts | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/packages/query-db-collection/tests/query.test.ts b/packages/query-db-collection/tests/query.test.ts index b94c73be7..b3a6dd712 100644 --- a/packages/query-db-collection/tests/query.test.ts +++ b/packages/query-db-collection/tests/query.test.ts @@ -2616,7 +2616,7 @@ describe(`QueryCollection`, () => { const queryFn = vi.fn().mockResolvedValue(items) - const onDelete = vi.fn(({ transaction, collection }) => { + const onDelete = vi.fn(async ({ transaction, collection }) => { const deletedItem = transaction.mutations[0]?.original // Call writeDelete inside onDelete handler - this should work without throwing collection.utils.writeDelete(deletedItem.id)