Skip to content
Open
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
5 changes: 5 additions & 0 deletions .changeset/soft-doodles-cover.md
Original file line number Diff line number Diff line change
@@ -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
14 changes: 12 additions & 2 deletions docs/collections/query-collection.md
Original file line number Diff line number Diff line change
Expand Up @@ -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
Expand Down Expand Up @@ -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

Expand Down Expand Up @@ -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
15 changes: 15 additions & 0 deletions packages/query-db-collection/src/query.ts
Original file line number Diff line number Diff line change
Expand Up @@ -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<T>,
TError,
Expand Down Expand Up @@ -381,6 +393,7 @@ export function queryCollectionOptions(
select,
queryClient,
enabled,
refetchType = `all`,
refetchInterval,
retry,
retryDelay,
Expand Down Expand Up @@ -601,6 +614,8 @@ export function queryCollectionOptions(
return queryClient.refetchQueries(
{
queryKey: queryKey,
exact: true,
type: refetchType,
},
{
throwOnError: opts?.throwOnError,
Expand Down
295 changes: 295 additions & 0 deletions packages/query-db-collection/tests/query.test.ts
Original file line number Diff line number Diff line change
Expand Up @@ -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<TestItem> = {
id: `all-todos`,
queryClient,
queryKey: queryKey,
queryFn: queryFn,
getKey,
startSync: true,
}
const config1: QueryCollectionConfig<TestItem> = {
id: `project-1-todos`,
queryClient,
queryKey: queryKey1,
queryFn: queryFn1,
getKey,
startSync: true,
}
const config2: QueryCollectionConfig<TestItem> = {
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<TestItem> = [{ 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<TestItem> = [{ 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<TestItem> = [{ 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 = (
Expand Down
Loading