feat(core): warn when a variable value is not a declared option

A composition can set a variable to a value outside that variable declared
enum options. The value silently falls back, the composition renders
something the author did not ask for, and no signal says a choice was
ignored.

Warns on both paths that resolve a variable, because they are separate. The
runtime guard covers a top-level composition. The compile guard covers a
sub-composition given instance values: those are baked into the variables
table at compile time and the scoped getVariables shim only reads that
table, so the runtime guard never runs there. That sub-comp case is the one
that motivated this, and it was the silent one.

Both call the same helper, so the message and the per-process dedupe set are
shared and an author sees one warning either way.

A warning rather than an error, for symmetry: the same defect must not carry
two severities depending on which mount path an author happened to use.
Escalation already has a home in lint, which a project can make blocking.

The check is split across four small helpers rather than one function:
parsing the declaration, naming the composition, reducing the option set to
comparable scalars, and deciding whether a value actually fell back. As one
function it audited at 21 cyclomatic and 25 cognitive.

Tests were mutation-checked. Five distinct breakages each killed a test,
including one that coerces the unknown value to the default instead of
warning, which would turn a diagnostic into a silent rewrite.
This commit is contained in:
Miguel Ángel
2026-08-08 20:59:54 +00:00
parent b1f7d8881b
commit ca244027c3
5 changed files with 389 additions and 5 deletions
+123 -1
View File
@@ -3,8 +3,9 @@ import { mkdtempSync, writeFileSync, mkdirSync, symlinkSync } from "node:fs";
import { tmpdir } from "node:os";
import { join } from "node:path";
import { parseHTML } from "linkedom";
import { describe, it, expect, vi } from "vitest";
import { afterEach, beforeEach, describe, it, expect, vi } from "vitest";
import { bundleToSingleHtml } from "./htmlBundler";
import { resetUnknownEnumWarnings } from "../runtime/getVariables";
import { getHyperframeRuntimeScript } from "../generated/runtime-inline";
function makeTempProject(files: Record<string, string>): string {
@@ -1388,3 +1389,124 @@ describe("bundleToSingleHtml", () => {
}
});
});
/**
* A sub-composition given a value outside a declared enum's `options` falls
* back silently. The runtime guard in getVariables.ts cannot see it: the
* bundler bakes the per-instance values into `window.__hfVariablesByComp` at
* compile time and the sub-comp's scoped `getVariables` shim only reads that
* table. Compile time is therefore the only place the defect is observable on
* this path, so the same warning is emitted here.
*/
describe("bundleToSingleHtml unknown enum values", () => {
let warnings: string[];
beforeEach(() => {
resetUnknownEnumWarnings();
warnings = [];
vi.spyOn(console, "warn").mockImplementation((...args: unknown[]) => {
warnings.push(args.map(String).join(" "));
});
});
afterEach(() => {
vi.restoreAllMocks();
resetUnknownEnumWarnings();
});
const enumWarnings = () => warnings.filter((w) => w.includes("runtime_unknown_enum_value"));
const ACCENT_ENUM =
'[{"id":"accent","type":"enum","label":"Accent","default":"green","options":["green","blue","violet"]}]';
function makeSubCompProject(variableValues: string, declaration = ACCENT_ENUM): string {
return makeTempProject({
"index.html": `<!doctype html>
<html><head></head><body>
<div id="root" data-composition-id="main" data-width="1920" data-height="1080">
<div
data-composition-id="card"
data-composition-src="compositions/card.html"
data-variable-values='${variableValues}'></div>
</div>
<script>window.__timelines={};</script>
</body></html>`,
"compositions/card.html": `<!doctype html>
<html data-composition-variables='${declaration}'>
<body>
<div data-composition-id="card" data-width="1920" data-height="1080"></div>
</body>
</html>`,
});
}
it("warns when a sub-composition instance value is not a declared option", async () => {
await bundleToSingleHtml(makeSubCompProject('{"accent":"orange"}'));
expect(enumWarnings()).toEqual([
'[hyperframes] runtime_unknown_enum_value: card variable "accent" got "orange", ' +
"which is not a declared option (green, blue, violet). " +
'Rendering "green" instead.',
]);
});
it("is silent when the instance value is a declared option", async () => {
await bundleToSingleHtml(makeSubCompProject('{"accent":"violet"}'));
expect(enumWarnings()).toEqual([]);
});
it("never inspects a variable declared without options", async () => {
const declaration = '[{"id":"accent","type":"string","label":"Accent","default":"green"}]';
await bundleToSingleHtml(makeSubCompProject('{"accent":"orange"}', declaration));
expect(enumWarnings()).toEqual([]);
});
it("is silent for a declared enum absent from the instance values", async () => {
await bundleToSingleHtml(makeSubCompProject('{"unrelated":"whatever"}'));
expect(enumWarnings()).toEqual([]);
});
it("passes the unknown value through to the bundle unrewritten", async () => {
const bundled = await bundleToSingleHtml(makeSubCompProject('{"accent":"orange"}'));
expect(bundled).toContain("window.__hfVariablesByComp = Object.assign({}, ");
expect(bundled).toContain('{ "card": { "accent": "orange" } }');
expect(bundled).toMatch(/\[data-composition-id="card"\]\s*\{[^}]*--accent:\s*orange/);
expect(bundled).not.toContain("--accent: green");
});
it("warns once for the same composition, variable and value across bundles", async () => {
const dir = makeSubCompProject('{"accent":"orange"}');
await bundleToSingleHtml(dir);
await bundleToSingleHtml(dir);
expect(enumWarnings()).toHaveLength(1);
});
it("warns for a <template>-mounted composition too", async () => {
const dir = makeTempProject({
"index.html": `<!doctype html>
<html><head></head><body>
<div id="root" data-composition-id="main" data-width="1920" data-height="1080">
<div data-composition-id="card" data-variable-values='{"accent":"orange"}'></div>
</div>
<template id="card-template">
<div data-composition-id="card" data-width="1920" data-height="1080"
data-composition-variables='${ACCENT_ENUM}'></div>
</template>
<script>window.__timelines={};</script>
</body></html>`,
});
await bundleToSingleHtml(dir);
expect(enumWarnings()).toEqual([
'[hyperframes] runtime_unknown_enum_value: card variable "accent" got "orange", ' +
"which is not a declared option (green, blue, violet). " +
'Rendering "green" instead.',
]);
});
});
+8 -1
View File
@@ -1,6 +1,6 @@
import { markFlattenedInnerRoot } from "../runtime/flattenedRoot";
export { FLATTENED_INNER_ROOT_STRIP_ATTRS } from "../runtime/flattenedRoot";
import { parseHostVariableValues } from "../runtime/getVariables";
import { parseHostVariableValues, warnUnknownEnumValues } from "../runtime/getVariables";
import { cssVariableName } from "../tokenSlug";
import { readFileSync, existsSync } from "fs";
import { resolve, relative, dirname, isAbsolute, sep } from "path";
@@ -980,6 +980,13 @@ export async function bundleToSingleHtml(
if (runtimeCompId && Object.keys(mergedVariables).length > 0) {
compVariablesByComp[runtimeCompId] = mergedVariables;
}
// Same defect on the <template> mount as on the data-composition-src
// mount (see inlineSubCompositions): the merged instance values are
// baked in here, so only compile time can see a value that falls back.
if (runtimeCompId) {
warnUnknownEnumValues(innerDoc.documentElement, mergedVariables, runtimeCompId);
warnUnknownEnumValues(innerRoot, mergedVariables, runtimeCompId);
}
pushSubCompVariableStyles(
innerDoc,
innerRoot,
@@ -22,6 +22,7 @@ import {
} from "./compositionScoping";
import { checkSubCompositionUsability } from "@hyperframes/parsers/sub-composition-validity";
import { enumerateNestedCompositionHosts, planCompositionAssembly } from "./compositionAssembly";
import { warnUnknownEnumValues } from "../runtime/getVariables";
// ---------------------------------------------------------------------------
// Public interface
@@ -272,6 +273,14 @@ export function inlineSubCompositions(
if (Object.keys(mergedVariables).length > 0) {
variablesByComp[runtimeCompId] = mergedVariables;
}
// Compile time is the only place this defect is visible on the sub-comp
// path: the instance value is baked into `__hfVariablesByComp` right
// here, and the scoped `getVariables` shim only reads that table, so the
// runtime's identical guard never runs. Same helper, so the message and
// the per-process dedupe set are shared with the runtime path and the
// author sees one warning either way.
warnUnknownEnumValues(compDoc.documentElement, mergedVariables, runtimeCompId);
warnUnknownEnumValues(innerRoot, mergedVariables, runtimeCompId);
}
// `<head>` <link>/<script src> are hoisted into the ROOT document, so they
+136 -2
View File
@@ -1,8 +1,8 @@
/**
* @vitest-environment jsdom
*/
import { describe, it, expect, beforeEach, afterEach } from "vitest";
import { getVariables, readDeclaredDefaults } from "./getVariables";
import { describe, it, expect, beforeEach, afterEach, vi } from "vitest";
import { getVariables, readDeclaredDefaults, resetUnknownEnumWarnings } from "./getVariables";
const VARIABLES_ATTR = "data-composition-variables";
@@ -204,6 +204,140 @@ describe("readDeclaredDefaults", () => {
});
});
describe("out-of-set enum values (observability only — never changes the returned map)", () => {
// One enum plus a number and a string, so "only enums are inspected" is
// pinned by the same declaration the warning reads.
const DECLARED = JSON.stringify([
{
id: "accent",
type: "enum",
label: "Accent",
default: "green",
options: [
{ value: "green", label: "Green" },
{ value: "blue", label: "Blue" },
{ value: "violet", label: "Violet" },
],
},
{ id: "swap_at", type: "number", label: "Swap at", default: 0.5 },
{ id: "title", type: "string", label: "Title", default: "Hello" },
]);
const DEFAULTS = { accent: "green", swap_at: 0.5, title: "Hello" };
let warnings: string[];
beforeEach(() => {
resetUnknownEnumWarnings();
document.body.innerHTML = "";
setDeclared(DECLARED);
document.documentElement.setAttribute("data-composition-id", "morph-swap");
setOverrides(undefined);
warnings = [];
vi.spyOn(console, "warn").mockImplementation((...args: unknown[]) => {
warnings.push(args.map(String).join(" "));
});
});
afterEach(() => {
vi.restoreAllMocks();
document.documentElement.removeAttribute("data-composition-id");
setDeclared(null);
setOverrides(undefined);
resetUnknownEnumWarnings();
});
it("a declared option warns nothing", () => {
setOverrides({ accent: "violet" });
expect(getVariables()).toEqual({ ...DEFAULTS, accent: "violet" });
expect(warnings).toEqual([]);
});
it("an absent value warns nothing — absent is the normal case", () => {
expect(getVariables()).toEqual(DEFAULTS);
expect(warnings).toEqual([]);
});
it("an enum declared without a default and never set warns nothing", () => {
setDeclared(
JSON.stringify([
{ id: "accent", type: "enum", label: "Accent", options: [{ value: "green" }] },
]),
);
expect(getVariables()).toEqual({});
expect(warnings).toEqual([]);
});
it("an out-of-set value warns once, naming composition, variable, value and fallback", () => {
setOverrides({ accent: "orange" });
expect(getVariables()).toEqual({ ...DEFAULTS, accent: "orange" });
expect(warnings).toHaveLength(1);
const message = warnings[0] ?? "";
expect(message).toContain("runtime_unknown_enum_value");
expect(message).toContain("morph-swap");
expect(message).toContain('"accent"');
expect(message).toContain('got "orange"');
expect(message).toContain("green, blue, violet");
expect(message).toContain('Rendering "green" instead');
});
it("falls back to the root composition id when the declarer carries none", () => {
// The real top-level shape: <html> declares, the root <div> has the id.
document.documentElement.removeAttribute("data-composition-id");
document.body.innerHTML = '<div data-composition-id="hero-scene"></div>';
setOverrides({ accent: "orange" });
getVariables();
expect(warnings).toHaveLength(1);
expect(warnings[0]).toContain("hero-scene");
});
it("the same bad value twice warns once; a different bad value warns again", () => {
setOverrides({ accent: "orange" });
getVariables();
getVariables();
expect(warnings).toHaveLength(1);
setOverrides({ accent: "puce" });
getVariables();
expect(warnings).toHaveLength(2);
expect(warnings[1]).toContain('got "puce"');
});
it("non-enum variables are never inspected (any number or string is legal)", () => {
setOverrides({ swap_at: 9.9, title: "anything at all" });
expect(getVariables()).toEqual({ ...DEFAULTS, swap_at: 9.9, title: "anything at all" });
expect(warnings).toEqual([]);
});
it("does not warn when the declared default is itself out of set — nothing fell back", () => {
setDeclared(
JSON.stringify([
{
id: "accent",
type: "enum",
label: "Accent",
default: "orange",
options: [{ value: "green" }, { value: "blue" }],
},
]),
);
expect(getVariables()).toEqual({ accent: "orange" });
expect(warnings).toEqual([]);
});
it("returns byte-identical values whether the value is in set or not", () => {
setOverrides({ accent: "violet" });
const good = getVariables();
setOverrides({ accent: "orange" });
const bad = getVariables();
expect(warnings).toHaveLength(1);
// Same keys, same non-enum values, and the bad value passed through
// untouched — the composition's own guard still owns the coercion.
expect(Object.keys(bad)).toEqual(Object.keys(good));
expect(bad).toEqual({ ...good, accent: "orange" });
expect(bad.accent).toBe("orange");
});
});
describe("css variable injection (figma brand-token chain)", () => {
afterEach(() => {
document.documentElement.removeAttribute(VARIABLES_ATTR);
+113 -1
View File
@@ -42,7 +42,119 @@ export function getVariables<
}
const overrides = readRenderOverrides();
return { ...declaredDefaults, ...overrides } as Partial<T>;
const merged = { ...declaredDefaults, ...overrides };
for (const el of declarers) warnUnknownEnumValues(el, merged);
return merged as Partial<T>;
}
/**
* An enum variable given a value outside its declared `options` is coerced to
* the composition's default by the composition's own guard. That fallback is
* deliberate (a bad value must never break a frame) but it was silent, so a
* meaningless value could sit in a project indefinitely and look correct only
* by coincidence. Warn; do not change what renders.
*
* Reads the option set straight off `data-composition-variables`, so it covers
* every declared enum, not just the `accentColors` guard shape, and needs no
* per-composition code. Deduped per composition+variable+value so a remount
* (studio re-init, seek) cannot spam the console.
*
* Silent for a valid value, for an absent one (an absent variable resolves to
* its own declared default), and for a value that already equals that default
* (nothing fell back).
*/
const warnedUnknownEnumValues = new Set<string>();
/** The declared entries, or `null` when the attribute is absent or unusable. */
function declaredEnumEntries(declarer: Element): Record<string, unknown>[] | null {
const raw = declarer.getAttribute("data-composition-variables");
if (!raw) return null;
let parsed: unknown;
try {
parsed = JSON.parse(raw);
} catch {
return null;
}
if (!Array.isArray(parsed)) return null;
return parsed.filter(
(entry): entry is Record<string, unknown> => !!entry && typeof entry === "object",
);
}
/** What to call the composition in the message. */
function enumWarningScopeName(declarer: Element, compositionId?: string): string {
return (
compositionId?.trim() ||
declarer.getAttribute("data-composition-id")?.trim() ||
// The canonical top-level shape declares the variables on <html>, which
// carries no id — the composition id sits on the root element below it.
// Without this the message reads "composition variable ...", naming
// nothing in a project that has more than one.
declarer.querySelector("[data-composition-id]")?.getAttribute("data-composition-id")?.trim() ||
"composition"
);
}
/** The option set as comparable scalars, dropping shapes we cannot compare. */
function allowedOptionValues(options: unknown[]): (string | number)[] {
return options
.map((option) =>
option && typeof option === "object" ? (option as Record<string, unknown>).value : option,
)
.filter((v): v is string | number => typeof v === "string" || typeof v === "number");
}
/**
* True when `value` fell back: it is set, it differs from the declared
* default, and it is not in the option set.
*
* Stringified compare: `--variables` / `data-variable-values` can deliver a
* declared numeric option as a string, and that is not the defect here.
*/
function isUnknownEnumValue(
entry: Record<string, unknown>,
value: unknown,
allowed: (string | number)[],
): boolean {
if (value === undefined || value === null) return false;
// Nothing was overridden: the resolved value IS the declared default, so no
// fallback happened and there is nothing to report. A default outside its
// own option set is a declaration defect (the linter's job) and warning here
// would print the self-contradictory "got X ... rendering X".
if ("default" in entry && String(value) === String(entry.default)) return false;
if (allowed.length === 0) return false;
return !allowed.some((v) => String(v) === String(value));
}
export function warnUnknownEnumValues(
declarer: Element | null | undefined,
resolved: Record<string, unknown>,
compositionId?: string,
): void {
if (!declarer) return;
const entries = declaredEnumEntries(declarer);
if (!entries) return;
const move = enumWarningScopeName(declarer, compositionId);
for (const entry of entries) {
if (typeof entry.id !== "string" || !Array.isArray(entry.options)) continue;
const value = resolved[entry.id];
const allowed = allowedOptionValues(entry.options);
if (!isUnknownEnumValue(entry, value, allowed)) continue;
const key = `${move}|${entry.id}|${String(value)}`;
if (warnedUnknownEnumValues.has(key)) continue;
warnedUnknownEnumValues.add(key);
const fallback = "default" in entry ? JSON.stringify(entry.default) : "the composition default";
console.warn(
`[hyperframes] runtime_unknown_enum_value: ${move} variable "${entry.id}" got ${JSON.stringify(value)}, which is not a declared option (${allowed.join(", ")}). Rendering ${fallback} instead.`,
);
}
}
/** Test-only: clear the per-page dedupe set. */
export function resetUnknownEnumWarnings(): void {
warnedUnknownEnumValues.clear();
}
/**