From 2d11707bbb6de09e9e383163e49d93b2c66d69e6 Mon Sep 17 00:00:00 2001 From: Jaime Resano Date: Sat, 31 Oct 2026 13:46:24 +0100 Subject: [PATCH] fix(db): hydrate disabled live queries without a stream record A query whose callback returns undefined has no collection, so the server never captures or streams a record for it. The browser hydration read still waited on its hash: it suspended while the DB stream was open and threw error 253 once the stream ended, so React fell back to client rendering. Skip the hydration wait when the observer has no collection. Co-Authored-By: Claude Opus 5.5 --- .changeset/disabled-query-hydration.md | 5 ++++ docs/contributing/oracle-coverage.md | 5 +++- .../react/start-ssr-e2e/e2e/ssr-db.spec.ts | 20 ++++++++++++++++ .../react/start-ssr-e2e/src/routeTree.gen.ts | 24 ++++++++++++++++--- .../src/routes/disabled-query.tsx | 24 +++++++++++++++++++ packages/db/src/live-query-observer-client.ts | 4 +++- .../ssr-stream-record-wait-oracle.test.ts | 17 +++++++++++++ 7 files changed, 94 insertions(+), 5 deletions(-) create mode 100644 .changeset/disabled-query-hydration.md create mode 100644 examples/react/start-ssr-e2e/src/routes/disabled-query.tsx 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(), ]) }