From c6b84c23ea9be02a12694bc2ea0903e823807bce Mon Sep 17 00:00:00 2001 From: Kyle Mathews Date: Thu, 9 Jul 2026 15:28:55 -0600 Subject: [PATCH 1/3] feat(query-db-collection): support runtime query client binding --- .changeset/runtime-query-binding.md | 5 + docs/collections/query-collection.md | 25 +++ packages/query-db-collection/src/index.ts | 2 + packages/query-db-collection/src/query.ts | 168 ++++++++++++++++++ .../query-db-collection/tests/query.test-d.ts | 15 +- .../query-db-collection/tests/query.test.ts | 55 +++++- 6 files changed, 268 insertions(+), 2 deletions(-) create mode 100644 .changeset/runtime-query-binding.md diff --git a/.changeset/runtime-query-binding.md b/.changeset/runtime-query-binding.md new file mode 100644 index 0000000000..d93c361927 --- /dev/null +++ b/.changeset/runtime-query-binding.md @@ -0,0 +1,5 @@ +--- +"@tanstack/query-db-collection": patch +--- + +Add `defineQueryCollectionOptions` for defining query collections without a `QueryClient` and binding a runtime client later. diff --git a/docs/collections/query-collection.md b/docs/collections/query-collection.md index 6c90f53dfb..4454de6c31 100644 --- a/docs/collections/query-collection.md +++ b/docs/collections/query-collection.md @@ -43,6 +43,31 @@ const todosCollection = createCollection( ) ``` +## Runtime QueryClient Binding + +For SSR or request-scoped environments, define the collection options without a `QueryClient`, then bind the request-local client where you create the collection: + +```typescript +import { QueryClient } from "@tanstack/query-core" +import { createCollection } from "@tanstack/db" +import { defineQueryCollectionOptions } from "@tanstack/query-db-collection" + +const todosDefinition = defineQueryCollectionOptions({ + queryKey: ["todos"], + queryFn: async () => { + const response = await fetch("/api/todos") + return response.json() + }, + getKey: (item) => item.id, +}) + +export function createTodosCollection(queryClient: QueryClient) { + return createCollection(todosDefinition.bind({ queryClient })) +} +``` + +Each call to `bind` uses only the `QueryClient` you pass. The direct `queryCollectionOptions({ queryClient, ... })` API remains supported. + ## Configuration Options The `queryCollectionOptions` function accepts the following options: diff --git a/packages/query-db-collection/src/index.ts b/packages/query-db-collection/src/index.ts index 14a5fe7022..4cb0ee21b9 100644 --- a/packages/query-db-collection/src/index.ts +++ b/packages/query-db-collection/src/index.ts @@ -3,7 +3,9 @@ export type { QueryCollectionMeta } from './global' export { + defineQueryCollectionOptions, queryCollectionOptions, + type DefinedQueryCollectionOptions, type QueryCollectionConfig, type QueryCollectionUtils, type SyncOperation, diff --git a/packages/query-db-collection/src/query.ts b/packages/query-db-collection/src/query.ts index ea303b8ddb..b8214a6e17 100644 --- a/packages/query-db-collection/src/query.ts +++ b/packages/query-db-collection/src/query.ts @@ -359,6 +359,174 @@ function getLoadSubsetOptionsForMeta( return serializableOptions } +export interface DefinedQueryCollectionOptions { + bind: (runtime: { queryClient: QueryClient }) => TResult + config: TConfig +} + +type QueryCollectionConfigWithoutClient< + T extends object = object, + TQueryFn extends (context: QueryFunctionContext) => any = ( + context: QueryFunctionContext, + ) => any, + TError = unknown, + TQueryKey extends QueryKey = QueryKey, + TKey extends string | number = string | number, + TSchema extends StandardSchemaV1 = never, + TQueryData = Awaited>, +> = Omit< + QueryCollectionConfig, + `queryClient` +> + +export function defineQueryCollectionOptions< + T extends StandardSchemaV1, + TQueryFn extends (context: QueryFunctionContext) => any, + TError = unknown, + TQueryKey extends QueryKey = QueryKey, + TKey extends string | number = string | number, + TQueryData = Awaited>, +>( + config: QueryCollectionConfigWithoutClient< + InferSchemaOutput, + TQueryFn, + TError, + TQueryKey, + TKey, + T + > & { + schema: T + select: (data: TQueryData) => Array> + }, +): DefinedQueryCollectionOptions< + typeof config, + CollectionConfig< + InferSchemaOutput, + TKey, + T, + QueryCollectionUtils, TKey, InferSchemaInput, TError> + > & { + schema: T + utils: QueryCollectionUtils< + InferSchemaOutput, + TKey, + InferSchemaInput, + TError + > + } +> + +export function defineQueryCollectionOptions< + T extends object, + TQueryFn extends (context: QueryFunctionContext) => any = ( + context: QueryFunctionContext, + ) => any, + TError = unknown, + TQueryKey extends QueryKey = QueryKey, + TKey extends string | number = string | number, + TQueryData = Awaited>, +>( + config: QueryCollectionConfigWithoutClient< + T, + TQueryFn, + TError, + TQueryKey, + TKey, + never, + TQueryData + > & { + schema?: never + select: (data: TQueryData) => Array + }, +): DefinedQueryCollectionOptions< + typeof config, + CollectionConfig> & { + schema?: never + utils: QueryCollectionUtils + } +> + +export function defineQueryCollectionOptions< + T extends StandardSchemaV1, + TError = unknown, + TQueryKey extends QueryKey = QueryKey, + TKey extends string | number = string | number, +>( + config: QueryCollectionConfigWithoutClient< + InferSchemaOutput, + ( + context: QueryFunctionContext, + ) => Array> | Promise>>, + TError, + TQueryKey, + TKey, + T + > & { + schema: T + }, +): DefinedQueryCollectionOptions< + typeof config, + CollectionConfig< + InferSchemaOutput, + TKey, + T, + QueryCollectionUtils, TKey, InferSchemaInput, TError> + > & { + schema: T + utils: QueryCollectionUtils< + InferSchemaOutput, + TKey, + InferSchemaInput, + TError + > + } +> + +export function defineQueryCollectionOptions< + T extends object, + TError = unknown, + TQueryKey extends QueryKey = QueryKey, + TKey extends string | number = string | number, +>( + config: QueryCollectionConfigWithoutClient< + T, + (context: QueryFunctionContext) => Array | Promise>, + TError, + TQueryKey, + TKey + > & { schema?: never }, +): DefinedQueryCollectionOptions< + typeof config, + CollectionConfig> & { + schema?: never + utils: QueryCollectionUtils + } +> + +export function defineQueryCollectionOptions( + config: QueryCollectionConfigWithoutClient< + Record, + (context: QueryFunctionContext) => any + >, +): DefinedQueryCollectionOptions< + typeof config, + CollectionConfig< + Record, + string | number, + never, + QueryCollectionUtils + > & { utils: QueryCollectionUtils } +> { + return { + config, + bind: ({ queryClient }) => + queryCollectionOptions({ + ...config, + queryClient, + }), + } +} + /** * Creates query collection options for use with a standard Collection. * This integrates TanStack Query with TanStack DB for automatic synchronization. diff --git a/packages/query-db-collection/tests/query.test-d.ts b/packages/query-db-collection/tests/query.test-d.ts index c526398f8a..c72a3bd91a 100644 --- a/packages/query-db-collection/tests/query.test-d.ts +++ b/packages/query-db-collection/tests/query.test-d.ts @@ -10,7 +10,7 @@ import { } from '@tanstack/db' import { QueryClient } from '@tanstack/query-core' import { z } from 'zod' -import { queryCollectionOptions } from '../src/query' +import { defineQueryCollectionOptions, queryCollectionOptions } from '../src/query' import type { DataTag, QueryFunctionContext, @@ -32,6 +32,19 @@ describe(`Query collection type resolution tests`, () => { // Create a mock QueryClient for tests const queryClient = new QueryClient() + it(`should preserve explicit types when definition is bound`, () => { + const definition = defineQueryCollectionOptions({ + id: `defined-test`, + queryKey: [`defined-test`], + queryFn: () => Promise.resolve([]), + getKey: (item) => item.id, + }) + + const options = definition.bind({ queryClient }) + + expectTypeOf(options.getKey).parameters.toEqualTypeOf<[ExplicitType]>() + }) + 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 9a792093f4..0bd4e8a9a0 100644 --- a/packages/query-db-collection/tests/query.test.ts +++ b/packages/query-db-collection/tests/query.test.ts @@ -14,7 +14,7 @@ import { stripVirtualProps, } from '../../db/tests/utils' import { persistedCollectionOptions } from '../../db-sqlite-persistence-core/src' -import { queryCollectionOptions } from '../src/query' +import { defineQueryCollectionOptions, queryCollectionOptions } from '../src/query' import type { QueryFunctionContext } from '@tanstack/query-core' import type { Collection, @@ -185,6 +185,59 @@ describe(`QueryCollection`, () => { queryClient.clear() }) + it(`should define options without QueryClient and bind at runtime`, async () => { + const initialItems: Array = [{ id: `1`, name: `Item 1` }] + const queryFn = vi.fn().mockResolvedValue(initialItems) + + const definition = defineQueryCollectionOptions({ + id: `defined-test`, + queryKey: [`definedItems`], + queryFn, + getKey, + startSync: true, + }) + + const collection = createCollection(definition.bind({ queryClient })) + + await vi.waitFor(() => { + expect(queryFn).toHaveBeenCalledTimes(1) + expect(collection.size).toBe(initialItems.length) + }) + + expect(stripVirtualProps(collection.get(`1`))).toEqual(initialItems[0]) + }) + + it(`should isolate bindings to the provided QueryClient`, async () => { + const firstClient = new QueryClient({ + defaultOptions: { queries: { retry: false } }, + }) + const secondClient = new QueryClient({ + defaultOptions: { queries: { retry: false } }, + }) + const queryFn = vi.fn().mockResolvedValue([{ id: `1`, name: `Item 1` }]) + + const definition = defineQueryCollectionOptions({ + id: `isolated-test`, + queryKey: [`isolatedItems`], + queryFn, + getKey, + startSync: false, + }) + + createCollection(definition.bind({ queryClient: firstClient })) + createCollection(definition.bind({ queryClient: secondClient })) + + firstClient.setQueryData([`isolatedItems`], [{ id: `first`, name: `First` }]) + + expect(firstClient.getQueryData([`isolatedItems`])).toEqual([ + { id: `first`, name: `First` }, + ]) + expect(secondClient.getQueryData([`isolatedItems`])).toBeUndefined() + + firstClient.clear() + secondClient.clear() + }) + it(`should initialize and fetch initial data`, async () => { const queryKey = [`testItems`] const initialItems: Array = [ From e433021205a12e795e8089e9e304023b93772c72 Mon Sep 17 00:00:00 2001 From: Kyle Mathews Date: Thu, 9 Jul 2026 16:35:05 -0600 Subject: [PATCH 2/3] Tighten runtime query collection definitions --- packages/query-db-collection/src/query.ts | 14 ++++++++++++-- .../query-db-collection/tests/query.test-d.ts | 18 ++++++++++++++++++ 2 files changed, 30 insertions(+), 2 deletions(-) diff --git a/packages/query-db-collection/src/query.ts b/packages/query-db-collection/src/query.ts index b8214a6e17..b2354540a5 100644 --- a/packages/query-db-collection/src/query.ts +++ b/packages/query-db-collection/src/query.ts @@ -375,9 +375,19 @@ type QueryCollectionConfigWithoutClient< TSchema extends StandardSchemaV1 = never, TQueryData = Awaited>, > = Omit< - QueryCollectionConfig, + QueryCollectionConfig< + T, + TQueryFn, + TError, + TQueryKey, + TKey, + TSchema, + TQueryData + >, `queryClient` -> +> & { + queryClient?: never +} export function defineQueryCollectionOptions< T extends StandardSchemaV1, diff --git a/packages/query-db-collection/tests/query.test-d.ts b/packages/query-db-collection/tests/query.test-d.ts index c72a3bd91a..096f20ef9d 100644 --- a/packages/query-db-collection/tests/query.test-d.ts +++ b/packages/query-db-collection/tests/query.test-d.ts @@ -45,6 +45,24 @@ describe(`Query collection type resolution tests`, () => { expectTypeOf(options.getKey).parameters.toEqualTypeOf<[ExplicitType]>() }) + it(`should not accept QueryClient in runtime-bound definitions`, () => { + defineQueryCollectionOptions({ + id: `defined-test-without-client`, + queryKey: [`defined-test-without-client`], + queryFn: () => Promise.resolve([]), + getKey: (item) => item.id, + }) + + defineQueryCollectionOptions({ + id: `defined-test-with-client`, + // @ts-expect-error - queryClient is supplied by definition.bind() + queryClient, + queryKey: [`defined-test-with-client`], + queryFn: () => Promise.resolve([]), + getKey: (item) => item.id, + }) + }) + it(`should prioritize explicit type in QueryCollectionConfig`, () => { const options = queryCollectionOptions({ id: `test`, From c7ccbea92ca3f4d3c8ff7385e0095b387fa6c0cc Mon Sep 17 00:00:00 2001 From: "autofix-ci[bot]" <114827586+autofix-ci[bot]@users.noreply.github.com> Date: Fri, 10 Jul 2026 14:44:05 +0000 Subject: [PATCH 3/3] ci: apply automated fixes --- .changeset/runtime-query-binding.md | 2 +- packages/query-db-collection/src/query.ts | 14 ++++++++++++-- packages/query-db-collection/tests/query.test-d.ts | 5 ++++- packages/query-db-collection/tests/query.test.ts | 10 ++++++++-- 4 files changed, 25 insertions(+), 6 deletions(-) diff --git a/.changeset/runtime-query-binding.md b/.changeset/runtime-query-binding.md index d93c361927..cd93840f79 100644 --- a/.changeset/runtime-query-binding.md +++ b/.changeset/runtime-query-binding.md @@ -1,5 +1,5 @@ --- -"@tanstack/query-db-collection": patch +'@tanstack/query-db-collection': patch --- Add `defineQueryCollectionOptions` for defining query collections without a `QueryClient` and binding a runtime client later. diff --git a/packages/query-db-collection/src/query.ts b/packages/query-db-collection/src/query.ts index b2354540a5..5b5e011775 100644 --- a/packages/query-db-collection/src/query.ts +++ b/packages/query-db-collection/src/query.ts @@ -414,7 +414,12 @@ export function defineQueryCollectionOptions< InferSchemaOutput, TKey, T, - QueryCollectionUtils, TKey, InferSchemaInput, TError> + QueryCollectionUtils< + InferSchemaOutput, + TKey, + InferSchemaInput, + TError + > > & { schema: T utils: QueryCollectionUtils< @@ -480,7 +485,12 @@ export function defineQueryCollectionOptions< InferSchemaOutput, TKey, T, - QueryCollectionUtils, TKey, InferSchemaInput, TError> + QueryCollectionUtils< + InferSchemaOutput, + TKey, + InferSchemaInput, + TError + > > & { schema: T utils: QueryCollectionUtils< diff --git a/packages/query-db-collection/tests/query.test-d.ts b/packages/query-db-collection/tests/query.test-d.ts index 096f20ef9d..cdc39cf110 100644 --- a/packages/query-db-collection/tests/query.test-d.ts +++ b/packages/query-db-collection/tests/query.test-d.ts @@ -10,7 +10,10 @@ import { } from '@tanstack/db' import { QueryClient } from '@tanstack/query-core' import { z } from 'zod' -import { defineQueryCollectionOptions, queryCollectionOptions } from '../src/query' +import { + defineQueryCollectionOptions, + queryCollectionOptions, +} from '../src/query' import type { DataTag, QueryFunctionContext, diff --git a/packages/query-db-collection/tests/query.test.ts b/packages/query-db-collection/tests/query.test.ts index 0bd4e8a9a0..62855b00be 100644 --- a/packages/query-db-collection/tests/query.test.ts +++ b/packages/query-db-collection/tests/query.test.ts @@ -14,7 +14,10 @@ import { stripVirtualProps, } from '../../db/tests/utils' import { persistedCollectionOptions } from '../../db-sqlite-persistence-core/src' -import { defineQueryCollectionOptions, queryCollectionOptions } from '../src/query' +import { + defineQueryCollectionOptions, + queryCollectionOptions, +} from '../src/query' import type { QueryFunctionContext } from '@tanstack/query-core' import type { Collection, @@ -227,7 +230,10 @@ describe(`QueryCollection`, () => { createCollection(definition.bind({ queryClient: firstClient })) createCollection(definition.bind({ queryClient: secondClient })) - firstClient.setQueryData([`isolatedItems`], [{ id: `first`, name: `First` }]) + firstClient.setQueryData( + [`isolatedItems`], + [{ id: `first`, name: `First` }], + ) expect(firstClient.getQueryData([`isolatedItems`])).toEqual([ { id: `first`, name: `First` },