diff --git a/.changeset/solid-ssr-transport-payload.md b/.changeset/solid-ssr-transport-payload.md new file mode 100644 index 0000000000..08bcd5fb4a --- /dev/null +++ b/.changeset/solid-ssr-transport-payload.md @@ -0,0 +1,6 @@ +--- +'@tanstack/solid-router': patch +'@tanstack/solid-start-client': patch +--- + +Solid owns its SSR transport and payload channel — with zero diff outside the Solid packages. The DehydratedRouter rides Solid's eval-free JSON record codec (`__TSR_P`) instead of the `$_TSR` script channel: a Solid-side override of the framework-only `serverSsr.dehydrate` (installed via the `onServerSsrAttach` lifecycle) builds the payload the way core does and serializes it through `createJSONSerializer` into a Solid-owned script buffer served by overridden `takeBufferedScripts`/`liftScriptBarrier`; the HTML stream transform is replaced with a Solid-native script sink in `renderRouterToStream`; and the client installs a synthetic `window.$_TSR` whose lazy `router` getter decodes the record queue, so core `hydrate` is unchanged. The SSR HTML carries no `$_TSR` bootstrap, no `$R` cross-reference header, and no parse-time eval. Falls back to the script channel wherever the Solid transfer isn't armed (and the client shim defers to a real `$_TSR` bootstrap), so React/Vue and older-server documents are untouched. A follow-up core-hooks PR against `main` will collapse the overrides into supported seams. diff --git a/RFC-solid-native-ssr.md b/RFC-solid-native-ssr.md index bdc1030fa6..7f95033fa9 100644 --- a/RFC-solid-native-ssr.md +++ b/RFC-solid-native-ssr.md @@ -74,10 +74,29 @@ renderer) never enters `createStartHandler`, so it can change freely. serialization in `RouterProvider`.)_ During server render the adapter serializes each settled match's state content-addressed (`tsr:` keys) — the pattern `solid-query`'s provider proved. - Still open within this bullet: promise-valued entries for matches - pending at render time (streaming SSR). This core has no per-match - settle promise, so it needs a dispatch-time hook; today pending matches - are skipped and the client boot falls through to current behavior. + The promise-valued half landed where the blocking contract puts it: not + as whole-match entries (blocking loaders settle before publish by + contract, so a pending match at serialize time doesn't exist on the + happy path) but as _deferred `loaderData` fields_. An unawaited promise + in `loaderData` rides `ctx.serialize` untouched — seroval streams its + resolution, the shell flushes with the fallback, the value arrives in a + later chunk, and the hydrating client adopts the same promise from the + registry entry. Zero adapter code; verified by chunk-order assertions + in the harness. Read-side, `` is compat surface only — the + native consumption is a memo returning the promise read under a + `Loading` boundary. +- **Provider-owned server dispatch.** _(Landed: `RouterProvider`.)_ The + server entry no longer calls `await router.load()` — the provider + detects an unloaded router (`!router._serverResult`), kicks `load()` + itself, and parks the render on it through an async memo gating the + match tree. Solid's streaming renderer awaits the park natively, so + blocking-loader semantics are byte-identical to the manual await; the + gate memo exists on both environments so hydration keys stay aligned, + and the client resolves it immediately (boot is the constructor priming + plus `Transitioner`'s settled-time load). Consequence: the bare pairing + requires `renderToStream` — `renderToString` is synchronous by design + in Solid 2 and throws on parked values. Recipes and templates use + `renderToStream` unconditionally. - **Hydration-claiming boot.** _(Landed: the `Router` constructor.)_ Match synchronously, prime match state from the registry, commit without running loaders. Placement discovered to be load-bearing: @@ -101,19 +120,104 @@ renderer) never enters `createStartHandler`, so it can change freely. `start-server-core` orchestrates through `attachRouterServerSsrUtils`, `dehydrate()`/`hydrate()`, and the stream handler — that contract is -shared core and stays intact as a facade. Within it, the Solid -`defaultStreamHandler`/`renderRouterToStream` source the transfer from the -registry channel instead of `__TSR_SSR__` script injection wherever both -exist; user `dehydrate`/`hydrate` hooks keep working. The flight collector -already went through this door: `loadFlightTarget` absorbed the -event-derivation half, and its extraction half shrinks further once match -state is registry-addressed. - -**Regression gate:** the three Solid Start e2e suites -(`basic-solid-query`, `server-functions`, `server-routes` — 37 tests, -including redirect-from-query on both mount and SSR paths, and the -transition semantics) all run locally today and define "didn't break -Start." +shared core and stays intact as a facade. The flight collector already +went through this door: `loadFlightTarget` absorbed the event-derivation +half. Phase 2 splits into transport and payload: + +- **2a — Solid-owned script transport.** _(Landed: + `renderRouterToStream`.)_ The Solid path no longer runs + `transformStreamWithRouter` — the 900-line HTML transform that decoded + every chunk, scanned for closing-tag boundaries, spliced router scripts + in, and held the `` tail until serialization finished. + Router scripts now ride the response writer directly: the shell payload + was always inlined by `` during the render (untouched), and + late scripts (streamed loaderData resolutions, the end marker) write + straight to the sink as the serializer emits them — the same + after-the-shell placement Solid's own late chunks use (HTML5 parsers + reparent trailing content; this is Solid's production protocol). The + script barrier lifts when the chunk carrying the `` tag has + been written (chunks are scanned only until the marker is seen); the + response closes when both the render completed and serialization + finished, with the transform's 60s timeout and cleanup semantics + preserved. Zero per-chunk decode/scan/splice on the hot path. +- **2b — payload through Solid's JSON codec.** _(Landed.)_ The parse-time + wall that blocked the registry route (adapter-typed values serialize as + `$_TSR.t.get(key)(...)` calls that evaluate before `fromSerializable` + implementations exist) doesn't exist on Solid's other channel: the + eval-free JSON codec Start's server functions already ride — + `createJSONSerializer` emits inert `SerovalNode` records, the client + decodes at runtime with `makeSerovalPlugin`-wrapped adapters. The + DehydratedRouter now takes that road — and, on this pre-release branch, + with **zero diff outside the Solid packages**: everything rides + Solid-side overrides of the (documented framework-only) + `router.serverSsr` members, installed through the existing + `onServerSsrAttach` lifecycle. A follow-up core-hooks PR against `main` + will let the overrides collapse into supported seams. + - **Attach seam (`solid-router`):** the `Router` constructor registers + an `onServerSsrAttach` listener that resolves the installer through a + slot (`solidSsrTransferSlot`) filled by the `ssr/server` entry module + — the encode half of Solid's codec must never enter the client module + graph (the Solid vite plugin treats it as server-only and a client + bundle that reaches it loses its entry emission), and the package's + `sideEffects` allowlist keeps bundlers from dropping the slot fill. + Unfilled slot (client bundle, or a server render that never imports + Solid's `ssr/server`) means core's script channel runs unchanged. + - **Server (`installSolidSsrTransfer`):** replaces `serverSsr.dehydrate` + with a Solid implementation that builds the DehydratedRouter the way + core does (rendered matches, shell slicing, `options.dehydrate()` + data, and a dehydrated manifest _derived_ from the public + `router.ssr.manifest` getter — the raw ServerManifest is closed over + by core's attach and unreachable from an adapter), then serializes it + through `createJSONSerializer` with the RPC codec's plugin recipe + (router adapters via `makeSerovalPlugin` + router defaults minus the + ReadableStream plugin Solid's codec already carries). Records ride a + Solid-owned mirror of core's `ScriptBuffer` as + `(self.__TSR_P=self.__TSR_P||[]).push({...})` data pushes — + `takeBufferedScripts`/`liftScriptBarrier` are overridden to serve it + with core's exact shell-inline tag shape (barrier id, nonce, + self-removal), so `` inlining, barrier deferral, and the + 2a sink all work unchanged. + `isDehydrated`/`isSerializationFinished`/`onSerializationFinished` + answer from Solid-side state (core's internal flags never advance + since core's dehydrate never runs); `setRenderFinished`/`cleanup` + wrap the originals so core's render-finished listeners and teardown + still fire. Core's seeded `$R` scope header and `$_TSR` bootstrap are + drained and discarded at attach — the channel carries no executable + payload. Streamed loaderData promises settle through later records; + the serializer's `onDone` is the end-of-stream that gates the sink + close. + - **Client (synthetic `$_TSR`):** core `hydrate(router)` is unchanged — + it still reads `window.$_TSR` (sets `.t`, replays `.buffer`, then + reads `.router`). Solid installs a synthetic `$_TSR` whose lazy + `router` getter decodes the `__TSR_P` queue through + `createJSONDataTable` at that final read — after adapters are + finalized (`RouterClient` reads them off the router; Solid's + `hydrateStart` reads the adapter array off + `window.__TSS_START_OPTIONS__`, the same array core's `hydrateStart` + populates before calling `hydrate`) — and hooks the queue's `push` so + late records settle pending promises. The object is built from + decoded JSON records; no parse-time eval, no `t` map, no + deferred-script buffer. Two load-bearing details: the decode module + is loaded through a dynamic import (a static one merges it into the + importer's chunk — for default-entry apps the client entry itself, + which then registers as a dynamic-import target of Solid's own lazy + decode load and gets its `isEntry` stripped by the vite plugin's + lazy-entry normalization, breaking Start's manifest capture), so the + shim's install returns a promise the callers await before core + hydrate; and the synthetic's `h()` deletes the global (the shim has + no post-hydration role — late records ride the queue's hooked `push`, + never `$_TSR.p`), keeping `typeof window.$_TSR === 'undefined'` a + valid hydration-finished probe on both channels. The shim is a no-op + when a real `$_TSR` bootstrap exists (a document rendered by a + script-channel server), so React/Vue and older-server documents are + untouched. + +**Regression gate (2a + 2b, all green on a fresh build):** nine Solid Start +e2e suites — `basic` (80), `server-functions` (29), `deferred-hydration` +(15), `selective-ssr` (11), `scroll-restoration` (10), `basic-solid-query` +(6), `serialization-adapters` (5), `server-routes` (2), `spa-mode` (2) — +160 tests covering streaming order, selective SSR lanes, adapter decode, +scroll scripts, and shell mode, plus the bare-pairing harness. ## Phase 3 — upstream @@ -128,11 +232,17 @@ without a native channel. - Match key identity: route id + params hash vs match id — needs to be stable across server/client and across redirects into the same route. -- `loaderData` streaming semantics vs the existing deferred API: a - promise-valued registry entry makes deferred _transfer_ free, but the - read-side API compatibility needs mapping. -- Scroll restoration and `__TSR_SSR__` consumers beyond match state +- ~~`loaderData` streaming semantics vs the existing deferred API~~ — + resolved: transfer is free (seroval streams promise fields in registry + entries), and the existing ``/`useAwaited` API keeps working as + compat while native reads (async memo under `Loading`) are the + documented path. +- ~~Scroll restoration and `__TSR_SSR__` consumers beyond match state (manifest/asset injection) — inventory what else rides the script - channel before swapping it. + channel before swapping it~~ — resolved: the channel's only producer + was `dehydrate()` itself (scroll restoration rides match meta/assets; + the manifest is inside the DehydratedRouter). With 2b the payload + carries everything and the bootstrap/cross-reference seeds are gone; + the `scroll-restoration` suite is green on the new channel. - Where the SSR teardown lands for router state (the query cache got cancel+clear on render disposal; matches may want the same). diff --git a/benchmarks/ssr/bench-utils.ts b/benchmarks/ssr/bench-utils.ts index 7a54fce897..718cecceec 100644 --- a/benchmarks/ssr/bench-utils.ts +++ b/benchmarks/ssr/bench-utils.ts @@ -37,6 +37,19 @@ function randomSegment(random: () => number) { export { createDeterministicRandom, randomSegment } +// React and Vue dehydrate the router over the `$_TSR` script channel; Solid +// ships the same payload as JSON records on the `__TSR_P` queue. Sanity +// checks accept whichever channel the framework under test emits. +const dehydrationMarkers = ['$_TSR', '__TSR_P'] as const + +export function findDehydrationMarkerIndex(body: string) { + for (const marker of dehydrationMarkers) { + const index = body.indexOf(marker) + if (index !== -1) return index + } + return -1 +} + export async function drainResponse(response: Response) { const reader = response.body?.getReader() diff --git a/benchmarks/ssr/scenarios/loaders/shared-bench.ts b/benchmarks/ssr/scenarios/loaders/shared-bench.ts index f52c51dccd..207995cd19 100644 --- a/benchmarks/ssr/scenarios/loaders/shared-bench.ts +++ b/benchmarks/ssr/scenarios/loaders/shared-bench.ts @@ -1,5 +1,9 @@ import { makeLevelData } from './shared-data' -import { randomSegment, runRequestLoop } from '../../bench-utils' +import { + findDehydrationMarkerIndex, + randomSegment, + runRequestLoop, +} from '../../bench-utils' import type { StartRequestHandler } from '../../bench-utils' export type { StartRequestHandler } @@ -54,7 +58,7 @@ export async function assertLoadersSanity(handler: StartRequestHandler) { throw new Error('Expected setup response to include the leaf loader item') } - if (!body.includes('$_TSR')) { + if (findDehydrationMarkerIndex(body) === -1) { throw new Error('Expected setup response to include the dehydration marker') } } diff --git a/benchmarks/ssr/scenarios/selective-ssr/shared-bench.ts b/benchmarks/ssr/scenarios/selective-ssr/shared-bench.ts index 5b2530ccab..bf7bed4405 100644 --- a/benchmarks/ssr/scenarios/selective-ssr/shared-bench.ts +++ b/benchmarks/ssr/scenarios/selective-ssr/shared-bench.ts @@ -1,5 +1,9 @@ import { makeLevelData } from '../loaders/shared-data' -import { randomSegment, runRequestLoop } from '../../bench-utils' +import { + findDehydrationMarkerIndex, + randomSegment, + runRequestLoop, +} from '../../bench-utils' import type { StartRequestHandler } from '../../bench-utils' export type { StartRequestHandler } @@ -60,7 +64,7 @@ export async function assertSelectiveSanity(handler: StartRequestHandler) { ) } - const hydrationIndex = body.indexOf('$_TSR') + const hydrationIndex = findDehydrationMarkerIndex(body) if (hydrationIndex === -1) { throw new Error('Expected setup response to include the dehydration marker') diff --git a/benchmarks/ssr/scenarios/serialization/shared-bench.ts b/benchmarks/ssr/scenarios/serialization/shared-bench.ts index 25ed478f03..b15d8c09c9 100644 --- a/benchmarks/ssr/scenarios/serialization/shared-bench.ts +++ b/benchmarks/ssr/scenarios/serialization/shared-bench.ts @@ -1,5 +1,9 @@ import { benchPointAdapterKey, richDateIso } from './shared-data' -import { randomSegment, runRequestLoop } from '../../bench-utils' +import { + findDehydrationMarkerIndex, + randomSegment, + runRequestLoop, +} from '../../bench-utils' import type { StartRequestHandler } from '../../bench-utils' export type { StartRequestHandler } @@ -41,7 +45,7 @@ async function fetchScenarioBody( ) } - if (!body.includes('$_TSR')) { + if (findDehydrationMarkerIndex(body) === -1) { throw new Error(`Expected ${route} response to include dehydration marker`) } @@ -60,24 +64,37 @@ function assertExcludes(body: string, marker: string, label: string) { } } +// Typed values leave different artifacts per dehydration channel: the +// `$_TSR` script channel emits reconstruction code (`new Date(...)`), while +// Solid's `__TSR_P` JSON records carry seroval node tags (Date is node type +// 5, Map is 8) and route Errors through the `$TSR/Error` adapter. +const scriptChannelTypedMarkers = ['new Date', 'new Map', 'new Error'] +const jsonChannelTypedMarkers = ['"t":5', '"t":8', '$TSR/Error'] + export async function assertSerializationScenario( handler: StartRequestHandler, ) { const richBody = await fetchScenarioBody(handler, 'rich') const plainBody = await fetchScenarioBody(handler, 'plain') + const typedMarkers = richBody.includes('__TSR_P') + ? jsonChannelTypedMarkers + : scriptChannelTypedMarkers assertIncludes(richBody, 'rich-sanity', 'rich') assertIncludes(richBody, benchPointAdapterKey, 'rich') assertIncludes(richBody, richDateIso, 'rich') - assertIncludes(richBody, 'new Date', 'rich') - assertIncludes(richBody, 'new Map', 'rich') - assertIncludes(richBody, 'new Error', 'rich') + for (const marker of typedMarkers) { + assertIncludes(richBody, marker, 'rich') + } assertIncludes(plainBody, 'plain-sanity', 'plain') assertExcludes(plainBody, benchPointAdapterKey, 'plain') - assertExcludes(plainBody, 'new Date', 'plain') - assertExcludes(plainBody, 'new Map', 'plain') - assertExcludes(plainBody, 'new Error', 'plain') + for (const marker of [ + ...scriptChannelTypedMarkers, + ...jsonChannelTypedMarkers, + ]) { + assertExcludes(plainBody, marker, 'plain') + } } export const serializationBenchOptions = { diff --git a/benchmarks/ssr/scenarios/streaming/shared-bench.ts b/benchmarks/ssr/scenarios/streaming/shared-bench.ts index cc45062e1b..6e24af36d7 100644 --- a/benchmarks/ssr/scenarios/streaming/shared-bench.ts +++ b/benchmarks/ssr/scenarios/streaming/shared-bench.ts @@ -1,6 +1,10 @@ import { expect } from 'vitest' import { streamChunkCount } from './shared-data' -import { randomSegment, runRequestLoop } from '../../bench-utils' +import { + findDehydrationMarkerIndex, + randomSegment, + runRequestLoop, +} from '../../bench-utils' import type { StartRequestHandler } from '../../bench-utils' export type { StartRequestHandler } @@ -45,7 +49,7 @@ export async function assertStreamingSanity(handler: StartRequestHandler) { const body = await response.text() const loadingSmallIndexes = getMarkerIndexes(body, 'loading-small') const loadingBigIndexes = getMarkerIndexes(body, 'loading-big') - const tsrIndex = body.indexOf('$_TSR') + const tsrIndex = findDehydrationMarkerIndex(body) expect(response.status).toBe(200) expect(loadingSmallIndexes).toHaveLength(1) diff --git a/packages/solid-router/package.json b/packages/solid-router/package.json index 2d803972a0..7a9768b10b 100644 --- a/packages/solid-router/package.json +++ b/packages/solid-router/package.json @@ -89,7 +89,10 @@ }, "./package.json": "./package.json" }, - "sideEffects": false, + "sideEffects": [ + "**/ssr/server.js", + "**/ssr/server.cjs" + ], "files": [ "dist", "src", diff --git a/packages/solid-router/repro-external-ssr/.gitignore b/packages/solid-router/repro-external-ssr/.gitignore new file mode 100644 index 0000000000..89f9ac04aa --- /dev/null +++ b/packages/solid-router/repro-external-ssr/.gitignore @@ -0,0 +1 @@ +out/ diff --git a/packages/solid-router/src/router.ts b/packages/solid-router/src/router.ts index 6ae1d85739..5587d19768 100644 --- a/packages/solid-router/src/router.ts +++ b/packages/solid-router/src/router.ts @@ -2,6 +2,7 @@ import { RouterCore } from '@tanstack/router-core' import { isServer } from '@tanstack/router-core/isServer' import { getStoreFactory } from './routerStores' import { primeRouterFromRegistry } from './registryTransfer' +import { solidSsrTransfer } from './ssr/solidSsrTransferSlot' import type { RouterHistory } from '@tanstack/history' import type { AnyRoute, @@ -102,6 +103,25 @@ export class Router< >, ) { super(options, getStoreFactory) + // Solid transports the Start SSR payload (DehydratedRouter) through + // Solid's eval-free JSON codec instead of the `$_TSR` script channel: + // the overrides installed at serverSsr-attach time (see + // routerPayloadServer) own dehydration + the buffered script channel, + // and the client decodes the record queue at hydrate time (see + // routerPayloadClient). Registration must happen at construction — + // before attachRouterServerSsrUtils fires the lifecycle — so the + // overrides are in place when Start's executeRouter (or + // createRequestHandler) calls dehydrate(). The installer lives behind + // the transfer slot (see solidSsrTransferSlot); unfilled — client + // bundle, or a server render without Solid's ssr/server module — the + // listener no-ops and core's script channel runs unchanged. + if (isServer) { + const lifecycle = (this.serverSsrLifecycle ??= {}) + const listeners = (lifecycle.onServerSsrAttach ??= []) + listeners.push((serverSsr) => + solidSsrTransfer.install?.(this, serverSsr), + ) + } // The hydration-claiming boot (see registryTransfer). Router creation is // the client's natural pre-render moment: module code runs after the // document — and the registry entries the server's RouterProvider wrote — diff --git a/packages/solid-router/src/ssr/RouterClient.tsx b/packages/solid-router/src/ssr/RouterClient.tsx index 69abc97f0b..b41c753c5e 100644 --- a/packages/solid-router/src/ssr/RouterClient.tsx +++ b/packages/solid-router/src/ssr/RouterClient.tsx @@ -1,12 +1,27 @@ import { hydrate } from '@tanstack/router-core/ssr/client' import { Await } from '../awaited' import { RouterProvider } from '../RouterProvider' +import { + installRouterPayloadShim, + readRouterPayload, +} from './routerPayloadClient' import type { AnyRouter } from '@tanstack/router-core' let hydrationPromise: Promise | undefined export function RouterClient(props: { router: AnyRouter }) { - hydrationPromise ??= hydrate(props.router).finally(() => window.$_TSR!.h()) + if (!hydrationPromise) { + // The payload rides Solid's JSON codec (record queue); the shim hands it + // to the unchanged core hydrate through a synthetic `$_TSR` and resolves + // once the (lazily loaded) decoder is ready. The trailing `h()` signals + // hydration complete — the synthetic deletes itself; a real bootstrap + // (script-channel server) runs its own teardown. + hydrationPromise = installRouterPayloadShim(() => + readRouterPayload(props.router), + ) + .then(() => hydrate(props.router)) + .finally(() => window.$_TSR?.h()) + } return ( {} +// Matches TSR_SCRIPT_BARRIER_ID in router-core/src/ssr/constants.ts (and the +// Solid mirror in routerPayloadServer) — the id of the inline script tag +// renders when it drains the buffered payload into the shell. +// Once that tag has been WRITTEN to the sink, later scripts may flow (the +// initial payload record must parse before any streamed follow-up records). +const SCRIPT_BARRIER_MARKER = '$tsr-stream-barrier' + +// Mirrors the transform's serialization timeout: how long after the app +// render finishes we keep the response open waiting for router +// serialization (streamed loaderData promises) to settle. +const SERIALIZATION_TIMEOUT_MS = 60000 + // Bot responses wait for the server renderer before streaming. If the request // disconnects during that wait, unblock so the pipe can abort and clean up. async function waitForReadyOrAbort( @@ -49,6 +57,13 @@ export const renderRouterToStream = async ({ }) => { const { writable, readable } = new TransformStream() + // Solid transfer note: by the time this renderer runs, serverSsr.dehydrate + // (the Solid override — see routerPayloadServer) already serialized the + // initial payload record into the Solid script buffer, so + // drains it into the shell. Streamed loaderData resolutions keep emitting + // records afterward; the override's serialization-finished signal gates the + // stream close below through the same serverSsr members as before. + const serializationAdapters = (router.options as any)?.serializationAdapters || (router.options.ssr as any)?.serializationAdapters @@ -70,8 +85,8 @@ export const renderRouterToStream = async ({ // `w` via `w.getWriter()`. To still own the lifecycle we hand Solid a // proxy WritableStream that forwards into an inner writer we control on // the real TransformStream writable. Aborting the inner writer errors - // the underlying readable (which our router transform reads from), - // surfacing the cancel through the response pipeline. + // the underlying readable, surfacing the cancel through the response + // pipeline. // // RESIDUAL RISK: solid-js@1.x does NOT expose a disposal hook on // `renderToStream`, and its internal write loop swallows writer @@ -97,10 +112,170 @@ export const renderRouterToStream = async ({ const abortSolidPipe = (reason?: unknown) => { if (writerDone) return writerDone = true + tsrSink.dispose() void innerWriter .abort(reason) .catch(() => {}) .finally(releaseWriter) + // The old transform released router SSR state on its internal error + // paths; nobody else does when the response errors mid-consumption + // (dispose() is only driven by request abort). Idempotent. + try { + router.serverSsr?.cleanup() + } catch {} + } + + // --- Router script sink --------------------------------------------- + // The router's SSR channel (bootstrap + dehydrated payload + streamed + // promise resolutions + the end marker) used to reach the response + // through transformReadableStreamWithRouter: an HTML transform that + // decoded every chunk, scanned for safe closing-tag boundaries, spliced + // buffered scripts in, and held the `` tail until router + // serialization finished. + // + // Solid's own streaming protocol already appends late chunks (settled + // boundaries, hydration data scripts) after the shell — which for a + // full-document render means after ``; HTML5 parsers reparent + // trailing content into and execute scripts in order. Router + // scripts are the same class of content, so they ride the same writer + // directly: + // + // - The shell payload is untouched: drains the buffered + // bootstrap + initial payload into an inline tag during the render. + // - Late scripts write straight to the inner writer as the serializer + // emits them (writer queuing keeps them ordered between Solid chunks; + // they can never split a chunk). + // - The script barrier lifts when the chunk carrying the tag + // has been written — detected by scanning chunks (only) until the + // marker id is seen, then scanning stops. + // - The response closes when BOTH the Solid render completed AND router + // serialization finished, with the transform's 60s safety timeout. + const serverSsr = router.serverSsr + const textEncoder = new TextEncoder() + const barrierDecoder = serverSsr ? new TextDecoder() : undefined + // Carry the tail of the previous chunk so a marker split across two + // chunks is still seen. + let barrierScanTail = '' + let barrierLifted = !serverSsr + let solidDone = false + let serializationFinished = !serverSsr + let innerClosed = false + let serializationTimeout: ReturnType | undefined + const unsubscribes: Array<() => void> = [] + + const tsrSink = { + drain() { + if (!serverSsr || innerClosed || writerDone) return + const html = serverSsr.takeBufferedHtml() + if (!html) return + void innerWriter.write(textEncoder.encode(html)).catch(() => {}) + }, + scanForBarrier(chunk: unknown) { + if (barrierLifted || !serverSsr) return + let text: string | undefined + if (typeof chunk === 'string') { + text = chunk + } else if (ArrayBuffer.isView(chunk)) { + text = barrierDecoder!.decode(chunk as Uint8Array, { stream: true }) + } + if (text === undefined) return + const scan = barrierScanTail + text + if (scan.includes(SCRIPT_BARRIER_MARKER)) { + barrierLifted = true + barrierScanTail = '' + // Buffered post-shell scripts flow from here on (enqueue → + // microtask → injectScript → onInjectedHtml → drain). + serverSsr.liftScriptBarrier() + } else { + barrierScanTail = scan.slice(1 - SCRIPT_BARRIER_MARKER.length) + } + }, + maybeClose() { + if (!solidDone || !serializationFinished || innerClosed || writerDone) + return + innerClosed = true + this.dispose() + this.drainRaw() + writerDone = true + void innerWriter + .close() + .catch(() => {}) + .finally(() => { + releaseWriter() + // Normal completion releases router SSR state, matching the + // transform's end-of-stream cleanup. Idempotent under the + // dispose()-driven cleanup of cancelled responses. + try { + serverSsr?.cleanup() + } catch {} + }) + }, + // drain() without the closed guard, for the final flush ahead of close. + drainRaw() { + if (!serverSsr) return + const html = serverSsr.takeBufferedHtml() + if (!html) return + void innerWriter.write(textEncoder.encode(html)).catch(() => {}) + }, + dispose() { + if (serializationTimeout !== undefined) { + clearTimeout(serializationTimeout) + serializationTimeout = undefined + } + for (const unsub of unsubscribes.splice(0)) { + try { + unsub() + } catch {} + } + }, + onSolidDone() { + solidDone = true + if (!serverSsr) { + this.maybeClose() + return + } + try { + // Lifts the barrier as a fallback (an app without never + // renders the marker) and flushes if serialization already finished. + serverSsr.setRenderFinished() + } catch {} + serializationFinished = + serializationFinished || serverSsr.isSerializationFinished() + if (!serializationFinished && serializationTimeout === undefined) { + serializationTimeout = setTimeout(() => { + if (innerClosed || writerDone) return + console.error('Serialization timeout after app render finished') + abortSolidPipe( + new Error('Serialization timeout after app render finished'), + ) + try { + serverSsr.cleanup() + } catch {} + }, SERIALIZATION_TIMEOUT_MS) + } + this.drain() + this.maybeClose() + }, + } + + if (serverSsr) { + // Subscriptions before snapshots so events between the two are not lost. + unsubscribes.push( + serverSsr.onInjectedHtml(() => { + tsrSink.drain() + }), + serverSsr.onSerializationFinished(() => { + serializationFinished = true + if (serializationTimeout !== undefined) { + clearTimeout(serializationTimeout) + serializationTimeout = undefined + } + tsrSink.drain() + tsrSink.maybeClose() + }), + ) + serializationFinished = serverSsr.isSerializationFinished() + tsrSink.drain() } const onRequestAbort = () => { @@ -130,24 +305,27 @@ export const renderRouterToStream = async ({ const solidWritable = new WritableStream({ write(chunk) { + let out = chunk if (!doctypeWritten) { doctypeWritten = true if (ArrayBuffer.isView(chunk)) { const bytes = chunk as Uint8Array - const out = new Uint8Array(doctype.length + bytes.length) - out.set(doctype, 0) - out.set(bytes, doctype.length) - return innerWriter.write(out) + const merged = new Uint8Array(doctype.length + bytes.length) + merged.set(doctype, 0) + merged.set(bytes, doctype.length) + out = merged } } - return innerWriter.write(chunk) + const written = innerWriter.write(out) + tsrSink.scanForBarrier(chunk) + return written }, close() { - writerDone = true - return innerWriter.close().finally(releaseWriter) + tsrSink.onSolidDone() }, abort(reason) { writerDone = true + tsrSink.dispose() return innerWriter.abort(reason).finally(releaseWriter) }, }) @@ -174,14 +352,9 @@ export const renderRouterToStream = async ({ } } - const responseStream = transformReadableStreamWithRouter( - router, - readable as unknown as ReadableStream, - { signal: request.signal, onAbort: abortSolidPipe }, - ) return createSsrStreamResponse( router, - new Response(responseStream as any, { + new Response(readable as any, { status: router._serverResult?.type === 'render' ? router._serverResult.status diff --git a/packages/solid-router/src/ssr/routerPayload.ts b/packages/solid-router/src/ssr/routerPayload.ts new file mode 100644 index 0000000000..8a0203ea9a --- /dev/null +++ b/packages/solid-router/src/ssr/routerPayload.ts @@ -0,0 +1,66 @@ +import { defaultSerovalPlugins, makeSerovalPlugin } from '@tanstack/router-core' +import type { AnySerializationAdapter } from '@tanstack/router-core' +import type { SerializerPlugin } from '@solidjs/web/serialization/decode' + +// --- Router payload channel (Solid-owned SSR transfer) ---------------------- +// +// The Solid adapter transports the DehydratedRouter through Solid's eval-free +// JSON codec instead of the `$_TSR` script channel: the server serializes the +// VALUE (built by the Solid-side `serverSsr.dehydrate` override, see +// routerPayloadServer) with `createJSONSerializer`, pushing inert SerovalNode +// records into a global queue via inline scripts; the client drains the queue +// through `createJSONDataTable` with the same plugin list at hydrateStart time +// — runtime decode, no parse-time eval. Streamed values (deferred loaderData +// promises) settle through later records, which is the codec's native +// contract. +// +// Everything rides Solid-side overrides of the (documented framework-only) +// `router.serverSsr` members, installed at `onServerSsrAttach` time — core's +// script-channel dehydrate never runs and no non-Solid package changes. A +// follow-up core-hooks PR against `main` will let these overrides collapse +// into supported hooks. + +/** Key of the DehydratedRouter in the keyed record space. */ +export const ROUTER_PAYLOAD_KEY = 'router' + +/** Global queue the server's record scripts push into. */ +export const ROUTER_PAYLOAD_GLOBAL = '__TSR_P' + +/** Record shape produced by `createJSONSerializer` / consumed by the table. */ +export interface RouterPayloadRecord { + key?: string + node?: unknown + initial?: boolean +} + +// The tag ReadableStreamPlugin registers under. Solid's JSON codec composes +// DEFAULT_WEB_PLUGINS (which includes it) under any custom list, so the +// router's copy must be dropped or the tag would be registered twice. +const READABLE_STREAM_PLUGIN_TAG = 'seroval/plugins/web/ReadableStream' + +/** + * The plugin list for the router payload codec. Must be identical on both + * peers: the router's serialization adapters (runtime encode/decode via + * `makeSerovalPlugin`) plus the router's default plugins, minus the + * ReadableStream plugin Solid's codec already registers. + * + * Both peers read the same merged adapter list (start instance + plugin + + * server function + router adapters): the server from + * `router.options.serializationAdapters` (createStartHandler's + * router.update), the client from `router.options.serializationAdapters` + * (RouterClient) or `window.__TSS_START_OPTIONS__.serializationAdapters` + * (hydrateStart, which is the same array instance router.update installs). + */ +export function getRouterPayloadPlugins( + adapters: Array | undefined, +): Array { + // Seroval's own Plugin type and Solid's hand-declared SerializerPlugin + // mirror describe the same runtime shape; the nominal generics don't + // overlap structurally, hence the double cast. + return [ + ...(adapters?.map(makeSerovalPlugin) ?? []), + ...defaultSerovalPlugins.filter( + (plugin) => plugin.tag !== READABLE_STREAM_PLUGIN_TAG, + ), + ] as unknown as Array +} diff --git a/packages/solid-router/src/ssr/routerPayloadClient.ts b/packages/solid-router/src/ssr/routerPayloadClient.ts new file mode 100644 index 0000000000..a4b0286cc6 --- /dev/null +++ b/packages/solid-router/src/ssr/routerPayloadClient.ts @@ -0,0 +1,147 @@ +import { + ROUTER_PAYLOAD_GLOBAL, + ROUTER_PAYLOAD_KEY, + getRouterPayloadPlugins, +} from './routerPayload' +import type { RouterPayloadRecord } from './routerPayload' +import type { AnyRouter, AnySerializationAdapter } from '@tanstack/router-core' +import type { + DehydratedRouter, + TsrSsrGlobal, +} from '@tanstack/router-core/ssr/client' + +type DecodeModule = typeof import('@solidjs/web/serialization/decode') + +// The decode module is loaded dynamically, NOT statically: Solid's own web +// runtime lazy-loads it (`import('@solidjs/web/serialization/decode')` when a +// hydration payload exists), so a static import here would merge the decode +// module into whatever chunk imports this file — for apps on solid-start's +// default client entry, the entry chunk itself. The entry chunk then shows up +// as a dynamic-import target (of Solid's lazy decode load), and the Solid +// vite plugin's lazy-entry normalization strips its `isEntry` flag, breaking +// Start's manifest capture ("No entry file found"). Loading through the same +// dynamic specifier shares Solid's decode chunk instead. +let decodeModule: DecodeModule | undefined +let decodeModulePromise: Promise | undefined + +function loadDecodeModule(): Promise { + return (decodeModulePromise ??= import( + '@solidjs/web/serialization/decode' + ).then((mod) => { + decodeModule = mod + })) +} + +/** + * Reads the Solid-transferred DehydratedRouter from the record queue the + * server's inline scripts pushed into (`self.__TSR_P`). + * + * Called lazily — after the router's serialization adapters are finalized + * (router creation for RouterClient, hydrateStart's `router.update` for + * Start), so the decode plugin list matches what the server encoded with. + * Records that arrive after this runs (streamed loaderData resolutions on a + * still-open response) feed the same decode table through the hooked `push`, + * settling the promises the initial record referenced. + * + * Requires the decode module to be loaded — `installRouterPayloadShim`'s + * promise (which callers await before core hydrate) resolves after the load. + * + * Returns `undefined` when no queue exists (no SSR payload). + */ +export function readRouterPayloadFromAdapters( + adapters: Array | undefined, +): DehydratedRouter | undefined { + const queue = (globalThis as any)[ROUTER_PAYLOAD_GLOBAL] as + | Array + | undefined + if (!queue) return undefined + + if (!decodeModule) { + throw new Error( + 'Router payload decode module not loaded — await the promise returned ' + + 'by installRouterPayloadShim before hydrating', + ) + } + const table = decodeModule.createJSONDataTable({ + plugins: getRouterPayloadPlugins(adapters), + }) + for (const record of queue) table.apply(record) + // Late records decode on arrival instead of queueing. + queue.push = (record: RouterPayloadRecord) => { + table.apply(record) + return 0 + } + + return table.resolve({ + $ref: ROUTER_PAYLOAD_KEY, + }) +} + +export function readRouterPayload( + router: AnyRouter, +): DehydratedRouter | undefined { + return readRouterPayloadFromAdapters( + router.options.serializationAdapters as + | Array + | undefined, + ) +} + +/** + * Installs a synthetic `window.$_TSR` so the unchanged core `hydrate()` reads + * the Solid-decoded payload instead of the script channel. Core hydrate's + * bootstrap contract: it sets `tsr.t` (adapter map), replays `tsr.buffer`, + * sets `tsr.initialized`, then reads `tsr.router` — the lazy getter decodes + * the `__TSR_P` record queue at that final read, by which point the caller's + * adapter source (see `readPayload`) is fully populated. Everything is + * constructed from decoded JSON records; no script-channel eval. + * + * `h()` (the hydration-complete signal the caller fires after core hydrate) + * deletes the synthetic global, mirroring the observable end state of core's + * bootstrap (which deletes `$_TSR` once hydrated + stream ended). The + * synthetic has no post-hydration role — late records ride the `__TSR_P` + * queue's hooked `push`, never `$_TSR.p` — so hydration completion alone is + * the deletion point. `typeof window.$_TSR === 'undefined'` therefore stays + * a valid "hydration finished" probe on both channels. + * + * Returns a promise that resolves once the payload decoder is ready (loaded + * lazily to share Solid's own decode chunk — see `loadDecodeModule`). Await + * it before running core hydrate. + * + * No-op when `window.$_TSR` already exists: a document rendered by a + * script-channel server (e.g. an older deploy) defined the real bootstrap in + * its shell, and core hydrate should read that instead. + */ +export function installRouterPayloadShim( + readPayload: () => DehydratedRouter | undefined, +): Promise { + if (window.$_TSR) return Promise.resolve() + + let decoded: DehydratedRouter | undefined + let decodeRan = false + const noop = () => {} + const synthetic: TsrSsrGlobal = { + get router() { + if (!decodeRan) { + decodeRan = true + decoded = readPayload() + } + return decoded + }, + buffer: [], + initialized: false, + h: () => { + if (window.$_TSR === synthetic) { + delete (window as { $_TSR?: TsrSsrGlobal }).$_TSR + } + }, + e: noop, + c: noop, + p: (script: () => void) => script(), + } + window.$_TSR = synthetic + + // Only payload-carrying documents need the decoder. + if (!(globalThis as any)[ROUTER_PAYLOAD_GLOBAL]) return Promise.resolve() + return loadDecodeModule() +} diff --git a/packages/solid-router/src/ssr/routerPayloadServer.ts b/packages/solid-router/src/ssr/routerPayloadServer.ts new file mode 100644 index 0000000000..8621b6a114 --- /dev/null +++ b/packages/solid-router/src/ssr/routerPayloadServer.ts @@ -0,0 +1,419 @@ +import { createJSONSerializer } from '@solidjs/web/serialization' +import { _getRenderedMatches } from '@tanstack/router-core' +import { + ROUTER_PAYLOAD_GLOBAL, + ROUTER_PAYLOAD_KEY, + getRouterPayloadPlugins, +} from './routerPayload' +import type { RouterPayloadRecord } from './routerPayload' +import type { + AnyRouteMatch, + AnyRouter, + AnySerializationAdapter, + Manifest, +} from '@tanstack/router-core' +import type { + DehydratedMatch, + DehydratedRouter, +} from '@tanstack/router-core/ssr/client' + +type ServerSsr = NonNullable + +// --------------------------------------------------------------------------- +// Mirrors of router-core internals (pending the core-hooks PR against main). +// +// The Solid-side `dehydrate` override must produce the same DehydratedRouter +// core's dehydrate would, and the Solid-side script buffer must present the +// same shell-inline / barrier-deferral behavior core's ScriptBuffer does. +// The pieces below are NOT exported from @tanstack/router-core, so they are +// duplicated here 1:1. Each notes its source. A follow-up core PR will export +// proper hooks and delete these mirrors. +// --------------------------------------------------------------------------- + +// Mirror of TSR_SCRIPT_BARRIER_ID (router-core/src/ssr/constants.ts): the id +// of the inline script tag renders when it drains the buffered +// payload into the shell. renderRouterToStream scans outgoing chunks for it +// to know when post-shell scripts may flow. +const TSR_SCRIPT_BARRIER_ID = '$tsr-stream-barrier' + +// Mirror of dehydrateSsrMatchId (router-core/src/ssr/ssr-match-id.ts). +function dehydrateSsrMatchId(id: string): string { + return id + .replaceAll('~', '~~') + .replaceAll('\0', '~0') + .replaceAll('\uFFFD', '~r') + .replaceAll('/', '\0') +} + +// Mirror of dehydrateMatch (router-core/src/ssr/ssr-server.ts). +function dehydrateMatch(match: AnyRouteMatch): DehydratedMatch { + const dehydratedMatch: DehydratedMatch = { + i: dehydrateSsrMatchId(match.id), + u: match.updatedAt, + s: match.status, + } + + const properties = [ + ['__beforeLoadContext', 'b'], + ['loaderData', 'l'], + ['error', 'e'], + ['ssr', 'ssr'], + ] as const + + for (const [key, shorthand] of properties) { + // `__beforeLoadContext` is internal to router-core and absent from the + // public AnyRouteMatch type; the runtime shape carries it. + const value = (match as unknown as Record)[key] + if (value !== undefined) { + dehydratedMatch[shorthand] = value + } + } + if (match._notFound) { + dehydratedMatch.g = true + } + return dehydratedMatch +} + +// Mirror of createInlineCssPlaceholderAsset (router-core/src/manifest.ts): +// the dehydrated manifest ships a contentless inline-style asset; the client +// adopts the server-rendered