diff --git a/packages/core/src/compiler/htmlBundler.test.ts b/packages/core/src/compiler/htmlBundler.test.ts index 1bab57364..724ad9bfa 100644 --- a/packages/core/src/compiler/htmlBundler.test.ts +++ b/packages/core/src/compiler/htmlBundler.test.ts @@ -5,6 +5,7 @@ import { join } from "node:path"; import { parseHTML } from "linkedom"; import { describe, it, expect } from "vitest"; import { bundleToSingleHtml } from "./htmlBundler"; +import { getHyperframeRuntimeScript } from "../generated/runtime-inline"; function makeTempProject(files: Record): string { const dir = mkdtempSync(join(tmpdir(), "hf-bundler-test-")); @@ -82,6 +83,55 @@ describe("bundleToSingleHtml", () => { 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("", `${tag}\n`)`. `String.prototype.replace`'s + // second argument is a substitution template — `$&` expands to the matched + // substring (here, ``). The minified runtime IIFE contains legitimate + // `$&` sequences (e.g. `if(te&&$&!y.hasAttribute(...))`), so the bundler + // silently injected stray `` 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 `` so the bundler takes the + // `sanitized.replace("", …)` injection path — the only branch that + // exercises the substitution-template behavior. Authoring without a + // `` falls back to slice+concat (safe but doesn't catch this bug). + const dir = makeTempProject({ + "index.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( + /]*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 + // `` — the real closing tag. Before the fix, every `$&` in the + // runtime expanded to an extra `` 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 () => { // Regression guard for the joinJsChunks helper. If a chunk ends with `// ...` // and we naively appended `;` on the same line, the appended semicolon would diff --git a/packages/core/src/compiler/htmlBundler.ts b/packages/core/src/compiler/htmlBundler.ts index aeb2d9e99..5225ccc42 100644 --- a/packages/core/src/compiler/htmlBundler.ts +++ b/packages/core/src/compiler/htmlBundler.ts @@ -52,7 +52,14 @@ function injectInterceptor(html: string, runtimeMode: "inline" | "placeholder" = tag = ``; } if (sanitized.includes("")) { - return sanitized.replace("", `${tag}\n`); + // 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 + // ``, silently corrupting the runtime and breaking every + // timeline in the bundle with a parse-time SyntaxError. + return sanitized.replace("", () => `${tag}\n`); } const htmlOpenMatch = sanitized.match(/]*>/i); if (htmlOpenMatch?.index != null) {