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) {