diff --git a/.changeset/query-options-pass-through.md b/.changeset/query-options-pass-through.md new file mode 100644 index 000000000..70a48e43e --- /dev/null +++ b/.changeset/query-options-pass-through.md @@ -0,0 +1,5 @@ +--- +'@tanstack/query-db-collection': minor +--- + +Add top-level Query Collection support for additional Query observer options while preserving QueryClient defaultOptions behavior. diff --git a/docs/collections/query-collection.md b/docs/collections/query-collection.md index 6c90f53df..0a0265c39 100644 --- a/docs/collections/query-collection.md +++ b/docs/collections/query-collection.md @@ -56,9 +56,9 @@ The `queryCollectionOptions` function accepts the following options: ### Query Options -Query Collections use TanStack Query internally, but `queryCollectionOptions` is not a full `QueryObserverOptions` pass-through. It exposes only the Query options supported by the collection adapter. Fields that affect row materialization, collection identity, and synchronization are handled by the adapter itself. +Query Collections use TanStack Query internally and expose supported Query observer options as top-level `queryCollectionOptions` fields. -The following Query options are forwarded to the underlying Query observer: +The following top-level Query Collection options are forwarded to the underlying Query observer: - `select`: Function that extracts the row array TanStack DB materializes from a wrapped Query response - `enabled`: Whether the query should automatically run (default: `true`) @@ -67,9 +67,28 @@ The following Query options are forwarded to the underlying Query observer: - `retryDelay`: Delay between retries - `staleTime`: How long data is considered fresh - `gcTime`: How long unused query data stays in the Query cache +- `refetchOnWindowFocus`: Whether to refetch when the window regains focus +- `refetchOnReconnect`: Whether to refetch when the network reconnects +- `refetchOnMount`: Whether to refetch when the observer mounts +- `networkMode`: Query network mode - `meta`: Metadata passed to the query function context. Query Collections may add `loadSubsetOptions` for on-demand queries. -Except for `meta`, these options are only passed to TanStack Query when you define them. If you omit them, `QueryClient.defaultOptions` can still apply. +```ts +const todosCollection = createCollection( + queryCollectionOptions({ + queryKey: ["todos"], + queryFn: fetchTodos, + queryClient, + getKey: (todo) => todo.id, + refetchOnWindowFocus: true, + refetchOnReconnect: true, + refetchOnMount: "always", + networkMode: "online", + }) +) +``` + +Top-level `meta` is always merged by Query Collection so it can add on-demand `loadSubsetOptions`. Other supported top-level Query options are only passed to TanStack Query when you define them. If you omit them, `QueryClient.defaultOptions` can still apply. Some fields are owned or reinterpreted by the collection adapter rather than treated as ordinary Query option pass-through: @@ -81,20 +100,19 @@ Some fields are owned or reinterpreted by the collection adapter rather than tre - `getKey`: Extracts each row's stable TanStack DB key. - Mutation handlers such as `onInsert`, `onUpdate`, and `onDelete`. -Other TanStack Query options are not currently exposed through `queryCollectionOptions`. Common examples include: +Some TanStack Query fields are owned or reinterpreted by Query Collection and are intentionally not exposed as ordinary Query observer options: + +- `queryKey`, `queryFn`, and `queryClient` +- `select` (Query Collection uses this for row extraction, not TanStack Query's observer-level `select` contract) +- `meta` (merged by Query Collection so on-demand `loadSubsetOptions` can be included) +- `subscribed` (Query Collection owns the observer subscription lifecycle) +- `structuralSharing` and `notifyOnChangeProps` (managed by Query Collection synchronization) -- `initialData` -- `placeholderData` -- `refetchOnWindowFocus` -- `refetchOnReconnect` -- `refetchOnMount` -- `networkMode` -- `throwOnError` -- configurable `structuralSharing` +Other semantic options, such as `initialData`, `placeholderData`, and TanStack Query observer-level `select`, are intentionally deferred until their Query Collection behavior is explicitly designed. ### Using with `queryOptions(...)` -If your app already uses TanStack Query's `queryOptions` helper (e.g. from `@tanstack/react-query`), you can spread those options into `queryCollectionOptions`. Note that `queryFn` must be explicitly provided since query collections require it both in types and at runtime: +If your app already uses TanStack Query's `queryOptions` helper (e.g. from `@tanstack/react-query`), you can spread compatible top-level options into `queryCollectionOptions`. Note that `queryFn` must be explicitly provided since query collections require it both in types and at runtime, and Query Collection's `select` option is for row extraction rather than TanStack Query observer-level selection: ```typescript import { QueryClient } from "@tanstack/query-core" diff --git a/packages/query-db-collection/src/query.ts b/packages/query-db-collection/src/query.ts index ea303b8dd..59c851967 100644 --- a/packages/query-db-collection/src/query.ts +++ b/packages/query-db-collection/src/query.ts @@ -49,6 +49,40 @@ type InferSchemaInput = T extends StandardSchemaV1 type TQueryKeyBuilder = (opts: LoadSubsetOptions) => TQueryKey +const queryObserverOptionKeys = [ + `enabled`, + `refetchInterval`, + `retry`, + `retryDelay`, + `staleTime`, + `gcTime`, + `refetchOnWindowFocus`, + `refetchOnReconnect`, + `refetchOnMount`, + `networkMode`, +] as const + +type QueryObserverOptionKey = (typeof queryObserverOptionKeys)[number] + +type QueryObserverOptionValues = Pick< + QueryObserverOptions, any, Array, Array, any>, + QueryObserverOptionKey +> + +function pickDefinedQueryObserverOptions( + config: Partial, +): Partial { + const options: Partial = {} + + for (const key of queryObserverOptionKeys) { + if (config[key] !== undefined) { + ;(options as Record)[key] = config[key] + } + } + + return options +} + /** * Configuration options for creating a Query Collection * @template T - The explicit type of items stored in the collection @@ -129,6 +163,34 @@ export interface QueryCollectionConfig< TQueryData, TQueryKey >[`gcTime`] + refetchOnWindowFocus?: QueryObserverOptions< + TQueryData, + TError, + Array, + TQueryData, + TQueryKey + >[`refetchOnWindowFocus`] + refetchOnReconnect?: QueryObserverOptions< + TQueryData, + TError, + Array, + TQueryData, + TQueryKey + >[`refetchOnReconnect`] + refetchOnMount?: QueryObserverOptions< + TQueryData, + TError, + Array, + TQueryData, + TQueryKey + >[`refetchOnMount`] + networkMode?: QueryObserverOptions< + TQueryData, + TError, + Array, + TQueryData, + TQueryKey + >[`networkMode`] persistedGcTime?: number /** @@ -596,6 +658,10 @@ export function queryCollectionOptions( retryDelay, staleTime, gcTime, + refetchOnWindowFocus, + refetchOnReconnect, + refetchOnMount, + networkMode, persistedGcTime, getKey, onInsert, @@ -1172,19 +1238,23 @@ export function queryCollectionOptions( Array, any > = { + ...pickDefinedQueryObserverOptions({ + enabled, + refetchInterval, + retry, + retryDelay, + staleTime, + gcTime, + refetchOnWindowFocus, + refetchOnReconnect, + refetchOnMount, + networkMode, + }), queryKey: key, queryFn: queryFunction, meta: extendedMeta, structuralSharing: true, notifyOnChangeProps: `all`, - - // Only include options that are explicitly defined to allow QueryClient defaultOptions to be used - ...(enabled !== undefined && { enabled }), - ...(refetchInterval !== undefined && { refetchInterval }), - ...(retry !== undefined && { retry }), - ...(retryDelay !== undefined && { retryDelay }), - ...(staleTime !== undefined && { staleTime }), - ...(gcTime !== undefined && { gcTime }), } const localObserver = new QueryObserver< @@ -1876,6 +1946,23 @@ export function queryCollectionOptions( // Enhanced internalSync that captures write functions for manual use const enhancedInternalSync: SyncConfig[`sync`] = (params) => { const { begin, write, commit, collection } = params + let queryClientMounted = false + + const mountQueryClient = () => { + if (!queryClientMounted) { + queryClient.mount() + queryClientMounted = true + } + } + + const unmountQueryClient = () => { + if (queryClientMounted) { + queryClient.unmount() + queryClientMounted = false + } + } + + mountQueryClient() // Get the base query key for the context (handle both static and function-based keys) const contextQueryKey = @@ -1895,8 +1982,27 @@ export function queryCollectionOptions( updateCacheData, } - // Call the original internalSync logic - return internalSync(params) + // Call the original internalSync logic, pairing QueryClient mount with the + // collection sync lifecycle so focus/reconnect managers dispatch events for + // standalone QueryClient usage. + const syncResult = internalSync(params) + const sync = + typeof syncResult === `function` + ? { cleanup: syncResult } + : typeof syncResult === `object` + ? syncResult + : {} + + return { + ...sync, + cleanup: () => { + try { + sync.cleanup?.() + } finally { + unmountQueryClient() + } + }, + } } // Create write utils using the manual-sync module diff --git a/packages/query-db-collection/tests/query.test-d.ts b/packages/query-db-collection/tests/query.test-d.ts index c526398f8..30e8f64c9 100644 --- a/packages/query-db-collection/tests/query.test-d.ts +++ b/packages/query-db-collection/tests/query.test-d.ts @@ -32,6 +32,30 @@ describe(`Query collection type resolution tests`, () => { // Create a mock QueryClient for tests const queryClient = new QueryClient() + it(`should type supported top-level Query observer options and reject adapter-owned fields`, () => { + queryCollectionOptions({ + id: `query-options-types`, + queryClient, + queryKey: [`query-options-types`], + queryFn: () => Promise.resolve([]), + getKey: (item) => item.id, + refetchOnWindowFocus: true, + refetchOnReconnect: true, + refetchOnMount: `always`, + networkMode: `online`, + }) + + queryCollectionOptions({ + id: `query-options-subscribed-owned`, + queryClient, + queryKey: [`query-options-subscribed-owned`], + queryFn: () => Promise.resolve([]), + getKey: (item) => item.id, + // @ts-expect-error Query Collection owns observer subscription lifecycle. + subscribed: false, + }) + }) + it(`should prioritize explicit type in QueryCollectionConfig`, () => { const options = queryCollectionOptions({ id: `test`, diff --git a/packages/query-db-collection/tests/query.test.ts b/packages/query-db-collection/tests/query.test.ts index 9a792093f..c711d661f 100644 --- a/packages/query-db-collection/tests/query.test.ts +++ b/packages/query-db-collection/tests/query.test.ts @@ -1,5 +1,11 @@ import { afterEach, beforeEach, describe, expect, it, vi } from 'vitest' -import { QueryClient, dehydrate, hashKey } from '@tanstack/query-core' +import { + QueryClient, + dehydrate, + focusManager, + hashKey, + onlineManager, +} from '@tanstack/query-core' import { BTreeIndex, createCollection, @@ -185,6 +191,129 @@ describe(`QueryCollection`, () => { queryClient.clear() }) + it(`should pass through additional top-level Query observer options`, async () => { + const queryKey = [`query-options-pass-through`] + const queryFn = vi.fn().mockResolvedValue([{ id: `1`, name: `Item 1` }]) + + const collection = createCollection( + queryCollectionOptions({ + id: `query-options-pass-through`, + queryClient, + queryKey, + queryFn, + getKey, + startSync: true, + refetchOnWindowFocus: true, + refetchOnReconnect: true, + refetchOnMount: `always`, + networkMode: `online`, + }), + ) + + await vi.waitFor(() => { + expect(collection.size).toBe(1) + }) + + const query = queryClient.getQueryCache().find({ queryKey, exact: true }) + const options = query?.options as any + expect(options.refetchOnWindowFocus).toBe(true) + expect(options.refetchOnReconnect).toBe(true) + expect(options.refetchOnMount).toBe(`always`) + expect(options.networkMode).toBe(`online`) + }) + + it(`should refetch on focus and reconnect with standalone QueryClient`, async () => { + const queryKey = [`query-options-event-refetch`] + const queryFn = vi + .fn() + .mockResolvedValueOnce([{ id: `1`, name: `Initial` }]) + .mockResolvedValueOnce([{ id: `1`, name: `Focused` }]) + .mockResolvedValueOnce([{ id: `1`, name: `Reconnected` }]) + + const collection = createCollection( + queryCollectionOptions({ + id: `query-options-event-refetch`, + queryClient, + queryKey, + queryFn, + getKey, + startSync: true, + staleTime: 0, + refetchOnWindowFocus: true, + refetchOnReconnect: true, + }), + ) + + try { + await vi.waitFor(() => { + expect(collection.size).toBe(1) + expect(queryFn).toHaveBeenCalledTimes(1) + }) + + focusManager.setFocused(false) + focusManager.setFocused(true) + + await vi.waitFor(() => { + expect(queryFn).toHaveBeenCalledTimes(2) + }) + + onlineManager.setOnline(false) + onlineManager.setOnline(true) + + await vi.waitFor(() => { + expect(queryFn).toHaveBeenCalledTimes(3) + }) + } finally { + await collection.cleanup() + focusManager.setFocused(undefined) + onlineManager.setOnline(true) + } + }) + + it(`should omit undefined Query observer options to preserve defaults`, async () => { + const queryKey = [`query-options-default-preservation`] + const queryFn = vi.fn().mockResolvedValue([{ id: `1`, name: `Item 1` }]) + + const clientWithDefaults = new QueryClient({ + defaultOptions: { + queries: { + staleTime: 1234, + retry: 3, + refetchOnWindowFocus: false, + }, + }, + }) + + const collection = createCollection( + queryCollectionOptions({ + id: `query-options-default-preservation`, + queryClient: clientWithDefaults, + queryKey, + queryFn, + getKey, + startSync: true, + staleTime: 5678, + retry: undefined, + refetchOnWindowFocus: true, + }), + ) + + await vi.waitFor(() => { + expect(collection.size).toBe(1) + }) + + const query = clientWithDefaults.getQueryCache().find({ + queryKey, + exact: true, + }) + const options = query?.options as any + expect(options.staleTime).toBe(5678) + expect(options.retry).toBe(3) + expect(options.refetchOnWindowFocus).toBe(true) + + clientWithDefaults.clear() + }) + it(`should initialize and fetch initial data`, async () => { const queryKey = [`testItems`] const initialItems: Array = [