From db1662de6d6328fc8c36f19c019deec1cf800ed4 Mon Sep 17 00:00:00 2001 From: Kyle Mathews Date: Thu, 16 Jul 2026 16:00:50 -0600 Subject: [PATCH 1/3] docs(query-db): design initial data semantics --- .../query-initial-placeholder-data-design.md | 248 ++++++++++++++++++ 1 file changed, 248 insertions(+) create mode 100644 docs/collections/query-initial-placeholder-data-design.md diff --git a/docs/collections/query-initial-placeholder-data-design.md b/docs/collections/query-initial-placeholder-data-design.md new file mode 100644 index 000000000..d35e7be58 --- /dev/null +++ b/docs/collections/query-initial-placeholder-data-design.md @@ -0,0 +1,248 @@ +# Query Collection initial and placeholder data semantics + +## Status and scope + +This document proposes the semantics for TanStack Query `initialData` and +`placeholderData` at the `@tanstack/query-db-collection` boundary. It is the +design follow-up for [RFC #1643](https://github.com/TanStack/db/issues/1643) +and [issue #346](https://github.com/TanStack/db/issues/346). It does not change +runtime behavior or the persistence format. + +The adapter connects two different models: + +- TanStack Query owns a document cache and remote-query lifecycle. +- TanStack DB owns normalized rows, local queries, and optimistic writes. +- Query Collection projects a Query response into rows and records which Query + key owns each materialized row. + +`initialData` and `placeholderData` must not be treated as equivalent ways to +provide an array. In Query Core 5.90.20, `initialData` initializes Query cache +state with `status: "success"` and a `dataUpdatedAt` timestamp. By contrast, +`placeholderData` is computed per observer only while Query state is pending; +it produces an observer result with `isPlaceholderData: true` but is not stored +in Query state. + +## Decision + +Support Query-owned `initialData` as an additive, eager-mode option. Materialize +it immediately through the existing row extraction and ownership pipeline. +Do not expose or materialize `placeholderData` in the first implementation. + +Query Collection can already materialize data that was seeded or hydrated into +the QueryClient before the observer is created, and QueryClient defaults can +already provide `initialData` indirectly. That is useful existing behavior, but +it does not close the configuration gap. Applications commonly share one +QueryClient across many Query Collections: a client-wide default is too broad, +while imperative `setQueryData` requires coordinating collection construction, +exact Query keys, and initialization elsewhere. The additive field supplies a +collection-local declaration while leaving Query as the cache authority. + +The minimal additive API is: + +```ts +initialData?: TQueryData | (() => TQueryData) +initialDataUpdatedAt?: number | (() => number | undefined) +``` + +These remain flat top-level fields. Their types describe the original Query +response, not the extracted row array. Consequently, wrapped responses use the +same adapter `select` as network responses: + +```ts +queryCollectionOptions({ + queryKey: ["todos"], + queryFn: fetchTodos, + initialData: { items: serverTodos, nextCursor: null }, + select: (response) => response.items, + // ... +}) +``` + +Query keys still define cache identity. If two Query Collections on the same +QueryClient use the same exact key, they observe one shared Query document; +`initialData` initializes that document only when it does not already exist. +Collection-local configuration does not create collection-local cache data, and +later observers must not replace an existing document with their initializer. +Collections that require independent initial documents must use distinct keys. + +This initial API is limited to `syncMode: "eager"`. A single configuration-level +value cannot lawfully initialize an open-ended family of on-demand subset keys, +and Query's `initialData` function receives no query key or subset context. +Applications that already know data for an exact on-demand key should seed or +hydrate that Query cache entry instead. A future subset-aware initializer would +need an explicit key/subset argument and a separate design. + +`placeholderData` remains Query UI vocabulary. A DB collection has no +observer-local result surface: materializing a placeholder would make it visible +to every DB query and mutation, assign it row ownership, and potentially persist +it. Callers should render placeholders in the consuming UI. A future opt-in +temporary-row feature, if needed, should be a DB feature with explicit provenance +and lifecycle rather than Query's `placeholderData` option. + +## Authority model + +"Authoritative" has two dimensions here. Query owns the authoritative document +for a Query key, while DB owns the current normalized row state. A server result +is the newest remote snapshot, but local optimistic transactions can temporarily +overlay its rows. + +| Phase | Query document authority | Materialized row authority | Consequence | +| --- | --- | --- | --- | +| No cached data | None | Existing DB/persisted rows, if any | The collection waits for Query; absence is not an empty result. | +| Initial data | Query cache `initialData` | Its projected rows, subject to normal local overlays | It is a real cached snapshot, not temporary presentation data. | +| Fetch/refetch in flight | Existing Query document | Existing rows | Loading does not clear rows or ownership. | +| Server success | Returned response replaces the Query document | Projected server rows reconcile the owning Query key | Missing rows lose this Query owner's lease; shared rows remain. | +| Fetch/refetch error | Last successful Query document | Existing rows | An error does not retract initial or previously fetched rows. | +| Placeholder presentation | No Query document | No rows | Placeholder data is never passed to DB. | + +`initialDataUpdatedAt` and `staleTime` remain Query-owned. They decide whether a +fetch starts; the adapter does not reproduce their freshness calculation. An +initial value is therefore a seed snapshot with ordinary Query authority, not a +weaker class of row waiting to be promoted. The first successful server result +reconciles it through the same path as any later refetch. + +## Behavior matrix + +| Concern | `initialData` | `placeholderData` | +| --- | --- | --- | +| Query cache | Stored as successful Query data | Not stored; observer-only | +| Existing cache entry | Existing cached/hydrated data wins; `initialData` is not reapplied | Not applicable | +| DB materialization | Immediate in eager mode | Never | +| Wrapped response | Adapter `select(initialData)` extracts rows; original envelope stays cached | Unsupported | +| Function value | Evaluated by Query once when the Query is created | Not forwarded or evaluated by the adapter | +| Ownership | The Query key owns projected rows exactly like a server success | No ownership | +| Overlapping subsets | Not applicable to the initial eager-only API | Not applicable | +| Ready state | Initial successful result can make the collection ready synchronously | Cannot make the collection ready | +| Refetch success | Reconciles additions, updates, and removals normally | N/A | +| Refetch error | Initial rows and ownership remain; error state is reported | N/A | +| Cancellation/cleanup | Existing ownership cleanup rules apply; a cancelled fetch does not retract the cached seed | No rows to clean up | +| Query cache GC/unload | Existing Query-to-row ownership and persisted-retention rules apply | No effect | +| Query dehydration | Query owns persistence of the initial response | Never persisted | +| DB persistence/hydration | Rows and owner metadata use the existing format; no provenance tag is added | Never persisted or hydrated | +| Direct writes before server success | Allowed under the existing write rules below | No target rows exist | +| QueryClient defaults | Supported for eager `initialData`; see compatibility guard below | Must be suppressed at this adapter boundary | + +## Select and writes + +Adapter `select` remains a one-way row extractor. It is applied identically to +initial and network responses, and TanStack Query retains the original response +shape. Query observer-level `select` remains unsupported. + +Direct writes before the first server result follow the current authority rule: +they update DB immediately and may patch Query cache only when the reverse update +is lawful. A raw array can be replaced. For a wrapped response, the existing +best-effort patch is lawful only when `select` returns an array property of the +cached object by reference, allowing the wrapper to be preserved. A derived +projection such as `response.edges.map(...)` has no general reverse projection; +the adapter must leave that Query document unchanged and rely on invalidate or +refetch. It must never fabricate an envelope around rows. + +The next successful remote response remains authoritative for that Query key and +may overwrite a direct cache patch or normalized row value. Mutation handlers and +optimistic transaction barriers retain their existing semantics. + +## Ownership, persistence, and transitions + +Initial rows use the existing `queryToRows` and `rowToQueries` relationship. No +`seed`, `temporary`, or `placeholder` bit is added to a row. This keeps these +invariants intact: + +1. A successful result, whether initial or fetched, is a complete snapshot for + its Query key. +2. A row is deleted when a snapshot omits it only if no other Query key owns it. +3. Unload, cache GC, and collection cleanup remove only the relevant ownership. +4. A failed or cancelled fetch cannot turn the last successful snapshot into an + empty snapshot. +5. Persistence records only Query data and the existing row ownership metadata; + observer-only presentation state is never persisted. + +The expected transitions are: + +- **Initial, fresh:** materialize and become ready; do not fetch until Query's + normal freshness triggers say to do so. +- **Initial, stale:** materialize and become ready while Query fetches; success + reconciles the same ownership, and error retains the seed. +- **Loading without data:** keep the collection's prior independently owned or + hydrated rows; do not infer an empty result. +- **Placeholder to loading/success/error:** the placeholder is UI-only. DB sees + no transition until success; error leaves DB unchanged. +- **Cleanup before network completion:** existing cancellation and readiness + listener cleanup apply. A late result must not mutate a cleaned-up collection. + +## Defaults and compatibility guard + +Query Collection currently constructs a `QueryObserver`, so QueryClient defaults +can contain semantic fields even when Query Collection does not expose them. +Implementation must explicitly enforce this design after Query defaults are +resolved: + +- reject an explicitly configured `initialData` in on-demand mode; +- prevent default `initialData` from initializing on-demand subset observers; +- prevent explicit or default `placeholderData` from reaching all Query + Collection observers; +- continue to let omitted eager `initialData` and `initialDataUpdatedAt` inherit + QueryClient defaults; +- never copy a function-valued initializer into adapter metadata or persisted + ownership metadata. Query Core may own it as an option and stores only its + evaluated data in Query state. + +Silently materializing default placeholder data would violate the public +compatibility table even before a top-level field is added. The guard is therefore +a correctness fix, not support for placeholder semantics. + +Other Query-owned options keep their current classification. Query key creation, +adapter `select`, subscriptions, `notifyOnChangeProps`, and structural sharing +remain adapter-owned or reinterpreted. No nested `queryOptions`, runtime binding +API, subset deduplication, or lease manager is introduced by this design. + +## Rejected designs + +- **Forward both options mechanically.** `result.isSuccess` is true for a + placeholder observer result, so the current success handler would normalize + presentation-only data and give it durable-looking ownership. +- **Tag placeholder rows and later promote or delete them.** Tags would have to + survive collisions with real rows, overlapping observers, local writes, + unload, GC, persistence, and hydration. Query's observer-local placeholder has + no collection-wide lifetime that can drive those transitions safely. +- **Treat initial rows as unowned temporary rows.** Query considers initial data + real cached data. Bypassing normal ownership would leak rows or allow cleanup + of one Query to remove rows still represented by another. +- **Seed every on-demand key from one value.** A collection-level initializer + cannot prove membership in arbitrary predicate/order/limit subsets. +- **Create a wrapped response from selected rows.** A read projection is not an + inverse. Fabricating metadata, cursors, or edges corrupts Query cache meaning. +- **Persist initializer functions.** Functions are not structured-clone safe and + are runtime configuration, not data. + +## Implementation and test sequence + +Each behavior PR should begin with the named failing or characterization tests. + +1. **Guard the existing boundary.** Add focused tests proving that explicit and + QueryClient-default `placeholderData` never materialize, never mark the + collection ready, and never survive dehydration as data. Characterize current + default `initialData` behavior in eager and on-demand modes. Then suppress + placeholder and on-demand initialization when constructing observers. +2. **Add eager initial data.** Add the two flat typed fields and forward only + defined values so QueryClient defaults remain intact. Test static and function + values, fresh versus stale timestamps, synchronous readiness, fetch error, + cancellation, cleanup, an explicit on-demand configuration error, multiple + collections on one QueryClient, and same-key first-initializer-wins behavior. +3. **Lock projection and writes.** Test raw arrays, direct-property wrapped + responses, and derived projections. Verify the full initial envelope remains + in Query cache, lawful direct writes preserve it, unlawful reverse projection + does not fabricate one, and server success reconciles rows. +4. **Lock ownership.** Test initial-to-server row removal, overlapping ownership + with externally hydrated/persisted rows, GC/unload, remount within `gcTime`, + and late notification after cleanup. Reuse the existing ownership machinery; + do not add a second seed ownership map. +5. **Lock persistence.** Dehydrate and `structuredClone` initial raw and wrapped + data; hydrate Query and DB state; verify ownership reconciliation and that no + function appears in Query metadata or adapter persistence metadata. +6. **Document the shipped API.** Move the settled behavior into the Query Options, + row extraction, direct writes, on-demand, and persistence sections of the user + guide. Update RFC #1643 and close or narrow #346 only after these contracts ship. + +Placeholder materialization, subset-aware initialization, and a lawful general +reverse projection are separate future proposals. None is a prerequisite for the +minimal eager `initialData` API. From 5b3fd1ddf267a0e50c634d87d05fd90d78e57867 Mon Sep 17 00:00:00 2001 From: Kyle Mathews Date: Thu, 16 Jul 2026 16:09:23 -0600 Subject: [PATCH 2/3] feat(query-db): support eager initial data --- .changeset/young-cats-initialize.md | 5 + docs/collections/query-collection.md | 58 +++- packages/query-db-collection/src/errors.ts | 9 + packages/query-db-collection/src/query.ts | 49 +++ .../query-db-collection/tests/query.test.ts | 294 ++++++++++++++++++ 5 files changed, 414 insertions(+), 1 deletion(-) create mode 100644 .changeset/young-cats-initialize.md diff --git a/.changeset/young-cats-initialize.md b/.changeset/young-cats-initialize.md new file mode 100644 index 000000000..10fa4e827 --- /dev/null +++ b/.changeset/young-cats-initialize.md @@ -0,0 +1,5 @@ +--- +'@tanstack/query-db-collection': minor +--- + +Add eager collection support for TanStack Query `initialData` and `initialDataUpdatedAt`, including wrapped response projection and collection-local initialization on shared QueryClient instances. diff --git a/docs/collections/query-collection.md b/docs/collections/query-collection.md index c2635453b..e55fb9413 100644 --- a/docs/collections/query-collection.md +++ b/docs/collections/query-collection.md @@ -200,6 +200,8 @@ The following top-level Query Collection options are forwarded to the underlying - `refetchOnReconnect`: Whether to refetch when the network reconnects - `refetchOnMount`: Whether to refetch when the observer mounts - `networkMode`: Query network mode +- `initialData`: Initial Query response for eager collections +- `initialDataUpdatedAt`: Timestamp used by TanStack Query to determine initial data freshness - `meta`: Metadata passed to the query function context. Query Collections may add `loadSubsetOptions` for on-demand queries. ```ts @@ -237,7 +239,7 @@ Some TanStack Query fields are owned or reinterpreted by Query Collection and ar - `subscribed` (Query Collection owns the observer subscription lifecycle) - `structuralSharing` and `notifyOnChangeProps` (managed by Query Collection synchronization) -Other semantic options, such as `initialData`, `placeholderData`, and TanStack Query observer-level `select`, are intentionally deferred until their Query Collection behavior is explicitly designed. +`placeholderData` is intentionally unsupported. TanStack Query treats placeholder data as observer-local presentation state rather than cached Query data. Materializing it would expose temporary UI data as collection-wide normalized rows. Render placeholders in the consuming UI instead. ### Using with `queryOptions(...)` @@ -271,6 +273,51 @@ const todosCollection = createCollection( If `queryFn` is missing at runtime, `queryCollectionOptions` throws `QueryFnRequiredError`. +### Initial Data + +Eager Query Collections support TanStack Query's `initialData` and +`initialDataUpdatedAt` options. Initial data has the original Query response +shape, is stored in the Query cache, and is immediately materialized as +normalized collection rows. TanStack Query uses `initialDataUpdatedAt` together +with `staleTime` to decide whether to fetch. + +```typescript +const serverRenderedAt = Date.now() +const initialTodos = [ + { id: "1", title: "Write documentation" }, + { id: "2", title: "Ship initial data support" }, +] + +const todosCollection = createCollection( + queryCollectionOptions({ + queryKey: ["todos"], + queryFn: fetchTodos, + queryClient, + getKey: (todo) => todo.id, + initialData: initialTodos, + initialDataUpdatedAt: serverRenderedAt, + staleTime: 60_000, + }), +) +``` + +An existing cached or hydrated Query response takes precedence over +`initialData`. Query keys remain the cache identity: two collections using the +same QueryClient and exact Query key observe one shared Query document, and a +later collection's initializer does not replace it. Use distinct Query keys for +independent documents. + +Initial data is supported only for eager collections. A collection-wide value +cannot establish row membership for arbitrary on-demand predicates, ordering, +limits, and offsets. For `syncMode: "on-demand"`, seed or hydrate the exact +derived Query cache entries instead. + +If a stale initial response triggers a fetch, the initial rows remain available +while it is in flight. A successful response reconciles them through the normal +row ownership pipeline; an error retains the initial rows. Direct writes use the +same Query cache-patching rules as fetched data, and a later successful server +response may reconcile or replace those writes. + ### Selecting Rows from Wrapped Responses Many APIs return rows inside a response envelope that also contains metadata such as pagination cursors, totals, or request information. Use `select` to extract the row array that TanStack DB should materialize: @@ -289,6 +336,11 @@ const todosCollection = createCollection( const response = await fetch("/api/todos") return response.json() }, + initialData: { + items: [{ id: "1", title: "Initial todo" }], + nextCursor: undefined, + total: 1, + }, select: (response) => response.items, queryClient, getKey: (item) => item.id, @@ -298,6 +350,10 @@ const todosCollection = createCollection( `select` is a query-db-collection row extraction hook. It tells TanStack DB which rows to materialize while the TanStack Query cache keeps the original query response shape. In the example above, `queryClient.getQueryData(["todos"])` still returns the full `TodosResponse`, including `nextCursor` and `total`. +The same projection applies to `initialData`: provide the complete response +envelope, and Query Collection materializes the rows returned by `select` while +preserving the envelope in the Query cache. + This differs from TanStack Query's observer-level `select`: query-db-collection uses this option to bridge Query's response object into DB's normalized row store. Direct write utilities such as `writeInsert`, `writeUpdate`, and `writeDelete` make a best-effort attempt to update the matching row array inside wrapped Query cache entries while preserving wrapper metadata. diff --git a/packages/query-db-collection/src/errors.ts b/packages/query-db-collection/src/errors.ts index 722474525..2a226181b 100644 --- a/packages/query-db-collection/src/errors.ts +++ b/packages/query-db-collection/src/errors.ts @@ -36,6 +36,15 @@ export class GetKeyRequiredError extends QueryCollectionError { } } +export class InitialDataInOnDemandModeError extends QueryCollectionError { + constructor() { + super( + `[QueryCollection] initialData and initialDataUpdatedAt are only supported when syncMode is 'eager'. Seed or hydrate the exact Query cache key for on-demand subsets instead.`, + ) + this.name = `InitialDataInOnDemandModeError` + } +} + export class SyncNotInitializedError extends QueryCollectionError { constructor() { super( diff --git a/packages/query-db-collection/src/query.ts b/packages/query-db-collection/src/query.ts index f104cba3b..4a08a9b8f 100644 --- a/packages/query-db-collection/src/query.ts +++ b/packages/query-db-collection/src/query.ts @@ -2,6 +2,7 @@ import { QueryObserver, hashKey } from '@tanstack/query-core' import { deepEquals } from '@tanstack/db' import { GetKeyRequiredError, + InitialDataInOnDemandModeError, QueryClientRequiredError, QueryFnRequiredError, QueryKeyRequiredError, @@ -191,6 +192,26 @@ export interface QueryCollectionConfig< TQueryData, TQueryKey >[`networkMode`] + /** + * Data used to initialize the TanStack Query cache for an eager collection. + * The value has the original Query response shape and is projected through + * the collection's select option before rows are materialized. + */ + initialData?: QueryObserverOptions< + TQueryData, + TError, + Array, + TQueryData, + TQueryKey + >[`initialData`] + /** The timestamp TanStack Query uses to determine initialData freshness. */ + initialDataUpdatedAt?: QueryObserverOptions< + TQueryData, + TError, + Array, + TQueryData, + TQueryKey + >[`initialDataUpdatedAt`] persistedGcTime?: number /** @@ -662,6 +683,8 @@ export function queryCollectionOptions( refetchOnReconnect, refetchOnMount, networkMode, + initialData, + initialDataUpdatedAt, persistedGcTime, getKey, onInsert, @@ -674,6 +697,13 @@ export function queryCollectionOptions( // Default to eager sync mode if not provided const syncMode = baseCollectionConfig.syncMode ?? `eager` + if ( + syncMode === `on-demand` && + (initialData !== undefined || initialDataUpdatedAt !== undefined) + ) { + throw new InitialDataInOnDemandModeError() + } + // Compute the base query key once for cache lookups. // All derived keys (from on-demand predicates or function-based queryKey) must // share this prefix so that queryCache.findAll({ queryKey: baseKey }) can find them. @@ -1288,6 +1318,25 @@ export function queryCollectionOptions( refetchOnMount, networkMode, }), + ...(syncMode === `eager` + ? { + ...(initialData !== undefined && { initialData }), + ...(initialDataUpdatedAt !== undefined && { + initialDataUpdatedAt, + }), + } + : { + // A collection-wide initializer cannot establish membership for + // arbitrary on-demand subsets. A defined initializer is needed + // to override a QueryClient default because Query Core can apply + // defaults again while constructing each Query. + initialData: () => undefined, + initialDataUpdatedAt: undefined, + }), + // Placeholder data is observer-local UI state in TanStack Query. It + // must not be exposed as collection-wide normalized rows, including + // when supplied through QueryClient defaults. + placeholderData: undefined, queryKey: key, queryFn: queryFunction, meta: extendedMeta, diff --git a/packages/query-db-collection/tests/query.test.ts b/packages/query-db-collection/tests/query.test.ts index d72a7a3da..d2aa95e9e 100644 --- a/packages/query-db-collection/tests/query.test.ts +++ b/packages/query-db-collection/tests/query.test.ts @@ -232,6 +232,300 @@ describe(`QueryCollection`, () => { expect(options.networkMode).toBe(`online`) }) + describe(`initialData`, () => { + it(`materializes eager initial data without fetching while it is fresh`, async () => { + const queryKey = [`initial-data-eager`] + const initialData: Array = [{ id: `1`, name: `Initial item` }] + const queryFn = vi + .fn<() => Promise>>() + .mockResolvedValue([{ id: `1`, name: `Fetched item` }]) + + const collection = createCollection( + queryCollectionOptions({ + id: `initial-data-eager`, + queryClient, + queryKey, + queryFn, + getKey, + startSync: true, + initialData, + staleTime: Infinity, + }), + ) + + try { + await vi.waitFor(() => { + expect(collection.status).toBe(`ready`) + expect(stripVirtualProps(collection.get(`1`))).toEqual(initialData[0]) + }) + + expect(queryFn).not.toHaveBeenCalled() + expect(queryClient.getQueryData(queryKey)).toEqual(initialData) + } finally { + await collection.cleanup() + } + }) + + it(`evaluates a function initializer once for a missing Query cache entry`, async () => { + const initialData = vi.fn(() => [{ id: `1`, name: `Initial item` }]) + const collection = createCollection( + queryCollectionOptions({ + id: `initial-data-function`, + queryClient, + queryKey: [`initial-data-function`], + queryFn: vi.fn().mockResolvedValue([]), + getKey, + startSync: true, + initialData, + staleTime: Infinity, + }), + ) + + try { + await vi.waitFor(() => { + expect(collection.get(`1`)?.name).toBe(`Initial item`) + }) + expect(initialData).toHaveBeenCalledTimes(1) + } finally { + await collection.cleanup() + } + }) + + it(`keeps stale initial rows while fetching and reconciles the server result`, async () => { + let resolveQuery: ((items: Array) => void) | undefined + const queryFn = vi.fn( + () => + new Promise>((resolve) => { + resolveQuery = resolve + }), + ) + const collection = createCollection( + queryCollectionOptions({ + id: `initial-data-stale`, + queryClient, + queryKey: [`initial-data-stale`], + queryFn, + getKey, + startSync: true, + initialData: [{ id: `initial`, name: `Initial` }], + initialDataUpdatedAt: 1, + staleTime: 0, + }), + ) + + try { + await vi.waitFor(() => { + expect(queryFn).toHaveBeenCalledTimes(1) + expect(collection.get(`initial`)?.name).toBe(`Initial`) + }) + + resolveQuery?.([{ id: `server`, name: `Server` }]) + await vi.waitFor(() => { + expect(collection.has(`initial`)).toBe(false) + expect(collection.get(`server`)?.name).toBe(`Server`) + }) + } finally { + await collection.cleanup() + } + }) + + it(`projects a wrapped initial response while preserving its Query cache shape`, async () => { + const queryKey = [`initial-data-wrapped`] + const initialResponse = { + items: [{ id: `1`, name: `Initial item` }], + nextCursor: `next`, + } + const queryFn = vi.fn().mockResolvedValue(initialResponse) + + const collection = createCollection( + queryCollectionOptions({ + id: `initial-data-wrapped`, + queryClient, + queryKey, + queryFn, + select: (response: typeof initialResponse) => response.items, + getKey, + startSync: true, + initialData: initialResponse, + staleTime: Infinity, + }), + ) + + try { + await vi.waitFor(() => { + expect(stripVirtualProps(collection.get(`1`))).toEqual( + initialResponse.items[0], + ) + }) + + expect(queryFn).not.toHaveBeenCalled() + expect(queryClient.getQueryData(queryKey)).toEqual(initialResponse) + } finally { + await collection.cleanup() + } + }) + + it(`keeps initial data scoped to each collection on a shared QueryClient`, async () => { + const first = createCollection( + queryCollectionOptions({ + id: `initial-data-shared-client-first`, + queryClient, + queryKey: [`initial-data-shared-client`, `first`], + queryFn: vi.fn().mockResolvedValue([]), + getKey, + startSync: true, + initialData: [{ id: `1`, name: `First` }], + staleTime: Infinity, + }), + ) + const second = createCollection( + queryCollectionOptions({ + id: `initial-data-shared-client-second`, + queryClient, + queryKey: [`initial-data-shared-client`, `second`], + queryFn: vi.fn().mockResolvedValue([]), + getKey, + startSync: true, + initialData: [{ id: `2`, name: `Second` }], + staleTime: Infinity, + }), + ) + + try { + await vi.waitFor(() => { + expect(first.get(`1`)?.name).toBe(`First`) + expect(second.get(`2`)?.name).toBe(`Second`) + }) + expect(first.has(`2`)).toBe(false) + expect(second.has(`1`)).toBe(false) + } finally { + await Promise.all([first.cleanup(), second.cleanup()]) + } + }) + + it(`does not replace existing Query data with a later collection initializer`, async () => { + const queryKey = [`initial-data-shared-key`] + queryClient.setQueryData(queryKey, [{ id: `cached`, name: `Cached` }]) + + const collection = createCollection( + queryCollectionOptions({ + id: `initial-data-shared-key`, + queryClient, + queryKey, + queryFn: vi.fn().mockResolvedValue([]), + getKey, + startSync: true, + initialData: [{ id: `initial`, name: `Initial` }], + staleTime: Infinity, + }), + ) + + try { + await vi.waitFor(() => { + expect(collection.get(`cached`)?.name).toBe(`Cached`) + }) + expect(collection.has(`initial`)).toBe(false) + } finally { + await collection.cleanup() + } + }) + + it(`rejects collection-level initial data in on-demand mode`, () => { + expect(() => + queryCollectionOptions({ + id: `initial-data-on-demand`, + queryClient, + queryKey: [`initial-data-on-demand`], + queryFn: vi.fn().mockResolvedValue([]), + getKey, + syncMode: `on-demand`, + initialData: [{ id: `1`, name: `Initial` }], + }), + ).toThrow( + `initialData and initialDataUpdatedAt are only supported when syncMode is 'eager'`, + ) + }) + + it(`does not apply QueryClient initial data defaults to on-demand subsets`, async () => { + const defaultInitialQueryClient = new QueryClient({ + defaultOptions: { + queries: { + initialData: [{ id: `default`, name: `Default` }], + staleTime: Infinity, + retry: false, + }, + }, + }) + const queryFn = vi + .fn() + .mockResolvedValue([{ id: `server`, name: `Server` }]) + const collection = createCollection( + queryCollectionOptions({ + id: `initial-data-default-on-demand`, + queryClient: defaultInitialQueryClient, + queryKey: [`initial-data-default-on-demand`], + queryFn, + getKey, + syncMode: `on-demand`, + startSync: true, + }), + ) + + try { + await collection._sync.loadSubset({}) + await vi.waitFor(() => { + expect(collection.get(`server`)?.name).toBe(`Server`) + }) + expect(collection.has(`default`)).toBe(false) + expect(queryFn).toHaveBeenCalledTimes(1) + } finally { + await collection.cleanup() + defaultInitialQueryClient.clear() + } + }) + + it(`does not materialize QueryClient placeholder defaults`, async () => { + const placeholderQueryClient = new QueryClient({ + defaultOptions: { + queries: { + placeholderData: [{ id: `placeholder`, name: `Placeholder` }], + retry: false, + }, + }, + }) + let resolveQuery: ((items: Array) => void) | undefined + const queryFn = vi.fn( + () => + new Promise>((resolve) => { + resolveQuery = resolve + }), + ) + const collection = createCollection( + queryCollectionOptions({ + id: `placeholder-default`, + queryClient: placeholderQueryClient, + queryKey: [`placeholder-default`], + queryFn, + getKey, + startSync: true, + }), + ) + + try { + await vi.waitFor(() => expect(queryFn).toHaveBeenCalledTimes(1)) + expect(collection.has(`placeholder`)).toBe(false) + + resolveQuery?.([{ id: `server`, name: `Server` }]) + await vi.waitFor(() => { + expect(collection.get(`server`)?.name).toBe(`Server`) + }) + } finally { + await collection.cleanup() + placeholderQueryClient.clear() + } + }) + }) + it(`should refetch on focus and reconnect with standalone QueryClient`, async () => { const queryKey = [`query-options-event-refetch`] const queryFn = vi From 63025e8d661102400de595c6d62ed36e507a0b5a Mon Sep 17 00:00:00 2001 From: Kyle Mathews Date: Thu, 16 Jul 2026 16:27:56 -0600 Subject: [PATCH 3/3] Apply code simplification and review fixes --- .../query-initial-placeholder-data-design.md | 7 ++-- packages/query-db-collection/src/query.ts | 42 ++++++++++--------- 2 files changed, 27 insertions(+), 22 deletions(-) diff --git a/docs/collections/query-initial-placeholder-data-design.md b/docs/collections/query-initial-placeholder-data-design.md index d35e7be58..9ee866f32 100644 --- a/docs/collections/query-initial-placeholder-data-design.md +++ b/docs/collections/query-initial-placeholder-data-design.md @@ -2,11 +2,12 @@ ## Status and scope -This document proposes the semantics for TanStack Query `initialData` and +This document defines the semantics for TanStack Query `initialData` and `placeholderData` at the `@tanstack/query-db-collection` boundary. It is the design follow-up for [RFC #1643](https://github.com/TanStack/db/issues/1643) -and [issue #346](https://github.com/TanStack/db/issues/346). It does not change -runtime behavior or the persistence format. +and [issue #346](https://github.com/TanStack/db/issues/346). The accompanying +implementation adds the approved eager `initialData` behavior without changing +the persistence format. The adapter connects two different models: diff --git a/packages/query-db-collection/src/query.ts b/packages/query-db-collection/src/query.ts index 4a08a9b8f..6c0abe6a2 100644 --- a/packages/query-db-collection/src/query.ts +++ b/packages/query-db-collection/src/query.ts @@ -704,6 +704,28 @@ export function queryCollectionOptions( throw new InitialDataInOnDemandModeError() } + const initialDataObserverOptions = + syncMode === `eager` + ? { + ...(initialData !== undefined && { initialData }), + ...(initialDataUpdatedAt !== undefined && { + initialDataUpdatedAt, + }), + // Placeholder data is observer-local UI state in TanStack Query. It + // must not be exposed as collection-wide normalized rows, including + // when supplied through QueryClient defaults. + placeholderData: undefined, + } + : { + // A collection-wide initializer cannot establish membership for + // arbitrary on-demand subsets. A defined initializer is needed to + // override a QueryClient default because Query Core can apply + // defaults again while constructing each Query. + initialData: () => undefined, + initialDataUpdatedAt: undefined, + placeholderData: undefined, + } + // Compute the base query key once for cache lookups. // All derived keys (from on-demand predicates or function-based queryKey) must // share this prefix so that queryCache.findAll({ queryKey: baseKey }) can find them. @@ -1318,25 +1340,7 @@ export function queryCollectionOptions( refetchOnMount, networkMode, }), - ...(syncMode === `eager` - ? { - ...(initialData !== undefined && { initialData }), - ...(initialDataUpdatedAt !== undefined && { - initialDataUpdatedAt, - }), - } - : { - // A collection-wide initializer cannot establish membership for - // arbitrary on-demand subsets. A defined initializer is needed - // to override a QueryClient default because Query Core can apply - // defaults again while constructing each Query. - initialData: () => undefined, - initialDataUpdatedAt: undefined, - }), - // Placeholder data is observer-local UI state in TanStack Query. It - // must not be exposed as collection-wide normalized rows, including - // when supplied through QueryClient defaults. - placeholderData: undefined, + ...initialDataObserverOptions, queryKey: key, queryFn: queryFunction, meta: extendedMeta,