diff --git a/.changeset/disabled-query-hydration.md b/.changeset/disabled-query-hydration.md new file mode 100644 index 0000000000..ec1075f9a4 --- /dev/null +++ b/.changeset/disabled-query-hydration.md @@ -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. diff --git a/docs/contributing/oracle-coverage.md b/docs/contributing/oracle-coverage.md index 6e5ba7182e..a0155289ab 100644 --- a/docs/contributing/oracle-coverage.md +++ b/docs/contributing/oracle-coverage.md @@ -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 diff --git a/examples/react/start-ssr-e2e/e2e/ssr-db.spec.ts b/examples/react/start-ssr-e2e/e2e/ssr-db.spec.ts index f9461d6fac..1c0ae61df2 100644 --- a/examples/react/start-ssr-e2e/e2e/ssr-db.spec.ts +++ b/examples/react/start-ssr-e2e/e2e/ssr-db.spec.ts @@ -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 = [] + 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([]) +}) diff --git a/examples/react/start-ssr-e2e/src/routeTree.gen.ts b/examples/react/start-ssr-e2e/src/routeTree.gen.ts index d7843dc42f..de15eb5a61 100644 --- a/examples/react/start-ssr-e2e/src/routeTree.gen.ts +++ b/examples/react/start-ssr-e2e/src/routeTree.gen.ts @@ -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' @@ -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', @@ -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 } @@ -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' @@ -87,6 +104,7 @@ declare module '@tanstack/react-router' { const rootRouteChildren: RootRouteChildren = { IndexRoute: IndexRoute, + DisabledQueryRoute: DisabledQueryRoute, SsrDbRoute: SsrDbRoute, SsrDbStreamRoute: SsrDbStreamRoute, } diff --git a/examples/react/start-ssr-e2e/src/routes/disabled-query.tsx b/examples/react/start-ssr-e2e/src/routes/disabled-query.tsx new file mode 100644 index 0000000000..1f4bb73683 --- /dev/null +++ b/examples/react/start-ssr-e2e/src/routes/disabled-query.tsx @@ -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(undefined) + const { status } = useLiveQuery({ + query: (q) => + id === undefined ? undefined : q.from({ todo: ssrTodoCollection }), + }) + React.useEffect(() => setHydrated(true), []) + return ( +
+ {hydrated ? `hydrated` : `ssr`} + {status} +
+ ) +} diff --git a/packages/db/src/live-query-observer-client.ts b/packages/db/src/live-query-observer-client.ts index cac2f6d6e4..7c2c0845c4 100644 --- a/packages/db/src/live-query-observer-client.ts +++ b/packages/db/src/live-query-observer-client.ts @@ -110,10 +110,12 @@ export class LiveQueryObserverClient< getServerSnapshot(preloadRequested: boolean): LiveQuerySnapshot { 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 diff --git a/packages/db/tests/ssr-stream-record-wait-oracle.test.ts b/packages/db/tests/ssr-stream-record-wait-oracle.test.ts index 87c307e9d2..b4c80630d7 100644 --- a/packages/db/tests/ssr-stream-record-wait-oracle.test.ts +++ b/packages/db/tests/ssr-stream-record-wait-oracle.test.ts @@ -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' @@ -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() const snapshot: DehydratedLiveQueryResult = { rows: [{ key: `a`, value: { id: `a`, version: 0 } }], @@ -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() @@ -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() @@ -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) @@ -212,6 +228,7 @@ async function checkRecordHistory(history: SsrStreamRecordHistory) { }, [ () => result.resolve(snapshot), () => observer.dispose(), + () => disabled.dispose(), () => client.cleanup(), ]) }