Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
6 changes: 6 additions & 0 deletions packages/docs/src/pages/api/Defer.mdx
Original file line number Diff line number Diff line change
Expand Up @@ -108,3 +108,9 @@ By default, FUNSTACK Static puts the entire app (`<App />`) into one RSC payload
When you use `defer(<Component />)`, 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 `<DeferredComponent moduleId="fun__rsc-payload/b5698be72eea3c37" />`. 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.
3 changes: 3 additions & 0 deletions packages/static/e2e/fixture-ssr-defer/src/App.tsx
Original file line number Diff line number Diff line change
@@ -1,13 +1,16 @@
import { defer } from "@funstack/static/entries/rsc";
import DeferredContent from "./DeferredContent";
import IdProbe from "./IdProbe";

export default function App() {
const deferred = defer(<DeferredContent />, { name: "DeferredContent" });
return (
<main>
<h1>SSR Defer Test</h1>
<p data-testid="server-rendered">Server rendered content</p>
<IdProbe label="main-1" />
{deferred}
<IdProbe label="main-2" />
</main>
);
}
3 changes: 3 additions & 0 deletions packages/static/e2e/fixture-ssr-defer/src/DeferredContent.tsx
Original file line number Diff line number Diff line change
@@ -1,4 +1,5 @@
import { defer } from "@funstack/static/entries/rsc";
import IdProbe from "./IdProbe";
import NestedDeferredContent from "./NestedDeferredContent";

export default function DeferredContent() {
Expand All @@ -8,6 +9,8 @@ export default function DeferredContent() {
return (
<>
<p data-testid="deferred-content">Hello from deferred component</p>
<IdProbe label="deferred-1" />
<IdProbe label="deferred-2" />
{nested}
</>
);
Expand Down
14 changes: 14 additions & 0 deletions packages/static/e2e/fixture-ssr-defer/src/IdProbe.tsx
Original file line number Diff line number Diff line change
@@ -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 (
<p data-testid={`id-probe-${label}`} data-probe-id={id} id={id}>
{id}
</p>
);
}
Original file line number Diff line number Diff line change
@@ -1,7 +1,12 @@
import IdProbe from "./IdProbe";

export default function NestedDeferredContent() {
return (
<p data-testid="nested-deferred-content">
Hello from nested deferred component
</p>
<>
<p data-testid="nested-deferred-content">
Hello from nested deferred component
</p>
<IdProbe label="nested-1" />
</>
);
}
3 changes: 3 additions & 0 deletions packages/static/e2e/fixture/src/App.tsx
Original file line number Diff line number Diff line change
@@ -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(<DeferredSection />, { name: "DeferredSection" });
Expand All @@ -9,7 +10,9 @@ export default function App() {
<h1>E2E Test App</h1>
<p data-testid="server-rendered">Server rendered content</p>
<Counter />
<IdProbe label="main" />
{deferred}
<IdProbe label="main-after" />
</main>
);
}
2 changes: 2 additions & 0 deletions packages/static/e2e/fixture/src/DeferredSection.tsx
Original file line number Diff line number Diff line change
@@ -1,4 +1,5 @@
import { defer } from "@funstack/static/entries/rsc";
import IdProbe from "./IdProbe";
import NestedDeferredSection from "./NestedDeferredSection";

export default function DeferredSection() {
Expand All @@ -8,6 +9,7 @@ export default function DeferredSection() {
return (
<>
<p data-testid="deferred-section">Deferred section content</p>
<IdProbe label="deferred" />
{nested}
</>
);
Expand Down
14 changes: 14 additions & 0 deletions packages/static/e2e/fixture/src/IdProbe.tsx
Original file line number Diff line number Diff line change
@@ -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 (
<p data-testid={`id-probe-${label}`} data-probe-id={id} id={id}>
{id}
</p>
);
}
6 changes: 5 additions & 1 deletion packages/static/e2e/fixture/src/root.tsx
Original file line number Diff line number Diff line change
@@ -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 (
<html lang="en">
<html lang="en" data-probe-id={id}>
<head>
<meta charSet="UTF-8" />
<title>E2E Test Fixture</title>
Expand Down
17 changes: 17 additions & 0 deletions packages/static/e2e/tests-dev/ssr-defer.spec.ts
Original file line number Diff line number Diff line change
Expand Up @@ -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);
});
});
20 changes: 20 additions & 0 deletions packages/static/e2e/tests/build.spec.ts
Original file line number Diff line number Diff line change
Expand Up @@ -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")),
);
// <html> (Root shell), main, deferred, main-after
expect(ids).toHaveLength(4);
expect(new Set(ids).size).toBe(ids.length);
});
});
71 changes: 71 additions & 0 deletions packages/static/e2e/tests/ssr-defer.spec.ts
Original file line number Diff line number Diff line change
Expand Up @@ -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<string>();
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([]);
});
});
10 changes: 6 additions & 4 deletions packages/static/src/fs-routes/entries.tsx
Original file line number Diff line number Diff line change
@@ -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,
Expand All @@ -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,
};

Expand Down
49 changes: 30 additions & 19 deletions packages/static/src/rsc/defer.tsx
Original file line number Diff line number Diff line change
Expand Up @@ -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";

/**
Expand All @@ -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;

Expand All @@ -43,12 +33,20 @@ export function setDeferRenderErrorHandler(
deferRenderErrorHandler = handler;
}

export const deferRegistry = new DeferRegistry((element, name) => {
const handler = deferRenderErrorHandler;
return renderToReadableStream<ReactNode>(element, {
onError: handler && ((error: unknown) => handler(error, name)),
});
});
export const deferRegistry = new DeferRegistry(
(element, { name, identifierPrefix }) => {
const handler = deferRenderErrorHandler;
return renderToReadableStream<ReactNode>(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
Expand All @@ -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.
*
Expand Down
20 changes: 19 additions & 1 deletion packages/static/src/rsc/deferRegistry.test.ts
Original file line number Diff line number Diff line change
@@ -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;

Expand Down Expand Up @@ -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<RenderToStream>(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
Expand Down
Loading
Loading