feat(core): scope getVariables() per sub-comp instance (PR 2/4) (#601)

## What

Building on **PR #600** (`getVariables()` helper + `--variables` flag), this PR scopes the helper so embedded sub-compositions see their own per-instance values. The same composition source can now be embedded N times with different content via `data-variable-values` on each host.

This is **PR 2 of a 4-PR stack** — based on `feat/get-variables`. Will retarget to `main` after PR #600 merges.

## Why

`data-variable-values` was already documented as the per-instance attribute for nested compositions, but readers had to hand-roll `JSON.parse(host.dataset.variableValues)` and there was no scoping system — every sub-comp script had to re-implement the same pattern. This PR routes the values to a scoped `getVariables()` so authors use one API in both top-level and sub-comp contexts.

## How

```
[host element]                                    [sub-comp source HTML]
data-variable-values='{...}'        +    <html data-composition-variables='[...]'>
                                                  ^ declared defaults
        ↓                                                  ↓
        └──────────── compositionLoader ────────────────────┘
                            ↓
            window.__hfVariablesByComp[compId] = merged
                            ↓
                  scripts wrapped by compositionScoping
                            ↓
            __hyperframes.getVariables() → scoped lookup
```

Three small surgical changes:

- **`compositionLoader.ts`** — before injecting wrapped scripts, parse the host's `data-variable-values` JSON, merge it over `readDeclaredDefaults(doc.documentElement)` (reused from PR 1's runtime helper), and write the result to `window.__hfVariablesByComp[compositionId]`. Skipped when both sides are empty so the table only grows when there's actual data. Inline templates (no separate `<html>` root) get host overrides only.

- **`compositionScoping.ts`** — wrapper IIFE now takes a fourth parameter `__hyperframes` alongside the existing scoped `document` / `gsap` / `window`. Same shadowing pattern that already works for the other three. The scoped `__hyperframes` overrides `getVariables` to read from `__hfVariablesByComp[__hfCompId]` and returns a fresh object each call so script mutations don't leak into the shared table.

- **`getVariables.ts`** — `readDeclaredDefaults` becomes a named export (was a private helper) so the loader reuses the exact same defaults-extraction logic the top-level helper uses.

Top-level scripts that aren't wrapped by `compositionScoping` keep calling `window.__hyperframes.getVariables()` and get the unscoped path from PR 1 (declared defaults + CLI `--variables` override).

## Test plan

- [x] Unit tests added/updated
  - **3 new `compositionScoping.test.ts`** — scoped `getVariables` invocation routes to `__hfVariablesByComp[compId]`; missing-entry returns `{}`; mutation of the returned object doesn't leak into the shared table.
  - **5 new `compositionLoader.test.ts`** — merge order (host overrides win); declared-only path when host has no `data-variable-values`; empty-skip when neither side has data; invalid-host-JSON falls through to declared defaults; per-instance scoping across two hosts that share a source.
  - **3 new `getVariables.test.ts`** — covering the newly-public `readDeclaredDefaults` export (null root, valid attribute, invalid JSON / non-array).
  - All 622 existing core tests green.
- [x] Manual flow walkthrough — host with `data-variable-values='{"title":"Pro"}'` → sub-comp's `__hyperframes.getVariables()` returns `{title:"Pro", ...declaredDefaults}`. Two hosts pointing at the same source with different overrides → each script sees its own values.
- [x] Documentation updated
  - `docs/concepts/compositions.mdx` — switched the sub-comp example from `JSON.parse(host.dataset.variableValues)` to `__hyperframes.getVariables()`; added declared-defaults pattern and per-instance scoping note.
  - `docs/concepts/data-attributes.mdx` — clarified per-instance behavior on `data-variable-values`.

## Backwards compatibility

Fully backwards compatible. Compositions that read `host.dataset.variableValues` directly keep working — the host attribute is still set as before. The new path is a strict addition.

🤖 Generated with [Claude Code](https://claude.com/claude-code)
This commit is contained in:
James Russo
2026-05-04 13:05:25 -07:00
committed by GitHub
9 changed files with 351 additions and 37 deletions
+40 -30
View File
@@ -129,52 +129,62 @@ Every composition has two layers:
HyperFrames does not automatically bind `data-var-*` attributes into your composition DOM or CSS.
Today, the supported pattern is:
The supported pattern is:
1. Pass per-instance values on the composition host with `data-variable-values`
2. Read those values inside the composition and apply them in your own script
1. Declare the variables once on the sub-comp's `<html>` root with `data-composition-variables` (id + type + default).
2. Pass per-instance values on each composition host with `data-variable-values`.
3. Read the resolved values inside the composition with `window.__hyperframes.getVariables()`. The runtime layers the host's `data-variable-values` over the declared defaults on a per-instance basis, so the same source can be embedded multiple times with different values.
```html index.html
<div
data-composition-id="card"
data-composition-id="card-pro"
data-composition-src="compositions/card.html"
data-start="0"
data-track-index="1"
data-variable-values='{"title":"Hello","color":"#ff4d4f"}'
data-variable-values='{"title":"Pro","color":"#ff4d4f"}'
></div>
<div
data-composition-id="card-enterprise"
data-composition-src="compositions/card.html"
data-start="card-pro"
data-track-index="1"
data-variable-values='{"title":"Enterprise","color":"#22c55e"}'
></div>
```
```html compositions/card.html
<template id="card-template">
<div data-composition-id="card" data-width="1920" data-height="1080">
<h1 class="title">Fallback</h1>
<html data-composition-variables='[
{"id":"title","type":"string","label":"Title","default":"Fallback"},
{"id":"color","type":"color","label":"Color","default":"#111827"}
]'>
<body>
<div data-composition-id="card" data-width="1920" data-height="1080">
<h1 class="title"></h1>
<style>
[data-composition-id="card"] {
--card-color: #111827;
}
<style>
[data-composition-id="card"] {
--card-color: #111827;
}
[data-composition-id="card"] .title {
color: var(--card-color);
}
</style>
[data-composition-id="card"] .title {
color: var(--card-color);
}
</style>
<script>
const root = document.querySelector('[data-composition-id="card"]');
const vars = JSON.parse(root?.getAttribute("data-variable-values") ?? "{}");
const titleEl = root?.querySelector(".title");
if (titleEl) {
titleEl.textContent = vars.title ?? "Fallback";
}
root?.style.setProperty("--card-color", String(vars.color ?? "#111827"));
</script>
</div>
</template>
<script>
// Inside a sub-comp script, getVariables() returns the per-instance
// values: declared defaults < host data-variable-values overrides.
const { title, color } = __hyperframes.getVariables();
const root = document.querySelector('[data-composition-id="card"]');
root.querySelector(".title").textContent = title;
root.style.setProperty("--card-color", color);
</script>
</div>
</body>
</html>
```
If you are building tooling on top of `@hyperframes/core`, you can also declare variable metadata separately with `data-composition-variables` and read it via `extractCompositionMetadata()`. That metadata is descriptive only; you still apply the actual values manually inside the composition.
If you are building tooling on top of `@hyperframes/core`, the same `data-composition-variables` array is readable via `extractCompositionMetadata()` for Studio editing UI and analysis pipelines.
## Listing Compositions
+2 -2
View File
@@ -29,8 +29,8 @@ Hyperframes uses HTML data attributes to control timing, media playback, and [co
| `data-width` | `"1920"` | Composition width in pixels |
| `data-height` | `"1080"` | Composition height in pixels |
| `data-composition-src` | `"./intro.html"` | Path to external [composition](/concepts/compositions) HTML file |
| `data-variable-values` | `'{"title":"Hello"}'` | JSON object of values passed to a nested composition. HyperFrames carries these values through, but your composition script must read and apply them manually. |
| `data-composition-variables` | `'[{"id":"title","type":"string","label":"Title","default":"Hello"}]'` | JSON array of declared variables (`id`, `type`, `label`, `default`). Drives Studio editing UI and provides defaults read by `window.__hyperframes.getVariables()`. The CLI flag `hyperframes render --variables '<json>'` overrides these defaults at render time. |
| `data-variable-values` | `'{"title":"Hello"}'` | JSON object of values passed to a nested composition. Inside the sub-composition, read them via `window.__hyperframes.getVariables()` — the runtime layers these over the sub-comp's own `data-composition-variables` defaults and exposes the merged result on a per-instance basis (the same source can be embedded multiple times with different values). |
| `data-composition-variables` | `'[{"id":"title","type":"string","label":"Title","default":"Hello"}]'` | JSON array of declared variables (`id`, `type`, `label`, `default`). Drives Studio editing UI and provides defaults read by `window.__hyperframes.getVariables()`. The CLI flag `hyperframes render --variables '<json>'` overrides these defaults at top-level render time; host elements override them per-instance via `data-variable-values`. |
## Element Visibility
@@ -51,6 +51,74 @@ 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,
},
};
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,
},
};
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,21 @@ 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) {
if (!window.__hfVariablesByComp) window.__hfVariablesByComp = {};
window.__hfVariablesByComp[scopeCompositionId] = merged;
}
}
for (const scriptPayload of scriptPayloads) {
const injectedScript = document.createElement("script");
if (scriptPayload.type) {
@@ -371,6 +409,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>>;
}
}