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
7 changes: 0 additions & 7 deletions packages/static/src/build/buildApp.ts
Original file line number Diff line number Diff line change
Expand Up @@ -51,15 +51,8 @@ export async function buildApp(
}

// Process all deferred components once across all entries.
// We pass a dummy empty stream since we handle per-entry RSC payloads separately.
const dummyStream = new ReadableStream<Uint8Array>({
start(controller) {
controller.close();
},
});
const { components, idMapping } = await processRscComponents(
deferRegistry.loadAll(),
dummyStream,
options.rscPayloadDir,
context,
);
Expand Down
38 changes: 12 additions & 26 deletions packages/static/src/build/rscProcessor.test.ts
Original file line number Diff line number Diff line change
Expand Up @@ -7,15 +7,6 @@ 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 }>,
) {
Expand All @@ -30,18 +21,17 @@ function referencedIds(content: string): string[] {
}

describe("processRscComponents", () => {
it("replaces temp IDs with content hashes in app content", async () => {
it("maps temp IDs to content hashes", 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}`);
expect(result.idMapping.get(a)).toBe(finalId);
});

it("finalizes nested references before hashing the referencing payload", async () => {
Expand All @@ -52,7 +42,6 @@ describe("processRscComponents", () => {
{ id: parent, data: `parent references ${child}` },
{ id: child, data: "child content" },
]),
streamOf(`app references ${parent}`),
dir,
);

Expand All @@ -65,8 +54,8 @@ describe("processRscComponents", () => {
expect(finalIds).toContain(ref);
}
}
expect(result.appRscContent).not.toContain("temp-");
expect(finalIds).toContain(referencedIds(result.appRscContent)[0]);
expect(finalIds).toContain(result.idMapping.get(parent));
expect(finalIds).toContain(result.idMapping.get(child));
});

it("resolves a diamond of references", async () => {
Expand All @@ -81,14 +70,15 @@ describe("processRscComponents", () => {
{ 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-");
for (const finalId of result.idMapping.values()) {
expect(finalId).not.toContain("temp-");
}
});

it("produces the same hashes regardless of temp IDs and order", async () => {
Expand All @@ -100,21 +90,17 @@ describe("processRscComponents", () => {
{ id: child, data: "child content" },
];
if (flip) items.reverse();
const result = await processRscComponents(
componentsOf(items),
streamOf(`app references ${parent}`),
dir,
);
const result = await processRscComponents(componentsOf(items), dir);
return {
finalIds: new Set(result.components.map((c) => c.finalId)),
appRscContent: result.appRscContent,
parentFinalId: result.idMapping.get(parent),
};
}

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);
expect(first.parentFinalId).toBe(second.parentFinalId);
});

it("keeps temp IDs for components in cycles and warns", async () => {
Expand All @@ -126,14 +112,14 @@ describe("processRscComponents", () => {
{ 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}`);
expect(result.idMapping.get(a)).toBe(a);
expect(result.idMapping.get(b)).toBe(b);
});
});
24 changes: 4 additions & 20 deletions packages/static/src/build/rscProcessor.ts
Original file line number Diff line number Diff line change
@@ -1,4 +1,3 @@
import { drainStream } from "../util/drainStream";
import { getPayloadIDFor } from "../rsc/rscModule";
import { computeContentHash } from "./contentHash";
import { findReferencedIds, topologicalSort } from "./dependencyGraph";
Expand All @@ -11,7 +10,6 @@ export interface ProcessedComponent {

export interface ProcessResult {
components: ProcessedComponent[];
appRscContent: string;
idMapping: Map<string, string>;
}

Expand All @@ -25,13 +23,11 @@ interface RawComponent {
* Processes RSC components by replacing temporary UUIDs with content-based hashes.
*
* @param deferRegistryIterator - Iterator yielding components with { id, data }
* @param appRscStream - The main RSC stream
* @param rscPayloadDir - Directory name used as a prefix for RSC payload IDs (e.g. "fun__rsc-payload")
* @param context - Optional context for logging warnings
*/
export async function processRscComponents(
deferRegistryIterator: AsyncIterable<RawComponent>,
appRscStream: ReadableStream,
rscPayloadDir: string,
context?: { warn: (message: string) => void },
): Promise<ProcessResult> {
Expand All @@ -43,21 +39,17 @@ export async function processRscComponents(
componentNames.set(id, name);
}

// Step 2: Drain appRsc stream to string
let appRscContent = await drainStream(appRscStream);

// If no components, return early
if (components.size === 0) {
return {
components: [],
appRscContent,
idMapping: new Map(),
};
}

const allIds = new Set(components.keys());

// Step 3: Build dependency graph
// Step 2: Build dependency graph
// For each component, find which other component IDs appear in its content
const dependencies = new Map<string, Set<string>>();
for (const [id, content] of components) {
Expand All @@ -67,10 +59,10 @@ export async function processRscComponents(
dependencies.set(id, refs);
}

// Step 4: Topologically sort components
// Step 3: Topologically sort components
const { sorted, inCycle } = topologicalSort(dependencies);

// Step 5: Handle cycles - warn and keep original temp IDs
// Step 4: Handle cycles - warn and keep original temp IDs
const idMapping = new Map<string, string>();

if (inCycle.length > 0) {
Expand All @@ -82,7 +74,7 @@ export async function processRscComponents(
}
}

// Step 6: Process components in dependency order (dependencies before
// Step 5: 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[] = [];
Expand Down Expand Up @@ -129,16 +121,8 @@ export async function processRscComponents(
});
}

// Step 7: Process appRsc - replace all temp IDs with final IDs
for (const [oldId, newId] of idMapping) {
if (oldId !== newId) {
appRscContent = appRscContent.replaceAll(oldId, newId);
}
}

return {
components: processedComponents,
appRscContent,
idMapping,
};
}