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); +}