Skip to content
Open
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
6 changes: 6 additions & 0 deletions .changeset/solid-adapters-wait.md
Original file line number Diff line number Diff line change
@@ -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.
2 changes: 1 addition & 1 deletion packages/router-core/src/ssr/tsrScript.ts
Original file line number Diff line number Diff line change
Expand Up @@ -16,5 +16,5 @@ self.$_TSR = {
p(script) {
!this.initialized ? this.buffer.push(script) : script()
},
buffer: [],
buffer: self.$_TSR?.buffer || [],
}
12 changes: 11 additions & 1 deletion packages/router-core/tests/tsr-script-teardown.test.ts
Original file line number Diff line number Diff line change
@@ -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`.
Expand Down Expand Up @@ -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])
})
})
26 changes: 25 additions & 1 deletion packages/solid-router/src/ssr/renderRouterToStream.tsx
Original file line number Diff line number Diff line change
@@ -1,4 +1,5 @@
import * as Solid from '@solidjs/web'
import { createHydrationSerializer } from '@solidjs/web/serialization'
import { isbot } from 'isbot'
import {
createSsrStreamResponse,
Expand All @@ -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(
Expand Down Expand Up @@ -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.
Expand Down
166 changes: 165 additions & 1 deletion packages/solid-router/tests/renderRouterToStream.test.tsx
Original file line number Diff line number Diff line change
@@ -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(() => ({
Expand Down Expand Up @@ -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() {
Expand All @@ -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<string, (value: number) => 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<Uint8Array>) => {
const writer = writable.getWriter()
await writer.write(
new TextEncoder().encode('<html><body>solid</body></html>'),
)
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<any>
serializer?: typeof createHydrationSerializer
}
const payloads: Array<string> = []
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<void>(() => {})
Expand Down