Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
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
8 changes: 8 additions & 0 deletions .changeset/fix-client-pages-and-suspense-ownership.md
Original file line number Diff line number Diff line change
@@ -0,0 +1,8 @@
---
'@tanstack/db': patch
'@tanstack/react-db': patch
'@tanstack/preact-db': patch
'@tanstack/svelte-db': patch
---

Keep live-query Collections across finite page changes under a DbClient, including React Suspense and Svelte, while preserving page-specific preload and hydration snapshots. Give independent React Suspense queries separate successful Collections so one query cannot move another query's page. Surface a private Suspense output's failed first load while it is showing copied ready rows, and keep its health independent of a peer's page-request failures. Fix initially disabled keyed query admission in Preact and Svelte, and finish hydration seed handoff when subset work settles without a row or status change.
159 changes: 153 additions & 6 deletions docs/contributing/oracle-coverage.md

Large diffs are not rendered by default.

Large diffs are not rendered by default.

2,144 changes: 2,144 additions & 0 deletions docs/contributing/oracle-reviews/2026-11-03-client-suspense-page-ownership.md

Large diffs are not rendered by default.

30 changes: 21 additions & 9 deletions docs/framework/react/overview.md
Original file line number Diff line number Diff line change
Expand Up @@ -58,7 +58,7 @@ function TodoList() {

### Query Identity

React live query hooks derive the live query identity from structured query IR by default. The hook runs the query builder, normalizes the resulting IR, and uses that as the identity. When the derived identity changes, the old live query collection is cleaned up and a new one is created.
React live query hooks derive the live query identity from structured query IR by default. The hook runs the query builder, normalizes the resulting IR, and uses that as the identity. A changed query plan creates a new live-query Collection. Changing only a finite outer limit or offset moves the existing Collection's window.

That means normal structured queries do not need a separate `queryKey`. Collection descriptors provide stable collection IDs, and captured values inside structured expressions become part of the derived identity:

Expand All @@ -82,8 +82,11 @@ unmount it, clean up its source and client scope, then mount the replacement.
Separate hooks and independent client scopes can use the same ID.

A mounted binding keeps its Collection options until query identity changes.
Independent Suspense bindings share a Collection only when their supplied
Collection options agree. Object and function options use reference equality.
Independent query inputs to `useLiveQuery` and `useLiveSuspenseQuery` receive
their own live-query Collections, even with equal query keys and options. Their
source Collections can be shared. Passing the same pre-created Collection to
both hooks explicitly shares that Collection. Changing only the outer finite
limit or offset retains each hook's Collection, including under `DbProvider`.
Cached `DbClient` results additionally reject conflicting `getKey` or `schema`
references under the same query identity; use distinct query keys for those
callers.
Expand Down Expand Up @@ -120,6 +123,12 @@ An explicit `queryKey` is the caller's identity contract. If its source object
changes, change the key too, even when the source ID stays the same. Use a
primitive scope token in the key; Collection objects cannot be hashed there.

Treat `queryKey` and its nested values as immutable. Fresh objects with equal
contents keep the authored query, including when record properties appear in a
different order. To change a nested value, replace that object and its parent
objects, as with React state. In-place mutation has no supported invalidation
behavior. This applies to both `useLiveQuery` and `useLiveSuspenseQuery`.

An unhashable query without `queryKey` throws `UnhashableQueryIRError`, so
represent the values that opaque logic captures in the key. An empty
`queryKey` counts as none, as it does for a `DbClient` preload. A dependency
Expand All @@ -129,11 +138,11 @@ as a performance escape hatch.

#### What Happens When Identity Changes

When the derived identity or explicit query key changes:
1. The previous live query collection is cleaned up
2. A new query is created with the updated values
3. The component re-renders with the new data
4. The hook suspends (for `useLiveSuspenseQuery`) or shows loading state
When an identity change requires a new query plan, the hook replaces its
live-query Collection and renders the new result. While its initial result
loads, `useLiveSuspenseQuery` shows the Suspense fallback and `useLiveQuery`
reports loading. A finite outer limit or offset change retains the Collection
and moves its window after commit.

#### Best Practices

Expand Down Expand Up @@ -224,4 +233,7 @@ function App() {
}
```

When the derived identity or explicit `queryKey` changes, `useLiveSuspenseQuery` will re-suspend, showing your Suspense fallback until the new data is ready.
When a changed query plan creates a new live-query Collection,
`useLiveSuspenseQuery` shows your Suspense fallback until its initial result
is ready. Finite outer-window moves retain the Collection and follow its
window publication policy.
6 changes: 6 additions & 0 deletions docs/guides/live-queries.md
Original file line number Diff line number Diff line change
Expand Up @@ -277,6 +277,12 @@ function UserSearch({ search }: { search: string }) {

You can also provide a `queryKey` as a performance escape hatch for a very hot render path, but normal structured queries should omit it.

For bindings that compare query identity, treat the key and its nested values
as immutable. Fresh plain objects and arrays with equal contents keep the
authored query; record property order does not matter. Replace changed nested
objects and their parents to invalidate the query on the next framework update.
Mutating a key in place has no supported invalidation behavior.

Query identity matters where a hook compares it to decide whether to compile
again: React and Preact compare it on every render, and a `DbClient` matches
preloaded and hydrated queries by it. Those hooks throw
Expand Down
11 changes: 10 additions & 1 deletion packages/db/src/live-query-observer.ts
Original file line number Diff line number Diff line change
Expand Up @@ -632,7 +632,16 @@ class LiveQueryObserverImpl<
// 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.
if (this.clientBinding) ownRelease(this.clientBinding.subscribe())
if (this.clientBinding) {
ownRelease(this.clientBinding.subscribe())
// A retained ready Collection can settle subset work without another
// row or status change. That settlement must release its hydration seed.
ownRelease(
collection.on(`loadingSubset:change`, () => {
if (this.clientBinding?.seed) this.clientBinding.scheduleHandoff()
}),
)
}
const subscription = collection.subscribeChanges(
(changes) => notify(changes as Array<ChangeMessage<T, TKey>>),
{ includeInitialState: !this.wholesale && !attachedWithHydrationSeed },
Expand Down
6 changes: 3 additions & 3 deletions packages/db/src/live-query-options.ts
Original file line number Diff line number Diff line change
Expand Up @@ -310,8 +310,8 @@ export function getLiveQueryHash(
return getStableValueHash(identity, `queryKey`)
}

// Only adapter-owned Collections enter this table. Cache- and Suspense-owned
// Collections retain their result identity and are never movable by a peer.
// Each entry belongs to one framework binding or an unclaimed retry resource.
// DbClient snapshots remain separate; peers never share a mutable window owner.
const resolvedQueryValues = new WeakMap<
object,
{ collection: object; config: NonNullable<ReturnType<typeof queryConfig>> }
Expand Down Expand Up @@ -430,7 +430,7 @@ export function resolveLiveQueryValue(
// A pool here has passed `configPool`.
pool?: PooledLiveQueries | false
startSync?: boolean
/** @internal Previous adapter-owned result; absent for shared results. */
/** @internal undefined skips window tracking; null starts an owned result. */
previousCollection?: Collection<object, string | number, {}> | null
/** @internal Identity of one framework binding; never shared by peers. */
windowOwner?: object
Expand Down
42 changes: 42 additions & 0 deletions packages/db/tests/conformance/client-window-history-oracle.ts
Original file line number Diff line number Diff line change
@@ -0,0 +1,42 @@
/** Transported page histories receive the window owner's sort/slice law.
* Each history moves while local readiness is held, mounts fresh old-page
* readers, then releases local rows and returns. Bounds include empty pages.
* Host receivers supply the public hook and native flush; this module only
* owns input grammar. It imports no adapter or production identity machinery.
*/
import fc from 'fast-check'
import { declareOracle } from '../oracle-declaration'
// Width/offset vary independently, including pages beyond the final row.
// Every history moves twice and returns to the first page. Removing the held
// readiness cut loses the distinction between a matching seed and local rows.
export const clientWindowArbitrary = fc.record({
count: fc.integer({ min: 1, max: 12 }),
offset: fc.integer({ min: 0, max: 4 }),
width: fc.integer({ min: 1, max: 4 }),
jump: fc.integer({ min: 1, max: 4 }),
latePeers: fc.integer({ min: 1, max: 2 }),
})
export type ClientWindowHistory = {
count: number
offset: number
width: number
jump: number
latePeers: number
}
export const clientWindowExamples: Array<[ClientWindowHistory]> = [
[{ count: 10, offset: 0, width: 2, jump: 2, latePeers: 1 }],
[{ count: 2, offset: 0, width: 1, jump: 1, latePeers: 1 }],
]
export const clientWindowStates = [
`first seed`,
`moved seed`,
`local handoff`,
`peer isolation`,
`held peer keyed state`,
`local peer keyed state`,
`returned page`,
`client scope switch`,
`fresh old-page lookup`,
] as const

declareOracle({ grammar: [clientWindowArbitrary] })
26 changes: 21 additions & 5 deletions packages/db/tests/conformance/contract.ts
Original file line number Diff line number Diff line change
Expand Up @@ -31,8 +31,21 @@
* projections, predicates, order, nested windows and Collection options still
* require compilation. An unchanged
* explicit key keeps its authored input, and changed opaque keyed inputs compile.
* DbClient and Suspense result sharing retain their identity-based behavior;
* they are outside this adapter-owned optimization.
* An initially disabled input has no authored plan to retain: enabling it admits
* its first plan even with the same key.
* Explicit keys are immutable values (maintainer decision, 2026-11-03): fresh
* equal plain records and arrays retain the authored input, regardless of
* record property order. Replacing a nested value with a different value
* invalidates it. Replace changed objects along their path; mutating a key
* in place is unsupported and has no promised invalidation behavior.
* The same law applies to every client-backed adapter (issue #2345) and to
* Suspense hooks (maintainer decision, 2026-11-03). Independent query inputs
* own distinct live-query Collections, even under equal keys and options.
* Each moved client input uses the new page's preload/hydration identity;
* transported page snapshots keep their original identities. An explicitly
* supplied Collection remains caller-owned and may be shared. Before an initial
* Suspense render succeeds, only its fallback is public; retry progress is owed,
* but retention of an uncommitted Collection is not.
*
* A window move requires a committed host update and runs after any current
* Collection publication. A speculative React or Preact render cannot move the
Expand Down Expand Up @@ -237,6 +250,8 @@ export interface LiveQueryHandle {
* still tell a loading error (`isLoadingError`) from a crash (`isCrashed`).
*/
statusSurface?: () => ConformanceStatusSurface
/** Public fallback visibility for a hook that suspends before initial readiness. */
isSuspended?: () => boolean
/** The live-query Collection currently backing this result. */
collection: () => Collection<any, any, any> | null
/** Let the framework scheduler + core sync settle, then resolve. */
Expand Down Expand Up @@ -387,9 +402,8 @@ export interface LiveQueryDriver {
* Whether the binding compares query identity to decide when to compile
* again (see the opening query-identity law). The driver declares the
* adapter's documented policy for the inputs it mounts; the suite does not
* infer it from observed output. Svelte compares identity only for a query
* that a `DbClient` backs, and its shared drivers mount none, so they
* declare `false`.
* infer it from observed output. Svelte compares identity for client-backed queries; the driver declares
* this per input mode rather than per framework.
*/
comparesQueryIdentity: boolean
/** No active waivers. Kept empty in drivers to make this boundary explicit. */
Expand All @@ -404,6 +418,8 @@ export interface LiveQueryDriver {
features?: {
serverSnapshot?: boolean
suspense?: boolean
/** A successful return is the initial-readiness observation; pending reads expose a fallback. */
suspendsUntilReady?: boolean
/**
* Public sharing policy: queries whose config passes `pooledLiveQueries`
* and that filter only by `eq` on one source share one source subscription
Expand Down
35 changes: 6 additions & 29 deletions packages/db/tests/conformance/indexing-config-oracle.ts
Original file line number Diff line number Diff line change
Expand Up @@ -114,38 +114,15 @@ export function expectIndexingConfiguration(
expect(collection.config.defaultIndexType).toBe(expected.constructor)
}

// Existing Suspense sharing remains available to separate bindings with the
// same query identity and compatible effective indexing declarations. An
// omitted mode means off; constructor identity is reference equality.
export function expectedIndexingReuse(
first: IndexSettings,
second: IndexSettings,
identity: ReturnType<typeof indexConfigurationPairs>[number][`identity`],
): boolean {
const left = expectedIndexing(first)
const right = expectedIndexing(second)
return (
identity !== `different-key` &&
!left.rejects &&
!right.rejects &&
left.mode === right.mode &&
left.constructor === right.constructor
)
}

export function expectIndexingReuse(
// Independent Suspense bindings own distinct outputs (maintainer decision,
// 2026-11-03). Effective indexing equality never authorizes mutable sharing.
export function expectIndependentIndexingOutputs(
firstOutput: object | undefined,
secondOutput: object | undefined,
first: IndexSettings,
second: IndexSettings,
identity: ReturnType<typeof indexConfigurationPairs>[number][`identity`],
): void {
if (expectedIndexingReuse(first, second, identity))
expect(secondOutput).toBe(firstOutput)
else if (
!expectedIndexing(first).rejects &&
!expectedIndexing(second).rejects
)
if (!expectedIndexing(first).rejects && !expectedIndexing(second).rejects)
expect(secondOutput).not.toBe(firstOutput)
}

Expand All @@ -167,12 +144,12 @@ declareOracle({
indexConfigurationPairs,
indexOptionAbsencePairs,
],
model: [expectedIndexing, expectedIndexingReuse],
model: [expectedIndexing],
check: [
expectIndexing,
expectIndexingAdmission,
expectIndexingConfiguration,
expectIndexingReuse,
expectIndependentIndexingOutputs,
],
})

Expand Down
19 changes: 8 additions & 11 deletions packages/db/tests/conformance/indexing-reuse-oracle.test.ts
Original file line number Diff line number Diff line change
@@ -1,17 +1,16 @@
/**
* Calibrate the shared initial-binding identity comparison over its existing
* finite grammar. Each pair of admitted declarations supplies the opposite of
* the promised identity relation: an overshared or undershared output. Rejected
* the promised identity relation: a shared output despite independent bindings. Rejected
* declarations supply no output and impose no output-identity comparison.
* This tests checker sensitivity, not another production receiving path.
*/
import { expect, test } from 'vitest'
import { BTreeIndex, BasicIndex } from '../../src'
import { declareOracle } from '../oracle-declaration'
import {
expectIndexingReuse,
expectIndependentIndexingOutputs,
expectedIndexing,
expectedIndexingReuse,
indexConfigurationLabel,
indexConfigurationPairs,
indexConfigurations,
Expand All @@ -24,25 +23,23 @@ const histories = indexConfigurationPairs(
)
declareOracle({
grammar: [indexConfigurationPairs],
model: [expectedIndexing, expectedIndexingReuse],
check: [expectIndexingReuse],
model: [expectedIndexing],
check: [expectIndependentIndexingOutputs],
})

test.each(histories)(
`identity comparison rejects the opposite relation: %j`,
({ first, second, identity }) => {
({ first, second }) => {
const left = expectedIndexing(first).rejects ? undefined : {}
const right = expectedIndexing(second).rejects ? undefined : {}
if (!left || !right) {
expect(() =>
expectIndexingReuse(left, right, first, second, identity),
expectIndependentIndexingOutputs(left, right, first, second),
).not.toThrow()
} else {
const wrong = expectedIndexingReuse(first, second, identity)
? right
: left
const wrong = left
expect(() =>
expectIndexingReuse(left, wrong, first, second, identity),
expectIndependentIndexingOutputs(left, wrong, first, second),
).toThrow()
}
},
Expand Down
Loading
Loading