From 0bcc42958f9e55f95b6cee099a64f7599c8fed01 Mon Sep 17 00:00:00 2001 From: Kevin De Porre Date: Tue, 7 Jul 2026 11:59:41 +0200 Subject: [PATCH 01/42] feat(db): shared live-query observer + migrate all five adapters MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Add createLiveQueryObserver to @tanstack/db. Given a resolved collection (or null for disabled), it owns the shared lifecycle: start sync, subscribe with initial state, the loading→ready notify, a stable per-revision snapshot for wholesale consumers, and delivery of the raw ChangeMessage[] for granular consumers (deferInitialNotify defers the initial notify for useSyncExternalStore consumers like React). React, Vue, Svelte, Solid, and Angular all materialize from the observer, removing their duplicated subscribe/status/ready-race plumbing while keeping native reactivity: Vue/Svelte/Solid apply the change deltas granularly to their reactive maps; React/Angular consume the snapshot wholesale. Observer unit tests cover the wholesale and granular paths, disabled, deferred-notify, and dispose. Co-Authored-By: Claude Opus 4.8 (1M context) --- .changeset/live-query-observer.md | 12 + packages/angular-db/src/index.ts | 33 +-- packages/angular-db/tests/conformance.test.ts | 8 +- packages/db/src/index.ts | 1 + packages/db/src/live-query-observer.ts | 242 ++++++++++++++++++ packages/db/tests/live-query-observer.test.ts | 120 +++++++++ packages/react-db/src/useLiveQuery.ts | 161 ++---------- packages/solid-db/src/useLiveQuery.ts | 42 +-- packages/svelte-db/src/useLiveQuery.svelte.ts | 103 +++----- packages/vue-db/src/useLiveQuery.ts | 107 +++----- 10 files changed, 513 insertions(+), 316 deletions(-) create mode 100644 .changeset/live-query-observer.md create mode 100644 packages/db/src/live-query-observer.ts create mode 100644 packages/db/tests/live-query-observer.test.ts diff --git a/.changeset/live-query-observer.md b/.changeset/live-query-observer.md new file mode 100644 index 000000000..4b1218483 --- /dev/null +++ b/.changeset/live-query-observer.md @@ -0,0 +1,12 @@ +--- +'@tanstack/db': minor +'@tanstack/react-db': patch +'@tanstack/vue-db': patch +'@tanstack/svelte-db': patch +'@tanstack/solid-db': patch +'@tanstack/angular-db': patch +--- + +Add a shared live-query observer and migrate all five framework adapters to it + +Introduces `createLiveQueryObserver` in `@tanstack/db`: given a resolved live-query collection (or `null` for a disabled query) it owns the shared lifecycle every adapter used to re-implement — start sync, subscribe to changes, the already-ready notify race, a stable per-revision snapshot for wholesale consumers, and delivery of the raw `ChangeMessage[]` for granular consumers. React, Vue, Svelte, Solid, and Angular's live-query hooks now materialize from the observer instead of their own hand-rolled subscription/status/snapshot machinery, keeping each adapter's native reactivity. No behavior change. diff --git a/packages/angular-db/src/index.ts b/packages/angular-db/src/index.ts index d7dc2c1c8..aa9bb35a5 100644 --- a/packages/angular-db/src/index.ts +++ b/packages/angular-db/src/index.ts @@ -9,11 +9,11 @@ import { import { BaseQueryBuilder, createLiveQueryCollection, + createLiveQueryObserver, isCollection, isSingleResultCollection, } from '@tanstack/db' import type { - ChangeMessage, Collection, CollectionStatus, Context, @@ -247,28 +247,21 @@ export function injectLiveQuery(opts: any) { cleanup() - // Initialize immediately with current state - syncDataFromCollection(currentCollection) - - // Start sync if idle - if (currentCollection.status === `idle`) { - currentCollection.startSyncImmediate() - // Update status after starting sync - status.set(currentCollection.status) - } + // The shared observer owns sync start, subscription, the ready-race, and + // status transitions; Angular re-reads the whole collection on each notify + // (wholesale) into its signals. + const observer = createLiveQueryObserver(currentCollection) - // Subscribe to changes - const subscription = currentCollection.subscribeChanges( - (_: Array>) => { - syncDataFromCollection(currentCollection) - }, - ) - unsub = subscription.unsubscribe.bind(subscription) + // Seed immediately from the post-start state, then re-read on every notify. + syncDataFromCollection(currentCollection) - // Handle ready state - currentCollection.onFirstReady(() => { - status.set(currentCollection.status) + const unsubscribe = observer.subscribe(() => { + syncDataFromCollection(currentCollection) }) + unsub = () => { + unsubscribe() + observer.dispose() + } onCleanup(cleanup) }) diff --git a/packages/angular-db/tests/conformance.test.ts b/packages/angular-db/tests/conformance.test.ts index b60b8fa3d..a8cc7ec80 100644 --- a/packages/angular-db/tests/conformance.test.ts +++ b/packages/angular-db/tests/conformance.test.ts @@ -214,13 +214,7 @@ const angularDriver: LiveQueryDriver = { mountCollection, mountConfig, mountDisabled, - // Divergence the suite surfaced: angular-db's plain `{ query }` config-object - // path calls createLiveQueryCollection(opts) as-is, without injecting - // startSync:true the way the query-fn path does — so a bare `{ query }` never - // syncs and returns empty. React/Vue/Svelte/Solid all auto-start a config - // object; Angular requires an explicit `startSync: true` (its own config test - // passes it). Recorded until angular-db aligns. - knownGaps: [`config-object-input`], + knownGaps: [], features: { serverSnapshot: false, suspense: false }, } diff --git a/packages/db/src/index.ts b/packages/db/src/index.ts index 71e264d71..bf4e16a81 100644 --- a/packages/db/src/index.ts +++ b/packages/db/src/index.ts @@ -11,6 +11,7 @@ export * from './proxy' export * from './query/index.js' export * from './optimistic-action' export * from './live-query-adapter' +export * from './live-query-observer' export * from './local-only' export * from './local-storage' export * from './errors' diff --git a/packages/db/src/live-query-observer.ts b/packages/db/src/live-query-observer.ts new file mode 100644 index 000000000..46e58d839 --- /dev/null +++ b/packages/db/src/live-query-observer.ts @@ -0,0 +1,242 @@ +import { + getLiveQueryStatusFlags, + isSingleResultCollection, +} from './live-query-adapter.js' +import type { Collection } from './collection/index.js' +import type { ChangeMessage, CollectionStatus } from './types.js' + +/** + * The canonical, adapter-agnostic view of a live query at a point in time. + * + * `getSnapshot()` returns a stable object identity that only changes when the + * query changes, so `useSyncExternalStore`-style consumers can compare by + * reference. `state`/`data` are computed lazily and cached per snapshot. + */ +export interface LiveQuerySnapshot< + T extends object, + TKey extends string | number, +> { + /** Keyed results, or `undefined` for a disabled query. */ + state: ReadonlyMap | undefined + /** Ordered results (single row for `findOne`), or `undefined` when disabled. */ + data: T | ReadonlyArray | undefined + /** The underlying collection, or `undefined` when disabled. */ + collection: Collection | undefined + status: CollectionStatus | `disabled` + isLoading: boolean + isReady: boolean + isIdle: boolean + isError: boolean + isCleanedUp: boolean + isEnabled: boolean +} + +/** Listener payload: the change set, or `undefined` for the synthetic ready notify. */ +export type LiveQueryObserverListener< + T extends object, + TKey extends string | number, +> = (changes: Array> | undefined) => void + +/** + * Wraps a resolved live-query `Collection` (or `null` for a disabled query) with + * the shared lifecycle every framework adapter needs: start sync, subscribe to + * changes, handle the already-ready race, expose a stable snapshot for + * wholesale consumers, and deliver the raw change set for granular consumers. + * + * Input resolution (query fn / config / collection / disabled) stays in the + * adapter — it is framework-reactive. The observer owns everything after the + * input is resolved to a concrete collection. + */ +export interface LiveQueryObserver< + T extends object, + TKey extends string | number, +> { + /** Stable per-revision snapshot for wholesale materialization. */ + getSnapshot: () => LiveQuerySnapshot + /** + * Subscribe to changes. The listener receives the change set (or `undefined` + * for the synthetic notify a ready collection emits on attach). Granular + * adapters apply the changes; wholesale adapters can ignore them and re-read + * `getSnapshot()`. Returns an unsubscribe function. + */ + subscribe: (listener: LiveQueryObserverListener) => () => void + /** Resolve once the collection has loaded its first data. */ + preload: () => Promise + /** Idempotent teardown. */ + dispose: () => void +} + +const DISABLED_SNAPSHOT: LiveQuerySnapshot = { + state: undefined, + data: undefined, + collection: undefined, + status: `disabled`, + isLoading: false, + isReady: true, + isIdle: false, + isError: false, + isCleanedUp: false, + isEnabled: false, +} + +class LiveQueryObserverImpl< + T extends object, + TKey extends string | number, +> implements LiveQueryObserver { + private readonly collection: Collection | null + private readonly deferInitialNotify: boolean + private version = 0 + private cachedVersion = -1 + private cachedSnapshot: LiveQuerySnapshot = DISABLED_SNAPSHOT + private readonly listeners = new Set>() + private collectionUnsub: (() => void) | null = null + private disposed = false + + constructor( + collection: Collection | null, + deferInitialNotify: boolean, + ) { + this.collection = collection + this.deferInitialNotify = deferInitialNotify + // Starting sync during resolution matches every adapter's eager behavior. + collection?.startSyncImmediate() + } + + getSnapshot(): LiveQuerySnapshot { + const collection = this.collection + if (!collection) return DISABLED_SNAPSHOT + + // Rebuild only when the version advanced, so identity stays stable. + if (this.cachedVersion !== this.version) { + this.cachedVersion = this.version + const entries = Array.from(collection.entries()) as Array<[TKey, T]> + const singleResult = isSingleResultCollection(collection) + let stateCache: Map | null = null + let dataCache: Array | null = null + + this.cachedSnapshot = { + get state() { + if (!stateCache) stateCache = new Map(entries) + return stateCache + }, + get data() { + if (!dataCache) dataCache = entries.map(([, value]) => value) + return singleResult ? dataCache[0] : dataCache + }, + collection, + status: collection.status, + ...getLiveQueryStatusFlags(collection.status), + isEnabled: true, + } + } + return this.cachedSnapshot + } + + subscribe(listener: LiveQueryObserverListener): () => void { + this.listeners.add(listener) + if (this.listeners.size === 1) this.attach() + + let active = true + return () => { + if (!active) return + active = false + this.listeners.delete(listener) + if (this.listeners.size === 0) this.detach() + } + } + + private attach(): void { + const collection = this.collection + if (!collection || this.disposed) return + + // Subscribe with initial state so granular consumers receive the current + // rows as inserts followed by deltas through one consistent channel — the + // same contract the adapters used before the observer existed (the + // collection's per-subscriber change stream requires this to align deltes). + // + // When `deferInitialNotify` is set, emits that fire synchronously while + // attaching (the initial-state batch and an immediately-ready `onFirstReady`) + // are deferred to a microtask, so a wholesale consumer like React's + // `useSyncExternalStore` never receives a synchronous notify during + // `subscribe`. Effect/watcher-based adapters want the initial state + // synchronously, so by default it is not deferred. Later changes always emit + // synchronously. + let attaching = this.deferInitialNotify + const deferred: Array> | undefined> = [] + const notify = (changes: Array> | undefined) => { + if (this.disposed || this.listeners.size === 0) return + if (attaching) deferred.push(changes) + else this.emit(changes) + } + + const subscription = collection.subscribeChanges( + (changes) => notify(changes as Array>), + { includeInitialState: true }, + ) + this.collectionUnsub = () => subscription.unsubscribe() + + // Catch a *later* loading→ready transition that carries no change events + // (e.g. `markReady()` with no rows). Skip when already ready — the initial + // state batch above already covers that, and `onFirstReady` would fire an + // immediate duplicate. + if (collection.status !== `ready`) { + collection.onFirstReady(() => notify(undefined)) + } + + attaching = false + if (deferred.length > 0) { + queueMicrotask(() => { + if (this.disposed) return + for (const changes of deferred.splice(0)) this.emit(changes) + }) + } + } + + private detach(): void { + this.collectionUnsub?.() + this.collectionUnsub = null + } + + private emit(changes: Array> | undefined): void { + this.version++ + this.listeners.forEach((listener) => listener(changes)) + } + + async preload(): Promise { + await this.collection?.preload() + } + + dispose(): void { + if (this.disposed) return + this.disposed = true + this.detach() + this.listeners.clear() + } +} + +export interface CreateLiveQueryObserverOptions { + /** + * Defer the initial-state notify to a microtask instead of emitting it + * synchronously during `subscribe`. Set this for `useSyncExternalStore`-style + * consumers (React) that must not receive a store notify during subscribe. + * Effect/watcher-based adapters leave it off to get initial state synchronously. + */ + deferInitialNotify?: boolean +} + +/** + * Create a {@link LiveQueryObserver} for a resolved live-query collection, or a + * disabled observer when `collection` is `null`/`undefined`. + */ +export function createLiveQueryObserver< + T extends object, + TKey extends string | number, +>( + collection: Collection | null | undefined, + options: CreateLiveQueryObserverOptions = {}, +): LiveQueryObserver { + return new LiveQueryObserverImpl( + collection ?? null, + options.deferInitialNotify ?? false, + ) +} diff --git a/packages/db/tests/live-query-observer.test.ts b/packages/db/tests/live-query-observer.test.ts new file mode 100644 index 000000000..9815ba9d0 --- /dev/null +++ b/packages/db/tests/live-query-observer.test.ts @@ -0,0 +1,120 @@ +import { describe, expect, it } from 'vitest' +import { createCollection } from '../src/collection/index.js' +import { createLiveQueryObserver } from '../src/live-query-observer.js' +import { mockSyncCollectionOptions } from './utils.js' +import type { ChangeMessage } from '../src/types.js' + +interface Row { + id: string + name: string +} + +const SEED: Array = [ + { id: `1`, name: `A` }, + { id: `2`, name: `B` }, +] + +let seq = 0 +function makeSource(data: Array = SEED) { + return createCollection( + mockSyncCollectionOptions({ + id: `observer-test-${seq++}`, + getKey: (r) => r.id, + initialData: data, + }), + ) +} + +describe(`createLiveQueryObserver`, () => { + it(`exposes a stable snapshot of a ready collection (wholesale path)`, () => { + const observer = createLiveQueryObserver(makeSource() as any) + + const snap = observer.getSnapshot() + expect(snap.isEnabled).toBe(true) + expect(snap.isReady).toBe(true) + expect(snap.status).toBe(`ready`) + expect(snap.data).toHaveLength(2) + expect(snap.state?.get(`1`)).toMatchObject({ name: `A` }) + // Same identity when nothing changed. + expect(observer.getSnapshot()).toBe(snap) + observer.dispose() + }) + + it(`delivers initial state then change deltas to subscribers (granular path)`, () => { + const source = makeSource() + const observer = createLiveQueryObserver(source as any) + + const deltas: Array> = [] + const unsub = observer.subscribe((changes) => { + if (changes) deltas.push(...changes) + }) + // Initial rows arrive synchronously as inserts (includeInitialState). + expect( + deltas + .filter((c) => c.type === `insert`) + .map((c) => c.key) + .sort(), + ).toEqual([`1`, `2`]) + + const before = observer.getSnapshot() + source.utils.begin() + source.utils.write({ type: `insert`, value: { id: `3`, name: `C` } }) + source.utils.commit() + + // Subsequent deltas keep flowing synchronously... + expect(deltas.some((c) => c.type === `insert` && c.key === `3`)).toBe(true) + // ...and wholesale consumers see a fresh, updated snapshot. + const after = observer.getSnapshot() + expect(after).not.toBe(before) + expect(after.data).toHaveLength(3) + + unsub() + observer.dispose() + }) + + it(`stops notifying after unsubscribe / dispose`, () => { + const source = makeSource() + const observer = createLiveQueryObserver(source as any) + + let count = 0 + const unsub = observer.subscribe(() => { + count++ + }) + unsub() + const countAfterUnsub = count // initial-state notify may have fired + + source.utils.begin() + source.utils.write({ type: `insert`, value: { id: `9`, name: `Z` } }) + source.utils.commit() + + // No further notifications after unsubscribe. + expect(count).toBe(countAfterUnsub) + observer.dispose() + }) + + it(`represents a disabled query (null collection)`, () => { + const observer = createLiveQueryObserver(null) + const snap = observer.getSnapshot() + expect(snap.isEnabled).toBe(false) + expect(snap.status).toBe(`disabled`) + expect(snap.data).toBeUndefined() + expect(snap.state).toBeUndefined() + observer.dispose() + }) + + it(`defers the initial notify to a microtask when deferInitialNotify is set`, async () => { + const observer = createLiveQueryObserver(makeSource() as any, { + deferInitialNotify: true, + }) + let notified = false + observer.subscribe(() => { + notified = true + }) + // Not synchronous during subscribe (protects React's useSyncExternalStore)... + expect(notified).toBe(false) + await Promise.resolve() + // ...delivered on the next microtask. + expect(notified).toBe(true) + observer.dispose() + }) +}) diff --git a/packages/react-db/src/useLiveQuery.ts b/packages/react-db/src/useLiveQuery.ts index 8dc3d0a31..59cb0f46f 100644 --- a/packages/react-db/src/useLiveQuery.ts +++ b/packages/react-db/src/useLiveQuery.ts @@ -2,9 +2,8 @@ import { useRef, useSyncExternalStore } from 'react' import { BaseQueryBuilder, createLiveQueryCollection, - getLiveQueryStatusFlags, + createLiveQueryObserver, isCollection, - isSingleResultCollection, } from '@tanstack/db' import type { Collection, @@ -14,6 +13,7 @@ import type { InferResultType, InitialQueryBuilder, LiveQueryCollectionConfig, + LiveQueryObserver, NonSingleResult, QueryBuilder, SingleResult, @@ -328,12 +328,10 @@ export function useLiveQuery( const depsRef = useRef | null>(null) const configRef = useRef(null) - // Use refs to track version and memoized snapshot - const versionRef = useRef(0) - const snapshotRef = useRef<{ - collection: Collection | null - version: number - } | null>(null) + // The shared observer owns subscription, the ready-race, and the snapshot. + const observerRef = useRef | null>( + null, + ) // Check if we need to create/recreate the collection const needsNewCollection = @@ -413,143 +411,30 @@ export function useLiveQuery( } } - // Reset refs when collection changes + // Recreate the observer when the underlying collection changes. if (needsNewCollection) { - versionRef.current = 0 - snapshotRef.current = null + observerRef.current?.dispose() + // Defer the initial notify: useSyncExternalStore must not be notified + // synchronously during subscribe. + observerRef.current = createLiveQueryObserver(collectionRef.current, { + deferInitialNotify: true, + }) } + const observer = observerRef.current! - // Create stable subscribe function using ref + // Stable subscribe bound to the current observer; the observer owns the + // subscription, ready-race, and disposal. const subscribeRef = useRef< ((onStoreChange: () => void) => () => void) | null >(null) if (!subscribeRef.current || needsNewCollection) { - subscribeRef.current = (onStoreChange: () => void) => { - // If no collection, return a no-op unsubscribe function - if (!collectionRef.current) { - return () => {} - } - - let unsubscribed = false - - const subscription = collectionRef.current.subscribeChanges(() => { - // Drop late notifies that race with unsubscribe. - if (unsubscribed) return - // Bump version on any change; getSnapshot will rebuild next time - versionRef.current += 1 - onStoreChange() - }) - // Already-ready collections won't emit an initial change. Notify React - // ourselves, but defer to a microtask — calling onStoreChange synchronously - // here lands during the render-to-commit window and trips React's - // "state update on a component that hasn't mounted yet" warning. - if (collectionRef.current.status === `ready`) { - queueMicrotask(() => { - if (unsubscribed) return - versionRef.current += 1 - onStoreChange() - }) - } - return () => { - unsubscribed = true - subscription.unsubscribe() - } - } - } - - // Create stable getSnapshot function using ref - const getSnapshotRef = useRef< - | (() => { - collection: Collection | null - version: number - }) - | null - >(null) - if (!getSnapshotRef.current || needsNewCollection) { - getSnapshotRef.current = () => { - const currentVersion = versionRef.current - const currentCollection = collectionRef.current - - // Recreate snapshot object only if version/collection changed - if ( - !snapshotRef.current || - snapshotRef.current.version !== currentVersion || - snapshotRef.current.collection !== currentCollection - ) { - snapshotRef.current = { - collection: currentCollection, - version: currentVersion, - } - } - - return snapshotRef.current - } - } - - // Use useSyncExternalStore to subscribe to collection changes - const snapshot = useSyncExternalStore( - subscribeRef.current, - getSnapshotRef.current, - ) - - // Track last snapshot (from useSyncExternalStore) and the returned value separately - const returnedSnapshotRef = useRef<{ - collection: Collection | null - version: number - } | null>(null) - // Keep implementation return loose to satisfy overload signatures - const returnedRef = useRef(null) - - // Rebuild returned object only when the snapshot changes (version or collection identity) - if ( - !returnedSnapshotRef.current || - returnedSnapshotRef.current.version !== snapshot.version || - returnedSnapshotRef.current.collection !== snapshot.collection - ) { - // Handle null collection case (when callback returns undefined/null) - if (!snapshot.collection) { - returnedRef.current = { - state: undefined, - data: undefined, - collection: undefined, - status: `disabled`, - isLoading: false, - isReady: true, - isIdle: false, - isError: false, - isCleanedUp: false, - isEnabled: false, - } - } else { - // Capture a stable view of entries for this snapshot to avoid tearing - const entries = Array.from(snapshot.collection.entries()) - const singleResult = isSingleResultCollection(snapshot.collection) - let stateCache: Map | null = null - let dataCache: Array | null = null - - returnedRef.current = { - get state() { - if (!stateCache) { - stateCache = new Map(entries) - } - return stateCache - }, - get data() { - if (!dataCache) { - dataCache = entries.map(([, value]) => value) - } - return singleResult ? dataCache[0] : dataCache - }, - collection: snapshot.collection, - status: snapshot.collection.status, - ...getLiveQueryStatusFlags(snapshot.collection.status), - isEnabled: true, - } - } - - // Remember the snapshot that produced this returned value - returnedSnapshotRef.current = snapshot + subscribeRef.current = (onStoreChange) => + observer.subscribe(() => onStoreChange()) } - return returnedRef.current! + // The observer returns a stable snapshot per revision, which is the return + // shape this hook exposes. Keep the return loose to satisfy the overloads. + return useSyncExternalStore(subscribeRef.current, () => + observer.getSnapshot(), + ) as any } diff --git a/packages/solid-db/src/useLiveQuery.ts b/packages/solid-db/src/useLiveQuery.ts index 7759915f3..a2a39a7fa 100644 --- a/packages/solid-db/src/useLiveQuery.ts +++ b/packages/solid-db/src/useLiveQuery.ts @@ -10,6 +10,7 @@ import { ReactiveMap } from '@solid-primitives/map' import { BaseQueryBuilder, createLiveQueryCollection, + createLiveQueryObserver, isCollection, isSingleResultCollection, } from '@tanstack/db' @@ -389,36 +390,35 @@ export function useLiveQuery( setData([]) return } - const subscription = currentCollection.subscribeChanges( - (changes: Array>) => { - // Apply each change individually to the reactive state + + // The shared observer owns subscription, the ready-race, and status; Solid + // materializes into its keyed ReactiveMap (granular) + reconciled store. + const observer = createLiveQueryObserver(currentCollection) + const unsubscribe = observer.subscribe( + (changes: Array> | undefined) => { batch(() => { - for (const change of changes) { - switch (change.type) { - case `insert`: - case `update`: - state.set(change.key, change.value) - break - case `delete`: - state.delete(change.key) - break + if (changes) { + for (const change of changes) { + switch (change.type) { + case `insert`: + case `update`: + state.set(change.key, change.value) + break + case `delete`: + state.delete(change.key) + break + } } } - syncDataFromCollection(currentCollection) - - // Update status ref on every change - setStatus(currentCollection.status) + setStatus(observer.getSnapshot().status) }) }, - { - // Include initial state to ensure immediate population for pre-created collections - includeInitialState: true, - }, ) onCleanup(() => { - subscription.unsubscribe() + unsubscribe() + observer.dispose() }) }) diff --git a/packages/svelte-db/src/useLiveQuery.svelte.ts b/packages/svelte-db/src/useLiveQuery.svelte.ts index 789d3d08f..385ccfd55 100644 --- a/packages/svelte-db/src/useLiveQuery.svelte.ts +++ b/packages/svelte-db/src/useLiveQuery.svelte.ts @@ -5,6 +5,7 @@ import { SvelteMap } from 'svelte/reactivity' import { BaseQueryBuilder, createLiveQueryCollection, + createLiveQueryObserver, isCollection, isSingleResultCollection, } from '@tanstack/db' @@ -17,6 +18,7 @@ import type { InferResultType, InitialQueryBuilder, LiveQueryCollectionConfig, + LiveQueryObserver, NonSingleResult, QueryBuilder, SingleResult, @@ -375,13 +377,26 @@ export function useLiveQuery( }) } - // Track current unsubscribe function - let currentUnsubscribe: (() => void) | null = null + // The shared observer owns subscription, the ready-race, and status; Svelte + // materializes into its own rune-backed map (granular) + ordered array. + let currentObserver: LiveQueryObserver | null = null + + const syncFromObserver = ( + observer: LiveQueryObserver, + currentCollection: Collection, + ) => { + status = observer.getSnapshot().status as CollectionStatus + syncDataFromCollection(currentCollection) + } // Watch for collection changes and subscribe to updates $effect(() => { const currentCollection = collection + // Tear down any previous observer. + currentObserver?.dispose() + currentObserver = null + // Handle null collection (disabled query) if (!currentCollection) { status = `disabled` as const @@ -389,81 +404,43 @@ export function useLiveQuery( state.clear() internalData = [] }) - if (currentUnsubscribe) { - currentUnsubscribe() - currentUnsubscribe = null - } return } - // Update status state whenever the effect runs - status = currentCollection.status - - // Clean up previous subscription - if (currentUnsubscribe) { - currentUnsubscribe() - } - - // Initialize state with current collection data - untrack(() => { - state.clear() - for (const [key, value] of currentCollection.entries()) { - state.set(key, value) - } - }) - - // Initialize data array in correct order - syncDataFromCollection(currentCollection) + const observer = createLiveQueryObserver(currentCollection) + currentObserver = observer - // Listen for the first ready event to catch status transitions - // that might not trigger change events (fixes async status transition bug) - currentCollection.onFirstReady(() => { - // Update status directly - Svelte's reactivity system handles the update automatically - // Note: We cannot use flushSync here as it's disallowed inside effects in async mode - status = currentCollection.status - }) + // Initial rows arrive as the observer's first delta (includeInitialState); + // apply them and every subsequent delta granularly to the rune-backed map. + untrack(() => state.clear()) - // Subscribe to collection changes with granular updates - const subscription = currentCollection.subscribeChanges( - (changes: Array>) => { - // Apply each change individually to the reactive state + const unsubscribe = observer.subscribe( + (changes: Array> | undefined) => { untrack(() => { - for (const change of changes) { - switch (change.type) { - case `insert`: - case `update`: - state.set(change.key, change.value) - break - case `delete`: - state.delete(change.key) - break + if (changes) { + for (const change of changes) { + switch (change.type) { + case `insert`: + case `update`: + state.set(change.key, change.value) + break + case `delete`: + state.delete(change.key) + break + } } } }) - - // Update the data array to maintain sorted order - syncDataFromCollection(currentCollection) - // Update status state on every change - status = currentCollection.status - }, - { - includeInitialState: true, + syncFromObserver(observer, currentCollection) }, ) - - currentUnsubscribe = subscription.unsubscribe.bind(subscription) - - // Preload collection data if not already started - if (currentCollection.status === `idle`) { - currentCollection.preload().catch(console.error) - } + syncFromObserver(observer, currentCollection) // Cleanup when effect is invalidated return () => { - if (currentUnsubscribe) { - currentUnsubscribe() - currentUnsubscribe = null - } + unsubscribe() + observer.dispose() + currentObserver = null } }) diff --git a/packages/vue-db/src/useLiveQuery.ts b/packages/vue-db/src/useLiveQuery.ts index 762cbda0a..c12fdb8ee 100644 --- a/packages/vue-db/src/useLiveQuery.ts +++ b/packages/vue-db/src/useLiveQuery.ts @@ -1,7 +1,6 @@ import { computed, getCurrentInstance, - nextTick, onUnmounted, reactive, ref, @@ -10,6 +9,7 @@ import { } from 'vue' import { createLiveQueryCollection, + createLiveQueryObserver, isCollection, isSingleResultCollection, } from '@tanstack/db' @@ -22,6 +22,7 @@ import type { InferResultType, InitialQueryBuilder, LiveQueryCollectionConfig, + LiveQueryObserver, NonSingleResult, QueryBuilder, SingleResult, @@ -363,101 +364,73 @@ export function useLiveQuery( internalData.push(...Array.from(currentCollection.values())) } - // Track current unsubscribe function - let currentUnsubscribe: (() => void) | null = null + // The shared observer owns subscription, the ready-race, and status; Vue + // materializes into its own reactive map (granular) + ordered array. + let currentObserver: LiveQueryObserver | null = null + + const syncFromObserver = ( + observer: LiveQueryObserver, + currentCollection: Collection, + ) => { + status.value = observer.getSnapshot().status as CollectionStatus + syncDataFromCollection(currentCollection) + } // Watch for collection changes and subscribe to updates watchEffect((onInvalidate) => { const currentCollection = collection.value + // Tear down any previous observer. + currentObserver?.dispose() + currentObserver = null + // Handle null collection (disabled query) if (!currentCollection) { status.value = `disabled` as const state.clear() internalData.length = 0 - if (currentUnsubscribe) { - currentUnsubscribe() - currentUnsubscribe = null - } return } - // Update status ref whenever the effect runs - status.value = currentCollection.status - - // Clean up previous subscription - if (currentUnsubscribe) { - currentUnsubscribe() - } + const observer = createLiveQueryObserver(currentCollection) + currentObserver = observer - // Initialize state with current collection data + // Initial rows arrive as the observer's first delta (includeInitialState); + // apply them and every subsequent delta granularly to the reactive map. state.clear() - for (const [key, value] of currentCollection.entries()) { - state.set(key, value) - } - - // Initialize data array in correct order - syncDataFromCollection(currentCollection) - // Listen for the first ready event to catch status transitions - // that might not trigger change events (fixes async status transition bug) - currentCollection.onFirstReady(() => { - // Use nextTick to ensure Vue reactivity updates properly - nextTick(() => { - status.value = currentCollection.status - }) - }) - - // Subscribe to collection changes with granular updates - const subscription = currentCollection.subscribeChanges( - (changes: Array>) => { - // Apply each change individually to the reactive state - for (const change of changes) { - switch (change.type) { - case `insert`: - case `update`: - state.set(change.key, change.value) - break - case `delete`: - state.delete(change.key) - break + const unsubscribe = observer.subscribe( + (changes: Array> | undefined) => { + if (changes) { + for (const change of changes) { + switch (change.type) { + case `insert`: + case `update`: + state.set(change.key, change.value) + break + case `delete`: + state.delete(change.key) + break + } } } - - // Update the data array to maintain sorted order - syncDataFromCollection(currentCollection) - // Update status ref on every change - status.value = currentCollection.status - }, - { - includeInitialState: true, + syncFromObserver(observer, currentCollection) }, ) - - currentUnsubscribe = subscription.unsubscribe.bind(subscription) - - // Preload collection data if not already started - if (currentCollection.status === `idle`) { - currentCollection.preload().catch(console.error) - } + syncFromObserver(observer, currentCollection) // Cleanup when effect is invalidated onInvalidate(() => { - if (currentUnsubscribe) { - currentUnsubscribe() - currentUnsubscribe = null - } + unsubscribe() + observer.dispose() + currentObserver = null }) }) // Cleanup on unmount (only if we're in a component context) const instance = getCurrentInstance() if (instance) { - onUnmounted(() => { - if (currentUnsubscribe) { - currentUnsubscribe() - } - }) + onUnmounted(() => currentObserver?.dispose()) } return { From e957121df1e63bff3234c0897e09d87a04219951 Mon Sep 17 00:00:00 2001 From: Kevin De Porre Date: Tue, 7 Jul 2026 17:23:58 +0200 Subject: [PATCH 02/42] fix(db): make observer onFirstReady detach-safe MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit onFirstReady returns no unsubscribe and detach() couldn't remove it, so a subscribe → unsubscribe-before-ready → subscribe sequence left a stale ready callback that also fired on markReady — the current listener saw two synthetic ready notifications instead of one. Guard the callback with an attach-generation token so only the current attachment's callback notifies. Adds a regression test. Co-Authored-By: Claude Opus 4.8 (1M context) --- packages/db/src/live-query-observer.ts | 15 +++++++- packages/db/tests/live-query-observer.test.ts | 38 ++++++++++++++++++- 2 files changed, 51 insertions(+), 2 deletions(-) diff --git a/packages/db/src/live-query-observer.ts b/packages/db/src/live-query-observer.ts index 46e58d839..c95f52b73 100644 --- a/packages/db/src/live-query-observer.ts +++ b/packages/db/src/live-query-observer.ts @@ -90,6 +90,9 @@ class LiveQueryObserverImpl< private cachedSnapshot: LiveQuerySnapshot = DISABLED_SNAPSHOT private readonly listeners = new Set>() private collectionUnsub: (() => void) | null = null + // Bumped on each attach. `onFirstReady` can't be unsubscribed, so a callback + // from a superseded attach checks this to no-op instead of double-notifying. + private attachGeneration = 0 private disposed = false constructor( @@ -149,6 +152,8 @@ class LiveQueryObserverImpl< const collection = this.collection if (!collection || this.disposed) return + const generation = ++this.attachGeneration + // Subscribe with initial state so granular consumers receive the current // rows as inserts followed by deltas through one consistent channel — the // same contract the adapters used before the observer existed (the @@ -179,8 +184,16 @@ class LiveQueryObserverImpl< // (e.g. `markReady()` with no rows). Skip when already ready — the initial // state batch above already covers that, and `onFirstReady` would fire an // immediate duplicate. + // + // `onFirstReady` returns no unsubscribe, so a callback left behind by an + // earlier attach (subscribe → unsubscribe-before-ready → subscribe) would + // still fire on `markReady`. Guard with the attach generation so only the + // current attachment's callback notifies. if (collection.status !== `ready`) { - collection.onFirstReady(() => notify(undefined)) + collection.onFirstReady(() => { + if (generation !== this.attachGeneration) return + notify(undefined) + }) } attaching = false diff --git a/packages/db/tests/live-query-observer.test.ts b/packages/db/tests/live-query-observer.test.ts index 9815ba9d0..5acafe9d8 100644 --- a/packages/db/tests/live-query-observer.test.ts +++ b/packages/db/tests/live-query-observer.test.ts @@ -1,7 +1,10 @@ import { describe, expect, it } from 'vitest' import { createCollection } from '../src/collection/index.js' import { createLiveQueryObserver } from '../src/live-query-observer.js' -import { mockSyncCollectionOptions } from './utils.js' +import { + mockSyncCollectionOptions, + mockSyncCollectionOptionsNoInitialState, +} from './utils.js' import type { ChangeMessage } from '../src/types.js' interface Row { @@ -25,6 +28,18 @@ function makeSource(data: Array = SEED) { ) } +/** A collection that is syncing but not yet ready, with a manual `markReady`. */ +function makeLoadingSource() { + const collection = createCollection( + mockSyncCollectionOptionsNoInitialState({ + id: `observer-loading-${seq++}`, + getKey: (r) => r.id, + }), + ) + collection.startSyncImmediate() + return collection +} + describe(`createLiveQueryObserver`, () => { it(`exposes a stable snapshot of a ready collection (wholesale path)`, () => { const observer = createLiveQueryObserver(makeSource() as any) @@ -117,4 +132,25 @@ describe(`createLiveQueryObserver`, () => { expect(notified).toBe(true) observer.dispose() }) + + it(`fires the ready notify once after unsubscribe-before-ready then resubscribe`, () => { + const collection = makeLoadingSource() + const observer = createLiveQueryObserver(collection as any) + + // Subscribe then unsubscribe while still loading — this registers an + // onFirstReady callback that detach() can't remove. + observer.subscribe(() => {})() + + let readyNotifications = 0 + observer.subscribe((changes) => { + if (changes === undefined) readyNotifications++ + }) + + collection.utils.markReady() + + // Only the current subscription's ready callback should fire, not the + // stale one left behind by the first (already unsubscribed) attach. + expect(readyNotifications).toBe(1) + observer.dispose() + }) }) From c05a72603a07edc728418f777a90e44a9dc8b54a Mon Sep 17 00:00:00 2001 From: Kevin De Porre Date: Thu, 9 Jul 2026 09:59:48 +0200 Subject: [PATCH 03/42] fix(db): address observer/react lifecycle review findings MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit - observer: getSnapshot() rebuilds when collection.status changes without a version bump (status-only loading→ready / preload with no active subscription), so a cached snapshot can't go stale. - observer: guard the deferred initial-notify microtask with the attach generation + listener count, so a superseded attach can't flush a stale initial batch to a later listener. - react: don't dispose the previous observer during render (unsafe under concurrent rendering) — useSyncExternalStore detaches it when the subscribe changes; dispose the current observer in an unmount effect instead. - tests: regressions for the deferred-notify race and the status-only snapshot refresh (both verified red before the fixes). Co-Authored-By: Claude Opus 4.8 (1M context) --- packages/db/src/live-query-observer.ts | 22 ++++++++++-- packages/db/tests/live-query-observer.test.ts | 36 +++++++++++++++++++ packages/react-db/src/useLiveQuery.ts | 17 +++++++-- 3 files changed, 69 insertions(+), 6 deletions(-) diff --git a/packages/db/src/live-query-observer.ts b/packages/db/src/live-query-observer.ts index c95f52b73..8387b3838 100644 --- a/packages/db/src/live-query-observer.ts +++ b/packages/db/src/live-query-observer.ts @@ -87,6 +87,7 @@ class LiveQueryObserverImpl< private readonly deferInitialNotify: boolean private version = 0 private cachedVersion = -1 + private cachedStatus: CollectionStatus | undefined private cachedSnapshot: LiveQuerySnapshot = DISABLED_SNAPSHOT private readonly listeners = new Set>() private collectionUnsub: (() => void) | null = null @@ -109,9 +110,15 @@ class LiveQueryObserverImpl< const collection = this.collection if (!collection) return DISABLED_SNAPSHOT - // Rebuild only when the version advanced, so identity stays stable. - if (this.cachedVersion !== this.version) { + // Rebuild when the version advanced, or when the collection's status + // changed without a version bump (e.g. a status-only loading→ready + // transition or `preload()` while there is no active subscription). + if ( + this.cachedVersion !== this.version || + this.cachedStatus !== collection.status + ) { this.cachedVersion = this.version + this.cachedStatus = collection.status const entries = Array.from(collection.entries()) as Array<[TKey, T]> const singleResult = isSingleResultCollection(collection) let stateCache: Map | null = null @@ -199,7 +206,16 @@ class LiveQueryObserverImpl< attaching = false if (deferred.length > 0) { queueMicrotask(() => { - if (this.disposed) return + // Skip if the observer was disposed, has no listeners, or a newer + // attach superseded this one before the flush — otherwise a stale + // initial batch would reach the current listener. + if ( + this.disposed || + this.listeners.size === 0 || + generation !== this.attachGeneration + ) { + return + } for (const changes of deferred.splice(0)) this.emit(changes) }) } diff --git a/packages/db/tests/live-query-observer.test.ts b/packages/db/tests/live-query-observer.test.ts index 5acafe9d8..1035a11ef 100644 --- a/packages/db/tests/live-query-observer.test.ts +++ b/packages/db/tests/live-query-observer.test.ts @@ -153,4 +153,40 @@ describe(`createLiveQueryObserver`, () => { expect(readyNotifications).toBe(1) observer.dispose() }) + + it(`does not flush a superseded deferred initial notify (deferInitialNotify)`, async () => { + const observer = createLiveQueryObserver(makeSource() as any, { + deferInitialNotify: true, + }) + + // Subscribe then unsubscribe before the microtask flush, then resubscribe. + observer.subscribe(() => {})() + + let notifications = 0 + observer.subscribe(() => { + notifications++ + }) + await Promise.resolve() + + // Only the current subscription's deferred initial notify should flush, + // not the stale one queued by the first (superseded) attach. + expect(notifications).toBe(1) + observer.dispose() + }) + + it(`refreshes the snapshot when status changes without a version bump`, () => { + // A status-only loading→ready transition with no active subscription: the + // cached snapshot must not stay stale (covers the preload() case too). + const collection = makeLoadingSource() + const observer = createLiveQueryObserver(collection as any) + + expect(observer.getSnapshot().isReady).toBe(false) + expect(observer.getSnapshot().status).toBe(`loading`) + + collection.utils.markReady() + + expect(observer.getSnapshot().isReady).toBe(true) + expect(observer.getSnapshot().status).toBe(`ready`) + observer.dispose() + }) }) diff --git a/packages/react-db/src/useLiveQuery.ts b/packages/react-db/src/useLiveQuery.ts index 59cb0f46f..0baa266f0 100644 --- a/packages/react-db/src/useLiveQuery.ts +++ b/packages/react-db/src/useLiveQuery.ts @@ -1,4 +1,4 @@ -import { useRef, useSyncExternalStore } from 'react' +import { useEffect, useRef, useSyncExternalStore } from 'react' import { BaseQueryBuilder, createLiveQueryCollection, @@ -411,9 +411,12 @@ export function useLiveQuery( } } - // Recreate the observer when the underlying collection changes. + // Recreate the observer when the underlying collection changes. Do not + // dispose the previous observer here — teardown during render is unsafe under + // concurrent rendering. `useSyncExternalStore` unsubscribes the old observer + // when `subscribeRef` changes (below), which detaches it; the final observer + // is disposed in the unmount effect. if (needsNewCollection) { - observerRef.current?.dispose() // Defer the initial notify: useSyncExternalStore must not be notified // synchronously during subscribe. observerRef.current = createLiveQueryObserver(collectionRef.current, { @@ -422,6 +425,14 @@ export function useLiveQuery( } const observer = observerRef.current! + // Dispose the current observer on unmount (commit-phase cleanup). + useEffect( + () => () => { + observerRef.current?.dispose() + }, + [], + ) + // Stable subscribe bound to the current observer; the observer owns the // subscription, ready-race, and disposal. const subscribeRef = useRef< From fd3c87ddf0b95912102e383eaba93f0be0fba3df Mon Sep 17 00:00:00 2001 From: Kevin De Porre Date: Thu, 9 Jul 2026 14:35:52 +0200 Subject: [PATCH 04/42] fix(react-db): don't dispose the observer in an unmount effect (StrictMode) MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit The unmount-effect dispose could run during StrictMode/offscreen effect replay (mount → cleanup → mount) without a re-render, leaving observerRef pointing at a disposed observer; the next subscribe hit attach()'s disposed guard and the store stopped resubscribing. Remove the explicit dispose — useSyncExternalStore already detaches the observer on unsubscribe/unmount, so the collection subscription is torn down and the observer is GC'd. Adds a StrictMode regression test (verified red before the fix). Co-Authored-By: Claude Opus 4.8 (1M context) --- packages/react-db/src/useLiveQuery.ts | 21 +++------ .../tests/useLiveQuery.strictmode.test.tsx | 44 +++++++++++++++++++ 2 files changed, 51 insertions(+), 14 deletions(-) create mode 100644 packages/react-db/tests/useLiveQuery.strictmode.test.tsx diff --git a/packages/react-db/src/useLiveQuery.ts b/packages/react-db/src/useLiveQuery.ts index 0baa266f0..058d9eb91 100644 --- a/packages/react-db/src/useLiveQuery.ts +++ b/packages/react-db/src/useLiveQuery.ts @@ -1,4 +1,4 @@ -import { useEffect, useRef, useSyncExternalStore } from 'react' +import { useRef, useSyncExternalStore } from 'react' import { BaseQueryBuilder, createLiveQueryCollection, @@ -411,11 +411,12 @@ export function useLiveQuery( } } - // Recreate the observer when the underlying collection changes. Do not - // dispose the previous observer here — teardown during render is unsafe under - // concurrent rendering. `useSyncExternalStore` unsubscribes the old observer - // when `subscribeRef` changes (below), which detaches it; the final observer - // is disposed in the unmount effect. + // Recreate the observer when the underlying collection changes. The observer + // is not disposed explicitly here or on unmount: `useSyncExternalStore` + // unsubscribes it when the subscribe changes or the component unmounts, which + // detaches the collection subscription; the observer is then GC'd. (An unmount + // effect that disposed it would misfire under StrictMode/offscreen effect + // replay, leaving a disposed observer in the ref.) if (needsNewCollection) { // Defer the initial notify: useSyncExternalStore must not be notified // synchronously during subscribe. @@ -425,14 +426,6 @@ export function useLiveQuery( } const observer = observerRef.current! - // Dispose the current observer on unmount (commit-phase cleanup). - useEffect( - () => () => { - observerRef.current?.dispose() - }, - [], - ) - // Stable subscribe bound to the current observer; the observer owns the // subscription, ready-race, and disposal. const subscribeRef = useRef< diff --git a/packages/react-db/tests/useLiveQuery.strictmode.test.tsx b/packages/react-db/tests/useLiveQuery.strictmode.test.tsx new file mode 100644 index 000000000..874d02062 --- /dev/null +++ b/packages/react-db/tests/useLiveQuery.strictmode.test.tsx @@ -0,0 +1,44 @@ +import { describe, expect, it } from 'vitest' +import { StrictMode } from 'react' +import { act, renderHook, waitFor } from '@testing-library/react' +import { createCollection } from '@tanstack/db' +import { useLiveQuery } from '../src/useLiveQuery' +import { mockSyncCollectionOptions } from '../../db/tests/utils' + +type Person = { id: string; name: string } + +describe(`useLiveQuery under StrictMode`, () => { + it(`keeps the subscription alive across StrictMode effect replay`, async () => { + const collection = createCollection( + mockSyncCollectionOptions({ + id: `strictmode-persons`, + getKey: (p) => p.id, + initialData: [{ id: `1`, name: `A` }], + }), + ) + + // StrictMode double-invokes effects (mount → cleanup → mount). A dispose in + // the unmount effect would tear the observer down and never recreate it, + // leaving a dead subscription. + const { result } = renderHook( + () => + useLiveQuery((q) => + q + .from({ p: collection }) + .select(({ p }) => ({ id: p.id, name: p.name })), + ), + { wrapper: StrictMode }, + ) + + await waitFor(() => expect(result.current.data).toHaveLength(1)) + + // A mutation after the StrictMode replay must still reach the hook. + act(() => { + collection.utils.begin() + collection.utils.write({ type: `insert`, value: { id: `2`, name: `B` } }) + collection.utils.commit() + }) + + await waitFor(() => expect(result.current.data).toHaveLength(2)) + }) +}) From 7577d405aba086c02a9b8453fc3d4c9cd2ea93bc Mon Sep 17 00:00:00 2001 From: Kevin De Porre Date: Mon, 13 Jul 2026 11:39:06 +0200 Subject: [PATCH 05/42] test(db): cover live-query keyed-state invariant on recompile Expose the keyed `state` map in the shared conformance harness (added to ConformanceResult and read by all five adapter drivers) and add a steady-state `recompile-drops-stale-keys` scenario asserting the map stays in sync with `data` across a narrowing recompile. Also add a solid-db regression (in useLiveQuery.test.tsx) that inspects `state` synchronously in the window after a recompile, where solid-db leaks the previous collection's keys until its async resource reconciles. This test fails until the follow-up fix (state.clear() before re-subscribing). Co-Authored-By: Claude Opus 4.8 --- packages/angular-db/tests/conformance.test.ts | 1 + packages/db/tests/conformance/contract.ts | 6 +++ packages/db/tests/conformance/suite.ts | 29 +++++++++++++ packages/react-db/tests/conformance.test.tsx | 1 + packages/solid-db/tests/conformance.test.tsx | 1 + packages/solid-db/tests/useLiveQuery.test.tsx | 43 +++++++++++++++++++ .../tests/conformance.svelte.test.ts | 1 + packages/vue-db/tests/conformance.test.ts | 1 + 8 files changed, 83 insertions(+) diff --git a/packages/angular-db/tests/conformance.test.ts b/packages/angular-db/tests/conformance.test.ts index a8cc7ec80..6a5b5ba66 100644 --- a/packages/angular-db/tests/conformance.test.ts +++ b/packages/angular-db/tests/conformance.test.ts @@ -133,6 +133,7 @@ function makeHandle(result: any, destroy: () => void): LiveQueryHandle { current(): ConformanceResult { return { data: result.data(), + state: result.state(), status: result.status(), isReady: Boolean(result.isReady()), isError: Boolean(result.isError()), diff --git a/packages/db/tests/conformance/contract.ts b/packages/db/tests/conformance/contract.ts index 7726f558b..bf86b1055 100644 --- a/packages/db/tests/conformance/contract.ts +++ b/packages/db/tests/conformance/contract.ts @@ -74,6 +74,12 @@ export interface DbOps { export interface ConformanceResult { /** Array for list queries; a single row (or undefined) for `findOne`. */ data: any + /** + * The keyed result map (`undefined` when disabled). Exposed so scenarios can + * assert the granular map stays in sync with `data` — e.g. that stale keys + * from a previous collection don't linger after a recompile. + */ + state: ReadonlyMap | undefined status: string isReady: boolean isError: boolean diff --git a/packages/db/tests/conformance/suite.ts b/packages/db/tests/conformance/suite.ts index eb52b1826..ba4c53360 100644 --- a/packages/db/tests/conformance/suite.ts +++ b/packages/db/tests/conformance/suite.ts @@ -493,6 +493,35 @@ export function runSuite(rawDriver: LiveQueryDriver) { }, ) + scenario( + `recompile-drops-stale-keys`, + `recompiling to a narrower result drops keys from the previous collection`, + async () => { + const source = driver.makeSource(SEED) + const h = driver.mountControllable( + (q, minAge) => + q + .from({ items: source.collection }) + .where(({ items }: any) => ops.gt(items.age, minAge)) + .select(({ items }: any) => ({ id: items.id })), + 10, + ) + await h.flush() + expect(h.current().data).toHaveLength(3) // all ages > 10 + // The keyed `state` map must mirror `data` exactly. + expect(h.current().state?.size).toBe(3) + + // Narrowing the filter recompiles into a *new* underlying collection + // holding fewer keys. `includeInitialState` only inserts the new rows; + // if the adapter reuses a persistent keyed map without clearing it, the + // dropped keys leak into `state` even though `data` looks correct. + await h.setParam(32) // only John Smith (age 35) survives + expect(h.current().data).toHaveLength(1) + expect(h.current().state?.size).toBe(1) + h.unmount() + }, + ) + scenario( `disabled-transition`, `disabled -> enabled -> disabled toggles correctly`, diff --git a/packages/react-db/tests/conformance.test.tsx b/packages/react-db/tests/conformance.test.tsx index 303bbd89e..3916d67b7 100644 --- a/packages/react-db/tests/conformance.test.tsx +++ b/packages/react-db/tests/conformance.test.tsx @@ -169,6 +169,7 @@ function makeHandle(hook: RenderHookResult) { const r: any = hook.result.current return { data: r?.data, + state: r?.state, status: r?.status ?? `idle`, isReady: Boolean(r?.isReady), isError: Boolean(r?.isError), diff --git a/packages/solid-db/tests/conformance.test.tsx b/packages/solid-db/tests/conformance.test.tsx index 023addafd..ae7bf2138 100644 --- a/packages/solid-db/tests/conformance.test.tsx +++ b/packages/solid-db/tests/conformance.test.tsx @@ -130,6 +130,7 @@ function makeHandle( const result = getResult() return { data: result?.data, + state: result?.state, status: result?.status ?? `idle`, isReady: Boolean(result?.isReady), isError: Boolean(result?.isError), diff --git a/packages/solid-db/tests/useLiveQuery.test.tsx b/packages/solid-db/tests/useLiveQuery.test.tsx index 378c2a0fc..b7567a635 100644 --- a/packages/solid-db/tests/useLiveQuery.test.tsx +++ b/packages/solid-db/tests/useLiveQuery.test.tsx @@ -523,6 +523,49 @@ describe(`Query Collections`, () => { }) }) + it(`should drop stale keys from state synchronously when parameters narrow`, async () => { + // Narrowing recompiles into a *new* collection with fewer keys. The + // observer re-seeds via `includeInitialState`, which only inserts current + // rows and never deletes the previous collection's keys. `state` must be + // cleared synchronously so the dropped keys don't linger in the window + // before the async resource reconciles (this reads `state` with no settle; + // `data`, rebuilt wholesale, stays correct either way). + return createRoot(async (dispose) => { + const collection = createCollection( + mockSyncCollectionOptions({ + id: `stale-keys-on-narrow-test`, + getKey: (person: Person) => person.id, + initialData: initialPersons, + }), + ) + + const [minAge, setMinAge] = createSignal(10) + const rendered = renderHook( + (props: { minAge: Accessor }) => { + return useLiveQuery((q) => + q + .from({ collection }) + .where(({ collection: c }) => gt(c.age, props.minAge())) + .select(({ collection: c }) => ({ id: c.id })), + ) + }, + { initialProps: [{ minAge }] }, + ) + + await new Promise((resolve) => setTimeout(resolve, 50)) + expect(rendered.result.state.size).toBe(3) // all three ages > 10 + + // Narrow to only John Smith (age 35); ids 1 and 2 must not linger. + setMinAge(32) + + expect(rendered.result.state.size).toBe(1) + expect(rendered.result.state.has(`1`)).toBe(false) + expect(rendered.result.state.has(`2`)).toBe(false) + + dispose() + }) + }) + it(`should be able to query a result collection with live updates`, async () => { const collection = createCollection( mockSyncCollectionOptions({ diff --git a/packages/svelte-db/tests/conformance.svelte.test.ts b/packages/svelte-db/tests/conformance.svelte.test.ts index a57709b2b..a2f4a66d3 100644 --- a/packages/svelte-db/tests/conformance.svelte.test.ts +++ b/packages/svelte-db/tests/conformance.svelte.test.ts @@ -128,6 +128,7 @@ function makeHandle(getQuery: () => any, dispose: () => void): LiveQueryHandle { const query = getQuery() return { data: query?.data, + state: query?.state, status: query?.status ?? `idle`, isReady: Boolean(query?.isReady), isError: Boolean(query?.isError), diff --git a/packages/vue-db/tests/conformance.test.ts b/packages/vue-db/tests/conformance.test.ts index 432876f4f..eef6e0a2f 100644 --- a/packages/vue-db/tests/conformance.test.ts +++ b/packages/vue-db/tests/conformance.test.ts @@ -126,6 +126,7 @@ function makeHandle(result: any, scope: ReturnType) { current(): ConformanceResult { return { data: result.data?.value, + state: result.state?.value, status: result.status?.value ?? `idle`, isReady: Boolean(result.isReady?.value), isError: Boolean(result.isError?.value), From 2a0679e04577962b1e5d0e59e539467b6c32145b Mon Sep 17 00:00:00 2001 From: Kevin De Porre Date: Mon, 13 Jul 2026 11:39:22 +0200 Subject: [PATCH 06/42] fix(solid-db): clear keyed state before subscribing to a new collection MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit When the query recompiles to a different collection, the observer re-seeds via `includeInitialState`, which only inserts current rows and never deletes keys from the previous collection. Without clearing first, the dropped keys lingered in `state` until the async resource reconciled — a transient window where `state` exposed stale rows (though `data`, rebuilt wholesale, stayed correct). Clear synchronously before re-subscribing, matching vue-db and svelte-db. Fixes the solid-db stale-keys regression added in the previous commit. Co-Authored-By: Claude Opus 4.8 --- packages/solid-db/src/useLiveQuery.ts | 5 +++++ 1 file changed, 5 insertions(+) diff --git a/packages/solid-db/src/useLiveQuery.ts b/packages/solid-db/src/useLiveQuery.ts index a2a39a7fa..a50b8269c 100644 --- a/packages/solid-db/src/useLiveQuery.ts +++ b/packages/solid-db/src/useLiveQuery.ts @@ -394,6 +394,11 @@ export function useLiveQuery( // The shared observer owns subscription, the ready-race, and status; Solid // materializes into its keyed ReactiveMap (granular) + reconciled store. const observer = createLiveQueryObserver(currentCollection) + // Clear any keys carried over from a previous collection before the new + // observer re-seeds via `includeInitialState` (which only inserts current + // rows, never deletes stale ones). Without this, switching collections + // leaves the dropped keys in `state` until the async resource reconciles. + state.clear() const unsubscribe = observer.subscribe( (changes: Array> | undefined) => { batch(() => { From 593818a338a1a610152611a77d9cfc8734778d31 Mon Sep 17 00:00:00 2001 From: Kevin De Porre Date: Mon, 13 Jul 2026 12:26:47 +0200 Subject: [PATCH 07/42] feat(db): republish ordered live queries on an order-only move (RFC #1623 phase 4) An `orderBy` live query that reorders its rows without changing any projected row value (an "order-only move") was swallowed by the collection's value-diff: `.values()`/`.entries()` re-sorted, but no change event fired, so subscribers kept the stale order. This is the last universal expected-fail in the cross-adapter conformance suite (issue #1601). Phase 4 of the live-query platform RFC calls for an explicit layout-revision contract rather than a forged row `update`. This does that: - The live-query flush captures the retracted side of each change and, after commit, detects an order-only move (value deep-equal, `orderByIndex` moved) and publishes a first-class empty layout-change notification via a new `CollectionChangesManager.emitLayoutChangeEvent()`. - The shared observer snapshot gains `layoutRevision`, which increments on any visible membership, ordering, or order-only-move change. All five adapters pick this up through their existing wholesale re-read, so the `order-only-move` conformance scenario is removed from UNIVERSAL_EXPECTED_FAIL and now passes on React, Vue, Svelte, Solid, and Angular. Distinct from PR #1601 (v-anton), which fixes the same bug via a forced row `update`; this uses the RFC's layout-revision approach instead. Co-Authored-By: Claude Opus 4.8 --- .changeset/live-query-order-only-move.md | 13 ++ packages/db/src/collection/changes.ts | 13 ++ packages/db/src/live-query-observer.ts | 21 +++ .../query/live/collection-config-builder.ts | 43 +++++++ packages/db/src/query/live/types.ts | 6 + packages/db/tests/conformance/suite.ts | 2 +- .../tests/live-query-order-only-move.test.ts | 120 ++++++++++++++++++ 7 files changed, 217 insertions(+), 1 deletion(-) create mode 100644 .changeset/live-query-order-only-move.md create mode 100644 packages/db/tests/live-query-order-only-move.test.ts diff --git a/.changeset/live-query-order-only-move.md b/.changeset/live-query-order-only-move.md new file mode 100644 index 000000000..b391621da --- /dev/null +++ b/.changeset/live-query-order-only-move.md @@ -0,0 +1,13 @@ +--- +'@tanstack/db': patch +--- + +fix(db): republish ordered live queries on an order-only move + +An `orderBy` live query that reordered its rows without changing any projected +row value (an "order-only move") previously emitted nothing, so `useLiveQuery` +kept rendering the stale order. The live-query collection now publishes an +explicit layout-change notification when this happens, and the shared live-query +observer snapshot exposes a `layoutRevision` that increments on any visible +membership, ordering, or order-only-move change. All five framework adapters +pick this up via their existing wholesale re-read. diff --git a/packages/db/src/collection/changes.ts b/packages/db/src/collection/changes.ts index dc07cd3f1..dc79a0443 100644 --- a/packages/db/src/collection/changes.ts +++ b/packages/db/src/collection/changes.ts @@ -60,6 +60,19 @@ export class CollectionChangesManager< } } + /** + * Notify subscribers that the visible layout (row order) changed without any + * row value changing — e.g. an order-only move in an `orderBy` live query. + * Emits an empty batch directly (bypassing the empty-array check) so ordered + * consumers re-read the now re-sorted collection. This is a first-class layout + * signal, deliberately not a forged row `update`. + */ + public emitLayoutChangeEvent(): void { + for (const subscription of this.changeSubscriptions) { + subscription.emitEvents([]) + } + } + /** * Enriches a change message with virtual properties ($synced, $origin, $key, $collectionId). * Uses the "add-if-missing" pattern to preserve virtual properties from upstream collections. diff --git a/packages/db/src/live-query-observer.ts b/packages/db/src/live-query-observer.ts index 8387b3838..44fe2df18 100644 --- a/packages/db/src/live-query-observer.ts +++ b/packages/db/src/live-query-observer.ts @@ -22,6 +22,14 @@ export interface LiveQuerySnapshot< data: T | ReadonlyArray | undefined /** The underlying collection, or `undefined` when disabled. */ collection: Collection | undefined + /** + * Monotonic counter bumped whenever the visible layout changes — membership, + * ordering, or an order-only move. Lets consumers detect a reorder that + * changed no row value (which `data`/`state` identity alone can't express on + * its own once row values are structurally shared). Increments in lockstep + * with snapshot identity for enabled queries. + */ + layoutRevision: number status: CollectionStatus | `disabled` isLoading: boolean isReady: boolean @@ -70,6 +78,7 @@ const DISABLED_SNAPSHOT: LiveQuerySnapshot = { state: undefined, data: undefined, collection: undefined, + layoutRevision: 0, status: `disabled`, isLoading: false, isReady: true, @@ -89,6 +98,8 @@ class LiveQueryObserverImpl< private cachedVersion = -1 private cachedStatus: CollectionStatus | undefined private cachedSnapshot: LiveQuerySnapshot = DISABLED_SNAPSHOT + private layoutRevision = 0 + private lastLayoutSignature: string | undefined private readonly listeners = new Set>() private collectionUnsub: (() => void) | null = null // Bumped on each attach. `onFirstReady` can't be unsubscribed, so a callback @@ -124,6 +135,15 @@ class LiveQueryObserverImpl< let stateCache: Map | null = null let dataCache: Array | null = null + // Bump the layout revision when the ordered key sequence changed — + // membership, ordering, or an order-only move all shift it. `\u0000` + // can't appear in a stringified key, so it's a safe separator. + const layoutSignature = entries.map(([key]) => String(key)).join(`\u0000`) + if (layoutSignature !== this.lastLayoutSignature) { + this.lastLayoutSignature = layoutSignature + this.layoutRevision++ + } + this.cachedSnapshot = { get state() { if (!stateCache) stateCache = new Map(entries) @@ -134,6 +154,7 @@ class LiveQueryObserverImpl< return singleResult ? dataCache[0] : dataCache }, collection, + layoutRevision: this.layoutRevision, status: collection.status, ...getLiveQueryStatusFlags(collection.status), isEnabled: true, diff --git a/packages/db/src/query/live/collection-config-builder.ts b/packages/db/src/query/live/collection-config-builder.ts index a6a51b478..faf09baa5 100644 --- a/packages/db/src/query/live/collection-config-builder.ts +++ b/packages/db/src/query/live/collection-config-builder.ts @@ -11,6 +11,7 @@ import { } from '../../errors.js' import { transactionScopedScheduler } from '../../scheduler.js' import { getActiveTransaction } from '../../transactions.js' +import { deepEquals } from '../../utils.js' import { CollectionSubscriber } from './collection-subscriber.js' import { getCollectionBuilder } from './collection-registry.js' import { LIVE_QUERY_INTERNAL } from './internal.js' @@ -799,6 +800,11 @@ export class CollectionConfigBuilder< existing.orderByIndex = changes.orderByIndex } } + // Keep the retracted (old) side for order-only-move detection. + if (changes.deletes > 0) { + existing.previousValue = changes.previousValue + existing.previousOrderByIndex = changes.previousOrderByIndex + } } else { merged.set(customKey, { ...changes }) } @@ -811,6 +817,17 @@ export class CollectionConfigBuilder< begin() changesToApply.forEach(this.applyChanges.bind(this, config)) commit() + // An order-only move (the row's projected value is unchanged but its + // `orderByIndex` moved) is swallowed by the collection's value-diff, so + // `commit()` emits nothing even though `.values()`/`.entries()` are now + // re-sorted. Publish an explicit layout-change notification so ordered + // consumers re-read — a first-class signal, not a forged row `update`. + if (hasOrderOnlyMove(changesToApply)) { + const changesManager = (config.collection as any)._changes as { + emitLayoutChangeEvent: () => void + } + changesManager.emitLayoutChangeEvent() + } } pendingChanges = new Map() @@ -2315,6 +2332,10 @@ function accumulateChanges( } if (multiplicity < 0) { changes.deletes += Math.abs(multiplicity) + // Remember the retracted (old) value + position so the flush can tell an + // order-only move apart from a real value change. + changes.previousValue = value + changes.previousOrderByIndex = orderByIndex } else if (multiplicity > 0) { changes.inserts += multiplicity // Update value to the latest version for this key @@ -2326,3 +2347,25 @@ function accumulateChanges( acc.set(key, changes) return acc } + +/** + * Detect whether any accumulated change is an "order-only move": a row that was + * updated in place (both retracted and re-inserted) whose `orderByIndex` moved + * but whose projected value is deep-equal to before. The collection's value-diff + * emits nothing for these, so the flush must publish a layout notification. + * Rows whose value actually changed are ignored — they emit a normal `update`. + */ +function hasOrderOnlyMove(changesToApply: Map>): boolean { + for (const changes of changesToApply.values()) { + if ( + changes.inserts > 0 && + changes.deletes > 0 && + changes.orderByIndex !== changes.previousOrderByIndex && + changes.previousValue !== undefined && + deepEquals(changes.previousValue, changes.value) + ) { + return true + } + } + return false +} diff --git a/packages/db/src/query/live/types.ts b/packages/db/src/query/live/types.ts index 118015bd6..307397698 100644 --- a/packages/db/src/query/live/types.ts +++ b/packages/db/src/query/live/types.ts @@ -16,6 +16,12 @@ export type Changes = { inserts: number value: T orderByIndex: string | undefined + // Captured from the retract side of a change so the flush can detect an + // "order-only move": a row whose projected value is unchanged but whose + // `orderByIndex` moved. Such a move is swallowed by the collection's + // value-diff, so it needs an explicit layout notification. + previousValue?: T + previousOrderByIndex?: string | undefined } export type SyncState = { diff --git a/packages/db/tests/conformance/suite.ts b/packages/db/tests/conformance/suite.ts index ba4c53360..4f612f4eb 100644 --- a/packages/db/tests/conformance/suite.ts +++ b/packages/db/tests/conformance/suite.ts @@ -39,7 +39,7 @@ const ISSUES: Array = [ ] /** Keys that are expected to fail on ALL adapters (core gaps, not adapter drift). */ -const UNIVERSAL_EXPECTED_FAIL = new Set([`order-only-move`]) +const UNIVERSAL_EXPECTED_FAIL = new Set([]) export function runSuite(rawDriver: LiveQueryDriver) { const { ops } = rawDriver diff --git a/packages/db/tests/live-query-order-only-move.test.ts b/packages/db/tests/live-query-order-only-move.test.ts new file mode 100644 index 000000000..a3362f116 --- /dev/null +++ b/packages/db/tests/live-query-order-only-move.test.ts @@ -0,0 +1,120 @@ +import { describe, expect, it } from 'vitest' +import { createCollection } from '../src/collection/index.js' +import { createLiveQueryCollection } from '../src/query/live-query-collection.js' +import { createLiveQueryObserver } from '../src/live-query-observer.js' +import { mockSyncCollectionOptions } from './utils.js' + +interface Person { + id: string + name: string + age: number +} + +const SEED: Array = [ + { id: `1`, name: `Alice`, age: 30 }, + { id: `2`, name: `Bob`, age: 20 }, + { id: `3`, name: `Carol`, age: 40 }, +] + +let seq = 0 +function makeSource(data: Array = SEED) { + return createCollection( + mockSyncCollectionOptions({ + id: `order-only-move-${seq++}`, + getKey: (p) => p.id, + initialData: data, + }), + ) +} + +/** Live query ordered by `age` (NOT projected), selecting only `{ id, name }`. */ +async function makeOrderedByAge(source: ReturnType) { + const lq = createLiveQueryCollection((q) => + q + .from({ p: source }) + .orderBy(({ p }) => p.age, `asc`) + .select(({ p }) => ({ id: p.id, name: p.name })), + ) + await lq.preload() + return lq +} + +const flush = () => new Promise((r) => setTimeout(r, 0)) + +describe(`order-only move (RFC #1623 phase 4)`, () => { + it(`republishes the ordered result when a row moves but its value is unchanged`, async () => { + const source = makeSource() + const lq = await makeOrderedByAge(source) + const observer = createLiveQueryObserver<{ id: string; name: string }, string>( + lq as any, + ) + + let notifications = 0 + observer.subscribe(() => { + notifications++ + }) + + const before = observer.getSnapshot() + expect((before.data as Array).map((r) => r.id)).toEqual([`2`, `1`, `3`]) + const revBefore = before.layoutRevision + + // Move Bob (age 20 -> 99) to the end. The projected `{ id, name }` is + // identical, so the collection's value-diff emits no row change — only the + // layout notification should republish the new order. + source.utils.begin() + source.utils.write({ type: `update`, value: { id: `2`, name: `Bob`, age: 99 } }) + source.utils.commit() + await flush() + + const after = observer.getSnapshot() + expect((after.data as Array).map((r) => r.id)).toEqual([`1`, `3`, `2`]) + expect(after.layoutRevision).toBeGreaterThan(revBefore) + expect(notifications).toBeGreaterThan(0) + observer.dispose() + }) + + it(`does not bump the layout revision when nothing about the layout changes`, async () => { + const source = makeSource() + const lq = await makeOrderedByAge(source) + const observer = createLiveQueryObserver<{ id: string; name: string }, string>( + lq as any, + ) + observer.subscribe(() => {}) + + const revBefore = observer.getSnapshot().layoutRevision + + // Update a row's `age` in a way that keeps its sort position (20 -> 21, + // still the youngest) and does not change the projected value. + source.utils.begin() + source.utils.write({ type: `update`, value: { id: `2`, name: `Bob`, age: 21 } }) + source.utils.commit() + await flush() + + // Order is unchanged (`2` still first), so the layout revision is stable. + const after = observer.getSnapshot() + expect((after.data as Array).map((r) => r.id)).toEqual([`2`, `1`, `3`]) + expect(after.layoutRevision).toBe(revBefore) + observer.dispose() + }) + + it(`bumps the layout revision on membership changes too`, async () => { + const source = makeSource() + const lq = await makeOrderedByAge(source) + const observer = createLiveQueryObserver<{ id: string; name: string }, string>( + lq as any, + ) + observer.subscribe(() => {}) + + const revBefore = observer.getSnapshot().layoutRevision + + source.utils.begin() + source.utils.write({ type: `insert`, value: { id: `4`, name: `Dan`, age: 10 } }) + source.utils.commit() + await flush() + + const after = observer.getSnapshot() + expect((after.data as Array).map((r) => r.id)).toEqual([`4`, `2`, `1`, `3`]) + expect(after.layoutRevision).toBeGreaterThan(revBefore) + observer.dispose() + }) +}) From 2ea8f1ed86c78d49947fb383f8435536b053ea0c Mon Sep 17 00:00:00 2001 From: "autofix-ci[bot]" <114827586+autofix-ci[bot]@users.noreply.github.com> Date: Mon, 13 Jul 2026 10:28:23 +0000 Subject: [PATCH 08/42] ci: apply automated fixes --- .../query/live/collection-config-builder.ts | 4 +- .../tests/live-query-order-only-move.test.ts | 49 +++++++++++++------ 2 files changed, 38 insertions(+), 15 deletions(-) diff --git a/packages/db/src/query/live/collection-config-builder.ts b/packages/db/src/query/live/collection-config-builder.ts index faf09baa5..49ad01e77 100644 --- a/packages/db/src/query/live/collection-config-builder.ts +++ b/packages/db/src/query/live/collection-config-builder.ts @@ -2355,7 +2355,9 @@ function accumulateChanges( * emits nothing for these, so the flush must publish a layout notification. * Rows whose value actually changed are ignored — they emit a normal `update`. */ -function hasOrderOnlyMove(changesToApply: Map>): boolean { +function hasOrderOnlyMove( + changesToApply: Map>, +): boolean { for (const changes of changesToApply.values()) { if ( changes.inserts > 0 && diff --git a/packages/db/tests/live-query-order-only-move.test.ts b/packages/db/tests/live-query-order-only-move.test.ts index a3362f116..0b9d24562 100644 --- a/packages/db/tests/live-query-order-only-move.test.ts +++ b/packages/db/tests/live-query-order-only-move.test.ts @@ -45,9 +45,10 @@ describe(`order-only move (RFC #1623 phase 4)`, () => { it(`republishes the ordered result when a row moves but its value is unchanged`, async () => { const source = makeSource() const lq = await makeOrderedByAge(source) - const observer = createLiveQueryObserver<{ id: string; name: string }, string>( - lq as any, - ) + const observer = createLiveQueryObserver< + { id: string; name: string }, + string + >(lq as any) let notifications = 0 observer.subscribe(() => { @@ -55,14 +56,21 @@ describe(`order-only move (RFC #1623 phase 4)`, () => { }) const before = observer.getSnapshot() - expect((before.data as Array).map((r) => r.id)).toEqual([`2`, `1`, `3`]) + expect((before.data as Array).map((r) => r.id)).toEqual([ + `2`, + `1`, + `3`, + ]) const revBefore = before.layoutRevision // Move Bob (age 20 -> 99) to the end. The projected `{ id, name }` is // identical, so the collection's value-diff emits no row change — only the // layout notification should republish the new order. source.utils.begin() - source.utils.write({ type: `update`, value: { id: `2`, name: `Bob`, age: 99 } }) + source.utils.write({ + type: `update`, + value: { id: `2`, name: `Bob`, age: 99 }, + }) source.utils.commit() await flush() @@ -76,9 +84,10 @@ describe(`order-only move (RFC #1623 phase 4)`, () => { it(`does not bump the layout revision when nothing about the layout changes`, async () => { const source = makeSource() const lq = await makeOrderedByAge(source) - const observer = createLiveQueryObserver<{ id: string; name: string }, string>( - lq as any, - ) + const observer = createLiveQueryObserver< + { id: string; name: string }, + string + >(lq as any) observer.subscribe(() => {}) const revBefore = observer.getSnapshot().layoutRevision @@ -86,7 +95,10 @@ describe(`order-only move (RFC #1623 phase 4)`, () => { // Update a row's `age` in a way that keeps its sort position (20 -> 21, // still the youngest) and does not change the projected value. source.utils.begin() - source.utils.write({ type: `update`, value: { id: `2`, name: `Bob`, age: 21 } }) + source.utils.write({ + type: `update`, + value: { id: `2`, name: `Bob`, age: 21 }, + }) source.utils.commit() await flush() @@ -100,20 +112,29 @@ describe(`order-only move (RFC #1623 phase 4)`, () => { it(`bumps the layout revision on membership changes too`, async () => { const source = makeSource() const lq = await makeOrderedByAge(source) - const observer = createLiveQueryObserver<{ id: string; name: string }, string>( - lq as any, - ) + const observer = createLiveQueryObserver< + { id: string; name: string }, + string + >(lq as any) observer.subscribe(() => {}) const revBefore = observer.getSnapshot().layoutRevision source.utils.begin() - source.utils.write({ type: `insert`, value: { id: `4`, name: `Dan`, age: 10 } }) + source.utils.write({ + type: `insert`, + value: { id: `4`, name: `Dan`, age: 10 }, + }) source.utils.commit() await flush() const after = observer.getSnapshot() - expect((after.data as Array).map((r) => r.id)).toEqual([`4`, `2`, `1`, `3`]) + expect((after.data as Array).map((r) => r.id)).toEqual([ + `4`, + `2`, + `1`, + `3`, + ]) expect(after.layoutRevision).toBeGreaterThan(revBefore) observer.dispose() }) From d84fb7b8d13139517943606b6174f2d6aa3a1e9c Mon Sep 17 00:00:00 2001 From: Kevin De Porre Date: Mon, 13 Jul 2026 16:51:02 +0200 Subject: [PATCH 09/42] refactor(db): compare key sequence directly for layoutRevision + fix doc Addresses independent review of the layoutRevision contract: - The join-with-separator signature could collide: a key value equal to the concatenation of neighboring keys around the separator produces the same string as two separate keys, so a real layout change (a membership change whose combined key spans the separator) was missed. Compare the ordered key sequence directly instead - collision-free, and it avoids materializing a large string on every snapshot rebuild (a new key array is only allocated when the layout actually moved). Adds a regression test. - Correct the layoutRevision doc comment: it is NOT in lockstep with snapshot identity (a value-only update yields a new snapshot but the same layoutRevision). Co-Authored-By: Claude Opus 4.8 --- packages/db/src/live-query-observer.ts | 41 +++++++++++++------ packages/db/tests/live-query-observer.test.ts | 23 +++++++++++ 2 files changed, 52 insertions(+), 12 deletions(-) diff --git a/packages/db/src/live-query-observer.ts b/packages/db/src/live-query-observer.ts index 44fe2df18..cd742a7d5 100644 --- a/packages/db/src/live-query-observer.ts +++ b/packages/db/src/live-query-observer.ts @@ -23,11 +23,14 @@ export interface LiveQuerySnapshot< /** The underlying collection, or `undefined` when disabled. */ collection: Collection | undefined /** - * Monotonic counter bumped whenever the visible layout changes — membership, - * ordering, or an order-only move. Lets consumers detect a reorder that - * changed no row value (which `data`/`state` identity alone can't express on - * its own once row values are structurally shared). Increments in lockstep - * with snapshot identity for enabled queries. + * Monotonic counter bumped whenever the visible layout (the ordered key + * sequence) changes — membership, ordering, or an order-only move. Lets + * consumers detect a reorder that changed no row value (which `data`/`state` + * identity alone can't express once row values are structurally shared). + * + * It is NOT in lockstep with snapshot identity: a value-only update produces a + * new snapshot while `layoutRevision` stays put. A `layoutRevision` change + * always accompanies a new snapshot, but not vice versa. */ layoutRevision: number status: CollectionStatus | `disabled` @@ -99,7 +102,7 @@ class LiveQueryObserverImpl< private cachedStatus: CollectionStatus | undefined private cachedSnapshot: LiveQuerySnapshot = DISABLED_SNAPSHOT private layoutRevision = 0 - private lastLayoutSignature: string | undefined + private lastLayoutKeys: Array | undefined private readonly listeners = new Set>() private collectionUnsub: (() => void) | null = null // Bumped on each attach. `onFirstReady` can't be unsubscribed, so a callback @@ -135,12 +138,26 @@ class LiveQueryObserverImpl< let stateCache: Map | null = null let dataCache: Array | null = null - // Bump the layout revision when the ordered key sequence changed — - // membership, ordering, or an order-only move all shift it. `\u0000` - // can't appear in a stringified key, so it's a safe separator. - const layoutSignature = entries.map(([key]) => String(key)).join(`\u0000`) - if (layoutSignature !== this.lastLayoutSignature) { - this.lastLayoutSignature = layoutSignature + // Bump the layout revision when the ordered key sequence changes + // (membership, ordering, or an order-only move). Compare the key sequence + // directly rather than via a serialized signature: a joined-with-separator + // signature can collide when a key value equals the concatenation of + // neighboring keys around the separator. Comparing keys also avoids + // materializing a large string on every rebuild; a new key array is only + // allocated when the layout actually moved. + const prevKeys = this.lastLayoutKeys + let layoutChanged = + prevKeys === undefined || prevKeys.length !== entries.length + if (!layoutChanged) { + for (let i = 0; i < entries.length; i++) { + if (prevKeys![i] !== entries[i]![0]) { + layoutChanged = true + break + } + } + } + if (layoutChanged) { + this.lastLayoutKeys = entries.map(([key]) => key) this.layoutRevision++ } diff --git a/packages/db/tests/live-query-observer.test.ts b/packages/db/tests/live-query-observer.test.ts index 1035a11ef..7ec98ff25 100644 --- a/packages/db/tests/live-query-observer.test.ts +++ b/packages/db/tests/live-query-observer.test.ts @@ -189,4 +189,27 @@ describe(`createLiveQueryObserver`, () => { expect(observer.getSnapshot().status).toBe(`ready`) observer.dispose() }) + + it(`bumps layoutRevision on a membership change that a joined key signature would collide on`, () => { + // Two keys "a","b" vs a single key "a\u0000b" join to the same string under + // any separator that can appear in a key. The revision compares the key + // sequence directly, so it must still register the membership change. + const source = makeSource([ + { id: `a`, name: `A` }, + { id: `b`, name: `B` }, + ]) + const observer = createLiveQueryObserver(source as any) + observer.subscribe(() => {}) + + const revBefore = observer.getSnapshot().layoutRevision + + source.utils.begin() + source.utils.write({ type: `delete`, value: { id: `a`, name: `A` } }) + source.utils.write({ type: `delete`, value: { id: `b`, name: `B` } }) + source.utils.write({ type: `insert`, value: { id: `a\u0000b`, name: `AB` } }) + source.utils.commit() + + expect(observer.getSnapshot().layoutRevision).not.toBe(revBefore) + observer.dispose() + }) }) From d7628551f219cab27ddd542bffda40c8ee7f5aad Mon Sep 17 00:00:00 2001 From: "autofix-ci[bot]" <114827586+autofix-ci[bot]@users.noreply.github.com> Date: Mon, 13 Jul 2026 14:52:20 +0000 Subject: [PATCH 10/42] ci: apply automated fixes --- packages/db/tests/live-query-observer.test.ts | 5 ++++- 1 file changed, 4 insertions(+), 1 deletion(-) diff --git a/packages/db/tests/live-query-observer.test.ts b/packages/db/tests/live-query-observer.test.ts index 7ec98ff25..eb07e0c3d 100644 --- a/packages/db/tests/live-query-observer.test.ts +++ b/packages/db/tests/live-query-observer.test.ts @@ -206,7 +206,10 @@ describe(`createLiveQueryObserver`, () => { source.utils.begin() source.utils.write({ type: `delete`, value: { id: `a`, name: `A` } }) source.utils.write({ type: `delete`, value: { id: `b`, name: `B` } }) - source.utils.write({ type: `insert`, value: { id: `a\u0000b`, name: `AB` } }) + source.utils.write({ + type: `insert`, + value: { id: `a\u0000b`, name: `AB` }, + }) source.utils.commit() expect(observer.getSnapshot().layoutRevision).not.toBe(revBefore) From 43f1a7d6f4664b745378eab8baa72e6f238dc511 Mon Sep 17 00:00:00 2001 From: Kevin De Porre Date: Wed, 15 Jul 2026 10:10:38 +0200 Subject: [PATCH 11/42] test(db): add failing regressions for Kyle's review findings Two gaps in the order-only-move handling, reproduced as failing tests (to be fixed in a follow-up commit): 1. A commit containing both an ordinary value update and an order-only move publishes twice (commit's row batch + the separate empty layout event), where exactly one publication is expected. 2. Ordered child collections produced by `includes` don't consume the insertion-side order metadata or publish a layout-only move, so an ordered child stays in its old order after a child order-only move. Co-Authored-By: Claude Opus 4.8 --- .../tests/live-query-order-only-move.test.ts | 100 ++++++++++++++++++ 1 file changed, 100 insertions(+) diff --git a/packages/db/tests/live-query-order-only-move.test.ts b/packages/db/tests/live-query-order-only-move.test.ts index 0b9d24562..50a6b6110 100644 --- a/packages/db/tests/live-query-order-only-move.test.ts +++ b/packages/db/tests/live-query-order-only-move.test.ts @@ -2,6 +2,7 @@ import { describe, expect, it } from 'vitest' import { createCollection } from '../src/collection/index.js' import { createLiveQueryCollection } from '../src/query/live-query-collection.js' import { createLiveQueryObserver } from '../src/live-query-observer.js' +import { eq } from '../src/query/builder/functions.js' import { mockSyncCollectionOptions } from './utils.js' interface Person { @@ -138,4 +139,103 @@ describe(`order-only move (RFC #1623 phase 4)`, () => { expect(after.layoutRevision).toBeGreaterThan(revBefore) observer.dispose() }) + + // Kyle's review issue 1: a commit containing both an ordinary value update + // and an order-only move must publish exactly once — the ordinary publication + // already carries the final values and ordering, so the separate layout event + // is redundant. + it(`publishes a mixed value update and order-only move exactly once`, async () => { + const source = makeSource() + const lq = await makeOrderedByAge(source) + const observer = createLiveQueryObserver< + { id: string; name: string }, + string + >(lq as any) + + let notifications = 0 + observer.subscribe(() => notifications++) + notifications = 0 // exclude subscribeChanges' initial-state publication + + source.utils.begin() + source.utils.write({ + type: `update`, + value: { id: `1`, name: `Alicia`, age: 30 }, + }) + source.utils.write({ + type: `update`, + value: { id: `2`, name: `Bob`, age: 99 }, + }) + source.utils.commit() + + const after = observer.getSnapshot() + expect( + (after.data as Array).map(({ id, name }) => [id, name]), + ).toEqual([ + [`1`, `Alicia`], + [`3`, `Carol`], + [`2`, `Bob`], + ]) + expect(notifications).toBe(1) + observer.dispose() + }) + + // Kyle's review issue 2: an ordered child collection produced by `includes` + // must consume the insertion-side order metadata and publish its move when the + // projected child value is unchanged. + it(`publishes an ordered included child move exactly once`, async () => { + const parents = createCollection( + mockSyncCollectionOptions<{ id: string }>({ + id: `order-only-parents-${seq++}`, + getKey: ({ id }) => id, + initialData: [{ id: `p1` }], + }), + ) + const children = createCollection( + mockSyncCollectionOptions<{ + id: string + parentId: string + name: string + position: number + }>({ + id: `order-only-children-${seq++}`, + getKey: ({ id }) => id, + initialData: [ + { id: `c1`, parentId: `p1`, name: `One`, position: 1 }, + { id: `c2`, parentId: `p1`, name: `Two`, position: 2 }, + ], + }), + ) + const lq = createLiveQueryCollection((q) => + q.from({ parent: parents }).select(({ parent }) => ({ + id: parent.id, + children: q + .from({ child: children }) + .where(({ child }) => eq(child.parentId, parent.id)) + .orderBy(({ child }) => child.position) + .select(({ child }) => ({ id: child.id, name: child.name })), + })), + ) + await lq.preload() + + const childCollection = (lq.get(`p1`) as any).children + let notifications = 0 + const subscription = childCollection.subscribeChanges( + () => notifications++, + { includeInitialState: false }, + ) + + children.utils.begin() + children.utils.write({ + type: `update`, + value: { id: `c1`, parentId: `p1`, name: `One`, position: 3 }, + }) + children.utils.commit() + + expect([...childCollection.values()].map(({ id }: any) => id)).toEqual([ + `c2`, + `c1`, + ]) + expect(notifications).toBe(1) + subscription.unsubscribe() + }) }) From c9ec751d83b2bc9d5b92dd344cc9743236fc6e68 Mon Sep 17 00:00:00 2001 From: Kevin De Porre Date: Wed, 15 Jul 2026 10:18:18 +0200 Subject: [PATCH 12/42] fix(db): coalesce layout publications and cover ordered includes children Addresses Kyle's review of the order-only-move handling: 1. A commit containing both an ordinary value update and an order-only move published twice: commit() emitted the row batch and then the separate layout event fired redundantly. Replace hasOrderOnlyMove with needsLayoutOnlyPublication, which fires the layout event only when the commit published nothing else (any real insert/delete/value-changed update already notifies subscribers, who re-read the re-sorted collection). 2. Ordered child collections produced by includes did not reorder on an order-only child move: - The child accumulate replaced value on the insert side but left the retracted orderByIndex, so the child collection re-sorted against a stale index. Update orderByIndex on insert and capture the retract side (both the single-level and nested-includes accumulate blocks). - The child flush committed without a layout-only publication when the projected child value was unchanged. Publish one through the same mechanism (emitLayoutChange) when the child commit published nothing else. Co-Authored-By: Claude Opus 4.8 --- .../query/live/collection-config-builder.ts | 75 ++++++++++++++----- 1 file changed, 56 insertions(+), 19 deletions(-) diff --git a/packages/db/src/query/live/collection-config-builder.ts b/packages/db/src/query/live/collection-config-builder.ts index 49ad01e77..0db2687de 100644 --- a/packages/db/src/query/live/collection-config-builder.ts +++ b/packages/db/src/query/live/collection-config-builder.ts @@ -822,11 +822,10 @@ export class CollectionConfigBuilder< // `commit()` emits nothing even though `.values()`/`.entries()` are now // re-sorted. Publish an explicit layout-change notification so ordered // consumers re-read — a first-class signal, not a forged row `update`. - if (hasOrderOnlyMove(changesToApply)) { - const changesManager = (config.collection as any)._changes as { - emitLayoutChangeEvent: () => void - } - changesManager.emitLayoutChangeEvent() + // Only when the commit published nothing else: any real row change + // already notifies subscribers, who re-read the re-sorted collection. + if (needsLayoutOnlyPublication(changesToApply)) { + emitLayoutChange(config.collection) } } pendingChanges = new Map() @@ -910,9 +909,14 @@ export class CollectionConfigBuilder< if (multiplicity < 0) { existing.deletes += Math.abs(multiplicity) + existing.previousValue = childResult + existing.previousOrderByIndex = _orderByIndex } else if (multiplicity > 0) { existing.inserts += multiplicity existing.value = childResult + if (_orderByIndex !== undefined) { + existing.orderByIndex = _orderByIndex + } } byChild.set(childKey, existing) @@ -1337,9 +1341,14 @@ function setupNestedPipelines( if (multiplicity < 0) { existing.deletes += Math.abs(multiplicity) + existing.previousValue = childResult + existing.previousOrderByIndex = _orderByIndex } else if (multiplicity > 0) { existing.inserts += multiplicity existing.value = childResult + if (_orderByIndex !== undefined) { + existing.orderByIndex = _orderByIndex + } } byChild.set(childKey, existing) @@ -2002,6 +2011,13 @@ function flushIncludesState( } } entry.syncMethods.commit() + // Same order-only-move handling as the parent flush: a child row that + // moved without a value change is swallowed by the value-diff, so + // publish a layout-only notification when the child commit published + // nothing else. + if (needsLayoutOnlyPublication(childChanges)) { + emitLayoutChange(entry.syncMethods.collection) + } } // Update routing index for nested includes @@ -2349,25 +2365,46 @@ function accumulateChanges( } /** - * Detect whether any accumulated change is an "order-only move": a row that was - * updated in place (both retracted and re-inserted) whose `orderByIndex` moved - * but whose projected value is deep-equal to before. The collection's value-diff - * emits nothing for these, so the flush must publish a layout notification. - * Rows whose value actually changed are ignored — they emit a normal `update`. + * Decide whether a flush needs a standalone layout-change publication. + * + * An "order-only move" — a row updated in place whose `orderByIndex` moved but + * whose projected value is deep-equal to before — is swallowed by the value-diff + * and needs an explicit layout notification. But that notification is only + * needed when the same `commit()` published nothing else: any real change + * (insert, delete, or a value-changed update) already notifies subscribers, who + * re-read the re-sorted collection, so a second layout event would be redundant. + * + * Returns true iff there is at least one order-only move AND no change that the + * commit itself publishes. */ -function hasOrderOnlyMove( +function needsLayoutOnlyPublication( changesToApply: Map>, ): boolean { + let layoutMoved = false + let commitPublishes = false for (const changes of changesToApply.values()) { - if ( - changes.inserts > 0 && - changes.deletes > 0 && - changes.orderByIndex !== changes.previousOrderByIndex && - changes.previousValue !== undefined && - deepEquals(changes.previousValue, changes.value) + const isUpdate = changes.inserts > 0 && changes.deletes > 0 + if (!isUpdate) { + // A net insert or delete always publishes a row change. + commitPublishes = true + } else if ( + changes.previousValue === undefined || + !deepEquals(changes.previousValue, changes.value) ) { - return true + // In-place update whose value changed — publishes a normal `update`. + commitPublishes = true + } else if (changes.orderByIndex !== changes.previousOrderByIndex) { + // Value unchanged, position moved — swallowed by the value-diff. + layoutMoved = true } } - return false + return layoutMoved && !commitPublishes +} + +/** Emit a layout-only change notification on a live-query collection. */ +function emitLayoutChange(collection: Collection): void { + const changesManager = (collection as any)._changes as { + emitLayoutChangeEvent: () => void + } + changesManager.emitLayoutChangeEvent() } From ff339d8c3bf297827e6fd67d2b7a7c39c2b9bc29 Mon Sep 17 00:00:00 2001 From: Kevin De Porre Date: Wed, 15 Jul 2026 10:34:54 +0200 Subject: [PATCH 13/42] test(db): guard order-only moves in deeply-nested ordered includes The includes flush is recursive, so the order-only-move handling must hold beyond one level. Adds a two-level ordered-includes regression (org -> teams -> members): moving a grandchild whose projected value is unchanged must re-sort its collection and publish exactly once. Verified red when the child-flush layout publication is removed. Co-Authored-By: Claude Opus 4.8 --- .../tests/live-query-order-only-move.test.ts | 79 +++++++++++++++++++ 1 file changed, 79 insertions(+) diff --git a/packages/db/tests/live-query-order-only-move.test.ts b/packages/db/tests/live-query-order-only-move.test.ts index 50a6b6110..442ec77fe 100644 --- a/packages/db/tests/live-query-order-only-move.test.ts +++ b/packages/db/tests/live-query-order-only-move.test.ts @@ -238,4 +238,83 @@ describe(`order-only move (RFC #1623 phase 4)`, () => { expect(notifications).toBe(1) subscription.unsubscribe() }) + + // The includes flush is recursive, so the order-only-move handling must hold + // at depth, not just one level. Two levels of ordered includes + // (org -> teams -> members); move a grandchild whose projected value is + // unchanged and assert its collection re-sorts and publishes exactly once. + it(`publishes an ordered move in a deeply-nested included child exactly once`, async () => { + const orgs = createCollection( + mockSyncCollectionOptions<{ id: string }>({ + id: `order-only-orgs-${seq++}`, + getKey: ({ id }) => id, + initialData: [{ id: `o1` }], + }), + ) + const teams = createCollection( + mockSyncCollectionOptions<{ id: string; orgId: string; position: number }>( + { + id: `order-only-teams-${seq++}`, + getKey: ({ id }) => id, + initialData: [{ id: `t1`, orgId: `o1`, position: 1 }], + }, + ), + ) + const members = createCollection( + mockSyncCollectionOptions<{ + id: string + teamId: string + name: string + position: number + }>({ + id: `order-only-members-${seq++}`, + getKey: ({ id }) => id, + initialData: [ + { id: `m1`, teamId: `t1`, name: `One`, position: 1 }, + { id: `m2`, teamId: `t1`, name: `Two`, position: 2 }, + ], + }), + ) + const lq = createLiveQueryCollection((q) => + q.from({ org: orgs }).select(({ org }) => ({ + id: org.id, + teams: q + .from({ team: teams }) + .where(({ team }) => eq(team.orgId, org.id)) + .orderBy(({ team }) => team.position) + .select(({ team }) => ({ + id: team.id, + members: q + .from({ member: members }) + .where(({ member }) => eq(member.teamId, team.id)) + .orderBy(({ member }) => member.position) + .select(({ member }) => ({ id: member.id, name: member.name })), + })), + })), + ) + await lq.preload() + + const teamCollection = (lq.get(`o1`) as any).teams + const memberCollection = (teamCollection.get(`t1`)).members + let notifications = 0 + const subscription = memberCollection.subscribeChanges( + () => notifications++, + { includeInitialState: false }, + ) + + // Move m1 behind m2 (position 1 -> 3); projected { id, name } unchanged. + members.utils.begin() + members.utils.write({ + type: `update`, + value: { id: `m1`, teamId: `t1`, name: `One`, position: 3 }, + }) + members.utils.commit() + + expect([...memberCollection.values()].map(({ id }: any) => id)).toEqual([ + `m2`, + `m1`, + ]) + expect(notifications).toBe(1) + subscription.unsubscribe() + }) }) From 4634ea5d2a69efb5f6b8d854c8009faa43029127 Mon Sep 17 00:00:00 2001 From: "autofix-ci[bot]" <114827586+autofix-ci[bot]@users.noreply.github.com> Date: Wed, 15 Jul 2026 08:36:41 +0000 Subject: [PATCH 14/42] ci: apply automated fixes --- .../tests/live-query-order-only-move.test.ts | 18 ++++++++++-------- 1 file changed, 10 insertions(+), 8 deletions(-) diff --git a/packages/db/tests/live-query-order-only-move.test.ts b/packages/db/tests/live-query-order-only-move.test.ts index 442ec77fe..a4fb2700f 100644 --- a/packages/db/tests/live-query-order-only-move.test.ts +++ b/packages/db/tests/live-query-order-only-move.test.ts @@ -252,13 +252,15 @@ describe(`order-only move (RFC #1623 phase 4)`, () => { }), ) const teams = createCollection( - mockSyncCollectionOptions<{ id: string; orgId: string; position: number }>( - { - id: `order-only-teams-${seq++}`, - getKey: ({ id }) => id, - initialData: [{ id: `t1`, orgId: `o1`, position: 1 }], - }, - ), + mockSyncCollectionOptions<{ + id: string + orgId: string + position: number + }>({ + id: `order-only-teams-${seq++}`, + getKey: ({ id }) => id, + initialData: [{ id: `t1`, orgId: `o1`, position: 1 }], + }), ) const members = createCollection( mockSyncCollectionOptions<{ @@ -295,7 +297,7 @@ describe(`order-only move (RFC #1623 phase 4)`, () => { await lq.preload() const teamCollection = (lq.get(`o1`) as any).teams - const memberCollection = (teamCollection.get(`t1`)).members + const memberCollection = teamCollection.get(`t1`).members let notifications = 0 const subscription = memberCollection.subscribeChanges( () => notifications++, From 877bc234371029d1b6d3f7c555749d8120a757ba Mon Sep 17 00:00:00 2001 From: Kevin De Porre Date: Wed, 15 Jul 2026 13:48:27 +0200 Subject: [PATCH 15/42] feat(db): shared live-query window controller (RFC #1623 phase 5) Extracts the forward-pagination state machine out of react-db's useLiveInfiniteQuery into a framework-agnostic createLiveQueryWindowController in @tanstack/db, composing the shared live-query observer. The controller owns loadedPageCount, the peek-ahead window (via collection.utils.setWindow), page slicing, and hasNextPage/isFetchingNextPage, and exposes a reactivity-free getSnapshot/subscribe/fetchNextPage/reset/dispose surface mirroring the observer. react-db's useLiveInfiniteQuery is now a thin binding over it with no public API change; its existing suite stays green. Vue/Svelte/Solid/Angular can build infinite queries on the same controller instead of re-porting React's logic. Co-Authored-By: Claude Opus 4.8 --- .changeset/live-query-window-controller.md | 14 + packages/db/src/index.ts | 1 + .../db/src/live-query-window-controller.ts | 302 ++++++++++++++++++ .../live-query-window-controller.test.ts | 174 ++++++++++ packages/react-db/src/useLiveInfiniteQuery.ts | 244 +++++--------- 5 files changed, 575 insertions(+), 160 deletions(-) create mode 100644 .changeset/live-query-window-controller.md create mode 100644 packages/db/src/live-query-window-controller.ts create mode 100644 packages/db/tests/live-query-window-controller.test.ts diff --git a/.changeset/live-query-window-controller.md b/.changeset/live-query-window-controller.md new file mode 100644 index 000000000..bdf4f7a5a --- /dev/null +++ b/.changeset/live-query-window-controller.md @@ -0,0 +1,14 @@ +--- +'@tanstack/db': patch +'@tanstack/react-db': patch +--- + +feat(db): shared live-query window controller for infinite queries + +Adds `createLiveQueryWindowController` to `@tanstack/db` — the framework-agnostic +forward-pagination state machine (loaded-page count, peek-ahead window via +`setWindow`, page slicing, `hasNextPage`/`isFetchingNextPage`) that composes the +live-query observer. `react-db`'s `useLiveInfiniteQuery` is reimplemented as a +thin binding over it with no public API change, so other framework adapters can +build infinite queries on the same shared semantics instead of re-porting the +React hook. diff --git a/packages/db/src/index.ts b/packages/db/src/index.ts index bf4e16a81..9958d56be 100644 --- a/packages/db/src/index.ts +++ b/packages/db/src/index.ts @@ -12,6 +12,7 @@ export * from './query/index.js' export * from './optimistic-action' export * from './live-query-adapter' export * from './live-query-observer' +export * from './live-query-window-controller' export * from './local-only' export * from './local-storage' export * from './errors' diff --git a/packages/db/src/live-query-window-controller.ts b/packages/db/src/live-query-window-controller.ts new file mode 100644 index 000000000..41ebd3e76 --- /dev/null +++ b/packages/db/src/live-query-window-controller.ts @@ -0,0 +1,302 @@ +import { createLiveQueryObserver } from './live-query-observer.js' +import type { + CreateLiveQueryObserverOptions, + LiveQueryObserver, +} from './live-query-observer.js' +import type { Collection } from './collection/index.js' +import type { CollectionStatus } from './types.js' + +const DEFAULT_PAGE_SIZE = 20 + +/** + * A page-windowed view of a live query at a point in time. Extends the live + * query's status/data contract with forward pagination derived from a + * peek-ahead window (`limit = loadedPages * pageSize + 1`): the extra row tells + * us whether another page exists and is then dropped from `data`/`pages`. + * + * `getSnapshot()` returns a stable identity that only changes when the query, + * the page count, or the fetching state changes, so `useSyncExternalStore`-style + * consumers can compare by reference. + */ +export interface LiveQueryWindowSnapshot< + T extends object, + TKey extends string | number, +> { + /** Rows across all loaded pages, peek-ahead row removed. */ + data: ReadonlyArray + /** Rows grouped into pages of `pageSize`. */ + pages: ReadonlyArray> + /** `initialPageParam + i` for each loaded page. */ + pageParams: ReadonlyArray + hasNextPage: boolean + isFetchingNextPage: boolean + /** Keyed results for the whole window (incl. peek row), or `undefined` when disabled. */ + state: ReadonlyMap | undefined + collection: Collection | undefined + status: CollectionStatus | `disabled` + isLoading: boolean + isReady: boolean + isIdle: boolean + isError: boolean + isCleanedUp: boolean + isEnabled: boolean +} + +export interface CreateLiveQueryWindowControllerOptions + extends CreateLiveQueryObserverOptions { + /** Rows per page (default 20). A falsy value falls back to the default. */ + pageSize?: number + /** Value of the first page's `pageParam` (default 0). */ + initialPageParam?: number + /** + * Defer applying the first window until the collection is ready. Set for + * query-function inputs whose collection is created lazily and already carries + * the first page's window in its query; leave off for a pre-created collection + * whose window must be established up front. + */ + waitForReady?: boolean +} + +/** + * Owns the forward-pagination state machine for an ordered live query: the + * loaded-page count, the peek-ahead window (via `collection.utils.setWindow`), + * page slicing, and `hasNextPage`/`isFetchingNextPage`. Composes a + * {@link LiveQueryObserver} for the data + lifecycle channel. Framework adapters + * resolve the input to a collection and materialize the snapshot natively. + */ +export interface LiveQueryWindowController< + T extends object, + TKey extends string | number, +> { + getSnapshot: () => LiveQueryWindowSnapshot + subscribe: (listener: () => void) => () => void + /** Load one more page (no-op when already fetching or no next page exists). */ + fetchNextPage: () => void + /** Reset back to the first page — call when the input identity/deps change. */ + reset: () => void + preload: () => Promise + dispose: () => void +} + +interface CachedFrom { + observerSnapshot: unknown + loadedPageCount: number + isFetchingNextPage: boolean +} + +class LiveQueryWindowControllerImpl< + T extends object, + TKey extends string | number, +> implements LiveQueryWindowController +{ + private readonly observer: LiveQueryObserver + private readonly collection: Collection | null + private readonly pageSize: number + private readonly initialPageParam: number + private readonly waitForReady: boolean + + private loadedPageCount = 1 + private isFetchingNextPage = false + // The limit last handed to `setWindow`, so we don't re-apply an unchanged + // window on every observer notification. + private appliedLimit: number | undefined + // Bumped on each window application so a superseded load promise doesn't clear + // the fetching flag for a window that no longer applies. + private windowGeneration = 0 + + private readonly listeners = new Set<() => void>() + private observerUnsub: (() => void) | null = null + private cachedSnapshot: LiveQueryWindowSnapshot | null = null + private cachedFrom: CachedFrom | null = null + private disposed = false + + constructor( + collection: Collection | null, + options: CreateLiveQueryWindowControllerOptions, + ) { + this.collection = collection + this.pageSize = options.pageSize || DEFAULT_PAGE_SIZE + this.initialPageParam = options.initialPageParam ?? 0 + this.waitForReady = options.waitForReady ?? false + this.observer = createLiveQueryObserver(collection, { + deferInitialNotify: options.deferInitialNotify, + }) + } + + getSnapshot(): LiveQueryWindowSnapshot { + const observerSnapshot = this.observer.getSnapshot() + const cached = this.cachedSnapshot + if ( + cached && + this.cachedFrom && + this.cachedFrom.observerSnapshot === observerSnapshot && + this.cachedFrom.loadedPageCount === this.loadedPageCount && + this.cachedFrom.isFetchingNextPage === this.isFetchingNextPage + ) { + return cached + } + + const enabled = observerSnapshot.isEnabled + const rows = + enabled && Array.isArray(observerSnapshot.data) + ? (observerSnapshot.data as ReadonlyArray) + : [] + const totalRequested = this.loadedPageCount * this.pageSize + // The window peeks one row past what was requested; its presence means + // there is another page. It is not part of the visible result. + const hasNextPage = enabled && rows.length > totalRequested + + // A disabled query has no pages; an enabled query always has `loadedPageCount` + // pages (the last may be empty when there is no data yet). + const pageCount = enabled ? this.loadedPageCount : 0 + const pages: Array> = [] + const pageParams: Array = [] + for (let i = 0; i < pageCount; i++) { + pages.push(rows.slice(i * this.pageSize, (i + 1) * this.pageSize)) + pageParams.push(this.initialPageParam + i) + } + + this.cachedSnapshot = { + data: rows.slice(0, totalRequested), + pages, + pageParams, + hasNextPage, + isFetchingNextPage: this.isFetchingNextPage, + state: observerSnapshot.state, + collection: observerSnapshot.collection, + status: observerSnapshot.status, + isLoading: observerSnapshot.isLoading, + isReady: observerSnapshot.isReady, + isIdle: observerSnapshot.isIdle, + isError: observerSnapshot.isError, + isCleanedUp: observerSnapshot.isCleanedUp, + isEnabled: observerSnapshot.isEnabled, + } + this.cachedFrom = { + observerSnapshot, + loadedPageCount: this.loadedPageCount, + isFetchingNextPage: this.isFetchingNextPage, + } + return this.cachedSnapshot + } + + subscribe(listener: () => void): () => void { + this.listeners.add(listener) + if (this.listeners.size === 1) { + this.observerUnsub = this.observer.subscribe(() => this.onObserverNotify()) + // Establish the current window now that the query is active. + this.applyWindow() + } + + let active = true + return () => { + if (!active) return + active = false + this.listeners.delete(listener) + if (this.listeners.size === 0) { + this.observerUnsub?.() + this.observerUnsub = null + } + } + } + + fetchNextPage(): void { + if (this.disposed || this.isFetchingNextPage) return + if (!this.getSnapshot().hasNextPage) return + this.loadedPageCount++ + this.applyWindow() + this.notify() + } + + reset(): void { + if (this.disposed) return + if (this.loadedPageCount === 1 && this.appliedLimit !== undefined) { + // Already on the first page; nothing to reset. + return + } + this.loadedPageCount = 1 + this.appliedLimit = undefined + this.applyWindow() + this.notify() + } + + preload(): Promise { + return this.observer.preload() + } + + dispose(): void { + if (this.disposed) return + this.disposed = true + this.observerUnsub?.() + this.observerUnsub = null + this.observer.dispose() + this.listeners.clear() + } + + private onObserverNotify(): void { + // Re-apply the window in case readiness just changed (a deferred first + // apply) — idempotent when the window is unchanged — then republish. + this.applyWindow() + this.notify() + } + + private applyWindow(): void { + const collection = this.collection + if (!collection || this.disposed) return + if (this.waitForReady && !this.observer.getSnapshot().isReady) return + + const limit = this.loadedPageCount * this.pageSize + 1 + if (limit === this.appliedLimit) return + this.appliedLimit = limit + + const utils = collection.utils as + | { setWindow?: (o: { offset: number; limit: number }) => true | Promise } + | undefined + if (typeof utils?.setWindow !== `function`) return + + const generation = ++this.windowGeneration + const result = utils.setWindow({ offset: 0, limit }) + if (result === true) { + this.setFetching(false) + return + } + + this.setFetching(true) + result + .catch(() => { + // Swallow — the load error surfaces through the collection's status. + }) + .finally(() => { + // Only clear for the window this call requested; a newer apply owns the + // flag otherwise. + if (!this.disposed && generation === this.windowGeneration) { + this.setFetching(false) + } + }) + } + + private setFetching(value: boolean): void { + if (this.isFetchingNextPage === value) return + this.isFetchingNextPage = value + this.notify() + } + + private notify(): void { + this.listeners.forEach((listener) => listener()) + } +} + +/** + * Create a {@link LiveQueryWindowController} for a resolved, ordered live-query + * collection (which must have an `orderBy`), or a disabled controller when + * `collection` is `null`/`undefined`. + */ +export function createLiveQueryWindowController< + T extends object, + TKey extends string | number, +>( + collection: Collection | null | undefined, + options: CreateLiveQueryWindowControllerOptions = {}, +): LiveQueryWindowController { + return new LiveQueryWindowControllerImpl(collection ?? null, options) +} diff --git a/packages/db/tests/live-query-window-controller.test.ts b/packages/db/tests/live-query-window-controller.test.ts new file mode 100644 index 000000000..11dea911b --- /dev/null +++ b/packages/db/tests/live-query-window-controller.test.ts @@ -0,0 +1,174 @@ +import { describe, expect, it } from 'vitest' +import { createCollection } from '../src/collection/index.js' +import { createLiveQueryCollection } from '../src/query/live-query-collection.js' +import { createLiveQueryWindowController } from '../src/live-query-window-controller.js' +import { mockSyncCollectionOptions } from './utils.js' + +interface Row { + id: string + n: number +} + +const ROWS: Array = [1, 2, 3, 4, 5].map((n) => ({ id: String(n), n })) + +let seq = 0 +function makeSource() { + return createCollection( + mockSyncCollectionOptions({ + id: `window-ctrl-${seq++}`, + getKey: (r) => r.id, + initialData: ROWS, + }), + ) +} + +/** Ordered live query with page 1's peek-ahead window baked in, as the React adapter builds it. */ +function makeOrderedLiveQuery(source: ReturnType, pageSize: number) { + return createLiveQueryCollection({ + query: (q) => + q + .from({ r: source }) + .orderBy(({ r }) => r.n, `asc`) + .limit(pageSize + 1) + .offset(0) + .select(({ r }) => ({ id: r.id, n: r.n })), + startSync: true, + gcTime: 1, + }) +} + +const flush = () => new Promise((r) => setTimeout(r, 0)) + +const ids = (snap: { data: ReadonlyArray }) => snap.data.map((r) => r.id) + +describe(`createLiveQueryWindowController`, () => { + it(`exposes the first page with a peek-ahead hasNextPage`, async () => { + const lq = makeOrderedLiveQuery(makeSource(), 2) + const controller = createLiveQueryWindowController(lq as any, { + pageSize: 2, + }) + controller.subscribe(() => {}) + await lq.preload() + await flush() + + const snap = controller.getSnapshot() + expect(ids(snap)).toEqual([`1`, `2`]) + expect(snap.pages.map((p) => p.map((r) => r.id))).toEqual([[`1`, `2`]]) + expect(snap.pageParams).toEqual([0]) + expect(snap.hasNextPage).toBe(true) + expect(snap.isFetchingNextPage).toBe(false) + controller.dispose() + }) + + it(`loads further pages via fetchNextPage until the source is exhausted`, async () => { + const lq = makeOrderedLiveQuery(makeSource(), 2) + const controller = createLiveQueryWindowController(lq as any, { + pageSize: 2, + }) + controller.subscribe(() => {}) + await lq.preload() + await flush() + + controller.fetchNextPage() + await flush() + let snap = controller.getSnapshot() + expect(ids(snap)).toEqual([`1`, `2`, `3`, `4`]) + expect(snap.pages.map((p) => p.map((r) => r.id))).toEqual([ + [`1`, `2`], + [`3`, `4`], + ]) + expect(snap.pageParams).toEqual([0, 1]) + expect(snap.hasNextPage).toBe(true) + + controller.fetchNextPage() + await flush() + snap = controller.getSnapshot() + // 5 rows total; the 3rd page is a partial page and there is no peek row. + expect(ids(snap)).toEqual([`1`, `2`, `3`, `4`, `5`]) + expect(snap.pages.map((p) => p.map((r) => r.id))).toEqual([ + [`1`, `2`], + [`3`, `4`], + [`5`], + ]) + expect(snap.hasNextPage).toBe(false) + controller.dispose() + }) + + it(`fetchNextPage is a no-op when there is no next page`, async () => { + const lq = makeOrderedLiveQuery(makeSource(), 10) // pageSize > row count + const controller = createLiveQueryWindowController(lq as any, { + pageSize: 10, + }) + controller.subscribe(() => {}) + await lq.preload() + await flush() + expect(controller.getSnapshot().hasNextPage).toBe(false) + + controller.fetchNextPage() + await flush() + expect(ids(controller.getSnapshot())).toEqual([`1`, `2`, `3`, `4`, `5`]) + expect(controller.getSnapshot().pages).toHaveLength(1) + controller.dispose() + }) + + it(`reset returns to the first page`, async () => { + const lq = makeOrderedLiveQuery(makeSource(), 2) + const controller = createLiveQueryWindowController(lq as any, { + pageSize: 2, + }) + controller.subscribe(() => {}) + await lq.preload() + await flush() + + controller.fetchNextPage() + await flush() + expect(controller.getSnapshot().pages).toHaveLength(2) + + controller.reset() + await flush() + const snap = controller.getSnapshot() + expect(ids(snap)).toEqual([`1`, `2`]) + expect(snap.pages).toHaveLength(1) + controller.dispose() + }) + + it(`notifies subscribers on data changes and page changes`, async () => { + const source = makeSource() + const lq = makeOrderedLiveQuery(source, 2) + const controller = createLiveQueryWindowController(lq as any, { + pageSize: 2, + }) + let notifications = 0 + controller.subscribe(() => notifications++) + await lq.preload() + await flush() + + notifications = 0 + controller.fetchNextPage() + await flush() + expect(notifications).toBeGreaterThan(0) + controller.dispose() + }) + + it(`returns a stable snapshot identity when nothing changed`, async () => { + const lq = makeOrderedLiveQuery(makeSource(), 2) + const controller = createLiveQueryWindowController(lq as any, { + pageSize: 2, + }) + controller.subscribe(() => {}) + await lq.preload() + await flush() + expect(controller.getSnapshot()).toBe(controller.getSnapshot()) + controller.dispose() + }) + + it(`represents a disabled controller (null collection)`, () => { + const controller = createLiveQueryWindowController(null) + const snap = controller.getSnapshot() + expect(snap.isEnabled).toBe(false) + expect(snap.data).toEqual([]) + expect(snap.hasNextPage).toBe(false) + expect(snap.pages).toEqual([]) + controller.dispose() + }) +}) diff --git a/packages/react-db/src/useLiveInfiniteQuery.ts b/packages/react-db/src/useLiveInfiniteQuery.ts index 99c77c739..1f66823e2 100644 --- a/packages/react-db/src/useLiveInfiniteQuery.ts +++ b/packages/react-db/src/useLiveInfiniteQuery.ts @@ -1,23 +1,25 @@ -import { useCallback, useEffect, useMemo, useRef, useState } from 'react' -import { CollectionImpl } from '@tanstack/db' -import { useLiveQuery } from './useLiveQuery' +import { useCallback, useRef, useSyncExternalStore } from 'react' +import { + CollectionImpl, + createLiveQueryCollection, + createLiveQueryWindowController, +} from '@tanstack/db' import type { Collection, Context, InferResultType, InitialQueryBuilder, - LiveQueryCollectionUtils, + LiveQueryWindowController, NonSingleResult, QueryBuilder, } from '@tanstack/db' -/** - * Type guard to check if utils object has setWindow method (LiveQueryCollectionUtils) - */ -function isLiveQueryCollectionUtils( - utils: unknown, -): utils is LiveQueryCollectionUtils { - return typeof (utils as any).setWindow === `function` +// Live queries created here are cleaned up immediately (0 disables GC). +const DEFAULT_GC_TIME_MS = 1 + +/** Type guard: does this collection expose `setWindow` (i.e. has an orderBy)? */ +function hasSetWindow(collection: Collection): boolean { + return typeof (collection.utils)?.setWindow === `function` } export type UseLiveInfiniteQueryConfig = { @@ -151,14 +153,6 @@ export function useLiveInfiniteQuery( ) } - // Track how many pages have been loaded - const [loadedPageCount, setLoadedPageCount] = useState(1) - const [isFetchingNextPage, setIsFetchingNextPage] = useState(false) - - // Track collection instance and whether we've validated it (only for pre-created collections) - const collectionRef = useRef(isCollection ? queryFnOrCollection : null) - const hasValidatedCollectionRef = useRef(false) - // Track deps for query functions (stringify for comparison) let depsKey: string try { @@ -169,162 +163,92 @@ export function useLiveInfiniteQuery( `Ensure all dependency values are JSON-serializable.`, ) } - const prevDepsKeyRef = useRef(depsKey) - - // Reset pagination when inputs change - useEffect(() => { - let shouldReset = false - - if (isCollection) { - // Reset if collection instance changed - if (collectionRef.current !== queryFnOrCollection) { - collectionRef.current = queryFnOrCollection - hasValidatedCollectionRef.current = false - shouldReset = true - } - } else { - // Reset if deps changed (for query functions) - if (prevDepsKeyRef.current !== depsKey) { - prevDepsKeyRef.current = depsKey - shouldReset = true - } - } - if (shouldReset) { - setLoadedPageCount(1) - } - }, [isCollection, queryFnOrCollection, depsKey]) + const collectionRef = useRef | null>(null) + const controllerRef = useRef | null>(null) + const configRef = useRef(null) + const depsRef = useRef(null) - // Create a live query with initial limit and offset - // Either pass collection directly or wrap query function - // Use pageSize + 1 for peek-ahead detection (to know if there are more pages) - const queryResult = isCollection - ? useLiveQuery(queryFnOrCollection) - : useLiveQuery( - (q) => - queryFnOrCollection(q) - .limit(pageSize + 1) - .offset(0), - deps, - ) + // Recreate the underlying collection + controller when the input identity + // (pre-created collection) or the deps (query function) change. A fresh + // controller starts back at page 1, which is the desired reset behaviour. + const needsNew = + !controllerRef.current || + (isCollection && configRef.current !== queryFnOrCollection) || + (!isCollection && depsRef.current !== depsKey) - // Adjust window when pagination changes - useEffect(() => { - const utils = queryResult.collection.utils - const expectedOffset = 0 - const expectedLimit = loadedPageCount * pageSize + 1 // +1 for peek ahead - - // Check if collection has orderBy (required for setWindow) - if (!isLiveQueryCollectionUtils(utils)) { - // For pre-created collections, throw an error if no orderBy - if (isCollection) { + if (needsNew) { + if (isCollection) { + const collection = queryFnOrCollection as Collection + if (!hasSetWindow(collection)) { throw new Error( `useLiveInfiniteQuery: Pre-created live query collection must have an orderBy clause for infinite pagination to work. ` + `Please add .orderBy() to your createLiveQueryCollection query.`, ) } - return - } - - // For pre-created collections, validate window on first check - if (isCollection && !hasValidatedCollectionRef.current) { - const currentWindow = utils.getWindow() - if ( - currentWindow && - (currentWindow.offset !== expectedOffset || - currentWindow.limit !== expectedLimit) - ) { - console.warn( - `useLiveInfiniteQuery: Pre-created collection has window {offset: ${currentWindow.offset}, limit: ${currentWindow.limit}} ` + - `but hook expects {offset: ${expectedOffset}, limit: ${expectedLimit}}. Adjusting window now.`, - ) - } - hasValidatedCollectionRef.current = true - } - - // For query functions, wait until collection is ready - if (!isCollection && !queryResult.isReady) return - - // Adjust the window - let cancelled = false - const result = utils.setWindow({ - offset: expectedOffset, - limit: expectedLimit, - }) - - if (result !== true) { - setIsFetchingNextPage(true) - result - .catch((error: unknown) => { - if (!cancelled) - console.error(`useLiveInfiniteQuery: setWindow failed:`, error) - }) - .finally(() => { - if (!cancelled) setIsFetchingNextPage(false) - }) + collection.startSyncImmediate() + collectionRef.current = collection + configRef.current = queryFnOrCollection } else { - setIsFetchingNextPage(false) - } - - return () => { - cancelled = true - } - }, [ - isCollection, - queryResult.collection, - queryResult.isReady, - loadedPageCount, - pageSize, - ]) - - // Split the data array into pages and determine if there's a next page - const { pages, pageParams, hasNextPage, flatData } = useMemo(() => { - const dataArray = ( - Array.isArray(queryResult.data) ? queryResult.data : [] - ) as InferResultType - const totalItemsRequested = loadedPageCount * pageSize - - // Check if we have more data than requested (the peek ahead item) - const hasMore = dataArray.length > totalItemsRequested - - // Build pages array (without the peek ahead item) - const pagesResult: Array[number]>> = [] - const pageParamsResult: Array = [] - - for (let i = 0; i < loadedPageCount; i++) { - const pageData = dataArray.slice(i * pageSize, (i + 1) * pageSize) - pagesResult.push(pageData) - pageParamsResult.push(initialPageParam + i) + // Wrap the query with the first page's peek-ahead window; the controller + // grows the limit from here via setWindow. + collectionRef.current = createLiveQueryCollection({ + query: (q: InitialQueryBuilder) => + queryFnOrCollection(q) + .limit(pageSize + 1) + .offset(0), + startSync: true, + gcTime: DEFAULT_GC_TIME_MS, + }) + depsRef.current = depsKey } + controllerRef.current = createLiveQueryWindowController( + collectionRef.current, + { + pageSize, + initialPageParam, + // useSyncExternalStore must not be notified synchronously on subscribe. + deferInitialNotify: true, + // A query-function collection already carries page 1's window in its + // query, so defer the (redundant) first apply until it is ready; a + // pre-created collection needs its window established up front. + waitForReady: !isCollection, + }, + ) + } + const controller = controllerRef.current! + + // Stable subscribe bound to the current controller. + const subscribeRef = useRef<((onStoreChange: () => void) => () => void) | null>( + null, + ) + if (!subscribeRef.current || needsNew) { + subscribeRef.current = (onStoreChange) => controller.subscribe(onStoreChange) + } - // Flatten the pages for the data return (without peek ahead item) - const flatDataResult = dataArray.slice( - 0, - totalItemsRequested, - ) as InferResultType - - return { - pages: pagesResult, - pageParams: pageParamsResult, - hasNextPage: hasMore, - flatData: flatDataResult, - } - }, [queryResult.data, loadedPageCount, pageSize, initialPageParam]) + const snapshot = useSyncExternalStore(subscribeRef.current, () => + controller.getSnapshot(), + ) - // Fetch next page const fetchNextPage = useCallback(() => { - if (!hasNextPage || isFetchingNextPage) return - - setLoadedPageCount((prev) => prev + 1) - }, [hasNextPage, isFetchingNextPage]) + controllerRef.current?.fetchNextPage() + }, []) return { - ...queryResult, - data: flatData, - pages, - pageParams, + data: snapshot.data as InferResultType, + state: snapshot.state, + status: snapshot.status, + isLoading: snapshot.isLoading, + isReady: snapshot.isReady, + isIdle: snapshot.isIdle, + isError: snapshot.isError, + isCleanedUp: snapshot.isCleanedUp, + collection: snapshot.collection, + isEnabled: snapshot.isEnabled, + pages: snapshot.pages as Array[number]>>, + pageParams: snapshot.pageParams as Array, fetchNextPage, - hasNextPage, - isFetchingNextPage, + hasNextPage: snapshot.hasNextPage, + isFetchingNextPage: snapshot.isFetchingNextPage, } as UseLiveInfiniteQueryReturn } From fa26a67362d3b6a7e50e01b78b5804fd7a891e4b Mon Sep 17 00:00:00 2001 From: "autofix-ci[bot]" <114827586+autofix-ci[bot]@users.noreply.github.com> Date: Wed, 15 Jul 2026 11:50:16 +0000 Subject: [PATCH 16/42] ci: apply automated fixes --- packages/db/src/live-query-window-controller.ts | 17 +++++++++++------ .../tests/live-query-window-controller.test.ts | 5 ++++- packages/react-db/src/useLiveInfiniteQuery.ts | 11 ++++++----- 3 files changed, 21 insertions(+), 12 deletions(-) diff --git a/packages/db/src/live-query-window-controller.ts b/packages/db/src/live-query-window-controller.ts index 41ebd3e76..8c40ed585 100644 --- a/packages/db/src/live-query-window-controller.ts +++ b/packages/db/src/live-query-window-controller.ts @@ -42,8 +42,7 @@ export interface LiveQueryWindowSnapshot< isEnabled: boolean } -export interface CreateLiveQueryWindowControllerOptions - extends CreateLiveQueryObserverOptions { +export interface CreateLiveQueryWindowControllerOptions extends CreateLiveQueryObserverOptions { /** Rows per page (default 20). A falsy value falls back to the default. */ pageSize?: number /** Value of the first page's `pageParam` (default 0). */ @@ -87,8 +86,7 @@ interface CachedFrom { class LiveQueryWindowControllerImpl< T extends object, TKey extends string | number, -> implements LiveQueryWindowController -{ +> implements LiveQueryWindowController { private readonly observer: LiveQueryObserver private readonly collection: Collection | null private readonly pageSize: number @@ -183,7 +181,9 @@ class LiveQueryWindowControllerImpl< subscribe(listener: () => void): () => void { this.listeners.add(listener) if (this.listeners.size === 1) { - this.observerUnsub = this.observer.subscribe(() => this.onObserverNotify()) + this.observerUnsub = this.observer.subscribe(() => + this.onObserverNotify(), + ) // Establish the current window now that the query is active. this.applyWindow() } @@ -250,7 +250,12 @@ class LiveQueryWindowControllerImpl< this.appliedLimit = limit const utils = collection.utils as - | { setWindow?: (o: { offset: number; limit: number }) => true | Promise } + | { + setWindow?: (o: { + offset: number + limit: number + }) => true | Promise + } | undefined if (typeof utils?.setWindow !== `function`) return diff --git a/packages/db/tests/live-query-window-controller.test.ts b/packages/db/tests/live-query-window-controller.test.ts index 11dea911b..59ab46a41 100644 --- a/packages/db/tests/live-query-window-controller.test.ts +++ b/packages/db/tests/live-query-window-controller.test.ts @@ -23,7 +23,10 @@ function makeSource() { } /** Ordered live query with page 1's peek-ahead window baked in, as the React adapter builds it. */ -function makeOrderedLiveQuery(source: ReturnType, pageSize: number) { +function makeOrderedLiveQuery( + source: ReturnType, + pageSize: number, +) { return createLiveQueryCollection({ query: (q) => q diff --git a/packages/react-db/src/useLiveInfiniteQuery.ts b/packages/react-db/src/useLiveInfiniteQuery.ts index 1f66823e2..6136d134c 100644 --- a/packages/react-db/src/useLiveInfiniteQuery.ts +++ b/packages/react-db/src/useLiveInfiniteQuery.ts @@ -19,7 +19,7 @@ const DEFAULT_GC_TIME_MS = 1 /** Type guard: does this collection expose `setWindow` (i.e. has an orderBy)? */ function hasSetWindow(collection: Collection): boolean { - return typeof (collection.utils)?.setWindow === `function` + return typeof collection.utils?.setWindow === `function` } export type UseLiveInfiniteQueryConfig = { @@ -219,11 +219,12 @@ export function useLiveInfiniteQuery( const controller = controllerRef.current! // Stable subscribe bound to the current controller. - const subscribeRef = useRef<((onStoreChange: () => void) => () => void) | null>( - null, - ) + const subscribeRef = useRef< + ((onStoreChange: () => void) => () => void) | null + >(null) if (!subscribeRef.current || needsNew) { - subscribeRef.current = (onStoreChange) => controller.subscribe(onStoreChange) + subscribeRef.current = (onStoreChange) => + controller.subscribe(onStoreChange) } const snapshot = useSyncExternalStore(subscribeRef.current, () => From ab9b37e43a23717be92fd7fb4265e2e18d78dd58 Mon Sep 17 00:00:00 2001 From: Kevin De Porre Date: Wed, 15 Jul 2026 14:47:55 +0200 Subject: [PATCH 17/42] fix(react-db): restore useLiveQuery type-only import for return type UseLiveInfiniteQueryReturn references ReturnType, but the import was dropped in the controller rewrite. vitest's typecheck missed it; the package build (strict tsc) caught it (TS2304). Re-add as a type-only import. Co-Authored-By: Claude Opus 4.8 --- packages/react-db/src/useLiveInfiniteQuery.ts | 2 ++ 1 file changed, 2 insertions(+) diff --git a/packages/react-db/src/useLiveInfiniteQuery.ts b/packages/react-db/src/useLiveInfiniteQuery.ts index 6136d134c..5f5e71406 100644 --- a/packages/react-db/src/useLiveInfiniteQuery.ts +++ b/packages/react-db/src/useLiveInfiniteQuery.ts @@ -4,6 +4,8 @@ import { createLiveQueryCollection, createLiveQueryWindowController, } from '@tanstack/db' +// Type-only: used in `ReturnType` in UseLiveInfiniteQueryReturn. +import type { useLiveQuery } from './useLiveQuery' import type { Collection, Context, From 8d82827aa0739bde4b9a441b06d98dd47a40725a Mon Sep 17 00:00:00 2001 From: Kevin De Porre Date: Wed, 15 Jul 2026 15:19:20 +0200 Subject: [PATCH 18/42] fix(react-db): react to runtime pageSize changes + restore window-mismatch warn Addresses review of the window-controller extraction: - pageSize/initialPageParam are now part of the controller-recreation check, so changing them at runtime re-windows and re-slices (the old hook had them in its effect/memo deps; the first controller draft baked them in at creation). - Restore the one-time console.warn when a pre-created collection's existing window differs from the first page the hook enforces (dropped in the rewrite). Co-Authored-By: Claude Opus 4.8 --- packages/react-db/src/useLiveInfiniteQuery.ts | 27 ++++++- .../tests/useLiveInfiniteQuery.test.tsx | 79 ++++++++++++++++++- 2 files changed, 101 insertions(+), 5 deletions(-) diff --git a/packages/react-db/src/useLiveInfiniteQuery.ts b/packages/react-db/src/useLiveInfiniteQuery.ts index 5f5e71406..00a524652 100644 --- a/packages/react-db/src/useLiveInfiniteQuery.ts +++ b/packages/react-db/src/useLiveInfiniteQuery.ts @@ -170,16 +170,24 @@ export function useLiveInfiniteQuery( const controllerRef = useRef | null>(null) const configRef = useRef(null) const depsRef = useRef(null) + const pageSizeRef = useRef(pageSize) + const initialPageParamRef = useRef(initialPageParam) + const validatedCollectionRef = useRef(null) // Recreate the underlying collection + controller when the input identity - // (pre-created collection) or the deps (query function) change. A fresh - // controller starts back at page 1, which is the desired reset behaviour. + // (pre-created collection), the deps (query function), or the page shape + // (`pageSize`/`initialPageParam`) change. A fresh controller starts back at + // page 1, which is the desired reset behaviour. const needsNew = !controllerRef.current || + pageSizeRef.current !== pageSize || + initialPageParamRef.current !== initialPageParam || (isCollection && configRef.current !== queryFnOrCollection) || (!isCollection && depsRef.current !== depsKey) if (needsNew) { + pageSizeRef.current = pageSize + initialPageParamRef.current = initialPageParam if (isCollection) { const collection = queryFnOrCollection as Collection if (!hasSetWindow(collection)) { @@ -188,6 +196,21 @@ export function useLiveInfiniteQuery( `Please add .orderBy() to your createLiveQueryCollection query.`, ) } + // Warn once per collection instance if its current window doesn't match + // the first page the hook is about to enforce. + if (validatedCollectionRef.current !== collection) { + validatedCollectionRef.current = collection + const currentWindow = collection.utils.getWindow?.() + if ( + currentWindow && + (currentWindow.offset !== 0 || currentWindow.limit !== pageSize + 1) + ) { + console.warn( + `useLiveInfiniteQuery: Pre-created collection has window {offset: ${currentWindow.offset}, limit: ${currentWindow.limit}} ` + + `but the hook expects {offset: 0, limit: ${pageSize + 1}}. Adjusting window now.`, + ) + } + } collection.startSyncImmediate() collectionRef.current = collection configRef.current = queryFnOrCollection diff --git a/packages/react-db/tests/useLiveInfiniteQuery.test.tsx b/packages/react-db/tests/useLiveInfiniteQuery.test.tsx index 9aa63244e..542350df9 100644 --- a/packages/react-db/tests/useLiveInfiniteQuery.test.tsx +++ b/packages/react-db/tests/useLiveInfiniteQuery.test.tsx @@ -1,7 +1,6 @@ -import { describe, expect, it } from 'vitest' +import { describe, expect, it, vi } from 'vitest' import { act, renderHook, waitFor } from '@testing-library/react' -import { createCollection, createLiveQueryCollection, eq } from '@tanstack/db' -import { BTreeIndex } from '@tanstack/db' +import { BTreeIndex, createCollection, createLiveQueryCollection, eq } from '@tanstack/db' import { useLiveInfiniteQuery } from '../src/useLiveInfiniteQuery' import { mockSyncCollectionOptions } from '../../db/tests/utils' import { createFilterFunctionFromExpression } from '../../db/src/collection/change-events' @@ -695,6 +694,44 @@ describe(`useLiveInfiniteQuery`, () => { }) }) + it(`re-windows and re-slices when pageSize changes at runtime`, async () => { + const posts = createMockPosts(50) + const collection = createCollection( + mockSyncCollectionOptions({ + autoIndex: `eager`, + id: `pagesize-change-test`, + getKey: (post: Post) => post.id, + initialData: posts, + }), + ) + + const { result, rerender } = renderHook( + ({ pageSize }: { pageSize: number }) => + useLiveInfiniteQuery( + (q) => + q + .from({ posts: collection }) + .orderBy(({ posts: p }) => p.createdAt, `desc`), + { pageSize }, + ), + { initialProps: { pageSize: 5 } }, + ) + + await waitFor(() => expect(result.current.isReady).toBe(true)) + expect(result.current.data).toHaveLength(5) + expect(result.current.pages[0]).toHaveLength(5) + + // Grow the page size at runtime (no deps change) — the window and the + // page slicing must both pick it up. + act(() => { + rerender({ pageSize: 10 }) + }) + + await waitFor(() => expect(result.current.data).toHaveLength(10)) + expect(result.current.pages).toHaveLength(1) + expect(result.current.pages[0]).toHaveLength(10) + }) + it(`should track pageParams correctly`, async () => { const posts = createMockPosts(30) const collection = createCollection( @@ -1738,6 +1775,42 @@ describe(`useLiveInfiniteQuery`, () => { expect(result.current.hasNextPage).toBe(true) }) + it(`warns when a pre-created collection's window differs from the first page`, async () => { + const posts = createMockPosts(50) + const collection = createCollection( + mockSyncCollectionOptions({ + autoIndex: `eager`, + id: `mismatched-window-warn-test`, + getKey: (post: Post) => post.id, + initialData: posts, + }), + ) + const liveQueryCollection = createLiveQueryCollection({ + query: (q) => + q + .from({ posts: collection }) + .orderBy(({ posts: p }) => p.createdAt, `desc`) + .limit(5) + .offset(0), + }) + await liveQueryCollection.preload() + // Give the collection a concrete window that differs from the hook's + // expected first page (offset 0, limit pageSize + 1). + liveQueryCollection.utils.setWindow({ offset: 0, limit: 5 }) + + const warn = vi.spyOn(console, `warn`).mockImplementation(() => {}) + try { + renderHook(() => + useLiveInfiniteQuery(liveQueryCollection, { pageSize: 10 }), + ) + expect(warn).toHaveBeenCalledWith( + expect.stringContaining(`Pre-created collection has window`), + ) + } finally { + warn.mockRestore() + } + }) + it(`should handle live updates with pre-created collection`, async () => { const posts = createMockPosts(30) const collection = createCollection( From 5d50a0c058da621f33a9d836b84ef900e37b7087 Mon Sep 17 00:00:00 2001 From: "autofix-ci[bot]" <114827586+autofix-ci[bot]@users.noreply.github.com> Date: Wed, 15 Jul 2026 13:20:36 +0000 Subject: [PATCH 19/42] ci: apply automated fixes --- packages/react-db/tests/useLiveInfiniteQuery.test.tsx | 7 ++++++- 1 file changed, 6 insertions(+), 1 deletion(-) diff --git a/packages/react-db/tests/useLiveInfiniteQuery.test.tsx b/packages/react-db/tests/useLiveInfiniteQuery.test.tsx index 542350df9..e961167de 100644 --- a/packages/react-db/tests/useLiveInfiniteQuery.test.tsx +++ b/packages/react-db/tests/useLiveInfiniteQuery.test.tsx @@ -1,6 +1,11 @@ import { describe, expect, it, vi } from 'vitest' import { act, renderHook, waitFor } from '@testing-library/react' -import { BTreeIndex, createCollection, createLiveQueryCollection, eq } from '@tanstack/db' +import { + BTreeIndex, + createCollection, + createLiveQueryCollection, + eq, +} from '@tanstack/db' import { useLiveInfiniteQuery } from '../src/useLiveInfiniteQuery' import { mockSyncCollectionOptions } from '../../db/tests/utils' import { createFilterFunctionFromExpression } from '../../db/src/collection/change-events' From 6db6b752563c9ba1933114fcfd57225a6ed872b7 Mon Sep 17 00:00:00 2001 From: Kevin De Porre Date: Mon, 20 Jul 2026 11:39:27 +0200 Subject: [PATCH 20/42] fix(db): FIFO non-reentrant observer dispatch over subscription records MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit A listener that synchronously mutates the collection used to trigger a nested, reentrant dispatch: later subscribers could observe the nested event (e.g. a delete) before the outer one (the insert) it reacted to. Publications are now queued and dispatched FIFO. Each publication is delivered over a snapshot of subscription records taken when it is dispatched: a subscription removed mid-delivery still receives the in-flight publication, one added mid-delivery does not. Records — not raw callbacks — identify subscriptions, so subscribing the same function twice no longer collapses into one Set entry whose first unsubscribe tore down both. Co-Authored-By: Claude Fable 5 --- packages/db/src/live-query-observer.ts | 64 ++++++++--- packages/db/tests/live-query-observer.test.ts | 104 ++++++++++++++++++ 2 files changed, 155 insertions(+), 13 deletions(-) diff --git a/packages/db/src/live-query-observer.ts b/packages/db/src/live-query-observer.ts index 8387b3838..13076e66d 100644 --- a/packages/db/src/live-query-observer.ts +++ b/packages/db/src/live-query-observer.ts @@ -66,6 +66,16 @@ export interface LiveQueryObserver< dispose: () => void } +/** + * One logical subscription. Records — not raw callbacks — identify + * subscriptions, so the same listener function can be subscribed twice and + * each subscription tears down independently. + */ +interface SubscriptionRecord { + listener: LiveQueryObserverListener + active: boolean +} + const DISABLED_SNAPSHOT: LiveQuerySnapshot = { state: undefined, data: undefined, @@ -89,7 +99,14 @@ class LiveQueryObserverImpl< private cachedVersion = -1 private cachedStatus: CollectionStatus | undefined private cachedSnapshot: LiveQuerySnapshot = DISABLED_SNAPSHOT - private readonly listeners = new Set>() + private readonly subscriptions = new Set>() + // Publications are dispatched FIFO: an emit that happens while another + // publication is being delivered (a listener mutating the collection + // synchronously) is queued, never delivered reentrantly. + private readonly publicationQueue: Array< + Array> | undefined + > = [] + private dispatching = false private collectionUnsub: (() => void) | null = null // Bumped on each attach. `onFirstReady` can't be unsubscribed, so a callback // from a superseded attach checks this to no-op instead of double-notifying. @@ -143,15 +160,15 @@ class LiveQueryObserverImpl< } subscribe(listener: LiveQueryObserverListener): () => void { - this.listeners.add(listener) - if (this.listeners.size === 1) this.attach() + const record: SubscriptionRecord = { listener, active: true } + this.subscriptions.add(record) + if (this.subscriptions.size === 1) this.attach() - let active = true return () => { - if (!active) return - active = false - this.listeners.delete(listener) - if (this.listeners.size === 0) this.detach() + if (!record.active) return + record.active = false + this.subscriptions.delete(record) + if (this.subscriptions.size === 0) this.detach() } } @@ -176,7 +193,7 @@ class LiveQueryObserverImpl< let attaching = this.deferInitialNotify const deferred: Array> | undefined> = [] const notify = (changes: Array> | undefined) => { - if (this.disposed || this.listeners.size === 0) return + if (this.disposed || this.subscriptions.size === 0) return if (attaching) deferred.push(changes) else this.emit(changes) } @@ -211,7 +228,7 @@ class LiveQueryObserverImpl< // initial batch would reach the current listener. if ( this.disposed || - this.listeners.size === 0 || + this.subscriptions.size === 0 || generation !== this.attachGeneration ) { return @@ -227,8 +244,27 @@ class LiveQueryObserverImpl< } private emit(changes: Array> | undefined): void { - this.version++ - this.listeners.forEach((listener) => listener(changes)) + this.publicationQueue.push(changes) + if (this.dispatching) return + + this.dispatching = true + try { + // A dispose() during dispatch empties the queue, ending this loop. + while (this.publicationQueue.length > 0) { + const publication = this.publicationQueue.shift()! + this.version++ + // Deliver over a snapshot of the records taken when this publication + // is dispatched: a subscription removed mid-delivery still receives + // the in-flight publication; one added mid-delivery does not. + const records = Array.from(this.subscriptions) + for (const subRecord of records) { + if (this.disposed) return + subRecord.listener(publication) + } + } + } finally { + this.dispatching = false + } } async preload(): Promise { @@ -239,7 +275,9 @@ class LiveQueryObserverImpl< if (this.disposed) return this.disposed = true this.detach() - this.listeners.clear() + for (const subRecord of this.subscriptions) subRecord.active = false + this.subscriptions.clear() + this.publicationQueue.length = 0 } } diff --git a/packages/db/tests/live-query-observer.test.ts b/packages/db/tests/live-query-observer.test.ts index 1035a11ef..0e2e9d835 100644 --- a/packages/db/tests/live-query-observer.test.ts +++ b/packages/db/tests/live-query-observer.test.ts @@ -174,6 +174,110 @@ describe(`createLiveQueryObserver`, () => { observer.dispose() }) + it(`dispatches nested publications FIFO, never reentrantly`, () => { + const source = makeSource() + const observer = createLiveQueryObserver(source as any) + + // Listener A reacts to the insert of row 3 by synchronously deleting it — + // a nested publication while the insert is still being delivered. + observer.subscribe((changes) => { + if (changes?.some((c) => c.type === `insert` && c.key === `3`)) { + source.utils.begin() + source.utils.write({ type: `delete`, value: { id: `3`, name: `C` } }) + source.utils.commit() + } + }) + + const listenerBEvents: Array = [] + observer.subscribe((changes) => { + for (const c of changes ?? []) { + if (c.key === `3`) listenerBEvents.push(c.type) + } + }) + + source.utils.begin() + source.utils.write({ type: `insert`, value: { id: `3`, name: `C` } }) + source.utils.commit() + + // B must observe the insert before the (nested) delete. + expect(listenerBEvents).toEqual([`insert`, `delete`]) + observer.dispose() + }) + + it(`does not deliver an in-flight publication to a listener added during dispatch`, () => { + const source = makeSource() + const observer = createLiveQueryObserver(source as any) + + let lateListenerCalls = 0 + observer.subscribe((changes) => { + // Add the late listener only while the row-4 delta is being dispatched. + if (changes?.some((c) => c.key === `4`)) { + observer.subscribe(() => { + lateListenerCalls++ + }) + } + }) + + source.utils.begin() + source.utils.write({ type: `insert`, value: { id: `4`, name: `D` } }) + source.utils.commit() + + expect(lateListenerCalls).toBe(0) + observer.dispose() + }) + + it(`still delivers the in-flight publication to a listener removed during dispatch`, () => { + const source = makeSource() + const observer = createLiveQueryObserver(source as any) + + let existingListenerCalls = 0 + let unsubB: (() => void) | null = null + observer.subscribe(() => { + unsubB?.() + unsubB = null + }) + unsubB = observer.subscribe(() => { + existingListenerCalls++ + }) + + source.utils.begin() + source.utils.write({ type: `insert`, value: { id: `5`, name: `E` } }) + source.utils.commit() + + // A removed B while the publication was in flight; B still receives it. + expect(existingListenerCalls).toBe(1) + observer.dispose() + }) + + it(`treats two subscriptions with the same callback as independent`, () => { + const source = makeSource() + const observer = createLiveQueryObserver(source as any) + + let calls = 0 + const shared = () => { + calls++ + } + const unsubFirst = observer.subscribe(shared) + const unsubSecond = observer.subscribe(shared) + + unsubFirst() + calls = 0 + source.utils.begin() + source.utils.write({ type: `insert`, value: { id: `6`, name: `F` } }) + source.utils.commit() + + // The second subscription survives the first one's teardown. + expect(calls).toBe(1) + unsubSecond() + + calls = 0 + source.utils.begin() + source.utils.write({ type: `insert`, value: { id: `7`, name: `G` } }) + source.utils.commit() + expect(calls).toBe(0) + observer.dispose() + }) + it(`refreshes the snapshot when status changes without a version bump`, () => { // A status-only loading→ready transition with no active subscription: the // cached snapshot must not stay stale (covers the preload() case too). From 7a86eae43a4b829c0133affd6a2cac6fdca1786d Mon Sep 17 00:00:00 2001 From: Kevin De Porre Date: Mon, 20 Jul 2026 11:41:52 +0200 Subject: [PATCH 21/42] fix(db): release the collection subscription on dispose during initial replay MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit subscribeChanges delivers the initial state synchronously, so a listener could dispose the observer before the subscription handle was stored — detach() then had nothing to release and the collection subscription leaked past disposal. The release hook is now registered before the subscription is created, making attachment transactional: if detach() fired mid-replay, the subscription is undone as soon as subscribeChanges returns. Co-Authored-By: Claude Fable 5 --- packages/db/src/live-query-observer.ts | 15 +++++++++++++-- packages/db/tests/live-query-observer.test.ts | 11 +++++++++++ 2 files changed, 24 insertions(+), 2 deletions(-) diff --git a/packages/db/src/live-query-observer.ts b/packages/db/src/live-query-observer.ts index 13076e66d..ec789a0ea 100644 --- a/packages/db/src/live-query-observer.ts +++ b/packages/db/src/live-query-observer.ts @@ -198,11 +198,22 @@ class LiveQueryObserverImpl< else this.emit(changes) } - const subscription = collection.subscribeChanges( + // `subscribeChanges` delivers the initial state synchronously, so a + // listener can dispose the observer while the collection subscription is + // still being created. Register the release hook up front; if detach() + // ran during that replay (collectionUnsub no longer points at our hook), + // undo the subscription as soon as the call returns. + let subscription: { unsubscribe: () => void } | null = null + const release = () => subscription?.unsubscribe() + this.collectionUnsub = release + subscription = collection.subscribeChanges( (changes) => notify(changes as Array>), { includeInitialState: true }, ) - this.collectionUnsub = () => subscription.unsubscribe() + if (this.collectionUnsub !== release) { + subscription.unsubscribe() + return + } // Catch a *later* loading→ready transition that carries no change events // (e.g. `markReady()` with no rows). Skip when already ready — the initial diff --git a/packages/db/tests/live-query-observer.test.ts b/packages/db/tests/live-query-observer.test.ts index 0e2e9d835..55cacd9fb 100644 --- a/packages/db/tests/live-query-observer.test.ts +++ b/packages/db/tests/live-query-observer.test.ts @@ -278,6 +278,17 @@ describe(`createLiveQueryObserver`, () => { observer.dispose() }) + it(`releases the collection subscription when a listener disposes during initial replay`, () => { + const source = makeSource() + const observer = createLiveQueryObserver(source as any) + + // The initial-state replay is delivered synchronously inside subscribe(); + // disposing from the listener must not leak the collection subscription. + observer.subscribe(() => observer.dispose()) + + expect(source.subscriberCount).toBe(0) + }) + it(`refreshes the snapshot when status changes without a version bump`, () => { // A status-only loading→ready transition with no active subscription: the // cached snapshot must not stay stale (covers the preload() case too). From 482c82b9603cf5323580dc1865611dfb4c2bac6a Mon Sep 17 00:00:00 2001 From: Kevin De Porre Date: Mon, 20 Jul 2026 11:43:38 +0200 Subject: [PATCH 22/42] fix(db): seed late observer subscribers; reject subscribe after dispose MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit The initial-state replay only happened on the first attach, so a second concurrent subscriber started with no rows and could never converge — its keyed map silently stayed empty. A subscriber arriving while the observer is already attached is now seeded with the collection's current rows as inserts, delivered to that subscription alone without advancing the observer revision. subscribe() after dispose() used to register a listener that could never fire; it now throws LiveQueryObserverDisposedError. Co-Authored-By: Claude Fable 5 --- packages/db/src/errors.ts | 6 +++ packages/db/src/live-query-observer.ts | 33 ++++++++++++- packages/db/tests/live-query-observer.test.ts | 47 +++++++++++++++---- 3 files changed, 77 insertions(+), 9 deletions(-) diff --git a/packages/db/src/errors.ts b/packages/db/src/errors.ts index 0bfd2f996..710025b65 100644 --- a/packages/db/src/errors.ts +++ b/packages/db/src/errors.ts @@ -135,6 +135,12 @@ export class NegativeActiveSubscribersError extends CollectionStateError { } } +export class LiveQueryObserverDisposedError extends CollectionStateError { + constructor() { + super(`Cannot subscribe to a disposed LiveQueryObserver`) + } +} + // Collection Operation Errors export class CollectionOperationError extends TanStackDBError { constructor(message: string) { diff --git a/packages/db/src/live-query-observer.ts b/packages/db/src/live-query-observer.ts index ec789a0ea..690817515 100644 --- a/packages/db/src/live-query-observer.ts +++ b/packages/db/src/live-query-observer.ts @@ -1,3 +1,4 @@ +import { LiveQueryObserverDisposedError } from './errors.js' import { getLiveQueryStatusFlags, isSingleResultCollection, @@ -160,9 +161,19 @@ class LiveQueryObserverImpl< } subscribe(listener: LiveQueryObserverListener): () => void { + if (this.disposed) throw new LiveQueryObserverDisposedError() + const record: SubscriptionRecord = { listener, active: true } this.subscriptions.add(record) - if (this.subscriptions.size === 1) this.attach() + if (this.subscriptions.size === 1) { + this.attach() + } else { + // The initial-state replay only happens on attach, so a subscriber that + // arrives while already attached is seeded with the current rows — + // delivered to this subscription alone, without advancing the observer's + // revision (the collection state did not change). + this.seed(record) + } return () => { if (!record.active) return @@ -172,6 +183,26 @@ class LiveQueryObserverImpl< } } + /** Deliver the collection's current rows to one late subscription as inserts. */ + private seed(record: SubscriptionRecord): void { + const collection = this.collection + if (!collection) return + + const seedChanges: Array> = [] + for (const [key, value] of collection.entries() as IterableIterator< + [TKey, T] + >) { + seedChanges.push({ type: `insert`, key, value }) + } + if (seedChanges.length === 0) return + + const deliver = () => { + if (record.active) record.listener(seedChanges) + } + if (this.deferInitialNotify) queueMicrotask(deliver) + else deliver() + } + private attach(): void { const collection = this.collection if (!collection || this.disposed) return diff --git a/packages/db/tests/live-query-observer.test.ts b/packages/db/tests/live-query-observer.test.ts index 55cacd9fb..f2839121e 100644 --- a/packages/db/tests/live-query-observer.test.ts +++ b/packages/db/tests/live-query-observer.test.ts @@ -208,12 +208,14 @@ describe(`createLiveQueryObserver`, () => { const source = makeSource() const observer = createLiveQueryObserver(source as any) - let lateListenerCalls = 0 + let lateListenerRow4Deliveries = 0 observer.subscribe((changes) => { // Add the late listener only while the row-4 delta is being dispatched. if (changes?.some((c) => c.key === `4`)) { - observer.subscribe(() => { - lateListenerCalls++ + observer.subscribe((lateChanges) => { + if (lateChanges?.some((c) => c.key === `4`)) { + lateListenerRow4Deliveries++ + } }) } }) @@ -222,7 +224,9 @@ describe(`createLiveQueryObserver`, () => { source.utils.write({ type: `insert`, value: { id: `4`, name: `D` } }) source.utils.commit() - expect(lateListenerCalls).toBe(0) + // The late subscriber receives row 4 exactly once — via its seed of the + // already-committed state, NOT additionally via the in-flight publication. + expect(lateListenerRow4Deliveries).toBe(1) observer.dispose() }) @@ -230,14 +234,14 @@ describe(`createLiveQueryObserver`, () => { const source = makeSource() const observer = createLiveQueryObserver(source as any) - let existingListenerCalls = 0 + let row5Deliveries = 0 let unsubB: (() => void) | null = null observer.subscribe(() => { unsubB?.() unsubB = null }) - unsubB = observer.subscribe(() => { - existingListenerCalls++ + unsubB = observer.subscribe((changes) => { + if (changes?.some((c) => c.key === `5`)) row5Deliveries++ }) source.utils.begin() @@ -245,7 +249,7 @@ describe(`createLiveQueryObserver`, () => { source.utils.commit() // A removed B while the publication was in flight; B still receives it. - expect(existingListenerCalls).toBe(1) + expect(row5Deliveries).toBe(1) observer.dispose() }) @@ -289,6 +293,33 @@ describe(`createLiveQueryObserver`, () => { expect(source.subscriberCount).toBe(0) }) + it(`seeds a second concurrent subscriber with the current rows`, () => { + const source = makeSource() + const observer = createLiveQueryObserver(source as any) + + observer.subscribe(() => {}) + + // The attach (and its initial-state replay) already happened; a late + // subscriber must still receive the current rows as inserts. + const secondSubscriberKeys: Array = [] + observer.subscribe((changes) => { + for (const c of changes ?? []) { + if (c.type === `insert`) secondSubscriberKeys.push(c.key) + } + }) + + expect(secondSubscriberKeys.sort()).toEqual([`1`, `2`]) + observer.dispose() + }) + + it(`throws when subscribing after dispose`, () => { + const observer = createLiveQueryObserver(makeSource() as any) + observer.dispose() + expect(() => observer.subscribe(() => {})).toThrow( + /disposed LiveQueryObserver/, + ) + }) + it(`refreshes the snapshot when status changes without a version bump`, () => { // A status-only loading→ready transition with no active subscription: the // cached snapshot must not stay stale (covers the preload() case too). From d0875e43c71636788200dbf7929edcf01994ed98 Mon Sep 17 00:00:00 2001 From: Kevin De Porre Date: Mon, 20 Jul 2026 11:47:31 +0200 Subject: [PATCH 23/42] fix(db): drive observer snapshots from a collection-owned state revision MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit The observer counted every delivery — including per-attach bootstrap replays and empty ready flushes — as a semantic revision. One readiness transition published three times ([], undefined, []), a plain unsubscribe/resubscribe manufactured a new snapshot identity with unchanged data, and rows committed while nothing was attached left the cached snapshot stale. The semantic clock now lives on the collection: emitEvents advances a monotonic stateRevision once per committed batch, whether or not anyone is subscribed. getSnapshot keys its cache on (stateRevision, status), so detached snapshots stay fresh and attachment replay can no longer advance the clock. Empty change batches are dropped from publication — only real deltas and the synthetic ready notify go out — so a readiness transition publishes exactly once. Co-Authored-By: Claude Fable 5 --- packages/db/src/collection/changes.ts | 13 +++++ packages/db/src/collection/index.ts | 9 ++++ packages/db/src/live-query-observer.ts | 20 ++++---- packages/db/tests/live-query-observer.test.ts | 48 +++++++++++++++++++ 4 files changed, 82 insertions(+), 8 deletions(-) diff --git a/packages/db/src/collection/changes.ts b/packages/db/src/collection/changes.ts index dc07cd3f1..e5bdfb845 100644 --- a/packages/db/src/collection/changes.ts +++ b/packages/db/src/collection/changes.ts @@ -30,6 +30,15 @@ export class CollectionChangesManager< public batchedEvents: Array> = [] public shouldBatchEvents = false + /** + * Monotonic revision of the collection's visible state, advanced once per + * committed batch of changes — including while nothing is subscribed. + * Lets consumers (the live-query observer) cheaply detect "did the data + * change" without subscribing, and stays untouched by subscription + * bootstrap replays, which do not go through emitEvents. + */ + public stateRevision = 0 + /** * Creates a new CollectionChangesManager instance */ @@ -77,6 +86,10 @@ export class CollectionChangesManager< changes: Array>, forceEmit = false, ): void { + // The visible state was already committed by the caller, so the revision + // advances even when the events below end up batched for later emission. + if (changes.length > 0) this.stateRevision++ + // Skip batching for user actions (forceEmit=true) to keep UI responsive if (this.shouldBatchEvents && !forceEmit) { // Add events to the batch diff --git a/packages/db/src/collection/index.ts b/packages/db/src/collection/index.ts index 137fd5f59..13887a43d 100644 --- a/packages/db/src/collection/index.ts +++ b/packages/db/src/collection/index.ts @@ -420,6 +420,15 @@ export class CollectionImpl< return this._changes.activeSubscribersCount } + /** + * Monotonic revision of the collection's visible state; advances once per + * committed batch of changes, even while nothing is subscribed. + * Internal — used by the live-query observer's snapshot cache. + */ + public get _stateRevision(): number { + return this._changes.stateRevision + } + /** * Register a callback to be executed when the collection first becomes ready * Useful for preloading collections diff --git a/packages/db/src/live-query-observer.ts b/packages/db/src/live-query-observer.ts index 690817515..391f14a9b 100644 --- a/packages/db/src/live-query-observer.ts +++ b/packages/db/src/live-query-observer.ts @@ -96,8 +96,7 @@ class LiveQueryObserverImpl< > implements LiveQueryObserver { private readonly collection: Collection | null private readonly deferInitialNotify: boolean - private version = 0 - private cachedVersion = -1 + private cachedRevision = -1 private cachedStatus: CollectionStatus | undefined private cachedSnapshot: LiveQuerySnapshot = DISABLED_SNAPSHOT private readonly subscriptions = new Set>() @@ -128,14 +127,16 @@ class LiveQueryObserverImpl< const collection = this.collection if (!collection) return DISABLED_SNAPSHOT - // Rebuild when the version advanced, or when the collection's status - // changed without a version bump (e.g. a status-only loading→ready - // transition or `preload()` while there is no active subscription). + // The semantic clock: rebuild only when the collection's own state + // revision or status moved. The revision advances on every committed + // change — even while nothing is subscribed, so a detached snapshot never + // goes stale — and is untouched by subscription bootstrap replays, so + // resubscribing never manufactures a new snapshot identity. if ( - this.cachedVersion !== this.version || + this.cachedRevision !== collection._stateRevision || this.cachedStatus !== collection.status ) { - this.cachedVersion = this.version + this.cachedRevision = collection._stateRevision this.cachedStatus = collection.status const entries = Array.from(collection.entries()) as Array<[TKey, T]> const singleResult = isSingleResultCollection(collection) @@ -225,6 +226,10 @@ class LiveQueryObserverImpl< const deferred: Array> | undefined> = [] const notify = (changes: Array> | undefined) => { if (this.disposed || this.subscriptions.size === 0) return + // An empty batch carries no semantic change (e.g. the collection's + // empty-ready flush); only real deltas and the synthetic ready notify + // (undefined) are published. + if (changes !== undefined && changes.length === 0) return if (attaching) deferred.push(changes) else this.emit(changes) } @@ -294,7 +299,6 @@ class LiveQueryObserverImpl< // A dispose() during dispatch empties the queue, ending this loop. while (this.publicationQueue.length > 0) { const publication = this.publicationQueue.shift()! - this.version++ // Deliver over a snapshot of the records taken when this publication // is dispatched: a subscription removed mid-delivery still receives // the in-flight publication; one added mid-delivery does not. diff --git a/packages/db/tests/live-query-observer.test.ts b/packages/db/tests/live-query-observer.test.ts index f2839121e..2c8f14f30 100644 --- a/packages/db/tests/live-query-observer.test.ts +++ b/packages/db/tests/live-query-observer.test.ts @@ -320,6 +320,54 @@ describe(`createLiveQueryObserver`, () => { ) }) + it(`preserves snapshot identity across subscribe/unsubscribe cycles`, () => { + const observer = createLiveQueryObserver(makeSource() as any) + + const before = observer.getSnapshot() + observer.subscribe(() => {})() + observer.subscribe(() => {})() + + // Bootstrap replay is per-subscriber delivery, not a semantic revision: + // nothing observable changed, so the snapshot identity must not change. + expect(observer.getSnapshot()).toBe(before) + observer.dispose() + }) + + it(`emits exactly one post-bootstrap notification for a readiness transition`, () => { + const collection = makeLoadingSource() + const observer = createLiveQueryObserver(collection as any) + + const events: Array = [] + observer.subscribe((changes) => events.push(changes)) + + collection.utils.markReady() + + // Not the old [[], undefined, []]: empty batches carry no semantic change, + // so one readiness transition publishes exactly once. + expect(events).toEqual([undefined]) + observer.dispose() + }) + + it(`serves a fresh snapshot for rows changed while detached`, () => { + const source = makeSource() + const observer = createLiveQueryObserver(source as any) + + const unsubscribe = observer.subscribe(() => {}) + const before = observer.getSnapshot() + unsubscribe() + + // Mutate while nothing is attached; the status does not change. + source.utils.begin() + source.utils.write({ type: `insert`, value: { id: `8`, name: `H` } }) + source.utils.commit() + + const after = observer.getSnapshot() + expect(after).not.toBe(before) + expect(after.state?.has(`8`)).toBe(true) + expect(after.data).toHaveLength(3) + observer.dispose() + }) + it(`refreshes the snapshot when status changes without a version bump`, () => { // A status-only loading→ready transition with no active subscription: the // cached snapshot must not stay stale (covers the preload() case too). From 37b5eea99d0590730a8333e9dc70d0e3db24827d Mon Sep 17 00:00:00 2001 From: Kevin De Porre Date: Mon, 20 Jul 2026 11:55:26 +0200 Subject: [PATCH 24/42] test(angular-db): align the mock collection with the real collection contract MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit The hand-rolled mock notified subscribers with empty change batches as a wake-up signal — something real collections never do — and lacked the state revision and status event channel the observer relies on. It now advances _stateRevision on committed changes, emits real delete/insert deltas from __replaceAll, and publishes status transitions through on('status:change') instead of an empty notify. Co-Authored-By: Claude Fable 5 --- .../tests/inject-live-query.test.ts | 32 +++++++++++++++++-- 1 file changed, 29 insertions(+), 3 deletions(-) diff --git a/packages/angular-db/tests/inject-live-query.test.ts b/packages/angular-db/tests/inject-live-query.test.ts index 81fbb12a9..71aa198a5 100644 --- a/packages/angular-db/tests/inject-live-query.test.ts +++ b/packages/angular-db/tests/inject-live-query.test.ts @@ -80,14 +80,25 @@ function createMockCollection( } let status: CollectionStatus = initialStatus + let stateRevision = 0 const subs = new Set<(changes: Array) => void>() const readySubs = new Set<() => void>() + const statusSubs = new Set<(event: any) => void>() const id = `mock-col-` + Math.random().toString(36).slice(2) + // Mirrors the real collection contract: committed changes advance the + // state revision before they are emitted. const notify = (changes: Array = []) => { + if (changes.length > 0) stateRevision++ for (const cb of subs) cb(changes) } + const emitStatusChange = (previousStatus: CollectionStatus) => { + for (const cb of statusSubs) { + cb({ type: `status:change`, previousStatus, status }) + } + } + const notifyReady = () => { for (const cb of readySubs) cb() } @@ -97,6 +108,13 @@ function createMockCollection( get status() { return status }, + get _stateRevision() { + return stateRevision + }, + on: (event: string, cb: (e: any) => void) => { + if (event === `status:change`) statusSubs.add(cb) + return () => statusSubs.delete(cb) + }, entries: () => Array.from(map.entries()), values: () => Array.from(map.values()), get: (key: K) => map.get(key), @@ -127,17 +145,25 @@ function createMockCollection( } }, __setStatus: (s: CollectionStatus) => { + const previousStatus = status const wasNotReady = status !== `ready` status = s - notify([]) + emitStatusChange(previousStatus) if (wasNotReady && status === `ready`) { setTimeout(notifyReady, 0) } }, __replaceAll: (rows: Array>) => { + const changes: Array = [] + for (const [key, value] of map.entries()) { + changes.push({ type: `delete`, key, value }) + } map.clear() - for (const r of rows) map.set(r.id, r) - notify([]) + for (const r of rows) { + map.set(r.id, r) + changes.push({ type: `insert`, key: r.id, value: r }) + } + notify(changes) }, __upsert: (row: T & Record<`id`, K>) => { const isUpdate = map.has(row.id) From eeafe1778d48f791740407c4081346f7d3fdd936 Mon Sep 17 00:00:00 2001 From: Kevin De Porre Date: Mon, 20 Jul 2026 11:55:30 +0200 Subject: [PATCH 25/42] fix(db): publish collection status changes through the canonical path MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit The observer consumed row changes and onFirstReady but not the collection's status events: a mounted consumer could sit on a stale loading/ready status after an error or cleaned-up transition until an unrelated row event happened to arrive. Status changes now publish a synthetic notify through the same canonical path as data changes. This also retires the onFirstReady registration, whose callbacks could not be unsubscribed and accumulated across attach/detach cycles while loading — collection.on('status:change') returns a real unsubscribe that detach releases. Co-Authored-By: Claude Fable 5 --- packages/db/src/live-query-observer.ts | 34 ++++++++----------- packages/db/tests/live-query-observer.test.ts | 19 +++++++++++ 2 files changed, 33 insertions(+), 20 deletions(-) diff --git a/packages/db/src/live-query-observer.ts b/packages/db/src/live-query-observer.ts index 391f14a9b..a734bfac5 100644 --- a/packages/db/src/live-query-observer.ts +++ b/packages/db/src/live-query-observer.ts @@ -108,8 +108,8 @@ class LiveQueryObserverImpl< > = [] private dispatching = false private collectionUnsub: (() => void) | null = null - // Bumped on each attach. `onFirstReady` can't be unsubscribed, so a callback - // from a superseded attach checks this to no-op instead of double-notifying. + // Bumped on each attach so a deferred initial-notify microtask queued by a + // superseded attach can detect it and skip flushing. private attachGeneration = 0 private disposed = false @@ -216,7 +216,7 @@ class LiveQueryObserverImpl< // collection's per-subscriber change stream requires this to align deltes). // // When `deferInitialNotify` is set, emits that fire synchronously while - // attaching (the initial-state batch and an immediately-ready `onFirstReady`) + // attaching (the initial-state batch and any synchronous status notify) // are deferred to a microtask, so a wholesale consumer like React's // `useSyncExternalStore` never receives a synchronous notify during // `subscribe`. Effect/watcher-based adapters want the initial state @@ -234,13 +234,23 @@ class LiveQueryObserverImpl< else this.emit(changes) } + // Status transitions that carry no change events (loading→ready with no + // rows, error, cleaned-up) are part of the canonical publication path: + // any status change publishes a synthetic notify so consumers re-read the + // snapshot. Unlike onFirstReady, `on` returns a real unsubscribe, so a + // detached attachment leaves nothing behind. + const statusUnsub = collection.on(`status:change`, () => notify(undefined)) + // `subscribeChanges` delivers the initial state synchronously, so a // listener can dispose the observer while the collection subscription is // still being created. Register the release hook up front; if detach() // ran during that replay (collectionUnsub no longer points at our hook), // undo the subscription as soon as the call returns. let subscription: { unsubscribe: () => void } | null = null - const release = () => subscription?.unsubscribe() + const release = () => { + statusUnsub() + subscription?.unsubscribe() + } this.collectionUnsub = release subscription = collection.subscribeChanges( (changes) => notify(changes as Array>), @@ -251,22 +261,6 @@ class LiveQueryObserverImpl< return } - // Catch a *later* loading→ready transition that carries no change events - // (e.g. `markReady()` with no rows). Skip when already ready — the initial - // state batch above already covers that, and `onFirstReady` would fire an - // immediate duplicate. - // - // `onFirstReady` returns no unsubscribe, so a callback left behind by an - // earlier attach (subscribe → unsubscribe-before-ready → subscribe) would - // still fire on `markReady`. Guard with the attach generation so only the - // current attachment's callback notifies. - if (collection.status !== `ready`) { - collection.onFirstReady(() => { - if (generation !== this.attachGeneration) return - notify(undefined) - }) - } - attaching = false if (deferred.length > 0) { queueMicrotask(() => { diff --git a/packages/db/tests/live-query-observer.test.ts b/packages/db/tests/live-query-observer.test.ts index 2c8f14f30..6ca15b7d5 100644 --- a/packages/db/tests/live-query-observer.test.ts +++ b/packages/db/tests/live-query-observer.test.ts @@ -368,6 +368,25 @@ describe(`createLiveQueryObserver`, () => { observer.dispose() }) + it(`wakes consumers on status-only transitions (error, cleaned-up)`, () => { + const source = makeSource() + const observer = createLiveQueryObserver(source as any) + + const statuses: Array = [] + observer.subscribe(() => { + statuses.push(observer.getSnapshot().status) + }) + + // Status transitions carry no row changes; the observer must publish + // them through the same canonical path as data changes. + source._lifecycle.setStatus(`error`) + source._lifecycle.setStatus(`cleaned-up`) + + expect(statuses).toContain(`error`) + expect(statuses).toContain(`cleaned-up`) + observer.dispose() + }) + it(`refreshes the snapshot when status changes without a version bump`, () => { // A status-only loading→ready transition with no active subscription: the // cached snapshot must not stay stale (covers the preload() case too). From 4932b732a3ab1e988eb03dbc3d3884515a206841 Mon Sep 17 00:00:00 2001 From: Kevin De Porre Date: Mon, 20 Jul 2026 12:00:21 +0200 Subject: [PATCH 26/42] fix(db): per-consumer initial-state policy; lazy snapshot materialization MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Forcing includeInitialState on every attach was a behavior change for the wholesale adapters: React and Angular never requested an initial snapshot before the observer, and the forced request issued an unfiltered loadSubset({ where: undefined }) against on-demand collections. The observer now takes a mode option: granular (default — Vue/Svelte/Solid) keeps the initial-state subscription and late- subscriber seeding; wholesale (React/Angular) subscribes with includeInitialState: false, restoring the pre-observer loading policy while deletes still flow through as notifies. getSnapshot() now materializes rows lazily on first state/data access, so a consumer that only reads status never enumerates the collection. The React already-ready microtask notify is gone with the bootstrap replay; it existed because the pre-observer per-subscription version could miss a ready transition between render and subscribe, which the collection-owned revision plus useSyncExternalStore's post-subscribe re-read now cover. Co-Authored-By: Claude Fable 5 --- packages/angular-db/src/index.ts | 6 +- packages/db/src/live-query-observer.ts | 53 ++++++++--- packages/db/tests/live-query-observer.test.ts | 93 ++++++++++++++++++- packages/react-db/src/useLiveQuery.ts | 3 + .../useLiveQuery.eager-onstorechange.test.tsx | 18 +++- 5 files changed, 154 insertions(+), 19 deletions(-) diff --git a/packages/angular-db/src/index.ts b/packages/angular-db/src/index.ts index aa9bb35a5..366c25001 100644 --- a/packages/angular-db/src/index.ts +++ b/packages/angular-db/src/index.ts @@ -250,7 +250,11 @@ export function injectLiveQuery(opts: any) { // The shared observer owns sync start, subscription, the ready-race, and // status transitions; Angular re-reads the whole collection on each notify // (wholesale) into its signals. - const observer = createLiveQueryObserver(currentCollection) + // Angular re-reads the collection on notify; wholesale mode preserves its + // pre-observer loading policy (no initial-state snapshot request). + const observer = createLiveQueryObserver(currentCollection, { + mode: `wholesale`, + }) // Seed immediately from the post-start state, then re-read on every notify. syncDataFromCollection(currentCollection) diff --git a/packages/db/src/live-query-observer.ts b/packages/db/src/live-query-observer.ts index a734bfac5..0b6cffee3 100644 --- a/packages/db/src/live-query-observer.ts +++ b/packages/db/src/live-query-observer.ts @@ -96,6 +96,7 @@ class LiveQueryObserverImpl< > implements LiveQueryObserver { private readonly collection: Collection | null private readonly deferInitialNotify: boolean + private readonly wholesale: boolean private cachedRevision = -1 private cachedStatus: CollectionStatus | undefined private cachedSnapshot: LiveQuerySnapshot = DISABLED_SNAPSHOT @@ -116,9 +117,11 @@ class LiveQueryObserverImpl< constructor( collection: Collection | null, deferInitialNotify: boolean, + wholesale: boolean, ) { this.collection = collection this.deferInitialNotify = deferInitialNotify + this.wholesale = wholesale // Starting sync during resolution matches every adapter's eager behavior. collection?.startSyncImmediate() } @@ -138,18 +141,21 @@ class LiveQueryObserverImpl< ) { this.cachedRevision = collection._stateRevision this.cachedStatus = collection.status - const entries = Array.from(collection.entries()) as Array<[TKey, T]> const singleResult = isSingleResultCollection(collection) + // Rows are materialized lazily on first `state`/`data` access, so a + // consumer that only reads `status` never enumerates the collection. + let entriesCache: Array<[TKey, T]> | null = null let stateCache: Map | null = null let dataCache: Array | null = null + const readEntries = () => + (entriesCache ??= Array.from(collection.entries()) as Array<[TKey, T]>) this.cachedSnapshot = { get state() { - if (!stateCache) stateCache = new Map(entries) - return stateCache + return (stateCache ??= new Map(readEntries())) }, get data() { - if (!dataCache) dataCache = entries.map(([, value]) => value) + dataCache ??= readEntries().map(([, value]) => value) return singleResult ? dataCache[0] : dataCache }, collection, @@ -169,11 +175,12 @@ class LiveQueryObserverImpl< if (this.subscriptions.size === 1) { this.attach() } else { - // The initial-state replay only happens on attach, so a subscriber that - // arrives while already attached is seeded with the current rows — - // delivered to this subscription alone, without advancing the observer's - // revision (the collection state did not change). - this.seed(record) + // The initial-state replay only happens on attach, so a granular + // subscriber that arrives while already attached is seeded with the + // current rows — delivered to this subscription alone, without advancing + // the observer's revision (the collection state did not change). + // Wholesale consumers read getSnapshot() instead and need no seed. + if (!this.wholesale) this.seed(record) } return () => { @@ -210,10 +217,14 @@ class LiveQueryObserverImpl< const generation = ++this.attachGeneration - // Subscribe with initial state so granular consumers receive the current - // rows as inserts followed by deltas through one consistent channel — the - // same contract the adapters used before the observer existed (the - // collection's per-subscriber change stream requires this to align deltes). + // Granular consumers subscribe with initial state so they receive the + // current rows as inserts followed by deltas through one consistent + // channel (the collection's per-subscriber change stream requires this to + // align deltas). Wholesale consumers subscribe WITHOUT initial state — + // preserving their pre-observer loading policy: no snapshot request means + // no unfiltered loadSubset({ where: undefined }) against on-demand + // collections. The explicit `false` marks all state as seen so deletes + // still flow through as notifies. // // When `deferInitialNotify` is set, emits that fire synchronously while // attaching (the initial-state batch and any synchronous status notify) @@ -254,7 +265,7 @@ class LiveQueryObserverImpl< this.collectionUnsub = release subscription = collection.subscribeChanges( (changes) => notify(changes as Array>), - { includeInitialState: true }, + { includeInitialState: !this.wholesale }, ) if (this.collectionUnsub !== release) { subscription.unsubscribe() @@ -329,6 +340,19 @@ export interface CreateLiveQueryObserverOptions { * Effect/watcher-based adapters leave it off to get initial state synchronously. */ deferInitialNotify?: boolean + /** + * How subscribers consume the observer: + * + * - `granular` (default): subscribers apply the delivered `ChangeMessage[]` + * deltas to their own keyed state (Vue/Svelte/Solid). The observer + * subscribes with initial state and seeds late subscribers, so every + * subscriber converges from deltas alone. + * - `wholesale`: subscribers treat notifications as a wake-up and re-read + * `getSnapshot()` (React/Angular). The observer subscribes WITHOUT initial + * state, preserving those adapters' loading policy — no snapshot request, + * so no unfiltered `loadSubset` against on-demand collections. + */ + mode?: `granular` | `wholesale` } /** @@ -345,5 +369,6 @@ export function createLiveQueryObserver< return new LiveQueryObserverImpl( collection ?? null, options.deferInitialNotify ?? false, + options.mode === `wholesale`, ) } diff --git a/packages/db/tests/live-query-observer.test.ts b/packages/db/tests/live-query-observer.test.ts index 6ca15b7d5..1fb076843 100644 --- a/packages/db/tests/live-query-observer.test.ts +++ b/packages/db/tests/live-query-observer.test.ts @@ -1,4 +1,4 @@ -import { describe, expect, it } from 'vitest' +import { describe, expect, it, vi } from 'vitest' import { createCollection } from '../src/collection/index.js' import { createLiveQueryObserver } from '../src/live-query-observer.js' import { @@ -40,6 +40,42 @@ function makeLoadingSource() { return collection } +/** An on-demand collection whose sync exposes a loadSubset spy. */ +function makeLoadSubsetSource() { + const loadSubsetCalls: Array = [] + let writeRow: (type: `insert` | `delete`, row: Row) => void + const collection = createCollection({ + id: `observer-loadsubset-${seq++}`, + getKey: (r) => r.id, + startSync: false, + syncMode: `on-demand`, + sync: { + sync: ({ begin, write, commit, markReady }) => { + begin() + for (const row of SEED) write({ type: `insert`, value: row }) + commit() + markReady() + writeRow = (type, row) => { + begin() + write({ type, value: row }) + commit() + } + return { + loadSubset: (options: unknown) => { + loadSubsetCalls.push(options) + return true as const + }, + } + }, + }, + }) + return { + collection, + loadSubsetCalls, + writeRow: (type: `insert` | `delete`, row: Row) => writeRow(type, row), + } +} + describe(`createLiveQueryObserver`, () => { it(`exposes a stable snapshot of a ready collection (wholesale path)`, () => { const observer = createLiveQueryObserver(makeSource() as any) @@ -368,6 +404,61 @@ describe(`createLiveQueryObserver`, () => { observer.dispose() }) + it(`wholesale mode does not request an initial snapshot (no unfiltered loadSubset)`, () => { + const { collection, loadSubsetCalls, writeRow } = makeLoadSubsetSource() + const observer = createLiveQueryObserver(collection as any, { + mode: `wholesale`, + }) + + const notifies: Array = [] + observer.subscribe((changes) => notifies.push(changes)) + + // No initial-state request, so no loadSubset({ where: undefined }) — the + // pre-observer React/Angular loading policy. + expect(loadSubsetCalls).toHaveLength(0) + // No bootstrap replay either; wholesale consumers read getSnapshot(). + expect(notifies).toHaveLength(0) + expect(observer.getSnapshot().data).toHaveLength(2) + + // Deltas — including deletes — still wake the consumer. + writeRow(`delete`, { id: `1`, name: `A` }) + + expect(notifies).toHaveLength(1) + expect(observer.getSnapshot().data).toHaveLength(1) + observer.dispose() + }) + + it(`granular mode still seeds from an initial snapshot`, () => { + const { collection, loadSubsetCalls } = makeLoadSubsetSource() + const observer = createLiveQueryObserver(collection as any) + + const inserted: Array = [] + observer.subscribe((changes) => { + for (const c of changes ?? []) { + if (c.type === `insert`) inserted.push(c.key) + } + }) + + expect(inserted.sort()).toEqual([`1`, `2`]) + expect(loadSubsetCalls).toHaveLength(1) + observer.dispose() + }) + + it(`does not enumerate entries for a status-only snapshot read`, () => { + const source = makeSource() + const observer = createLiveQueryObserver(source as any) + + const entriesSpy = vi.spyOn(source, `entries`) + expect(observer.getSnapshot().status).toBe(`ready`) + expect(entriesSpy).not.toHaveBeenCalled() + + // Materialization happens on first data/state access, once per revision. + expect(observer.getSnapshot().data).toHaveLength(2) + expect(observer.getSnapshot().state?.size).toBe(2) + expect(entriesSpy).toHaveBeenCalledTimes(1) + observer.dispose() + }) + it(`wakes consumers on status-only transitions (error, cleaned-up)`, () => { const source = makeSource() const observer = createLiveQueryObserver(source as any) diff --git a/packages/react-db/src/useLiveQuery.ts b/packages/react-db/src/useLiveQuery.ts index 058d9eb91..df7a06d80 100644 --- a/packages/react-db/src/useLiveQuery.ts +++ b/packages/react-db/src/useLiveQuery.ts @@ -422,6 +422,9 @@ export function useLiveQuery( // synchronously during subscribe. observerRef.current = createLiveQueryObserver(collectionRef.current, { deferInitialNotify: true, + // React re-reads getSnapshot() on notify; subscribing without initial + // state preserves the hook's pre-observer loading policy. + mode: `wholesale`, }) } const observer = observerRef.current! diff --git a/packages/react-db/tests/useLiveQuery.eager-onstorechange.test.tsx b/packages/react-db/tests/useLiveQuery.eager-onstorechange.test.tsx index 207f93156..29f801e83 100644 --- a/packages/react-db/tests/useLiveQuery.eager-onstorechange.test.tsx +++ b/packages/react-db/tests/useLiveQuery.eager-onstorechange.test.tsx @@ -54,12 +54,24 @@ describe(`useLiveQuery: eager onStoreChange must not fire synchronously during s const onStoreChange = vi.fn() const unsub = capturedSubscribe!(onStoreChange) - // onStoreChange must not be invoked synchronously inside subscribe; - // it should be deferred to a microtask so it lands after React commits. + // onStoreChange must not be invoked synchronously inside subscribe — + // useSyncExternalStore's own post-subscribe getSnapshot re-read covers a + // ready transition that happened between render and subscribe, so an + // already-ready unchanged collection needs no wake-up at all. expect(onStoreChange).not.toHaveBeenCalled() await Promise.resolve() - expect(onStoreChange).toHaveBeenCalledTimes(1) + expect(onStoreChange).not.toHaveBeenCalled() + + // A real delta does wake the store. + base.utils.begin() + base.utils.write({ + type: `insert`, + value: { id: `3`, name: `C`, age: 30 }, + }) + base.utils.commit() + await Promise.resolve() + expect(onStoreChange).toHaveBeenCalled() unsub() }) From 52b11e906f70fc638a6a833e8be8e767a65f337a Mon Sep 17 00:00:00 2001 From: Kevin De Porre Date: Mon, 20 Jul 2026 12:02:53 +0200 Subject: [PATCH 27/42] =?UTF-8?q?fix(db):=20remove=20deferInitialNotify=20?= =?UTF-8?q?=E2=80=94=20event=20reordering=20gone=20by=20construction?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit The deferred initial notify could be overtaken by a same-tick delta: the bootstrap batch waited in a microtask while later changes emitted synchronously, so a granular consumer could see v2 before v1. The mechanism existed solely so React's useSyncExternalStore was not notified during its own subscribe call. With React on wholesale mode there is no bootstrap replay to defer — nothing is delivered synchronously during a wholesale subscribe — so the deferral, its attach-generation guard, and the reordering hazard are all removed. Every publication is now delivered synchronously in commit order. Co-Authored-By: Claude Fable 5 --- packages/db/src/live-query-observer.ts | 61 ++----------------- packages/db/tests/live-query-observer.test.ts | 47 +++++++++----- packages/react-db/src/useLiveQuery.ts | 7 ++- 3 files changed, 42 insertions(+), 73 deletions(-) diff --git a/packages/db/src/live-query-observer.ts b/packages/db/src/live-query-observer.ts index 0b6cffee3..568bf859d 100644 --- a/packages/db/src/live-query-observer.ts +++ b/packages/db/src/live-query-observer.ts @@ -95,7 +95,6 @@ class LiveQueryObserverImpl< TKey extends string | number, > implements LiveQueryObserver { private readonly collection: Collection | null - private readonly deferInitialNotify: boolean private readonly wholesale: boolean private cachedRevision = -1 private cachedStatus: CollectionStatus | undefined @@ -109,18 +108,10 @@ class LiveQueryObserverImpl< > = [] private dispatching = false private collectionUnsub: (() => void) | null = null - // Bumped on each attach so a deferred initial-notify microtask queued by a - // superseded attach can detect it and skip flushing. - private attachGeneration = 0 private disposed = false - constructor( - collection: Collection | null, - deferInitialNotify: boolean, - wholesale: boolean, - ) { + constructor(collection: Collection | null, wholesale: boolean) { this.collection = collection - this.deferInitialNotify = deferInitialNotify this.wholesale = wholesale // Starting sync during resolution matches every adapter's eager behavior. collection?.startSyncImmediate() @@ -204,19 +195,13 @@ class LiveQueryObserverImpl< } if (seedChanges.length === 0) return - const deliver = () => { - if (record.active) record.listener(seedChanges) - } - if (this.deferInitialNotify) queueMicrotask(deliver) - else deliver() + record.listener(seedChanges) } private attach(): void { const collection = this.collection if (!collection || this.disposed) return - const generation = ++this.attachGeneration - // Granular consumers subscribe with initial state so they receive the // current rows as inserts followed by deltas through one consistent // channel (the collection's per-subscriber change stream requires this to @@ -225,24 +210,13 @@ class LiveQueryObserverImpl< // no unfiltered loadSubset({ where: undefined }) against on-demand // collections. The explicit `false` marks all state as seen so deletes // still flow through as notifies. - // - // When `deferInitialNotify` is set, emits that fire synchronously while - // attaching (the initial-state batch and any synchronous status notify) - // are deferred to a microtask, so a wholesale consumer like React's - // `useSyncExternalStore` never receives a synchronous notify during - // `subscribe`. Effect/watcher-based adapters want the initial state - // synchronously, so by default it is not deferred. Later changes always emit - // synchronously. - let attaching = this.deferInitialNotify - const deferred: Array> | undefined> = [] const notify = (changes: Array> | undefined) => { if (this.disposed || this.subscriptions.size === 0) return // An empty batch carries no semantic change (e.g. the collection's // empty-ready flush); only real deltas and the synthetic ready notify // (undefined) are published. if (changes !== undefined && changes.length === 0) return - if (attaching) deferred.push(changes) - else this.emit(changes) + this.emit(changes) } // Status transitions that carry no change events (loading→ready with no @@ -271,23 +245,6 @@ class LiveQueryObserverImpl< subscription.unsubscribe() return } - - attaching = false - if (deferred.length > 0) { - queueMicrotask(() => { - // Skip if the observer was disposed, has no listeners, or a newer - // attach superseded this one before the flush — otherwise a stale - // initial batch would reach the current listener. - if ( - this.disposed || - this.subscriptions.size === 0 || - generation !== this.attachGeneration - ) { - return - } - for (const changes of deferred.splice(0)) this.emit(changes) - }) - } } private detach(): void { @@ -333,13 +290,6 @@ class LiveQueryObserverImpl< } export interface CreateLiveQueryObserverOptions { - /** - * Defer the initial-state notify to a microtask instead of emitting it - * synchronously during `subscribe`. Set this for `useSyncExternalStore`-style - * consumers (React) that must not receive a store notify during subscribe. - * Effect/watcher-based adapters leave it off to get initial state synchronously. - */ - deferInitialNotify?: boolean /** * How subscribers consume the observer: * @@ -350,7 +300,9 @@ export interface CreateLiveQueryObserverOptions { * - `wholesale`: subscribers treat notifications as a wake-up and re-read * `getSnapshot()` (React/Angular). The observer subscribes WITHOUT initial * state, preserving those adapters' loading policy — no snapshot request, - * so no unfiltered `loadSubset` against on-demand collections. + * so no unfiltered `loadSubset` against on-demand collections. Nothing is + * delivered synchronously during `subscribe`, which keeps + * `useSyncExternalStore`-style consumers safe by construction. */ mode?: `granular` | `wholesale` } @@ -368,7 +320,6 @@ export function createLiveQueryObserver< ): LiveQueryObserver { return new LiveQueryObserverImpl( collection ?? null, - options.deferInitialNotify ?? false, options.mode === `wholesale`, ) } diff --git a/packages/db/tests/live-query-observer.test.ts b/packages/db/tests/live-query-observer.test.ts index 1fb076843..0e8fcc0a2 100644 --- a/packages/db/tests/live-query-observer.test.ts +++ b/packages/db/tests/live-query-observer.test.ts @@ -153,19 +153,39 @@ describe(`createLiveQueryObserver`, () => { observer.dispose() }) - it(`defers the initial notify to a microtask when deferInitialNotify is set`, async () => { + it(`delivers nothing synchronously during a wholesale subscribe`, () => { const observer = createLiveQueryObserver(makeSource() as any, { - deferInitialNotify: true, + mode: `wholesale`, }) let notified = false observer.subscribe(() => { notified = true }) - // Not synchronous during subscribe (protects React's useSyncExternalStore)... + // No bootstrap replay in wholesale mode: useSyncExternalStore-style + // consumers are never notified inside their own subscribe call. expect(notified).toBe(false) - await Promise.resolve() - // ...delivered on the next microtask. - expect(notified).toBe(true) + expect(observer.getSnapshot().data).toHaveLength(2) + observer.dispose() + }) + + it(`delivers events in commit order — no notify can overtake an older one`, () => { + const source = makeSource() + const observer = createLiveQueryObserver(source as any) + + const order: Array = [] + observer.subscribe((changes) => { + for (const c of changes ?? []) order.push(`${c.type}:${c.key}`) + }) + order.length = 0 // drop the bootstrap + + source.utils.begin() + source.utils.write({ type: `insert`, value: { id: `v1`, name: `V1` } }) + source.utils.commit() + source.utils.begin() + source.utils.write({ type: `insert`, value: { id: `v2`, name: `V2` } }) + source.utils.commit() + + expect(order).toEqual([`insert:v1`, `insert:v2`]) observer.dispose() }) @@ -190,23 +210,20 @@ describe(`createLiveQueryObserver`, () => { observer.dispose() }) - it(`does not flush a superseded deferred initial notify (deferInitialNotify)`, async () => { - const observer = createLiveQueryObserver(makeSource() as any, { - deferInitialNotify: true, - }) + it(`a resubscribe before a microtask cannot leak a stale bootstrap`, async () => { + const observer = createLiveQueryObserver(makeSource() as any) - // Subscribe then unsubscribe before the microtask flush, then resubscribe. + // Subscribe then unsubscribe immediately, then resubscribe. All delivery + // is synchronous now, so nothing deferred can flush later. observer.subscribe(() => {})() let notifications = 0 observer.subscribe(() => { notifications++ }) + expect(notifications).toBe(1) // the synchronous bootstrap replay await Promise.resolve() - - // Only the current subscription's deferred initial notify should flush, - // not the stale one queued by the first (superseded) attach. - expect(notifications).toBe(1) + expect(notifications).toBe(1) // and nothing else afterwards observer.dispose() }) diff --git a/packages/react-db/src/useLiveQuery.ts b/packages/react-db/src/useLiveQuery.ts index df7a06d80..2ada39077 100644 --- a/packages/react-db/src/useLiveQuery.ts +++ b/packages/react-db/src/useLiveQuery.ts @@ -420,10 +420,11 @@ export function useLiveQuery( if (needsNewCollection) { // Defer the initial notify: useSyncExternalStore must not be notified // synchronously during subscribe. + // Wholesale mode: React re-reads getSnapshot() on notify, keeps the + // hook's pre-observer loading policy, and — because wholesale delivers + // nothing synchronously during subscribe — never notifies + // useSyncExternalStore inside its own subscribe call. observerRef.current = createLiveQueryObserver(collectionRef.current, { - deferInitialNotify: true, - // React re-reads getSnapshot() on notify; subscribing without initial - // state preserves the hook's pre-observer loading policy. mode: `wholesale`, }) } From 5e92926d65b22c6a268955a31c70b8bfb1be02a4 Mon Sep 17 00:00:00 2001 From: Kevin De Porre Date: Mon, 20 Jul 2026 12:07:48 +0200 Subject: [PATCH 28/42] =?UTF-8?q?fix(db):=20make=20observer=20construction?= =?UTF-8?q?=20inert=20=E2=80=94=20sync=20activates=20on=20first=20subscrib?= =?UTF-8?q?e?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Constructing an observer called startSyncImmediate(), so building one in a render that is later abandoned (React concurrent rendering) activated sync with no committed consumer. Construction is now side-effect-free: activation happens through the first subscription's own addSubscriber path — the identical startSync call — after the status listener is wired, so the loading/ready transitions of a synchronously-starting collection are observed and published instead of happening silently before anyone listens. The adapters' behavior is unchanged: React's input-resolution paths start sync in render themselves (pre-existing, unchanged here), and the effect-based adapters subscribe in the same tick they construct. Co-Authored-By: Claude Fable 5 --- .../tests/inject-live-query.test.ts | 7 ++--- packages/db/src/live-query-observer.ts | 10 +++++-- packages/db/tests/live-query-observer.test.ts | 27 ++++++++++++++++--- 3 files changed, 36 insertions(+), 8 deletions(-) diff --git a/packages/angular-db/tests/inject-live-query.test.ts b/packages/angular-db/tests/inject-live-query.test.ts index 71aa198a5..a2dceb059 100644 --- a/packages/angular-db/tests/inject-live-query.test.ts +++ b/packages/angular-db/tests/inject-live-query.test.ts @@ -122,6 +122,8 @@ function createMockCollection( size: () => map.size, subscribeChanges: (cb: (changes: Array) => void) => { subs.add(cb) + // Real collections start sync when the first subscriber attaches. + api.startSyncImmediate() return { unsubscribe: () => subs.delete(cb), } @@ -136,11 +138,10 @@ function createMockCollection( }, preload: () => Promise.resolve(), startSyncImmediate: () => { - const wasNotReady = status !== `ready` + const previousStatus = status if (status === `idle`) { status = `ready` - } - if (wasNotReady && status === `ready`) { + emitStatusChange(previousStatus) setTimeout(notifyReady, 0) } }, diff --git a/packages/db/src/live-query-observer.ts b/packages/db/src/live-query-observer.ts index 568bf859d..66ec5555c 100644 --- a/packages/db/src/live-query-observer.ts +++ b/packages/db/src/live-query-observer.ts @@ -110,11 +110,12 @@ class LiveQueryObserverImpl< private collectionUnsub: (() => void) | null = null private disposed = false + // Construction is side-effect-free: sync activation belongs to the first + // subscription (attach), so building an observer — e.g. in a React render + // that may be abandoned — cannot activate resources on its own. constructor(collection: Collection | null, wholesale: boolean) { this.collection = collection this.wholesale = wholesale - // Starting sync during resolution matches every adapter's eager behavior. - collection?.startSyncImmediate() } getSnapshot(): LiveQuerySnapshot { @@ -202,6 +203,11 @@ class LiveQueryObserverImpl< const collection = this.collection if (!collection || this.disposed) return + // Sync activation happens inside subscribeChanges (addSubscriber starts + // an idle/cleaned-up collection) — the same startSync path the old + // constructor-time startSyncImmediate() took, but now owned by the first + // committed subscription and observed by the status listener below. + // Granular consumers subscribe with initial state so they receive the // current rows as inserts followed by deltas through one consistent // channel (the collection's per-subscriber change stream requires this to diff --git a/packages/db/tests/live-query-observer.test.ts b/packages/db/tests/live-query-observer.test.ts index 0e8fcc0a2..eea759381 100644 --- a/packages/db/tests/live-query-observer.test.ts +++ b/packages/db/tests/live-query-observer.test.ts @@ -433,14 +433,16 @@ describe(`createLiveQueryObserver`, () => { // No initial-state request, so no loadSubset({ where: undefined }) — the // pre-observer React/Angular loading policy. expect(loadSubsetCalls).toHaveLength(0) - // No bootstrap replay either; wholesale consumers read getSnapshot(). - expect(notifies).toHaveLength(0) + // No bootstrap replay either (only status wake-ups, which carry no + // changes); wholesale consumers read getSnapshot(). + expect(notifies.filter((n) => n !== undefined)).toHaveLength(0) expect(observer.getSnapshot().data).toHaveLength(2) // Deltas — including deletes — still wake the consumer. + const notifiesBefore = notifies.length writeRow(`delete`, { id: `1`, name: `A` }) - expect(notifies).toHaveLength(1) + expect(notifies.length).toBe(notifiesBefore + 1) expect(observer.getSnapshot().data).toHaveLength(1) observer.dispose() }) @@ -476,6 +478,25 @@ describe(`createLiveQueryObserver`, () => { observer.dispose() }) + it(`does not activate sync at construction — only on first subscribe`, () => { + const collection = createCollection( + mockSyncCollectionOptionsNoInitialState({ + id: `observer-idle-${seq++}`, + getKey: (r) => r.id, + }), + ) + const observer = createLiveQueryObserver(collection as any) + + // Construction (e.g. in an abandoned React render) is inert. + expect(collection.status).toBe(`idle`) + expect(observer.getSnapshot().status).toBe(`idle`) + + const unsubscribe = observer.subscribe(() => {}) + expect(collection.status).not.toBe(`idle`) + unsubscribe() + observer.dispose() + }) + it(`wakes consumers on status-only transitions (error, cleaned-up)`, () => { const source = makeSource() const observer = createLiveQueryObserver(source as any) From 58bd2c13299f29340c629063416dab749de190b3 Mon Sep 17 00:00:00 2001 From: Kevin De Porre Date: Mon, 20 Jul 2026 12:09:46 +0200 Subject: [PATCH 29/42] fix(solid-db): generation-guard the resource's async continuations Solid discards a superseded fetch's return value, but the fetcher's post-await writes are side effects into hook-scoped state: switching collections while toArrayWhenReady() was pending let the old continuation resurrect the replaced collection's rows and status over the new one's. Both the success and error continuations now check a generation counter and no-op when superseded. Co-Authored-By: Claude Fable 5 --- packages/solid-db/src/useLiveQuery.ts | 12 ++- packages/solid-db/tests/useLiveQuery.test.tsx | 76 +++++++++++++++++++ 2 files changed, 87 insertions(+), 1 deletion(-) diff --git a/packages/solid-db/src/useLiveQuery.ts b/packages/solid-db/src/useLiveQuery.ts index a50b8269c..9b53e1d88 100644 --- a/packages/solid-db/src/useLiveQuery.ts +++ b/packages/solid-db/src/useLiveQuery.ts @@ -351,9 +351,16 @@ export function useLiveQuery( ) } + // Generation guard for the resource's async continuations: Solid discards a + // superseded fetch's *return value*, but the writes below are side effects + // into hook-scoped state and would still run — resurrecting rows/status from + // a collection that has already been replaced. + let resourceGeneration = 0 + const [getDataResource] = createResource( () => ({ currentCollection: collection() }), async ({ currentCollection }) => { + const generation = ++resourceGeneration if (!currentCollection) { return [] } @@ -361,9 +368,12 @@ export function useLiveQuery( try { await currentCollection.toArrayWhenReady() } catch (error) { - setStatus(`error`) + if (generation === resourceGeneration) setStatus(`error`) throw error } + if (generation !== resourceGeneration) { + return data + } // Initialize state with current collection data batch(() => { state.clear() diff --git a/packages/solid-db/tests/useLiveQuery.test.tsx b/packages/solid-db/tests/useLiveQuery.test.tsx index b7567a635..e1df013e4 100644 --- a/packages/solid-db/tests/useLiveQuery.test.tsx +++ b/packages/solid-db/tests/useLiveQuery.test.tsx @@ -566,6 +566,82 @@ describe(`Query Collections`, () => { }) }) + it(`does not resurrect state from a superseded collection's async continuation`, async () => { + // The resource fetcher awaits toArrayWhenReady(); if the collection is + // switched while that await is pending, the old continuation must not + // write its (now stale) rows/status over the new collection's. + return createRoot(async (dispose) => { + let beginA: (() => void) | undefined + let writeA: ((msg: any) => void) | undefined + let commitA: (() => void) | undefined + let markReadyA: (() => void) | undefined + + const slowCollection = createCollection({ + id: `superseded-async-slow`, + getKey: (person: Person) => person.id, + startSync: false, + sync: { + sync: ({ begin, write, commit, markReady }) => { + beginA = begin + writeA = write + commitA = commit + markReadyA = markReady + // Stays loading until markReady is called manually. + }, + }, + }) + const fastCollection = createCollection( + mockSyncCollectionOptions({ + id: `superseded-async-fast`, + getKey: (person: Person) => person.id, + initialData: [initialPersons[0]!], + }), + ) + + const [useSlow, setUseSlow] = createSignal(true) + const rendered = renderHook(() => { + return useLiveQuery((q) => + q + .from({ persons: useSlow() ? slowCollection : fastCollection }) + .select(({ persons }) => ({ id: persons.id, name: persons.name })), + ) + }) + + await new Promise((resolve) => setTimeout(resolve, 10)) + expect(rendered.result.isLoading).toBe(true) + + // Switch collections while the slow fetch is still awaiting readiness. + setUseSlow(false) + await new Promise((resolve) => setTimeout(resolve, 10)) + expect(rendered.result.state.has(`1`)).toBe(true) + + // The superseded collection now becomes ready with different rows; its + // continuation resolves but must not clobber the current state. + beginA!() + writeA!({ + type: `insert`, + value: { + id: `stale`, + name: `Stale Row`, + age: 99, + email: `stale@example.com`, + isActive: false, + team: `none`, + }, + }) + commitA!() + markReadyA!() + await new Promise((resolve) => setTimeout(resolve, 20)) + + expect(rendered.result.state.has(`stale`)).toBe(false) + expect(rendered.result.state.has(`1`)).toBe(true) + expect(rendered.result.data.map((p: any) => p.id)).toEqual([`1`]) + expect(rendered.result.status).toBe(`ready`) + + dispose() + }) + }) + it(`should be able to query a result collection with live updates`, async () => { const collection = createCollection( mockSyncCollectionOptions({ From b24d1637298774f4fa3c5e43ef03e4a2342cb60f Mon Sep 17 00:00:00 2001 From: Kevin De Porre Date: Mon, 20 Jul 2026 12:10:51 +0200 Subject: [PATCH 30/42] docs(db): mark the observer as internal/unstable; honest changeset MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit The observer is a contract for TanStack DB's official adapters, not a public extension point — the exported factory and interface now say so (@internal, may change in any release). The changeset drops the false "No behavior change" claim and describes the lifecycle fixes and the per-adapter loading-policy preservation instead. Co-Authored-By: Claude Fable 5 --- .changeset/live-query-observer.md | 8 ++++++-- packages/db/src/live-query-observer.ts | 15 ++++++++++++--- 2 files changed, 18 insertions(+), 5 deletions(-) diff --git a/.changeset/live-query-observer.md b/.changeset/live-query-observer.md index 4b1218483..95565b091 100644 --- a/.changeset/live-query-observer.md +++ b/.changeset/live-query-observer.md @@ -7,6 +7,10 @@ '@tanstack/angular-db': patch --- -Add a shared live-query observer and migrate all five framework adapters to it +Add an internal shared live-query observer and migrate all five framework adapters to it -Introduces `createLiveQueryObserver` in `@tanstack/db`: given a resolved live-query collection (or `null` for a disabled query) it owns the shared lifecycle every adapter used to re-implement — start sync, subscribe to changes, the already-ready notify race, a stable per-revision snapshot for wholesale consumers, and delivery of the raw `ChangeMessage[]` for granular consumers. React, Vue, Svelte, Solid, and Angular's live-query hooks now materialize from the observer instead of their own hand-rolled subscription/status/snapshot machinery, keeping each adapter's native reactivity. No behavior change. +Introduces `createLiveQueryObserver` in `@tanstack/db`: given a resolved live-query collection (or `null` for a disabled query) it owns the lifecycle every adapter used to re-implement — sync activation on first subscribe, change and status subscriptions, a snapshot with stable identity per state revision for wholesale consumers, and delivery of the raw `ChangeMessage[]` for granular consumers. React, Vue, Svelte, Solid, and Angular's live-query hooks now materialize from the observer instead of their own hand-rolled subscription/status/snapshot machinery, keeping each adapter's native reactivity and each adapter's data-loading policy (wholesale adapters subscribe without initial state; granular adapters seed from it). + +The observer is an **internal, unstable contract** for TanStack DB's official adapters — it is exported so the adapter packages can consume it, but it is not a public extension point yet and its API may change in any release. + +The migration also fixes several live-query lifecycle defects: status-only transitions (`error`, `cleaned-up`) now reach mounted consumers; snapshot identity is stable across unsubscribe/resubscribe and stays fresh while detached; dispatch is FIFO and non-reentrant with subscriptions identified by record rather than callback; disposing during the synchronous initial replay no longer leaks the collection subscription; subscribing after dispose throws instead of registering a dead listener; Solid guards its async resource continuations against superseded collections; and constructing an observer no longer activates sync (activation belongs to the first committed subscription). diff --git a/packages/db/src/live-query-observer.ts b/packages/db/src/live-query-observer.ts index 66ec5555c..83899bdfe 100644 --- a/packages/db/src/live-query-observer.ts +++ b/packages/db/src/live-query-observer.ts @@ -40,13 +40,17 @@ export type LiveQueryObserverListener< /** * Wraps a resolved live-query `Collection` (or `null` for a disabled query) with - * the shared lifecycle every framework adapter needs: start sync, subscribe to - * changes, handle the already-ready race, expose a stable snapshot for - * wholesale consumers, and deliver the raw change set for granular consumers. + * the shared lifecycle every framework adapter needs: start sync on first + * subscribe, subscribe to changes and status transitions, expose a stable + * snapshot for wholesale consumers, and deliver the raw change set for + * granular consumers. * * Input resolution (query fn / config / collection / disabled) stays in the * adapter — it is framework-reactive. The observer owns everything after the * input is resolved to a concrete collection. + * + * @internal Unstable contract for TanStack DB's official framework adapters — + * not a public extension point yet; may change in any release. */ export interface LiveQueryObserver< T extends object, @@ -316,6 +320,11 @@ export interface CreateLiveQueryObserverOptions { /** * Create a {@link LiveQueryObserver} for a resolved live-query collection, or a * disabled observer when `collection` is `null`/`undefined`. + * + * @internal This is an unstable contract shared by TanStack DB's official + * framework adapters. It is exported so the adapter packages can use it, but + * it is not a public extension point yet: its API may change in any release + * without a semver major. */ export function createLiveQueryObserver< T extends object, From 650ccb75251d593663205192da81e7aa1613d3db Mon Sep 17 00:00:00 2001 From: Tanner Linsley Date: Tue, 11 Aug 2026 15:44:04 -0600 Subject: [PATCH 31/42] fix(db): address live query observer review --- packages/db/src/collection/lifecycle.ts | 7 +- packages/db/src/live-query-observer.ts | 222 ++++++++++++++---- packages/db/tests/live-query-observer.test.ts | 201 +++++++++++++++- packages/solid-db/src/useLiveQuery.ts | 14 ++ packages/solid-db/tests/useLiveQuery.test.tsx | 31 +++ packages/svelte-db/src/useLiveQuery.svelte.ts | 7 + .../tests/useLiveQuery.svelte.test.ts | 24 ++ packages/vue-db/src/useLiveQuery.ts | 7 + packages/vue-db/tests/useLiveQuery.test.ts | 21 ++ 9 files changed, 479 insertions(+), 55 deletions(-) diff --git a/packages/db/src/collection/lifecycle.ts b/packages/db/src/collection/lifecycle.ts index a9454ddca..8f8cced21 100644 --- a/packages/db/src/collection/lifecycle.ts +++ b/packages/db/src/collection/lifecycle.ts @@ -263,8 +263,11 @@ export class CollectionLifecycleManager< // This fires the status:change event to notify listeners this.setStatus(`cleaned-up`) - // Finally, cleanup event handlers after the event has been fired - this.events.cleanup() + // Active collection subscriptions still depend on lifecycle events. + // Once the last subscriber leaves, its GC cleanup clears the handlers. + if (this.changes.activeSubscribersCount === 0) { + this.events.cleanup() + } return true } else { diff --git a/packages/db/src/live-query-observer.ts b/packages/db/src/live-query-observer.ts index 83899bdfe..595e194b7 100644 --- a/packages/db/src/live-query-observer.ts +++ b/packages/db/src/live-query-observer.ts @@ -11,7 +11,8 @@ import type { ChangeMessage, CollectionStatus } from './types.js' * * `getSnapshot()` returns a stable object identity that only changes when the * query changes, so `useSyncExternalStore`-style consumers can compare by - * reference. `state`/`data` are computed lazily and cached per snapshot. + * reference. Each snapshot owns a captured view of `state`/`data`, so reading + * an older snapshot cannot expose rows from a later revision. */ export interface LiveQuerySnapshot< T extends object, @@ -81,6 +82,14 @@ interface SubscriptionRecord { active: boolean } +interface Publication { + changes: Array> | undefined + targets: Array> + entries?: Array<[TKey, T]> + status: CollectionStatus + collectionRevision?: number +} + const DISABLED_SNAPSHOT: LiveQuerySnapshot = { state: undefined, data: undefined, @@ -100,17 +109,20 @@ class LiveQueryObserverImpl< > implements LiveQueryObserver { private readonly collection: Collection | null private readonly wholesale: boolean - private cachedRevision = -1 - private cachedStatus: CollectionStatus | undefined + private visibleStatus: CollectionStatus | undefined + private cachedEntries: Array<[TKey, T]> | undefined + private cachedCollectionRevision: number | undefined + private snapshotDirty = true private cachedSnapshot: LiveQuerySnapshot = DISABLED_SNAPSHOT private readonly subscriptions = new Set>() // Publications are dispatched FIFO: an emit that happens while another // publication is being delivered (a listener mutating the collection // synchronously) is queued, never delivered reentrantly. - private readonly publicationQueue: Array< - Array> | undefined - > = [] + private readonly publicationQueue: Array> = [] private dispatching = false + private deliveryScheduled = false + private blockDelivery = false + private attached = false private collectionUnsub: (() => void) | null = null private disposed = false @@ -126,43 +138,97 @@ class LiveQueryObserverImpl< const collection = this.collection if (!collection) return DISABLED_SNAPSHOT - // The semantic clock: rebuild only when the collection's own state - // revision or status moved. The revision advances on every committed - // change — even while nothing is subscribed, so a detached snapshot never - // goes stale — and is untouched by subscription bootstrap replays, so - // resubscribing never manufactures a new snapshot identity. - if ( - this.cachedRevision !== collection._stateRevision || - this.cachedStatus !== collection.status - ) { - this.cachedRevision = collection._stateRevision - this.cachedStatus = collection.status + if (!this.attached) this.refreshDetachedState(collection) + + if (this.snapshotDirty) { + const entries = + this.cachedEntries ?? this.captureEntries(collection).entries + const state = new Map(entries) + const data = entries.map(([, value]) => value) const singleResult = isSingleResultCollection(collection) - // Rows are materialized lazily on first `state`/`data` access, so a - // consumer that only reads `status` never enumerates the collection. - let entriesCache: Array<[TKey, T]> | null = null - let stateCache: Map | null = null - let dataCache: Array | null = null - const readEntries = () => - (entriesCache ??= Array.from(collection.entries()) as Array<[TKey, T]>) + const status = this.visibleStatus ?? collection.status this.cachedSnapshot = { - get state() { - return (stateCache ??= new Map(readEntries())) - }, - get data() { - dataCache ??= readEntries().map(([, value]) => value) - return singleResult ? dataCache[0] : dataCache - }, + state, + data: singleResult ? data[0] : data, collection, - status: collection.status, - ...getLiveQueryStatusFlags(collection.status), + status, + ...getLiveQueryStatusFlags(status), isEnabled: true, } + this.snapshotDirty = false } return this.cachedSnapshot } + private getCollectionRevision( + collection: Collection, + ): number | undefined { + const revision = (collection as { _stateRevision?: unknown })._stateRevision + return typeof revision === `number` ? revision : undefined + } + + private readEntries(collection: Collection): { + entries: Array<[TKey, T]> + revision?: number + } { + const entries = Array.from(collection.entries()) as Array<[TKey, T]> + const revision = this.getCollectionRevision(collection) + return { entries, revision } + } + + private captureEntries(collection: Collection): { + entries: Array<[TKey, T]> + revision?: number + } { + const { entries, revision } = this.readEntries(collection) + this.cachedEntries = entries + this.cachedCollectionRevision = revision + return { entries, revision } + } + + private entriesEqual( + left: Array<[TKey, T]> | undefined, + right: Array<[TKey, T]>, + ): boolean { + if (!left || left.length !== right.length) return false + return left.every( + ([key, value], index) => + right[index]![0] === key && right[index]![1] === value, + ) + } + + /** + * While detached there is no delivered-publication clock, so fall back to + * the collection revision. Compatible cross-copy collections that predate + * `_stateRevision` are compared structurally instead. + */ + private refreshDetachedState(collection: Collection): void { + const status = collection.status + const revision = this.getCollectionRevision(collection) + + if (revision !== undefined) { + if ( + this.cachedEntries === undefined || + revision !== this.cachedCollectionRevision + ) { + this.captureEntries(collection) + this.snapshotDirty = true + } + } else { + const entries = Array.from(collection.entries()) as Array<[TKey, T]> + if (!this.entriesEqual(this.cachedEntries, entries)) { + this.cachedEntries = entries + this.snapshotDirty = true + } + } + + if (this.visibleStatus !== status) { + this.visibleStatus = status + this.snapshotDirty = true + } + } + subscribe(listener: LiveQueryObserverListener): () => void { if (this.disposed) throw new LiveQueryObserverDisposedError() @@ -200,12 +266,15 @@ class LiveQueryObserverImpl< } if (seedChanges.length === 0) return - record.listener(seedChanges) + this.emit(seedChanges, [record]) } private attach(): void { const collection = this.collection if (!collection || this.disposed) return + this.attached = true + this.visibleStatus ??= collection.status + this.blockDelivery = this.wholesale // Sync activation happens inside subscribeChanges (addSubscriber starts // an idle/cleaned-up collection) — the same startSync path the old @@ -220,13 +289,30 @@ class LiveQueryObserverImpl< // no unfiltered loadSubset({ where: undefined }) against on-demand // collections. The explicit `false` marks all state as seen so deletes // still flow through as notifies. - const notify = (changes: Array> | undefined) => { + let receivingInitialState = true + const notify = ( + changes: Array> | undefined, + status: CollectionStatus = collection.status, + ) => { if (this.disposed || this.subscriptions.size === 0) return // An empty batch carries no semantic change (e.g. the collection's // empty-ready flush); only real deltas and the synthetic ready notify // (undefined) are published. if (changes !== undefined && changes.length === 0) return - this.emit(changes) + const isInitialReplay = receivingInitialState && changes !== undefined + const captured = + changes !== undefined && !isInitialReplay + ? this.readEntries(collection) + : status === `cleaned-up` + ? this.readEntries(collection) + : undefined + this.emit( + changes, + undefined, + captured?.entries, + status, + captured?.revision, + ) } // Status transitions that carry no change events (loading→ready with no @@ -234,7 +320,9 @@ class LiveQueryObserverImpl< // any status change publishes a synthetic notify so consumers re-read the // snapshot. Unlike onFirstReady, `on` returns a real unsubscribe, so a // detached attachment leaves nothing behind. - const statusUnsub = collection.on(`status:change`, () => notify(undefined)) + const statusUnsub = collection.on(`status:change`, ({ status }) => + notify(undefined, status), + ) // `subscribeChanges` delivers the initial state synchronously, so a // listener can dispose the observer while the collection subscription is @@ -251,19 +339,54 @@ class LiveQueryObserverImpl< (changes) => notify(changes as Array>), { includeInitialState: !this.wholesale }, ) + receivingInitialState = false + this.blockDelivery = false if (this.collectionUnsub !== release) { subscription.unsubscribe() return } + if (this.publicationQueue.length > 0 && this.wholesale) { + this.scheduleDelivery() + } } private detach(): void { this.collectionUnsub?.() this.collectionUnsub = null + this.attached = false + this.blockDelivery = false + this.publicationQueue.length = 0 + } + + private emit( + changes: Array> | undefined, + targets = Array.from(this.subscriptions), + entries?: Array<[TKey, T]>, + status = this.collection?.status ?? `cleaned-up`, + collectionRevision?: number, + ): void { + this.publicationQueue.push({ + changes, + targets, + entries, + status, + collectionRevision, + }) + if (this.dispatching || this.blockDelivery || this.deliveryScheduled) return + + this.flushPublications() } - private emit(changes: Array> | undefined): void { - this.publicationQueue.push(changes) + private scheduleDelivery(): void { + if (this.deliveryScheduled) return + this.deliveryScheduled = true + queueMicrotask(() => { + this.deliveryScheduled = false + if (!this.disposed && !this.blockDelivery) this.flushPublications() + }) + } + + private flushPublications(): void { if (this.dispatching) return this.dispatching = true @@ -271,13 +394,21 @@ class LiveQueryObserverImpl< // A dispose() during dispatch empties the queue, ending this loop. while (this.publicationQueue.length > 0) { const publication = this.publicationQueue.shift()! - // Deliver over a snapshot of the records taken when this publication - // is dispatched: a subscription removed mid-delivery still receives - // the in-flight publication; one added mid-delivery does not. - const records = Array.from(this.subscriptions) - for (const subRecord of records) { + if (publication.entries) { + this.cachedEntries = publication.entries + this.cachedCollectionRevision = publication.collectionRevision + this.snapshotDirty = true + } + if (this.visibleStatus !== publication.status) { + this.visibleStatus = publication.status + this.snapshotDirty = true + } + // Targets are captured when the publication is queued: a subscription + // removed mid-delivery still receives the in-flight publication, and + // one added later does not. Late-subscriber seeds use the same queue. + for (const subRecord of publication.targets) { if (this.disposed) return - subRecord.listener(publication) + subRecord.listener(publication.changes) } } } finally { @@ -296,6 +427,7 @@ class LiveQueryObserverImpl< for (const subRecord of this.subscriptions) subRecord.active = false this.subscriptions.clear() this.publicationQueue.length = 0 + this.deliveryScheduled = false } } diff --git a/packages/db/tests/live-query-observer.test.ts b/packages/db/tests/live-query-observer.test.ts index eea759381..e3b1788cf 100644 --- a/packages/db/tests/live-query-observer.test.ts +++ b/packages/db/tests/live-query-observer.test.ts @@ -76,6 +76,55 @@ function makeLoadSubsetSource() { } } +function makeControlledTruncateSource() { + let begin!: () => void + let write!: (message: { type: `insert`; value: Row }) => void + let commit!: () => void + let truncate!: () => void + let resolveLoad!: () => void + let loadCalls = 0 + + const collection = createCollection({ + id: `observer-truncate-${seq++}`, + getKey: (row) => row.id, + syncMode: `on-demand`, + sync: { + sync: (ops) => { + begin = ops.begin + write = ops.write + commit = ops.commit + truncate = ops.truncate + ops.markReady() + + return { + loadSubset: () => { + loadCalls++ + return new Promise((resolve) => { + resolveLoad = () => { + begin() + write({ type: `insert`, value: SEED[0]! }) + commit() + resolve() + } + }) + }, + } + }, + }, + }) + + return { + collection, + syncOps: { + begin: () => begin(), + truncate: () => truncate(), + commit: () => commit(), + }, + resolveLoad: () => resolveLoad(), + loadCount: () => loadCalls, + } +} + describe(`createLiveQueryObserver`, () => { it(`exposes a stable snapshot of a ready collection (wholesale path)`, () => { const observer = createLiveQueryObserver(makeSource() as any) @@ -421,7 +470,7 @@ describe(`createLiveQueryObserver`, () => { observer.dispose() }) - it(`wholesale mode does not request an initial snapshot (no unfiltered loadSubset)`, () => { + it(`wholesale mode does not request an initial snapshot (no unfiltered loadSubset)`, async () => { const { collection, loadSubsetCalls, writeRow } = makeLoadSubsetSource() const observer = createLiveQueryObserver(collection as any, { mode: `wholesale`, @@ -439,10 +488,15 @@ describe(`createLiveQueryObserver`, () => { expect(observer.getSnapshot().data).toHaveLength(2) // Deltas — including deletes — still wake the consumer. - const notifiesBefore = notifies.length + const deltasBefore = notifies.filter( + (changes) => changes !== undefined, + ).length writeRow(`delete`, { id: `1`, name: `A` }) + await Promise.resolve() - expect(notifies.length).toBe(notifiesBefore + 1) + expect(notifies.filter((changes) => changes !== undefined)).toHaveLength( + deltasBefore + 1, + ) expect(observer.getSnapshot().data).toHaveLength(1) observer.dispose() }) @@ -463,17 +517,19 @@ describe(`createLiveQueryObserver`, () => { observer.dispose() }) - it(`does not enumerate entries for a status-only snapshot read`, () => { + it(`reuses captured entries for a status-only snapshot change`, () => { const source = makeSource() const observer = createLiveQueryObserver(source as any) const entriesSpy = vi.spyOn(source, `entries`) - expect(observer.getSnapshot().status).toBe(`ready`) - expect(entriesSpy).not.toHaveBeenCalled() - - // Materialization happens on first data/state access, once per revision. expect(observer.getSnapshot().data).toHaveLength(2) + expect(entriesSpy).toHaveBeenCalledTimes(1) + + source._lifecycle.setStatus(`error`) + + expect(observer.getSnapshot().status).toBe(`error`) expect(observer.getSnapshot().state?.size).toBe(2) + // Status-only changes reuse the immutable row capture. expect(entriesSpy).toHaveBeenCalledTimes(1) observer.dispose() }) @@ -531,4 +587,133 @@ describe(`createLiveQueryObserver`, () => { expect(observer.getSnapshot().status).toBe(`ready`) observer.dispose() }) + + it(`keeps an unread snapshot pinned to the revision when it was created`, () => { + const source = makeSource() + const observer = createLiveQueryObserver(source as any, { + mode: `wholesale`, + }) + const before = observer.getSnapshot() + + source.utils.begin() + source.utils.write({ type: `insert`, value: { id: `3`, name: `C` } }) + source.utils.commit() + + expect(before.data).toHaveLength(2) + expect(before.state?.has(`3`)).toBe(false) + observer.dispose() + }) + + it(`does not notify synchronously when wholesale subscribe activates an idle source`, () => { + const { collection } = makeLoadSubsetSource() + const observer = createLiveQueryObserver(collection as any, { + mode: `wholesale`, + }) + let insideSubscribe = true + let calledSynchronously = false + + const unsubscribe = observer.subscribe(() => { + if (insideSubscribe) calledSynchronously = true + }) + insideSubscribe = false + + expect(calledSynchronously).toBe(false) + unsubscribe() + observer.dispose() + }) + + it(`does not reenter a late subscriber while delivering its seed`, () => { + const source = makeSource() + const observer = createLiveQueryObserver(source as any) + observer.subscribe(() => {}) + let callbackDepth = 0 + let wasReentrant = false + let wrote = false + + observer.subscribe(() => { + callbackDepth++ + if (callbackDepth > 1) wasReentrant = true + if (!wrote) { + wrote = true + source.utils.begin() + source.utils.write({ type: `insert`, value: { id: `3`, name: `C` } }) + source.utils.commit() + } + callbackDepth-- + }) + + expect(wasReentrant).toBe(false) + observer.dispose() + }) + + it(`keeps waking consumers after collection cleanup clears event listeners`, () => { + const source = makeSource() + const observer = createLiveQueryObserver(source as any) + const statuses: Array = [] + observer.subscribe(() => statuses.push(observer.getSnapshot().status)) + + void source.cleanup() + source._lifecycle.setStatus(`error`) + + expect(statuses).toContain(`cleaned-up`) + expect(statuses).toContain(`error`) + observer.dispose() + }) + + it(`invalidates snapshots for compatible collections without a state revision`, () => { + const rows = new Map([[`1`, { id: `1`, name: `A` }]]) + const listeners = new Set< + (changes: Array>) => void + >() + const collection = { + status: `ready`, + entries: () => rows.entries(), + on: () => () => {}, + subscribeChanges: ( + listener: (changes: Array>) => void, + ) => { + listeners.add(listener) + return { unsubscribe: () => listeners.delete(listener) } + }, + preload: async () => {}, + } + const observer = createLiveQueryObserver(collection as any, { + mode: `wholesale`, + }) + observer.subscribe(() => {}) + const before = observer.getSnapshot() + + const row = { id: `2`, name: `B` } + rows.set(row.id, row) + for (const listener of listeners) { + listener([{ type: `insert`, key: row.id, value: row }]) + } + + const after = observer.getSnapshot() + expect(after).not.toBe(before) + expect(after.data).toHaveLength(2) + observer.dispose() + }) + + it(`does not expose a truncate while its subscription is buffering a refetch`, async () => { + const { collection, syncOps, resolveLoad, loadCount } = + makeControlledTruncateSource() + await collection.stateWhenReady() + + const observer = createLiveQueryObserver(collection as any) + observer.subscribe(() => {}) + + resolveLoad() + await vi.waitFor(() => expect(observer.getSnapshot().data).toHaveLength(1)) + const before = observer.getSnapshot() + + syncOps.begin() + syncOps.truncate() + syncOps.commit() + await vi.waitFor(() => expect(loadCount()).toBe(2)) + + expect(observer.getSnapshot()).toBe(before) + expect(observer.getSnapshot().data).toHaveLength(1) + observer.dispose() + }) }) diff --git a/packages/solid-db/src/useLiveQuery.ts b/packages/solid-db/src/useLiveQuery.ts index 9b53e1d88..cf8d84f43 100644 --- a/packages/solid-db/src/useLiveQuery.ts +++ b/packages/solid-db/src/useLiveQuery.ts @@ -424,12 +424,26 @@ export function useLiveQuery( break } } + } else { + // Cleanup and other status-only publications carry no row deltas. + // Rebuild the keyed view so it cannot diverge from ordered data. + state.clear() + for (const [key, value] of observer.getSnapshot().state ?? []) { + state.set(key, value) + } } syncDataFromCollection(currentCollection) setStatus(observer.getSnapshot().status) }) }, ) + // An already-ready empty collection produces no initial row batch. Bring + // ordered data and status in line synchronously instead of waiting for the + // resource continuation to correct the previous collection's rows. + batch(() => { + syncDataFromCollection(currentCollection) + setStatus(observer.getSnapshot().status) + }) onCleanup(() => { unsubscribe() diff --git a/packages/solid-db/tests/useLiveQuery.test.tsx b/packages/solid-db/tests/useLiveQuery.test.tsx index e1df013e4..debfe95a5 100644 --- a/packages/solid-db/tests/useLiveQuery.test.tsx +++ b/packages/solid-db/tests/useLiveQuery.test.tsx @@ -85,6 +85,37 @@ const initialIssues: Array = [ ] describe(`Query Collections`, () => { + it(`clears data immediately when switching to an already-ready empty collection`, async () => { + return createRoot(async (dispose) => { + const populated = createCollection( + mockSyncCollectionOptions({ + id: `solid-populated-switch`, + getKey: (person) => person.id, + initialData: initialPersons, + }), + ) + const empty = createCollection( + mockSyncCollectionOptions({ + id: `solid-empty-switch`, + getKey: (person) => person.id, + initialData: [], + }), + ) + populated.startSyncImmediate() + empty.startSyncImmediate() + + const [current, setCurrent] = createSignal(populated) + const result = useLiveQuery(current) + await waitFor(() => expect(result()).toHaveLength(3)) + + setCurrent(empty) + + expect(result()).toHaveLength(0) + expect(result.state.size).toBe(0) + dispose() + }) + }) + it(`should work with basic collection and select`, async () => { const collection = createCollection( mockSyncCollectionOptions({ diff --git a/packages/svelte-db/src/useLiveQuery.svelte.ts b/packages/svelte-db/src/useLiveQuery.svelte.ts index 71dc7a12a..0186bf054 100644 --- a/packages/svelte-db/src/useLiveQuery.svelte.ts +++ b/packages/svelte-db/src/useLiveQuery.svelte.ts @@ -436,6 +436,13 @@ export function useLiveQuery( break } } + } else { + // Cleanup and other status-only publications carry no row deltas. + // Rebuild the keyed view so it cannot diverge from ordered data. + state.clear() + for (const [key, value] of observer.getSnapshot().state ?? []) { + state.set(key, value) + } } }) syncFromObserver(observer, currentCollection) diff --git a/packages/svelte-db/tests/useLiveQuery.svelte.test.ts b/packages/svelte-db/tests/useLiveQuery.svelte.test.ts index cb16e8579..bb0b80764 100644 --- a/packages/svelte-db/tests/useLiveQuery.svelte.test.ts +++ b/packages/svelte-db/tests/useLiveQuery.svelte.test.ts @@ -81,6 +81,30 @@ describe(`Query Collections`, () => { cleanup?.() }) + it(`keeps data and keyed state aligned after collection cleanup`, () => { + const collection = createCollection( + mockSyncCollectionOptions({ + id: `cleanup-alignment-svelte`, + getKey: (person) => person.id, + initialData: initialPersons, + }), + ) + + cleanup = $effect.root(() => { + const query = useLiveQuery(collection) + flushSync() + + expect(query.data).toHaveLength(3) + expect(query.state.size).toBe(3) + + void collection.cleanup() + flushSync() + + expect(query.data).toHaveLength(0) + expect(query.state.size).toBe(0) + }) + }) + it(`should work with basic collection and select`, () => { const collection = createCollection( mockSyncCollectionOptions({ diff --git a/packages/vue-db/src/useLiveQuery.ts b/packages/vue-db/src/useLiveQuery.ts index c12fdb8ee..40a2fafb9 100644 --- a/packages/vue-db/src/useLiveQuery.ts +++ b/packages/vue-db/src/useLiveQuery.ts @@ -413,6 +413,13 @@ export function useLiveQuery( break } } + } else { + // Cleanup and other status-only publications carry no row deltas. + // Rebuild the keyed view so it cannot diverge from ordered data. + state.clear() + for (const [key, value] of observer.getSnapshot().state ?? []) { + state.set(key, value) + } } syncFromObserver(observer, currentCollection) }, diff --git a/packages/vue-db/tests/useLiveQuery.test.ts b/packages/vue-db/tests/useLiveQuery.test.ts index 57b8ae57b..657aec902 100644 --- a/packages/vue-db/tests/useLiveQuery.test.ts +++ b/packages/vue-db/tests/useLiveQuery.test.ts @@ -99,6 +99,27 @@ async function waitFor(fn: () => void, timeout = 2000, interval = 20) { } describe(`Query Collections`, () => { + it(`keeps data and keyed state aligned after collection cleanup`, async () => { + const collection = createCollection( + mockSyncCollectionOptions({ + id: `cleanup-alignment-vue`, + getKey: (person) => person.id, + initialData: initialPersons, + }), + ) + const result = useLiveQuery(collection) + + await waitForVueUpdate() + expect(result.data.value).toHaveLength(3) + expect(result.state.value.size).toBe(3) + + await collection.cleanup() + await nextTick() + + expect(result.data.value).toHaveLength(0) + expect(result.state.value.size).toBe(0) + }) + it(`should work with basic collection and select`, async () => { const collection = createCollection( mockSyncCollectionOptions({ From c4a72f9ebb22d8b545de91d1104244a29db2cb5f Mon Sep 17 00:00:00 2001 From: Tanner Linsley Date: Tue, 11 Aug 2026 15:59:41 -0600 Subject: [PATCH 32/42] fix(db): preserve wholesale consistency reads --- packages/angular-db/src/index.ts | 6 +- packages/db/src/live-query-observer.ts | 61 +++++++++++-------- packages/db/tests/live-query-observer.test.ts | 7 ++- 3 files changed, 43 insertions(+), 31 deletions(-) diff --git a/packages/angular-db/src/index.ts b/packages/angular-db/src/index.ts index 392bfc6d7..ba579c31a 100644 --- a/packages/angular-db/src/index.ts +++ b/packages/angular-db/src/index.ts @@ -258,12 +258,12 @@ export function injectLiveQuery(opts: any) { mode: `wholesale`, }) - // Seed immediately from the post-start state, then re-read on every notify. - syncDataFromCollection(currentCollection) - const unsubscribe = observer.subscribe(() => { syncDataFromCollection(currentCollection) }) + // Wholesale attach suppresses listener calls raised by synchronous sync + // startup. Read once after subscribe returns to capture that final state. + syncDataFromCollection(currentCollection) unsub = () => { unsubscribe() observer.dispose() diff --git a/packages/db/src/live-query-observer.ts b/packages/db/src/live-query-observer.ts index 595e194b7..4b597965a 100644 --- a/packages/db/src/live-query-observer.ts +++ b/packages/db/src/live-query-observer.ts @@ -120,7 +120,6 @@ class LiveQueryObserverImpl< // synchronously) is queued, never delivered reentrantly. private readonly publicationQueue: Array> = [] private dispatching = false - private deliveryScheduled = false private blockDelivery = false private attached = false private collectionUnsub: (() => void) | null = null @@ -182,9 +181,23 @@ class LiveQueryObserverImpl< revision?: number } { const { entries, revision } = this.readEntries(collection) + this.updateCachedEntries(entries, revision) + return { entries, revision } + } + + private updateCachedEntries( + entries: Array<[TKey, T]>, + revision: number | undefined, + ): void { + const changed = + revision !== undefined + ? this.cachedEntries === undefined || + revision !== this.cachedCollectionRevision + : !this.entriesEqual(this.cachedEntries, entries) + this.cachedEntries = entries this.cachedCollectionRevision = revision - return { entries, revision } + if (changed) this.snapshotDirty = true } private entriesEqual( @@ -217,10 +230,7 @@ class LiveQueryObserverImpl< } } else { const entries = Array.from(collection.entries()) as Array<[TKey, T]> - if (!this.entriesEqual(this.cachedEntries, entries)) { - this.cachedEntries = entries - this.snapshotDirty = true - } + this.updateCachedEntries(entries, undefined) } if (this.visibleStatus !== status) { @@ -345,8 +355,14 @@ class LiveQueryObserverImpl< subscription.unsubscribe() return } - if (this.publicationQueue.length > 0 && this.wholesale) { - this.scheduleDelivery() + if (this.wholesale) { + // Publications raised while subscribeChanges starts sync are part of the + // subscribe handshake. Apply their final snapshot state now, but suppress + // listener delivery: useSyncExternalStore performs its consistency read + // immediately after subscribe returns. + this.flushPublications(false) + const { entries, revision } = this.readEntries(collection) + this.updateCachedEntries(entries, revision) } } @@ -372,21 +388,12 @@ class LiveQueryObserverImpl< status, collectionRevision, }) - if (this.dispatching || this.blockDelivery || this.deliveryScheduled) return + if (this.dispatching || this.blockDelivery) return this.flushPublications() } - private scheduleDelivery(): void { - if (this.deliveryScheduled) return - this.deliveryScheduled = true - queueMicrotask(() => { - this.deliveryScheduled = false - if (!this.disposed && !this.blockDelivery) this.flushPublications() - }) - } - - private flushPublications(): void { + private flushPublications(deliver = true): void { if (this.dispatching) return this.dispatching = true @@ -395,9 +402,10 @@ class LiveQueryObserverImpl< while (this.publicationQueue.length > 0) { const publication = this.publicationQueue.shift()! if (publication.entries) { - this.cachedEntries = publication.entries - this.cachedCollectionRevision = publication.collectionRevision - this.snapshotDirty = true + this.updateCachedEntries( + publication.entries, + publication.collectionRevision, + ) } if (this.visibleStatus !== publication.status) { this.visibleStatus = publication.status @@ -406,9 +414,11 @@ class LiveQueryObserverImpl< // Targets are captured when the publication is queued: a subscription // removed mid-delivery still receives the in-flight publication, and // one added later does not. Late-subscriber seeds use the same queue. - for (const subRecord of publication.targets) { - if (this.disposed) return - subRecord.listener(publication.changes) + if (deliver) { + for (const subRecord of publication.targets) { + if (this.disposed) return + subRecord.listener(publication.changes) + } } } } finally { @@ -427,7 +437,6 @@ class LiveQueryObserverImpl< for (const subRecord of this.subscriptions) subRecord.active = false this.subscriptions.clear() this.publicationQueue.length = 0 - this.deliveryScheduled = false } } diff --git a/packages/db/tests/live-query-observer.test.ts b/packages/db/tests/live-query-observer.test.ts index e3b1788cf..1c537dc0b 100644 --- a/packages/db/tests/live-query-observer.test.ts +++ b/packages/db/tests/live-query-observer.test.ts @@ -470,7 +470,7 @@ describe(`createLiveQueryObserver`, () => { observer.dispose() }) - it(`wholesale mode does not request an initial snapshot (no unfiltered loadSubset)`, async () => { + it(`wholesale mode does not request an initial snapshot (no unfiltered loadSubset)`, () => { const { collection, loadSubsetCalls, writeRow } = makeLoadSubsetSource() const observer = createLiveQueryObserver(collection as any, { mode: `wholesale`, @@ -492,7 +492,6 @@ describe(`createLiveQueryObserver`, () => { (changes) => changes !== undefined, ).length writeRow(`delete`, { id: `1`, name: `A` }) - await Promise.resolve() expect(notifies.filter((changes) => changes !== undefined)).toHaveLength( deltasBefore + 1, @@ -609,6 +608,7 @@ describe(`createLiveQueryObserver`, () => { const observer = createLiveQueryObserver(collection as any, { mode: `wholesale`, }) + const before = observer.getSnapshot() let insideSubscribe = true let calledSynchronously = false @@ -618,6 +618,9 @@ describe(`createLiveQueryObserver`, () => { insideSubscribe = false expect(calledSynchronously).toBe(false) + expect(observer.getSnapshot()).not.toBe(before) + expect(observer.getSnapshot().status).toBe(`ready`) + expect(observer.getSnapshot().data).toHaveLength(2) unsubscribe() observer.dispose() }) From c91af8180152ff482dbcd9ed5e6527edeae1aadd Mon Sep 17 00:00:00 2001 From: Tanner Linsley Date: Tue, 11 Aug 2026 16:02:46 -0600 Subject: [PATCH 33/42] fix(db): capture granular initial loads --- packages/db/src/live-query-observer.ts | 5 +---- packages/db/tests/live-query-observer.test.ts | 3 +++ 2 files changed, 4 insertions(+), 4 deletions(-) diff --git a/packages/db/src/live-query-observer.ts b/packages/db/src/live-query-observer.ts index 4b597965a..40028fefb 100644 --- a/packages/db/src/live-query-observer.ts +++ b/packages/db/src/live-query-observer.ts @@ -299,7 +299,6 @@ class LiveQueryObserverImpl< // no unfiltered loadSubset({ where: undefined }) against on-demand // collections. The explicit `false` marks all state as seen so deletes // still flow through as notifies. - let receivingInitialState = true const notify = ( changes: Array> | undefined, status: CollectionStatus = collection.status, @@ -309,9 +308,8 @@ class LiveQueryObserverImpl< // empty-ready flush); only real deltas and the synthetic ready notify // (undefined) are published. if (changes !== undefined && changes.length === 0) return - const isInitialReplay = receivingInitialState && changes !== undefined const captured = - changes !== undefined && !isInitialReplay + changes !== undefined ? this.readEntries(collection) : status === `cleaned-up` ? this.readEntries(collection) @@ -349,7 +347,6 @@ class LiveQueryObserverImpl< (changes) => notify(changes as Array>), { includeInitialState: !this.wholesale }, ) - receivingInitialState = false this.blockDelivery = false if (this.collectionUnsub !== release) { subscription.unsubscribe() diff --git a/packages/db/tests/live-query-observer.test.ts b/packages/db/tests/live-query-observer.test.ts index 1c537dc0b..d73682c2c 100644 --- a/packages/db/tests/live-query-observer.test.ts +++ b/packages/db/tests/live-query-observer.test.ts @@ -503,6 +503,7 @@ describe(`createLiveQueryObserver`, () => { it(`granular mode still seeds from an initial snapshot`, () => { const { collection, loadSubsetCalls } = makeLoadSubsetSource() const observer = createLiveQueryObserver(collection as any) + const before = observer.getSnapshot() const inserted: Array = [] observer.subscribe((changes) => { @@ -513,6 +514,8 @@ describe(`createLiveQueryObserver`, () => { expect(inserted.sort()).toEqual([`1`, `2`]) expect(loadSubsetCalls).toHaveLength(1) + expect(observer.getSnapshot()).not.toBe(before) + expect(observer.getSnapshot().data).toHaveLength(2) observer.dispose() }) From 6db5b1aeb42d7385713a4d1a28704299a8b8fc3b Mon Sep 17 00:00:00 2001 From: Tanner Linsley Date: Tue, 11 Aug 2026 17:21:37 -0600 Subject: [PATCH 34/42] fix(db): harden live query window subscriptions --- packages/db/src/errors.ts | 6 + .../db/src/live-query-window-controller.ts | 91 ++++++++++--- .../live-query-window-controller.test.ts | 122 +++++++++++++++++- packages/react-db/src/useLiveInfiniteQuery.ts | 4 +- 4 files changed, 204 insertions(+), 19 deletions(-) diff --git a/packages/db/src/errors.ts b/packages/db/src/errors.ts index 710025b65..0281b13af 100644 --- a/packages/db/src/errors.ts +++ b/packages/db/src/errors.ts @@ -141,6 +141,12 @@ export class LiveQueryObserverDisposedError extends CollectionStateError { } } +export class LiveQueryWindowControllerDisposedError extends CollectionStateError { + constructor() { + super(`Cannot subscribe to a disposed LiveQueryWindowController`) + } +} + // Collection Operation Errors export class CollectionOperationError extends TanStackDBError { constructor(message: string) { diff --git a/packages/db/src/live-query-window-controller.ts b/packages/db/src/live-query-window-controller.ts index 8c40ed585..5e048280b 100644 --- a/packages/db/src/live-query-window-controller.ts +++ b/packages/db/src/live-query-window-controller.ts @@ -1,3 +1,4 @@ +import { LiveQueryWindowControllerDisposedError } from './errors.js' import { createLiveQueryObserver } from './live-query-observer.js' import type { CreateLiveQueryObserverOptions, @@ -83,6 +84,15 @@ interface CachedFrom { isFetchingNextPage: boolean } +interface SubscriptionRecord { + listener: () => void + active: boolean +} + +interface Publication { + targets: Array +} + class LiveQueryWindowControllerImpl< T extends object, TKey extends string | number, @@ -92,6 +102,7 @@ class LiveQueryWindowControllerImpl< private readonly pageSize: number private readonly initialPageParam: number private readonly waitForReady: boolean + private readonly wholesale: boolean private loadedPageCount = 1 private isFetchingNextPage = false @@ -102,7 +113,10 @@ class LiveQueryWindowControllerImpl< // the fetching flag for a window that no longer applies. private windowGeneration = 0 - private readonly listeners = new Set<() => void>() + private readonly subscriptions = new Set() + private readonly publicationQueue: Array = [] + private dispatching = false + private blockDelivery = false private observerUnsub: (() => void) | null = null private cachedSnapshot: LiveQueryWindowSnapshot | null = null private cachedFrom: CachedFrom | null = null @@ -116,8 +130,9 @@ class LiveQueryWindowControllerImpl< this.pageSize = options.pageSize || DEFAULT_PAGE_SIZE this.initialPageParam = options.initialPageParam ?? 0 this.waitForReady = options.waitForReady ?? false + this.wholesale = options.mode === `wholesale` this.observer = createLiveQueryObserver(collection, { - deferInitialNotify: options.deferInitialNotify, + mode: options.mode, }) } @@ -179,21 +194,41 @@ class LiveQueryWindowControllerImpl< } subscribe(listener: () => void): () => void { - this.listeners.add(listener) - if (this.listeners.size === 1) { - this.observerUnsub = this.observer.subscribe(() => - this.onObserverNotify(), - ) - // Establish the current window now that the query is active. - this.applyWindow() + if (this.disposed) throw new LiveQueryWindowControllerDisposedError() + + const record: SubscriptionRecord = { listener, active: true } + this.subscriptions.add(record) + if (this.subscriptions.size === 1) { + // A wholesale subscriber re-reads the snapshot immediately after + // subscribing, so setup publications are redundant and must not fire + // inside useSyncExternalStore's subscribe call. + this.blockDelivery = this.wholesale + let observerUnsub: (() => void) | null = null + try { + observerUnsub = this.observer.subscribe(() => this.onObserverNotify()) + if (this.hasBeenDisposed()) { + observerUnsub() + } else { + this.observerUnsub = observerUnsub + // Establish the current window now that the query is active. + this.applyWindow() + } + } catch (error) { + observerUnsub?.() + this.observerUnsub = null + record.active = false + this.subscriptions.delete(record) + throw error + } finally { + this.blockDelivery = false + } } - let active = true return () => { - if (!active) return - active = false - this.listeners.delete(listener) - if (this.listeners.size === 0) { + if (!record.active) return + record.active = false + this.subscriptions.delete(record) + if (this.subscriptions.size === 0) { this.observerUnsub?.() this.observerUnsub = null } @@ -230,7 +265,9 @@ class LiveQueryWindowControllerImpl< this.observerUnsub?.() this.observerUnsub = null this.observer.dispose() - this.listeners.clear() + for (const record of this.subscriptions) record.active = false + this.subscriptions.clear() + this.publicationQueue.length = 0 } private onObserverNotify(): void { @@ -287,7 +324,29 @@ class LiveQueryWindowControllerImpl< } private notify(): void { - this.listeners.forEach((listener) => listener()) + if (this.disposed || this.blockDelivery || this.subscriptions.size === 0) { + return + } + + this.publicationQueue.push({ targets: [...this.subscriptions] }) + if (this.dispatching) return + + this.dispatching = true + try { + while (this.publicationQueue.length > 0) { + const publication = this.publicationQueue.shift()! + for (const record of publication.targets) { + if (this.hasBeenDisposed()) return + record.listener() + } + } + } finally { + this.dispatching = false + } + } + + private hasBeenDisposed(): boolean { + return this.disposed } } diff --git a/packages/db/tests/live-query-window-controller.test.ts b/packages/db/tests/live-query-window-controller.test.ts index 59ab46a41..983803d0a 100644 --- a/packages/db/tests/live-query-window-controller.test.ts +++ b/packages/db/tests/live-query-window-controller.test.ts @@ -1,6 +1,7 @@ -import { describe, expect, it } from 'vitest' +import { describe, expect, it, vi } from 'vitest' import { createCollection } from '../src/collection/index.js' import { createLiveQueryCollection } from '../src/query/live-query-collection.js' +import { LiveQueryWindowControllerDisposedError } from '../src/errors.js' import { createLiveQueryWindowController } from '../src/live-query-window-controller.js' import { mockSyncCollectionOptions } from './utils.js' @@ -153,6 +154,125 @@ describe(`createLiveQueryWindowController`, () => { controller.dispose() }) + it(`does not notify synchronously while subscribing`, () => { + const lq = makeOrderedLiveQuery(makeSource(), 2) + vi.spyOn(lq.utils, `setWindow`).mockReturnValue(new Promise(() => {})) + const controller = createLiveQueryWindowController(lq as any, { + pageSize: 2, + mode: `wholesale`, + }) + let subscribing = true + let notifiedWhileSubscribing = false + + const unsubscribe = controller.subscribe(() => { + if (subscribing) notifiedWhileSubscribing = true + }) + subscribing = false + + expect(notifiedWhileSubscribing).toBe(false) + unsubscribe() + controller.dispose() + }) + + it(`keeps duplicate callback subscriptions independent`, async () => { + const lq = makeOrderedLiveQuery(makeSource(), 2) + const controller = createLiveQueryWindowController(lq as any, { + pageSize: 2, + }) + let notifications = 0 + const listener = () => notifications++ + const unsubscribeFirst = controller.subscribe(listener) + const unsubscribeSecond = controller.subscribe(listener) + await lq.preload() + await flush() + + notifications = 0 + unsubscribeFirst() + controller.fetchNextPage() + await flush() + + expect(notifications).toBeGreaterThan(0) + unsubscribeSecond() + controller.dispose() + }) + + it(`does not deliver an in-flight notification to a late subscriber`, async () => { + const lq = makeOrderedLiveQuery(makeSource(), 2) + const controller = createLiveQueryWindowController(lq as any, { + pageSize: 2, + }) + let publishing = false + let lateNotifications = 0 + let unsubscribeLate: (() => void) | undefined + const unsubscribeFirst = controller.subscribe(() => { + if (publishing && !unsubscribeLate) { + unsubscribeLate = controller.subscribe(() => lateNotifications++) + } + }) + await lq.preload() + await flush() + vi.spyOn(lq.utils, `setWindow`).mockReturnValue(true) + + publishing = true + controller.fetchNextPage() + publishing = false + + expect(lateNotifications).toBe(0) + unsubscribeLate?.() + unsubscribeFirst() + controller.dispose() + }) + + it(`rejects every subscription after disposal`, () => { + const controller = createLiveQueryWindowController( + makeOrderedLiveQuery(makeSource(), 2) as any, + { pageSize: 2 }, + ) + controller.dispose() + + expect(() => controller.subscribe(() => {})).toThrow( + LiveQueryWindowControllerDisposedError, + ) + expect(() => controller.subscribe(() => {})).toThrow( + LiveQueryWindowControllerDisposedError, + ) + }) + + it(`releases subscriptions when disposed during initial replay`, () => { + const lq = makeOrderedLiveQuery(makeSource(), 2) + const controller = createLiveQueryWindowController(lq as any, { + pageSize: 2, + }) + + controller.subscribe(() => controller.dispose()) + + expect(lq.subscriberCount).toBe(0) + }) + + it(`stops an in-flight publication when a listener disposes`, async () => { + const lq = makeOrderedLiveQuery(makeSource(), 2) + const controller = createLiveQueryWindowController(lq as any, { + pageSize: 2, + }) + let publishing = false + let secondListenerNotifications = 0 + controller.subscribe(() => { + if (publishing) controller.dispose() + }) + controller.subscribe(() => { + if (publishing) secondListenerNotifications++ + }) + await lq.preload() + await flush() + vi.spyOn(lq.utils, `setWindow`).mockReturnValue(true) + + publishing = true + controller.fetchNextPage() + publishing = false + + expect(secondListenerNotifications).toBe(0) + }) + it(`returns a stable snapshot identity when nothing changed`, async () => { const lq = makeOrderedLiveQuery(makeSource(), 2) const controller = createLiveQueryWindowController(lq as any, { diff --git a/packages/react-db/src/useLiveInfiniteQuery.ts b/packages/react-db/src/useLiveInfiniteQuery.ts index 00a524652..2704608d3 100644 --- a/packages/react-db/src/useLiveInfiniteQuery.ts +++ b/packages/react-db/src/useLiveInfiniteQuery.ts @@ -232,8 +232,8 @@ export function useLiveInfiniteQuery( { pageSize, initialPageParam, - // useSyncExternalStore must not be notified synchronously on subscribe. - deferInitialNotify: true, + // Wholesale mode provides useSyncExternalStore's no-sync-notify contract. + mode: 'wholesale', // A query-function collection already carries page 1's window in its // query, so defer the (redundant) first apply until it is ready; a // pre-created collection needs its window established up front. From 619c3a37f1843df0094008351c1542db164a114a Mon Sep 17 00:00:00 2001 From: Tanner Linsley Date: Tue, 11 Aug 2026 17:41:26 -0600 Subject: [PATCH 35/42] fix(db): bind layout revisions to sync transactions --- packages/db/src/collection/changes.ts | 19 +----- packages/db/src/collection/index.ts | 4 +- packages/db/src/collection/state.ts | 6 +- packages/db/src/collection/sync.ts | 17 +++++ .../tests/live-query-order-only-move.test.ts | 64 +++++++++++++++++++ 5 files changed, 90 insertions(+), 20 deletions(-) diff --git a/packages/db/src/collection/changes.ts b/packages/db/src/collection/changes.ts index 44ac43949..87d092f9b 100644 --- a/packages/db/src/collection/changes.ts +++ b/packages/db/src/collection/changes.ts @@ -29,7 +29,6 @@ export class CollectionChangesManager< public changeSubscriptions = new Set() public batchedEvents: Array> = [] public shouldBatchEvents = false - private pendingLayoutChange = false /** * Monotonic revision of the collection's visible state, advanced once per @@ -77,17 +76,6 @@ export class CollectionChangesManager< } } - /** - * Mark the next committed publication as layout-changing. Each subscription - * receives its filtered row batch once, or one empty batch when filtering - * removed every row change, so ordered consumers always re-read without a - * duplicate callback or a forged row `update`. - */ - public markLayoutChange(): void { - this.layoutRevision++ - this.pendingLayoutChange = true - } - /** * Enriches a change message with virtual properties ($synced, $origin, $key, $collectionId). * Uses the "add-if-missing" pattern to preserve virtual properties from upstream collections. @@ -104,10 +92,12 @@ export class CollectionChangesManager< public emitEvents( changes: Array>, forceEmit = false, + layoutChanged = false, ): void { // The visible state was already committed by the caller, so the revision // advances even when the events below end up batched for later emission. if (changes.length > 0) this.stateRevision++ + if (layoutChanged) this.layoutRevision++ // Skip batching for user actions (forceEmit=true) to keep UI responsive if (this.shouldBatchEvents && !forceEmit) { @@ -130,15 +120,10 @@ export class CollectionChangesManager< this.shouldBatchEvents = false } - const layoutChanged = this.pendingLayoutChange if (rawEvents.length === 0 && !layoutChanged) { return } - // Clear before notifying so a nested publication can mark a new layout - // change without this flush erasing it. - this.pendingLayoutChange = false - // Enrich all change messages with virtual properties // This uses the "add-if-missing" pattern to preserve pass-through semantics const enrichedEvents: Array< diff --git a/packages/db/src/collection/index.ts b/packages/db/src/collection/index.ts index 20d3fbd6b..c775e7ada 100644 --- a/packages/db/src/collection/index.ts +++ b/packages/db/src/collection/index.ts @@ -437,9 +437,9 @@ export class CollectionImpl< return this._changes.layoutRevision } - /** Mark the next committed publication as layout-changing. Internal. */ + /** Mark the active sync transaction as layout-changing. Internal. */ public _markLayoutChange(): void { - this._changes.markLayoutChange() + this._sync.markLayoutChange() } /** diff --git a/packages/db/src/collection/state.ts b/packages/db/src/collection/state.ts index 0f7b3b868..1eeb92b2d 100644 --- a/packages/db/src/collection/state.ts +++ b/packages/db/src/collection/state.ts @@ -25,6 +25,7 @@ interface PendingSyncedTransaction< TKey extends string | number = string | number, > { committed: boolean + layoutChanged: boolean operations: Array> truncate?: boolean deletedKeys: Set @@ -836,10 +837,12 @@ export class CollectionStateManager< uncommittedSyncedTransactions, hasTruncateSync, hasImmediateSync, + layoutChanged, } = this.pendingSyncedTransactions.reduce( (acc, t) => { if (t.committed) { acc.committedSyncedTransactions.push(t) + acc.layoutChanged ||= t.layoutChanged if (t.truncate) { acc.hasTruncateSync = true } @@ -860,6 +863,7 @@ export class CollectionStateManager< >, hasTruncateSync: false, hasImmediateSync: false, + layoutChanged: false, }, ) @@ -1331,7 +1335,7 @@ export class CollectionStateManager< } // End batching and emit all events (combines any batched events with sync events) - this.changes.emitEvents(events, true) + this.changes.emitEvents(events, true, layoutChanged) this.pendingSyncedTransactions = uncommittedSyncedTransactions diff --git a/packages/db/src/collection/sync.ts b/packages/db/src/collection/sync.ts index af89ed2cf..a6539ce90 100644 --- a/packages/db/src/collection/sync.ts +++ b/packages/db/src/collection/sync.ts @@ -71,6 +71,22 @@ export class CollectionSyncManager< this._events = deps.events } + /** Mark the active sync transaction as changing collection layout. */ + public markLayoutChange(): void { + const pendingTransaction = + this.state.pendingSyncedTransactions[ + this.state.pendingSyncedTransactions.length - 1 + ] + if (!pendingTransaction) { + throw new NoPendingSyncTransactionWriteError() + } + if (pendingTransaction.committed) { + throw new SyncTransactionAlreadyCommittedWriteError() + } + + pendingTransaction.layoutChanged = true + } + /** * Start the sync process for this collection * This is called when the collection is first accessed or preloaded @@ -92,6 +108,7 @@ export class CollectionSyncManager< begin: (options?: { immediate?: boolean }) => { this.state.pendingSyncedTransactions.push({ committed: false, + layoutChanged: false, operations: [], deletedKeys: new Set(), rowMetadataWrites: new Map(), diff --git a/packages/db/tests/live-query-order-only-move.test.ts b/packages/db/tests/live-query-order-only-move.test.ts index c94bcce88..2637bf8a3 100644 --- a/packages/db/tests/live-query-order-only-move.test.ts +++ b/packages/db/tests/live-query-order-only-move.test.ts @@ -1,5 +1,6 @@ import { describe, expect, it } from 'vitest' import { createCollection } from '../src/collection/index.js' +import { createDeferred } from '../src/deferred.js' import { createLiveQueryCollection } from '../src/query/live-query-collection.js' import { createLiveQueryObserver } from '../src/live-query-observer.js' import { eq } from '../src/query/builder/functions.js' @@ -116,6 +117,69 @@ describe(`order-only move (RFC #1623 phase 4)`, () => { observer.dispose() }) + it(`refreshes a detached observer when an order-only sync is parked`, async () => { + const source = makeSource() + const persist = createDeferred() + const lq = createLiveQueryCollection({ + getKey: (row) => row.id, + query: (q) => + q + .from({ p: source }) + .orderBy(({ p }) => p.age, `asc`) + .select(({ p }) => ({ id: p.id, name: p.name })), + onUpdate: () => persist.promise, + }) + await lq.preload() + const observer = createLiveQueryObserver< + { id: string; name: string }, + string + >(lq as any) + + const before = observer.getSnapshot() + const collectionLayoutRevisionBefore = lq._layoutRevision + expect((before.data as Array).map((row) => row.id)).toEqual([ + `2`, + `1`, + `3`, + ]) + + const mutation = lq.update( + `1`, + { optimistic: false }, + (draft) => void (draft.name = `Pending`), + ) + expect(mutation.state).toBe(`persisting`) + + source.utils.begin() + source.utils.write({ + type: `update`, + value: { id: `2`, name: `Bob`, age: 99 }, + }) + source.utils.commit() + await flush() + + const parked = observer.getSnapshot() + expect((parked.data as Array).map((row) => row.id)).toEqual([ + `2`, + `1`, + `3`, + ]) + expect(lq._layoutRevision).toBe(collectionLayoutRevisionBefore) + + persist.resolve() + await mutation.isPersisted.promise + await flush() + + const after = observer.getSnapshot() + expect((after.data as Array).map((row) => row.id)).toEqual([ + `1`, + `3`, + `2`, + ]) + expect(lq._layoutRevision).toBeGreaterThan(collectionLayoutRevisionBefore) + observer.dispose() + }) + it(`does not bump the layout revision when nothing about the layout changes`, async () => { const source = makeSource() const lq = await makeOrderedByAge(source) From c27d57213d7661964b5556f8acf1df17394bf610 Mon Sep 17 00:00:00 2001 From: Tanner Linsley Date: Tue, 11 Aug 2026 17:55:13 -0600 Subject: [PATCH 36/42] fix(db): address post-merge review feedback --- .changeset/live-query-observer.md | 4 ++-- packages/db/src/collection/sync.ts | 13 +------------ .../tests/query/includes-oracle.property.test.ts | 14 +++++++------- 3 files changed, 10 insertions(+), 21 deletions(-) diff --git a/.changeset/live-query-observer.md b/.changeset/live-query-observer.md index 95565b091..62e747700 100644 --- a/.changeset/live-query-observer.md +++ b/.changeset/live-query-observer.md @@ -9,8 +9,8 @@ Add an internal shared live-query observer and migrate all five framework adapters to it -Introduces `createLiveQueryObserver` in `@tanstack/db`: given a resolved live-query collection (or `null` for a disabled query) it owns the lifecycle every adapter used to re-implement — sync activation on first subscribe, change and status subscriptions, a snapshot with stable identity per state revision for wholesale consumers, and delivery of the raw `ChangeMessage[]` for granular consumers. React, Vue, Svelte, Solid, and Angular's live-query hooks now materialize from the observer instead of their own hand-rolled subscription/status/snapshot machinery, keeping each adapter's native reactivity and each adapter's data-loading policy (wholesale adapters subscribe without initial state; granular adapters seed from it). +Introduces `createLiveQueryObserver` in `@tanstack/db`: given a resolved live-query collection (or `null` for a disabled query) it owns the subscription lifecycle every adapter used to re-implement — change and status subscriptions, a snapshot with stable identity per state revision for wholesale consumers, and delivery of the raw `ChangeMessage[]` for granular consumers. React, Vue, Svelte, Solid, and Angular's live-query hooks now materialize from the observer instead of their own hand-rolled subscription/status/snapshot machinery, keeping each adapter's native reactivity and each adapter's data-loading policy (wholesale adapters subscribe without initial state; granular adapters seed from it). The observer is an **internal, unstable contract** for TanStack DB's official adapters — it is exported so the adapter packages can consume it, but it is not a public extension point yet and its API may change in any release. -The migration also fixes several live-query lifecycle defects: status-only transitions (`error`, `cleaned-up`) now reach mounted consumers; snapshot identity is stable across unsubscribe/resubscribe and stays fresh while detached; dispatch is FIFO and non-reentrant with subscriptions identified by record rather than callback; disposing during the synchronous initial replay no longer leaks the collection subscription; subscribing after dispose throws instead of registering a dead listener; Solid guards its async resource continuations against superseded collections; and constructing an observer no longer activates sync (activation belongs to the first committed subscription). +The migration also fixes several live-query lifecycle defects: status-only transitions (`error`, `cleaned-up`) now reach mounted consumers; snapshot identity is stable across unsubscribe/resubscribe and stays fresh while detached; dispatch is FIFO and non-reentrant with subscriptions identified by record rather than callback; disposing during the synchronous initial replay no longer leaks the collection subscription; subscribing after dispose throws instead of registering a dead listener; Solid guards its async resource continuations against superseded collections; and constructing an observer no longer activates sync. Observers activate on their first committed subscription unless an adapter has already started a pre-created collection supplied directly or returned from a callback. diff --git a/packages/db/src/collection/sync.ts b/packages/db/src/collection/sync.ts index a6539ce90..8fa49b163 100644 --- a/packages/db/src/collection/sync.ts +++ b/packages/db/src/collection/sync.ts @@ -73,18 +73,7 @@ export class CollectionSyncManager< /** Mark the active sync transaction as changing collection layout. */ public markLayoutChange(): void { - const pendingTransaction = - this.state.pendingSyncedTransactions[ - this.state.pendingSyncedTransactions.length - 1 - ] - if (!pendingTransaction) { - throw new NoPendingSyncTransactionWriteError() - } - if (pendingTransaction.committed) { - throw new SyncTransactionAlreadyCommittedWriteError() - } - - pendingTransaction.layoutChanged = true + this.getActivePendingSyncTransaction().layoutChanged = true } /** diff --git a/packages/db/tests/query/includes-oracle.property.test.ts b/packages/db/tests/query/includes-oracle.property.test.ts index 0239bd3df..a69fde2da 100644 --- a/packages/db/tests/query/includes-oracle.property.test.ts +++ b/packages/db/tests/query/includes-oracle.property.test.ts @@ -1198,16 +1198,16 @@ describe(`includes recompute oracle`, () => { }, ) - // These known failures must reject with the oracle's assertion mismatch. - // A fixed bug or an unrelated runtime error makes the matching test fail. fcTest.prop([fc.constant(confirmedChildReorderSeed)], { numRuns: 1, seed: 2051245230, })( - `discovered seed: confirmed child reorder matches recomputation`, - expectAssertionFailure(expectScenarioMatches), + `regression seed: confirmed child reorder matches recomputation`, + expectScenarioMatches, ) + // These known failures must reject with the oracle's assertion mismatch. + // A fixed bug or an unrelated runtime error makes the matching test fail. fcTest.prop([fc.constant(sharedMaterializeSeed)], { numRuns: 1, seed: 1685, @@ -1348,8 +1348,8 @@ describe(`includes recompute oracle`, () => { ) fcTest.prop([fc.constant(`#1444`)], { numRuns: 1, seed: 1444 })( - `known seed: optimistic child reorder matches recomputation`, - expectAssertionFailure(async () => { + `regression seed: optimistic child reorder matches recomputation`, + async () => { const roots = createControlledCollection(`order-seed-roots`, [ { id: 1, group: 1, value: 0, position: 0 }, ]) @@ -1411,6 +1411,6 @@ describe(`includes recompute oracle`, () => { children.collection.cleanup(), ]) } - }), + }, ) }) From 1bf883f48fc0311c983fefbf1146570aeb9d1782 Mon Sep 17 00:00:00 2001 From: Tanner Linsley Date: Tue, 11 Aug 2026 20:45:39 -0600 Subject: [PATCH 37/42] fix(db): harden live query window ownership --- .changeset/live-query-window-controller.md | 14 +- packages/db/src/index.ts | 1 + .../db/src/live-query-window-controller.ts | 518 +++++++++++++----- .../query/live/collection-config-builder.ts | 21 +- .../live-query-window-controller.test.ts | 290 +++++++++- packages/react-db/src/useLiveInfiniteQuery.ts | 113 ++-- .../tests/useLiveInfiniteQuery.test.tsx | 201 ++++++- 7 files changed, 936 insertions(+), 222 deletions(-) diff --git a/.changeset/live-query-window-controller.md b/.changeset/live-query-window-controller.md index bdf4f7a5a..8688684f7 100644 --- a/.changeset/live-query-window-controller.md +++ b/.changeset/live-query-window-controller.md @@ -3,12 +3,10 @@ '@tanstack/react-db': patch --- -feat(db): shared live-query window controller for infinite queries +feat(db): internal shared live-query window controller for infinite queries -Adds `createLiveQueryWindowController` to `@tanstack/db` — the framework-agnostic -forward-pagination state machine (loaded-page count, peek-ahead window via -`setWindow`, page slicing, `hasNextPage`/`isFetchingNextPage`) that composes the -live-query observer. `react-db`'s `useLiveInfiniteQuery` is reimplemented as a -thin binding over it with no public API change, so other framework adapters can -build infinite queries on the same shared semantics instead of re-porting the -React hook. +Adds the unstable, `@internal` `createLiveQueryWindowController` adapter +primitive to `@tanstack/db`. It owns forward pagination, collection-scoped +window leases, transactional page commits, and failure/retry state while the +RFC contract is finalized. `react-db`'s `useLiveInfiniteQuery` becomes a thin +binding over it with no public API change. diff --git a/packages/db/src/index.ts b/packages/db/src/index.ts index 9958d56be..80814640d 100644 --- a/packages/db/src/index.ts +++ b/packages/db/src/index.ts @@ -12,6 +12,7 @@ export * from './query/index.js' export * from './optimistic-action' export * from './live-query-adapter' export * from './live-query-observer' +/** @internal Unstable adapter primitive for RFC #1623. */ export * from './live-query-window-controller' export * from './local-only' export * from './local-storage' diff --git a/packages/db/src/live-query-window-controller.ts b/packages/db/src/live-query-window-controller.ts index 5e048280b..9e7663a5c 100644 --- a/packages/db/src/live-query-window-controller.ts +++ b/packages/db/src/live-query-window-controller.ts @@ -1,37 +1,165 @@ -import { LiveQueryWindowControllerDisposedError } from './errors.js' +import { + LiveQueryWindowControllerDisposedError, + SetWindowRequiresOrderByError, +} from './errors.js' import { createLiveQueryObserver } from './live-query-observer.js' import type { - CreateLiveQueryObserverOptions, LiveQueryObserver, + LiveQuerySnapshot, } from './live-query-observer.js' import type { Collection } from './collection/index.js' import type { CollectionStatus } from './types.js' const DEFAULT_PAGE_SIZE = 20 +type WindowResult = true | Promise + +type WindowTarget = object & { + utils?: { + setWindow?: (options: { offset: number; limit: number }) => WindowResult + } +} + +type PendingWindow = { + generation: number + limit: number + promise: Promise +} + +class WindowCoordinator { + private readonly leases = new Map() + private appliedLimit: number | undefined + private pending: PendingWindow | undefined + private generation = 0 + + constructor(private readonly target: WindowTarget) {} + + request(lease: symbol, limit: number): WindowResult { + this.leases.set(lease, limit) + return this.applyDesiredWindow() + } + + release(lease: symbol): void { + if (!this.leases.delete(lease)) return + + // A pending request may still mutate the physical operator, but it no longer + // establishes the accepted window for the remaining lease set. + this.generation++ + this.pending = undefined + + if (this.leases.size === 0) { + // There is no consumer-visible window to maintain. Force the next lease to + // re-apply even when it requests the same limit as the previous consumer. + this.appliedLimit = undefined + return + } + + try { + const result = this.applyDesiredWindow() + if (result !== true) { + void result.catch(() => { + // Unsubscribe has no async error channel. Leave the physical window + // unaccepted so the next request retries it. + this.appliedLimit = undefined + }) + } + } catch { + // The remaining controller will retry on its next request. + this.appliedLimit = undefined + } + } + + private getDesiredLimit(): number | undefined { + let desired: number | undefined + for (const limit of this.leases.values()) { + desired = desired === undefined ? limit : Math.max(desired, limit) + } + return desired + } + + private applyDesiredWindow(): WindowResult { + const limit = this.getDesiredLimit() + if (limit === undefined) return true + if (this.pending?.limit === limit) return this.pending.promise + if (this.pending) { + // `setWindow` mutates the physical operator before its load promise + // settles. A different desired window must therefore be applied again, + // even when it matches the last settled limit. + this.generation++ + this.pending = undefined + this.appliedLimit = undefined + } + if (limit === this.appliedLimit) return true + + const setWindow = this.target.utils?.setWindow + if (typeof setWindow !== `function`) { + throw new SetWindowRequiresOrderByError() + } + + const generation = ++this.generation + const result = setWindow.call(this.target.utils, { offset: 0, limit }) + if (result === true) { + if (generation === this.generation && this.getDesiredLimit() === limit) { + this.appliedLimit = limit + } + return true + } + + const promise = result.then( + () => { + if ( + generation === this.generation && + this.getDesiredLimit() === limit + ) { + this.appliedLimit = limit + } + if (this.pending?.generation === generation) { + this.pending = undefined + } + }, + (error: unknown) => { + if (this.pending?.generation === generation) { + this.pending = undefined + } + throw error + }, + ) + this.pending = { generation, limit, promise } + return promise + } +} + +const windowCoordinators = new WeakMap() + +function getWindowCoordinator(target: WindowTarget): WindowCoordinator { + let coordinator = windowCoordinators.get(target) + if (!coordinator) { + coordinator = new WindowCoordinator(target) + windowCoordinators.set(target, coordinator) + } + return coordinator +} + /** - * A page-windowed view of a live query at a point in time. Extends the live - * query's status/data contract with forward pagination derived from a - * peek-ahead window (`limit = loadedPages * pageSize + 1`): the extra row tells - * us whether another page exists and is then dropped from `data`/`pages`. + * A page-windowed view of a live query at a point in time. * - * `getSnapshot()` returns a stable identity that only changes when the query, - * the page count, or the fetching state changes, so `useSyncExternalStore`-style - * consumers can compare by reference. + * @internal This contract is unstable while RFC #1623 is being implemented. */ export interface LiveQueryWindowSnapshot< T extends object, TKey extends string | number, > { - /** Rows across all loaded pages, peek-ahead row removed. */ + /** Rows across all committed pages, with the peek-ahead row removed. */ data: ReadonlyArray - /** Rows grouped into pages of `pageSize`. */ + /** Rows grouped into committed pages of `pageSize`. */ pages: ReadonlyArray> - /** `initialPageParam + i` for each loaded page. */ + /** `initialPageParam + i` for each committed page. */ pageParams: ReadonlyArray hasNextPage: boolean isFetchingNextPage: boolean - /** Keyed results for the whole window (incl. peek row), or `undefined` when disabled. */ + /** The last pagination failure, cleared when a retry begins. */ + error: unknown + /** Keyed results for the physical window, or `undefined` when disabled. */ state: ReadonlyMap | undefined collection: Collection | undefined status: CollectionStatus | `disabled` @@ -43,45 +171,38 @@ export interface LiveQueryWindowSnapshot< isEnabled: boolean } -export interface CreateLiveQueryWindowControllerOptions extends CreateLiveQueryObserverOptions { - /** Rows per page (default 20). A falsy value falls back to the default. */ +/** @internal This contract is unstable while RFC #1623 is being implemented. */ +export interface CreateLiveQueryWindowControllerOptions { + /** Rows per page (default 20). Non-positive values use the default. */ pageSize?: number /** Value of the first page's `pageParam` (default 0). */ initialPageParam?: number - /** - * Defer applying the first window until the collection is ready. Set for - * query-function inputs whose collection is created lazily and already carries - * the first page's window in its query; leave off for a pre-created collection - * whose window must be established up front. - */ - waitForReady?: boolean + /** Committed pages to preserve when a framework binding changes page shape. */ + initialPageCount?: number } -/** - * Owns the forward-pagination state machine for an ordered live query: the - * loaded-page count, the peek-ahead window (via `collection.utils.setWindow`), - * page slicing, and `hasNextPage`/`isFetchingNextPage`. Composes a - * {@link LiveQueryObserver} for the data + lifecycle channel. Framework adapters - * resolve the input to a collection and materialize the snapshot natively. - */ +/** @internal This contract is unstable while RFC #1623 is being implemented. */ export interface LiveQueryWindowController< T extends object, TKey extends string | number, > { getSnapshot: () => LiveQueryWindowSnapshot subscribe: (listener: () => void) => () => void - /** Load one more page (no-op when already fetching or no next page exists). */ - fetchNextPage: () => void - /** Reset back to the first page — call when the input identity/deps change. */ - reset: () => void + /** Load one more page, resolving only after that page is committed. */ + fetchNextPage: () => Promise + /** Reset to the first page, resolving after the smaller window is accepted. */ + reset: () => Promise preload: () => Promise dispose: () => void } interface CachedFrom { observerSnapshot: unknown - loadedPageCount: number + committedPageCount: number isFetchingNextPage: boolean + hasPaginationError: boolean + paginationError: unknown + failedHasNextPage: boolean } interface SubscriptionRecord { @@ -99,24 +220,27 @@ class LiveQueryWindowControllerImpl< > implements LiveQueryWindowController { private readonly observer: LiveQueryObserver private readonly collection: Collection | null + private readonly coordinator: WindowCoordinator | null + private readonly lease = Symbol(`liveQueryWindowLease`) private readonly pageSize: number private readonly initialPageParam: number - private readonly waitForReady: boolean - private readonly wholesale: boolean - private loadedPageCount = 1 + private committedPageCount: number private isFetchingNextPage = false - // The limit last handed to `setWindow`, so we don't re-apply an unchanged - // window on every observer notification. - private appliedLimit: number | undefined - // Bumped on each window application so a superseded load promise doesn't clear - // the fetching flag for a window that no longer applies. + private hasPaginationError = false + private paginationError: unknown + private failedHasNextPage = false private windowGeneration = 0 + private pendingWindowGeneration: number | undefined + private leaseActive = false + private leaseGeneration = 0 private readonly subscriptions = new Set() private readonly publicationQueue: Array = [] private dispatching = false private blockDelivery = false + private transitionDepth = 0 + private transitionNeedsNotify = false private observerUnsub: (() => void) | null = null private cachedSnapshot: LiveQueryWindowSnapshot | null = null private cachedFrom: CachedFrom | null = null @@ -127,12 +251,22 @@ class LiveQueryWindowControllerImpl< options: CreateLiveQueryWindowControllerOptions, ) { this.collection = collection - this.pageSize = options.pageSize || DEFAULT_PAGE_SIZE + this.coordinator = collection + ? getWindowCoordinator(collection as unknown as WindowTarget) + : null + this.pageSize = + options.pageSize !== undefined && options.pageSize > 0 + ? options.pageSize + : DEFAULT_PAGE_SIZE this.initialPageParam = options.initialPageParam ?? 0 - this.waitForReady = options.waitForReady ?? false - this.wholesale = options.mode === `wholesale` + this.committedPageCount = Math.max( + 1, + Math.floor(options.initialPageCount ?? 1), + ) + // The controller listener carries no delta payload, so wholesale is the + // only coherent observer contract and guarantees non-reentrant subscribe. this.observer = createLiveQueryObserver(collection, { - mode: options.mode, + mode: `wholesale`, }) } @@ -143,8 +277,11 @@ class LiveQueryWindowControllerImpl< cached && this.cachedFrom && this.cachedFrom.observerSnapshot === observerSnapshot && - this.cachedFrom.loadedPageCount === this.loadedPageCount && - this.cachedFrom.isFetchingNextPage === this.isFetchingNextPage + this.cachedFrom.committedPageCount === this.committedPageCount && + this.cachedFrom.isFetchingNextPage === this.isFetchingNextPage && + this.cachedFrom.hasPaginationError === this.hasPaginationError && + this.cachedFrom.paginationError === this.paginationError && + this.cachedFrom.failedHasNextPage === this.failedHasNextPage ) { return cached } @@ -154,14 +291,13 @@ class LiveQueryWindowControllerImpl< enabled && Array.isArray(observerSnapshot.data) ? (observerSnapshot.data as ReadonlyArray) : [] - const totalRequested = this.loadedPageCount * this.pageSize - // The window peeks one row past what was requested; its presence means - // there is another page. It is not part of the visible result. - const hasNextPage = enabled && rows.length > totalRequested - - // A disabled query has no pages; an enabled query always has `loadedPageCount` - // pages (the last may be empty when there is no data yet). - const pageCount = enabled ? this.loadedPageCount : 0 + const totalRequested = this.committedPageCount * this.pageSize + const computedHasNextPage = enabled && rows.length > totalRequested + const hasNextPage = this.hasPaginationError + ? this.failedHasNextPage + : computedHasNextPage + + const pageCount = enabled ? this.committedPageCount : 0 const pages: Array> = [] const pageParams: Array = [] for (let i = 0; i < pageCount; i++) { @@ -169,26 +305,31 @@ class LiveQueryWindowControllerImpl< pageParams.push(this.initialPageParam + i) } + const status = this.hasPaginationError ? `error` : observerSnapshot.status this.cachedSnapshot = { data: rows.slice(0, totalRequested), pages, pageParams, hasNextPage, isFetchingNextPage: this.isFetchingNextPage, + error: this.hasPaginationError ? this.paginationError : undefined, state: observerSnapshot.state, collection: observerSnapshot.collection, - status: observerSnapshot.status, + status, isLoading: observerSnapshot.isLoading, isReady: observerSnapshot.isReady, isIdle: observerSnapshot.isIdle, - isError: observerSnapshot.isError, + isError: this.hasPaginationError || observerSnapshot.isError, isCleanedUp: observerSnapshot.isCleanedUp, isEnabled: observerSnapshot.isEnabled, } this.cachedFrom = { observerSnapshot, - loadedPageCount: this.loadedPageCount, + committedPageCount: this.committedPageCount, isFetchingNextPage: this.isFetchingNextPage, + hasPaginationError: this.hasPaginationError, + paginationError: this.paginationError, + failedHasNextPage: this.failedHasNextPage, } return this.cachedSnapshot } @@ -199,23 +340,22 @@ class LiveQueryWindowControllerImpl< const record: SubscriptionRecord = { listener, active: true } this.subscriptions.add(record) if (this.subscriptions.size === 1) { - // A wholesale subscriber re-reads the snapshot immediately after - // subscribing, so setup publications are redundant and must not fire - // inside useSyncExternalStore's subscribe call. - this.blockDelivery = this.wholesale + this.blockDelivery = true let observerUnsub: (() => void) | null = null try { + // Store the desired physical window before observer activation can + // compile or restart the live-query pipeline. + const windowResult = this.activateLease(this.committedPageCount) + const leaseGeneration = this.leaseGeneration observerUnsub = this.observer.subscribe(() => this.onObserverNotify()) - if (this.hasBeenDisposed()) { - observerUnsub() - } else { - this.observerUnsub = observerUnsub - // Establish the current window now that the query is active. - this.applyWindow() + this.observerUnsub = observerUnsub + if (windowResult !== true) { + this.trackAttachmentFailure(windowResult, leaseGeneration) } } catch (error) { observerUnsub?.() this.observerUnsub = null + this.deactivateLease() record.active = false this.subscriptions.delete(record) throw error @@ -231,99 +371,216 @@ class LiveQueryWindowControllerImpl< if (this.subscriptions.size === 0) { this.observerUnsub?.() this.observerUnsub = null + this.deactivateLease() } } } - fetchNextPage(): void { - if (this.disposed || this.isFetchingNextPage) return - if (!this.getSnapshot().hasNextPage) return - this.loadedPageCount++ - this.applyWindow() - this.notify() + fetchNextPage(): Promise { + if (this.disposed || this.isFetchingNextPage) return Promise.resolve() + if (!this.getSnapshot().hasNextPage) return Promise.resolve() + return this.requestPageCount(this.committedPageCount + 1, true) } - reset(): void { - if (this.disposed) return - if (this.loadedPageCount === 1 && this.appliedLimit !== undefined) { - // Already on the first page; nothing to reset. - return + reset(): Promise { + if (this.disposed) return Promise.resolve() + if ( + this.committedPageCount === 1 && + !this.hasPaginationError && + !this.isFetchingNextPage && + this.pendingWindowGeneration === undefined + ) { + return Promise.resolve() } - this.loadedPageCount = 1 - this.appliedLimit = undefined - this.applyWindow() - this.notify() + return this.requestPageCount(1, false) } - preload(): Promise { - return this.observer.preload() + async preload(): Promise { + if (this.disposed) throw new LiveQueryWindowControllerDisposedError() + + const temporaryLease = !this.leaseActive + try { + const result = this.activateLease(this.committedPageCount) + if (result !== true) await result + await this.observer.preload() + } catch (error) { + this.hasPaginationError = true + this.paginationError = error + this.failedHasNextPage = this.getComputedHasNextPage() + this.notify() + throw error + } finally { + if (temporaryLease && this.subscriptions.size === 0) { + this.deactivateLease() + } + } } dispose(): void { if (this.disposed) return this.disposed = true + this.windowGeneration++ + this.pendingWindowGeneration = undefined this.observerUnsub?.() this.observerUnsub = null + this.deactivateLease() this.observer.dispose() for (const record of this.subscriptions) record.active = false this.subscriptions.clear() this.publicationQueue.length = 0 } - private onObserverNotify(): void { - // Re-apply the window in case readiness just changed (a deferred first - // apply) — idempotent when the window is unchanged — then republish. - this.applyWindow() - this.notify() - } + private requestPageCount( + requestedPageCount: number, + fetchingNextPage: boolean, + ): Promise { + const generation = ++this.windowGeneration + const previousHasNextPage = this.getSnapshot().hasNextPage + const temporaryLease = !this.leaseActive && this.subscriptions.size === 0 + this.pendingWindowGeneration = undefined - private applyWindow(): void { - const collection = this.collection - if (!collection || this.disposed) return - if (this.waitForReady && !this.observer.getSnapshot().isReady) return - - const limit = this.loadedPageCount * this.pageSize + 1 - if (limit === this.appliedLimit) return - this.appliedLimit = limit - - const utils = collection.utils as - | { - setWindow?: (o: { - offset: number - limit: number - }) => true | Promise - } - | undefined - if (typeof utils?.setWindow !== `function`) return + this.beginTransition() + this.isFetchingNextPage = fetchingNextPage + this.hasPaginationError = false + this.paginationError = undefined + if (fetchingNextPage) this.notify() + + let result: WindowResult + try { + result = this.activateLease(requestedPageCount) + } catch (error) { + this.isFetchingNextPage = false + this.hasPaginationError = true + this.paginationError = error + this.failedHasNextPage = previousHasNextPage + this.notify() + this.endTransition() + if (temporaryLease) this.deactivateLease() + return Promise.reject(error) + } - const generation = ++this.windowGeneration - const result = utils.setWindow({ offset: 0, limit }) if (result === true) { - this.setFetching(false) - return + if (!this.disposed && generation === this.windowGeneration) { + this.committedPageCount = requestedPageCount + this.isFetchingNextPage = false + this.failedHasNextPage = false + this.notify() + } + this.endTransition() + if (temporaryLease) this.deactivateLease() + return Promise.resolve() } - this.setFetching(true) - result - .catch(() => { - // Swallow — the load error surfaces through the collection's status. - }) + this.pendingWindowGeneration = generation + this.endTransition() + + return result + .then( + () => { + if (this.disposed || generation !== this.windowGeneration) return + this.beginTransition() + this.pendingWindowGeneration = undefined + this.committedPageCount = requestedPageCount + this.isFetchingNextPage = false + this.failedHasNextPage = false + this.notify() + this.endTransition() + }, + (error: unknown) => { + if (!this.disposed && generation === this.windowGeneration) { + this.beginTransition() + this.pendingWindowGeneration = undefined + this.isFetchingNextPage = false + this.hasPaginationError = true + this.paginationError = error + this.failedHasNextPage = previousHasNextPage + this.notify() + this.endTransition() + } + throw error + }, + ) .finally(() => { - // Only clear for the window this call requested; a newer apply owns the - // flag otherwise. - if (!this.disposed && generation === this.windowGeneration) { - this.setFetching(false) - } + this.releaseTemporaryLease(temporaryLease) }) } - private setFetching(value: boolean): void { - if (this.isFetchingNextPage === value) return - this.isFetchingNextPage = value + private releaseTemporaryLease(temporaryLease: boolean): void { + if (temporaryLease && this.subscriptions.size === 0) { + this.deactivateLease() + } + } + + private activateLease(pageCount: number): WindowResult { + this.leaseGeneration++ + if (!this.coordinator || !this.collection) return true + this.leaseActive = true + return this.coordinator.request(this.lease, pageCount * this.pageSize + 1) + } + + private deactivateLease(): void { + if (!this.leaseActive || !this.coordinator) return + this.leaseGeneration++ + this.leaseActive = false + this.coordinator.release(this.lease) + } + + private trackAttachmentFailure( + result: Promise, + leaseGeneration: number, + ): void { + void result.catch((error: unknown) => { + if ( + this.disposed || + !this.leaseActive || + leaseGeneration !== this.leaseGeneration + ) { + return + } + this.beginTransition() + this.hasPaginationError = true + this.paginationError = error + this.failedHasNextPage = this.getComputedHasNextPage() + this.notify() + this.endTransition() + }) + } + + private getComputedHasNextPage(): boolean { + const snapshot: LiveQuerySnapshot = this.observer.getSnapshot() + return ( + snapshot.isEnabled && + Array.isArray(snapshot.data) && + snapshot.data.length > this.committedPageCount * this.pageSize + ) + } + + private onObserverNotify(): void { + if (this.pendingWindowGeneration !== undefined) return this.notify() } + private beginTransition(): void { + this.transitionDepth++ + } + + private endTransition(): void { + this.transitionDepth-- + if (this.transitionDepth === 0 && this.transitionNeedsNotify) { + this.transitionNeedsNotify = false + this.publish() + } + } + private notify(): void { + if (this.transitionDepth > 0) { + this.transitionNeedsNotify = true + return + } + this.publish() + } + + private publish(): void { if (this.disposed || this.blockDelivery || this.subscriptions.size === 0) { return } @@ -337,6 +594,7 @@ class LiveQueryWindowControllerImpl< const publication = this.publicationQueue.shift()! for (const record of publication.targets) { if (this.hasBeenDisposed()) return + if (!record.active) continue record.listener() } } @@ -351,9 +609,9 @@ class LiveQueryWindowControllerImpl< } /** - * Create a {@link LiveQueryWindowController} for a resolved, ordered live-query - * collection (which must have an `orderBy`), or a disabled controller when - * `collection` is `null`/`undefined`. + * Create an internal forward-window controller for an ordered live query. + * + * @internal This factory is unstable while RFC #1623 is being implemented. */ export function createLiveQueryWindowController< T extends object, diff --git a/packages/db/src/query/live/collection-config-builder.ts b/packages/db/src/query/live/collection-config-builder.ts index e324119c6..9c26bbfe6 100644 --- a/packages/db/src/query/live/collection-config-builder.ts +++ b/packages/db/src/query/live/collection-config-builder.ts @@ -275,9 +275,17 @@ export class CollectionConfigBuilder< throw new SetWindowRequiresOrderByError() } - this.currentWindow = options - this.windowFn(options) - this.maybeRunGraphFn?.() + const previousWindow = this.currentWindow + try { + this.windowFn(options) + this.maybeRunGraphFn?.() + this.currentWindow = options + } catch (error) { + if (previousWindow) { + this.windowFn(previousWindow) + } + throw error + } // Check if loading a subset was triggered if (this.liveQueryCollection?.isLoadingSubset) { @@ -655,6 +663,7 @@ export class CollectionConfigBuilder< // Clear current sync session state this.currentSyncConfig = undefined this.currentSyncState = undefined + this.maybeRunGraphFn = undefined // Clear all pending graph runs to prevent memory leaks from in-flight transactions // that may flush after the sync session ends @@ -709,6 +718,12 @@ export class CollectionConfigBuilder< this.optimizableOrderByCollections, (windowFn: (options: WindowOptions) => void) => { this.windowFn = windowFn + // `setWindow` mutates the compiled top-K operator, which is replaced + // whenever a cleaned-up live query compiles a fresh pipeline. Keep the + // desired window on the builder and replay it into each new operator. + if (this.currentWindow) { + windowFn(this.currentWindow) + } }, ) diff --git a/packages/db/tests/live-query-window-controller.test.ts b/packages/db/tests/live-query-window-controller.test.ts index 983803d0a..fa511afd2 100644 --- a/packages/db/tests/live-query-window-controller.test.ts +++ b/packages/db/tests/live-query-window-controller.test.ts @@ -13,12 +13,12 @@ interface Row { const ROWS: Array = [1, 2, 3, 4, 5].map((n) => ({ id: String(n), n })) let seq = 0 -function makeSource() { +function makeSource(initialData: Array = ROWS) { return createCollection( mockSyncCollectionOptions({ id: `window-ctrl-${seq++}`, getKey: (r) => r.id, - initialData: ROWS, + initialData, }), ) } @@ -115,6 +115,37 @@ describe(`createLiveQueryWindowController`, () => { controller.dispose() }) + it(`represents an empty enabled query as one empty page`, async () => { + const lq = makeOrderedLiveQuery(makeSource([]), 2) + const controller = createLiveQueryWindowController(lq as any, { + pageSize: 2, + }) + controller.subscribe(() => {}) + await lq.preload() + + const snapshot = controller.getSnapshot() + expect(snapshot.isEnabled).toBe(true) + expect(snapshot.data).toEqual([]) + expect(snapshot.pages).toEqual([[]]) + expect(snapshot.hasNextPage).toBe(false) + controller.dispose() + }) + + it(`uses the default page size when pageSize is zero`, async () => { + const lq = makeOrderedLiveQuery(makeSource(), 2) + const controller = createLiveQueryWindowController(lq as any, { + pageSize: 0, + }) + controller.subscribe(() => {}) + await lq.preload() + + const snapshot = controller.getSnapshot() + expect(ids(snapshot)).toEqual([`1`, `2`, `3`, `4`, `5`]) + expect(snapshot.pages).toHaveLength(1) + expect(snapshot.hasNextPage).toBe(false) + controller.dispose() + }) + it(`reset returns to the first page`, async () => { const lq = makeOrderedLiveQuery(makeSource(), 2) const controller = createLiveQueryWindowController(lq as any, { @@ -154,12 +185,231 @@ describe(`createLiveQueryWindowController`, () => { controller.dispose() }) - it(`does not notify synchronously while subscribing`, () => { + it(`retries a window that throws synchronously`, () => { + const lq = makeOrderedLiveQuery(makeSource(), 2) + const setWindow = vi + .spyOn(lq.utils, `setWindow`) + .mockImplementationOnce(() => { + throw new Error(`window failed`) + }) + .mockReturnValue(true) + const controller = createLiveQueryWindowController(lq as any, { + pageSize: 2, + }) + + expect(() => controller.subscribe(() => {})).toThrow(`window failed`) + const unsubscribe = controller.subscribe(() => {}) + + expect(setWindow).toHaveBeenCalledTimes(2) + unsubscribe() + controller.dispose() + }) + + it(`keeps the committed page retryable when a window load rejects`, async () => { + const lq = makeOrderedLiveQuery(makeSource(), 2) + const controller = createLiveQueryWindowController(lq as any, { + pageSize: 2, + }) + controller.subscribe(() => {}) + await lq.preload() + await flush() + expect(controller.getSnapshot().hasNextPage).toBe(true) + + const failure = new Error(`load failed`) + vi.spyOn(lq.utils, `setWindow`).mockRejectedValueOnce(failure) + + await expect(Promise.resolve(controller.fetchNextPage())).rejects.toThrow( + `load failed`, + ) + + expect(controller.getSnapshot().pages).toHaveLength(1) + expect(controller.getSnapshot().hasNextPage).toBe(true) + expect((controller.getSnapshot() as { error?: unknown }).error).toBe( + failure, + ) + + await controller.fetchNextPage() + expect(controller.getSnapshot().pages).toHaveLength(2) + controller.dispose() + }) + + it(`publishes one coherent loading snapshot and one settled snapshot`, async () => { const lq = makeOrderedLiveQuery(makeSource(), 2) - vi.spyOn(lq.utils, `setWindow`).mockReturnValue(new Promise(() => {})) const controller = createLiveQueryWindowController(lq as any, { pageSize: 2, - mode: `wholesale`, + }) + const snapshots: Array<{ pages: number; fetching: boolean }> = [] + controller.subscribe(() => { + const snapshot = controller.getSnapshot() + snapshots.push({ + pages: snapshot.pages.length, + fetching: snapshot.isFetchingNextPage, + }) + }) + await lq.preload() + await flush() + snapshots.length = 0 + + let resolveWindow!: () => void + vi.spyOn(lq.utils, `setWindow`).mockReturnValueOnce( + new Promise((resolve) => { + resolveWindow = resolve + }), + ) + + const fetch = Promise.resolve(controller.fetchNextPage()) + expect(snapshots).toEqual([{ pages: 1, fetching: true }]) + + resolveWindow() + await fetch + expect(snapshots).toEqual([ + { pages: 1, fetching: true }, + { pages: 2, fetching: false }, + ]) + controller.dispose() + }) + + it(`reset supersedes an in-flight page expansion`, async () => { + const lq = makeOrderedLiveQuery(makeSource(), 2) + const controller = createLiveQueryWindowController(lq as any, { + pageSize: 2, + }) + controller.subscribe(() => {}) + await lq.preload() + + let resolveExpansion!: () => void + const setWindow = vi + .spyOn(lq.utils, `setWindow`) + .mockReturnValueOnce( + new Promise((resolve) => { + resolveExpansion = resolve + }), + ) + .mockReturnValueOnce(true) + + const expansion = controller.fetchNextPage() + expect(controller.getSnapshot().isFetchingNextPage).toBe(true) + + await controller.reset() + expect(controller.getSnapshot().pages).toHaveLength(1) + expect(controller.getSnapshot().isFetchingNextPage).toBe(false) + expect(setWindow).toHaveBeenNthCalledWith(1, { offset: 0, limit: 5 }) + expect(setWindow).toHaveBeenNthCalledWith(2, { offset: 0, limit: 3 }) + + resolveExpansion() + await expansion + expect(controller.getSnapshot().pages).toHaveLength(1) + controller.dispose() + }) + + it(`replays the desired window after collection cleanup`, async () => { + const lq = makeOrderedLiveQuery(makeSource(), 2) + const controller = createLiveQueryWindowController(lq as any, { + pageSize: 2, + }) + const unsubscribe = controller.subscribe(() => {}) + await lq.preload() + + await controller.fetchNextPage() + await flush() + expect(ids(controller.getSnapshot())).toEqual([`1`, `2`, `3`, `4`]) + + unsubscribe() + await lq.cleanup() + + controller.subscribe(() => {}) + await lq.preload() + await flush() + + expect(ids(controller.getSnapshot())).toEqual([`1`, `2`, `3`, `4`]) + expect(controller.getSnapshot().hasNextPage).toBe(true) + controller.dispose() + }) + + it(`establishes the desired window before preload`, async () => { + const source = makeSource() + const lq = createLiveQueryCollection({ + query: (q) => + q + .from({ r: source }) + .orderBy(({ r }) => r.n, `asc`) + .limit(2) + .select(({ r }) => ({ id: r.id, n: r.n })), + gcTime: 1, + }) + const controller = createLiveQueryWindowController(lq as any, { + pageSize: 2, + }) + + await controller.preload() + + expect(controller.getSnapshot().hasNextPage).toBe(true) + expect(lq.utils.getWindow()).toEqual({ offset: 0, limit: 3 }) + controller.dispose() + }) + + it(`coordinates the physical window across multiple controllers`, async () => { + const lq = makeOrderedLiveQuery(makeSource(), 2) + const larger = createLiveQueryWindowController(lq as any, { + pageSize: 2, + }) + const smaller = createLiveQueryWindowController(lq as any, { + pageSize: 1, + }) + + larger.subscribe(() => {}) + smaller.subscribe(() => {}) + await lq.preload() + await flush() + + expect(lq.utils.getWindow()).toEqual({ offset: 0, limit: 3 }) + expect(ids(larger.getSnapshot())).toEqual([`1`, `2`]) + expect(larger.getSnapshot().hasNextPage).toBe(true) + + await larger.fetchNextPage() + expect(lq.utils.getWindow()).toEqual({ offset: 0, limit: 5 }) + + await smaller.fetchNextPage() + expect(lq.utils.getWindow()).toEqual({ offset: 0, limit: 5 }) + + larger.dispose() + expect(lq.utils.getWindow()).toEqual({ offset: 0, limit: 3 }) + smaller.dispose() + }) + + it(`ignores a failed attachment superseded by a new lease`, async () => { + const lq = makeOrderedLiveQuery(makeSource(), 2) + await lq.preload() + + let rejectFirst!: (error: Error) => void + vi.spyOn(lq.utils, `setWindow`) + .mockReturnValueOnce( + new Promise((_, reject) => { + rejectFirst = reject + }), + ) + .mockReturnValue(true) + + const controller = createLiveQueryWindowController(lq as any, { + pageSize: 2, + }) + const unsubscribe = controller.subscribe(() => {}) + unsubscribe() + controller.subscribe(() => {}) + + rejectFirst(new Error(`stale attachment failed`)) + await flush() + + expect(controller.getSnapshot().isError).toBe(false) + expect(controller.getSnapshot().error).toBeUndefined() + controller.dispose() + }) + + it(`does not notify synchronously while subscribing by default`, async () => { + const lq = makeOrderedLiveQuery(makeSource(), 2) + await lq.preload() + const controller = createLiveQueryWindowController(lq as any, { + pageSize: 2, }) let subscribing = true let notifiedWhileSubscribing = false @@ -238,13 +488,15 @@ describe(`createLiveQueryWindowController`, () => { ) }) - it(`releases subscriptions when disposed during initial replay`, () => { + it(`releases subscriptions when disposed by a listener`, async () => { const lq = makeOrderedLiveQuery(makeSource(), 2) const controller = createLiveQueryWindowController(lq as any, { pageSize: 2, }) controller.subscribe(() => controller.dispose()) + await lq.preload() + await controller.fetchNextPage() expect(lq.subscriberCount).toBe(0) }) @@ -273,6 +525,32 @@ describe(`createLiveQueryWindowController`, () => { expect(secondListenerNotifications).toBe(0) }) + it(`skips a listener unsubscribed during an in-flight publication`, async () => { + const lq = makeOrderedLiveQuery(makeSource(), 2) + const controller = createLiveQueryWindowController(lq as any, { + pageSize: 2, + }) + let publishing = false + let secondListenerNotifications = 0 + let unsubscribeSecond = () => {} + controller.subscribe(() => { + if (publishing) unsubscribeSecond() + }) + unsubscribeSecond = controller.subscribe(() => { + if (publishing) secondListenerNotifications++ + }) + await lq.preload() + await flush() + vi.spyOn(lq.utils, `setWindow`).mockReturnValue(true) + + publishing = true + await controller.fetchNextPage() + publishing = false + + expect(secondListenerNotifications).toBe(0) + controller.dispose() + }) + it(`returns a stable snapshot identity when nothing changed`, async () => { const lq = makeOrderedLiveQuery(makeSource(), 2) const controller = createLiveQueryWindowController(lq as any, { diff --git a/packages/react-db/src/useLiveInfiniteQuery.ts b/packages/react-db/src/useLiveInfiniteQuery.ts index 2704608d3..70a250f13 100644 --- a/packages/react-db/src/useLiveInfiniteQuery.ts +++ b/packages/react-db/src/useLiveInfiniteQuery.ts @@ -19,8 +19,19 @@ import type { // Live queries created here are cleaned up immediately (0 disables GC). const DEFAULT_GC_TIME_MS = 1 +type WindowedCollection = Collection & { + utils: { + setWindow: (options: { + offset: number + limit: number + }) => true | Promise + } +} + /** Type guard: does this collection expose `setWindow` (i.e. has an orderBy)? */ -function hasSetWindow(collection: Collection): boolean { +function hasSetWindow( + collection: Collection, +): collection is WindowedCollection { return typeof collection.utils?.setWindow === `function` } @@ -50,8 +61,13 @@ export type UseLiveInfiniteQueryReturn = Omit< fetchNextPage: () => void hasNextPage: boolean isFetchingNextPage: boolean + error: unknown } +type EnabledLiveQueryReturn = ReturnType< + typeof useLiveQuery +> + /** * Create an infinite query using a query function with live updates * @@ -155,39 +171,30 @@ export function useLiveInfiniteQuery( ) } - // Track deps for query functions (stringify for comparison) - let depsKey: string - try { - depsKey = JSON.stringify(deps) - } catch { - throw new Error( - `useLiveInfiniteQuery: dependency array contains values that cannot be serialized (e.g. circular references). ` + - `Ensure all dependency values are JSON-serializable.`, - ) - } - const collectionRef = useRef | null>(null) const controllerRef = useRef | null>(null) const configRef = useRef(null) - const depsRef = useRef(null) + const depsRef = useRef | null>(null) const pageSizeRef = useRef(pageSize) const initialPageParamRef = useRef(initialPageParam) const validatedCollectionRef = useRef(null) - // Recreate the underlying collection + controller when the input identity - // (pre-created collection), the deps (query function), or the page shape - // (`pageSize`/`initialPageParam`) change. A fresh controller starts back at - // page 1, which is the desired reset behaviour. - const needsNew = - !controllerRef.current || - pageSizeRef.current !== pageSize || - initialPageParamRef.current !== initialPageParam || + const dependenciesChanged = + !isCollection && + (depsRef.current === null || + depsRef.current.length !== deps.length || + depsRef.current.some((dep, index) => dep !== deps[index])) + const needsNewCollection = + !collectionRef.current || (isCollection && configRef.current !== queryFnOrCollection) || - (!isCollection && depsRef.current !== depsKey) + dependenciesChanged + const pageShapeChanged = + pageSizeRef.current !== pageSize || + initialPageParamRef.current !== initialPageParam + const needsNewController = + !controllerRef.current || needsNewCollection || pageShapeChanged - if (needsNew) { - pageSizeRef.current = pageSize - initialPageParamRef.current = initialPageParam + if (needsNewCollection) { if (isCollection) { const collection = queryFnOrCollection as Collection if (!hasSetWindow(collection)) { @@ -211,7 +218,6 @@ export function useLiveInfiniteQuery( ) } } - collection.startSyncImmediate() collectionRef.current = collection configRef.current = queryFnOrCollection } else { @@ -222,59 +228,62 @@ export function useLiveInfiniteQuery( queryFnOrCollection(q) .limit(pageSize + 1) .offset(0), - startSync: true, + // Construction happens during render. Synchronization starts only when + // useSyncExternalStore commits the controller subscription. + startSync: false, gcTime: DEFAULT_GC_TIME_MS, }) - depsRef.current = depsKey + depsRef.current = [...deps] } + } + + if (needsNewController) { + const initialPageCount = + controllerRef.current && !needsNewCollection + ? Math.max(1, controllerRef.current.getSnapshot().pages.length) + : 1 + pageSizeRef.current = pageSize + initialPageParamRef.current = initialPageParam controllerRef.current = createLiveQueryWindowController( collectionRef.current, { pageSize, initialPageParam, - // Wholesale mode provides useSyncExternalStore's no-sync-notify contract. - mode: 'wholesale', - // A query-function collection already carries page 1's window in its - // query, so defer the (redundant) first apply until it is ready; a - // pre-created collection needs its window established up front. - waitForReady: !isCollection, + initialPageCount, }, ) } const controller = controllerRef.current! - // Stable subscribe bound to the current controller. - const subscribeRef = useRef< - ((onStoreChange: () => void) => () => void) | null - >(null) - if (!subscribeRef.current || needsNew) { - subscribeRef.current = (onStoreChange) => - controller.subscribe(onStoreChange) - } - - const snapshot = useSyncExternalStore(subscribeRef.current, () => - controller.getSnapshot(), + const subscribe = useCallback( + (onStoreChange: () => void) => controller.subscribe(onStoreChange), + [controller], ) + const getSnapshot = useCallback(() => controller.getSnapshot(), [controller]) + const snapshot = useSyncExternalStore(subscribe, getSnapshot) const fetchNextPage = useCallback(() => { - controllerRef.current?.fetchNextPage() - }, []) + void controller.fetchNextPage() + }, [controller]) return { data: snapshot.data as InferResultType, - state: snapshot.state, - status: snapshot.status, + state: snapshot.state as EnabledLiveQueryReturn[`state`], + status: snapshot.status as EnabledLiveQueryReturn[`status`], isLoading: snapshot.isLoading, isReady: snapshot.isReady, isIdle: snapshot.isIdle, isError: snapshot.isError, isCleanedUp: snapshot.isCleanedUp, - collection: snapshot.collection, - isEnabled: snapshot.isEnabled, + collection: + snapshot.collection as EnabledLiveQueryReturn[`collection`], + isEnabled: + snapshot.isEnabled as EnabledLiveQueryReturn[`isEnabled`], pages: snapshot.pages as Array[number]>>, pageParams: snapshot.pageParams as Array, fetchNextPage, hasNextPage: snapshot.hasNextPage, isFetchingNextPage: snapshot.isFetchingNextPage, - } as UseLiveInfiniteQueryReturn + error: snapshot.error, + } } diff --git a/packages/react-db/tests/useLiveInfiniteQuery.test.tsx b/packages/react-db/tests/useLiveInfiniteQuery.test.tsx index e961167de..475f04713 100644 --- a/packages/react-db/tests/useLiveInfiniteQuery.test.tsx +++ b/packages/react-db/tests/useLiveInfiniteQuery.test.tsx @@ -1,5 +1,6 @@ import { describe, expect, it, vi } from 'vitest' -import { act, renderHook, waitFor } from '@testing-library/react' +import { act, render, renderHook, waitFor } from '@testing-library/react' +import { Suspense } from 'react' import { BTreeIndex, createCollection, @@ -10,6 +11,7 @@ import { useLiveInfiniteQuery } from '../src/useLiveInfiniteQuery' import { mockSyncCollectionOptions } from '../../db/tests/utils' import { createFilterFunctionFromExpression } from '../../db/src/collection/change-events' import type { LoadSubsetOptions } from '@tanstack/db' +import type { ReactNode } from 'react' type Post = { id: string @@ -108,6 +110,68 @@ function createOnDemandCollection(opts: OnDemandCollectionOptions) { } describe(`useLiveInfiniteQuery`, () => { + it(`does not activate a query-function collection for an abandoned render`, async () => { + const source = createCollection( + mockSyncCollectionOptions({ + id: `abandoned-infinite-query`, + getKey: (post) => post.id, + initialData: createMockPosts(10), + }), + ) + const never = new Promise(() => {}) + + function AbandonedQuery(): ReactNode { + useLiveInfiniteQuery( + (q) => + q + .from({ post: source }) + .orderBy(({ post }) => post.createdAt, `desc`), + { pageSize: 3 }, + ) + throw never + } + + const rendered = render( + + + , + ) + await new Promise((resolve) => setTimeout(resolve, 0)) + + expect(source.subscriberCount).toBe(0) + rendered.unmount() + }) + + it(`does not activate a supplied collection for an abandoned render`, async () => { + const source = createCollection( + mockSyncCollectionOptions({ + id: `abandoned-supplied-infinite-query`, + getKey: (post) => post.id, + initialData: createMockPosts(10), + }), + ) + const liveQuery = createLiveQueryCollection({ + query: (q) => + q.from({ post: source }).orderBy(({ post }) => post.createdAt, `desc`), + }) + const never = new Promise(() => {}) + + function AbandonedQuery(): ReactNode { + useLiveInfiniteQuery(liveQuery, { pageSize: 3 }) + throw never + } + + const rendered = render( + + + , + ) + await new Promise((resolve) => setTimeout(resolve, 0)) + + expect(source.subscriberCount).toBe(0) + rendered.unmount() + }) + it(`should fetch initial page of data`, async () => { const posts = createMockPosts(50) const collection = createCollection( @@ -726,17 +790,108 @@ describe(`useLiveInfiniteQuery`, () => { expect(result.current.data).toHaveLength(5) expect(result.current.pages[0]).toHaveLength(5) - // Grow the page size at runtime (no deps change) — the window and the - // page slicing must both pick it up. + act(() => result.current.fetchNextPage()) + await waitFor(() => expect(result.current.pages).toHaveLength(2)) + act(() => result.current.fetchNextPage()) + await waitFor(() => expect(result.current.pages).toHaveLength(3)) + + // Grow the page size without discarding the committed page count. act(() => { rerender({ pageSize: 10 }) }) - await waitFor(() => expect(result.current.data).toHaveLength(10)) - expect(result.current.pages).toHaveLength(1) + await waitFor(() => expect(result.current.data).toHaveLength(30)) + expect(result.current.pages).toHaveLength(3) expect(result.current.pages[0]).toHaveLength(10) }) + it(`compares dependencies by identity instead of serialization`, async () => { + const source = createCollection( + mockSyncCollectionOptions({ + id: `infinite-query-map-deps`, + getKey: (post) => post.id, + initialData: createMockPosts(10), + }), + ) + const { result, rerender } = renderHook( + ({ filter }: { filter: Map }) => + useLiveInfiniteQuery( + (q) => + q + .from({ post: source }) + .where(({ post }) => eq(post.category, filter.get(`category`))) + .orderBy(({ post }) => post.createdAt, `desc`), + { pageSize: 3 }, + [filter], + ), + { + initialProps: { + filter: new Map([[`category`, `tech`]]), + }, + }, + ) + + await waitFor(() => { + expect(result.current.isReady).toBe(true) + expect(result.current.data.length).toBeGreaterThan(0) + expect( + result.current.data.every((post) => post.category === `tech`), + ).toBe(true) + }) + + rerender({ filter: new Map([[`category`, `life`]]) }) + + await waitFor(() => { + expect(result.current.data.length).toBeGreaterThan(0) + expect( + result.current.data.every((post) => post.category === `life`), + ).toBe(true) + }) + }) + + it(`binds fetchNextPage to the controller that returned it`, async () => { + const sourceA = createCollection( + mockSyncCollectionOptions({ + id: `infinite-query-generation-a`, + getKey: (post) => post.id, + initialData: createMockPosts(10), + }), + ) + const sourceB = createCollection( + mockSyncCollectionOptions({ + id: `infinite-query-generation-b`, + getKey: (post) => post.id, + initialData: createMockPosts(10), + }), + ) + const queryA = createLiveQueryCollection({ + query: (q) => + q.from({ post: sourceA }).orderBy(({ post }) => post.createdAt, `desc`), + }) + const queryB = createLiveQueryCollection({ + query: (q) => + q.from({ post: sourceB }).orderBy(({ post }) => post.createdAt, `desc`), + }) + const { result, rerender } = renderHook( + ({ query }) => useLiveInfiniteQuery(query, { pageSize: 2 }), + { initialProps: { query: queryA } }, + ) + + await waitFor(() => expect(result.current.isReady).toBe(true)) + const fetchFromA = result.current.fetchNextPage + + rerender({ query: queryB }) + await waitFor(() => { + expect(result.current.collection).toBe(queryB) + expect(result.current.isReady).toBe(true) + expect(result.current.pages).toHaveLength(1) + }) + + act(() => fetchFromA()) + + expect(result.current.pages).toHaveLength(1) + }) + it(`should track pageParams correctly`, async () => { const posts = createMockPosts(30) const collection = createCollection( @@ -1801,7 +1956,7 @@ describe(`useLiveInfiniteQuery`, () => { await liveQueryCollection.preload() // Give the collection a concrete window that differs from the hook's // expected first page (offset 0, limit pageSize + 1). - liveQueryCollection.utils.setWindow({ offset: 0, limit: 5 }) + await liveQueryCollection.utils.setWindow({ offset: 0, limit: 5 }) const warn = vi.spyOn(console, `warn`).mockImplementation(() => {}) try { @@ -1811,6 +1966,10 @@ describe(`useLiveInfiniteQuery`, () => { expect(warn).toHaveBeenCalledWith( expect.stringContaining(`Pre-created collection has window`), ) + expect(liveQueryCollection.utils.getWindow()).toEqual({ + offset: 0, + limit: 11, + }) } finally { warn.mockRestore() } @@ -1945,7 +2104,7 @@ describe(`useLiveInfiniteQuery`, () => { }) }) - it(`throws a descriptive error when deps contain non-serializable values`, () => { + it(`accepts circular dependency values`, async () => { const posts = createMockPosts(10) const collection = createCollection( mockSyncCollectionOptions({ @@ -1959,21 +2118,17 @@ describe(`useLiveInfiniteQuery`, () => { const circular: Record = { a: 1 } circular.self = circular - expect(() => { - renderHook(() => { - return useLiveInfiniteQuery( - (q) => - q - .from({ posts: collection }) - .orderBy(({ posts: p }) => p.createdAt, `desc`), - { - pageSize: 5, - getNextPageParam: (lastPage) => - lastPage.length === 5 ? lastPage.length : undefined, - }, - [circular], - ) - }) - }).toThrow(/useLiveInfiniteQuery.*dependency/) + const { result } = renderHook(() => + useLiveInfiniteQuery( + (q) => + q + .from({ posts: collection }) + .orderBy(({ posts: p }) => p.createdAt, `desc`), + { pageSize: 5 }, + [circular], + ), + ) + + await waitFor(() => expect(result.current.isReady).toBe(true)) }) }) From d704b954d79ad3daa6bccd15b741cdd40d6be94d Mon Sep 17 00:00:00 2001 From: Tanner Linsley Date: Tue, 11 Aug 2026 20:58:45 -0600 Subject: [PATCH 38/42] fix(db): close window controller race gaps --- .../db/src/live-query-window-controller.ts | 31 ++++--- .../query/live/collection-config-builder.ts | 17 +++- .../live-query-window-controller.test.ts | 85 +++++++++++++++++++ .../tests/useLiveInfiniteQuery.test.tsx | 7 +- 4 files changed, 126 insertions(+), 14 deletions(-) diff --git a/packages/db/src/live-query-window-controller.ts b/packages/db/src/live-query-window-controller.ts index 9e7663a5c..0781571a0 100644 --- a/packages/db/src/live-query-window-controller.ts +++ b/packages/db/src/live-query-window-controller.ts @@ -234,6 +234,7 @@ class LiveQueryWindowControllerImpl< private pendingWindowGeneration: number | undefined private leaseActive = false private leaseGeneration = 0 + private inFlightLeaseHolders = 0 private readonly subscriptions = new Set() private readonly publicationQueue: Array = [] @@ -371,7 +372,7 @@ class LiveQueryWindowControllerImpl< if (this.subscriptions.size === 0) { this.observerUnsub?.() this.observerUnsub = null - this.deactivateLease() + if (this.inFlightLeaseHolders === 0) this.deactivateLease() } } } @@ -398,11 +399,16 @@ class LiveQueryWindowControllerImpl< async preload(): Promise { if (this.disposed) throw new LiveQueryWindowControllerDisposedError() - const temporaryLease = !this.leaseActive + const hadPaginationError = this.hasPaginationError + this.hasPaginationError = false + this.paginationError = undefined + this.acquireInFlightLease() try { const result = this.activateLease(this.committedPageCount) if (result !== true) await result await this.observer.preload() + this.failedHasNextPage = false + if (hadPaginationError) this.notify() } catch (error) { this.hasPaginationError = true this.paginationError = error @@ -410,9 +416,7 @@ class LiveQueryWindowControllerImpl< this.notify() throw error } finally { - if (temporaryLease && this.subscriptions.size === 0) { - this.deactivateLease() - } + this.releaseInFlightLease() } } @@ -436,8 +440,8 @@ class LiveQueryWindowControllerImpl< ): Promise { const generation = ++this.windowGeneration const previousHasNextPage = this.getSnapshot().hasNextPage - const temporaryLease = !this.leaseActive && this.subscriptions.size === 0 this.pendingWindowGeneration = undefined + this.acquireInFlightLease() this.beginTransition() this.isFetchingNextPage = fetchingNextPage @@ -455,7 +459,7 @@ class LiveQueryWindowControllerImpl< this.failedHasNextPage = previousHasNextPage this.notify() this.endTransition() - if (temporaryLease) this.deactivateLease() + this.releaseInFlightLease() return Promise.reject(error) } @@ -467,7 +471,7 @@ class LiveQueryWindowControllerImpl< this.notify() } this.endTransition() - if (temporaryLease) this.deactivateLease() + this.releaseInFlightLease() return Promise.resolve() } @@ -501,12 +505,17 @@ class LiveQueryWindowControllerImpl< }, ) .finally(() => { - this.releaseTemporaryLease(temporaryLease) + this.releaseInFlightLease() }) } - private releaseTemporaryLease(temporaryLease: boolean): void { - if (temporaryLease && this.subscriptions.size === 0) { + private acquireInFlightLease(): void { + this.inFlightLeaseHolders++ + } + + private releaseInFlightLease(): void { + this.inFlightLeaseHolders-- + if (this.inFlightLeaseHolders === 0 && this.subscriptions.size === 0) { this.deactivateLease() } } diff --git a/packages/db/src/query/live/collection-config-builder.ts b/packages/db/src/query/live/collection-config-builder.ts index 9c26bbfe6..ab2906d59 100644 --- a/packages/db/src/query/live/collection-config-builder.ts +++ b/packages/db/src/query/live/collection-config-builder.ts @@ -123,6 +123,7 @@ export class CollectionConfigBuilder< public liveQueryCollection?: Collection private windowFn: ((options: WindowOptions) => void) | undefined + private readonly initialWindow: WindowOptions | undefined private currentWindow: WindowOptions | undefined private maybeRunGraphFn: (() => void) | undefined @@ -176,6 +177,12 @@ export class CollectionConfigBuilder< query: config.query, requireObjectResult: true, }) + this.initialWindow = this.query.orderBy?.length + ? { + offset: this.query.offset ?? 0, + limit: this.query.limit ?? Infinity, + } + : undefined this.collections = extractCollectionsFromQuery(this.query) const collectionAliasesById = extractCollectionAliases(this.query) @@ -275,14 +282,20 @@ export class CollectionConfigBuilder< throw new SetWindowRequiresOrderByError() } - const previousWindow = this.currentWindow + const previousWindow = this.currentWindow ?? this.initialWindow try { this.windowFn(options) this.maybeRunGraphFn?.() this.currentWindow = options } catch (error) { if (previousWindow) { - this.windowFn(previousWindow) + try { + this.windowFn(previousWindow) + this.maybeRunGraphFn?.() + } catch { + // Recovery is best-effort; preserve the error from the requested + // window rather than replacing it with a rollback failure. + } } throw error } diff --git a/packages/db/tests/live-query-window-controller.test.ts b/packages/db/tests/live-query-window-controller.test.ts index fa511afd2..9b57c0f51 100644 --- a/packages/db/tests/live-query-window-controller.test.ts +++ b/packages/db/tests/live-query-window-controller.test.ts @@ -1,6 +1,7 @@ import { describe, expect, it, vi } from 'vitest' import { createCollection } from '../src/collection/index.js' import { createLiveQueryCollection } from '../src/query/live-query-collection.js' +import { LIVE_QUERY_INTERNAL } from '../src/query/live/internal.js' import { LiveQueryWindowControllerDisposedError } from '../src/errors.js' import { createLiveQueryWindowController } from '../src/live-query-window-controller.js' import { mockSyncCollectionOptions } from './utils.js' @@ -205,6 +206,35 @@ describe(`createLiveQueryWindowController`, () => { controller.dispose() }) + it(`restores the initial operator window when a graph run throws`, () => { + const lq = makeOrderedLiveQuery(makeSource(), 2) + const builder = lq.utils[LIVE_QUERY_INTERNAL].getBuilder() + const originalWindowFn = Reflect.get(builder, `windowFn`) as (options: { + offset?: number + limit?: number + }) => void + const windowFn = vi.fn(originalWindowFn) + const requestedError = new Error(`requested window failed`) + const maybeRunGraph = vi + .fn() + .mockImplementationOnce(() => { + throw requestedError + }) + .mockImplementationOnce(() => { + throw new Error(`rollback failed`) + }) + Reflect.set(builder, `windowFn`, windowFn) + Reflect.set(builder, `maybeRunGraphFn`, maybeRunGraph) + + expect(() => lq.utils.setWindow({ offset: 0, limit: 5 })).toThrow( + requestedError, + ) + expect(windowFn).toHaveBeenNthCalledWith(1, { offset: 0, limit: 5 }) + expect(windowFn).toHaveBeenNthCalledWith(2, { offset: 0, limit: 3 }) + expect(maybeRunGraph).toHaveBeenCalledTimes(2) + expect(lq.utils.getWindow()).toBeUndefined() + }) + it(`keeps the committed page retryable when a window load rejects`, async () => { const lq = makeOrderedLiveQuery(makeSource(), 2) const controller = createLiveQueryWindowController(lq as any, { @@ -233,6 +263,23 @@ describe(`createLiveQueryWindowController`, () => { controller.dispose() }) + it(`clears a preload error after a successful retry`, async () => { + const lq = makeOrderedLiveQuery(makeSource(), 2) + const failure = new Error(`preload failed`) + vi.spyOn(lq.utils, `setWindow`).mockRejectedValueOnce(failure) + const controller = createLiveQueryWindowController(lq as any, { + pageSize: 2, + }) + + await expect(controller.preload()).rejects.toBe(failure) + expect(controller.getSnapshot().error).toBe(failure) + + await controller.preload() + expect(controller.getSnapshot().isError).toBe(false) + expect(controller.getSnapshot().error).toBeUndefined() + controller.dispose() + }) + it(`publishes one coherent loading snapshot and one settled snapshot`, async () => { const lq = makeOrderedLiveQuery(makeSource(), 2) const controller = createLiveQueryWindowController(lq as any, { @@ -302,6 +349,44 @@ describe(`createLiveQueryWindowController`, () => { controller.dispose() }) + it(`retains an unsubscribed lease until overlapping requests settle`, async () => { + const lq = makeOrderedLiveQuery(makeSource(), 2) + await lq.preload() + + const originalSetWindow = lq.utils.setWindow.bind(lq.utils) + const resolvers: Array<() => void> = [] + const setWindow = vi + .spyOn(lq.utils, `setWindow`) + .mockImplementation((options) => { + originalSetWindow(options) + if (resolvers.length >= 2) return true + return new Promise((resolve) => resolvers.push(resolve)) + }) + const controller = createLiveQueryWindowController(lq as any, { + pageSize: 2, + }) + + const expansion = controller.fetchNextPage() + const reset = controller.reset() + expect(setWindow).toHaveBeenCalledTimes(2) + + resolvers[0]!() + await expansion + + const competingController = createLiveQueryWindowController( + lq as any, + { pageSize: 1 }, + ) + competingController.subscribe(() => {}) + expect(setWindow).toHaveBeenCalledTimes(2) + + resolvers[1]!() + await reset + expect(controller.getSnapshot().pages).toHaveLength(1) + competingController.dispose() + controller.dispose() + }) + it(`replays the desired window after collection cleanup`, async () => { const lq = makeOrderedLiveQuery(makeSource(), 2) const controller = createLiveQueryWindowController(lq as any, { diff --git a/packages/react-db/tests/useLiveInfiniteQuery.test.tsx b/packages/react-db/tests/useLiveInfiniteQuery.test.tsx index 475f04713..73189f766 100644 --- a/packages/react-db/tests/useLiveInfiniteQuery.test.tsx +++ b/packages/react-db/tests/useLiveInfiniteQuery.test.tsx @@ -888,8 +888,13 @@ describe(`useLiveInfiniteQuery`, () => { }) act(() => fetchFromA()) - + await act(async () => { + await new Promise((resolve) => setTimeout(resolve, 0)) + }) expect(result.current.pages).toHaveLength(1) + + act(() => result.current.fetchNextPage()) + await waitFor(() => expect(result.current.pages).toHaveLength(2)) }) it(`should track pageParams correctly`, async () => { From ed51adbf6516b47de7f2c70d2b5c214306218bd0 Mon Sep 17 00:00:00 2001 From: Kyle Mathews Date: Wed, 12 Aug 2026 11:21:59 -0600 Subject: [PATCH 39/42] fix(db): preserve live query observer invariants --- packages/db/src/collection/changes.ts | 20 +++- packages/db/src/collection/index.ts | 5 + packages/db/src/collection/subscription.ts | 19 +--- packages/db/src/live-query-observer.ts | 26 ++++- .../tests/live-query-order-only-move.test.ts | 41 +++++-- packages/react-db/src/useLiveInfiniteQuery.ts | 4 + .../tests/useLiveInfiniteQuery.test.tsx | 104 +++++++++++++++++- 7 files changed, 185 insertions(+), 34 deletions(-) diff --git a/packages/db/src/collection/changes.ts b/packages/db/src/collection/changes.ts index 87d092f9b..d72f0f0c8 100644 --- a/packages/db/src/collection/changes.ts +++ b/packages/db/src/collection/changes.ts @@ -29,6 +29,7 @@ export class CollectionChangesManager< public changeSubscriptions = new Set() public batchedEvents: Array> = [] public shouldBatchEvents = false + private layoutChangeListeners = new Set<() => void>() /** * Monotonic revision of the collection's visible state, advanced once per @@ -41,8 +42,7 @@ export class CollectionChangesManager< /** * Monotonic revision advanced only for explicit layout-only publications. - * This distinguishes them from the legacy empty ready event, since both use - * an empty change batch at the public subscription boundary. + * Observers use it to detect reordered rows whose values did not change. */ public layoutRevision = 0 @@ -124,6 +124,14 @@ export class CollectionChangesManager< return } + // A layout-only publication is not a ChangeMessage batch. Keep it on the + // observer's internal channel instead of overloading the public empty + // ready event with a second meaning. + if (rawEvents.length === 0) { + for (const listener of this.layoutChangeListeners) listener() + return + } + // Enrich all change messages with virtual properties // This uses the "add-if-missing" pattern to preserve pass-through semantics const enrichedEvents: Array< @@ -132,10 +140,16 @@ export class CollectionChangesManager< // Emit to all listeners for (const subscription of this.changeSubscriptions) { - subscription.emitEvents(enrichedEvents, layoutChanged) + subscription.emitEvents(enrichedEvents) } } + /** Subscribe to layout-only publications. Internal observer channel. */ + public subscribeLayoutChanges(listener: () => void): () => void { + this.layoutChangeListeners.add(listener) + return () => this.layoutChangeListeners.delete(listener) + } + /** * Subscribe to changes in the collection */ diff --git a/packages/db/src/collection/index.ts b/packages/db/src/collection/index.ts index c775e7ada..f9bb465fe 100644 --- a/packages/db/src/collection/index.ts +++ b/packages/db/src/collection/index.ts @@ -437,6 +437,11 @@ export class CollectionImpl< return this._changes.layoutRevision } + /** Subscribe to layout-only publications. Internal observer channel. */ + public _subscribeLayoutChanges(listener: () => void): () => void { + return this._changes.subscribeLayoutChanges(listener) + } + /** Mark the active sync transaction as layout-changing. Internal. */ public _markLayoutChange(): void { this._sync.markLayoutChange() diff --git a/packages/db/src/collection/subscription.ts b/packages/db/src/collection/subscription.ts index 0f603ee71..3229b6f68 100644 --- a/packages/db/src/collection/subscription.ts +++ b/packages/db/src/collection/subscription.ts @@ -99,7 +99,6 @@ export class CollectionSubscription // This prevents a flash of missing content between deletes and new inserts private isBufferingForTruncate = false private truncateBuffer: Array>> = [] - private truncateBufferHasLayoutChange = false private pendingTruncateRefetches: Set> = new Set() public get status(): SubscriptionStatus { @@ -249,14 +248,9 @@ export class CollectionSubscription // Flatten all buffered changes into a single array for atomic emission // This ensures consumers see all truncate changes (deletes + inserts) in one callback const merged = this.truncateBuffer.flat() - const layoutChanged = this.truncateBufferHasLayoutChange - if (merged.length > 0 || layoutChanged) { - const delivered = this.filteredCallback(merged) - if (layoutChanged && !delivered) this.filteredCallback([]) - } + if (merged.length > 0) this.filteredCallback(merged) this.truncateBuffer = [] - this.truncateBufferHasLayoutChange = false } setOrderByIndex(index: IndexInterface) { @@ -325,10 +319,7 @@ export class CollectionSubscription return this.snapshotSent } - emitEvents( - changes: Array>, - layoutChanged = false, - ): boolean { + emitEvents(changes: Array>): boolean { const newChanges = this.filterAndFlipChanges(changes) if (this.isBufferingForTruncate) { @@ -337,12 +328,9 @@ export class CollectionSubscription if (newChanges.length > 0) { this.truncateBuffer.push(newChanges) } - if (layoutChanged) this.truncateBufferHasLayoutChange = true return false } else { - const delivered = this.filteredCallback(newChanges) - if (layoutChanged && !delivered) return this.filteredCallback([]) - return delivered + return this.filteredCallback(newChanges) } } @@ -742,7 +730,6 @@ export class CollectionSubscription // Clean up truncate buffer state this.isBufferingForTruncate = false this.truncateBuffer = [] - this.truncateBufferHasLayoutChange = false this.pendingTruncateRefetches.clear() // Unload all subsets that this subscription loaded diff --git a/packages/db/src/live-query-observer.ts b/packages/db/src/live-query-observer.ts index 3bb35c818..1cb0cba28 100644 --- a/packages/db/src/live-query-observer.ts +++ b/packages/db/src/live-query-observer.ts @@ -44,7 +44,10 @@ export interface LiveQuerySnapshot< isEnabled: boolean } -/** Listener payload: the change set, or `undefined` for the synthetic ready notify. */ +/** + * Listener payload: changes, `[]` for an internal layout-only publication, or + * `undefined` for a synthetic status/ready notification. + */ export type LiveQueryObserverListener< T extends object, TKey extends string | number, @@ -357,11 +360,16 @@ class LiveQueryObserverImpl< const notify = ( changes: Array> | undefined, status: CollectionStatus = collection.status, + explicitLayoutChange = false, ) => { if (this.disposed || this.subscriptions.size === 0) return const layoutRevision = this.getCollectionLayoutRevision(collection) - let layoutChanged = false - if (changes !== undefined && changes.length === 0) { + let layoutChanged = explicitLayoutChange + if ( + !explicitLayoutChange && + changes !== undefined && + changes.length === 0 + ) { // Empty ready events predate the explicit layout signal and share its // empty-array payload. Only forward an empty batch when the collection // confirms that a new layout-only publication occurred. @@ -401,6 +409,17 @@ class LiveQueryObserverImpl< const statusUnsub = collection.on(`status:change`, ({ status }) => notify(undefined, status), ) + const subscribeLayoutChanges = ( + collection as Collection & { + _subscribeLayoutChanges?: (listener: () => void) => () => void + } + )._subscribeLayoutChanges + const layoutUnsub = + typeof subscribeLayoutChanges === `function` + ? subscribeLayoutChanges.call(collection, () => + notify([], collection.status, true), + ) + : () => {} // `subscribeChanges` delivers the initial state synchronously, so a // listener can dispose the observer while the collection subscription is @@ -410,6 +429,7 @@ class LiveQueryObserverImpl< let subscription: { unsubscribe: () => void } | null = null const release = () => { statusUnsub() + layoutUnsub() subscription?.unsubscribe() } this.collectionUnsub = release diff --git a/packages/db/tests/live-query-order-only-move.test.ts b/packages/db/tests/live-query-order-only-move.test.ts index 2637bf8a3..c6d175466 100644 --- a/packages/db/tests/live-query-order-only-move.test.ts +++ b/packages/db/tests/live-query-order-only-move.test.ts @@ -240,6 +240,27 @@ describe(`order-only move (RFC #1623 phase 4)`, () => { observer.dispose() }) + it(`does not expose layout-only signals as empty public change batches`, async () => { + const source = makeSource() + const lq = await makeOrderedByAge(source) + const publications: Array> = [] + const subscription = lq.subscribeChanges( + (changes) => publications.push(changes), + { includeInitialState: false }, + ) + + source.utils.begin() + source.utils.write({ + type: `update`, + value: { id: `2`, name: `Bob`, age: 99 }, + }) + source.utils.commit() + await flush() + + expect(publications).toEqual([]) + subscription.unsubscribe() + }) + it(`bumps the layout revision on membership changes too`, async () => { const source = makeSource() const lq = await makeOrderedByAge(source) @@ -338,7 +359,7 @@ describe(`order-only move (RFC #1623 phase 4)`, () => { }) source.utils.commit() - expect(publications).toEqual([[]]) + expect(publications).toEqual([]) subscription.unsubscribe() }) @@ -381,11 +402,10 @@ describe(`order-only move (RFC #1623 phase 4)`, () => { await lq.preload() const childCollection = (lq.get(`p1`) as any).children + const observer = createLiveQueryObserver(childCollection) let notifications = 0 - const subscription = childCollection.subscribeChanges( - () => notifications++, - { includeInitialState: false }, - ) + observer.subscribe(() => notifications++) + notifications = 0 children.utils.begin() children.utils.write({ @@ -399,7 +419,7 @@ describe(`order-only move (RFC #1623 phase 4)`, () => { `c1`, ]) expect(notifications).toBe(1) - subscription.unsubscribe() + observer.dispose() }) // The includes flush is recursive, so the order-only-move handling must hold @@ -461,11 +481,10 @@ describe(`order-only move (RFC #1623 phase 4)`, () => { const teamCollection = (lq.get(`o1`) as any).teams const memberCollection = teamCollection.get(`t1`).members + const observer = createLiveQueryObserver(memberCollection) let notifications = 0 - const subscription = memberCollection.subscribeChanges( - () => notifications++, - { includeInitialState: false }, - ) + observer.subscribe(() => notifications++) + notifications = 0 // Move m1 behind m2 (position 1 -> 3); projected { id, name } unchanged. members.utils.begin() @@ -480,6 +499,6 @@ describe(`order-only move (RFC #1623 phase 4)`, () => { `m1`, ]) expect(notifications).toBe(1) - subscription.unsubscribe() + observer.dispose() }) }) diff --git a/packages/react-db/src/useLiveInfiniteQuery.ts b/packages/react-db/src/useLiveInfiniteQuery.ts index 70a250f13..da56bd472 100644 --- a/packages/react-db/src/useLiveInfiniteQuery.ts +++ b/packages/react-db/src/useLiveInfiniteQuery.ts @@ -178,6 +178,8 @@ export function useLiveInfiniteQuery( const pageSizeRef = useRef(pageSize) const initialPageParamRef = useRef(initialPageParam) const validatedCollectionRef = useRef(null) + const inputKind = isCollection ? `collection` : `query` + const inputKindRef = useRef(null) const dependenciesChanged = !isCollection && @@ -186,6 +188,7 @@ export function useLiveInfiniteQuery( depsRef.current.some((dep, index) => dep !== deps[index])) const needsNewCollection = !collectionRef.current || + inputKindRef.current !== inputKind || (isCollection && configRef.current !== queryFnOrCollection) || dependenciesChanged const pageShapeChanged = @@ -195,6 +198,7 @@ export function useLiveInfiniteQuery( !controllerRef.current || needsNewCollection || pageShapeChanged if (needsNewCollection) { + inputKindRef.current = inputKind if (isCollection) { const collection = queryFnOrCollection as Collection if (!hasSetWindow(collection)) { diff --git a/packages/react-db/tests/useLiveInfiniteQuery.test.tsx b/packages/react-db/tests/useLiveInfiniteQuery.test.tsx index 73189f766..af7c46351 100644 --- a/packages/react-db/tests/useLiveInfiniteQuery.test.tsx +++ b/packages/react-db/tests/useLiveInfiniteQuery.test.tsx @@ -10,7 +10,7 @@ import { import { useLiveInfiniteQuery } from '../src/useLiveInfiniteQuery' import { mockSyncCollectionOptions } from '../../db/tests/utils' import { createFilterFunctionFromExpression } from '../../db/src/collection/change-events' -import type { LoadSubsetOptions } from '@tanstack/db' +import type { InitialQueryBuilder, LoadSubsetOptions } from '@tanstack/db' import type { ReactNode } from 'react' type Post = { @@ -2136,4 +2136,106 @@ describe(`useLiveInfiniteQuery`, () => { await waitFor(() => expect(result.current.isReady).toBe(true)) }) + + it(`recreates when switching collection to query function and back`, async () => { + const collectionSource = createCollection( + mockSyncCollectionOptions({ + id: `input-kind-collection-source`, + getKey: (post) => post.id, + initialData: createMockPosts(10), + }), + ) + const querySource = createCollection( + mockSyncCollectionOptions({ + id: `input-kind-query-source`, + getKey: (post) => post.id, + initialData: createMockPosts(10).map((post) => ({ + ...post, + id: `query-${post.id}`, + })), + }), + ) + const collectionInput = createLiveQueryCollection({ + query: (q) => + q + .from({ posts: collectionSource }) + .orderBy(({ posts }) => posts.createdAt, `desc`) + .limit(4), + }) + await collectionInput.preload() + const queryInput = (q: InitialQueryBuilder) => + q + .from({ posts: querySource }) + .orderBy(({ posts }) => posts.createdAt, `desc`) + + const { result, rerender } = renderHook( + ({ useCollection }: { useCollection: boolean }) => + useLiveInfiniteQuery( + (useCollection ? collectionInput : queryInput) as any, + { pageSize: 3 }, + ...((useCollection ? [] : [[]]) as [Array] | []), + ), + { initialProps: { useCollection: true } }, + ) + + await waitFor(() => expect(result.current.isReady).toBe(true)) + expect((result.current.data[0] as Post).id).toBe(`1`) + rerender({ useCollection: false }) + await waitFor(() => + expect((result.current.data[0] as Post).id).toBe(`query-1`), + ) + rerender({ useCollection: true }) + await waitFor(() => expect((result.current.data[0] as Post).id).toBe(`1`)) + }) + + it(`recreates when switching query function to collection and back`, async () => { + const querySource = createCollection( + mockSyncCollectionOptions({ + id: `input-kind-query-first-source`, + getKey: (post) => post.id, + initialData: createMockPosts(10), + }), + ) + const collectionSource = createCollection( + mockSyncCollectionOptions({ + id: `input-kind-collection-second-source`, + getKey: (post) => post.id, + initialData: createMockPosts(10).map((post) => ({ + ...post, + id: `collection-${post.id}`, + })), + }), + ) + const queryInput = (q: InitialQueryBuilder) => + q + .from({ posts: querySource }) + .orderBy(({ posts }) => posts.createdAt, `desc`) + const collectionInput = createLiveQueryCollection({ + query: (q) => + q + .from({ posts: collectionSource }) + .orderBy(({ posts }) => posts.createdAt, `desc`) + .limit(4), + }) + await collectionInput.preload() + + const { result, rerender } = renderHook( + ({ useCollection }: { useCollection: boolean }) => + useLiveInfiniteQuery( + (useCollection ? collectionInput : queryInput) as any, + { pageSize: 3 }, + ...((useCollection ? [] : [[]]) as [Array] | []), + ), + { initialProps: { useCollection: false } }, + ) + + await waitFor(() => expect(result.current.isReady).toBe(true)) + expect((result.current.data[0] as Post).id).toBe(`1`) + rerender({ useCollection: true }) + await waitFor(() => + expect((result.current.data[0] as Post).id).toBe(`collection-1`), + ) + rerender({ useCollection: false }) + await waitFor(() => expect((result.current.data[0] as Post).id).toBe(`1`)) + }) }) From e7309803a7c9e0ef0760d4034741ce720c57467d Mon Sep 17 00:00:00 2001 From: Kyle Mathews Date: Wed, 12 Aug 2026 11:48:39 -0600 Subject: [PATCH 40/42] fix(react-db): handle pagination load failures --- packages/react-db/src/useLiveInfiniteQuery.ts | 5 ++- .../tests/useLiveInfiniteQuery.test.tsx | 35 +++++++++++++++++++ 2 files changed, 39 insertions(+), 1 deletion(-) diff --git a/packages/react-db/src/useLiveInfiniteQuery.ts b/packages/react-db/src/useLiveInfiniteQuery.ts index da56bd472..d4f6fe473 100644 --- a/packages/react-db/src/useLiveInfiniteQuery.ts +++ b/packages/react-db/src/useLiveInfiniteQuery.ts @@ -267,7 +267,10 @@ export function useLiveInfiniteQuery( const snapshot = useSyncExternalStore(subscribe, getSnapshot) const fetchNextPage = useCallback(() => { - void controller.fetchNextPage() + void controller.fetchNextPage().catch(() => { + // Pagination errors are exposed through the controller snapshot. The + // hook's void callback has no promise error channel, so consume it here. + }) }, [controller]) return { diff --git a/packages/react-db/tests/useLiveInfiniteQuery.test.tsx b/packages/react-db/tests/useLiveInfiniteQuery.test.tsx index af7c46351..1f420495f 100644 --- a/packages/react-db/tests/useLiveInfiniteQuery.test.tsx +++ b/packages/react-db/tests/useLiveInfiniteQuery.test.tsx @@ -897,6 +897,41 @@ describe(`useLiveInfiniteQuery`, () => { await waitFor(() => expect(result.current.pages).toHaveLength(2)) }) + it(`exposes pagination failures without an unhandled rejection`, async () => { + const source = createCollection( + mockSyncCollectionOptions({ + id: `infinite-query-pagination-failure`, + getKey: (post) => post.id, + initialData: createMockPosts(10), + }), + ) + const query = createLiveQueryCollection({ + query: (q) => + q.from({ post: source }).orderBy(({ post }) => post.createdAt, `desc`), + }) + const { result } = renderHook(() => + useLiveInfiniteQuery(query, { pageSize: 2 }), + ) + + await waitFor(() => { + expect(result.current.isReady).toBe(true) + expect(result.current.hasNextPage).toBe(true) + }) + + const failure = new Error(`window load failed`) + vi.spyOn(query.utils, `setWindow`).mockRejectedValueOnce(failure) + + act(() => result.current.fetchNextPage()) + + await waitFor(() => { + expect(result.current.isError).toBe(true) + expect(result.current.error).toBe(failure) + expect(result.current.isFetchingNextPage).toBe(false) + }) + expect(result.current.pages).toHaveLength(1) + expect(result.current.hasNextPage).toBe(true) + }) + it(`should track pageParams correctly`, async () => { const posts = createMockPosts(30) const collection = createCollection( From b7172eaa83d9323f63d2549b5d2bf683d5c3b908 Mon Sep 17 00:00:00 2001 From: Kyle Mathews Date: Wed, 12 Aug 2026 13:33:04 -0600 Subject: [PATCH 41/42] fix(db): harden live query window coordination --- packages/db/src/collection/changes.ts | 7 +- packages/db/src/collection/subscription.ts | 5 +- packages/db/src/collection/sync.ts | 17 +- .../db/src/live-query-window-controller.ts | 135 ++++++++-- .../query/live/collection-config-builder.ts | 27 +- .../src/query/live/collection-subscriber.ts | 5 +- .../tests/live-query-order-only-move.test.ts | 4 +- .../live-query-window-controller.test.ts | 240 +++++++++++++++++- packages/react-db/src/useLiveInfiniteQuery.ts | 18 +- .../tests/useLiveInfiniteQuery.test.tsx | 63 ++++- 10 files changed, 463 insertions(+), 58 deletions(-) diff --git a/packages/db/src/collection/changes.ts b/packages/db/src/collection/changes.ts index d72f0f0c8..b5b9dfb83 100644 --- a/packages/db/src/collection/changes.ts +++ b/packages/db/src/collection/changes.ts @@ -124,12 +124,11 @@ export class CollectionChangesManager< return } - // A layout-only publication is not a ChangeMessage batch. Keep it on the - // observer's internal channel instead of overloading the public empty - // ready event with a second meaning. + // Notify both internal layout consumers and the public subscription API. + // Public subscribers historically receive an empty batch for order-only + // moves because there is no row-value ChangeMessage to publish. if (rawEvents.length === 0) { for (const listener of this.layoutChangeListeners) listener() - return } // Enrich all change messages with virtual properties diff --git a/packages/db/src/collection/subscription.ts b/packages/db/src/collection/subscription.ts index 3229b6f68..7757be14a 100644 --- a/packages/db/src/collection/subscription.ts +++ b/packages/db/src/collection/subscription.ts @@ -302,12 +302,13 @@ export class CollectionSubscription this.pendingLoadSubsetPromises.add(syncResult) this.setStatus(`loadingSubset`) - syncResult.finally(() => { + const finish = () => { this.pendingLoadSubsetPromises.delete(syncResult) if (this.pendingLoadSubsetPromises.size === 0) { this.setStatus(`ready`) } - }) + } + void syncResult.then(finish, finish) } } diff --git a/packages/db/src/collection/sync.ts b/packages/db/src/collection/sync.ts index 8fa49b163..d71710611 100644 --- a/packages/db/src/collection/sync.ts +++ b/packages/db/src/collection/sync.ts @@ -444,6 +444,18 @@ export class CollectionSyncManager< return this.pendingLoadSubsetPromises.size > 0 } + /** Wait for the subset loads that are active during the current operation. */ + public waitForCurrentLoadSubset(): true | Promise { + if (this.pendingLoadSubsetPromises.size === 0) return true + return this.waitForPendingLoadSubset() + } + + private async waitForPendingLoadSubset(): Promise { + do { + await Promise.all([...this.pendingLoadSubsetPromises]) + } while (this.pendingLoadSubsetPromises.size > 0) + } + /** * Tracks a load promise for isLoadingSubset state. * @internal This is for internal coordination (e.g., live-query glue code), not for general use. @@ -462,7 +474,7 @@ export class CollectionSyncManager< }) } - promise.finally(() => { + const finish = () => { const loadingEnding = this.pendingLoadSubsetPromises.size === 1 && this.pendingLoadSubsetPromises.has(promise) @@ -477,7 +489,8 @@ export class CollectionSyncManager< loadingSubsetTransition: `end`, }) } - }) + } + void promise.then(finish, finish) } /** diff --git a/packages/db/src/live-query-window-controller.ts b/packages/db/src/live-query-window-controller.ts index 0781571a0..e22ca6170 100644 --- a/packages/db/src/live-query-window-controller.ts +++ b/packages/db/src/live-query-window-controller.ts @@ -2,6 +2,7 @@ import { LiveQueryWindowControllerDisposedError, SetWindowRequiresOrderByError, } from './errors.js' +import { getLiveQueryStatusFlags } from './live-query-adapter.js' import { createLiveQueryObserver } from './live-query-observer.js' import type { LiveQueryObserver, @@ -17,6 +18,7 @@ type WindowResult = true | Promise type WindowTarget = object & { utils?: { setWindow?: (options: { offset: number; limit: number }) => WindowResult + getWindow?: () => { offset: number; limit: number } | undefined } } @@ -28,29 +30,76 @@ type PendingWindow = { class WindowCoordinator { private readonly leases = new Map() + private readonly leaseVersions = new Map() + private readonly initialWindow: { offset: number; limit: number } | undefined private appliedLimit: number | undefined private pending: PendingWindow | undefined private generation = 0 + private leaseVersion = 0 - constructor(private readonly target: WindowTarget) {} + constructor(private readonly target: WindowTarget) { + this.initialWindow = target.utils?.getWindow?.() + } request(lease: symbol, limit: number): WindowResult { + const previousLimit = this.leases.get(lease) + const previousVersion = this.leaseVersions.get(lease) + const version = ++this.leaseVersion this.leases.set(lease, limit) - return this.applyDesiredWindow() + this.leaseVersions.set(lease, version) + + let result: WindowResult + try { + result = this.applyDesiredWindow() + } catch (error) { + this.rollbackLease(lease, version, previousLimit, previousVersion) + this.appliedLimit = undefined + throw error + } + + if (result === true) return true + return result.catch(async (error: unknown) => { + if (this.rollbackLease(lease, version, previousLimit, previousVersion)) { + this.generation++ + this.pending = undefined + this.appliedLimit = undefined + try { + if (this.leases.size === 0) { + this.restoreInitialWindow() + } else { + const rollback = this.applyDesiredWindow() + if (rollback !== true) await rollback + } + } catch { + // Preserve the failure from the requested window. + } + } + throw error + }) + } + + isLeaseSatisfied(lease: symbol, minimumLimit: number): boolean { + const limit = this.leases.get(lease) + if (limit === undefined || limit < minimumLimit) return false + const desiredLimit = this.getDesiredLimit() + const currentWindow = this.target.utils?.getWindow?.() + return ( + currentWindow === undefined || + (currentWindow.offset === 0 && currentWindow.limit === desiredLimit) + ) } release(lease: symbol): void { if (!this.leases.delete(lease)) return + this.leaseVersions.delete(lease) // A pending request may still mutate the physical operator, but it no longer // establishes the accepted window for the remaining lease set. this.generation++ this.pending = undefined + this.appliedLimit = undefined if (this.leases.size === 0) { - // There is no consumer-visible window to maintain. Force the next lease to - // re-apply even when it requests the same limit as the previous consumer. - this.appliedLimit = undefined return } @@ -77,6 +126,38 @@ class WindowCoordinator { return desired } + private rollbackLease( + lease: symbol, + version: number, + previousLimit: number | undefined, + previousVersion: number | undefined, + ): boolean { + if (this.leaseVersions.get(lease) !== version) return false + if (previousLimit === undefined) { + this.leases.delete(lease) + this.leaseVersions.delete(lease) + } else { + this.leases.set(lease, previousLimit) + if (previousVersion === undefined) { + this.leaseVersions.delete(lease) + } else { + this.leaseVersions.set(lease, previousVersion) + } + } + return true + } + + private restoreInitialWindow(): void { + const setWindow = this.target.utils?.setWindow + if (!this.initialWindow || typeof setWindow !== `function`) return + try { + const result = setWindow.call(this.target.utils, this.initialWindow) + if (result !== true) void result.catch(() => {}) + } catch { + // Release has no error channel. A future lease will retry its own window. + } + } + private applyDesiredWindow(): WindowResult { const limit = this.getDesiredLimit() if (limit === undefined) return true @@ -89,7 +170,14 @@ class WindowCoordinator { this.pending = undefined this.appliedLimit = undefined } - if (limit === this.appliedLimit) return true + const currentWindow = this.target.utils?.getWindow?.() + if ( + limit === this.appliedLimit && + currentWindow?.offset === 0 && + currentWindow.limit === limit + ) { + return true + } const setWindow = this.target.utils?.setWindow if (typeof setWindow !== `function`) { @@ -260,10 +348,10 @@ class LiveQueryWindowControllerImpl< ? options.pageSize : DEFAULT_PAGE_SIZE this.initialPageParam = options.initialPageParam ?? 0 - this.committedPageCount = Math.max( - 1, - Math.floor(options.initialPageCount ?? 1), - ) + const initialPageCount = Math.floor(options.initialPageCount ?? 1) + this.committedPageCount = Number.isFinite(initialPageCount) + ? Math.max(1, initialPageCount) + : 1 // The controller listener carries no delta payload, so wholesale is the // only coherent observer contract and guarantees non-reentrant subscribe. this.observer = createLiveQueryObserver(collection, { @@ -307,6 +395,9 @@ class LiveQueryWindowControllerImpl< } const status = this.hasPaginationError ? `error` : observerSnapshot.status + const statusFlags = this.hasPaginationError + ? getLiveQueryStatusFlags(`error`) + : observerSnapshot this.cachedSnapshot = { data: rows.slice(0, totalRequested), pages, @@ -317,10 +408,10 @@ class LiveQueryWindowControllerImpl< state: observerSnapshot.state, collection: observerSnapshot.collection, status, - isLoading: observerSnapshot.isLoading, - isReady: observerSnapshot.isReady, - isIdle: observerSnapshot.isIdle, - isError: this.hasPaginationError || observerSnapshot.isError, + isLoading: statusFlags.isLoading, + isReady: statusFlags.isReady, + isIdle: statusFlags.isIdle, + isError: statusFlags.isError, isCleanedUp: observerSnapshot.isCleanedUp, isEnabled: observerSnapshot.isEnabled, } @@ -346,7 +437,7 @@ class LiveQueryWindowControllerImpl< try { // Store the desired physical window before observer activation can // compile or restart the live-query pipeline. - const windowResult = this.activateLease(this.committedPageCount) + const windowResult = this.ensureLeaseActive(this.committedPageCount) const leaseGeneration = this.leaseGeneration observerUnsub = this.observer.subscribe(() => this.onObserverNotify()) this.observerUnsub = observerUnsub @@ -404,7 +495,7 @@ class LiveQueryWindowControllerImpl< this.paginationError = undefined this.acquireInFlightLease() try { - const result = this.activateLease(this.committedPageCount) + const result = this.ensureLeaseActive(this.committedPageCount) if (result !== true) await result await this.observer.preload() this.failedHasNextPage = false @@ -527,6 +618,17 @@ class LiveQueryWindowControllerImpl< return this.coordinator.request(this.lease, pageCount * this.pageSize + 1) } + private ensureLeaseActive(pageCount: number): WindowResult { + const minimumLimit = pageCount * this.pageSize + 1 + if ( + this.leaseActive && + this.coordinator?.isLeaseSatisfied(this.lease, minimumLimit) + ) { + return true + } + return this.activateLease(pageCount) + } + private deactivateLease(): void { if (!this.leaseActive || !this.coordinator) return this.leaseGeneration++ @@ -565,7 +667,6 @@ class LiveQueryWindowControllerImpl< } private onObserverNotify(): void { - if (this.pendingWindowGeneration !== undefined) return this.notify() } diff --git a/packages/db/src/query/live/collection-config-builder.ts b/packages/db/src/query/live/collection-config-builder.ts index ab2906d59..e497150e5 100644 --- a/packages/db/src/query/live/collection-config-builder.ts +++ b/packages/db/src/query/live/collection-config-builder.ts @@ -300,34 +300,18 @@ export class CollectionConfigBuilder< throw error } - // Check if loading a subset was triggered - if (this.liveQueryCollection?.isLoadingSubset) { - // Loading was triggered, return a promise that resolves when it completes - return new Promise((resolve) => { - const unsubscribe = this.liveQueryCollection!.on( - `loadingSubset:change`, - (event) => { - if (!event.isLoadingSubset) { - unsubscribe() - resolve() - } - }, - ) - }) - } - - // No loading was triggered - return true + return this.liveQueryCollection?._sync.waitForCurrentLoadSubset() ?? true } getWindow(): { offset: number; limit: number } | undefined { // Only return window if this is a windowed query (has orderBy and windowFn) - if (!this.windowFn || !this.currentWindow) { + const window = this.currentWindow ?? this.initialWindow + if (!this.windowFn || !window) { return undefined } return { - offset: this.currentWindow.offset ?? 0, - limit: this.currentWindow.limit ?? 0, + offset: window.offset ?? 0, + limit: window.limit ?? 0, } } @@ -677,6 +661,7 @@ export class CollectionConfigBuilder< this.currentSyncConfig = undefined this.currentSyncState = undefined this.maybeRunGraphFn = undefined + this.currentWindow = undefined // Clear all pending graph runs to prevent memory leaks from in-flight transactions // that may flush after the sync session ends diff --git a/packages/db/src/query/live/collection-subscriber.ts b/packages/db/src/query/live/collection-subscriber.ts index 69c8220ad..9cfbf5852 100644 --- a/packages/db/src/query/live/collection-subscriber.ts +++ b/packages/db/src/query/live/collection-subscriber.ts @@ -234,11 +234,12 @@ export class CollectionSubscriber< const handleLoadSubsetResult = (result: Promise | true) => { if (result instanceof Promise) { this.pendingOrderedLoadPromise = result - result.finally(() => { + const finish = () => { if (this.pendingOrderedLoadPromise === result) { this.pendingOrderedLoadPromise = undefined } - }) + } + void result.then(finish, finish) } onLoadSubsetResult(result) } diff --git a/packages/db/tests/live-query-order-only-move.test.ts b/packages/db/tests/live-query-order-only-move.test.ts index c6d175466..5d6bf5181 100644 --- a/packages/db/tests/live-query-order-only-move.test.ts +++ b/packages/db/tests/live-query-order-only-move.test.ts @@ -240,7 +240,7 @@ describe(`order-only move (RFC #1623 phase 4)`, () => { observer.dispose() }) - it(`does not expose layout-only signals as empty public change batches`, async () => { + it(`exposes layout-only signals as empty public change batches`, async () => { const source = makeSource() const lq = await makeOrderedByAge(source) const publications: Array> = [] @@ -257,7 +257,7 @@ describe(`order-only move (RFC #1623 phase 4)`, () => { source.utils.commit() await flush() - expect(publications).toEqual([]) + expect(publications).toEqual([[]]) subscription.unsubscribe() }) diff --git a/packages/db/tests/live-query-window-controller.test.ts b/packages/db/tests/live-query-window-controller.test.ts index 9b57c0f51..c67e5d462 100644 --- a/packages/db/tests/live-query-window-controller.test.ts +++ b/packages/db/tests/live-query-window-controller.test.ts @@ -1,4 +1,5 @@ import { describe, expect, it, vi } from 'vitest' +import { BTreeIndex } from '../src/index.js' import { createCollection } from '../src/collection/index.js' import { createLiveQueryCollection } from '../src/query/live-query-collection.js' import { LIVE_QUERY_INTERNAL } from '../src/query/live/internal.js' @@ -232,7 +233,7 @@ describe(`createLiveQueryWindowController`, () => { expect(windowFn).toHaveBeenNthCalledWith(1, { offset: 0, limit: 5 }) expect(windowFn).toHaveBeenNthCalledWith(2, { offset: 0, limit: 3 }) expect(maybeRunGraph).toHaveBeenCalledTimes(2) - expect(lq.utils.getWindow()).toBeUndefined() + expect(lq.utils.getWindow()).toEqual({ offset: 0, limit: 3 }) }) it(`keeps the committed page retryable when a window load rejects`, async () => { @@ -316,6 +317,113 @@ describe(`createLiveQueryWindowController`, () => { controller.dispose() }) + it(`does not shrink the physical window when preload overlaps a page fetch`, async () => { + const lq = makeOrderedLiveQuery(makeSource(), 2) + const controller = createLiveQueryWindowController(lq as any, { + pageSize: 2, + }) + controller.subscribe(() => {}) + await lq.preload() + + const originalSetWindow = lq.utils.setWindow.bind(lq.utils) + let resolveExpansion!: () => void + vi.spyOn(lq.utils, `setWindow`).mockImplementation((options) => { + const result = originalSetWindow(options) + if (options.limit !== 5) return result + return new Promise((resolve) => { + resolveExpansion = resolve + }) + }) + + const expansion = controller.fetchNextPage() + const preload = controller.preload() + resolveExpansion() + await Promise.all([expansion, preload]) + + expect(controller.getSnapshot().pages).toHaveLength(2) + expect(lq.utils.getWindow()).toEqual({ offset: 0, limit: 5 }) + controller.dispose() + }) + + it(`publishes source changes while a page fetch is pending`, async () => { + const source = makeSource() + const lq = makeOrderedLiveQuery(source, 2) + const controller = createLiveQueryWindowController(lq as any, { + pageSize: 2, + }) + let notifications = 0 + controller.subscribe(() => notifications++) + await lq.preload() + notifications = 0 + + vi.spyOn(lq.utils, `setWindow`).mockReturnValueOnce( + new Promise(() => {}), + ) + void controller.fetchNextPage() + notifications = 0 + + source.utils.begin() + source.utils.write({ + type: `update`, + value: { id: `1`, n: 0 }, + }) + source.utils.commit() + await flush() + + expect(notifications).toBeGreaterThan(0) + expect(controller.getSnapshot().data[0]).toMatchObject({ id: `1`, n: 0 }) + controller.dispose() + }) + + it(`surfaces a real async subset-load failure from setWindow`, async () => { + const remoteRows = [...ROWS] + let rejectLoads = false + const failure = new Error(`remote page failed`) + const source = createCollection({ + id: `window-ctrl-rejecting-source-${seq++}`, + getKey: (row) => row.id, + syncMode: `on-demand`, + startSync: true, + autoIndex: `eager`, + defaultIndexType: BTreeIndex, + sync: { + sync: ({ begin, write, commit, markReady }) => { + markReady() + return { + loadSubset: (options) => + new Promise((resolve, reject) => { + queueMicrotask(() => { + if (rejectLoads) { + reject(failure) + return + } + begin() + remoteRows.slice(0, options.limit).forEach((row) => { + write({ type: `insert`, value: row }) + }) + commit() + resolve() + }) + }), + } + }, + }, + }) + const lq = makeOrderedLiveQuery(source, 2) + const controller = createLiveQueryWindowController(lq as any, { + pageSize: 2, + }) + controller.subscribe(() => {}) + await controller.preload() + expect(controller.getSnapshot().hasNextPage).toBe(true) + + rejectLoads = true + await expect(controller.fetchNextPage()).rejects.toBe(failure) + expect(controller.getSnapshot().pages).toHaveLength(1) + expect(controller.getSnapshot().error).toBe(failure) + controller.dispose() + }) + it(`reset supersedes an in-flight page expansion`, async () => { const lq = makeOrderedLiveQuery(makeSource(), 2) const controller = createLiveQueryWindowController(lq as any, { @@ -462,6 +570,98 @@ describe(`createLiveQueryWindowController`, () => { smaller.dispose() }) + it(`rolls a failed lease request back to its committed window`, async () => { + const lq = makeOrderedLiveQuery(makeSource(), 2) + const controller = createLiveQueryWindowController(lq as any, { + pageSize: 2, + }) + controller.subscribe(() => {}) + await lq.preload() + + const failure = new Error(`window failed`) + vi.spyOn(lq.utils, `setWindow`).mockRejectedValueOnce(failure) + await expect(controller.fetchNextPage()).rejects.toBe(failure) + + const smaller = createLiveQueryWindowController(lq as any, { + pageSize: 1, + }) + smaller.subscribe(() => {}) + + expect(lq.utils.getWindow()).toEqual({ offset: 0, limit: 3 }) + smaller.dispose() + controller.dispose() + }) + + it(`restores the remaining lease after a pending larger lease is released`, async () => { + const lq = makeOrderedLiveQuery(makeSource(), 2) + const keeper = createLiveQueryWindowController(lq as any, { + pageSize: 2, + }) + keeper.subscribe(() => {}) + await lq.preload() + await keeper.fetchNextPage() + expect(lq.utils.getWindow()).toEqual({ offset: 0, limit: 5 }) + + const transient = createLiveQueryWindowController(lq as any, { + pageSize: 2, + }) + transient.subscribe(() => {}) + await transient.fetchNextPage() + + const originalSetWindow = lq.utils.setWindow.bind(lq.utils) + let resolveExpansion!: () => void + vi.spyOn(lq.utils, `setWindow`).mockImplementation((options) => { + const result = originalSetWindow(options) + if (options.limit !== 7) return result + return new Promise((resolve) => { + resolveExpansion = resolve + }) + }) + + const expansion = transient.fetchNextPage() + transient.dispose() + + expect(lq.utils.getWindow()).toEqual({ offset: 0, limit: 5 }) + resolveExpansion() + await expansion + keeper.dispose() + }) + + it(`repairs an externally moved physical window`, async () => { + const lq = makeOrderedLiveQuery(makeSource(), 2) + const controller = createLiveQueryWindowController(lq as any, { + pageSize: 2, + }) + controller.subscribe(() => {}) + await lq.preload() + + await lq.utils.setWindow({ offset: 1, limit: 3 }) + expect(lq.utils.getWindow()).toEqual({ offset: 1, limit: 3 }) + + await controller.preload() + expect(lq.utils.getWindow()).toEqual({ offset: 0, limit: 3 }) + controller.dispose() + }) + + it(`restores the query's initial window after the last lease is released`, async () => { + const lq = makeOrderedLiveQuery(makeSource(), 2) + const controller = createLiveQueryWindowController(lq as any, { + pageSize: 2, + }) + const unsubscribe = controller.subscribe(() => {}) + await lq.preload() + await controller.fetchNextPage() + expect(lq.utils.getWindow()).toEqual({ offset: 0, limit: 5 }) + + unsubscribe() + controller.dispose() + await lq.cleanup() + await lq.preload() + + expect(lq.utils.getWindow()).toEqual({ offset: 0, limit: 3 }) + expect(lq.toArray).toHaveLength(3) + }) + it(`ignores a failed attachment superseded by a new lease`, async () => { const lq = makeOrderedLiveQuery(makeSource(), 2) await lq.preload() @@ -648,6 +848,44 @@ describe(`createLiveQueryWindowController`, () => { controller.dispose() }) + it(`derives status flags from a pagination error status`, async () => { + const lq = makeOrderedLiveQuery(makeSource(), 2) + const controller = createLiveQueryWindowController(lq as any, { + pageSize: 2, + }) + controller.subscribe(() => {}) + await lq.preload() + + vi.spyOn(lq.utils, `setWindow`).mockRejectedValueOnce( + new Error(`window failed`), + ) + await expect(controller.fetchNextPage()).rejects.toThrow(`window failed`) + + expect(controller.getSnapshot()).toMatchObject({ + status: `error`, + isLoading: false, + isReady: false, + isIdle: false, + isError: true, + isCleanedUp: false, + }) + controller.dispose() + }) + + it(`normalizes a NaN initial page count to one page`, async () => { + const lq = makeOrderedLiveQuery(makeSource(), 2) + const controller = createLiveQueryWindowController(lq as any, { + pageSize: 2, + initialPageCount: Number.NaN, + }) + controller.subscribe(() => {}) + await lq.preload() + + expect(controller.getSnapshot().pages).toHaveLength(1) + expect(ids(controller.getSnapshot())).toEqual([`1`, `2`]) + controller.dispose() + }) + it(`represents a disabled controller (null collection)`, () => { const controller = createLiveQueryWindowController(null) const snap = controller.getSnapshot() diff --git a/packages/react-db/src/useLiveInfiniteQuery.ts b/packages/react-db/src/useLiveInfiniteQuery.ts index d4f6fe473..c3d4c629c 100644 --- a/packages/react-db/src/useLiveInfiniteQuery.ts +++ b/packages/react-db/src/useLiveInfiniteQuery.ts @@ -3,6 +3,7 @@ import { CollectionImpl, createLiveQueryCollection, createLiveQueryWindowController, + deepEquals, } from '@tanstack/db' // Type-only: used in `ReturnType` in UseLiveInfiniteQueryReturn. import type { useLiveQuery } from './useLiveQuery' @@ -180,12 +181,17 @@ export function useLiveInfiniteQuery( const validatedCollectionRef = useRef(null) const inputKind = isCollection ? `collection` : `query` const inputKindRef = useRef(null) + const previousInputKind = inputKindRef.current const dependenciesChanged = !isCollection && (depsRef.current === null || depsRef.current.length !== deps.length || depsRef.current.some((dep, index) => dep !== deps[index])) + const dependenciesStructurallyEqual = + !isCollection && + depsRef.current !== null && + deepEquals(depsRef.current, deps) const needsNewCollection = !collectionRef.current || inputKindRef.current !== inputKind || @@ -242,10 +248,14 @@ export function useLiveInfiniteQuery( } if (needsNewController) { - const initialPageCount = - controllerRef.current && !needsNewCollection - ? Math.max(1, controllerRef.current.getSnapshot().pages.length) - : 1 + const previousController = controllerRef.current + const canPreservePageCount = + previousController !== null && + (!needsNewCollection || + (previousInputKind === `query` && dependenciesStructurallyEqual)) + const initialPageCount = canPreservePageCount + ? Math.max(1, previousController.getSnapshot().pages.length) + : 1 pageSizeRef.current = pageSize initialPageParamRef.current = initialPageParam controllerRef.current = createLiveQueryWindowController( diff --git a/packages/react-db/tests/useLiveInfiniteQuery.test.tsx b/packages/react-db/tests/useLiveInfiniteQuery.test.tsx index 1f420495f..77e33542f 100644 --- a/packages/react-db/tests/useLiveInfiniteQuery.test.tsx +++ b/packages/react-db/tests/useLiveInfiniteQuery.test.tsx @@ -849,6 +849,66 @@ describe(`useLiveInfiniteQuery`, () => { }) }) + it(`preserves loaded pages when dependencies are structurally unchanged`, async () => { + const source = createCollection( + mockSyncCollectionOptions({ + id: `infinite-query-structurally-equal-deps`, + getKey: (post) => post.id, + initialData: createMockPosts(20), + }), + ) + const { result, rerender } = renderHook( + ({ filter }: { filter: { category: string } }) => + useLiveInfiniteQuery( + (q) => + q + .from({ post: source }) + .where(({ post }) => eq(post.category, filter.category)) + .orderBy(({ post }) => post.createdAt, `desc`), + { pageSize: 2 }, + [filter], + ), + { initialProps: { filter: { category: `tech` } } }, + ) + + await waitFor(() => expect(result.current.isReady).toBe(true)) + act(() => result.current.fetchNextPage()) + await waitFor(() => expect(result.current.pages).toHaveLength(2)) + + rerender({ filter: { category: `tech` } }) + + await waitFor(() => expect(result.current.isReady).toBe(true)) + expect(result.current.pages).toHaveLength(2) + }) + + it(`releases a replaced controller through the external-store unsubscribe`, async () => { + const source = createCollection( + mockSyncCollectionOptions({ + id: `infinite-query-controller-replacement`, + getKey: (post) => post.id, + initialData: createMockPosts(20), + }), + ) + const query = createLiveQueryCollection({ + query: (q) => + q.from({ post: source }).orderBy(({ post }) => post.createdAt, `desc`), + }) + const { result, rerender, unmount } = renderHook( + ({ pageSize }) => useLiveInfiniteQuery(query, { pageSize }), + { initialProps: { pageSize: 2 } }, + ) + + await waitFor(() => expect(result.current.isReady).toBe(true)) + expect(query.subscriberCount).toBe(1) + + rerender({ pageSize: 3 }) + await waitFor(() => expect(result.current.pages[0]).toHaveLength(3)) + expect(query.subscriberCount).toBe(1) + + unmount() + expect(query.subscriberCount).toBe(0) + }) + it(`binds fetchNextPage to the controller that returned it`, async () => { const sourceA = createCollection( mockSyncCollectionOptions({ @@ -1994,9 +2054,6 @@ describe(`useLiveInfiniteQuery`, () => { .offset(0), }) await liveQueryCollection.preload() - // Give the collection a concrete window that differs from the hook's - // expected first page (offset 0, limit pageSize + 1). - await liveQueryCollection.utils.setWindow({ offset: 0, limit: 5 }) const warn = vi.spyOn(console, `warn`).mockImplementation(() => {}) try { From 4c44bcda73ced539fbd54e4de002409902eaa458 Mon Sep 17 00:00:00 2001 From: Kyle Mathews Date: Wed, 12 Aug 2026 13:49:58 -0600 Subject: [PATCH 42/42] docs: update window controller release notes --- .changeset/live-query-window-controller.md | 12 +++++------- 1 file changed, 5 insertions(+), 7 deletions(-) diff --git a/.changeset/live-query-window-controller.md b/.changeset/live-query-window-controller.md index 8688684f7..0f258cd5f 100644 --- a/.changeset/live-query-window-controller.md +++ b/.changeset/live-query-window-controller.md @@ -3,10 +3,8 @@ '@tanstack/react-db': patch --- -feat(db): internal shared live-query window controller for infinite queries - -Adds the unstable, `@internal` `createLiveQueryWindowController` adapter -primitive to `@tanstack/db`. It owns forward pagination, collection-scoped -window leases, transactional page commits, and failure/retry state while the -RFC contract is finalized. `react-db`'s `useLiveInfiniteQuery` becomes a thin -binding over it with no public API change. +Add the unstable, internal `createLiveQueryWindowController` primitive for +forward pagination. It coordinates collection-scoped window leases, commits +pages only after subset loads succeed, restores windows after failures and +cleanup, and lets React's `useLiveInfiniteQuery` become a thin binding without +changing its public API or resetting pages for structurally equal dependencies.