From 5ecdf0c1c1ed56e505bc5ef1b0588cb2bbb160da Mon Sep 17 00:00:00 2001 From: Petr Glaser Date: Thu, 29 Oct 2026 00:58:59 +0100 Subject: [PATCH] fix: inline boundaries that complete before the stream is read Write streamed SSR output when the destination starts reading, as React does, instead of enqueueing the shell as soon as it renders. Pending Suspense boundaries stay addressable in the buffered output: those that complete or fail before their bytes are written are inlined as `$` or `$E`/`$!` boundaries with their resources hoisted to the shell, and only boundaries still pending at the write stream a hidden segment and reveal script. A reader that waits for `allReady` therefore receives a complete document that renders without JavaScript. Co-Authored-By: Claude Opus 5.5 --- .changeset/ssr-inline-completed-boundaries.md | 5 + packages/redact/src/server/stream.ts | 144 +++++++---- packages/redact/src/server/walk.ts | 62 +++-- scripts/size-check.mjs | 8 +- .../ssr-inline-completed-boundaries.test.tsx | 224 ++++++++++++++++++ 5 files changed, 368 insertions(+), 75 deletions(-) create mode 100644 .changeset/ssr-inline-completed-boundaries.md create mode 100644 tests/ssr-inline-completed-boundaries.test.tsx diff --git a/.changeset/ssr-inline-completed-boundaries.md b/.changeset/ssr-inline-completed-boundaries.md new file mode 100644 index 0000000..9d5f595 --- /dev/null +++ b/.changeset/ssr-inline-completed-boundaries.md @@ -0,0 +1,5 @@ +--- +'@tanstack/redact': patch +--- + +Write streamed SSR output when the destination starts reading, as React does. Suspense boundaries that complete before then are inlined, so a reader that waits for `allReady` gets a complete document that renders without JavaScript. Only boundaries still pending at that point stream a hidden segment and reveal script. diff --git a/packages/redact/src/server/stream.ts b/packages/redact/src/server/stream.ts index 694a489..87b51a6 100644 --- a/packages/redact/src/server/stream.ts +++ b/packages/redact/src/server/stream.ts @@ -7,7 +7,7 @@ import { currentSSRFrame, type SSRFrame, } from './dispatcher' -import { walk, type SuspendedBoundary, type BrowserBailoutCallback } from './walk' +import { walk, boundaryEdges, type HtmlPart, type SuspendedBoundary, type BrowserBailoutCallback } from './walk' import { browserError, isBrowserToken, isBrowserError } from '../core/browser' import { BOUNDARY_REVEAL_RUNTIME, revealScript } from './bootstrap-script' import { escapeAttr, escapeScript } from './escape' @@ -47,6 +47,11 @@ export interface OrchestratorState { cancelled: Promise onShellReady?: () => void frame?: SSRFrame + // Output is written once the destination starts reading. Until then, + // completed boundaries stay inline, as React does before its first flush. + flowing?: boolean + flush?: () => void + outlined?: boolean } function createState(): OrchestratorState { @@ -85,9 +90,9 @@ export async function streamHtml( // is ~100 stream-controller round-trips. Batching collapses those into // one encode and one enqueue per shell — measured ~2-4% of total SSR // time on CPU profiles. - const shellChunks: string[] = [] + const shellChunks: HtmlPart[] = [] let head = 0 - const bufferedEmit: Emit = (chunk) => { + const bufferedEmit = (chunk: HtmlPart) => { shellChunks.push(chunk) } @@ -137,34 +142,42 @@ export async function streamHtml( if (suspended) await Promise.race([suspended, state.cancelled]) } - const hints = state.frame?.resources?.drain() - if (hints) shellChunks.splice(head, 0, hints) - if (state.frame) state.frame.shellFlushed = true - - // 2. Inject runtime + bootstrap scripts (once, after shell). Skip the - // reveal/event-replay runtime when nothing needs it — no suspensions to - // reveal and no bootstrap scripts to guard against early user input. That - // keeps fully-static SSR responses byte-equivalent to a plain walk and - // matches React's behavior where `renderToReadableStream` of a static - // tree emits only the markup. - const hasBootstrap = - bootstrapScriptContentToArray(options.bootstrapScriptContent).length > 0 || - (options.bootstrapScripts?.length ?? 0) > 0 || - (options.bootstrapModules?.length ?? 0) > 0 - if (boundaries.length > 0 || hasBootstrap) { - shellChunks.push(`${BOUNDARY_REVEAL_RUNTIME}`) - for (const content of bootstrapScriptContentToArray(options.bootstrapScriptContent)) { - shellChunks.push(inlineBootstrapTag(content, nonce)) - } - for (const s of options.bootstrapScripts ?? []) { - shellChunks.push(bootstrapTag(s, 'script', nonce)) - } - for (const m of options.bootstrapModules ?? []) { - shellChunks.push(bootstrapTag(m, 'module', nonce)) + // 2. Write the shell when the destination starts reading. Boundaries that + // completed by then are inlined, and their resources join the shell's. + let written = false + state.flush = () => { + if (written) return + written = true + const hints = state.frame?.resources?.drain() + if (hints) shellChunks.splice(head, 0, hints) + if (state.frame) state.frame.shellFlushed = true + let html = partsHtml(shellChunks, state) + + // Inject runtime + bootstrap scripts (once, after shell). Skip the + // reveal/event-replay runtime when nothing needs it — no pending + // boundaries to reveal and no bootstrap scripts to guard against early + // user input. That keeps fully-static SSR responses byte-equivalent to + // a plain walk and matches React's behavior where + // `renderToReadableStream` of a static tree emits only the markup. + const hasBootstrap = + bootstrapScriptContentToArray(options.bootstrapScriptContent).length > 0 || + (options.bootstrapScripts?.length ?? 0) > 0 || + (options.bootstrapModules?.length ?? 0) > 0 + if (state.outlined || hasBootstrap) { + html += `${BOUNDARY_REVEAL_RUNTIME}` + for (const content of bootstrapScriptContentToArray(options.bootstrapScriptContent)) { + html += inlineBootstrapTag(content, nonce) + } + for (const s of options.bootstrapScripts ?? []) { + html += bootstrapTag(s, 'script', nonce) + } + for (const m of options.bootstrapModules ?? []) { + html += bootstrapTag(m, 'module', nonce) + } } + if (html) emit(normalizeDocumentShell(html)) } - - if (shellChunks.length) emit(normalizeDocumentShell(shellChunks.join(''))) + if (state.flowing) state.flush() state.onShellReady?.() // 3. Stream suspended boundaries as they resolve @@ -199,18 +212,22 @@ function streamBoundary( const defer = (error: unknown) => { if (isBrowserError(error)) options.onBrowserBailout?.(error, { componentStack: (error as any).componentStack || b.componentStack }) else options.onError?.(error) - emit(`$RB(${b.id}${isBrowserError(error) ? '' : ',1'})`) + if (b.written) emit(`$RB(${b.id}${isBrowserError(error) ? '' : ',1'})`) + else b.marker = isBrowserError(error) ? '$!' : '$E' } if (state.aborted) { defer(state.reason); return } // Re-render the boundary's children into a string, restoring the // provider stack from when the boundary first suspended. - const parts: string[] = [] + // A boundary not yet written keeps its content for an inline write; its + // resources stay with the unwritten shell or segment that will contain it. + const written = b.written + const parts: HtmlPart[] = [] const sub: SuspendedBoundary[] = [] const resourceScope: ResourceScope = {} let styles: string[][] | undefined const previous = beginSSR(options.identifierPrefix, state.frame) - state.frame?.resources?.takeStylesheets() + if (written) state.frame?.resources?.takeStylesheets() const restore = applyContextSnapshot(b.contextSnapshot) try { walk(b.children, { @@ -225,18 +242,22 @@ function streamBoundary( state.frame?.resources?.mergeScope(resourceScope) } catch (err) { if (isThenable(err)) { - streamBoundary({ ...b, thenable: err }, emit, options, state) + b.thenable = err + streamBoundary(b, emit, options, state) } else defer(err) return } finally { restore() endSSR(previous) - const hints = state.frame?.resources?.drain() - styles = state.frame?.resources?.takeStylesheets() - if (hints) emit(hints) + if (written) { + const hints = state.frame?.resources?.drain() + styles = state.frame?.resources?.takeStylesheets() + if (hints) emit(hints) + } } - emit(`${revealScript(b.id, nonce, styles)}`) + if (written) emit(`${revealScript(b.id, nonce, styles)}`) + else b.content = parts // Recurse: any nested suspensions inside the now-revealed content for (const s of sub) streamBoundary(s, emit, options, state) @@ -245,6 +266,24 @@ function streamBoundary( task.then(() => state.pending.delete(task), () => state.pending.delete(task)) } +// Serialize buffered output. Boundaries that completed or failed before this +// write are inlined; pending ones keep their fallback and are marked written, +// so their later completion streams a hidden segment and reveal script. +function partsHtml(parts: HtmlPart[], state: OrchestratorState): string { + let html = '' + for (const part of parts) { + if (typeof part === 'string') { + html += part + continue + } + const marker = part.content ? '$' : part.marker ?? '$?' + if (marker === '$?') part.written = state.outlined = true + const [open, close] = boundaryEdges(part.id, marker) + html += open + partsHtml(part.content ?? part.fallback, state) + close + } + return html +} + async function drain(state: OrchestratorState): Promise { while (state.pending.size > 0) { await Promise.race(state.pending) @@ -350,6 +389,11 @@ export function renderToReadableStream( }) state.onShellReady = () => queueMicrotask(() => shellResolve(Object.assign(stream, { allReady }))) + const close = (controller: ReadableStreamDefaultController) => { + try { + controller.close() + } catch {} + } const stream = new ReadableStream({ start(controller) { const emit = (chunk: string) => { @@ -370,9 +414,7 @@ export function renderToReadableStream( streamHtml(children, emit, options, state).then( () => { detachAbortListener() - try { - controller.close() - } catch {} + if (state.flowing) close(controller) allReadyResolve() }, (err) => { @@ -386,11 +428,16 @@ export function renderToReadableStream( ) }, + pull(controller) { + state.flowing = true + state.flush?.() + if (state.closed && !state.errored) close(controller) + }, cancel(reason) { state.abort(reason) detachAbortListener() }, - }) + }, { highWaterMark: 0 }) return shell } @@ -416,7 +463,6 @@ export function renderToPipeableStream( ): PipeableHandle { const state = createState() - const buffers: string[] = [] let dest: PipeableWritable | null = null let shellReady = false let shellFailed = false @@ -429,16 +475,9 @@ export function renderToPipeableStream( else target.end() } - const flushTo = (w: PipeableWritable) => { - if (!buffers.length) return - for (const b of buffers) w.write(b) - buffers.length = 0 - } - + // Nothing is emitted before pipe() starts the flow. const emit: Emit = (chunk) => { - if (finished) return - if (dest) dest.write(chunk) - else buffers.push(chunk) + if (!failed) dest!.write(chunk) } state.onShellReady = () => queueMicrotask(() => { @@ -470,7 +509,8 @@ export function renderToPipeableStream( return { pipe(target: T): T { dest = target - if (!failed) flushTo(target) + state.flowing = true + if (!failed) state.flush?.() if (finished) finishDestination(target) return target }, diff --git a/packages/redact/src/server/walk.ts b/packages/redact/src/server/walk.ts index c015f74..1bb28f2 100644 --- a/packages/redact/src/server/walk.ts +++ b/packages/redact/src/server/walk.ts @@ -47,17 +47,25 @@ export interface ServerErrorInfo { export type BrowserBailoutCallback = (error: Error, errorInfo: ServerErrorInfo) => void +/** Streamed output keeps pending boundaries addressable until it is written. */ +export type HtmlPart = string | SuspendedBoundary + export interface SuspendedBoundary { id: number - fallbackHTML: string + fallback: HtmlPart[] children: ReactNode thenable: Promise contextSnapshot: ContextSnapshot componentStack: string + // Set by the stream: completed content and failure markers are inlined + // when the boundary is written; `written` boundaries need a later reveal. + content?: HtmlPart[] + marker?: string + written?: boolean } export interface WalkOptions { - emit: (chunk: string) => void + emit: (chunk: HtmlPart) => void onHead?: () => void onSuspend?: ((boundary: SuspendedBoundary) => void) | undefined onBrowserBailout?: BrowserBailoutCallback | undefined @@ -455,10 +463,10 @@ function walkSuspense( // Try to render the children synchronously. If a thenable is thrown, // record the boundary and emit the fallback. - const childParts: string[] = [] + const childParts: HtmlPart[] = [] const nested: SuspendedBoundary[] = [] const thrownInfo: { componentStack?: string | undefined } = {} - const childEmit = (s: string) => childParts.push(s) + const childEmit = (s: HtmlPart) => childParts.push(s) const resourceScope: ResourceScope | undefined = currentSSRFrame().shellFlushed ? {} : undefined try { walkNode(props.children, { @@ -476,21 +484,23 @@ function walkSuspense( if (isBrowserError(thenable)) opts.onBrowserBailout?.(thenable, { componentStack: (thenable as any).componentStack || opts.componentStack || '' }) else opts.onError?.(thenable) } - const fallbackParts: string[] = [] + const fallbackParts: HtmlPart[] = [] walkNode(props.fallback, { ...opts, emit: (s) => fallbackParts.push(s), onSuspend: opts.onSuspend ? (boundary) => nested.push(boundary) : undefined, nextBoundaryId: opts.nextBoundaryId, }) - if (!suspended) { - opts.emit(`${fallbackParts.join('')}`) - } else emitBoundary(opts, id, fallbackParts.join('')) - for (const boundary of nested) opts.onSuspend?.(boundary) - if (suspended) opts.onSuspend?.({ - id, fallbackHTML: fallbackParts.join(''), children: props.children, thenable, contextSnapshot, + // A streamed suspension is emitted as itself, so the stream can still + // inline its content if it completes before these bytes are written. + const boundary: SuspendedBoundary | undefined = suspended && opts.onSuspend ? { + id, fallback: fallbackParts, children: props.children, thenable, contextSnapshot, componentStack: thrownInfo.componentStack || opts.componentStack || '', - }) + } : undefined + if (boundary) opts.emit(boundary) + else emitBoundary(opts, id, fallbackParts, suspended ? '$?' : isBrowserError(thenable) ? '$!' : '$E') + for (const inner of nested) opts.onSuspend?.(inner) + if (boundary) opts.onSuspend!(boundary) return } @@ -503,17 +513,25 @@ function walkSuspense( // range to adopt on resolve — it creates fresh DOM next to the SSR content, // producing visible duplicates (e.g. double navbar logos). Markers let the // client treat this as a resolved boundary and hydrate in-place. - opts.emit(``) - opts.emit(childParts.join('')) - opts.emit(``) + emitBoundary(opts, id, childParts, '$') for (const boundary of nested) opts.onSuspend?.(boundary) } -function emitBoundary(opts: WalkOptions, id: number, fallbackHTML: string): void { - // Visible div wrapper so the fallback UI shows; B: id lets $RC locate it on - // reveal. The leading/trailing comments let hydration detect a pending - // boundary and register a reveal callback. - opts.emit(`
`) - opts.emit(fallbackHTML) - opts.emit(`
`) +function emitBoundary(opts: WalkOptions, id: number, parts: HtmlPart[], marker: string): void { + const [open, close] = boundaryEdges(id, marker) + opts.emit(open) + for (const part of parts) opts.emit(part) + opts.emit(close) +} + +/** + * Boundary markers: `$` completed, `$E`/`$!` client-rendered, `$?` pending. + * A pending fallback gets a visible div wrapper so the fallback UI shows; + * B: id lets $RC locate it on reveal. The leading/trailing comments let + * hydration detect a pending boundary and register a reveal callback. + */ +export function boundaryEdges(id: number, marker: string): [string, string] { + return marker === '$?' + ? [`
`, '
'] + : [``, ''] } diff --git a/scripts/size-check.mjs b/scripts/size-check.mjs index fc69f74..9ab2709 100644 --- a/scripts/size-check.mjs +++ b/scripts/size-check.mjs @@ -63,11 +63,17 @@ const STORE_HYDRATION_SIZE_ALLOWANCE = { 'redact/dom-client (hydration=stub)': [3, 0], } +// Measured cost of inlining boundaries that complete before their bytes are +// written, against main 37c051c, [source, built] gzip bytes above the cap. +const INLINE_BOUNDARY_SIZE_ALLOWANCE = { + 'redact/server': [188, 191], +} + let failed = false for (const tree of ['src', 'dist']) { for (const row of await measureSizes(tree)) { const baseBudget = BUDGETS[row.name] - const budget = baseBudget === undefined ? undefined : baseBudget + (STORE_HYDRATION_SIZE_ALLOWANCE[row.name]?.[tree === 'src' ? 0 : 1] ?? 0) + (ROOT_TRANSITION_SIZE_ALLOWANCE[row.name]?.[tree === 'src' ? 0 : 1] ?? 0) + (QUEUE_SIZE_ALLOWANCE[row.name]?.[tree === 'src' ? 0 : 1] ?? 0) + const budget = baseBudget === undefined ? undefined : baseBudget + (INLINE_BOUNDARY_SIZE_ALLOWANCE[row.name]?.[tree === 'src' ? 0 : 1] ?? 0) + (STORE_HYDRATION_SIZE_ALLOWANCE[row.name]?.[tree === 'src' ? 0 : 1] ?? 0) + (ROOT_TRANSITION_SIZE_ALLOWANCE[row.name]?.[tree === 'src' ? 0 : 1] ?? 0) + (QUEUE_SIZE_ALLOWANCE[row.name]?.[tree === 'src' ? 0 : 1] ?? 0) const ok = budget !== undefined && row.gz <= budget failed ||= !ok console.log(`${ok ? 'PASS' : 'FAIL'} ${tree} ${row.name}: ${row.gz} B gzip, budget ${budget ?? 'missing'}`) diff --git a/tests/ssr-inline-completed-boundaries.test.tsx b/tests/ssr-inline-completed-boundaries.test.tsx new file mode 100644 index 0000000..b5d8588 --- /dev/null +++ b/tests/ssr-inline-completed-boundaries.test.tsx @@ -0,0 +1,224 @@ +import { describe, expect, it, vi } from 'vitest' +import { Suspense, use, useId, useState, type ReactNode } from 'react' +import { flushSync, preinit } from 'react-dom' +import { hydrateRoot } from 'react-dom/client' +import { renderToPipeableStream, renderToReadableStream } from 'react-dom/server' + +const tick = () => new Promise(resolve => setTimeout(resolve, 0)) + +function deferred() { + let resolve!: (value: T) => void, reject!: (error: unknown) => void + const promise = new Promise((done, fail) => { resolve = done; reject = fail }) + return { promise, resolve, reject } +} + +function settled(value: T): Promise { + return Object.assign(Promise.resolve(value), { status: 'fulfilled', value }) +} + +function Read({ source, id }: { source: Promise; id: string }) { + return
{use(source)}
+} + +function pipe(element: ReactNode): Promise { + return new Promise((resolve, reject) => { + let html = '' + const handle = renderToPipeableStream(element, { + onShellError: reject, + onAllReady() { + handle.pipe({ write(chunk: string) { html += chunk }, end() { resolve(html) } }) + }, + }) + }) +} + +function Counter({ source, name }: { source: Promise; name: string }) { + const [count, setCount] = useState(0) + return +} + +function Counters({ a, b }: { a: Promise; b: Promise }) { + return ( +
+ a loading}> + b loading}> +
+ ) +} + +function Id({ source, name }: { source: Promise; name: string }) { + return {use(source)} +} + +function Identified({ a, b }: { a: Promise; b: Promise }) { + return ( +
+ a loading}> + b loading}> +
+ ) +} + +// Resolves `before` ahead of the first read and `after` once the shell is read. +async function readAround(element: ReactNode, before: (value: string) => void, after: (value: string) => void) { + const stream = await renderToReadableStream(element) + before('A') + await tick() + const reader = stream.getReader(), decoder = new TextDecoder() + const shell = decoder.decode((await reader.read()).value) + after('B') + let tail = '' + for (;;) { + const next = await reader.read() + if (next.done) break + tail += decoder.decode(next.value) + } + return { shell, tail } +} + +// What a browser shows with JavaScript disabled: no reveal script runs. +function withoutScripts(html: string): Document { + return new DOMParser().parseFromString(html, 'text/html') +} + +describe('SSR inlines boundaries completed before they are written', () => { + it('inlines a boundary that resolved before the destination started reading', async () => { + const data = deferred() + const stream = await renderToReadableStream( +
loading}>
, + ) + data.resolve('ready') + await tick() + expect(await new Response(stream).text()).toBe('
ready
') + }) + + it('writes a fully inline document when the reader waits for allReady', async () => { + const outer = deferred(), inner = deferred() + function Outer() { + const value = use(outer.promise) + return ( +
+ {value} + inner loading}> +
+ ) + } + const app = ( + +
outer loading}>
+ + ) + setTimeout(() => outer.resolve('outer'), 1) + setTimeout(() => inner.resolve('inner'), 3) + + const stream = await renderToReadableStream(app) + await stream.allReady + const readable = await new Response(stream).text() + const piped = await pipe(app) + + for (const html of [readable, piped]) { + expect(html).not.toMatch(/ { + const early = deferred(), late = deferred() + const { shell, tail } = await readAround(, early.resolve, late.resolve) + + // A completed before the write: inline, never outlined or revealed. + expect(shell).toContain('') + expect(shell).toContain('
b loading
') + expect(shell + tail).not.toContain('S:0') + expect(tail).toContain('') + expect(tail).toContain('$RC(1)') + + const container = document.createElement('div') + container.innerHTML = shell + tail + document.body.appendChild(container) + for (const script of Array.from(container.querySelectorAll('script'))) new Function(script.textContent ?? '')() + const a = container.querySelector('#a') as HTMLButtonElement, b = container.querySelector('#b') as HTMLButtonElement + const onRecoverableError = vi.fn() + hydrateRoot(container, , { onRecoverableError }) + await tick() + + expect(onRecoverableError).not.toHaveBeenCalled() + expect(container.querySelector('#a')).toBe(a) + expect(container.querySelector('#b')).toBe(b) + flushSync(() => a.click()) + flushSync(() => b.click()) + expect(a.textContent).toBe('A:1') + expect(b.textContent).toBe('B:1') + container.remove() + }) + + it('keeps the useId values of an outlined write when inlining', async () => { + const ids = async (resolveBeforeRead: boolean) => { + const early = deferred(), late = deferred() + const { shell, tail } = await readAround( + , + resolveBeforeRead ? early.resolve : () => {}, + () => { early.resolve('A'); late.resolve('B') }, + ) + const container = document.createElement('div') + container.innerHTML = shell + tail + for (const script of Array.from(container.querySelectorAll('script'))) new Function(script.textContent ?? '')() + return Array.from(container.querySelectorAll('[data-useid]'), node => `${node.id}=${node.getAttribute('data-useid')}`) + } + const mixed = await ids(true), outlined = await ids(false) + expect(mixed).toHaveLength(3) + expect(mixed).toEqual(outlined) + }) + + it('writes a boundary that failed before the write as client-rendered without a script', async () => { + const data = deferred(), failure = new Error('boundary failed') + const onError = vi.fn() + const stream = await renderToReadableStream( +
fallback}>
, + { onError }, + ) + data.reject(failure) + await stream.allReady + expect(await new Response(stream).text()).toBe('
fallback
') + expect(onError).toHaveBeenCalledTimes(1) + expect(onError).toHaveBeenCalledWith(failure) + }) + + it('puts stylesheets of boundaries completed before the write in the head', async () => { + const data = deferred() + function Styled() { + const value = use(data.promise) + preinit('/boundary.css', { as: 'style', precedence: 'theme' }) + return

{value}

+ } + const stream = await renderToReadableStream( + loading}>, + ) + data.resolve('styled') + await stream.allReady + const html = await new Response(stream).text() + expect(html).toBe( + '' + + '

styled

', + ) + }) + + it('drops boundaries inside a fallback that was replaced before the write', async () => { + const content = deferred(), fallback = deferred() + const stream = await renderToReadableStream( +
+ wait}>}> + + +
, + ) + content.resolve('content') + await tick() + fallback.resolve('fallback') + await stream.allReady + expect(await new Response(stream).text()).toBe('
content
') + }) +})