From f4044cb50e65347f4cd49880766354f425589f8a Mon Sep 17 00:00:00 2001 From: Ryan Carniato Date: Wed, 2 Sep 2026 19:03:09 -0700 Subject: [PATCH 1/3] feat(solid-router): Solid-owned SSR transport + JSON payload channel (Phase 2a+2b, zero core diff) MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Phase 2a — the Solid path drops transformStreamWithRouter: router scripts ride the response writer directly in renderRouterToStream (Solid-owned sink, script-barrier scanning until the chunk is written, doctype handling, 60s serialization timeout, abort/cleanup semantics). Phase 2b — the DehydratedRouter rides Solid's eval-free JSON codec (__TSR_P record queue) instead of the $_TSR script channel, implemented entirely as Solid-side overrides of the framework-only serverSsr members (installed via the onServerSsrAttach lifecycle): - serverSsr.dehydrate is replaced with a Solid implementation mirroring core's (rendered matches, isShell slicing, options.dehydrate() data, manifest derived from the public router.ssr.manifest getter) that serializes through createJSONSerializer into a Solid-owned script buffer; takeBufferedScripts/liftScriptBarrier serve it with core's shell-inline tag shape, so and the 2a sink are unchanged. - isDehydrated/isSerializationFinished/onSerializationFinished answer from Solid-side state; setRenderFinished/cleanup wrap the originals. - Core's seeded $R scope header and $_TSR bootstrap are drained and discarded at attach — the SSR HTML carries no executable payload scripts and no parse-time eval. - The client installs a synthetic window.$_TSR whose lazy router getter decodes the record queue (createJSONDataTable, push-hooked for streamed late records), so core hydrate() is byte-for-byte unchanged. The shim defers to a real $_TSR bootstrap (older-server documents). - The installer is resolved through solidSsrTransferSlot, filled by the ssr/server entry (sideEffects allowlist keeps the fill alive): the encode half of Solid's codec must never enter the client module graph. Zero diff to router-core, start-client-core, react-router, vue-router, and their start packages. A follow-up core-hooks PR against main will collapse the overrides into supported seams; the unexported core pieces mirrored here (dehydrateMatch, ScriptBuffer, barrier id, inline-css placeholder) are marked as such in routerPayloadServer. Co-authored-by: Cursor --- .changeset/solid-ssr-transport-payload.md | 6 + packages/solid-router/package.json | 5 +- .../repro-external-ssr/.gitignore | 1 + packages/solid-router/src/router.ts | 20 + .../solid-router/src/ssr/RouterClient.tsx | 17 +- packages/solid-router/src/ssr/client.ts | 5 + .../src/ssr/renderRouterToStream.tsx | 213 ++++++++- .../solid-router/src/ssr/routerPayload.ts | 66 +++ .../src/ssr/routerPayloadClient.ts | 147 ++++++ .../src/ssr/routerPayloadServer.ts | 419 ++++++++++++++++++ packages/solid-router/src/ssr/server.ts | 9 + .../src/ssr/solidSsrTransferSlot.ts | 21 + .../solid-start-client/src/hydrateStart.ts | 43 +- 13 files changed, 947 insertions(+), 25 deletions(-) create mode 100644 .changeset/solid-ssr-transport-payload.md create mode 100644 packages/solid-router/repro-external-ssr/.gitignore create mode 100644 packages/solid-router/src/ssr/routerPayload.ts create mode 100644 packages/solid-router/src/ssr/routerPayloadClient.ts create mode 100644 packages/solid-router/src/ssr/routerPayloadServer.ts create mode 100644 packages/solid-router/src/ssr/solidSsrTransferSlot.ts diff --git a/.changeset/solid-ssr-transport-payload.md b/.changeset/solid-ssr-transport-payload.md new file mode 100644 index 00000000000..08bcd5fb4a8 --- /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/packages/solid-router/package.json b/packages/solid-router/package.json index 2d803972a0d..7a9768b10b7 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 00000000000..89f9ac04aac --- /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 6ae1d85739a..5587d19768f 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 69abc97f0ba..b41c753c5eb 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 00000000000..8a0203ea9ac --- /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 00000000000..a4b0286cc6c --- /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 00000000000..8621b6a114b --- /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