fix(core): preserve replace-pattern characters in bundled runtime IIFE#1027
Merged
miguel-heygen merged 1 commit intoMay 22, 2026
Conversation
`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>
miguel-heygen
approved these changes
May 22, 2026
This file contains hidden or bidirectional Unicode text that may be interpreted or compiled differently than what appears below. To review, open the file in an editor that reveals hidden Unicode characters.
Learn more about bidirectional Unicode characters
Sign up for free
to join this conversation on GitHub.
Already have an account?
Sign in to comment
Add this suggestion to a batch that can be applied as a single commit.This suggestion is invalid because no changes were made to the code.Suggestions cannot be applied while the pull request is closed.Suggestions cannot be applied while viewing a subset of changes.Only one suggestion per line can be applied in a batch.Add this suggestion to a batch that can be applied as a single commit.Applying suggestions on deleted lines is not supported.You must change the existing code in this line in order to create a valid suggestion.Outdated suggestions cannot be applied.This suggestion has been applied or marked resolved.Suggestions cannot be applied from pending reviews.Suggestions cannot be applied on multi-line comments.Suggestions cannot be applied while the pull request is queued to merge.Suggestion cannot be applied right now. Please check back later.
What
In
packages/core/src/compiler/htmlBundler.ts:55, switch the runtime injection fromsanitized.replace("</head>", \${tag}\n`)to the function-replacer formsanitized.replace("", () => `${tag}\n`), and add a regression test inhtmlBundler.test.ts`.Why
String.prototype.replace's second argument is a substitution template —$&expands to the matched substring, and$$,$',$`,$1–$99have similar meanings. The minified runtime IIFE returned bygetHyperframeRuntimeScript()contains legitimate$&sequences in operator-adjacent positions (e.g.if(te&&$&!y.hasAttribute(...))) and$2capture-group references in regex contexts. With the old two-argument form, every$&inside the inlined runtime body was silently rewritten to the matched substring</head>, turning real JS likete&&$&!y.hasAttribute(…)intote&&</head>&!y.hasAttribute(…)and producing a parse-timeSyntaxError: Unexpected token '<'. That single error tears down the runtime IIFE, so__renderReadynever flips, no__player.seekever wires up, and everywindow.__timelines[*]stays paused at frame 0 — the entire bundled HTML renders empty.The same code path is hit by
hyperframes snapshot,hyperframes preview(studio),hyperframes validate, andhyperframes layout, because all four consumebundleToSingleHtml. Render is unaffected:producer/services/fileServer.tsalready inserts the runtime viainjectScriptsIntoHtml(htmlDocument.ts:186), which has been on the function-replacer form. The two paths were silently diverging, and only the bundler was buggy.How
One-line code change: pass a callback to
String.prototype.replaceinstead of a substitution template, so the runtime body is forwarded verbatim and no$-pattern expansion runs. A comment at the call site spells out the failure mode and why both paths ininjectInterceptorare not equally affected (the no-<head>fallback usesslice+ concat, which never invokes substitution at all).I considered escaping
$in the replacement string instead (tag.replace(/\$/g, "$$$$")), but that pushes the burden onto every future change totagand is easy to forget when adding more dynamic content. The function-replacer form is the canonical fix recommended by MDN for exactly this hazard.Test plan
Added
htmlBundler.test.ts > preserves \$&` replace-pattern characters in the inlined runtime body`:index.htmlincludes an explicit<head>(the only path that exercises the substitution-template branch — the no-<head>fallback is already safe).$&, so the test isn't tautological on future runtime builds without those sequences.<script>body from the bundled output and assert it is byte-equal togetHyperframeRuntimeScript().</head>— defense in depth, because every$&in the runtime would expand to an extra</head>before the fix.I verified the regression test catches the bug by temporarily reverting
htmlBundler.tsto the two-argument form: the new test fails, the existing 31 tests still pass. With the fix re-applied, all 32 tests pass.bun run --filter @hyperframes/core test htmlBundlerandbunx oxlint/bunx oxfmt --checkall pass)