Skip to content
Open
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
5 changes: 5 additions & 0 deletions .changeset/disabled-query-hydration.md
Original file line number Diff line number Diff line change
@@ -0,0 +1,5 @@
---
'@tanstack/db': patch
---

Hydrate a disabled live query without waiting for a server record. The server never streams a record for a query whose callback returns `undefined`, so the browser hydration read no longer suspends on it and no longer throws error 253 after the DB stream ends.
5 changes: 4 additions & 1 deletion docs/contributing/oracle-coverage.md
Original file line number Diff line number Diff line change
Expand Up @@ -1472,7 +1472,10 @@ cross public client cleanup, rematerialization and a new stream for the same
hash. Old wait promises remain settled, a new stream obtains a fresh wait, and
enabling the same open stream preserves its current pending demand. The sticky
failure implementation and an unconditional reset each fail the corresponding
checkpoint. Pending-result
checkpoint. A disabled peer reader with a portable hash crosses every
history; its hydration read must stay disabled while transport is open, after
each terminal and on a fresh stream. The unconditional hydration wait suspends
it while open and throws error 253 after close. Pending-result
disposal during client cleanup and timestamp policy after record consumption
are excluded from this grammar. The
[SSR review audit](oracle-reviews/2026-10-23-ssr-review.md) records RED on unchanged
Expand Down
20 changes: 20 additions & 0 deletions examples/react/start-ssr-e2e/e2e/ssr-db.spec.ts
Original file line number Diff line number Diff line change
Expand Up @@ -91,3 +91,23 @@ test(`TanStack Start streams a DB result snapshot and hands off to browser sync`
)
expect(browserErrors).toEqual([])
})

test(`TanStack Start hydrates a disabled live query after the DB stream ends`, async ({
page,
}) => {
const browserErrors: Array<string> = []
page.on(`console`, (message) => {
if (message.type() === `error`) {
browserErrors.push(message.text())
}
})
page.on(`pageerror`, (error) => {
browserErrors.push(error.message)
})

await page.goto(`/disabled-query`)

await expect(page.getByTestId(`hydration-state`)).toHaveText(`hydrated`)
await expect(page.getByTestId(`query-status`)).toHaveText(`disabled`)
expect(browserErrors).toEqual([])
})
24 changes: 21 additions & 3 deletions examples/react/start-ssr-e2e/src/routeTree.gen.ts
Original file line number Diff line number Diff line change
Expand Up @@ -10,6 +10,7 @@

import { Route as rootRouteImport } from './routes/__root'
import { Route as IndexRouteImport } from './routes/index'
import { Route as DisabledQueryRouteImport } from './routes/disabled-query'
import { Route as SsrDbRouteImport } from './routes/ssr-db'
import { Route as SsrDbStreamRouteImport } from './routes/ssr-db-stream'

Expand All @@ -18,6 +19,11 @@ const IndexRoute = IndexRouteImport.update({
path: '/',
getParentRoute: () => rootRouteImport,
} as any)
const DisabledQueryRoute = DisabledQueryRouteImport.update({
id: '/disabled-query',
path: '/disabled-query',
getParentRoute: () => rootRouteImport,
} as any)
const SsrDbRoute = SsrDbRouteImport.update({
id: '/ssr-db',
path: '/ssr-db',
Expand All @@ -31,30 +37,34 @@ const SsrDbStreamRoute = SsrDbStreamRouteImport.update({

export interface FileRoutesByFullPath {
'/': typeof IndexRoute
'/disabled-query': typeof DisabledQueryRoute
'/ssr-db': typeof SsrDbRoute
'/ssr-db-stream': typeof SsrDbStreamRoute
}
export interface FileRoutesByTo {
'/': typeof IndexRoute
'/disabled-query': typeof DisabledQueryRoute
'/ssr-db': typeof SsrDbRoute
'/ssr-db-stream': typeof SsrDbStreamRoute
}
export interface FileRoutesById {
__root__: typeof rootRouteImport
'/': typeof IndexRoute
'/disabled-query': typeof DisabledQueryRoute
'/ssr-db': typeof SsrDbRoute
'/ssr-db-stream': typeof SsrDbStreamRoute
}
export interface FileRouteTypes {
fileRoutesByFullPath: FileRoutesByFullPath
fullPaths: '/' | '/ssr-db' | '/ssr-db-stream'
fullPaths: '/' | '/disabled-query' | '/ssr-db' | '/ssr-db-stream'
fileRoutesByTo: FileRoutesByTo
to: '/' | '/ssr-db' | '/ssr-db-stream'
id: '__root__' | '/' | '/ssr-db' | '/ssr-db-stream'
to: '/' | '/disabled-query' | '/ssr-db' | '/ssr-db-stream'
id: '__root__' | '/' | '/disabled-query' | '/ssr-db' | '/ssr-db-stream'
fileRoutesById: FileRoutesById
}
export interface RootRouteChildren {
IndexRoute: typeof IndexRoute
DisabledQueryRoute: typeof DisabledQueryRoute
SsrDbRoute: typeof SsrDbRoute
SsrDbStreamRoute: typeof SsrDbStreamRoute
}
Expand All @@ -68,6 +78,13 @@ declare module '@tanstack/react-router' {
preLoaderRoute: typeof IndexRouteImport
parentRoute: typeof rootRouteImport
}
'/disabled-query': {
id: '/disabled-query'
path: '/disabled-query'
fullPath: '/disabled-query'
preLoaderRoute: typeof DisabledQueryRouteImport
parentRoute: typeof rootRouteImport
}
'/ssr-db': {
id: '/ssr-db'
path: '/ssr-db'
Expand All @@ -87,6 +104,7 @@ declare module '@tanstack/react-router' {

const rootRouteChildren: RootRouteChildren = {
IndexRoute: IndexRoute,
DisabledQueryRoute: DisabledQueryRoute,
SsrDbRoute: SsrDbRoute,
SsrDbStreamRoute: SsrDbStreamRoute,
}
Expand Down
24 changes: 24 additions & 0 deletions examples/react/start-ssr-e2e/src/routes/disabled-query.tsx
Original file line number Diff line number Diff line change
@@ -0,0 +1,24 @@
import * as React from 'react'
import { createFileRoute } from '@tanstack/react-router'
import { useLiveQuery } from '@tanstack/react-db'
import { ssrTodoCollection } from '../lib/ssr-fixture'

export const Route = createFileRoute(`/disabled-query`)({
component: DisabledQueryRoute,
})

function DisabledQueryRoute() {
const [hydrated, setHydrated] = React.useState(false)
const [id] = React.useState<string | undefined>(undefined)
const { status } = useLiveQuery({
query: (q) =>
id === undefined ? undefined : q.from({ todo: ssrTodoCollection }),
})
React.useEffect(() => setHydrated(true), [])
return (
<main>
<span data-testid="hydration-state">{hydrated ? `hydrated` : `ssr`}</span>
<span data-testid="query-status">{status}</span>
</main>
)
}
4 changes: 3 additions & 1 deletion packages/db/src/live-query-observer-client.ts
Original file line number Diff line number Diff line change
Expand Up @@ -110,10 +110,12 @@ export class LiveQueryObserverClient<

getServerSnapshot(preloadRequested: boolean): LiveQuerySnapshot<T, TKey> {
preloadRequested &&= this.client._isSsrStreamingEnabled()
// A disabled query renders no rows on the server, so no record is owed.
if (
typeof window !== `undefined` &&
!this.authoritative &&
this.queryHash
this.queryHash &&
this.collection
) {
const waiting = this.client._waitForSsrLiveQuery(this.queryHash, true)
if (waiting) throw waiting
Expand Down
17 changes: 17 additions & 0 deletions packages/db/tests/ssr-stream-record-wait-oracle.test.ts
Original file line number Diff line number Diff line change
Expand Up @@ -11,6 +11,8 @@
* the missing-record lifetime law. No timestamp policy after consumption is
* asserted. Internal wait observations isolate settlement and identity; public
* observer reads check terminal retries without starting adapter work.
* A disabled peer reader rendered no query on the server, so it is owed no
* record: its hydration read stays disabled at every checkpoint.
*/
import { fc } from '@fast-check/vitest'
import { describe, expect, it } from 'vitest'
Expand Down Expand Up @@ -73,6 +75,17 @@ async function checkRecordHistory(history: SsrStreamRecordHistory) {
queryHash: hash,
mode: `wholesale`,
})
// Framework adapters still give a disabled reader a portable hash.
const disabled = createLiveQueryObserver(null, {
client,
queryHash: `disabled-reader`,
mode: `wholesale`,
})
const expectDisabledView = (checkpoint: string) =>
expect(
disabled.getServerSnapshot().status,
`a disabled reader owes no record ${checkpoint}`,
).toBe(`disabled`)
const result = createDeferred<DehydratedLiveQueryResult>()
const snapshot: DehydratedLiveQueryResult = {
rows: [{ key: `a`, value: { id: `a`, version: 0 } }],
Expand All @@ -94,6 +107,7 @@ async function checkRecordHistory(history: SsrStreamRecordHistory) {
await client.preloadLiveQuery({ query })
const prefix = starts
const wait = () => client._waitForSsrLiveQuery(hash, true)
expectDisabledView(`while transport is open`)
if (!history.attemptAfterTerminal) {
const waiting = wait()
if (history.record === `pending`) expect(waiting).toBeUndefined()
Expand Down Expand Up @@ -139,6 +153,7 @@ async function checkRecordHistory(history: SsrStreamRecordHistory) {
client._failPendingLiveQueries(cause)
client._setSsrStreamingEnabled(false)
} else await client.cleanup()
expectDisabledView(`after transport ${history.terminal}`)
const expected = expectedSsrStreamRecordOutcome(history)
if (expected === `snapshot` || expected === `pending`) {
expect(wait()).toBeUndefined()
Expand Down Expand Up @@ -202,6 +217,7 @@ async function checkRecordHistory(history: SsrStreamRecordHistory) {
collections: [],
liveQueries: [{ queryHash: hash, dehydratedAt: 3, snapshot }],
})
expectDisabledView(`on a fresh stream`)
await replacement
expect(wait()).toBeUndefined()
expect(client._getLiveQuery(hash, true)?.snapshot).toEqual(snapshot)
Expand All @@ -212,6 +228,7 @@ async function checkRecordHistory(history: SsrStreamRecordHistory) {
}, [
() => result.resolve(snapshot),
() => observer.dispose(),
() => disabled.dispose(),
() => client.cleanup(),
])
}
Expand Down