Skip to content
Open
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
124 changes: 123 additions & 1 deletion packages/core/src/compiler/htmlBundler.test.ts
Original file line number Diff line number Diff line change
Expand Up @@ -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 {
Expand Down Expand Up @@ -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.',
]);
});
});
9 changes: 8 additions & 1 deletion packages/core/src/compiler/htmlBundler.ts
Original file line number Diff line number Diff line change
@@ -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";
Expand Down Expand Up @@ -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,
Expand Down
9 changes: 9 additions & 0 deletions packages/core/src/compiler/inlineSubCompositions.ts
Original file line number Diff line number Diff line change
Expand Up @@ -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
Expand Down Expand Up @@ -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
Expand Down
138 changes: 136 additions & 2 deletions packages/core/src/runtime/getVariables.test.ts
Original file line number Diff line number Diff line change
@@ -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";

Expand Down Expand Up @@ -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);
Expand Down
Loading
Loading