diff --git a/packages/docs/src/pages/api/Defer.mdx b/packages/docs/src/pages/api/Defer.mdx
index 2a5d693..942f9bb 100644
--- a/packages/docs/src/pages/api/Defer.mdx
+++ b/packages/docs/src/pages/api/Defer.mdx
@@ -108,3 +108,9 @@ By default, FUNSTACK Static puts the entire app (``) into one RSC payload
When you use `defer()`, FUNSTACK Static creates **additional RSC payloads** for the rendering result of the element. This results in an additional emit of RSC payload files like `/funstack__/fun__rsc-payload/b5698be72eea3c37`. If you provide a `name` option, the file name will include it (e.g., `/funstack__/fun__rsc-payload/HomePage-b5698be72eea3c37`).
In the main RSC payload, the `defer` call is replaced with a client component ``. This component is responsible for fetching the additional RSC payload from client and renders it when it's ready.
+
+### `useId()` in deferred components
+
+Each deferred payload is rendered by React separately from the main payload. Since `useId()` in Server Components numbers ids per render, FUNSTACK Static gives every deferred payload its own React `identifierPrefix`, so ids from deferred components never collide with ids from the main payload or from other deferred payloads on the same page. Ids from a named deferred payload look like `_HomePage_0S_1_`, and ids from an unnamed one like `_d0S_1_`.
+
+The prefix is numbered per `name`, so providing a `name` also keeps a payload's ids (and therefore the content hash of its payload file) stable when `defer()` calls with other names are added or removed elsewhere in the app.
diff --git a/packages/static/e2e/fixture-ssr-defer/src/App.tsx b/packages/static/e2e/fixture-ssr-defer/src/App.tsx
index 053c5df..41ff6eb 100644
--- a/packages/static/e2e/fixture-ssr-defer/src/App.tsx
+++ b/packages/static/e2e/fixture-ssr-defer/src/App.tsx
@@ -1,5 +1,6 @@
import { defer } from "@funstack/static/entries/rsc";
import DeferredContent from "./DeferredContent";
+import IdProbe from "./IdProbe";
export default function App() {
const deferred = defer(, { name: "DeferredContent" });
@@ -7,7 +8,9 @@ export default function App() {
SSR Defer Test
Server rendered content
+
{deferred}
+
);
}
diff --git a/packages/static/e2e/fixture-ssr-defer/src/DeferredContent.tsx b/packages/static/e2e/fixture-ssr-defer/src/DeferredContent.tsx
index 47c2679..b4d9744 100644
--- a/packages/static/e2e/fixture-ssr-defer/src/DeferredContent.tsx
+++ b/packages/static/e2e/fixture-ssr-defer/src/DeferredContent.tsx
@@ -1,4 +1,5 @@
import { defer } from "@funstack/static/entries/rsc";
+import IdProbe from "./IdProbe";
import NestedDeferredContent from "./NestedDeferredContent";
export default function DeferredContent() {
@@ -8,6 +9,8 @@ export default function DeferredContent() {
return (
<>
Hello from deferred component
+
+
{nested}
>
);
diff --git a/packages/static/e2e/fixture-ssr-defer/src/IdProbe.tsx b/packages/static/e2e/fixture-ssr-defer/src/IdProbe.tsx
new file mode 100644
index 0000000..82fae5f
--- /dev/null
+++ b/packages/static/e2e/fixture-ssr-defer/src/IdProbe.tsx
@@ -0,0 +1,14 @@
+import { useId } from "react";
+
+/**
+ * Renders a server-side `useId()` value as both the element id and a data
+ * attribute, so tests can check that ids from different payloads differ.
+ */
+export default function IdProbe({ label }: { label: string }) {
+ const id = useId();
+ return (
+
+ {id}
+
+ );
+}
diff --git a/packages/static/e2e/fixture-ssr-defer/src/NestedDeferredContent.tsx b/packages/static/e2e/fixture-ssr-defer/src/NestedDeferredContent.tsx
index 22772c2..3626893 100644
--- a/packages/static/e2e/fixture-ssr-defer/src/NestedDeferredContent.tsx
+++ b/packages/static/e2e/fixture-ssr-defer/src/NestedDeferredContent.tsx
@@ -1,7 +1,12 @@
+import IdProbe from "./IdProbe";
+
export default function NestedDeferredContent() {
return (
-
- Hello from nested deferred component
-
+ <>
+
+ Hello from nested deferred component
+
+
+ >
);
}
diff --git a/packages/static/e2e/fixture/src/App.tsx b/packages/static/e2e/fixture/src/App.tsx
index 302a23d..e0d25e7 100644
--- a/packages/static/e2e/fixture/src/App.tsx
+++ b/packages/static/e2e/fixture/src/App.tsx
@@ -1,6 +1,7 @@
import { defer } from "@funstack/static/entries/rsc";
import { Counter } from "./Counter";
import DeferredSection from "./DeferredSection";
+import IdProbe from "./IdProbe";
export default function App() {
const deferred = defer(, { name: "DeferredSection" });
@@ -9,7 +10,9 @@ export default function App() {
E2E Test App
Server rendered content
+
{deferred}
+
);
}
diff --git a/packages/static/e2e/fixture/src/DeferredSection.tsx b/packages/static/e2e/fixture/src/DeferredSection.tsx
index 21ed8da..fe465a3 100644
--- a/packages/static/e2e/fixture/src/DeferredSection.tsx
+++ b/packages/static/e2e/fixture/src/DeferredSection.tsx
@@ -1,4 +1,5 @@
import { defer } from "@funstack/static/entries/rsc";
+import IdProbe from "./IdProbe";
import NestedDeferredSection from "./NestedDeferredSection";
export default function DeferredSection() {
@@ -8,6 +9,7 @@ export default function DeferredSection() {
return (
<>
Deferred section content
+
{nested}
>
);
diff --git a/packages/static/e2e/fixture/src/IdProbe.tsx b/packages/static/e2e/fixture/src/IdProbe.tsx
new file mode 100644
index 0000000..82fae5f
--- /dev/null
+++ b/packages/static/e2e/fixture/src/IdProbe.tsx
@@ -0,0 +1,14 @@
+import { useId } from "react";
+
+/**
+ * Renders a server-side `useId()` value as both the element id and a data
+ * attribute, so tests can check that ids from different payloads differ.
+ */
+export default function IdProbe({ label }: { label: string }) {
+ const id = useId();
+ return (
+
+ {id}
+
+ );
+}
diff --git a/packages/static/e2e/fixture/src/root.tsx b/packages/static/e2e/fixture/src/root.tsx
index abb3869..ae8708d 100644
--- a/packages/static/e2e/fixture/src/root.tsx
+++ b/packages/static/e2e/fixture/src/root.tsx
@@ -1,6 +1,10 @@
+import { useId } from "react";
+
export default function Root({ children }: { children: React.ReactNode }) {
+ // A server useId() in the Root shell; must not collide with the App's.
+ const id = useId();
return (
-
+
E2E Test Fixture
diff --git a/packages/static/e2e/tests-dev/ssr-defer.spec.ts b/packages/static/e2e/tests-dev/ssr-defer.spec.ts
index 65f3df5..39b140d 100644
--- a/packages/static/e2e/tests-dev/ssr-defer.spec.ts
+++ b/packages/static/e2e/tests-dev/ssr-defer.spec.ts
@@ -32,4 +32,21 @@ test.describe("SSR with defer() (dev server)", () => {
expect(errors).toEqual([]);
});
+
+ test("server useId ids are unique across main and deferred payloads in SSR HTML", async ({
+ request,
+ }) => {
+ // Deferred payloads are separate Flight renders; each gets its own
+ // `useId()` prefix so their ids do not repeat the main payload's (#198).
+ const response = await request.get("/", { headers: htmlHeaders });
+ expect(response.ok()).toBe(true);
+ const html = await response.text();
+
+ const ids = [...html.matchAll(/data-probe-id="([^"]+)"/g)].map(
+ (match) => match[1]!,
+ );
+ // main-1, deferred-1, deferred-2, nested-1, main-2
+ expect(ids).toHaveLength(5);
+ expect(new Set(ids).size).toBe(ids.length);
+ });
});
diff --git a/packages/static/e2e/tests/build.spec.ts b/packages/static/e2e/tests/build.spec.ts
index 660dbe6..f9ae612 100644
--- a/packages/static/e2e/tests/build.spec.ts
+++ b/packages/static/e2e/tests/build.spec.ts
@@ -89,4 +89,24 @@ test.describe("Build output verification", () => {
const scriptResponse = await request.get(scriptPath);
expect(scriptResponse.ok()).toBe(true);
});
+ test("server useId ids in the Root shell, App and deferred payloads do not collide", async ({
+ page,
+ }) => {
+ // With SSR off, the Root shell HTML and the client-mounted App come from
+ // separate Flight renders (as do deferred payloads), so each needs its
+ // own `useId()` prefix (#198).
+ await page.goto("/");
+ await expect(page.getByTestId("id-probe-main")).toBeVisible();
+ await expect(page.getByTestId("id-probe-main-after")).toBeVisible();
+ await expect(page.getByTestId("id-probe-deferred")).toBeVisible();
+
+ const ids = await page
+ .locator("[data-probe-id]")
+ .evaluateAll((elements) =>
+ elements.map((element) => element.getAttribute("data-probe-id")),
+ );
+ // (Root shell), main, deferred, main-after
+ expect(ids).toHaveLength(4);
+ expect(new Set(ids).size).toBe(ids.length);
+ });
});
diff --git a/packages/static/e2e/tests/ssr-defer.spec.ts b/packages/static/e2e/tests/ssr-defer.spec.ts
index a05af8c..d98a17e 100644
--- a/packages/static/e2e/tests/ssr-defer.spec.ts
+++ b/packages/static/e2e/tests/ssr-defer.spec.ts
@@ -44,4 +44,75 @@ test.describe("SSR with defer()", () => {
expect(errors).toEqual([]);
expect(failedPayloadRequests).toEqual([]);
});
+
+ // Server `useId()` counts from zero in every Flight render; deferred
+ // payloads are separate renders, so without a per-payload prefix their ids
+ // would repeat the main payload's (#198).
+ const probeLabels = [
+ "main-1",
+ "deferred-1",
+ "deferred-2",
+ "nested-1",
+ "main-2",
+ ];
+
+ test("server useId ids are unique across main and deferred payloads in SSR HTML", async ({
+ request,
+ }) => {
+ const response = await request.get("/");
+ expect(response.ok()).toBe(true);
+ const html = await response.text();
+
+ const ids = [...html.matchAll(/data-probe-id="([^"]+)"/g)].map(
+ (match) => match[1]!,
+ );
+ expect(ids).toHaveLength(probeLabels.length);
+ expect(new Set(ids).size).toBe(ids.length);
+ for (const id of ids) {
+ expect(id).toMatch(/^_[A-Za-z0-9_-]*S_[0-9a-v]+_$/);
+ }
+ });
+
+ test("server useId ids stay unique after hydration", async ({ page }) => {
+ const errors: string[] = [];
+ page.on("pageerror", (error) => {
+ errors.push(error.message);
+ });
+ // A mismatch between SSR HTML and the payloads' ids would be reported
+ // as a hydration error on the console.
+ page.on("console", (message) => {
+ if (message.type() === "error") {
+ errors.push(message.text());
+ }
+ });
+
+ await page.goto("/");
+ for (const label of probeLabels) {
+ await expect(page.getByTestId(`id-probe-${label}`)).toBeVisible();
+ }
+ // Let hydration of the deferred content settle before checking the DOM.
+ await page.waitForTimeout(500);
+
+ const ids = await page
+ .locator("[data-probe-id]")
+ .evaluateAll((elements) =>
+ elements.map((element) => element.getAttribute("data-probe-id")),
+ );
+ expect(ids).toHaveLength(probeLabels.length);
+ expect(new Set(ids).size).toBe(ids.length);
+
+ const duplicateDomIds = await page.evaluate(() => {
+ const seen = new Set();
+ const duplicates: string[] = [];
+ for (const element of document.querySelectorAll("[id]")) {
+ if (seen.has(element.id)) {
+ duplicates.push(element.id);
+ }
+ seen.add(element.id);
+ }
+ return duplicates;
+ });
+ expect(duplicateDomIds).toEqual([]);
+ expect(errors).toEqual([]);
+ });
});
diff --git a/packages/static/src/fs-routes/entries.tsx b/packages/static/src/fs-routes/entries.tsx
index fe3751a..c446d95 100644
--- a/packages/static/src/fs-routes/entries.tsx
+++ b/packages/static/src/fs-routes/entries.tsx
@@ -1,5 +1,9 @@
import { FsRouteSlot } from "#rsc-client";
-import { deferRegistry, registerDeferredPayload } from "../rsc/defer";
+import {
+ deferRegistry,
+ registerDeferredPayload,
+ restoreDeferredPayload,
+} from "../rsc/defer";
import type { GetEntriesResult } from "../entryDefinition";
import {
createFsRoutesEntriesWithHost,
@@ -16,9 +20,7 @@ import {
const rscRuntimeHost: FsRoutesRuntimeHost = {
registerChunk: registerDeferredPayload,
hasChunk: (id) => deferRegistry.has(id),
- restoreChunk: (element, id, name) => {
- deferRegistry.register(element, id, name);
- },
+ restoreChunk: restoreDeferredPayload,
RouteSlot: FsRouteSlot,
};
diff --git a/packages/static/src/rsc/defer.tsx b/packages/static/src/rsc/defer.tsx
index e61dc2e..63de613 100644
--- a/packages/static/src/rsc/defer.tsx
+++ b/packages/static/src/rsc/defer.tsx
@@ -2,7 +2,9 @@ import type { ReactElement, ReactNode } from "react";
import { renderToReadableStream } from "@vitejs/plugin-rsc/react/rsc";
import { DeferredComponent } from "#rsc-client";
import { DeferRegistry } from "./deferRegistry";
+import { DeferIdentifierPrefixes } from "./identifierPrefix";
import { getPayloadIDFor } from "./rscModule";
+import { sanitizeName } from "./sanitizeName";
import { rscPayloadDir } from "virtual:funstack/config";
/**
@@ -17,18 +19,6 @@ export interface DeferOptions {
name?: string;
}
-/**
- * Sanitizes a name for use in file paths.
- * Replaces non-alphanumeric characters with underscores and limits length.
- */
-function sanitizeName(name: string): string {
- return name
- .replace(/[^a-zA-Z0-9_-]/g, "_")
- .replace(/_+/g, "_")
- .replace(/^_|_$/g, "")
- .slice(0, 50);
-}
-
let deferRenderErrorHandler:
((error: unknown, name: string | undefined) => void) | undefined;
@@ -43,12 +33,20 @@ export function setDeferRenderErrorHandler(
deferRenderErrorHandler = handler;
}
-export const deferRegistry = new DeferRegistry((element, name) => {
- const handler = deferRenderErrorHandler;
- return renderToReadableStream(element, {
- onError: handler && ((error: unknown) => handler(error, name)),
- });
-});
+export const deferRegistry = new DeferRegistry(
+ (element, { name, identifierPrefix }) => {
+ const handler = deferRenderErrorHandler;
+ return renderToReadableStream(element, {
+ // Each deferred payload is a Flight render of its own, so without a
+ // prefix its `useId()` ids would repeat those of the main payload and
+ // of other deferred payloads on the same page (#198).
+ identifierPrefix,
+ onError: handler && ((error: unknown) => handler(error, name)),
+ });
+ },
+);
+
+const identifierPrefixes = new DeferIdentifierPrefixes();
/**
* Registers a Server Component element as a separate RSC payload in the
@@ -65,10 +63,23 @@ export function registerDeferredPayload(
? `${sanitizedName}-${crypto.randomUUID()}`
: crypto.randomUUID();
const id = getPayloadIDFor(rawId, rscPayloadDir);
- deferRegistry.register(element, id, name);
+ restoreDeferredPayload(element, id, name);
return id;
}
+/**
+ * Registers a Server Component element in the shared defer registry under
+ * an already-known payload ID (used to re-register entries the dev registry
+ * has evicted). The entry receives a fresh identifier prefix.
+ */
+export function restoreDeferredPayload(
+ element: ReactElement,
+ id: string,
+ name?: string,
+): void {
+ deferRegistry.register(element, id, name, identifierPrefixes.next(name));
+}
+
/**
* Renders given Server Component into a separate RSC payload.
*
diff --git a/packages/static/src/rsc/deferRegistry.test.ts b/packages/static/src/rsc/deferRegistry.test.ts
index 5f190d9..fb8ba0f 100644
--- a/packages/static/src/rsc/deferRegistry.test.ts
+++ b/packages/static/src/rsc/deferRegistry.test.ts
@@ -1,7 +1,11 @@
import { afterEach, beforeEach, describe, expect, it, vi } from "vitest";
import { createElement } from "react";
import type { ReactElement } from "react";
-import { DeferRegistry, devDeferEvictionOptions } from "./deferRegistry";
+import {
+ DeferRegistry,
+ devDeferEvictionOptions,
+ type RenderToStream,
+} from "./deferRegistry";
const { ttlMs } = devDeferEvictionOptions;
@@ -52,6 +56,20 @@ describe("DeferRegistry", () => {
expect(entry?.state.state).toBe("ready");
});
+ it("renders an entry with the name and identifier prefix it was registered with", async () => {
+ const render = vi.fn(fakeRender);
+ registry = new DeferRegistry(render);
+ registry.register(element("hello"), "id1", "Hello", "Hello_0");
+
+ await registry.load("id1")?.drainPromise;
+
+ expect(render).toHaveBeenCalledTimes(1);
+ expect(render.mock.calls[0]![1]).toEqual({
+ name: "Hello",
+ identifierPrefix: "Hello_0",
+ });
+ });
+
describe("evictStale", () => {
it("never evicts pending entries by time", () => {
// A pending entry may be fetched arbitrarily late, e.g. deferred
diff --git a/packages/static/src/rsc/deferRegistry.ts b/packages/static/src/rsc/deferRegistry.ts
index b2cf897..2d834ea 100644
--- a/packages/static/src/rsc/deferRegistry.ts
+++ b/packages/static/src/rsc/deferRegistry.ts
@@ -4,6 +4,10 @@ import { drainStream } from "../util/drainStream";
export interface DeferEntry {
state: DeferEntryState;
name?: string;
+ /**
+ * Identifier prefix the entry was registered with; see `RenderOptions`.
+ */
+ identifierPrefix?: string;
drainPromise?: Promise;
/**
* Timestamp (ms) of the last registration or load of this entry.
@@ -34,14 +38,25 @@ type DeferEntryState =
error: unknown;
};
+export interface RenderOptions {
+ /** The debug name given to `defer()`, if any. */
+ name?: string;
+ /**
+ * React `identifierPrefix` for the render, so that `useId()` ids of this
+ * payload do not collide with those of other payloads on the same page.
+ * Fixed at registration so the entry renders the same ids whenever it is
+ * loaded.
+ */
+ identifierPrefix?: string;
+}
+
/**
* Renders a React element to an RSC payload stream.
* Injected so the registry does not depend on the RSC runtime directly.
- * `name` is the debug name given to `defer()`, if any.
*/
export type RenderToStream = (
element: ReactElement,
- name: string | undefined,
+ options: RenderOptions,
) => ReadableStream;
export interface EvictStaleOptions {
@@ -80,10 +95,16 @@ export class DeferRegistry {
this.#render = render;
}
- register(element: ReactElement, id: string, name?: string) {
+ register(
+ element: ReactElement,
+ id: string,
+ name?: string,
+ identifierPrefix?: string,
+ ) {
this.#registry.set(id, {
state: { element, state: "pending" },
name,
+ identifierPrefix,
lastAccessedAt: Date.now(),
});
}
@@ -101,7 +122,10 @@ export class DeferRegistry {
const { state } = entry;
switch (state.state) {
case "pending": {
- const stream = this.#render(state.element, entry.name);
+ const stream = this.#render(state.element, {
+ name: entry.name,
+ identifierPrefix: entry.identifierPrefix,
+ });
const [stream1, stream2] = stream.tee();
entry.state = { state: "streaming", stream: stream1 };
const drainPromise = drainStream(stream2);
diff --git a/packages/static/src/rsc/entry.tsx b/packages/static/src/rsc/entry.tsx
index 1796cba..94d64b2 100644
--- a/packages/static/src/rsc/entry.tsx
+++ b/packages/static/src/rsc/entry.tsx
@@ -4,6 +4,7 @@ import { devMainRscPath } from "./request";
import { generateAppMarker } from "./marker";
import { deferRegistry, setDeferRenderErrorHandler } from "./defer";
import { devDeferEvictionOptions } from "./deferRegistry";
+import { shellIdentifierPrefix } from "./identifierPrefix";
import { extractIDFromModulePath } from "./rscModule";
import { stripBasePath } from "../util/basePath";
import { urlPathToFileCandidates } from "../util/urlPath";
@@ -108,13 +109,19 @@ async function renderEntryToResponse(
} else {
// SSR off: shell RSC for SSR, full RSC for client
const rscStart = performance.now();
- const shellRscStream = renderToReadableStream({
- root: (
-
-
-
- ),
- });
+ const shellRscStream = renderToReadableStream(
+ {
+ root: (
+
+
+
+ ),
+ },
+ // The shell and the App are separate Flight renders that share one
+ // document in a production build, so the shell's `useId()` ids get
+ // their own prefix (#198). Applied in dev too for parity.
+ { identifierPrefix: shellIdentifierPrefix },
+ );
const clientRscStream = renderToReadableStream({
root: {appNode},
});
@@ -335,6 +342,10 @@ export async function build() {
),
},
{
+ // The shell's HTML stays in the document while the App payload
+ // (rendered separately, without a prefix) is mounted inside it, so
+ // the shell's `useId()` ids need a prefix of their own (#198).
+ identifierPrefix: shellIdentifierPrefix,
onError: createRenderErrorHandler(
renderErrors,
`RSC shell of "${entry.path}"`,
diff --git a/packages/static/src/rsc/identifierPrefix.test.ts b/packages/static/src/rsc/identifierPrefix.test.ts
new file mode 100644
index 0000000..de06375
--- /dev/null
+++ b/packages/static/src/rsc/identifierPrefix.test.ts
@@ -0,0 +1,65 @@
+import { describe, expect, it } from "vitest";
+import {
+ DeferIdentifierPrefixes,
+ shellIdentifierPrefix,
+} from "./identifierPrefix";
+
+describe("DeferIdentifierPrefixes", () => {
+ it("numbers unnamed payloads in registration order", () => {
+ const prefixes = new DeferIdentifierPrefixes();
+
+ expect(prefixes.next(undefined)).toBe("d0");
+ expect(prefixes.next(undefined)).toBe("d1");
+ });
+
+ it("numbers named payloads per name", () => {
+ const prefixes = new DeferIdentifierPrefixes();
+
+ expect(prefixes.next("Sidebar")).toBe("Sidebar_0");
+ expect(prefixes.next("Comments")).toBe("Comments_0");
+ expect(prefixes.next("Sidebar")).toBe("Sidebar_1");
+ expect(prefixes.next(undefined)).toBe("d0");
+ });
+
+ it("sanitizes names and counts names that sanitize alike together", () => {
+ const prefixes = new DeferIdentifierPrefixes();
+
+ expect(prefixes.next("fs-route src/pages/about.tsx {}")).toBe(
+ "fs-route_src_pages_about_tsx_0",
+ );
+ // "Card!" and "Card?" both sanitize to "Card" and must not collide.
+ expect(prefixes.next("Card!")).toBe("Card_0");
+ expect(prefixes.next("Card?")).toBe("Card_1");
+ });
+
+ it("treats a name that sanitizes to nothing as no name", () => {
+ const prefixes = new DeferIdentifierPrefixes();
+
+ expect(prefixes.next("!!!")).toBe("d0");
+ expect(prefixes.next(undefined)).toBe("d1");
+ });
+
+ it("never hands out the same prefix twice, even for ambiguous names", () => {
+ const prefixes = new DeferIdentifierPrefixes();
+ const seen = new Set();
+ const names = [undefined, "d", "d0", "a", "a_1", "a_1_2", "d_0", "shell"];
+ for (let round = 0; round < 15; round++) {
+ for (const name of names) {
+ const prefix = prefixes.next(name);
+ expect(seen.has(prefix)).toBe(false);
+ seen.add(prefix);
+ }
+ }
+ expect(seen.has(shellIdentifierPrefix)).toBe(false);
+ expect(seen.has("")).toBe(false);
+ });
+
+ it("only uses characters that are safe in CSS selectors", () => {
+ const prefixes = new DeferIdentifierPrefixes();
+
+ for (const name of [undefined, "Hello World", "a/b.c:d", "日本語"]) {
+ expect(prefixes.next(name)).toMatch(/^[A-Za-z0-9_-]+$/);
+ }
+ expect(shellIdentifierPrefix).toMatch(/^[A-Za-z0-9_-]+$/);
+ });
+});
diff --git a/packages/static/src/rsc/identifierPrefix.ts b/packages/static/src/rsc/identifierPrefix.ts
new file mode 100644
index 0000000..8fbe247
--- /dev/null
+++ b/packages/static/src/rsc/identifierPrefix.ts
@@ -0,0 +1,50 @@
+import { sanitizeName } from "./sanitizeName";
+
+/**
+ * `useId()` in a Server Component returns `_S__`, where ``
+ * counts from zero in every Flight render (`renderToReadableStream` call)
+ * and `` is the render's `identifierPrefix` option. The main payload
+ * of a page renders without a prefix, so every other Flight render whose
+ * output ends up in the same document must use a prefix of its own, or its
+ * ids collide with the main payload's (#198).
+ *
+ * All prefixes handed out here are distinct from one another and from the
+ * empty prefix, and only contain characters that are safe in CSS selectors
+ * (`[A-Za-z0-9_-]`, like React's own ids).
+ */
+
+/**
+ * Identifier prefix of the Root shell render in SSR-off mode. The shell's
+ * HTML stays in the document while the App payload (rendered separately,
+ * without a prefix) is mounted inside it.
+ */
+export const shellIdentifierPrefix = "shell";
+
+/**
+ * Hands out identifier prefixes for deferred payloads.
+ *
+ * Prefixes are `_` for named payloads and `d` for unnamed ones,
+ * where `` counts registrations with the same (sanitized) name. Counting
+ * per name rather than globally keeps a payload's ids — and so the content
+ * hash of its file in a build — unaffected by `defer()` calls with other
+ * names being added or removed elsewhere in the app.
+ */
+export class DeferIdentifierPrefixes {
+ #counters = new Map();
+
+ /**
+ * Returns the prefix for the next deferred payload registered under
+ * `name` (the raw name given to `defer()`, if any).
+ */
+ next(name: string | undefined): string {
+ // An empty sanitized name is treated like no name, as in the payload ID.
+ const key = name ? sanitizeName(name) : "";
+ const ordinal = this.#counters.get(key) ?? 0;
+ this.#counters.set(key, ordinal + 1);
+ // `_` is unambiguous: `` is the digits after the last
+ // underscore, so two different (name, n) pairs never produce the same
+ // string. `d` contains no underscore and cannot clash with it, nor
+ // with `shellIdentifierPrefix`.
+ return key === "" ? `d${ordinal}` : `${key}_${ordinal}`;
+ }
+}
diff --git a/packages/static/src/rsc/sanitizeName.ts b/packages/static/src/rsc/sanitizeName.ts
new file mode 100644
index 0000000..8a0515b
--- /dev/null
+++ b/packages/static/src/rsc/sanitizeName.ts
@@ -0,0 +1,11 @@
+/**
+ * Sanitizes a `defer()` name for use in file paths and DOM ids.
+ * Replaces non-alphanumeric characters with underscores and limits length.
+ */
+export function sanitizeName(name: string): string {
+ return name
+ .replace(/[^a-zA-Z0-9_-]/g, "_")
+ .replace(/_+/g, "_")
+ .replace(/^_|_$/g, "")
+ .slice(0, 50);
+}