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 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 832f6b755..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, @@ -601,6 +614,8 @@ export function queryCollectionOptions( return queryClient.refetchQueries( { 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 9a380547b..c031f2e2b 100644 --- a/packages/query-db-collection/tests/query.test.ts +++ b/packages/query-db-collection/tests/query.test.ts @@ -1958,6 +1958,301 @@ 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(`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 = (