From 7e471da0914d69b2b1ef9dfe55447ae09942cb5e Mon Sep 17 00:00:00 2001 From: Claude Date: Sun, 19 Jul 2026 00:54:36 +0000 Subject: [PATCH] fix: resolve nested defer() payload references in production builds MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Nested defer() (a deferred server component calling defer() again) was broken in production builds in two ways: - topologicalSort returned dependents before dependencies, so a parent payload was content-hashed and written while still containing the child's temporary UUID, while the child was written under its hashed name. The client's fetch of the referenced payload then 404'd at runtime, and the parent's hash was nondeterministic across builds. The sort now returns dependency order, so every referenced payload's final ID is known before the referencing payload is hashed. - DeferRegistry.loadAll snapshotted the registry once, but in non-SSR builds a nested defer() registers its child while the parent's stream is draining, after the snapshot — so the child payload was never written at all. loadAll now keeps picking up entries registered mid-iteration until none are left. Adds unit tests for processRscComponents covering nested and diamond references, hash determinism, and cycles, and extends the e2e fixtures (SSR and non-SSR) with nested defer() usage plus assertions that no payload request fails. Co-Authored-By: Claude Fable 5 Claude-Session: https://claude.ai/code/session_011JZ7N3pgH9q6i9qKpUcrwn --- .../fixture-ssr-defer/src/DeferredContent.tsx | 13 +- .../src/NestedDeferredContent.tsx | 7 + packages/static/e2e/fixture/src/App.tsx | 4 + .../e2e/fixture/src/DeferredSection.tsx | 14 ++ .../e2e/fixture/src/NestedDeferredSection.tsx | 5 + packages/static/e2e/tests/build.spec.ts | 31 ++++ packages/static/e2e/tests/ssr-defer.spec.ts | 14 ++ .../static/src/build/dependencyGraph.test.ts | 18 +-- packages/static/src/build/dependencyGraph.ts | 10 ++ .../static/src/build/rscProcessor.test.ts | 139 ++++++++++++++++++ packages/static/src/build/rscProcessor.ts | 4 +- packages/static/src/rsc/defer.tsx | 59 +++++--- 12 files changed, 283 insertions(+), 35 deletions(-) create mode 100644 packages/static/e2e/fixture-ssr-defer/src/NestedDeferredContent.tsx create mode 100644 packages/static/e2e/fixture/src/DeferredSection.tsx create mode 100644 packages/static/e2e/fixture/src/NestedDeferredSection.tsx create mode 100644 packages/static/src/build/rscProcessor.test.ts diff --git a/packages/static/e2e/fixture-ssr-defer/src/DeferredContent.tsx b/packages/static/e2e/fixture-ssr-defer/src/DeferredContent.tsx index 1aed12e..47c2679 100644 --- a/packages/static/e2e/fixture-ssr-defer/src/DeferredContent.tsx +++ b/packages/static/e2e/fixture-ssr-defer/src/DeferredContent.tsx @@ -1,3 +1,14 @@ +import { defer } from "@funstack/static/entries/rsc"; +import NestedDeferredContent from "./NestedDeferredContent"; + export default function DeferredContent() { - return

Hello from deferred component

; + const nested = defer(, { + name: "NestedDeferredContent", + }); + return ( + <> +

Hello from deferred component

+ {nested} + + ); } diff --git a/packages/static/e2e/fixture-ssr-defer/src/NestedDeferredContent.tsx b/packages/static/e2e/fixture-ssr-defer/src/NestedDeferredContent.tsx new file mode 100644 index 0000000..22772c2 --- /dev/null +++ b/packages/static/e2e/fixture-ssr-defer/src/NestedDeferredContent.tsx @@ -0,0 +1,7 @@ +export default function NestedDeferredContent() { + return ( +

+ Hello from nested deferred component +

+ ); +} diff --git a/packages/static/e2e/fixture/src/App.tsx b/packages/static/e2e/fixture/src/App.tsx index 86bac07..302a23d 100644 --- a/packages/static/e2e/fixture/src/App.tsx +++ b/packages/static/e2e/fixture/src/App.tsx @@ -1,11 +1,15 @@ +import { defer } from "@funstack/static/entries/rsc"; import { Counter } from "./Counter"; +import DeferredSection from "./DeferredSection"; export default function App() { + const deferred = defer(, { name: "DeferredSection" }); return (

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 new file mode 100644 index 0000000..21ed8da --- /dev/null +++ b/packages/static/e2e/fixture/src/DeferredSection.tsx @@ -0,0 +1,14 @@ +import { defer } from "@funstack/static/entries/rsc"; +import NestedDeferredSection from "./NestedDeferredSection"; + +export default function DeferredSection() { + const nested = defer(, { + name: "NestedDeferredSection", + }); + return ( + <> +

Deferred section content

+ {nested} + + ); +} diff --git a/packages/static/e2e/fixture/src/NestedDeferredSection.tsx b/packages/static/e2e/fixture/src/NestedDeferredSection.tsx new file mode 100644 index 0000000..1407dc5 --- /dev/null +++ b/packages/static/e2e/fixture/src/NestedDeferredSection.tsx @@ -0,0 +1,5 @@ +export default function NestedDeferredSection() { + return ( +

Nested deferred section content

+ ); +} diff --git a/packages/static/e2e/tests/build.spec.ts b/packages/static/e2e/tests/build.spec.ts index cd5e87b..660dbe6 100644 --- a/packages/static/e2e/tests/build.spec.ts +++ b/packages/static/e2e/tests/build.spec.ts @@ -45,6 +45,37 @@ test.describe("Build output verification", () => { expect(rscPayload.length).toBeGreaterThan(0); }); + test("nested deferred payloads resolve without failed requests", async ({ + page, + }) => { + const errors: string[] = []; + const failedPayloadRequests: string[] = []; + + page.on("pageerror", (error) => { + errors.push(error.message); + }); + // In the non-SSR build, deferred content is fetched from separate + // payload files; a payload referencing a payload that was not emitted + // under the referenced name would surface as a failed request here. + page.on("response", (response) => { + if (response.url().includes("/funstack__/") && !response.ok()) { + failedPayloadRequests.push(`${response.status()} ${response.url()}`); + } + }); + + await page.goto("/"); + + await expect(page.getByTestId("deferred-section")).toHaveText( + "Deferred section content", + ); + await expect(page.getByTestId("nested-deferred-section")).toHaveText( + "Nested deferred section content", + ); + + expect(errors).toEqual([]); + expect(failedPayloadRequests).toEqual([]); + }); + test("generates JavaScript bundles in /assets/", async ({ request }) => { const indexResponse = await request.get("/"); const html = await indexResponse.text(); diff --git a/packages/static/e2e/tests/ssr-defer.spec.ts b/packages/static/e2e/tests/ssr-defer.spec.ts index 22a14ec..a05af8c 100644 --- a/packages/static/e2e/tests/ssr-defer.spec.ts +++ b/packages/static/e2e/tests/ssr-defer.spec.ts @@ -9,14 +9,24 @@ test.describe("SSR with defer()", () => { // The deferred component's content should be present in SSR HTML expect(html).toContain("Hello from deferred component"); + // Nested deferred content is server-rendered too + expect(html).toContain("Hello from nested deferred component"); }); test("page renders without errors", async ({ page }) => { const errors: string[] = []; + const failedPayloadRequests: string[] = []; page.on("pageerror", (error) => { errors.push(error.message); }); + // A deferred payload referencing a payload that was not emitted under + // the referenced name would surface as a failed /funstack__/ request. + page.on("response", (response) => { + if (response.url().includes("/funstack__/") && !response.ok()) { + failedPayloadRequests.push(`${response.status()} ${response.url()}`); + } + }); await page.goto("/"); @@ -27,7 +37,11 @@ test.describe("SSR with defer()", () => { await expect(page.getByTestId("deferred-content")).toHaveText( "Hello from deferred component", ); + await expect(page.getByTestId("nested-deferred-content")).toHaveText( + "Hello from nested deferred component", + ); expect(errors).toEqual([]); + expect(failedPayloadRequests).toEqual([]); }); }); diff --git a/packages/static/src/build/dependencyGraph.test.ts b/packages/static/src/build/dependencyGraph.test.ts index 2e52d6c..ba58874 100644 --- a/packages/static/src/build/dependencyGraph.test.ts +++ b/packages/static/src/build/dependencyGraph.test.ts @@ -72,12 +72,12 @@ describe("topologicalSort", () => { expect(result.inCycle).toEqual([]); expect(result.sorted).toHaveLength(3); - // a should come before b, b should come before c + // Dependencies come first: c before b, b before a const indexA = result.sorted.indexOf("a"); const indexB = result.sorted.indexOf("b"); const indexC = result.sorted.indexOf("c"); - expect(indexA).toBeLessThan(indexB); - expect(indexB).toBeLessThan(indexC); + expect(indexC).toBeLessThan(indexB); + expect(indexB).toBeLessThan(indexA); }); it("handles diamond dependency pattern", () => { @@ -103,12 +103,12 @@ describe("topologicalSort", () => { const indexC = result.sorted.indexOf("c"); const indexD = result.sorted.indexOf("d"); - // a should come before b and c - expect(indexA).toBeLessThan(indexB); - expect(indexA).toBeLessThan(indexC); - // b and c should come before d - expect(indexB).toBeLessThan(indexD); - expect(indexC).toBeLessThan(indexD); + // Dependencies come first: d before b and c + expect(indexD).toBeLessThan(indexB); + expect(indexD).toBeLessThan(indexC); + // b and c come before a + expect(indexB).toBeLessThan(indexA); + expect(indexC).toBeLessThan(indexA); }); it("detects simple cycle", () => { diff --git a/packages/static/src/build/dependencyGraph.ts b/packages/static/src/build/dependencyGraph.ts index ce47481..f8ebd81 100644 --- a/packages/static/src/build/dependencyGraph.ts +++ b/packages/static/src/build/dependencyGraph.ts @@ -28,6 +28,12 @@ export function findReferencedIds( * Performs topological sort using Kahn's algorithm. * Returns both the sorted nodes and any nodes that are part of cycles. * + * The sorted nodes are in dependency order: every node appears after all of + * the nodes it references. This lets callers process a node knowing that + * everything it references has already been processed (used by the build to + * finalize content-hashed IDs of referenced payloads before hashing the + * referencing payload). + * * @param dependencies - Map of node ID to the set of IDs it depends on (references) */ export function topologicalSort( @@ -86,5 +92,9 @@ export function topologicalSort( } } + // Kahn's traversal above visits dependents before their dependencies + // (it starts from unreferenced nodes); reverse to get dependency order. + sorted.reverse(); + return { sorted, inCycle }; } diff --git a/packages/static/src/build/rscProcessor.test.ts b/packages/static/src/build/rscProcessor.test.ts new file mode 100644 index 0000000..11d015e --- /dev/null +++ b/packages/static/src/build/rscProcessor.test.ts @@ -0,0 +1,139 @@ +import { describe, it, expect, vi } from "vitest"; +import { processRscComponents } from "./rscProcessor"; + +const dir = "fun__rsc-payload"; + +function id(rawId: string): string { + return `${dir}/${rawId}`; +} + +function streamOf(content: string): ReadableStream { + return new ReadableStream({ + start(controller) { + controller.enqueue(new TextEncoder().encode(content)); + controller.close(); + }, + }); +} + +async function* componentsOf( + items: Array<{ id: string; data: string; name?: string }>, +) { + yield* items; +} + +/** + * Extracts all payload IDs referenced in a content string. + */ +function referencedIds(content: string): string[] { + return [...content.matchAll(/fun__rsc-payload\/[\w-]+/g)].map((m) => m[0]); +} + +describe("processRscComponents", () => { + it("replaces temp IDs with content hashes in app content", async () => { + const a = id("temp-a"); + const result = await processRscComponents( + componentsOf([{ id: a, data: "content of a" }]), + streamOf(`app references ${a}`), + dir, + ); + + expect(result.components).toHaveLength(1); + const finalId = result.components[0]!.finalId; + expect(finalId).not.toBe(a); + expect(result.appRscContent).toBe(`app references ${finalId}`); + }); + + it("finalizes nested references before hashing the referencing payload", async () => { + const parent = id("temp-parent"); + const child = id("temp-child"); + const result = await processRscComponents( + componentsOf([ + { id: parent, data: `parent references ${child}` }, + { id: child, data: "child content" }, + ]), + streamOf(`app references ${parent}`), + dir, + ); + + const finalIds = result.components.map((c) => c.finalId); + for (const component of result.components) { + // No emitted payload may reference a temp ID; every reference must + // point to another emitted payload. + expect(component.finalContent).not.toContain("temp-"); + for (const ref of referencedIds(component.finalContent)) { + expect(finalIds).toContain(ref); + } + } + expect(result.appRscContent).not.toContain("temp-"); + expect(finalIds).toContain(referencedIds(result.appRscContent)[0]); + }); + + it("resolves a diamond of references", async () => { + const top = id("temp-top"); + const left = id("temp-left"); + const right = id("temp-right"); + const bottom = id("temp-bottom"); + const result = await processRscComponents( + componentsOf([ + { id: top, data: `top references ${left} and ${right}` }, + { id: left, data: `left references ${bottom}` }, + { id: right, data: `right references ${bottom}` }, + { id: bottom, data: "bottom content" }, + ]), + streamOf(`app references ${top}`), + dir, + ); + + for (const component of result.components) { + expect(component.finalContent).not.toContain("temp-"); + } + expect(result.appRscContent).not.toContain("temp-"); + }); + + it("produces the same hashes regardless of temp IDs and order", async () => { + async function run(rawParent: string, rawChild: string, flip: boolean) { + const parent = id(rawParent); + const child = id(rawChild); + const items = [ + { id: parent, data: `parent references ${child}` }, + { id: child, data: "child content" }, + ]; + if (flip) items.reverse(); + const result = await processRscComponents( + componentsOf(items), + streamOf(`app references ${parent}`), + dir, + ); + return { + finalIds: new Set(result.components.map((c) => c.finalId)), + appRscContent: result.appRscContent, + }; + } + + const first = await run("temp-parent-1", "temp-child-1", false); + const second = await run("temp-parent-2", "temp-child-2", true); + expect(first.finalIds).toEqual(second.finalIds); + expect(first.appRscContent).toBe(second.appRscContent); + }); + + it("keeps temp IDs for components in cycles and warns", async () => { + const a = id("temp-a"); + const b = id("temp-b"); + const warn = vi.fn(); + const result = await processRscComponents( + componentsOf([ + { id: a, data: `a references ${b}` }, + { id: b, data: `b references ${a}` }, + ]), + streamOf(`app references ${a}`), + dir, + { warn }, + ); + + expect(warn).toHaveBeenCalledTimes(1); + const finalIds = result.components.map((c) => c.finalId).sort(); + expect(finalIds).toEqual([a, b]); + expect(result.appRscContent).toBe(`app references ${a}`); + }); +}); diff --git a/packages/static/src/build/rscProcessor.ts b/packages/static/src/build/rscProcessor.ts index e9a59ae..aeb5c5d 100644 --- a/packages/static/src/build/rscProcessor.ts +++ b/packages/static/src/build/rscProcessor.ts @@ -82,7 +82,9 @@ export async function processRscComponents( } } - // Step 6: Process sorted components in order + // Step 6: Process components in dependency order (dependencies before + // dependents), so that every referenced component's final ID is known + // before the referencing content is hashed and frozen. const processedComponents: ProcessedComponent[] = []; for (const tempId of sorted) { diff --git a/packages/static/src/rsc/defer.tsx b/packages/static/src/rsc/defer.tsx index ebbd0cd..82e56a5 100644 --- a/packages/static/src/rsc/defer.tsx +++ b/packages/static/src/rsc/defer.tsx @@ -105,43 +105,50 @@ export class DeferRegistry { /** * Iterates over all entries in parallel. * Yields results as each stream completes. + * + * Rendering a deferred element may itself call `defer()` (nested defer), + * registering new entries while earlier ones are still draining. Entries + * registered mid-iteration are picked up too, until none are left. */ async *loadAll() { const errors: unknown[] = []; - // Phase 1: Start all entries loading and collect drain promises. - // We use drain promises (which drain stream2 from tee) instead of - // draining stream1 directly, because stream1 may have been locked - // by createFromReadableStream during SSR. - const loadedEntries = Array.from(this.#registry, ([id, entry]) => { - const loaded = this.#loadEntry(entry); - return [id, loaded.drainPromise, entry.name] as const; - }); - - if (loadedEntries.length === 0) return; - type Result = { id: string; data: string; name?: string }; // Completion queue const completed: Array = []; let waiting: (() => void) | undefined; - let remainingCount = loadedEntries.length; + let remainingCount = 0; + const started = new Set(); - const onComplete = (result: Result | { error: unknown }) => { - completed.push(result); - remainingCount--; - waiting?.(); + // Start loading every entry not started yet and track its drain promise. + // We use drain promises (which drain stream2 from tee) instead of + // draining stream1 directly, because stream1 may have been locked + // by createFromReadableStream during SSR. + const startPending = () => { + for (const [id, entry] of this.#registry) { + if (started.has(id)) continue; + started.add(id); + const loaded = this.#loadEntry(entry); + remainingCount++; + loaded.drainPromise.then( + (data) => { + completed.push({ id, data, name: entry.name }); + remainingCount--; + waiting?.(); + }, + (error) => { + completed.push({ error }); + remainingCount--; + waiting?.(); + }, + ); + } }; - // Phase 2: Await drain promises - for (const [id, drainPromise, name] of loadedEntries) { - drainPromise.then( - (data) => onComplete({ id, data, name }), - (error) => onComplete({ error }), - ); - } + startPending(); - // Phase 3: Yield from queue as results arrive + // Yield from queue as results arrive while (remainingCount > 0 || completed.length > 0) { if (completed.length === 0) { await new Promise((r) => { @@ -156,6 +163,10 @@ export class DeferRegistry { yield result; } } + // A drained entry may have registered nested entries during its + // render; any registration happens before its parent's drain promise + // resolves, so once remainingCount hits 0 no new entries can appear. + startPending(); } if (errors.length > 0) {