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 ``
+ const b = scriptId(boundaryId(id, identifierPrefix)), s = scriptId(segmentId(id, identifierPrefix))
+ return ``
}
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(``)
+ emit(``)
}
if (state.aborted) { defer(state.reason); return }
@@ -236,7 +237,7 @@ function streamBoundary(
if (hints) emit(hints)
}
- emit(`
${parts.join('')}
${revealScript(b.id, nonce, styles)}`)
+ emit(`
${parts.join('')}
${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(`