Files
hyperframes/packages/core/src/runtime/getVariables.test.ts
T
JamesandClaude Opus 4.7 484ab54442 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>
2026-05-04 19:41:26 +00:00

134 lines
4.4 KiB
TypeScript

/**
* @vitest-environment jsdom
*/
import { describe, it, expect, beforeEach, afterEach } from "vitest";
import { getVariables, readDeclaredDefaults } from "./getVariables";
const VARIABLES_ATTR = "data-composition-variables";
function setDeclared(json: string | null) {
if (json == null) {
document.documentElement.removeAttribute(VARIABLES_ATTR);
} else {
document.documentElement.setAttribute(VARIABLES_ATTR, json);
}
}
function setOverrides(value: unknown) {
(window as Window & { __hfVariables?: unknown }).__hfVariables = value;
}
describe("getVariables", () => {
beforeEach(() => {
setDeclared(null);
setOverrides(undefined);
});
afterEach(() => {
setDeclared(null);
setOverrides(undefined);
});
it("returns {} when nothing is declared and no overrides", () => {
expect(getVariables()).toEqual({});
});
it("returns declared defaults when no overrides", () => {
setDeclared(
JSON.stringify([
{ id: "title", type: "string", label: "Title", default: "Hello" },
{ id: "count", type: "number", label: "Count", default: 3 },
{ id: "active", type: "boolean", label: "Active", default: true },
]),
);
expect(getVariables()).toEqual({ title: "Hello", count: 3, active: true });
});
it("merges overrides over declared defaults (overrides win)", () => {
setDeclared(
JSON.stringify([
{ id: "title", type: "string", label: "Title", default: "Hello" },
{ id: "theme", type: "string", label: "Theme", default: "light" },
]),
);
setOverrides({ title: "Custom Title" });
expect(getVariables()).toEqual({ title: "Custom Title", theme: "light" });
});
it("includes override keys not declared in the schema", () => {
setDeclared(JSON.stringify([{ id: "title", type: "string", label: "Title", default: "x" }]));
setOverrides({ extra: 42 });
expect(getVariables()).toEqual({ title: "x", extra: 42 });
});
it("returns {} when the declared JSON is invalid", () => {
setDeclared("{not-json");
expect(getVariables()).toEqual({});
});
it("ignores declared entries without an id or default", () => {
setDeclared(
JSON.stringify([
{ id: "ok", type: "string", label: "Ok", default: "yes" },
{ type: "string", label: "no-id", default: "nope" },
{ id: "no-default", type: "string", label: "No default" },
"not-an-object",
null,
]),
);
expect(getVariables()).toEqual({ ok: "yes" });
});
it("ignores non-array declared payloads", () => {
setDeclared(JSON.stringify({ title: "Hello" }));
expect(getVariables()).toEqual({});
});
it("ignores non-object overrides (string, array, null)", () => {
setDeclared(JSON.stringify([{ id: "title", type: "string", label: "Title", default: "x" }]));
setOverrides("not-an-object");
expect(getVariables()).toEqual({ title: "x" });
setOverrides([1, 2, 3]);
expect(getVariables()).toEqual({ title: "x" });
setOverrides(null);
expect(getVariables()).toEqual({ title: "x" });
});
it("supports the typed generic for editor ergonomics", () => {
setDeclared(
JSON.stringify([{ id: "title", type: "string", label: "Title", default: "Hello" }]),
);
type Vars = { title: string; missing?: number };
const vars = getVariables<Vars>();
expect(vars.title).toBe("Hello");
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({});
});
});