diff --git a/packages/static/src/build/buildApp.ts b/packages/static/src/build/buildApp.ts index 5c1108f..6c27ebc 100644 --- a/packages/static/src/build/buildApp.ts +++ b/packages/static/src/build/buildApp.ts @@ -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"; @@ -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 { - 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, diff --git a/packages/static/src/build/dependencyGraph.ts b/packages/static/src/build/dependencyGraph.ts index f8ebd81..c3d1fbf 100644 --- a/packages/static/src/build/dependencyGraph.ts +++ b/packages/static/src/build/dependencyGraph.ts @@ -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); } } diff --git a/packages/static/src/build/idReplacement.test.ts b/packages/static/src/build/idReplacement.test.ts new file mode 100644 index 0000000..53688cc --- /dev/null +++ b/packages/static/src/build/idReplacement.test.ts @@ -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"); + }); +}); diff --git a/packages/static/src/build/idReplacement.ts b/packages/static/src/build/idReplacement.ts new file mode 100644 index 0000000..f6fdf41 --- /dev/null +++ b/packages/static/src/build/idReplacement.ts @@ -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 { + 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)!, + ); +} diff --git a/packages/static/src/build/rscProcessor.ts b/packages/static/src/build/rscProcessor.ts index 79cdf5f..7e5e4a3 100644 --- a/packages/static/src/build/rscProcessor.ts +++ b/packages/static/src/build/rscProcessor.ts @@ -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; @@ -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); @@ -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