Skip to content

Commit b1ea5d6

Browse files
Merge pull request #1027 from lirian-su-opus/fix/bundler-runtime-replace-special-patterns
2 parents ee4e088 + 69b7965 commit b1ea5d6

2 files changed

Lines changed: 58 additions & 1 deletion

File tree

packages/core/src/compiler/htmlBundler.test.ts

Lines changed: 50 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -5,6 +5,7 @@ import { join } from "node:path";
55
import { parseHTML } from "linkedom";
66
import { describe, it, expect } from "vitest";
77
import { bundleToSingleHtml } from "./htmlBundler";
8+
import { getHyperframeRuntimeScript } from "../generated/runtime-inline";
89

910
function makeTempProject(files: Record<string, string>): string {
1011
const dir = mkdtempSync(join(tmpdir(), "hf-bundler-test-"));
@@ -82,6 +83,55 @@ describe("bundleToSingleHtml", () => {
8283
expect(innerLength).toBeGreaterThan(1000);
8384
});
8485

86+
it("preserves `$&` replace-pattern characters in the inlined runtime body", async () => {
87+
// Regression guard: `injectInterceptor` used to insert the runtime via
88+
// `sanitized.replace("</head>", `${tag}\n</head>`)`. `String.prototype.replace`'s
89+
// second argument is a substitution template — `$&` expands to the matched
90+
// substring (here, `</head>`). The minified runtime IIFE contains legitimate
91+
// `$&` sequences (e.g. `if(te&&$&!y.hasAttribute(...))`), so the bundler
92+
// silently injected stray `</head>` tags inside the runtime, producing a JS
93+
// SyntaxError that broke every timeline in the bundle. Switching to the
94+
// function-replacer form passes the runtime body through verbatim.
95+
// Use a document with an explicit `<head>` so the bundler takes the
96+
// `sanitized.replace("</head>", …)` injection path — the only branch that
97+
// exercises the substitution-template behavior. Authoring without a
98+
// `<head>` falls back to slice+concat (safe but doesn't catch this bug).
99+
const dir = makeTempProject({
100+
"index.html": `<!doctype html>
101+
<html><head></head><body>
102+
<div data-composition-id="root" data-width="320" data-height="180"></div>
103+
</body></html>`,
104+
});
105+
106+
const previousUrl = process.env.HYPERFRAME_RUNTIME_URL;
107+
delete process.env.HYPERFRAME_RUNTIME_URL;
108+
let bundled: string;
109+
try {
110+
bundled = await bundleToSingleHtml(dir);
111+
} finally {
112+
if (previousUrl !== undefined) process.env.HYPERFRAME_RUNTIME_URL = previousUrl;
113+
}
114+
115+
const original = getHyperframeRuntimeScript();
116+
// Sanity: the built runtime exercises this regression (no `$&` means the
117+
// test would tautologically pass even with the broken implementation).
118+
expect(original).toContain("$&");
119+
120+
const runtimeBlock = bundled.match(
121+
/<script\b[^>]*data-hyperframes-preview-runtime[^>]*>([\s\S]*?)<\/script>/i,
122+
);
123+
expect(runtimeBlock).not.toBeNull();
124+
const runtimeBody = runtimeBlock?.[1] ?? "";
125+
expect(runtimeBody).toBe(original);
126+
127+
// Defense in depth: the entire bundled document should contain exactly one
128+
// `</head>` — the real closing tag. Before the fix, every `$&` in the
129+
// runtime expanded to an extra `</head>` inside the inlined IIFE,
130+
// producing a `Unexpected token '<'` SyntaxError at parse time.
131+
const headCloses = bundled.match(/<\/head>/g) ?? [];
132+
expect(headCloses.length).toBe(1);
133+
});
134+
85135
it("preserves chunk integrity when a chunk ends with a line comment (ASI hazard guard)", async () => {
86136
// Regression guard for the joinJsChunks helper. If a chunk ends with `// ...`
87137
// and we naively appended `;` on the same line, the appended semicolon would

packages/core/src/compiler/htmlBundler.ts

Lines changed: 8 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -52,7 +52,14 @@ function injectInterceptor(html: string, runtimeMode: "inline" | "placeholder" =
5252
tag = `<script ${RUNTIME_BOOTSTRAP_ATTR}="1">${inlinedRuntime}</script>`;
5353
}
5454
if (sanitized.includes("</head>")) {
55-
return sanitized.replace("</head>", `${tag}\n</head>`);
55+
// Use a function replacer so `String.prototype.replace`'s substitution
56+
// patterns (`$&`, `$$`, `$'`, `` $` ``, `$1`–`$99`) inside the inlined
57+
// runtime IIFE are passed through verbatim. The minified runtime
58+
// contains the literal sequence `$&` as part of legitimate JS, and
59+
// the older `(pattern, string)` form would expand it to the matched
60+
// `</head>`, silently corrupting the runtime and breaking every
61+
// timeline in the bundle with a parse-time SyntaxError.
62+
return sanitized.replace("</head>", () => `${tag}\n</head>`);
5663
}
5764
const htmlOpenMatch = sanitized.match(/<html\b[^>]*>/i);
5865
if (htmlOpenMatch?.index != null) {

0 commit comments

Comments
 (0)