From 5bd4525da9ae0a7a97fcea61b2832a35df4870d3 Mon Sep 17 00:00:00 2001 From: Lucas Weng Date: Sun, 14 Sep 2025 09:59:46 +0800 Subject: [PATCH 1/3] 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/3] 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/3] 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