fix(core): preserve replace-pattern characters in bundled runtime IIFE

`injectInterceptor` used `String.prototype.replace(target, replacement)`
to inject the runtime `<script>` before `</head>`. The replacement
string is a substitution template — `$&` expands to the matched
substring, and the minified runtime IIFE contains legitimate `$&`
sequences (e.g. `if(te&&$&!y.hasAttribute(...))`), so every `$&` in
the body was silently rewritten to `</head>`, producing
`Unexpected token '<'` SyntaxErrors and breaking every timeline in
the bundle.

Switch to the function-replacer form so the runtime body is passed
through verbatim. Add a regression test that diffs the bundled
runtime body against `getHyperframeRuntimeScript()` and asserts only
one `</head>` survives in the document — the test exercises the
`<head>`-present injection path (the only branch that uses the
substitution template; the no-`<head>` fallback uses slice+concat
and was unaffected).

Only the bundler is affected — `producer/fileServer.ts` already uses
the function form via `injectScriptsIntoHtml` in
`htmlDocument.ts`, so render output was correct. Snapshot, preview,
studio, layout, and validate all consume `bundleToSingleHtml` and
were broken before this fix.

🤖 Generated with [Claude Code](https://claude.com/claude-code)

Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
This commit is contained in:
Lirian Su
2026-05-22 15:06:11 +08:00
co-authored by Claude Opus 4.7
parent ee4e088434
commit 69b7965446
2 changed files with 58 additions and 1 deletions
@@ -5,6 +5,7 @@ import { join } from "node:path";
import { parseHTML } from "linkedom"; import { parseHTML } from "linkedom";
import { describe, it, expect } from "vitest"; import { describe, it, expect } from "vitest";
import { bundleToSingleHtml } from "./htmlBundler"; import { bundleToSingleHtml } from "./htmlBundler";
import { getHyperframeRuntimeScript } from "../generated/runtime-inline";
function makeTempProject(files: Record<string, string>): string { function makeTempProject(files: Record<string, string>): string {
const dir = mkdtempSync(join(tmpdir(), "hf-bundler-test-")); const dir = mkdtempSync(join(tmpdir(), "hf-bundler-test-"));
@@ -82,6 +83,55 @@ describe("bundleToSingleHtml", () => {
expect(innerLength).toBeGreaterThan(1000); expect(innerLength).toBeGreaterThan(1000);
}); });
it("preserves `$&` replace-pattern characters in the inlined runtime body", async () => {
// Regression guard: `injectInterceptor` used to insert the runtime via
// `sanitized.replace("</head>", `${tag}\n</head>`)`. `String.prototype.replace`'s
// second argument is a substitution template — `$&` expands to the matched
// substring (here, `</head>`). The minified runtime IIFE contains legitimate
// `$&` sequences (e.g. `if(te&&$&!y.hasAttribute(...))`), so the bundler
// silently injected stray `</head>` tags inside the runtime, producing a JS
// SyntaxError that broke every timeline in the bundle. Switching to the
// function-replacer form passes the runtime body through verbatim.
// Use a document with an explicit `<head>` so the bundler takes the
// `sanitized.replace("</head>", …)` injection path — the only branch that
// exercises the substitution-template behavior. Authoring without a
// `<head>` falls back to slice+concat (safe but doesn't catch this bug).
const dir = makeTempProject({
"index.html": `<!doctype html>
<html><head></head><body>
<div data-composition-id="root" data-width="320" data-height="180"></div>
</body></html>`,
});
const previousUrl = process.env.HYPERFRAME_RUNTIME_URL;
delete process.env.HYPERFRAME_RUNTIME_URL;
let bundled: string;
try {
bundled = await bundleToSingleHtml(dir);
} finally {
if (previousUrl !== undefined) process.env.HYPERFRAME_RUNTIME_URL = previousUrl;
}
const original = getHyperframeRuntimeScript();
// Sanity: the built runtime exercises this regression (no `$&` means the
// test would tautologically pass even with the broken implementation).
expect(original).toContain("$&");
const runtimeBlock = bundled.match(
/<script\b[^>]*data-hyperframes-preview-runtime[^>]*>([\s\S]*?)<\/script>/i,
);
expect(runtimeBlock).not.toBeNull();
const runtimeBody = runtimeBlock?.[1] ?? "";
expect(runtimeBody).toBe(original);
// Defense in depth: the entire bundled document should contain exactly one
// `</head>` — the real closing tag. Before the fix, every `$&` in the
// runtime expanded to an extra `</head>` inside the inlined IIFE,
// producing a `Unexpected token '<'` SyntaxError at parse time.
const headCloses = bundled.match(/<\/head>/g) ?? [];
expect(headCloses.length).toBe(1);
});
it("preserves chunk integrity when a chunk ends with a line comment (ASI hazard guard)", async () => { it("preserves chunk integrity when a chunk ends with a line comment (ASI hazard guard)", async () => {
// Regression guard for the joinJsChunks helper. If a chunk ends with `// ...` // Regression guard for the joinJsChunks helper. If a chunk ends with `// ...`
// and we naively appended `;` on the same line, the appended semicolon would // and we naively appended `;` on the same line, the appended semicolon would
+8 -1
View File
@@ -52,7 +52,14 @@ function injectInterceptor(html: string, runtimeMode: "inline" | "placeholder" =
tag = `<script ${RUNTIME_BOOTSTRAP_ATTR}="1">${inlinedRuntime}</script>`; tag = `<script ${RUNTIME_BOOTSTRAP_ATTR}="1">${inlinedRuntime}</script>`;
} }
if (sanitized.includes("</head>")) { if (sanitized.includes("</head>")) {
return sanitized.replace("</head>", `${tag}\n</head>`); // Use a function replacer so `String.prototype.replace`'s substitution
// patterns (`$&`, `$$`, `$'`, `` $` ``, `$1``$99`) inside the inlined
// runtime IIFE are passed through verbatim. The minified runtime
// contains the literal sequence `$&` as part of legitimate JS, and
// the older `(pattern, string)` form would expand it to the matched
// `</head>`, silently corrupting the runtime and breaking every
// timeline in the bundle with a parse-time SyntaxError.
return sanitized.replace("</head>", () => `${tag}\n</head>`);
} }
const htmlOpenMatch = sanitized.match(/<html\b[^>]*>/i); const htmlOpenMatch = sanitized.match(/<html\b[^>]*>/i);
if (htmlOpenMatch?.index != null) { if (htmlOpenMatch?.index != null) {