From e787a71b08b3f40c69db0ab87bb0c9b02c6b8bc0 Mon Sep 17 00:00:00 2001 From: Fnine59 <36078040+Fnine59@users.noreply.github.com> Date: Tue, 18 Aug 2026 19:24:24 +0000 Subject: [PATCH] fix(solid-router): defer adapter hydration payloads --- .changeset/solid-adapters-wait.md | 6 + packages/router-core/src/ssr/tsrScript.ts | 2 +- .../tests/tsr-script-teardown.test.ts | 12 +- .../src/ssr/renderRouterToStream.tsx | 26 ++- .../tests/renderRouterToStream.test.tsx | 166 +++++++++++++++++- 5 files changed, 208 insertions(+), 4 deletions(-) create mode 100644 .changeset/solid-adapters-wait.md diff --git a/.changeset/solid-adapters-wait.md b/.changeset/solid-adapters-wait.md new file mode 100644 index 00000000000..55dda854b5c --- /dev/null +++ b/.changeset/solid-adapters-wait.md @@ -0,0 +1,6 @@ +--- +'@tanstack/router-core': patch +'@tanstack/solid-router': patch +--- + +Defer Solid hydration payloads that use custom serialization adapters until the router installs its adapter map. diff --git a/packages/router-core/src/ssr/tsrScript.ts b/packages/router-core/src/ssr/tsrScript.ts index 69c9abe05d5..b10a9e78291 100644 --- a/packages/router-core/src/ssr/tsrScript.ts +++ b/packages/router-core/src/ssr/tsrScript.ts @@ -16,5 +16,5 @@ self.$_TSR = { p(script) { !this.initialized ? this.buffer.push(script) : script() }, - buffer: [], + buffer: self.$_TSR?.buffer || [], } diff --git a/packages/router-core/tests/tsr-script-teardown.test.ts b/packages/router-core/tests/tsr-script-teardown.test.ts index 2355e1c99a0..cb91fdeedd2 100644 --- a/packages/router-core/tests/tsr-script-teardown.test.ts +++ b/packages/router-core/tests/tsr-script-teardown.test.ts @@ -1,10 +1,11 @@ -import { afterEach, beforeEach, describe, expect, test } from 'vitest' +import { afterEach, beforeEach, describe, expect, test, vi } from 'vitest' import minifiedTsrBootStrapScript from '../src/ssr/tsrScript?script-string' type TsrBootstrap = { h: () => void e: () => void c: () => void + buffer: Array<() => void> } // Assign `self.$_TSR`. @@ -43,4 +44,13 @@ describe('$_TSR client teardown', () => { expect((window as any).$_TSR).toBeUndefined() expect((window as any).$R.tsr).toBeUndefined() }) + + test('preserves adapter payloads queued before the bootstrap script', () => { + const pending = vi.fn() + ;(window as any).$_TSR = { buffer: [pending] } + + const tsr = installBootstrap() + + expect(tsr.buffer).toEqual([pending]) + }) }) diff --git a/packages/solid-router/src/ssr/renderRouterToStream.tsx b/packages/solid-router/src/ssr/renderRouterToStream.tsx index 5bc303e2e66..05255e1c4be 100644 --- a/packages/solid-router/src/ssr/renderRouterToStream.tsx +++ b/packages/solid-router/src/ssr/renderRouterToStream.tsx @@ -1,4 +1,5 @@ import * as Solid from '@solidjs/web' +import { createHydrationSerializer } from '@solidjs/web/serialization' import { isbot } from 'isbot' import { createSsrStreamResponse, @@ -9,9 +10,14 @@ import clientAssetsManifest from './clientAssetsManifest' import type { ReadableStream } from 'node:stream/web' import type { AnyRouter } from '@tanstack/router-core' import type { JSX } from '@solidjs/web' +import type { HydrationSerializerOptions } from '@solidjs/web/serialization' const noop = () => {} +function deferHydrationPayload(payload: string) { + return `(self.$_TSR=self.$_TSR||{buffer:[]},self.$_TSR.p?self.$_TSR.p(()=>{${payload}}):self.$_TSR.buffer.push(()=>{${payload}}))` +} + // 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( @@ -52,14 +58,32 @@ export const renderRouterToStream = async ({ const serializationAdapters = (router.options as any)?.serializationAdapters || (router.options.ssr as any)?.serializationAdapters + const trackPlugins = { didRun: false } const serovalPlugins = serializationAdapters?.map((adapter: any) => { - const plugin = makeSsrSerovalPlugin(adapter, { didRun: false }) + const plugin = makeSsrSerovalPlugin(adapter, trackPlugins) return plugin }) const stream = Solid.renderToStream(() => children, { nonce: router.options.ssr?.nonce, plugins: serovalPlugins, + // Once an adapter runs, preserve serializer reference order by deferring + // that payload and every payload after it behind the router's barrier. + ...(serovalPlugins + ? { + serializer: (options: HydrationSerializerOptions) => + createHydrationSerializer({ + ...options, + onData: (payload) => { + options.onData( + trackPlugins.didRun + ? deferHydrationPayload(payload) + : payload, + ) + }, + }), + } + : {}), // Prefer the bundler-provided client-assets bridge (module-keyed, the // shape Solid resolves lazy() assets from); the router's route-keyed // manifest is a last resort that cannot answer lazy module lookups. diff --git a/packages/solid-router/tests/renderRouterToStream.test.tsx b/packages/solid-router/tests/renderRouterToStream.test.tsx index 6351d83ebd0..84e21a223ee 100644 --- a/packages/solid-router/tests/renderRouterToStream.test.tsx +++ b/packages/solid-router/tests/renderRouterToStream.test.tsx @@ -1,6 +1,15 @@ import { afterEach, describe, expect, test, vi } from 'vitest' +import { + createHydrationSerializer, + getLocalHeaderScript, +} from '@solidjs/web/serialization' import { attachRouterServerSsrUtils } from '@tanstack/router-core/ssr/server' -import { createMemoryHistory, createRootRoute, createRouter } from '../src' +import { + createMemoryHistory, + createRootRoute, + createRouter, + createSerializationAdapter, +} from '../src' import type * as SolidWeb from 'solid-js/web' const solidMocks = vi.hoisted(() => ({ @@ -29,6 +38,9 @@ afterEach(() => { solidMocks.renderToStream.mockReset() solidMocks.pipeTo.mockReset() vi.restoreAllMocks() + delete (window as any).$_TSR + delete (window as any)._$HY + delete (window as any).$R }) async function buildRouter() { @@ -55,6 +67,158 @@ function drainBody(response: Response) { })().catch(() => true) } +class Money { + constructor(public readonly cents: number) {} + + format() { + return `$${(this.cents / 100).toFixed(2)}` + } +} + +const moneyAdapter = createSerializationAdapter({ + key: 'Money', + test: (value): value is Money => value instanceof Money, + toSerializable: (value) => value.cents, + fromSerializable: (cents: number) => new Money(cents), +}) + +function installPendingTsrBootstrap() { + return ((window as any).$_TSR = { + initialized: false, + buffer: [] as Array<() => void>, + p(script: () => void) { + !this.initialized ? this.buffer.push(script) : script() + }, + }) +} + +function installMoneyTransformer(tsr: { + buffer: Array<() => void> + t?: Map Money> +}) { + tsr.t = new Map([['Money', (cents: number) => new Money(cents)]]) + tsr.buffer.splice(0).forEach((script) => script()) +} + +async function getMoneyHydrationPayload() { + solidMocks.pipeTo.mockImplementationOnce( + async (writable: WritableStream) => { + const writer = writable.getWriter() + await writer.write( + new TextEncoder().encode('solid'), + ) + await writer.close() + }, + ) + solidMocks.renderToStream.mockImplementationOnce( + () => ({ pipeTo: solidMocks.pipeTo }) as any, + ) + + const router = await buildRouter() + router.options.serializationAdapters = [moneyAdapter] + const abortController = new AbortController() + + const response = unwrapResponse( + await renderRouterToStream({ + request: new Request('http://localhost/', { + signal: abortController.signal, + }), + router, + responseHeaders: new Headers(), + children: () => null, + }), + ) + + const options = solidMocks.renderToStream.mock.calls[0]![1] as { + plugins: Array + serializer?: typeof createHydrationSerializer + } + const payloads: Array = [] + const serializer = (options.serializer ?? createHydrationSerializer)({ + plugins: options.plugins, + scopeId: '', + onData: (payload) => payloads.push(payload), + }) + serializer.write('money', new Money(1234)) + serializer.flush() + + abortController.abort() + await drainBody(response) + router.serverSsr?.cleanup() + + return payloads.join(';') +} + +function installSolidSerializationHeader() { + new Function(getLocalHeaderScript(''))() +} + +describe('renderRouterToStream - serialization adapters', () => { + test('defers adapter payloads until hydration installs the transformer map', async () => { + const payload = await getMoneyHydrationPayload() + ;(window as any)._$HY = { r: {} } + installSolidSerializationHeader() + + expect(() => new Function(payload)()).not.toThrow() + + const earlyTsr = (window as any).$_TSR + expect(earlyTsr.buffer).toHaveLength(1) + expect((window as any)._$HY.r.money).toBeUndefined() + + installMoneyTransformer(earlyTsr) + expect((window as any)._$HY.r.money).toBeInstanceOf(Money) + expect((window as any)._$HY.r.money.format()).toBe('$12.34') + ;(window as any)._$HY = { r: {} } + installSolidSerializationHeader() + const pendingTsr = installPendingTsrBootstrap() + + expect(() => new Function(payload)()).not.toThrow() + expect(pendingTsr.buffer).toHaveLength(1) + expect((window as any)._$HY.r.money).toBeUndefined() + + installMoneyTransformer(pendingTsr) + expect((window as any)._$HY.r.money).toBeInstanceOf(Money) + expect((window as any)._$HY.r.money.format()).toBe('$12.34') + ;(window as any)._$HY = { r: {} } + installSolidSerializationHeader() + const initializedTsr = installPendingTsrBootstrap() + installMoneyTransformer(initializedTsr) + initializedTsr.initialized = true + + expect(() => new Function(payload)()).not.toThrow() + expect(initializedTsr.buffer).toHaveLength(0) + expect((window as any)._$HY.r.money).toBeInstanceOf(Money) + expect((window as any)._$HY.r.money.format()).toBe('$12.34') + }) + + test('uses Solid default serialization when adapters are not configured', async () => { + const abortController = new AbortController() + solidMocks.renderToStream.mockImplementationOnce( + () => ({ pipeTo: () => Promise.resolve() }) as any, + ) + const router = await buildRouter() + + const response = unwrapResponse( + await renderRouterToStream({ + request: new Request('http://localhost/', { + signal: abortController.signal, + }), + router, + responseHeaders: new Headers(), + children: () => null, + }), + ) + + const options = solidMocks.renderToStream.mock.calls[0]![1] + expect(options).not.toHaveProperty('serializer') + expect(options.plugins).toBeUndefined() + + abortController.abort() + await drainBody(response) + router.serverSsr?.cleanup() + }) +}) + describe('renderRouterToStream - bot abort', () => { test('request abort during bot wait terminates before rendering starts', async () => { const neverReady = new Promise(() => {})