fix(core): route window.__hyperframes to the scoped variant in sub-comps#1931
Conversation
Sub-composition scripts run inside a wrapper that passes the SCOPED
__hyperframes (per-instance getVariables) as a bare script param, while
`window` is a Proxy. That proxy intercepted only __timelines, so
`window.__hyperframes` fell through to the HOST page's base
__hyperframes — whose getVariables reads the host's variables, not this
instance's. So the two documented spellings diverged: the bare
`__hyperframes.getVariables()` param returned the correct per-instance
values, but `window.__hyperframes.getVariables()` returned the wrong
(host / empty) ones, silently rendering every reused instance with the
first instance's content (or defaults).
docs/concepts/variables.mdx already promises both forms "work in both
top-level and sub-composition scripts ... each instance sees its own
resolved values" — the runtime just didn't honor it. Reported directly
(a user lost significant debugging time across three parametrized
sub-comps before discovering the bare param was the only form that
worked), and matches an earlier deferred finding that getVariables()
returns {} for reused sub-comp instances.
Fix: the scoped `window` proxy now returns the scoped __hyperframes for
`prop === "__hyperframes"`, so window.__hyperframes.getVariables() and
the bare param resolve identically to this composition's own variables.
The scoped variant is Object.assign({}, base, { getVariables }), so all
other __hyperframes members still pass through to the base unchanged.
Test: two new executed-wrapper cases (new Function(...)(fakeWindow)) —
window.__hyperframes.getVariables() now returns the per-comp variables
instead of the TOP-LEVEL-LEAK host value, and a non-getVariables member
(fitTextFontSize) still reaches the base. Full core suite (1092) passes.
james-russo-rames-d-jusso
left a comment
There was a problem hiding this comment.
Reviewed at 3f76643.
🟢 Clean runtime-correctness fix — well-scoped, well-tested, docs already promised this behavior. The 2-line Proxy get handler surgically routes window.__hyperframes to the scoped variant, and the two new executed-wrapper tests (new Function("window", wrapped)(fakeWindow)) cover both the primary regression (correct per-instance getVariables()) and the sibling-preservation case (fitTextFontSize still passes through).
Verification notes:
- Hoisting claim holds —
var __hfScopedHyperframesat line 499-500 hoists its binding to function top so the Proxy handler closure at line 408 has a valid reference; assignment lands before__hfRun/sub-comp script execution. Comment explicitly documents this contract. - Root-cause writeup is the strongest part: pins the divergence between the bare-param form (worked) vs
window.__hyperframesform (silently wrong) that cost the reporter significant debugging time. Object.assign({}, base, { getVariables })preserves member pass-through cleanly — no risk of shadowing future__hyperframes.*additions.
Nits (non-blocking):
- 🟡 Consider a symmetric
settrap for__hyperframes— if any sub-comp author writeswindow.__hyperframes.foo = bar, it currently lands on the base host object (silent host-page mutation from sub-comp scope). Low-probability given the API surface but the asymmetry between theget(scoped) and defaultset(base) invites the same class of confusion this PR just fixed. Not blocking; call it out if it comes up in a follow-up. - 🟡 Doc precedent —
docs/concepts/variables.mdxpromises both forms work; consider a quick smoke assertion in the docs test suite (if one exists) to prevent future regression of the same shape.
No cross-repo impact (this is internal core scoping). No observability changes needed. Safe to land after review-level checks.
— Rames D Jusso
vanceingalls
left a comment
There was a problem hiding this comment.
🟢 R1 verdict — clean runtime-interop fix, correctness confirmed against all window.__hyperframes readers
Runtime-interop lens: traced the scoped-variant fan-out across every reader/writer of window.__hyperframes in the core module and its consumers. All paths correct.
Finding-by-finding
1. Fan-out audit — 🟢
packages/core/src/compiler/compositionScoping.ts:408
Traced every reader of window.__hyperframes in packages/core/src at HEAD 3f766435:
runtime/entry.ts:20— populateswindow.__hyperframes = { fitTextFontSize, getVariables }. Only two members. The scoped variant isObject.assign({}, base, { getVariables }), sofitTextFontSizepasses through the spread and remains the base reference. The second regression test (preserves non-getVariables members) locks this.runtime/colorGrading.ts:168(win.__hyperframes?.getVariables?.()) — runs outside the sub-comp wrapper (top-level runtime), so the scoping proxy never intercepts it. No interop change.runtime/getVariables.ts— the exported function itself; scoped variant swaps in a different closure. Unaffected.compositionScoping.test.ts— new tests plus the existing sub-comp harness cover both spellings.
No missed reader. Both __hyperframes.getVariables() (bare param) and window.__hyperframes.getVariables() (documented form) resolve to the scoped closure now, matching what docs/concepts/variables.mdx already promises.
2. Hoisting / temporal-dead-zone claim — 🟢
packages/core/src/compiler/compositionScoping.ts:395-420 + 499-508
The PR body claims __hfScopedHyperframes is a hoisted var assigned before any sub-comp script (the only reader) runs. Verified:
- Line 500:
var __hfScopedHyperframes = ...—varhoists the declaration to function top (bound toundefined). - Line 408 (inside
__hfScopedWindowproxygethandler): references__hfScopedHyperframes. - The proxy handler is only invoked when something reads
window.__hyperframes. The scoped-window is passed as thewindowparam to the wrapped sub-comp script at line 513 (.call(window, __hfScopedDocument, __hfScopedGsap, __hfScopedWindow, __hfScopedHyperframes)). __hfRun()is called on line 519, after both thevar __hfBaseHyperframes = ...andvar __hfScopedHyperframes = ...assignments have executed.
So by the time any code inside the sub-comp reads window.__hyperframes, the assignment has completed. TDZ isn't a risk. (Aside: if the sub-comp script itself calls setTimeout(() => window.__hyperframes.getVariables()) — that runs even later, well after wrapper setup, so still safe.)
3. Race / cross-comp isolation — 🟢
packages/core/src/compiler/compositionScoping.ts:499-508
Concern probed: two sub-compositions of the same host page executing concurrently — do they see distinct scopes or trample?
- Each sub-comp gets its OWN IIFE invocation of
wrapScopedCompositionScript. That means each has its own__hfScopedHyperframesclosure, its own__hfBaseHyperframessnapshot, and its own__hfScopedWindowproxy. Closures don't share state. getVariablesinside the scoped variant readswindow.__hfVariablesByComp[__hfTimelineCompId]at call time, not at wrapping time. As long as the loader has populated the entry for__hfTimelineCompIdbefore the script runs (which the doc-comment ingetVariables.tssays the loader does), each closure resolves independently.- The base
window.__hyperframesis a single object on the host page, but the scoped variant never mutates it —Object.assign({}, base, ...)creates a shallow copy.
No cross-comp trample surface.
4. set trap asymmetry — 🟢 (non-blocker note)
packages/core/src/compiler/compositionScoping.ts:411-418
The proxy's set handler does not intercept __hyperframes writes. If a sub-comp did window.__hyperframes = something, the write would land on the underlying window (host page's global), not the scoped variant. Reads would still return the scoped one, so a write-then-read sequence would be silently inconsistent.
Realistically no sub-comp author does this — window.__hyperframes is a runtime-owned handle. Not worth guarding; noting for completeness. Matches how __timelines is bidirectional (get + set trap) but that's motivated by real timeline-registry mutation, which __hyperframes doesn't have.
Peer-lens split: Rames has this PR's test-geometry + cross-repo consumer coverage. My lens confirms the runtime-interop correctness across every actual reader. Nothing to add on top.
Review by Via (runtime-interop lens)
Root cause
Sub-composition scripts run inside a wrapper (
wrapScopedCompositionScript) that passes the scoped__hyperframes(per-instancegetVariables) as a bare script param, whilewindowis aProxy. That proxy intercepted only__timelines, sowindow.__hyperframesfell through to the host page's base__hyperframes— whosegetVariables()reads the host's variables, not this instance's.So the two documented spellings diverged inside a sub-composition:
__hyperframes.getVariables()(bare param) → correct per-instance values ✅window.__hyperframes.getVariables()(the form shown throughout the docs) → wrong (host / empty) values ❌The visible symptom: every reused instance of a
data-composition-srcsub-comp rendered with the first instance's content (or defaults), even thoughdata-variable-valuesdiffered. A reporter lost significant debugging time across three parametrized sub-comps before discovering the bare param was the only form that worked; this also matches an earlier deferred finding thatgetVariables()returns{}for reused sub-comp instances.docs/concepts/variables.mdxalready promises both forms "work in both top-level and sub-composition scripts ... each instance sees its own resolved values." The runtime just didn't honor it — so this is a pure runtime correctness fix, no doc changes needed.Fix
The scoped
windowproxy now returns the scoped__hyperframesforprop === "__hyperframes", sowindow.__hyperframes.getVariables()and the bare param resolve identically to this composition's own variables. The scoped variant isObject.assign({}, base, { getVariables }), so every other__hyperframesmember still passes through to the base unchanged. (__hfScopedHyperframesis a hoistedvarassigned before any sub-comp script — the only reader — runs.)Test plan
new Function("window", wrapped)(fakeWindow), the same harness the existing scoping tests use):window.__hyperframes.getVariables()now returns the per-comp variables instead of theTOP-LEVEL-LEAKhost value.getVariablesmember (fitTextFontSize) still reaches the base__hyperframes.bun run --cwd packages/core test— 81 files / 1092 tests pass.bun run buildclean;oxlint/oxfmtclean.