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
13 changes: 12 additions & 1 deletion packages/static/e2e/fixture-ssr-defer/src/DeferredContent.tsx
Original file line number Diff line number Diff line change
@@ -1,3 +1,14 @@
import { defer } from "@funstack/static/entries/rsc";
import NestedDeferredContent from "./NestedDeferredContent";

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

export default function DeferredSection() {
const nested = defer(<NestedDeferredSection />, {
name: "NestedDeferredSection",
});
return (
<>
<p data-testid="deferred-section">Deferred section content</p>
{nested}
</>
);
}
5 changes: 5 additions & 0 deletions packages/static/e2e/fixture/src/NestedDeferredSection.tsx
Original file line number Diff line number Diff line change
@@ -0,0 +1,5 @@
export default function NestedDeferredSection() {
return (
<p data-testid="nested-deferred-section">Nested deferred section content</p>
);
}
31 changes: 31 additions & 0 deletions packages/static/e2e/tests/build.spec.ts
Original file line number Diff line number Diff line change
Expand Up @@ -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();
Expand Down
14 changes: 14 additions & 0 deletions packages/static/e2e/tests/ssr-defer.spec.ts
Original file line number Diff line number Diff line change
Expand Up @@ -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("/");

Expand All @@ -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([]);
});
});
18 changes: 9 additions & 9 deletions packages/static/src/build/dependencyGraph.test.ts
Original file line number Diff line number Diff line change
Expand Up @@ -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", () => {
Expand All @@ -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", () => {
Expand Down
10 changes: 10 additions & 0 deletions packages/static/src/build/dependencyGraph.ts
Original file line number Diff line number Diff line change
Expand Up @@ -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(
Expand Down Expand Up @@ -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 };
}
139 changes: 139 additions & 0 deletions packages/static/src/build/rscProcessor.test.ts
Original file line number Diff line number Diff line change
@@ -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<Uint8Array> {
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}`);
});
});
4 changes: 3 additions & 1 deletion packages/static/src/build/rscProcessor.ts
Original file line number Diff line number Diff line change
Expand Up @@ -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) {
Expand Down
Loading