Per @vai-bot's review on hf#641:
Important #1: dead `src=""` substitution sites
=============================================
Now that `bundleToSingleHtml` inlines the runtime IIFE by default, the empty
`src=""` placeholder is never emitted in the no-env-var path — the 5 downstream
substitution sites that grep for `src=""` were dead.
Two of them (studio dev server + studio vite preview) genuinely WANT the
placeholder so they can hot-reload a local /api/runtime.js endpoint without
re-inlining ~150 KB on every composition edit. Three of them (CLI validate,
snapshot, layout) were just doing the same inlining the bundler already does.
Resolution:
- Add a `runtime: "inline" | "placeholder"` option to `BundleOptions`. Default
is "inline" (matches the self-contained-bundle promise the function name
makes). The two studio surfaces explicitly pass `{ runtime: "placeholder" }`
to opt in.
- studioServer.ts + studio/vite.config.ts: pass the option, keep their
existing string-replace logic unchanged.
- validate.ts + snapshot.ts + layout.ts: delete the now-redundant runtime
substitution code (regex never matches the new inlined-runtime shape).
Important #2: joinJsChunks ASI hazard
======================================
The new helper appended `;` to chunks not already ending in `;` and joined
on `\n`. If a chunk ended with a `// line comment`, the appended semicolon
was eaten by the comment, leaving the next chunk's first statement attached
to the previous chunk's last expression — exactly the ASI hazard the helper
exists to prevent.
Fix: append `\n;` instead of `;` for chunks not already terminated. The
newline closes the line comment, the standalone `;` becomes the statement
separator. For typical chunks (already ending in `;`), output is unchanged
— still clean `\n`-joined chunks with no bare-semicolon lines.
Also added a trailing `;` to `wrapScopedCompositionScript`'s IIFE close
(`})()` → `})();`) so composition scripts join cleanly without falling
through to the `\n;` fallback.
New test: regression guard at the chunk boundary verifies every inline
script body in the bundle parses cleanly via esbuild even when a source JS
file ends with a line comment.
Verification
============
- `bun run --filter @hyperframes/core test` — 653/653 pass
- `bun run --filter @hyperframes/cli test` — 243/243 pass
- `bun run --filter @hyperframes/{core,cli,studio} typecheck` — clean
- `bunx oxfmt --check` + `bunx oxlint` on all touched files — clean
Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
Three issues in `bundleToSingleHtml` reported via Abhay's LLM-based code-validity
eval against the bundled output. Each is independently small; they share a single
PR because they're all artifacts of the bundler-output shape.
1. Empty `src=""` runtime placeholder (real bug)
`htmlBundler.ts:injectInterceptor` emitted
`<script data-hyperframes-preview-runtime="1" src=""></script>`
when no `HYPERFRAME_RUNTIME_URL` was configured. Empty `src` resolves to the
page URL itself; Chrome flags this as an infinite-fetch hazard. Three other
consumers (studioServer, validate, snapshot) post-process the placeholder to
substitute either a real URL or an inlined body — `bundleToSingleHtml` did
not, so the bundle wasn't actually self-contained despite the function name.
Fix: when no URL is configured, inline the runtime IIFE directly via
`getHyperframeRuntimeScript()`. Otherwise emit `src=…` as before.
2. Bare-semicolon lines between joined JS chunks (cosmetic)
Three sites used `chunks.join("\n;\n")` (body-script coalesce, local JS,
composition scripts) which produced a lone `;` on its own line between
chunks. Valid JS but a code smell. Replace with a `joinJsChunks()` helper
that ensures each chunk ends in `;` and joins on `\n`.
3. Empty `catch (_err) {}` in compositionScoping.ts (lint-noisy)
The `_err` underscore prefix signals "intentionally swallowed" but bundle-time
linters often don't honor that convention. Replaced with `catch { /* ... */ }`
(no binding, explanatory comment) — same behavior, no rule fires.
Tests: 2 new regression guards (runtime-not-empty-src, no-bare-semi) plus
existing tests updated to reflect the new inlined-runtime shape (the previous
"runtime block must not contain getElementById" assertion no longer holds
because the inlined body itself uses getElementById; replaced with a more
specific "author script not merged into runtime tag" check).
Issue #4 from the original report (Unterminated string at line 1111 col 18,
char 65497) was not directly reproducible after applying these fixes — esbuild
parses all 4 inline scripts in the rebundled output cleanly. The unterminated-
string symptom was likely a downstream artifact of the bare-semicolon joining
or the empty-src placeholder confusing the lint tool. If the original symptom
persists on a clean re-run against the fixed bundle, will open a follow-up PR
with a focused repro.
Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
Building on PR 1's getVariables() helper, this PR routes per-instance
values into the correct sub-composition. Same composition source can
now be embedded N times with different content via data-variable-values
on each host element.
How it works:
- compositionLoader, before injecting wrapped scripts, layers the host
element's data-variable-values JSON over the sub-comp's declared
defaults (its own data-composition-variables) and writes the merged
object to window.__hfVariablesByComp[compositionId]. Skipped when
both sides are empty so the table only grows for instances that
actually carry values.
- compositionScoping's wrapper IIFE now takes a fourth parameter
__hyperframes alongside the existing scoped document/gsap/window.
The scoped __hyperframes shadows getVariables() to read from
__hfVariablesByComp[__hfCompId], returning a fresh object each call
so script mutations don't leak into the shared table.
- Top-level scripts (not wrapped by compositionScoping) keep using the
unscoped window.__hyperframes.getVariables(), which reads
data-composition-variables defaults plus the CLI override
(window.__hfVariables) — same path as PR 1.
- readDeclaredDefaults is exported from getVariables.ts so the loader
reuses the exact same defaults-extraction logic the helper uses for
the top-level path.
Inline templates (no separate <html> document root) get host overrides
only — no declared defaults — since there's no separate <html> to read
data-composition-variables from. External sub-comps fetched via
data-composition-src get the full declared defaults + host overrides
merge.
Tests: 3 new compositionScoping tests covering scoped getVariables
invocation, missing-entry fallback, and mutation isolation. 5 new
compositionLoader tests covering merge order, declared-only path,
empty-skip, invalid-host-JSON resilience, and per-instance scoping
across two hosts sharing a source. 3 new getVariables tests covering
the newly-public readDeclaredDefaults. All 622 core tests green.
Docs: docs/concepts/compositions.mdx switched its sub-comp example from
hand-rolled JSON.parse(host.dataset.variableValues) to the new
__hyperframes.getVariables() pattern. data-attributes.mdx clarifies
per-instance scoping behavior.
This is PR 2 of a 4-PR stack. PR 3 adds schema validation + lint;
PR 4 ships skill / scaffold updates.
Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
## Problem
`wrapScopedCompositionScript` wraps composition scripts with scoped `document`, `window`, and `gsap` proxies. The current published `latest` and `alpha` packages still pass the proxy as the `Reflect.get` receiver, so browser host accessors like `document.body` can throw `TypeError: Illegal invocation`.
When that happens, the wrapper catches the error and aborts the rest of the composition script. For components that start hidden and reveal themselves through GSAP/timeline setup, that means the timeline is never registered and the render can stay visually empty.
Closes#606.
## What this fixes
- Reads scoped proxy properties with the original target as the `Reflect.get` receiver.
- Applies the same target-receiver pattern to scoped proxy setters, including remapped timeline registry writes.
- Preserves the existing behavior of binding returned methods back to the real target.
- Covers document, window, remapped timeline registry, GSAP, and GSAP utils accessors/setters in regression tests that throw unless the receiver is the original target.
## Root cause
The previous proxy traps called `Reflect.get(target, prop, receiver)`. For accessors, that invokes the getter with `this === receiver`, and in this wrapper the receiver is the proxy. Browser host getters such as `Document.prototype.body` validate their receiver and reject the proxy, which makes ordinary composition code like `document.body` fail before timeline registration can run.
## Verification
### Local checks
- Confirmed current npm state with `npm view @hyperframes/core version dist-tags versions --json` and `npm view @hyperframes/producer version dist-tags versions --json`: `latest` is `0.4.42`, `alpha` is `0.5.0-alpha.14`.
- Packed `@hyperframes/core` and `@hyperframes/producer` at both `latest` and `alpha`; all four packed artifacts still contained the bad `Reflect.get(target, prop, receiver)` / `utilsReceiver` wrapper patterns before this fix.
- `bun run --cwd packages/core test -- src/compiler/compositionScoping.test.ts`
- `bunx oxlint packages/core/src/compiler/compositionScoping.ts packages/core/src/compiler/compositionScoping.test.ts`
- `bunx oxfmt --check packages/core/src/compiler/compositionScoping.ts packages/core/src/compiler/compositionScoping.test.ts`
- `bun run --cwd packages/core build`
- `bun run --cwd packages/core typecheck`
- `bun run --cwd packages/producer typecheck`
- `bun run --cwd packages/producer build`
- `bun test packages/producer/src/services/htmlCompiler.test.ts`
- Confirmed the rebuilt core runtime and producer bundles no longer contain the old `Reflect.get(..., receiver)` / `Reflect.set(..., receiver)` scoped-wrapper patterns.
- `git diff --check`
- Pre-commit also reran lint, format, and typecheck successfully.
### Browser verification
Used `agent-browser` against generated local repro pages:
- `core latest 0.4.42`: `bodyRead: false`, `titleOpacity: "0"`, `timelineRegistered: false`, `errorCount: 1`
- `core alpha 0.5.0-alpha.14`: `bodyRead: false`, `titleOpacity: "0"`, `timelineRegistered: false`, `errorCount: 1`
- Patched local wrapper: `bodyRead: true`, `titleOpacity: "1"`, `timelineRegistered: true`, `errorCount: 0`
## Notes
- Browser screenshots and the `agent-browser` recordings are local-only under `tmp/issue-606/browser/`, including `issue-606-browser-proof.webm` and `issue-606-after-comment.webm`.
- No generated `dist/` artifacts are committed.