diff --git a/.changeset/soft-doodles-cover.md b/.changeset/soft-doodles-cover.md new file mode 100644 index 000000000..102b18448 --- /dev/null +++ b/.changeset/soft-doodles-cover.md @@ -0,0 +1,7 @@ +--- +"@tanstack/query-db-collection": patch +--- + +**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 cd4e7eedc..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,7 +56,7 @@ 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`) - `refetchInterval`: Refetch interval in milliseconds - `retry`: Retry configuration for failed queries @@ -83,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) - } + }, }) ) ``` @@ -119,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) @@ -135,7 +137,10 @@ 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`) + - Bypasses `enabled: false` to support imperative/manual refetching patterns (similar to hook `refetch()` behavior) + - Returns `QueryObserverResult` for inspecting the result ## Direct Writes @@ -144,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 @@ -158,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 @@ -167,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 @@ -192,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") }) ``` @@ -203,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 } @@ -229,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) @@ -243,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) }) }) @@ -254,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 @@ -282,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) }) }) @@ -318,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()) }, @@ -353,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 @@ -363,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) @@ -376,4 +397,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 9512bf47c..bbfd6db56 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, @@ -131,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. @@ -420,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 @@ -452,6 +458,9 @@ export function queryCollectionOptions( any >(queryClient, observerOptions) + // Store reference for imperative refetch + queryObserver = localObserver + let isSubscribed = false let actualUnsubscribeFn: (() => void) | null = null @@ -595,17 +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, - }, - { - 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 @@ -699,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 }) }, }, } diff --git a/packages/query-db-collection/tests/query.test.ts b/packages/query-db-collection/tests/query.test.ts index 5eb888485..b3a6dd712 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(), + ]) }) }) @@ -1958,6 +1984,247 @@ 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(), + ]) + }) + + it(`should use exact targeting when clearError() refetches to avoid unintended cascading`, async () => { + const queryKey1 = [`todos`, `project-1`] + const queryKey2 = [`todos`, `project-2`] + + 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) + + 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, + } + + const options1 = queryCollectionOptions(config1) + const options2 = queryCollectionOptions(config2) + + const collection1 = createCollection(options1) + const collection2 = createCollection(options2) + + await vi.waitFor(() => { + expect(collection1.utils.isError()).toBe(true) + expect(collection2.status).toBe(`ready`) + }) + + queryFn1.mockClear() + queryFn2.mockClear() + + await collection1.utils.clearError() + + await vi.waitFor(() => { + expect(queryFn1).toHaveBeenCalledTimes(1) + expect(queryFn2).not.toHaveBeenCalled() + }) + + await Promise.all([collection1.cleanup(), collection2.cleanup()]) + }) + + 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) + + await queryClient.prefetchQuery({ queryKey, queryFn }) + + const collection = createCollection( + queryCollectionOptions({ + id: `throw-on-error-test`, + queryClient, + queryKey, + queryFn, + getKey, + retry: false, + startSync: true, + }) + ) + + await vi.waitFor(() => { + expect(collection.utils.isError()).toBe(true) + }) + + await expect( + collection.utils.refetch({ throwOnError: true }) + ).rejects.toThrow(testError) + + // 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-test-syncing`, + queryClient, + queryKey, + queryFn, + getKey, + startSync: true, + }) + ) + + await vi.waitFor(() => { + expect(collection.status).toBe(`ready`) + }) + + queryFn.mockClear() + + await collection.utils.refetch() + expect(queryFn).toHaveBeenCalledTimes(1) + + await collection.cleanup() + }) + + it(`should refetch even when enabled: false (imperative refetch pattern)`, async () => { + const mockItems: Array = [{ id: `1`, name: `Item 1` }] + const queryKey = [`manual-fetch-test`] + const queryFn = vi.fn().mockResolvedValue(mockItems) + + const collection = createCollection( + queryCollectionOptions({ + id: `manual-fetch-test`, + queryClient, + queryKey, + queryFn, + getKey, + enabled: false, + startSync: true, + }) + ) + + // 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 when sync has not started (no observer created)`, async () => { + const queryKey = [`refetch-test-no-sync`] + const queryFn = vi.fn().mockResolvedValue([{ id: `1`, name: `A` }]) + + const collection = createCollection( + queryCollectionOptions({ + id: `refetch-test-no-sync`, + queryClient, + queryKey, + queryFn, + getKey, + startSync: false, + }) + ) + + // Refetch should be no-op because observer doesn't exist yet + await collection.utils.refetch() + expect(queryFn).not.toHaveBeenCalled() + + await collection.cleanup() + }) + }) + describe(`Error Handling`, () => { // Helper to create test collection with common configuration const createErrorHandlingTestCollection = (