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
17 changes: 1 addition & 16 deletions packages/static/src/build/buildApp.ts
Original file line number Diff line number Diff line change
Expand Up @@ -5,6 +5,7 @@ import type { ViteBuilder, MinimalPluginContextWithoutEnvironment } from "vite";
import { rscPayloadPlaceholder, getRscPayloadPath } from "./rscPath";
import { getModulePathFor } from "../rsc/rscModule";
import { processRscComponents } from "./rscProcessor";
import { replaceIdsInContent } from "./idReplacement";
import { computeContentHash } from "./contentHash";
import { drainStream } from "../util/drainStream";
import { validateEntryPath, checkDuplicatePaths } from "./validateEntryPath";
Expand Down Expand Up @@ -91,22 +92,6 @@ function normalizeBase(base: string): string {
return normalized === "/" ? "" : normalized;
}

/**
* Replaces temporary IDs with final hashed IDs in content.
*/
function replaceIdsInContent(
content: string,
idMapping: Map<string, string>,
): string {
let result = content;
for (const [oldId, newId] of idMapping) {
if (oldId !== newId) {
result = result.replaceAll(oldId, newId);
}
}
return result;
}

async function buildSingleEntry(
result: EntryBuildResult,
idMapping: Map<string, string>,
Expand Down
3 changes: 2 additions & 1 deletion packages/static/src/build/dependencyGraph.ts
Original file line number Diff line number Diff line change
Expand Up @@ -85,9 +85,10 @@ export function topologicalSort(
}

// Nodes not in sorted result are part of cycles
const sortedSet = new Set(sorted);
const inCycle: string[] = [];
for (const node of allNodes) {
if (!sorted.includes(node)) {
if (!sortedSet.has(node)) {
inCycle.push(node);
}
}
Expand Down
77 changes: 77 additions & 0 deletions packages/static/src/build/idReplacement.test.ts
Original file line number Diff line number Diff line change
@@ -0,0 +1,77 @@
import { describe, it, expect } from "vitest";
import { replaceIdsInContent } from "./idReplacement";

describe("replaceIdsInContent", () => {
it("replaces a single mapped ID", () => {
const idMapping = new Map([["temp-1", "final-1"]]);

const result = replaceIdsInContent("ref to temp-1 here", idMapping);

expect(result).toBe("ref to final-1 here");
});

it("replaces multiple different IDs in one content", () => {
const idMapping = new Map([
["temp-1", "final-1"],
["temp-2", "final-2"],
]);

const result = replaceIdsInContent("temp-1 and temp-2", idMapping);

expect(result).toBe("final-1 and final-2");
});

it("replaces all occurrences of the same ID", () => {
const idMapping = new Map([["temp-1", "final-1"]]);

const result = replaceIdsInContent("temp-1, temp-1, temp-1", idMapping);

expect(result).toBe("final-1, final-1, final-1");
});

it("skips identity mappings", () => {
const idMapping = new Map([
["temp-1", "temp-1"],
["temp-2", "final-2"],
]);

const result = replaceIdsInContent("temp-1 and temp-2", idMapping);

expect(result).toBe("temp-1 and final-2");
});

it("returns content unchanged when mapping is empty", () => {
const content = "no changes expected";

expect(replaceIdsInContent(content, new Map())).toBe(content);
});

it("returns content unchanged when mapping only has identity entries", () => {
const idMapping = new Map([["temp-1", "temp-1"]]);
const content = "temp-1 stays as is";

expect(replaceIdsInContent(content, idMapping)).toBe(content);
});

it("escapes regex special characters in IDs", () => {
const idMapping = new Map([["fun__rsc-payload/a.b+c(1)", "final-1"]]);

const result = replaceIdsInContent(
"see fun__rsc-payload/a.b+c(1) and fun__rsc-payload/aXb+c(1)",
idMapping,
);

expect(result).toBe("see final-1 and fun__rsc-payload/aXb+c(1)");
});

it("does not re-replace IDs produced by earlier replacements", () => {
const idMapping = new Map([
["temp-1", "temp-2"],
["temp-2", "final-2"],
]);

const result = replaceIdsInContent("temp-1 temp-2", idMapping);

expect(result).toBe("temp-2 final-2");
});
});
31 changes: 31 additions & 0 deletions packages/static/src/build/idReplacement.ts
Original file line number Diff line number Diff line change
@@ -0,0 +1,31 @@
/**
* Escapes characters that have special meaning in regular expressions.
*/
function escapeRegExp(str: string): string {
return str.replace(/[.*+?^${}()|[\]\\]/g, "\\$&");
}

/**
* Replaces temporary IDs with final hashed IDs in content.
*
* All replacements happen in a single pass over the content using one
* alternation regex, instead of one full-string scan per mapping entry.
* Identity mappings (oldId === newId) are skipped.
*/
export function replaceIdsInContent(
content: string,
idMapping: Map<string, string>,
): string {
let pattern = "";
for (const [oldId, newId] of idMapping) {
if (oldId !== newId) {
pattern += (pattern === "" ? "" : "|") + escapeRegExp(oldId);
}
}
if (pattern === "") {
return content;
}
return content.replace(new RegExp(pattern, "g"), (match) =>
idMapping.get(match)!,
);
}
17 changes: 3 additions & 14 deletions packages/static/src/build/rscProcessor.ts
Original file line number Diff line number Diff line change
@@ -1,6 +1,7 @@
import { getPayloadIDFor } from "../rsc/rscModule";
import { computeContentHash } from "./contentHash";
import { findReferencedIds, topologicalSort } from "./dependencyGraph";
import { replaceIdsInContent } from "./idReplacement";

export interface ProcessedComponent {
finalId: string;
Expand Down Expand Up @@ -80,14 +81,8 @@ export async function processRscComponents(
const processedComponents: ProcessedComponent[] = [];

for (const tempId of sorted) {
let content = components.get(tempId)!;

// Replace all already-finalized temp IDs with their hash-based IDs
for (const [oldId, newId] of idMapping) {
if (oldId !== newId) {
content = content.replaceAll(oldId, newId);
}
}
const content = replaceIdsInContent(components.get(tempId)!, idMapping);

// Compute content hash for this component
const contentHash = await computeContentHash(content);
Expand All @@ -105,14 +100,8 @@ export async function processRscComponents(

// Add cycle members to processed components (with original IDs)
for (const tempId of inCycle) {
let content = components.get(tempId)!;

// Replace finalized IDs in cycle member content
for (const [oldId, newId] of idMapping) {
if (oldId !== newId) {
content = content.replaceAll(oldId, newId);
}
}
const content = replaceIdsInContent(components.get(tempId)!, idMapping);

processedComponents.push({
finalId: tempId, // Keep original temp ID
Expand Down