From 353841dfeceed3b91fac7eedd7eccc14ea69731b Mon Sep 17 00:00:00 2001 From: Petr Glaser Date: Wed, 28 Oct 2026 14:01:43 +0100 Subject: [PATCH 1/2] fix: derive server useId values from tree position Co-Authored-By: Claude Code --- .changeset/ssr-useid-tree-position.md | 5 ++ packages/redact/src/dom/react-ids.ts | 4 +- packages/redact/src/dom/root-internal.ts | 3 +- packages/redact/src/server/dispatcher.ts | 12 ++-- packages/redact/src/server/stream.ts | 1 + packages/redact/src/server/walk.ts | 20 ++++-- scripts/compatibility-size-budget.mjs | 51 ++++++++++++++ scripts/size-check.mjs | 11 +++- tests/compatibility-size-budget.test.ts | 11 ++++ tests/hydration.test.tsx | 18 ++--- tests/mixed-renderer-hooks.test.tsx | 4 +- tests/ssr-tree-ids.test.tsx | 84 ++++++++++++++++++++++++ 12 files changed, 200 insertions(+), 24 deletions(-) create mode 100644 .changeset/ssr-useid-tree-position.md create mode 100644 tests/ssr-tree-ids.test.tsx diff --git a/.changeset/ssr-useid-tree-position.md b/.changeset/ssr-useid-tree-position.md new file mode 100644 index 0000000..3be022b --- /dev/null +++ b/.changeset/ssr-useid-tree-position.md @@ -0,0 +1,5 @@ +--- +'@tanstack/redact': patch +--- + +Derive server useId values from tree positions so suspended boundaries hydrate without changing IDs, and keep hydrated IDs separate from IDs allocated by later client mounts. diff --git a/packages/redact/src/dom/react-ids.ts b/packages/redact/src/dom/react-ids.ts index 37c47f3..c7dd0fe 100644 --- a/packages/redact/src/dom/react-ids.ts +++ b/packages/redact/src/dom/react-ids.ts @@ -1,8 +1,8 @@ import type { Fiber, FiberRoot } from '../core'; // React SSR encodes the original child slots, including empty slots, into IDs. -// Keep this opt-in so existing Redact SSR IDs remain compatible across upgrades. +// Redact SSR uses the same tree slots so suspended render order cannot change IDs. let clientId = 0; -const leadingBit = (tree: bigint): bigint => 1n << BigInt(tree.toString(2).length - 1); +export const leadingBit = (tree: bigint): bigint => 1n << BigInt(tree.toString(2).length - 1); export function forkId(base: bigint, count: number, index: number): bigint { const bit = leadingBit(base); return (bit << BigInt(32 - Math.clz32(count))) | (BigInt(index + 1) * bit) | (base ^ bit); diff --git a/packages/redact/src/dom/root-internal.ts b/packages/redact/src/dom/root-internal.ts index 59a2a3f..6965861 100644 --- a/packages/redact/src/dom/root-internal.ts +++ b/packages/redact/src/dom/root-internal.ts @@ -16,8 +16,7 @@ export function createFiberRoot(container: Element | Document | DocumentFragment i: options.identifierPrefix, h: false, } as FiberRoot; - if (options.identifierStrategy === 'react') - root.ri = true; + root.ri = true; attachRootFiber(root, container); return root; } diff --git a/packages/redact/src/server/dispatcher.ts b/packages/redact/src/server/dispatcher.ts index fe22539..881b80e 100644 --- a/packages/redact/src/server/dispatcher.ts +++ b/packages/redact/src/server/dispatcher.ts @@ -1,3 +1,4 @@ +import { leadingBit } from '../dom/react-ids' import { isFunction } from '../core/functions' import { readThenable } from '../react/thenable' import { idleFormStatus } from '../react/form-state' @@ -7,7 +8,8 @@ import { resourceHintDispatcher } from '../core/resource-hints' import { createResourceHints, type ResourceScope } from './resource-hints' export interface SSRFrame { - idCounter: number + treeId: bigint + localId: number identifierPrefix: string contextStack: Array<{ context: any; prev: any }> resources?: ReturnType @@ -23,9 +25,9 @@ resourceHintDispatcher.emit = (kind, href, options) => { return true } -export function beginSSR(identifierPrefix = ':R', nextFrame?: SSRFrame) { +export function beginSSR(identifierPrefix = '', nextFrame?: SSRFrame) { const previous = { frame, dispatcher: ReactSharedInternals.H } - frame = nextFrame ?? { idCounter: 0, identifierPrefix, contextStack: [] } + frame = nextFrame ?? { treeId: 1n, localId: 0, identifierPrefix, contextStack: [] } ReactSharedInternals.H = ssrDispatcher as any return previous } @@ -128,7 +130,9 @@ export const ssrDispatcher = { }, useId(): string { if (!frame) return ':r0:' - return `${frame.identifierPrefix}${(frame.idCounter++).toString(36)}` + const local = frame.localId++ + const tree = frame.treeId + return '_' + frame.identifierPrefix + 'R_' + (tree ^ leadingBit(tree)).toString(32) + (local ? 'H' + local.toString(32) : '') + '_' }, useTransition(): [boolean, (fn: () => void) => void] { return [false, (fn) => fn()] diff --git a/packages/redact/src/server/stream.ts b/packages/redact/src/server/stream.ts index 694a489..a37bfb8 100644 --- a/packages/redact/src/server/stream.ts +++ b/packages/redact/src/server/stream.ts @@ -214,6 +214,7 @@ function streamBoundary( const restore = applyContextSnapshot(b.contextSnapshot) try { walk(b.children, { + treeId: b.treeId, emit: (s) => parts.push(s), onSuspend: (n) => sub.push(n), nextBoundaryId: () => state.nextId++, diff --git a/packages/redact/src/server/walk.ts b/packages/redact/src/server/walk.ts index c015f74..207cbfc 100644 --- a/packages/redact/src/server/walk.ts +++ b/packages/redact/src/server/walk.ts @@ -1,3 +1,4 @@ +import { forkId } from '../dom/react-ids' import { isTextContent } from '../core/functions'; import { isFunction, isThenable } from '../core/functions' import { resolveClassProps } from '../core/class-props' @@ -49,6 +50,7 @@ export type BrowserBailoutCallback = (error: Error, errorInfo: ServerErrorInfo) export interface SuspendedBoundary { id: number + treeId: bigint fallbackHTML: string children: ReactNode thenable: Promise @@ -57,6 +59,7 @@ export interface SuspendedBoundary { } export interface WalkOptions { + treeId?: bigint | undefined emit: (chunk: string) => void onHead?: () => void onSuspend?: ((boundary: SuspendedBoundary) => void) | undefined @@ -127,11 +130,12 @@ function walkNode(node: ReactNode, opts: WalkOptions): void { return } if (Array.isArray(node)) { - for (const c of node) walkNode(c, opts) + const treeId = opts.treeId ?? 1n + for (let i = 0; i < node.length; i++) walkNode(node[i], { ...opts, treeId: forkId(treeId, node.length, i) }) return } if (isFunction((node as any)[Symbol.iterator])) { - for (const item of node as Iterable) walkNode(item, opts) + walkNode(Array.from(node as Iterable), opts) return } @@ -418,6 +422,10 @@ function walkComponent( if (opts.onBrowserBailout) { opts = { ...opts, componentStack: `\n at ${(fn as any).displayName || fn.name || 'Anonymous'}${opts.componentStack || ''}` } } + const frame = currentSSRFrame() + const previousTree = frame.treeId, previousLocal = frame.localId + frame.treeId = opts.treeId ?? 1n + frame.localId = 0 try { if ((fn as any).prototype?.isReactComponent) { props = resolveClassProps(fn, props) @@ -434,13 +442,17 @@ function walkComponent( return } const rendered = (fn as any)(props, ref) - walkNode(rendered, opts) + // A component which materialized IDs owns one extra tree slot for its children. + walkNode(rendered, frame.localId ? { ...opts, treeId: forkId(frame.treeId, 1, 0) } : opts) } catch (error) { if (opts.thrownInfo && !opts.thrownInfo.componentStack) opts.thrownInfo.componentStack = opts.componentStack if (isBrowserError(error) && !(error as any).componentStack) { Object.defineProperty(error, 'componentStack', { value: opts.componentStack }) } throw error + } finally { + frame.treeId = previousTree + frame.localId = previousLocal } } @@ -488,7 +500,7 @@ function walkSuspense( } 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, + id, treeId: opts.treeId ?? 1n, fallbackHTML: fallbackParts.join(''), children: props.children, thenable, contextSnapshot, componentStack: thrownInfo.componentStack || opts.componentStack || '', }) return diff --git a/scripts/compatibility-size-budget.mjs b/scripts/compatibility-size-budget.mjs index 5038c35..636e795 100644 --- a/scripts/compatibility-size-budget.mjs +++ b/scripts/compatibility-size-budget.mjs @@ -38,7 +38,58 @@ const allowances = { } } +// Measured SSR correctness costs against the exact PR base. +const ssrBase = '0c7274e17a3fade8396169bc6ed024903cd1252d' +const ssrAllowances = { + "full:dom/client:react18": { + "brotli": 13 + }, + "full:server/index:react19": { + "min": 440, + "gzip": 200, + "brotli": 190 + }, + "full:server/index:react18": { + "min": 440, + "gzip": 201, + "brotli": 207 + }, + "full:client-total:react18": { + "brotli": 48 + }, + "default:server/index:react19": { + "min": 440, + "gzip": 200, + "brotli": 190 + }, + "default:server/index:react18": { + "min": 440, + "gzip": 201, + "brotli": 207 + }, + "default:client-total:react18": { + "brotli": 4 + }, + "nano:dom/client:react18": { + "brotli": 21 + }, + "nano:server/index:react19": { + "min": 440, + "gzip": 200, + "brotli": 190 + }, + "nano:server/index:react18": { + "min": 440, + "gzip": 201, + "brotli": 207 + } +} + 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..de7c206 100644 --- a/scripts/size-check.mjs +++ b/scripts/size-check.mjs @@ -63,11 +63,20 @@ 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": [ + 190, + 188 + ] +} + 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..2f0a7d9 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(440) + expect(compatibilitySizeAllowance(base, server, 'gzip')).toBe(200) + expect(compatibilitySizeAllowance(base, server, 'brotli')).toBe(190) + 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/hydration.test.tsx b/tests/hydration.test.tsx index e2dafc6..6644396 100644 --- a/tests/hydration.test.tsx +++ b/tests/hydration.test.tsx @@ -220,8 +220,8 @@ describe('hydrateRoot', () => { } const html = renderToString() - expect(html).toContain('id="radix-:R0"') - expect(html).toContain('id="radix-:R1"') + expect(html).toContain('id="radix-_R_1_"') + expect(html).toContain('id="radix-_R_2_"') const container = setupWithHTML(html) const originalSecond = container.querySelectorAll('button')[1] @@ -234,7 +234,7 @@ describe('hydrateRoot', () => { const second = container.querySelectorAll('button')[1]! expect(errors).toEqual([]) expect(second).toBe(originalSecond) - expect(second.getAttribute('id')).toBe('radix-:R1') + expect(second.getAttribute('id')).toBe('radix-_R_2_') }) it('attaches event handlers to adopted DOM', () => { @@ -360,7 +360,7 @@ describe('hydrateRoot', () => { expect(errors).toEqual([]) expect(button).toBe(originalButton) expect(button?.id).toBe(serverId) - expect(button?.id).toBe('radix-app-0') + expect(button?.id).toBe('radix-_app-R_0_') }) it('starts hydrated useId sequences from each server-rendered root', () => { @@ -390,12 +390,12 @@ describe('hydrateRoot', () => { expect(errors).toEqual([]) expect(container.querySelector('button')).toBe(originalButton) - expect(container.querySelector('button')?.id).toBe('radix-:R0') + expect(container.querySelector('button')?.id).toBe('radix-_R_0_') expect(container.querySelector('button')?.nextElementSibling).toBe( originalContent, ) expect(container.querySelector('button')?.nextElementSibling?.id).toBe( - 'radix-:R1', + 'radix-_R_0H1_', ) }) @@ -430,8 +430,8 @@ describe('hydrateRoot', () => { expect(errors).toEqual([]) expect(buttons).toEqual(originalButtons) expect(buttons.map((button) => button.id)).toEqual([ - 'radix-:R0', - 'radix-:R1', + 'radix-_R_1_', + 'radix-_R_2_', ]) }) @@ -451,7 +451,7 @@ describe('hydrateRoot', () => { expect(errors).toHaveLength(1) expect(container.querySelector('button')).not.toBe(originalButton) - expect(container.querySelector('button')?.id).toMatch(/^radix-:r[0-9a-z]+$/) + expect(container.querySelector('button')?.id).toMatch(/^radix-_r_[0-9a-v]+_$/) }) it('clears the container if clean client render also throws', () => { diff --git a/tests/mixed-renderer-hooks.test.tsx b/tests/mixed-renderer-hooks.test.tsx index c89efbb..40b14bd 100644 --- a/tests/mixed-renderer-hooks.test.tsx +++ b/tests/mixed-renderer-hooks.test.tsx @@ -28,13 +28,13 @@ describe('hooks across client and server rendering', () => { const first = React.useId() expect(renderToString(, { identifierPrefix: 'inner', - })).toBe('inner:inner0') + })).toBe('inner:_innerR_0_') const second = React.useId() return {`${React.useContext(Context)}:${first}:${second}`} } expect(renderToString(, { identifierPrefix: 'outer', - })).toBe('outer:outer0:outer1') + })).toBe('outer:_outerR_0_:_outerR_0H1_') expect(Context._currentValue).toBe('default') }) diff --git a/tests/ssr-tree-ids.test.tsx b/tests/ssr-tree-ids.test.tsx new file mode 100644 index 0000000..570f392 --- /dev/null +++ b/tests/ssr-tree-ids.test.tsx @@ -0,0 +1,84 @@ +import { afterEach, expect, it, vi } from 'vitest' +import { Fragment, Suspense, createElement as h, useId } from 'react' +import { hydrateRoot, createRoot } from 'react-dom/client' +import { flushSync } from 'react-dom' +import { renderToReadableStream, renderToString } from 'react-dom/server' + +const roots: Array<{ unmount(): void }> = [] +afterEach(() => { for (const root of roots.splice(0)) root.unmount(); document.body.innerHTML = '' }) + +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 +} +function deferred() { + let resolve!: () => void + const promise = new Promise(r => { resolve = r }) + let ready = false + return { promise, resolve() { ready = true; resolve() }, read() { if (!ready) throw promise } } +} +function Field({ name = 'field' }: { name?: string }) { + const id = useId(), description = useId() + return +} + +it.each([undefined, 'modern-js-'])('hydrates tree-position IDs after boundaries resolve out of order (prefix %s)', async prefix => { + const a = deferred(), b = deferred() + function AsyncField({ gate }: { gate: ReturnType }) { gate.read(); return } + function Parent() { const id = useId(); return
} + const tree =
{null}{[]}
+ const options = prefix === undefined ? {} : { identifierPrefix: prefix } + const stream = await renderToReadableStream(tree, options) + b.resolve() + await Promise.resolve() + a.resolve() + const container = install(await new Response(stream).text()) + const main = container.firstElementChild + const nodes = [...container.querySelectorAll('input, label, span, section')] + const error = vi.fn() + roots.push(hydrateRoot(container, tree, { ...options, onRecoverableError: error })) + expect(error).not.toHaveBeenCalled() + expect(container.firstElementChild).toBe(main) + expect([...container.querySelectorAll('input, label, span, section')]).toEqual(nodes) + const ids = [...container.querySelectorAll('[id]')].map(node => node.id) + expect(new Set(ids).size).toBe(ids.length) +}) + +it('separates explicit-prefix hydrated IDs from new client IDs and other roots', () => { + const prefix = 'modern-js-' + const tree = (extra = false) =>
{extra && }
+ const container = install(renderToString(tree(), { identifierPrefix: prefix })) + const input = container.querySelector('input')! + const error = vi.fn() + const root = hydrateRoot(container, tree(), { identifierPrefix: prefix, onRecoverableError: error }) + roots.push(root) + flushSync(() => root.render(tree(true))) + const other = document.createElement('div') + document.body.appendChild(other) + const client = createRoot(other, { identifierPrefix: prefix }) + roots.push(client) + flushSync(() => client.render()) + expect(error).not.toHaveBeenCalled() + expect(container.querySelector('input')).toBe(input) + const ids = [...document.querySelectorAll('[id]')].map(node => node.id) + expect(new Set(ids).size).toBe(ids.length) + expect(input.id).toMatch(/^_modern-js-R_/) + expect(other.querySelector('input')!.id).toMatch(/^_modern-js-r_/) +}) + +it('matches nested arrays, empty slots, iterables and useId parents with deep descendants', () => { + function Parent() { return
{[null, [, false], new Set([])]}
} + let tree = h(Parent) + for (let i = 0; i < 40; i++) tree = h(Fragment, null, null, tree) + const container = install(renderToString(tree)) + const div = container.firstElementChild + const error = vi.fn() + roots.push(hydrateRoot(container, tree, { onRecoverableError: error })) + expect(error).not.toHaveBeenCalled() + expect(container.firstElementChild).toBe(div) + const ids = [...container.querySelectorAll('[id]')].map(node => node.id) + expect(new Set(ids).size).toBe(ids.length) +}) From 319d70943b94e59c557cc7ced45bbf440651d3a3 Mon Sep 17 00:00:00 2001 From: Petr Glaser Date: Wed, 28 Oct 2026 17:26:47 +0100 Subject: [PATCH 2/2] test: assert streamed hydration descendant identity Compare every hydrated descendant by reference so identical replacement markup cannot satisfy the adoption regression. Co-Authored-By: Claude Code --- tests/ssr-tree-ids.test.tsx | 4 +++- 1 file changed, 3 insertions(+), 1 deletion(-) diff --git a/tests/ssr-tree-ids.test.tsx b/tests/ssr-tree-ids.test.tsx index 570f392..97fe627 100644 --- a/tests/ssr-tree-ids.test.tsx +++ b/tests/ssr-tree-ids.test.tsx @@ -42,7 +42,9 @@ it.each([undefined, 'modern-js-'])('hydrates tree-position IDs after boundaries roots.push(hydrateRoot(container, tree, { ...options, onRecoverableError: error })) expect(error).not.toHaveBeenCalled() expect(container.firstElementChild).toBe(main) - expect([...container.querySelectorAll('input, label, span, section')]).toEqual(nodes) + const hydratedNodes = [...container.querySelectorAll('input, label, span, section')] + expect(hydratedNodes).toHaveLength(nodes.length) + hydratedNodes.forEach((node, index) => expect(node).toBe(nodes[index])) const ids = [...container.querySelectorAll('[id]')].map(node => node.id) expect(new Set(ids).size).toBe(ids.length) })