mirror of
https://github.com/heygen-com/hyperframes.git
synced 2026-09-03 04:38:33 +00:00
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:
committed by
James Russo
co-authored by
Claude Opus 4.7
parent
03b82e6ff8
commit
484ab54442
@@ -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) {
|
||||
|
||||
@@ -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({});
|
||||
});
|
||||
});
|
||||
|
||||
@@ -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
@@ -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>>;
|
||||
}
|
||||
}
|
||||
|
||||
|
||||
Reference in New Issue
Block a user