Skip to content
Open
Show file tree
Hide file tree
Changes from all commits
Commits
Show all changes
18 commits
Select commit Hold shift + click to select a range
0bcc429
feat(db): shared live-query observer + migrate all five adapters
kevin-dp Jul 7, 2026
e957121
fix(db): make observer onFirstReady detach-safe
kevin-dp Jul 7, 2026
c05a726
fix(db): address observer/react lifecycle review findings
kevin-dp Jul 9, 2026
fd3c87d
fix(react-db): don't dispose the observer in an unmount effect (Stric…
kevin-dp Jul 9, 2026
7577d40
test(db): cover live-query keyed-state invariant on recompile
kevin-dp Jul 13, 2026
2a0679e
fix(solid-db): clear keyed state before subscribing to a new collection
kevin-dp Jul 13, 2026
6db6b75
fix(db): FIFO non-reentrant observer dispatch over subscription records
kevin-dp Jul 20, 2026
7a86eae
fix(db): release the collection subscription on dispose during initia…
kevin-dp Jul 20, 2026
482c82b
fix(db): seed late observer subscribers; reject subscribe after dispose
kevin-dp Jul 20, 2026
d0875e4
fix(db): drive observer snapshots from a collection-owned state revision
kevin-dp Jul 20, 2026
37b5eea
test(angular-db): align the mock collection with the real collection …
kevin-dp Jul 20, 2026
eeafe17
fix(db): publish collection status changes through the canonical path
kevin-dp Jul 20, 2026
4932b73
fix(db): per-consumer initial-state policy; lazy snapshot materializa…
kevin-dp Jul 20, 2026
52b11e9
fix(db): remove deferInitialNotify — event reordering gone by constru…
kevin-dp Jul 20, 2026
5e92926
fix(db): make observer construction inert — sync activates on first s…
kevin-dp Jul 20, 2026
58bd2c1
fix(solid-db): generation-guard the resource's async continuations
kevin-dp Jul 20, 2026
b24d163
docs(db): mark the observer as internal/unstable; honest changeset
kevin-dp Jul 20, 2026
62cb87b
Merge branch 'main' into refactor/live-query-observer
KyleAMathews Jul 20, 2026
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
16 changes: 16 additions & 0 deletions .changeset/live-query-observer.md
Original file line number Diff line number Diff line change
@@ -0,0 +1,16 @@
---
'@tanstack/db': minor
'@tanstack/react-db': patch
'@tanstack/vue-db': patch
'@tanstack/svelte-db': patch
'@tanstack/solid-db': patch
'@tanstack/angular-db': patch
---

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).

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).
37 changes: 17 additions & 20 deletions packages/angular-db/src/index.ts
Original file line number Diff line number Diff line change
Expand Up @@ -9,11 +9,11 @@ import {
import {
BaseQueryBuilder,
createLiveQueryCollection,
createLiveQueryObserver,
isCollection,
isSingleResultCollection,
} from '@tanstack/db'
import type {
ChangeMessage,
Collection,
CollectionStatus,
Context,
Expand Down Expand Up @@ -249,28 +249,25 @@ 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.
// 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`,
})

// Subscribe to changes
const subscription = currentCollection.subscribeChanges(
(_: Array<ChangeMessage<any>>) => {
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)
})
Expand Down
1 change: 1 addition & 0 deletions packages/angular-db/tests/conformance.test.ts
Original file line number Diff line number Diff line change
Expand Up @@ -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()),
Expand Down
39 changes: 33 additions & 6 deletions packages/angular-db/tests/inject-live-query.test.ts
Original file line number Diff line number Diff line change
Expand Up @@ -80,14 +80,25 @@ function createMockCollection<T extends object, K extends string | number>(
}

let status: CollectionStatus = initialStatus
let stateRevision = 0
const subs = new Set<(changes: Array<any>) => 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<any> = []) => {
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()
}
Expand All @@ -97,13 +108,22 @@ function createMockCollection<T extends object, K extends string | number>(
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),
has: (key: K) => map.has(key),
size: () => map.size,
subscribeChanges: (cb: (changes: Array<any>) => void) => {
subs.add(cb)
// Real collections start sync when the first subscriber attaches.
api.startSyncImmediate()
return {
unsubscribe: () => subs.delete(cb),
}
Expand All @@ -118,26 +138,33 @@ function createMockCollection<T extends object, K extends string | number>(
},
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)
}
},
__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<T & Record<`id`, K>>) => {
const changes: Array<any> = []
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)
Expand Down
13 changes: 13 additions & 0 deletions packages/db/src/collection/changes.ts
Original file line number Diff line number Diff line change
Expand Up @@ -30,6 +30,15 @@ export class CollectionChangesManager<
public batchedEvents: Array<ChangeMessage<TOutput, TKey>> = []
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
*/
Expand Down Expand Up @@ -77,6 +86,10 @@ export class CollectionChangesManager<
changes: Array<ChangeMessage<TOutput, TKey>>,
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
Expand Down
9 changes: 9 additions & 0 deletions packages/db/src/collection/index.ts
Original file line number Diff line number Diff line change
Expand Up @@ -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
Expand Down
6 changes: 6 additions & 0 deletions packages/db/src/errors.ts
Original file line number Diff line number Diff line change
Expand Up @@ -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) {
Expand Down
1 change: 1 addition & 0 deletions packages/db/src/index.ts
Original file line number Diff line number Diff line change
Expand Up @@ -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'
Expand Down
Loading
Loading