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

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.
This commit is contained in:
Miguel Ángel
2026-07-04 14:08:08 -07:00
committed by GitHub
parent af5f3e5fab
commit f7c9c35d8b
2 changed files with 63 additions and 0 deletions
@@ -79,6 +79,59 @@ body { margin: 0; }
expect(fakeWindow.__captured).toEqual({ title: "Pro", price: "$29" });
});
it("routes the documented window.__hyperframes.getVariables() to the scoped variant too", () => {
// Regression: the docs (variables-and-media.md) show `window.__hyperframes.
// getVariables()`, but inside a sub-comp the scoped `window` proxy used to
// fall through to the HOST page's base __hyperframes, returning the wrong
// (or empty) variables — the bare `__hyperframes` param was the only form
// that worked. Both spellings must now resolve to this comp's variables.
const { document } = parseHTML(`<div data-composition-id="card-1"></div>`);
const fakeWindow: Record<string, unknown> = {
document,
__timelines: {},
__hfVariablesByComp: {
"card-1": { title: "Pro", price: "$29" },
"card-2": { title: "Enterprise", price: "Custom" },
},
__hyperframes: {
getVariables: () => ({ title: "TOP-LEVEL-LEAK" }),
fitTextFontSize: () => undefined,
},
};
const wrapped = wrapScopedCompositionScript(
`window.__captured = window.__hyperframes.getVariables();`,
"card-1",
);
new Function("window", wrapped)(fakeWindow);
expect(fakeWindow.__captured).toEqual({ title: "Pro", price: "$29" });
});
it("preserves non-getVariables members on window.__hyperframes (only getVariables is rescoped)", () => {
const { document } = parseHTML(`<div data-composition-id="card-1"></div>`);
let fitCalled = false;
const fakeWindow: Record<string, unknown> = {
document,
__timelines: {},
__hfVariablesByComp: { "card-1": { title: "Pro" } },
__hyperframes: {
getVariables: () => ({ title: "TOP-LEVEL-LEAK" }),
fitTextFontSize: () => {
fitCalled = true;
},
},
};
const wrapped = wrapScopedCompositionScript(
`window.__hyperframes.fitTextFontSize();`,
"card-1",
);
new Function("window", wrapped)(fakeWindow);
expect(fitCalled).toBe(true);
});
it("scoped getVariables reads from the runtime composition id when it differs", () => {
const { document } = parseHTML(`<div data-composition-id="scene"></div>`);
const fakeWindow: Record<string, unknown> = {
@@ -396,6 +396,16 @@ export function wrapScopedCompositionScript(
? new Proxy(window, {
get: function(target, prop, receiver) {
if (prop === "__timelines") return __hfGetTimelineRegistry();
// Inside a sub-composition, __hyperframes is passed as a bare script
// param bound to the SCOPED variant (per-comp getVariables). But
// authors routinely write the documented window.__hyperframes.
// getVariables() form, which would otherwise fall through to the host
// page's base __hyperframes and return the WRONG (or empty) variables
// for this instance. Route it to the scoped variant too so both
// spellings resolve to this composition's own variables.
// (__hfScopedHyperframes is a hoisted var assigned below, before any
// sub-comp script -- the only code that reads this -- runs.)
if (prop === "__hyperframes") return __hfScopedHyperframes;
return Reflect.get(target, prop, target);
},
set: function(target, prop, value, receiver) {