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) {