Skip to content

fix(core): route window.__hyperframes to the scoped variant in sub-comps#1931

Merged
miguel-heygen merged 1 commit into
mainfrom
fix/subcomp-window-hyperframes-scoping
Jul 4, 2026
Merged

fix(core): route window.__hyperframes to the scoped variant in sub-comps#1931
miguel-heygen merged 1 commit into
mainfrom
fix/subcomp-window-hyperframes-scoping

Conversation

@miguel-heygen

Copy link
Copy Markdown
Collaborator

Root cause

Sub-composition scripts run inside a wrapper (wrapScopedCompositionScript) 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 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-src sub-comp rendered with the first instance's content (or defaults), even though data-variable-values differed. 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 that getVariables() returns {} for reused sub-comp instances.

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 — so this is a pure runtime correctness fix, no doc changes needed.

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 every other __hyperframes member still passes through to the base unchanged. (__hfScopedHyperframes is a hoisted var assigned before any sub-comp script — the only reader — runs.)

Test plan

  • Two new executed-wrapper tests (new Function("window", wrapped)(fakeWindow), the same harness the existing scoping tests use):
    • window.__hyperframes.getVariables() now returns the per-comp variables instead of the TOP-LEVEL-LEAK host value.
    • a non-getVariables member (fitTextFontSize) still reaches the base __hyperframes.
  • bun run --cwd packages/core test — 81 files / 1092 tests pass.
  • Full bun run build clean; oxlint/oxfmt clean.

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.
@miguel-heygen miguel-heygen marked this pull request as ready for review July 4, 2026 20:16

@james-russo-rames-d-jusso james-russo-rames-d-jusso left a comment

Copy link
Copy Markdown

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

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 __hfScopedHyperframes at 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.__hyperframes form (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 set trap for __hyperframes — if any sub-comp author writes window.__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 the get (scoped) and default set (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.mdx promises 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 vanceingalls left a comment

Copy link
Copy Markdown
Collaborator

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

🟢 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 — populates window.__hyperframes = { fitTextFontSize, getVariables }. Only two members. The scoped variant is Object.assign({}, base, { getVariables }), so fitTextFontSize passes 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 = ...var hoists the declaration to function top (bound to undefined).
  • Line 408 (inside __hfScopedWindow proxy get handler): references __hfScopedHyperframes.
  • The proxy handler is only invoked when something reads window.__hyperframes. The scoped-window is passed as the window param to the wrapped sub-comp script at line 513 (.call(window, __hfScopedDocument, __hfScopedGsap, __hfScopedWindow, __hfScopedHyperframes)).
  • __hfRun() is called on line 519, after both the var __hfBaseHyperframes = ... and var __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 __hfScopedHyperframes closure, its own __hfBaseHyperframes snapshot, and its own __hfScopedWindow proxy. Closures don't share state.
  • getVariables inside the scoped variant reads window.__hfVariablesByComp[__hfTimelineCompId] at call time, not at wrapping time. As long as the loader has populated the entry for __hfTimelineCompId before the script runs (which the doc-comment in getVariables.ts says the loader does), each closure resolves independently.
  • The base window.__hyperframes is 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)

@miguel-heygen miguel-heygen merged commit f7c9c35 into main Jul 4, 2026
50 checks passed
@miguel-heygen miguel-heygen deleted the fix/subcomp-window-hyperframes-scoping branch July 4, 2026 21:08
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