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