diff --git a/packages/core/src/compiler/htmlBundler.test.ts b/packages/core/src/compiler/htmlBundler.test.ts
index b820d536fa..3a4f86da70 100644
--- a/packages/core/src/compiler/htmlBundler.test.ts
+++ b/packages/core/src/compiler/htmlBundler.test.ts
@@ -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 {
@@ -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": `
+
+
+
+
+
+`,
+ "compositions/card.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 -mounted composition too", async () => {
+ const dir = makeTempProject({
+ "index.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.',
+ ]);
+ });
+});
diff --git a/packages/core/src/compiler/htmlBundler.ts b/packages/core/src/compiler/htmlBundler.ts
index d7f32b77e8..c3fddf249c 100644
--- a/packages/core/src/compiler/htmlBundler.ts
+++ b/packages/core/src/compiler/htmlBundler.ts
@@ -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 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,
diff --git a/packages/core/src/compiler/inlineSubCompositions.ts b/packages/core/src/compiler/inlineSubCompositions.ts
index b7b68e8bb5..4e95b89c64 100644
--- a/packages/core/src/compiler/inlineSubCompositions.ts
+++ b/packages/core/src/compiler/inlineSubCompositions.ts
@@ -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);
}
// `` /