diff --git a/.changeset/ssr-prefixed-segment-ids.md b/.changeset/ssr-prefixed-segment-ids.md new file mode 100644 index 0000000..4104e21 --- /dev/null +++ b/.changeset/ssr-prefixed-segment-ids.md @@ -0,0 +1,7 @@ +--- +'@tanstack/redact': patch +--- + +Use safely escaped, prefixed hexadecimal transport IDs for streamed Suspense boundaries, pass both DOM IDs to reveal instructions, and emit the recognized stylesheet-gated reveal instruction. + +Keep reveal registrations shared across independently prefixed streams without changing early-event capture behavior. Remove the hidden segment and its parsing wrapper if a stylesheet-gated reveal fails, including when its boundary has already been deleted. diff --git a/packages/redact/src/dom/features/suspense/full.ts b/packages/redact/src/dom/features/suspense/full.ts index 41e28a8..cf01c63 100644 --- a/packages/redact/src/dom/features/suspense/full.ts +++ b/packages/redact/src/dom/features/suspense/full.ts @@ -196,7 +196,8 @@ function hydrateSuspenseBoundary(fiber: Fiber, props: any, boundary: [ } // Pending: fallback DOM lives inside
. Hydrate the fallback // React subtree against that div's children. - const bDiv = document.getElementById(`B:${id}`); + const boundaryId = (findRoot(fiber)?.i ?? '') + 'B:' + id.toString(16); + const bDiv = document.getElementById(boundaryId); try { if (bDiv) { setHydrationCursor(fiber, new HydrationCursor(bDiv)); @@ -216,7 +217,7 @@ function hydrateSuspenseBoundary(fiber: Fiber, props: any, boundary: [ clearHydrationCursor(fiber); } advanceCursorPast(fiber.p!, endMark); - (globalThis as any).$RH?.(id, () => rehydrateBoundary(fiber)); + (globalThis as any).$RH?.(boundaryId, () => rehydrateBoundary(fiber)); // If the inline runtime isn't present, nothing external will mark us dy. fiber.m = props; } diff --git a/packages/redact/src/server/bootstrap-script.ts b/packages/redact/src/server/bootstrap-script.ts index 6acb6aa..264c101 100644 --- a/packages/redact/src/server/bootstrap-script.ts +++ b/packages/redact/src/server/bootstrap-script.ts @@ -1,3 +1,4 @@ +import { boundaryId, segmentId, scriptId } from './stream-ids' /** * Inline client runtime injected into the streamed HTML. Two jobs: * @@ -24,19 +25,22 @@ import { escapeAttr } from './escape' export const BOUNDARY_REVEAL_RUNTIME = `(function(){` + + // All independently prefixed streams in a document share one reveal registry. + `if(!window.$RH){` + `var c={},r=new Map;` + `function place(n,v){` + `var a=document.head.querySelectorAll('link[rel="stylesheet"][data-precedence],style[data-precedence]'),t=a.length?a[a.length-1]:null,p=t;` + `for(var k=0;k${BOUNDARY_REVEAL_RUNTIME}` } -export function revealScript(id: number, nonce?: string, styles?: string[][]): string { +export function revealScript(id: number, nonce?: string, styles?: string[][], identifierPrefix = ''): string { const n = nonce ? ` nonce="${escapeAttr(nonce)}"` : '' - return `${styles?.length ? `$RL(${id},${JSON.stringify(styles).replace(/` + const b = scriptId(boundaryId(id, identifierPrefix)), s = scriptId(segmentId(id, identifierPrefix)) + return `${styles?.length ? `$RR(${b},${s},${JSON.stringify(styles).replace(/` } diff --git a/packages/redact/src/server/dispatcher.ts b/packages/redact/src/server/dispatcher.ts index fe22539..f7cc5aa 100644 --- a/packages/redact/src/server/dispatcher.ts +++ b/packages/redact/src/server/dispatcher.ts @@ -9,6 +9,7 @@ import { createResourceHints, type ResourceScope } from './resource-hints' export interface SSRFrame { idCounter: number identifierPrefix: string + transportPrefix?: string contextStack: Array<{ context: any; prev: any }> resources?: ReturnType shellFlushed?: boolean @@ -23,9 +24,9 @@ resourceHintDispatcher.emit = (kind, href, options) => { return true } -export function beginSSR(identifierPrefix = ':R', nextFrame?: SSRFrame) { +export function beginSSR(identifierPrefix?: string, nextFrame?: SSRFrame) { const previous = { frame, dispatcher: ReactSharedInternals.H } - frame = nextFrame ?? { idCounter: 0, identifierPrefix, contextStack: [] } + frame = nextFrame ?? { idCounter: 0, identifierPrefix: identifierPrefix ?? ':R', transportPrefix: identifierPrefix ?? '', contextStack: [] } ReactSharedInternals.H = ssrDispatcher as any return previous } diff --git a/packages/redact/src/server/stream-ids.ts b/packages/redact/src/server/stream-ids.ts new file mode 100644 index 0000000..b95555c --- /dev/null +++ b/packages/redact/src/server/stream-ids.ts @@ -0,0 +1,5 @@ +// Match Fizz's transport identifiers. Hook IDs and boundary comment numbers are +// separate namespaces; only the DOM transport IDs use this prefixed hex scheme. +export const boundaryId = (id: number, prefix = ''): string => prefix + 'B:' + id.toString(16) +export const segmentId = (id: number, prefix = ''): string => prefix + 'S:' + id.toString(16) +export const scriptId = (id: string): string => JSON.stringify(id).replace(/ { if (isBrowserError(error)) options.onBrowserBailout?.(error, { componentStack: (error as any).componentStack || b.componentStack }) else options.onError?.(error) - emit(`$RB(${b.id}${isBrowserError(error) ? '' : ',1'})`) + emit(`$RB(${scriptId(boundaryId(b.id, options.identifierPrefix))}${isBrowserError(error) ? '' : ',1'})`) } if (state.aborted) { defer(state.reason); return } @@ -236,7 +237,7 @@ function streamBoundary( if (hints) emit(hints) } - emit(`${revealScript(b.id, nonce, styles)}`) + emit(`${revealScript(b.id, nonce, styles, options.identifierPrefix)}`) // Recurse: any nested suspensions inside the now-revealed content for (const s of sub) streamBoundary(s, emit, options, state) diff --git a/packages/redact/src/server/walk.ts b/packages/redact/src/server/walk.ts index c015f74..b4fa2e5 100644 --- a/packages/redact/src/server/walk.ts +++ b/packages/redact/src/server/walk.ts @@ -1,3 +1,4 @@ +import { boundaryId } from './stream-ids' import { isTextContent } from '../core/functions'; import { isFunction, isThenable } from '../core/functions' import { resolveClassProps } from '../core/class-props' @@ -23,6 +24,7 @@ import { REACT_VIEW_TRANSITION_TYPE, } from '../react' import { + escapeAttr, attrToHtml, escapeText, escapeScript, @@ -513,7 +515,7 @@ 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(`
`) opts.emit(fallbackHTML) opts.emit(`
`) } diff --git a/scripts/compatibility-size-budget.mjs b/scripts/compatibility-size-budget.mjs index 5038c35..a1dfe34 100644 --- a/scripts/compatibility-size-budget.mjs +++ b/scripts/compatibility-size-budget.mjs @@ -38,7 +38,83 @@ const allowances = { } } +// Measured SSR correctness costs against the exact PR base. +const ssrBase = '0c7274e17a3fade8396169bc6ed024903cd1252d' +const ssrAllowances = { + "full:dom/client:react19": { + "min": 51, + "gzip": 12 + }, + "full:dom/client:react18": { + "min": 51, + "gzip": 12 + }, + "full:server/index:react19": { + "min": 323, + "gzip": 109, + "brotli": 112 + }, + "full:server/index:react18": { + "min": 323, + "gzip": 110, + "brotli": 99 + }, + "full:client-total:react19": { + "min": 51, + "gzip": 16, + "brotli": 20 + }, + "full:client-total:react18": { + "min": 51, + "gzip": 17, + "brotli": 32 + }, + "default:dom/client:react19": { + "min": 51, + "gzip": 10, + "brotli": 12 + }, + "default:dom/client:react18": { + "min": 51, + "gzip": 14, + "brotli": 36 + }, + "default:server/index:react19": { + "min": 323, + "gzip": 109, + "brotli": 112 + }, + "default:server/index:react18": { + "min": 323, + "gzip": 110, + "brotli": 99 + }, + "default:client-total:react19": { + "min": 51, + "gzip": 20 + }, + "default:client-total:react18": { + "min": 51, + "gzip": 19, + "brotli": 36 + }, + "nano:server/index:react19": { + "min": 323, + "gzip": 109, + "brotli": 112 + }, + "nano:server/index:react18": { + "min": 323, + "gzip": 110, + "brotli": 99 + } +} + export function compatibilitySizeAllowance(base, row, metric) { + if (base === ssrBase) { + const value = ssrAllowances[`${row.profile}:${row.entry}:${row.target}`]?.[metric] + return typeof value === 'number' ? value : 0 + } if (base !== approvedBase) return 0 const value = allowances[`${row.profile}:${row.entry}:${row.target}`]?.[metric] return typeof value === 'number' ? value : 0 diff --git a/scripts/size-check.mjs b/scripts/size-check.mjs index fc69f74..5b8d26a 100644 --- a/scripts/size-check.mjs +++ b/scripts/size-check.mjs @@ -63,11 +63,24 @@ const STORE_HYDRATION_SIZE_ALLOWANCE = { 'redact/dom-client (hydration=stub)': [3, 0], } +// Measured SSR correctness cost against main 0c7274e, [source, built]. +// Increase only entries exceeding the existing caps, by their exact deficit. +const SSR_SIZE_ALLOWANCE = { + "redact/server": [ + 186, + 188 + ], + "redact/dom-client (hydration=stub)": [ + 1, + 5 + ] +} + 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 + (SSR_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/compatibility-size-budget.test.ts b/tests/compatibility-size-budget.test.ts index 49e4ce1..d98eb3f 100644 --- a/tests/compatibility-size-budget.test.ts +++ b/tests/compatibility-size-budget.test.ts @@ -17,3 +17,14 @@ it('keeps unapproved profiles and targets at zero growth', () => { expect(compatibilitySizeAllowance(base, { ...nano, target: 'react18' }, 'brotli')).toBe(11) expect(compatibilitySizeAllowance(base, { ...nano, entry: 'react/index' }, 'brotli')).toBe(0) }) + +it('bounds the SSR correctness allowance to its measured base, entry and metric', () => { + const base = '0c7274e17a3fade8396169bc6ed024903cd1252d' + const server = { profile: 'full', entry: 'server/index', target: 'react19' } + expect(compatibilitySizeAllowance(base, server, 'min')).toBe(323) + expect(compatibilitySizeAllowance(base, server, 'gzip')).toBe(109) + expect(compatibilitySizeAllowance(base, server, 'brotli')).toBe(112) + expect(compatibilitySizeAllowance(base, server, 'constructor')).toBe(0) + expect(compatibilitySizeAllowance(base, { ...server, entry: 'react/index' }, 'gzip')).toBe(0) + expect(compatibilitySizeAllowance('another-base', server, 'gzip')).toBe(0) +}) diff --git a/tests/ssr-boundary-errors.test.tsx b/tests/ssr-boundary-errors.test.tsx index b18f721..7714f1f 100644 --- a/tests/ssr-boundary-errors.test.tsx +++ b/tests/ssr-boundary-errors.test.tsx @@ -28,7 +28,7 @@ it('keeps rejected lazy components on the existing streaming failure path', asyn const errors: unknown[] = [], Lazy = lazy(() => Promise.reject(failure)) const stream = await renderToReadableStream(h(Suspense, { fallback: h('p', null, 'loading') }, h(Lazy)), { onError: error => { errors.push(error) } }) const html = await new Response(stream).text() - expect(html).toContain('loading'); expect(errors).toEqual([failure]); expect(html).toContain('$RB(0,1)') + expect(html).toContain('loading'); expect(errors).toEqual([failure]); expect(html).toContain('$RB("B:0",1)') }) it('recovers the failed boundary on the client without replacing a sibling', async () => { let server = true diff --git a/tests/ssr-prefixed-segments.test.tsx b/tests/ssr-prefixed-segments.test.tsx new file mode 100644 index 0000000..588a347 --- /dev/null +++ b/tests/ssr-prefixed-segments.test.tsx @@ -0,0 +1,255 @@ +import { afterEach, expect, it, vi } from 'vitest' +import { Suspense, createElement as h, useLayoutEffect, useState } from 'react' +import { flushSync } from 'react-dom' +import { hydrateRoot } from 'react-dom/client' +import { renderToReadableStream, renderToString } from 'react-dom/server' + +const roots: Array<{ unmount(): void }> = [] +afterEach(() => { + for (const root of roots.splice(0)) root.unmount() + ;(window as any).$RE_stop?.() + for (const name of ['$RH', '$RC', '$RR', '$RB', '$RE_q', '$RE_stop']) delete (window as any)[name] + document.body.innerHTML = '' +}) +function gate() { + let resolve!: () => void, ready = false + const promise = new Promise(r => { resolve = r }) + return { read() { if (!ready) throw promise }, resolve() { ready = true; resolve() } } +} +function install(html: string) { + const container = document.createElement('div') + container.innerHTML = html + document.body.appendChild(container) + for (const script of container.querySelectorAll('script')) new Function(script.textContent!)() + return container +} + +it.each([undefined, '', 'app-', ':R'])('uses the same transport prefix in string and stream shells (prefix %s)', async prefix => { + const pending = gate() + function Async() { pending.read(); return ready } + const tree = + const options = prefix === undefined ? {} : { identifierPrefix: prefix } + const marker = `id="${prefix ?? ''}B:0"` + expect(renderToString(tree, options)).toContain(marker) + const stream = await renderToReadableStream(tree, options) + pending.resolve() + expect(await new Response(stream).text()).toContain(marker) +}) + +it('addresses a failed streamed boundary with its prefixed transport ID', async () => { + const pending = gate(), error = new Error('failed segment'), onError = vi.fn() + function Async(): never { pending.read(); throw error } + const stream = await renderToReadableStream(, { identifierPrefix: 'failure-', onError }) + pending.resolve() + expect(await new Response(stream).text()).toContain('$RB("failure-B:0",1)') + expect(onError).toHaveBeenCalledWith(error) +}) + +it('emits prefixed hexadecimal boundary/segment IDs and two-ID $RC calls', async () => { + const pending = gate(), prefix = 'modern-js-' + function Async({ index }: { index: number }) { pending.read(); return {index} } + const tree =
{Array.from({ length: 18 }, (_, index) => loading}>)}
+ const stream = await renderToReadableStream(tree, { identifierPrefix: prefix }) + pending.resolve() + const html = await new Response(stream).text() + for (let id = 0; id < 18; id++) { + const hex = id.toString(16) + expect(html).toContain(`id="modern-js-B:${hex}"`) + expect(html).toContain(`id="modern-js-S:${hex}"`) + expect(html).toContain(`$RC("modern-js-B:${hex}","modern-js-S:${hex}")`) + } + const container = install(html), main = container.firstElementChild, error = vi.fn() + roots.push(hydrateRoot(container, tree, { identifierPrefix: prefix, onRecoverableError: error })) + expect(error).not.toHaveBeenCalled() + expect(container.firstElementChild).toBe(main) + expect(container.querySelectorAll('span')).toHaveLength(18) +}) + +it.each(['before hydration', 'after first hydration'])('keeps pending roots interactive when the second shell arrives %s', async arrival => { + const a = gate(), b = gate(), effects = vi.fn(), error = vi.fn() + function Async({ pending, label }: { pending: ReturnType; label: string }) { + pending.read() + const [count, setCount] = useState(0) + useLayoutEffect(() => { effects(label) }, []) + return + } + const ta = + const tb = + const one = await renderToReadableStream(ta, { identifierPrefix: 'one-' }) + const two = await renderToReadableStream(tb, { identifierPrefix: 'two-' }) + const ra = one.getReader(), rb = two.getReader(), decoder = new TextDecoder() + const ca = install(decoder.decode((await ra.read()).value)) + let cb: HTMLDivElement + if (arrival === 'before hydration') cb = install(decoder.decode((await rb.read()).value)) + roots.push(hydrateRoot(ca, ta, { identifierPrefix: 'one-', onRecoverableError: error })) + if (arrival === 'after first hydration') cb = install(decoder.decode((await rb.read()).value)) + roots.push(hydrateRoot(cb!, tb, { identifierPrefix: 'two-', onRecoverableError: error })) + expect(effects).not.toHaveBeenCalled() + a.resolve(); b.resolve() + for (const [reader, container] of [[ra, ca], [rb, cb!]] as const) { + let html = '' + for (;;) { const { done, value } = await reader.read(); if (done) break; html += decoder.decode(value) } + const transport = document.createElement('div'); transport.innerHTML = html + const scripts = [...transport.querySelectorAll('script')] + const serverButton = transport.querySelector('button')! + while (transport.firstChild) container.appendChild(transport.firstChild) + for (const script of scripts) new Function(script.textContent!)() + expect(container.querySelector('button')).toBe(serverButton) + } + await vi.waitFor(() => expect(effects.mock.calls).toEqual([['one'], ['two']])) + for (const [container, label] of [[ca, 'one'], [cb!, 'two']] as const) { + const button = container.querySelector('button')! + expect(button.textContent).toBe(label + '0') + flushSync(() => button.click()) + expect(container.querySelector('button')).toBe(button) + expect(button.textContent).toBe(label + '1') + } + expect(error).not.toHaveBeenCalled() +}) + +it('finds a prefixed hexadecimal placeholder when hydration precedes reveal', async () => { + const pending = gate(), prefix = 'modern-js-' + const effect = vi.fn() + function Async() { + pending.read() + const [count, setCount] = useState(0) + useLayoutEffect(effect, []) + return + } + const tree =
{Array.from({ length: 10 }, (_, index) => {index})}loading}>
+ const stream = await renderToReadableStream(tree, { identifierPrefix: prefix }), reader = stream.getReader(), decoder = new TextDecoder() + const container = install(decoder.decode((await reader.read()).value)), main = container.firstElementChild, error = vi.fn() + roots.push(hydrateRoot(container, tree, { identifierPrefix: prefix, onRecoverableError: error })) + expect(container.querySelector('[id="modern-js-B:a"]')).not.toBeNull() + pending.resolve() + let html = '' + for (;;) { const { done, value } = await reader.read(); if (done) break; html += decoder.decode(value) } + const transport = document.createElement('div'); transport.innerHTML = html + const scripts = [...transport.querySelectorAll('script')] + const serverButton = transport.querySelector('button')! + while (transport.firstChild) container.appendChild(transport.firstChild) + for (const script of scripts) new Function(script.textContent!)() + expect(error).not.toHaveBeenCalled() + expect(container.firstElementChild).toBe(main) + await vi.waitFor(() => expect(effect).toHaveBeenCalledTimes(1)) + const button = container.querySelector('button')! + expect(button).toBe(serverButton) + expect(button.textContent).toBe('ready0') + flushSync(() => button.click()) + expect(button.textContent).toBe('ready1') +}) + +it('uses recognized $RR stylesheet instructions and safely escapes transport prefixes', async () => { + const pending = gate() + function Async() { pending.read(); return <>ready } + const prefix = 'quote-"&' + const tree =
{Array.from({ length: 10 }, (_, index) => {index})}loading}>
+ const stream = await renderToReadableStream(tree, { identifierPrefix: prefix, nonce: 'test-nonce' }) + pending.resolve() + const html = await new Response(stream).text() + const boundaryId = `${prefix}B:a`, segmentId = `${prefix}S:a` + const scriptId = (id: string) => JSON.stringify(id).replace(/