From 3f766435fc45c95a9081cf10041ae3ed368ce698 Mon Sep 17 00:00:00 2001 From: Miguel Angel Simon Sierra Date: Fri, 3 Jul 2026 23:17:07 -0700 Subject: [PATCH] fix(core): route window.__hyperframes to the scoped variant in sub-comps MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit 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. --- .../src/compiler/compositionScoping.test.ts | 53 +++++++++++++++++++ .../core/src/compiler/compositionScoping.ts | 10 ++++ 2 files changed, 63 insertions(+) diff --git a/packages/core/src/compiler/compositionScoping.test.ts b/packages/core/src/compiler/compositionScoping.test.ts index df3ce1d595..19c7b019d7 100644 --- a/packages/core/src/compiler/compositionScoping.test.ts +++ b/packages/core/src/compiler/compositionScoping.test.ts @@ -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(`
`); + const fakeWindow: Record = { + 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(`
`); + let fitCalled = false; + const fakeWindow: Record = { + 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(`
`); const fakeWindow: Record = { diff --git a/packages/core/src/compiler/compositionScoping.ts b/packages/core/src/compiler/compositionScoping.ts index be49f149a0..4315d770a9 100644 --- a/packages/core/src/compiler/compositionScoping.ts +++ b/packages/core/src/compiler/compositionScoping.ts @@ -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) {