Skip to content

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

Merged
miguel-heygen merged 1 commit into
heygen-com:mainfrom
lirian-su-opus:fix/bundler-runtime-replace-special-patterns
May 22, 2026
Merged

fix(core): preserve replace-pattern characters in bundled runtime IIFE#1027
miguel-heygen merged 1 commit into
heygen-com:mainfrom
lirian-su-opus:fix/bundler-runtime-replace-special-patterns

Conversation

@lirian-su-opus
Copy link
Copy Markdown

@lirian-su-opus lirian-su-opus commented May 22, 2026

What

In packages/core/src/compiler/htmlBundler.ts:55, switch the runtime injection from sanitized.replace("</head>", \${tag}\n`)to the function-replacer formsanitized.replace("", () => `${tag}\n`), and add a regression test in htmlBundler.test.ts`.

Why

String.prototype.replace's second argument is a substitution template — $& expands to the matched substring, and $$, $', $`, $1$99 have similar meanings. The minified runtime IIFE returned by getHyperframeRuntimeScript() contains legitimate $& sequences in operator-adjacent positions (e.g. if(te&&$&!y.hasAttribute(...))) and $2 capture-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 like te&&$&!y.hasAttribute(…) into te&&</head>&!y.hasAttribute(…) and producing a parse-time SyntaxError: Unexpected token '<'. That single error tears down the runtime IIFE, so __renderReady never flips, no __player.seek ever wires up, and every window.__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, and hyperframes layout, because all four consume bundleToSingleHtml. Render is unaffected: producer/services/fileServer.ts already inserts the runtime via injectScriptsIntoHtml (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.replace instead 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 in injectInterceptor are not equally affected (the no-<head> fallback uses slice + 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 to tag and 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`:

  1. Bundle a project whose index.html includes an explicit <head> (the only path that exercises the substitution-template branch — the no-<head> fallback is already safe).
  2. Sanity-assert that the built runtime contains $&, so the test isn't tautological on future runtime builds without those sequences.
  3. Extract the runtime <script> body from the bundled output and assert it is byte-equal to getHyperframeRuntimeScript().
  4. Assert the entire bundled document contains exactly one </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.ts to the two-argument form: the new test fails, the existing 31 tests still pass. With the fix re-applied, all 32 tests pass.

  • Unit tests added/updated
  • Manual testing performed (reproduced the original SyntaxError on a real composition, confirmed it disappears after the fix; verified bun run --filter @hyperframes/core test htmlBundler and bunx oxlint / bunx oxfmt --check all pass)
  • Documentation updated (not applicable — pure bug fix, no API or behavior surface change beyond what authors already expect)

`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 miguel-heygen merged commit b1ea5d6 into heygen-com:main May 22, 2026
43 of 62 checks passed
Sign up for free to join this conversation on GitHub. Already have an account? Sign in to comment

Labels

None yet

Projects

None yet

Development

Successfully merging this pull request may close these issues.

3 participants