feat(core): scope getVariables() per sub-comp instance

Building on PR 1's getVariables() helper, this PR routes per-instance
values into the correct sub-composition. Same composition source can
now be embedded N times with different content via data-variable-values
on each host element.

How it works:

- compositionLoader, before injecting wrapped scripts, layers the host
  element's data-variable-values JSON over the sub-comp's declared
  defaults (its own data-composition-variables) and writes the merged
  object to window.__hfVariablesByComp[compositionId]. Skipped when
  both sides are empty so the table only grows for instances that
  actually carry values.
- compositionScoping's wrapper IIFE now takes a fourth parameter
  __hyperframes alongside the existing scoped document/gsap/window.
  The scoped __hyperframes shadows getVariables() to read from
  __hfVariablesByComp[__hfCompId], returning a fresh object each call
  so script mutations don't leak into the shared table.
- Top-level scripts (not wrapped by compositionScoping) keep using the
  unscoped window.__hyperframes.getVariables(), which reads
  data-composition-variables defaults plus the CLI override
  (window.__hfVariables) — same path as PR 1.
- readDeclaredDefaults is exported from getVariables.ts so the loader
  reuses the exact same defaults-extraction logic the helper uses for
  the top-level path.

Inline templates (no separate <html> document root) get host overrides
only — no declared defaults — since there's no separate <html> to read
data-composition-variables from. External sub-comps fetched via
data-composition-src get the full declared defaults + host overrides
merge.

Tests: 3 new compositionScoping tests covering scoped getVariables
invocation, missing-entry fallback, and mutation isolation. 5 new
compositionLoader tests covering merge order, declared-only path,
empty-skip, invalid-host-JSON resilience, and per-instance scoping
across two hosts sharing a source. 3 new getVariables tests covering
the newly-public readDeclaredDefaults. All 622 core tests green.

Docs: docs/concepts/compositions.mdx switched its sub-comp example from
hand-rolled JSON.parse(host.dataset.variableValues) to the new
__hyperframes.getVariables() pattern. data-attributes.mdx clarifies
per-instance scoping behavior.

This is PR 2 of a 4-PR stack. PR 3 adds schema validation + lint;
PR 4 ships skill / scaffold updates.

Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
This commit is contained in:
James
2026-05-04 19:41:26 +00:00
committed by James Russo
co-authored by Claude Opus 4.7
parent 03b82e6ff8
commit 484ab54442
9 changed files with 356 additions and 37 deletions
@@ -51,6 +51,76 @@ body { margin: 0; }
expect(scoped).not.toContain('[data-start="0"]');
});
it("exposes a scoped __hyperframes.getVariables that reads __hfVariablesByComp[compId]", () => {
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,
},
__captured: undefined as unknown,
};
const wrapped = wrapScopedCompositionScript(
`window.__captured = __hyperframes.getVariables();`,
"card-1",
);
new Function("window", wrapped)(fakeWindow);
expect(fakeWindow.__captured).toEqual({ title: "Pro", price: "$29" });
});
it("scoped getVariables returns {} when __hfVariablesByComp has no entry for the comp", () => {
const { document } = parseHTML(`<div data-composition-id="missing"></div>`);
const fakeWindow: Record<string, unknown> = {
document,
__timelines: {},
__hyperframes: {
getVariables: () => ({ title: "TOP-LEVEL-LEAK" }),
fitTextFontSize: () => undefined,
},
__captured: undefined as unknown,
};
const wrapped = wrapScopedCompositionScript(
`window.__captured = __hyperframes.getVariables();`,
"missing",
);
new Function("window", wrapped)(fakeWindow);
expect(fakeWindow.__captured).toEqual({});
});
it("scoped getVariables returns a fresh object — mutations don't leak into the shared table", () => {
const { document } = parseHTML(`<div data-composition-id="card-1"></div>`);
const variablesByComp: Record<string, Record<string, unknown>> = {
"card-1": { title: "Pro" },
};
const fakeWindow: Record<string, unknown> = {
document,
__timelines: {},
__hfVariablesByComp: variablesByComp,
__hyperframes: {
getVariables: () => ({}),
fitTextFontSize: () => undefined,
},
};
const wrapped = wrapScopedCompositionScript(
`var v = __hyperframes.getVariables(); v.title = "MUTATED"; v.added = "extra";`,
"card-1",
);
new Function("window", wrapped)(fakeWindow);
expect(variablesByComp["card-1"]).toEqual({ title: "Pro" });
});
it("executes document and GSAP selectors inside the composition root", () => {
const { document } = parseHTML(`
<div data-composition-id="scene" data-start="intro"><h1 class="title">Scene</h1></div>
@@ -261,11 +261,21 @@ export function wrapScopedCompositionScript(
return typeof value === "function" ? value.bind(target) : value;
},
});
var __hfBaseHyperframes = window.__hyperframes;
var __hfScopedHyperframes = !__hfBaseHyperframes
? __hfBaseHyperframes
: Object.assign({}, __hfBaseHyperframes, {
getVariables: function() {
var byComp = window.__hfVariablesByComp;
var scoped = byComp && __hfCompId ? byComp[__hfCompId] : null;
return scoped ? Object.assign({}, scoped) : {};
},
});
var __hfRun = function() {
try {
(function(document, gsap, window) {
(function(document, gsap, window, __hyperframes) {
${source}
}).call(window, __hfScopedDocument, __hfScopedGsap, __hfScopedWindow);
}).call(window, __hfScopedDocument, __hfScopedGsap, __hfScopedWindow, __hfScopedHyperframes);
} catch (_err) {
console.error(__hfErrorLabel, __hfCompId, _err);
}
@@ -264,6 +264,144 @@ describe("loadExternalCompositions", () => {
expect(host1.querySelector("p")?.textContent).toBe("A");
expect(host2.querySelector("p")?.textContent).toBe("B");
});
describe("variable scoping (window.__hfVariablesByComp)", () => {
type WindowWithScopedVars = Window & {
__hfVariablesByComp?: Record<string, Record<string, unknown>>;
};
afterEach(() => {
delete (window as WindowWithScopedVars).__hfVariablesByComp;
});
it("merges sub-comp declared defaults with host data-variable-values", async () => {
const host = document.createElement("div");
host.setAttribute("data-composition-src", "https://example.com/card.html");
host.setAttribute("data-composition-id", "card-1");
host.setAttribute("data-variable-values", '{"title":"Pro","price":"$29"}');
document.body.appendChild(host);
const compositionHtml = `
<html data-composition-variables='[
{"id":"title","type":"string","label":"Title","default":"Default"},
{"id":"price","type":"string","label":"Price","default":"$0"},
{"id":"theme","type":"string","label":"Theme","default":"light"}
]'>
<body>
<div data-composition-id="card-1"><p>card</p></div>
</body>
</html>
`;
vi.spyOn(globalThis, "fetch").mockResolvedValue(
new Response(compositionHtml, { status: 200 }),
);
await loadExternalCompositions({ ...defaultParams });
const byComp = (window as WindowWithScopedVars).__hfVariablesByComp ?? {};
expect(byComp["card-1"]).toEqual({
title: "Pro", // host wins over declared default
price: "$29", // host wins
theme: "light", // host omits → declared default falls through
});
});
it("uses declared defaults when host has no data-variable-values", async () => {
const host = document.createElement("div");
host.setAttribute("data-composition-src", "https://example.com/card.html");
host.setAttribute("data-composition-id", "card-2");
document.body.appendChild(host);
const compositionHtml = `
<html data-composition-variables='[
{"id":"title","type":"string","label":"Title","default":"Default Title"}
]'>
<body><div data-composition-id="card-2"><p>x</p></div></body>
</html>
`;
vi.spyOn(globalThis, "fetch").mockResolvedValue(
new Response(compositionHtml, { status: 200 }),
);
await loadExternalCompositions({ ...defaultParams });
const byComp = (window as WindowWithScopedVars).__hfVariablesByComp ?? {};
expect(byComp["card-2"]).toEqual({ title: "Default Title" });
});
it("skips registration when neither declared defaults nor host overrides exist", async () => {
const host = document.createElement("div");
host.setAttribute("data-composition-src", "https://example.com/card.html");
host.setAttribute("data-composition-id", "card-empty");
document.body.appendChild(host);
const compositionHtml = `
<html><body><div data-composition-id="card-empty"><p>x</p></div></body></html>
`;
vi.spyOn(globalThis, "fetch").mockResolvedValue(
new Response(compositionHtml, { status: 200 }),
);
await loadExternalCompositions({ ...defaultParams });
const byComp = (window as WindowWithScopedVars).__hfVariablesByComp;
expect(byComp?.["card-empty"]).toBeUndefined();
});
it("ignores invalid JSON in host data-variable-values", async () => {
const host = document.createElement("div");
host.setAttribute("data-composition-src", "https://example.com/card.html");
host.setAttribute("data-composition-id", "card-bad");
host.setAttribute("data-variable-values", "{not json");
document.body.appendChild(host);
const compositionHtml = `
<html data-composition-variables='[{"id":"title","type":"string","label":"Title","default":"OK"}]'>
<body><div data-composition-id="card-bad"><p>x</p></div></body>
</html>
`;
vi.spyOn(globalThis, "fetch").mockResolvedValue(
new Response(compositionHtml, { status: 200 }),
);
await loadExternalCompositions({ ...defaultParams });
const byComp = (window as WindowWithScopedVars).__hfVariablesByComp ?? {};
expect(byComp["card-bad"]).toEqual({ title: "OK" });
});
it("registers per-instance entries for multiple sub-comps with the same source", async () => {
const host1 = document.createElement("div");
host1.setAttribute("data-composition-src", "https://example.com/card.html");
host1.setAttribute("data-composition-id", "card-A");
host1.setAttribute("data-variable-values", '{"title":"Pro","price":"$29"}');
document.body.appendChild(host1);
const host2 = document.createElement("div");
host2.setAttribute("data-composition-src", "https://example.com/card.html");
host2.setAttribute("data-composition-id", "card-B");
host2.setAttribute("data-variable-values", '{"title":"Enterprise","price":"Custom"}');
document.body.appendChild(host2);
const compositionHtml = `
<html data-composition-variables='[
{"id":"title","type":"string","label":"Title","default":"Default"},
{"id":"price","type":"string","label":"Price","default":"$0"}
]'>
<body><div data-composition-id="card-A"><p>x</p></div></body>
</html>
`;
vi.spyOn(globalThis, "fetch").mockImplementation(
async () => new Response(compositionHtml, { status: 200 }),
);
await loadExternalCompositions({ ...defaultParams });
const byComp = (window as WindowWithScopedVars).__hfVariablesByComp ?? {};
expect(byComp["card-A"]).toEqual({ title: "Pro", price: "$29" });
expect(byComp["card-B"]).toEqual({ title: "Enterprise", price: "Custom" });
});
});
});
describe("loadInlineTemplateCompositions", () => {
@@ -1,4 +1,5 @@
import { scopeCssToComposition, wrapScopedCompositionScript } from "../compiler/compositionScoping";
import { readDeclaredDefaults } from "./getVariables";
type LoadExternalCompositionsParams = {
injectedStyles: HTMLStyleElement[];
@@ -73,6 +74,19 @@ function resolveScriptSourceUrl(scriptSrc: string, compositionUrl: URL | null):
}
}
function parseHostVariableValues(host: Element): Record<string, unknown> {
const raw = host.getAttribute("data-variable-values");
if (!raw) return {};
let parsed: unknown;
try {
parsed = JSON.parse(raw);
} catch {
return {};
}
if (!parsed || typeof parsed !== "object" || Array.isArray(parsed)) return {};
return parsed as Record<string, unknown>;
}
async function mountCompositionContent(params: {
host: Element;
hostCompositionId: string | null;
@@ -88,6 +102,15 @@ async function mountCompositionContent(params: {
headStyles?: HTMLStyleElement[];
/** Extra <script> elements from the parsed document <head> (non-template sub-compositions). */
headScripts?: HTMLScriptElement[];
/**
* Defaults extracted from the sub-composition's own
* `<html data-composition-variables="...">` attribute. Layered under the
* host element's `data-variable-values` to produce the per-instance
* variables visible inside the sub-comp's scoped `getVariables()`.
* Populated only by `loadExternalCompositions`; inline templates have no
* separate document root so no declared defaults are passed.
*/
declaredVariableDefaults?: Record<string, unknown>;
onDiagnostic?: (payload: {
code: string;
details: Record<string, string | number | boolean | null | string[]>;
@@ -211,6 +234,24 @@ async function mountCompositionContent(params: {
params.host.innerHTML = params.fallbackBodyInnerHtml;
}
// Stash the per-instance variables BEFORE running scripts. The scoped
// `getVariables()` injected by `compositionScoping.ts` reads from
// `window.__hfVariablesByComp[compId]`, so this table must be populated
// before the wrapped IIFE evaluates.
if (scopeCompositionId) {
const merged = {
...(params.declaredVariableDefaults ?? {}),
...parseHostVariableValues(params.host),
};
if (Object.keys(merged).length > 0) {
const w = window as Window & {
__hfVariablesByComp?: Record<string, Record<string, unknown>>;
};
if (!w.__hfVariablesByComp) w.__hfVariablesByComp = {};
w.__hfVariablesByComp[scopeCompositionId] = merged;
}
}
for (const scriptPayload of scriptPayloads) {
const injectedScript = document.createElement("script");
if (scriptPayload.type) {
@@ -371,6 +412,7 @@ export async function loadExternalCompositions(
parseDimensionPx: params.parseDimensionPx,
headStyles,
headScripts,
declaredVariableDefaults: readDeclaredDefaults(doc.documentElement),
onDiagnostic: params.onDiagnostic,
});
} catch (error) {
+28 -1
View File
@@ -2,7 +2,7 @@
* @vitest-environment jsdom
*/
import { describe, it, expect, beforeEach, afterEach } from "vitest";
import { getVariables } from "./getVariables";
import { getVariables, readDeclaredDefaults } from "./getVariables";
const VARIABLES_ATTR = "data-composition-variables";
@@ -104,3 +104,30 @@ describe("getVariables", () => {
expect(vars.missing).toBeUndefined();
});
});
describe("readDeclaredDefaults", () => {
it("returns {} for a null root", () => {
expect(readDeclaredDefaults(null)).toEqual({});
});
it("extracts {id: default} from an arbitrary element with the attribute", () => {
const el = document.createElement("html");
el.setAttribute(
"data-composition-variables",
JSON.stringify([
{ id: "title", type: "string", label: "Title", default: "Hello" },
{ id: "count", type: "number", label: "Count", default: 3 },
]),
);
expect(readDeclaredDefaults(el)).toEqual({ title: "Hello", count: 3 });
});
it("returns {} when the attribute is invalid JSON or non-array", () => {
const a = document.createElement("html");
a.setAttribute("data-composition-variables", "{not json");
expect(readDeclaredDefaults(a)).toEqual({});
const b = document.createElement("html");
b.setAttribute("data-composition-variables", JSON.stringify({ title: "x" }));
expect(readDeclaredDefaults(b)).toEqual({});
});
});
+15 -2
View File
@@ -1,10 +1,17 @@
/**
* Reads the resolved variables for the current composition.
*
* Resolves to declared defaults from `<html data-composition-variables="...">`
* Top-level path: declared defaults from `<html data-composition-variables="...">`
* merged with `window.__hfVariables` (set at render time by the engine when
* the user passes `hyperframes render --variables '<json>'`).
*
* Sub-comp path (per-instance scoping): when called inside a sub-composition
* script wrapped by `compositionScoping.ts`, the wrapper shadows
* `__hyperframes.getVariables` with a scoped variant that returns the
* pre-merged values from `window.__hfVariablesByComp[compositionId]`. The
* loader populates that table before running scripts, layering the host
* element's `data-variable-values` over the sub-comp's declared defaults.
*
* Returns `Partial<T>` because not every declared variable is guaranteed to
* have a default, and not every key in `__hfVariables` is guaranteed to be
* declared. Callers are expected to destructure with their own fallbacks
@@ -23,7 +30,13 @@ export function getVariables<
return { ...declaredDefaults, ...overrides } as Partial<T>;
}
function readDeclaredDefaults(root: Element | null): Record<string, unknown> {
/**
* Extract `{id: default}` map from an element's `data-composition-variables`
* attribute. Returns an empty object when the attribute is missing, the JSON
* is unparseable, or the payload isn't an array. Exported so the
* compositionLoader can compute the same defaults map for sub-comp instances.
*/
export function readDeclaredDefaults(root: Element | null): Record<string, unknown> {
if (!root) return {};
const raw = root.getAttribute("data-composition-variables");
if (!raw) return {};
+9
View File
@@ -86,6 +86,15 @@ declare global {
* declared defaults from `<html data-composition-variables="...">`.
*/
__hfVariables?: Record<string, unknown>;
/**
* Per-instance, pre-merged variables for sub-compositions. Keyed by the
* sub-composition's `data-composition-id`. Populated by the runtime
* composition loader at mount time: layers the host element's
* `data-variable-values` over the sub-comp's declared defaults so the
* scoped `getVariables()` exposed by `compositionScoping.ts` returns the
* resolved values for the instance currently executing.
*/
__hfVariablesByComp?: Record<string, Record<string, unknown>>;
}
}