fix(core): align sub-composition scoping across runtime and bundler (#897)

This commit is contained in:
Phuong Le
2026-05-16 18:25:24 +02:00
committed by GitHub
parent 2355d505e1
commit be33afb361
6 changed files with 1566 additions and 134 deletions
@@ -75,6 +75,33 @@ body { margin: 0; }
expect(fakeWindow.__captured).toEqual({ title: "Pro", price: "$29" });
});
it("scoped getVariables reads from the runtime composition id when it differs", () => {
const { document } = parseHTML(`<div data-composition-id="scene"></div>`);
const fakeWindow: Record<string, unknown> = {
document,
__timelines: {},
__hfVariablesByComp: {
scene: { title: "Wrong" },
scene__hf1: { title: "Right" },
},
__hyperframes: {
getVariables: () => ({ title: "TOP-LEVEL-LEAK" }),
fitTextFontSize: () => undefined,
},
};
const wrapped = wrapScopedCompositionScript(
`window.__captured = __hyperframes.getVariables();`,
"scene",
"[HyperFrames] composition script error:",
undefined,
"scene__hf1",
);
new Function("window", wrapped)(fakeWindow);
expect(fakeWindow.__captured).toEqual({ title: "Right" });
});
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> = {
@@ -208,6 +235,124 @@ window.__selectedComp =
expect(fakeWindow.__selectedComp).toBe("scene-b");
});
it("scopes authored root id lookups after the flattened root drops its literal id", () => {
const { document } = parseHTML(`
<div data-composition-id="scene">
<div data-hf-authored-id="scene-root">
<h1 class="title">Scene</h1>
</div>
</div>
`);
const fakeWindow = {
document,
__selectedTitle: "",
__timelines: {},
};
const wrapped = wrapScopedCompositionScript(
`
window.__selectedTitle =
document.getElementById("scene-root")
?.querySelector(".title")
?.textContent || "missing";
`,
"scene",
"[HyperFrames] composition script error:",
undefined,
"scene",
"scene-root",
);
new Function("window", wrapped)(fakeWindow);
expect(fakeWindow.__selectedTitle).toBe("Scene");
});
it("does not rewrite authored root hash text inside CSS attribute values", () => {
const scoped = scopeCssToComposition(
'a[href="#scene-root"] { color: red; }',
"scene",
undefined,
"scene-root",
);
expect(scoped).toContain('[data-composition-id="scene"] a[href="#scene-root"]');
expect(scoped).not.toContain('[href="[data-hf-authored-id=');
});
it("does not rewrite authored root hash text inside querySelector attribute values", () => {
const { document } = parseHTML(`
<div data-composition-id="scene">
<a class="jump" href="#scene-root">Jump</a>
<div data-hf-authored-id="scene-root"></div>
</div>
`);
const fakeWindow = {
document,
__selectedHref: "",
__timelines: {},
};
const wrapped = wrapScopedCompositionScript(
`
window.__selectedHref =
document.querySelector('a[href="#scene-root"]')
?.getAttribute("href") || "missing";
`,
"scene",
"[HyperFrames] composition script error:",
undefined,
"scene",
"scene-root",
);
new Function("window", wrapped)(fakeWindow);
expect(fakeWindow.__selectedHref).toBe("#scene-root");
});
it("normalizes gsap.utils.selector() selectors for authored root ids and root timing attrs", () => {
const { document } = parseHTML(`
<div data-composition-id="scene" data-start="0">
<div data-hf-authored-id="scene-root">
<h1 class="title">Scene</h1>
</div>
</div>
<div data-composition-id="other" data-start="0">
<div data-hf-authored-id="scene-root">
<h1 class="title">Other</h1>
</div>
</div>
`);
const fakeWindow = {
document,
__selectedRootCount: 0,
__selectedTimedCount: 0,
__selectedTitle: "",
__timelines: {},
gsap: {
utils: {},
},
};
const wrapped = wrapScopedCompositionScript(
`
const select = gsap.utils.selector(document.querySelector('[data-composition-id="scene"]'));
window.__selectedRootCount = select('#scene-root').length;
window.__selectedTimedCount = select('[data-composition-id="scene"][data-start="0"] .title').length;
window.__selectedTitle = select('#scene-root .title')[0]?.textContent || "missing";
`,
"scene",
"[HyperFrames] composition script error:",
undefined,
"scene",
"scene-root",
);
new Function("window", "gsap", wrapped)(fakeWindow, fakeWindow.gsap);
expect(fakeWindow.__selectedRootCount).toBe(1);
expect(fakeWindow.__selectedTimedCount).toBe(1);
expect(fakeWindow.__selectedTitle).toBe("Scene");
});
it("reads scoped proxy accessors with the original target receiver", () => {
const root = {
contains(node: unknown) {
@@ -1,5 +1,7 @@
import postcss, { type AtRule, type Node, type Rule } from "postcss";
const AUTHORED_ROOT_ID_ATTR = "data-hf-authored-id";
function escapeRegExp(value: string): string {
return value.replace(/[.*+?^${}()|[\]\\]/g, "\\$&");
}
@@ -8,9 +10,101 @@ function escapeCssAttributeValue(value: string): string {
return value.replace(/\\/g, "\\\\").replace(/"/g, '\\"');
}
function scopeSelector(selector: string, scope: string, compositionId: string): string {
const selectorWithoutRootTiming = normalizeCompositionRootSelector(
function escapeCssIdentifier(value: string): string {
if (!value) return value;
const escaped = value.replace(/[^a-zA-Z0-9_-]/g, (char) => `\\${char}`);
return escaped.replace(/^-?\d/, (match) => `\\${match}`);
}
function getAuthoredRootIdSelectorForms(authoredRootId: string): string[] {
const trimmed = authoredRootId.trim();
if (!trimmed) return [];
return Array.from(new Set([trimmed, escapeCssIdentifier(trimmed)])).filter(Boolean);
}
function isSelectorNameChar(char: string | undefined): boolean {
return !!char && /[\w-]/.test(char);
}
function replaceAuthoredRootIdSelectors(
selector: string,
authoredRootId: string,
replacement: string,
): string {
const forms = getAuthoredRootIdSelectorForms(authoredRootId).sort((a, b) => b.length - a.length);
if (forms.length === 0) return selector;
let result = "";
let bracketDepth = 0;
let quote: '"' | "'" | null = null;
for (let index = 0; index < selector.length; index += 1) {
const char = selector[index];
const previousChar = index > 0 ? selector[index - 1] : "";
if (quote) {
result += char;
if (char === quote && previousChar !== "\\") {
quote = null;
}
continue;
}
if (char === '"' || char === "'") {
quote = char;
result += char;
continue;
}
if (char === "[") {
bracketDepth += 1;
result += char;
continue;
}
if (char === "]") {
bracketDepth = Math.max(0, bracketDepth - 1);
result += char;
continue;
}
if (char === "#" && bracketDepth === 0) {
const matchedForm = forms.find((form) => selector.startsWith(form, index + 1));
if (matchedForm) {
const nextChar = selector[index + 1 + matchedForm.length];
if (!isSelectorNameChar(nextChar)) {
result += replacement;
index += matchedForm.length;
continue;
}
}
}
result += char;
}
return result;
}
function normalizeAuthoredRootIdSelector(selector: string, authoredRootId?: string | null): string {
const trimmed = authoredRootId?.trim();
if (!trimmed) return selector;
return replaceAuthoredRootIdSelectors(
selector,
trimmed,
`[${AUTHORED_ROOT_ID_ATTR}="${escapeCssAttributeValue(trimmed)}"]`,
);
}
function scopeSelector(
selector: string,
scope: string,
compositionId: string,
authoredRootId?: string | null,
): string {
const selectorWithoutAuthoredRootId = normalizeAuthoredRootIdSelector(selector, authoredRootId);
const selectorWithoutRootTiming = normalizeCompositionRootSelector(
selectorWithoutAuthoredRootId,
scope,
compositionId,
);
@@ -63,6 +157,7 @@ export function scopeCssToComposition(
css: string,
compositionId: string,
scopeSelectorOverride?: string,
authoredRootId?: string | null,
): string {
const trimmedCompositionId = compositionId.trim();
if (!css || !trimmedCompositionId) return css;
@@ -74,7 +169,7 @@ export function scopeCssToComposition(
root.walkRules((rule) => {
if (isInsideGlobalAtRule(rule)) return;
rule.selectors = rule.selectors.map((selector) =>
scopeSelector(selector, scope, trimmedCompositionId),
scopeSelector(selector, scope, trimmedCompositionId, authoredRootId),
);
});
@@ -87,11 +182,13 @@ export function wrapScopedCompositionScript(
errorLabel = "[HyperFrames] composition script error:",
scopeSelectorOverride?: string,
timelineCompositionId = compositionId,
authoredRootId?: string | null,
): string {
const compositionIdLiteral = JSON.stringify(compositionId);
const timelineCompositionIdLiteral = JSON.stringify(timelineCompositionId);
const errorLabelLiteral = JSON.stringify(errorLabel);
const escapedCompositionId = escapeRegExp(compositionId);
const authoredRootIdLiteral = JSON.stringify(authoredRootId?.trim() || null);
const scopeSelectorLiteral = JSON.stringify(scopeSelectorOverride ?? null);
const rootSelectorPatternLiteral = JSON.stringify(
String.raw`\[\s*data-composition-id\s*=\s*(?:"${escapedCompositionId}"|'${escapedCompositionId}')\s*\]`,
@@ -99,10 +196,15 @@ export function wrapScopedCompositionScript(
const timingSelectorPatternLiteral = JSON.stringify(
String.raw`\s*\[\s*data-(?:start|duration)\s*=\s*(?:"[^"]*"|'[^']*')\s*\]`,
);
const authoredRootIdFormsLiteral = JSON.stringify(
getAuthoredRootIdSelectorForms(authoredRootId?.trim() || ""),
);
return `(function(){
var __hfCompId = ${compositionIdLiteral};
var __hfTimelineCompId = ${timelineCompositionIdLiteral};
var __hfErrorLabel = ${errorLabelLiteral};
var __hfAuthoredRootId = ${authoredRootIdLiteral};
var __hfAuthoredRootAttr = ${JSON.stringify(AUTHORED_ROOT_ID_ATTR)};
var __hfEscapeAttr = function(value) {
return (value + "").replace(/\\\\/g, "\\\\\\\\").replace(/"/g, "\\\\\\"");
};
@@ -112,11 +214,76 @@ export function wrapScopedCompositionScript(
var __hfRoot = null;
var __hfRootSelectorPattern = ${rootSelectorPatternLiteral};
var __hfTimingSelectorPattern = ${timingSelectorPatternLiteral};
var __hfAuthoredRootIdForms = ${authoredRootIdFormsLiteral};
var __hfAuthoredRootSelector = __hfAuthoredRootId
? "[" + __hfAuthoredRootAttr + '="' + __hfEscapeAttr(__hfAuthoredRootId) + '"]'
: "";
var __hfIsSelectorNameChar = function(char) {
return !!char && /[\\w-]/.test(char);
};
var __hfReplaceAuthoredRootIdSelectors = function(selector) {
if (!__hfAuthoredRootSelector || !__hfAuthoredRootIdForms.length || typeof selector !== "string") {
return selector;
}
var result = "";
var bracketDepth = 0;
var quote = null;
for (var index = 0; index < selector.length; index += 1) {
var char = selector[index];
var previousChar = index > 0 ? selector[index - 1] : "";
if (quote) {
result += char;
if (char === quote && previousChar !== "\\\\") {
quote = null;
}
continue;
}
if (char === '"' || char === "'") {
quote = char;
result += char;
continue;
}
if (char === "[") {
bracketDepth += 1;
result += char;
continue;
}
if (char === "]") {
bracketDepth = Math.max(0, bracketDepth - 1);
result += char;
continue;
}
if (char === "#" && bracketDepth === 0) {
var matchedForm = null;
for (var formIndex = 0; formIndex < __hfAuthoredRootIdForms.length; formIndex += 1) {
var form = __hfAuthoredRootIdForms[formIndex];
if (selector.slice(index + 1, index + 1 + form.length) === form) {
matchedForm = form;
break;
}
}
if (matchedForm) {
var nextChar = selector[index + 1 + matchedForm.length];
if (!__hfIsSelectorNameChar(nextChar)) {
result += __hfAuthoredRootSelector;
index += matchedForm.length;
continue;
}
}
}
result += char;
}
return result;
};
var __hfNormalizeSelector = function(selector) {
if (!__hfCompId || typeof selector !== "string") return selector;
return selector
var normalized = selector
.replace(new RegExp(__hfRootSelectorPattern + '(?:' + __hfTimingSelectorPattern + ')+', 'g'), __hfRootSelector)
.replace(new RegExp('(?:' + __hfTimingSelectorPattern + ')+' + __hfRootSelectorPattern, 'g'), __hfRootSelector);
if (__hfAuthoredRootSelector) {
normalized = __hfReplaceAuthoredRootIdSelectors(normalized);
}
return normalized;
};
var __hfFindRoot = function() {
if (!__hfRoot && __hfRootSelector) {
@@ -147,8 +314,15 @@ export function wrapScopedCompositionScript(
var root = __hfFindRoot();
if (!root) return found || null;
var idValue = id + "";
if (__hfAuthoredRootId && __hfAuthoredRootId === idValue && root.getAttribute && root.getAttribute(__hfAuthoredRootAttr) === idValue) {
return root;
}
if (root.id === idValue) return root;
if (typeof root.querySelector !== "function") return null;
try {
var authoredRootMatch = root.querySelector('[' + __hfAuthoredRootAttr + '="' + __hfEscapeAttr(idValue) + '"]');
if (authoredRootMatch) return authoredRootMatch;
} catch {}
if (typeof CSS !== "undefined" && CSS && typeof CSS.escape === "function") {
try {
return root.querySelector("#" + CSS.escape(idValue)) || null;
@@ -265,7 +439,12 @@ export function wrapScopedCompositionScript(
var root = baseEl || __hfFindRoot();
return function(selector) {
if (!root || typeof selector !== "string") return [];
return Array.prototype.slice.call(root.querySelectorAll(selector));
return Array.prototype.filter.call(
window.document.querySelectorAll(__hfNormalizeSelector(selector)),
function(node) {
return node === root || (typeof root.contains === "function" && root.contains(node));
},
);
};
};
}
@@ -284,7 +463,7 @@ export function wrapScopedCompositionScript(
: Object.assign({}, __hfBaseHyperframes, {
getVariables: function() {
var byComp = window.__hfVariablesByComp;
var scoped = byComp && __hfCompId ? byComp[__hfCompId] : null;
var scoped = byComp && __hfTimelineCompId ? byComp[__hfTimelineCompId] : null;
return scoped ? Object.assign({}, scoped) : {};
},
});
@@ -381,6 +381,221 @@ describe("bundleToSingleHtml", () => {
expect(bundled).toContain("__hfNormalizeSelector");
});
it("keeps an authored inner root wrapper for root id and class selectors", 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
id="scene-host"
data-composition-id="scene"
data-composition-src="compositions/scene.html"
data-start="0"
data-duration="5"></div>
</div>
<script>window.__timelines={};</script>
</body></html>`,
"compositions/scene.html": `<template id="scene-template">
<div id="scene-root" class="scene-root" data-composition-id="scene" data-width="1920" data-height="1080">
<style>
.scene-root .title { opacity: 0; }
#scene-root { font-family: Inter, sans-serif; }
</style>
<h1 class="title">Scene</h1>
</div>
</template>`,
});
const bundled = await bundleToSingleHtml(dir);
const { document } = parseHTML(bundled);
const host = document.querySelector("#scene-host");
const authoredRoot = host?.querySelector('[data-hf-authored-id="scene-root"]');
expect(host).toBeTruthy();
expect(authoredRoot).toBeTruthy();
expect(authoredRoot?.id).toBe("");
expect(authoredRoot?.getAttribute("data-composition-id")).toBeNull();
expect(authoredRoot?.getAttribute("data-hf-inner-root")).toBe("true");
expect(authoredRoot?.getAttribute("data-hf-authored-id")).toBe("scene-root");
expect(bundled).toContain('[data-composition-id="scene"] .scene-root .title');
expect(bundled).toContain('[data-composition-id="scene"] [data-hf-authored-id="scene-root"]');
});
it("does not keep duplicate authored root ids when the same external composition mounts twice", 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
id="scene-host-a"
data-composition-id="scene"
data-composition-src="compositions/scene.html"
data-start="0"
data-duration="5"></div>
<div
id="scene-host-b"
data-composition-id="scene"
data-composition-src="compositions/scene.html"
data-start="5"
data-duration="5"></div>
</div>
<script>window.__timelines={};</script>
</body></html>`,
"compositions/scene.html": `<template id="scene-template">
<div id="scene-root" class="scene-root" data-composition-id="scene" data-width="1920" data-height="1080">
<h1 class="title">Scene</h1>
</div>
</template>`,
});
const bundled = await bundleToSingleHtml(dir);
const { document } = parseHTML(bundled);
const authoredRoots = document.querySelectorAll('[data-hf-authored-id="scene-root"]');
expect(authoredRoots).toHaveLength(2);
expect(document.querySelectorAll("#scene-root")).toHaveLength(0);
expect(Array.from(authoredRoots).every((root) => !root.getAttribute("id"))).toBe(true);
});
it("mounts duplicate inline-template hosts instead of only the first one", 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 id="scene-host-a" data-composition-id="scene"></div>
<div id="scene-host-b" data-composition-id="scene"></div>
</div>
<template id="scene-template">
<div id="scene-root" data-composition-id="scene" data-width="1920" data-height="1080">
<h1 class="title">Scene</h1>
</div>
</template>
<script>window.__timelines={};</script>
</body></html>`,
});
const bundled = await bundleToSingleHtml(dir);
const { document } = parseHTML(bundled);
const hostA = document.querySelector("#scene-host-a");
const hostB = document.querySelector("#scene-host-b");
expect(hostA?.querySelector(".title")?.textContent).toBe("Scene");
expect(hostB?.querySelector(".title")?.textContent).toBe("Scene");
expect(hostA?.getAttribute("data-composition-id")).toBe("scene__hf1");
expect(hostB?.getAttribute("data-composition-id")).toBe("scene__hf2");
expect(hostA?.getAttribute("data-hf-original-composition-id")).toBe("scene");
expect(hostB?.getAttribute("data-hf-original-composition-id")).toBe("scene");
});
it("emits scoped style and script chunks for each duplicate inline-template host", 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 id="scene-host-a" data-composition-id="scene"></div>
<div id="scene-host-b" data-composition-id="scene"></div>
</div>
<template id="scene-template">
<div id="scene-root" data-composition-id="scene" data-width="1920" data-height="1080">
<style>.title { opacity: 0; }</style>
<h1 class="title">Scene</h1>
<script>
window.__timelines = window.__timelines || {};
window.__timelines.scene = { marker: "scene" };
</script>
</div>
</template>
<script>window.__timelines={};</script>
</body></html>`,
});
const bundled = await bundleToSingleHtml(dir);
expect(bundled).toContain('[data-composition-id="scene__hf1"] .title');
expect(bundled).toContain('[data-composition-id="scene__hf2"] .title');
expect(bundled).toContain('var __hfTimelineCompId = "scene__hf1"');
expect(bundled).toContain('var __hfTimelineCompId = "scene__hf2"');
});
it("uniquifies duplicate sub-compositions across inline-template and external hosts", 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 id="scene-host-inline" data-composition-id="scene"></div>
<div
id="scene-host-external"
data-composition-id="scene"
data-composition-src="compositions/scene.html"></div>
</div>
<template id="scene-template">
<div data-composition-id="scene" data-width="1920" data-height="1080">
<p>Inline scene</p>
</div>
</template>
<script>window.__timelines={};</script>
</body></html>`,
"compositions/scene.html": `<template id="scene-template">
<div data-composition-id="scene" data-width="1920" data-height="1080">
<p>External scene</p>
</div>
</template>`,
});
const bundled = await bundleToSingleHtml(dir);
const { document } = parseHTML(bundled);
const inlineHost = document.querySelector("#scene-host-inline");
const externalHost = document.querySelector("#scene-host-external");
expect(inlineHost?.getAttribute("data-composition-id")).toBe("scene__hf1");
expect(externalHost?.getAttribute("data-composition-id")).toBe("scene__hf2");
expect(inlineHost?.getAttribute("data-hf-original-composition-id")).toBe("scene");
expect(externalHost?.getAttribute("data-hf-original-composition-id")).toBe("scene");
expect(inlineHost?.querySelector("p")?.textContent).toBe("Inline scene");
expect(externalHost?.querySelector("p")?.textContent).toBe("External scene");
});
it("emits per-instance scoped variables for bundled sub-compositions", 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
id="card-a"
data-composition-id="card"
data-composition-src="compositions/card.html"
data-variable-values='{"title":"Pro"}'></div>
<div
id="card-b"
data-composition-id="card"
data-composition-src="compositions/card.html"
data-variable-values='{"title":"Enterprise"}'></div>
</div>
<script>window.__timelines={};</script>
</body></html>`,
"compositions/card.html": `<!doctype html>
<html data-composition-variables='[
{"id":"title","type":"string","label":"Title","default":"Default Title"},
{"id":"theme","type":"string","label":"Theme","default":"light"}
]'>
<body>
<div id="card-root" data-composition-id="card" data-width="1920" data-height="1080">
<script>
window.__timelines = window.__timelines || {};
window.__timelines[document.currentScript?.dataset.slot || "missing"] = __hyperframes.getVariables();
</script>
</div>
</body>
</html>`,
});
const bundled = await bundleToSingleHtml(dir);
expect(bundled).toContain("window.__hfVariablesByComp");
expect(bundled).toMatch(/card__hf1[\s\S]*Pro[\s\S]*light/);
expect(bundled).toMatch(/card__hf2[\s\S]*Enterprise[\s\S]*light/);
});
it("scopes external sub-composition styles and classic scripts", async () => {
const dir = makeTempProject({
"index.html": `<!doctype html>
+250 -91
View File
@@ -15,6 +15,7 @@ import {
import { scopeCssToComposition, wrapScopedCompositionScript } from "./compositionScoping";
import { validateHyperframeHtmlContract } from "./staticGuard";
import { getHyperframeRuntimeScript } from "../generated/runtime-inline";
import { readDeclaredDefaults } from "../runtime/getVariables";
/** Resolve a relative path within projectDir, rejecting traversal outside it. */
function safePath(projectDir: string, relativePath: string): string | null {
@@ -186,6 +187,138 @@ function uniqueCompositionId(baseId: string, index: number): string {
return `${baseId}__hf${index}`;
}
type BundledHostCompositionIdentity = {
authoredCompositionId: string | null;
runtimeCompositionId: string | null;
};
function getBundledHostCompositionIdentity(host: Element): BundledHostCompositionIdentity {
const currentCompositionId = (host.getAttribute("data-composition-id") || "").trim() || null;
const authoredCompositionId =
(host.getAttribute("data-hf-original-composition-id") || currentCompositionId || "").trim() ||
null;
return {
authoredCompositionId,
runtimeCompositionId: currentCompositionId,
};
}
function getBundledTrackedCompositionHosts(document: Document): Element[] {
const hosts = Array.from(
document.querySelectorAll<Element>("[data-composition-src], [data-composition-id]"),
);
return hosts.filter((host) => {
if (host.hasAttribute("data-composition-src")) return true;
const authoredCompositionId = getBundledHostCompositionIdentity(host).authoredCompositionId;
if (!authoredCompositionId) return false;
return !!document.getElementById(`${authoredCompositionId}-template`);
});
}
function shouldAssignBundledRuntimeCompositionId(host: Element, document: Document): boolean {
if (host.hasAttribute("data-composition-src")) return true;
const authoredCompositionId = getBundledHostCompositionIdentity(host).authoredCompositionId;
if (!authoredCompositionId) return false;
if (!document.getElementById(`${authoredCompositionId}-template`)) return false;
return host.children.length === 0;
}
function countBundledAuthoredCompositionIds(hosts: Element[]): Map<string, number> {
const counts = new Map<string, number>();
for (const host of hosts) {
const authoredCompositionId = getBundledHostCompositionIdentity(host).authoredCompositionId;
if (!authoredCompositionId) continue;
counts.set(authoredCompositionId, (counts.get(authoredCompositionId) || 0) + 1);
}
return counts;
}
function assignBundledRuntimeCompositionIds(
hosts: Element[],
counts: Map<string, number> = countBundledAuthoredCompositionIds(hosts),
): Map<Element, BundledHostCompositionIdentity> {
const instanceByCompositionId = new Map<string, number>();
const identities = new Map<Element, BundledHostCompositionIdentity>();
for (const host of hosts) {
const { authoredCompositionId, runtimeCompositionId: previousRuntimeCompositionId } =
getBundledHostCompositionIdentity(host);
const shouldAssign = shouldAssignBundledRuntimeCompositionId(host, host.ownerDocument);
if (!authoredCompositionId) {
identities.set(host, {
authoredCompositionId: null,
runtimeCompositionId: previousRuntimeCompositionId,
});
continue;
}
const duplicateInstance = (counts.get(authoredCompositionId) || 0) > 1;
let runtimeCompositionId = previousRuntimeCompositionId || authoredCompositionId;
if (shouldAssign) {
const instanceIndex = duplicateInstance
? (instanceByCompositionId.get(authoredCompositionId) || 0) + 1
: 0;
if (duplicateInstance) {
instanceByCompositionId.set(authoredCompositionId, instanceIndex);
host.setAttribute("data-hf-original-composition-id", authoredCompositionId);
} else {
host.removeAttribute("data-hf-original-composition-id");
}
runtimeCompositionId = duplicateInstance
? uniqueCompositionId(authoredCompositionId, instanceIndex)
: authoredCompositionId;
host.setAttribute("data-composition-id", runtimeCompositionId);
}
identities.set(host, {
authoredCompositionId,
runtimeCompositionId,
});
}
return identities;
}
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>;
}
const FLATTENED_INNER_ROOT_STRIP_ATTRS = [
"data-composition-id",
"data-composition-file",
"data-start",
"data-duration",
"data-end",
"data-track-index",
"data-track",
"data-composition-src",
"data-hf-authored-duration",
"data-hf-authored-end",
];
function prepareFlattenedInnerRoot(innerRoot: Element): Element {
const prepared = innerRoot.cloneNode(true) as Element;
const authoredRootId = prepared.getAttribute("id")?.trim();
for (const attrName of FLATTENED_INNER_ROOT_STRIP_ATTRS) {
prepared.removeAttribute(attrName);
}
if (authoredRootId) {
prepared.removeAttribute("id");
prepared.setAttribute("data-hf-authored-id", authoredRootId);
}
prepared.setAttribute("data-hf-inner-root", "true");
return prepared;
}
function enforceCompositionPixelSizing(document: Document): void {
const compositionEls = [
...document.querySelectorAll("[data-composition-id][data-width][data-height]"),
@@ -452,14 +585,12 @@ export async function bundleToSingleHtml(
const compStyleChunks: string[] = [];
const compScriptChunks: string[] = [];
const compExternalScriptSrcs: string[] = [];
const subCompositionHosts = [...document.querySelectorAll("[data-composition-src]")];
const hostCountsByCompositionId = new Map<string, number>();
for (const hostEl of subCompositionHosts) {
const compId = (hostEl.getAttribute("data-composition-id") || "").trim();
if (!compId) continue;
hostCountsByCompositionId.set(compId, (hostCountsByCompositionId.get(compId) || 0) + 1);
}
const hostInstanceByCompositionId = new Map<string, number>();
const compVariablesByComp: Record<string, Record<string, unknown>> = {};
const trackedCompositionHosts = getBundledTrackedCompositionHosts(document);
const hostIdentityByElement = assignBundledRuntimeCompositionIds(trackedCompositionHosts);
const subCompositionHosts = trackedCompositionHosts.filter((host) =>
host.hasAttribute("data-composition-src"),
);
for (const hostEl of subCompositionHosts) {
const src = hostEl.getAttribute("data-composition-src");
if (!src || !isRelativeUrl(src)) continue;
@@ -471,7 +602,9 @@ export async function bundleToSingleHtml(
}
const compDoc = parseHTMLContent(compHtml);
const compId = hostEl.getAttribute("data-composition-id");
const hostIdentity = hostIdentityByElement.get(hostEl);
const compId = hostIdentity?.authoredCompositionId || null;
const runtimeCompId = hostIdentity?.runtimeCompositionId || compId || "";
const contentRoot = compDoc.querySelector("template");
const contentHtml = contentRoot ? contentRoot.innerHTML || "" : compDoc.body.innerHTML || "";
const contentDoc = parseHTMLContent(contentHtml);
@@ -479,22 +612,19 @@ export async function bundleToSingleHtml(
? contentDoc.querySelector(`[data-composition-id="${compId}"]`)
: contentDoc.querySelector("[data-composition-id]");
const inferredCompId = innerRoot?.getAttribute("data-composition-id")?.trim() || "";
const authoredRootId = innerRoot?.getAttribute("id")?.trim() || null;
const scopeCompId = compId || inferredCompId;
const duplicateInstance = scopeCompId && (hostCountsByCompositionId.get(scopeCompId) || 0) > 1;
const instanceIndex = duplicateInstance
? (hostInstanceByCompositionId.get(scopeCompId) || 0) + 1
: 0;
if (duplicateInstance) hostInstanceByCompositionId.set(scopeCompId, instanceIndex);
const runtimeCompId =
duplicateInstance && scopeCompId
? uniqueCompositionId(scopeCompId, instanceIndex)
: scopeCompId;
const runtimeScope = runtimeCompId
? cssAttributeSelector("data-composition-id", runtimeCompId)
: "";
if (duplicateInstance && runtimeCompId) {
hostEl.setAttribute("data-hf-original-composition-id", scopeCompId);
hostEl.setAttribute("data-composition-id", runtimeCompId);
const mergedVariables = runtimeCompId
? {
...readDeclaredDefaults(compDoc.documentElement),
...parseHostVariableValues(hostEl),
}
: {};
if (runtimeCompId && Object.keys(mergedVariables).length > 0) {
compVariablesByComp[runtimeCompId] = mergedVariables;
}
// When a sub-composition is a full HTML document (no <template>), styles
@@ -505,7 +635,7 @@ export async function bundleToSingleHtml(
for (const s of [...compDoc.head.querySelectorAll("style")]) {
const css = rewriteCssAssetUrls(s.textContent || "", src);
compStyleChunks.push(
scopeCompId ? scopeCssToComposition(css, scopeCompId, runtimeScope) : css,
scopeCompId ? scopeCssToComposition(css, scopeCompId, runtimeScope, authoredRootId) : css,
);
}
for (const s of [...compDoc.head.querySelectorAll("script")]) {
@@ -519,7 +649,7 @@ export async function bundleToSingleHtml(
for (const s of [...contentDoc.querySelectorAll("style")]) {
const css = rewriteCssAssetUrls(s.textContent || "", src);
compStyleChunks.push(
scopeCompId ? scopeCssToComposition(css, scopeCompId, runtimeScope) : css,
scopeCompId ? scopeCssToComposition(css, scopeCompId, runtimeScope, authoredRootId) : css,
);
s.remove();
}
@@ -540,6 +670,7 @@ export async function bundleToSingleHtml(
"[HyperFrames] composition script error:",
runtimeScope,
runtimeCompId || scopeCompId,
authoredRootId,
)
: `(function(){ try { ${s.textContent || ""} } catch (_err) { console.error('[HyperFrames] composition script error:', _err); } })();`,
);
@@ -579,7 +710,8 @@ export async function bundleToSingleHtml(
if (innerH && !hostEl.getAttribute("data-height")) hostEl.setAttribute("data-height", innerH);
innerRoot.setAttribute("data-composition-file", src);
for (const child of [...innerRoot.querySelectorAll("style, script")]) child.remove();
hostEl.innerHTML = compId ? innerRoot.innerHTML || "" : innerRoot.outerHTML || "";
const preparedInnerRoot = prepareFlattenedInnerRoot(innerRoot);
hostEl.innerHTML = preparedInnerRoot.outerHTML || "";
} else {
for (const child of [...contentDoc.querySelectorAll("style, script")]) child.remove();
hostEl.innerHTML = contentDoc.body.innerHTML || "";
@@ -590,88 +722,110 @@ export async function bundleToSingleHtml(
// Inline template compositions: inject <template id="X-template"> content into
// matching empty host elements with data-composition-id="X" (no data-composition-src)
const candidateInlineHosts = trackedCompositionHosts.filter(
(host) => !host.hasAttribute("data-composition-src"),
);
for (const templateEl of [...document.querySelectorAll("template[id]")]) {
const templateId = templateEl.getAttribute("id") || "";
const match = templateId.match(/^(.+)-template$/);
if (!match) continue;
const compId = match[1];
if (!compId) continue;
// Find the matching host element (must have data-composition-id, no data-composition-src,
// and must NOT be inside a <template> element).
const hostSelector = `[data-composition-id="${compId}"]:not([data-composition-src])`;
// linkedom follows the DOM spec: querySelectorAll does not reach inside <template>
// content, so no isInsideTemplate filter is needed.
const host = document.querySelector(hostSelector);
if (!host) continue;
if (host.children.length > 0) continue; // already has content
const hosts = candidateInlineHosts.filter(
(host) =>
hostIdentityByElement.get(host)?.authoredCompositionId === compId &&
host.children.length === 0,
);
if (hosts.length === 0) continue;
// Get template content and inject into host
const templateHtml = templateEl.innerHTML || "";
const innerDoc = parseHTMLContent(templateHtml);
const innerRoot = innerDoc.querySelector(`[data-composition-id="${compId}"]`);
if (innerRoot) {
// Hoist styles into the collected style chunks
for (const styleEl of [...innerRoot.querySelectorAll("style")]) {
const css = styleEl.textContent || "";
compStyleChunks.push(compId ? scopeCssToComposition(css, compId) : css);
styleEl.remove();
for (const host of hosts) {
const hostIdentity = hostIdentityByElement.get(host);
const runtimeCompId = hostIdentity?.runtimeCompositionId || compId;
const innerDoc = parseHTMLContent(templateHtml);
const innerRoot = innerDoc.querySelector(`[data-composition-id="${compId}"]`);
const authoredRootId = innerRoot?.getAttribute("id")?.trim() || null;
const runtimeScope = runtimeCompId
? cssAttributeSelector("data-composition-id", runtimeCompId)
: "";
const mergedVariables = runtimeCompId ? parseHostVariableValues(host) : {};
if (runtimeCompId && Object.keys(mergedVariables).length > 0) {
compVariablesByComp[runtimeCompId] = mergedVariables;
}
// Hoist scripts into the collected script chunks
for (const scriptEl of [...innerRoot.querySelectorAll("script")]) {
const externalSrc = (scriptEl.getAttribute("src") || "").trim();
if (externalSrc) {
if (!compExternalScriptSrcs.includes(externalSrc)) {
compExternalScriptSrcs.push(externalSrc);
}
} else {
compScriptChunks.push(
compId
? wrapScopedCompositionScript(
scriptEl.textContent || "",
compId,
"[HyperFrames] composition script error:",
)
: `(function(){ try { ${scriptEl.textContent || ""} } catch (_err) { console.error('[HyperFrames] composition script error:', _err); } })();`,
if (innerRoot) {
// Hoist styles into the collected style chunks
for (const styleEl of [...innerRoot.querySelectorAll("style")]) {
const css = styleEl.textContent || "";
compStyleChunks.push(
compId ? scopeCssToComposition(css, compId, runtimeScope, authoredRootId) : css,
);
styleEl.remove();
}
scriptEl.remove();
}
// Copy dimension attributes from inner root to host if not already set
const innerW = innerRoot.getAttribute("data-width");
const innerH = innerRoot.getAttribute("data-height");
if (innerW && !host.getAttribute("data-width")) host.setAttribute("data-width", innerW);
if (innerH && !host.getAttribute("data-height")) host.setAttribute("data-height", innerH);
host.innerHTML = innerRoot.innerHTML || "";
} else {
// No matching inner root — inject all template content directly
for (const styleEl of [...innerDoc.querySelectorAll("style")]) {
const css = styleEl.textContent || "";
compStyleChunks.push(compId ? scopeCssToComposition(css, compId) : css);
styleEl.remove();
}
for (const scriptEl of [...innerDoc.querySelectorAll("script")]) {
const externalSrc = (scriptEl.getAttribute("src") || "").trim();
if (externalSrc) {
if (!compExternalScriptSrcs.includes(externalSrc)) {
compExternalScriptSrcs.push(externalSrc);
// Hoist scripts into the collected script chunks
for (const scriptEl of [...innerRoot.querySelectorAll("script")]) {
const externalSrc = (scriptEl.getAttribute("src") || "").trim();
if (externalSrc) {
if (!compExternalScriptSrcs.includes(externalSrc)) {
compExternalScriptSrcs.push(externalSrc);
}
} else {
compScriptChunks.push(
compId
? wrapScopedCompositionScript(
scriptEl.textContent || "",
compId,
"[HyperFrames] composition script error:",
runtimeScope,
runtimeCompId || compId,
authoredRootId,
)
: `(function(){ try { ${scriptEl.textContent || ""} } catch (_err) { console.error('[HyperFrames] composition script error:', _err); } })();`,
);
}
} else {
compScriptChunks.push(
compId
? wrapScopedCompositionScript(
scriptEl.textContent || "",
compId,
"[HyperFrames] composition script error:",
)
: `(function(){ try { ${scriptEl.textContent || ""} } catch (_err) { console.error('[HyperFrames] composition script error:', _err); } })();`,
);
scriptEl.remove();
}
scriptEl.remove();
// Copy dimension attributes from inner root to host if not already set
const innerW = innerRoot.getAttribute("data-width");
const innerH = innerRoot.getAttribute("data-height");
if (innerW && !host.getAttribute("data-width")) host.setAttribute("data-width", innerW);
if (innerH && !host.getAttribute("data-height")) host.setAttribute("data-height", innerH);
const preparedInnerRoot = prepareFlattenedInnerRoot(innerRoot);
host.innerHTML = preparedInnerRoot.outerHTML || "";
} else {
// No matching inner root — inject all template content directly
for (const styleEl of [...innerDoc.querySelectorAll("style")]) {
const css = styleEl.textContent || "";
compStyleChunks.push(compId ? scopeCssToComposition(css, compId, runtimeScope) : css);
styleEl.remove();
}
for (const scriptEl of [...innerDoc.querySelectorAll("script")]) {
const externalSrc = (scriptEl.getAttribute("src") || "").trim();
if (externalSrc) {
if (!compExternalScriptSrcs.includes(externalSrc)) {
compExternalScriptSrcs.push(externalSrc);
}
} else {
compScriptChunks.push(
compId
? wrapScopedCompositionScript(
scriptEl.textContent || "",
compId,
"[HyperFrames] composition script error:",
runtimeScope,
runtimeCompId || compId,
)
: `(function(){ try { ${scriptEl.textContent || ""} } catch (_err) { console.error('[HyperFrames] composition script error:', _err); } })();`,
);
}
scriptEl.remove();
}
host.innerHTML = innerDoc.body.innerHTML || "";
}
host.innerHTML = innerDoc.body.innerHTML || "";
}
// Remove the template element from the document
@@ -693,6 +847,11 @@ export async function bundleToSingleHtml(
style.textContent = compStyleChunks.join("\n\n");
document.head.appendChild(style);
}
if (Object.keys(compVariablesByComp).length > 0) {
compScriptChunks.unshift(
`window.__hfVariablesByComp = Object.assign({}, window.__hfVariablesByComp || {}, ${JSON.stringify(compVariablesByComp)});`,
);
}
if (compScriptChunks.length) {
const compScript = document.createElement("script");
compScript.textContent = joinJsChunks(compScriptChunks);
@@ -17,6 +17,9 @@ describe("loadExternalCompositions", () => {
document.head.querySelectorAll("style").forEach((s) => s.remove());
delete (window as Window & { gsap?: unknown; __selectedTitle?: unknown }).gsap;
delete (window as Window & { gsap?: unknown; __selectedTitle?: unknown }).__selectedTitle;
delete (window as Window & { __hyperframes?: unknown }).__hyperframes;
delete (window as Window & { __timelines?: unknown }).__timelines;
delete (window as WindowWithScopedVars).__hfVariablesByComp;
vi.restoreAllMocks();
});
@@ -243,6 +246,261 @@ describe("loadExternalCompositions", () => {
).toBe(false);
});
it("preserves the authored inner root wrapper for class and id scoped styles", async () => {
const host = document.createElement("div");
host.setAttribute("data-composition-src", "https://example.com/comp.html");
host.setAttribute("data-composition-id", "scene");
document.body.appendChild(host);
const compositionHtml = `
<html><body>
<div id="scene-root" class="scene-root" data-composition-id="scene" data-width="1920" data-height="1080">
<style>
.scene-root .title { opacity: 0; }
#scene-root { font-family: Inter, sans-serif; }
</style>
<h1 class="title">Scene</h1>
</div>
</body></html>
`;
vi.spyOn(globalThis, "fetch").mockResolvedValue(new Response(compositionHtml, { status: 200 }));
const injectedStyles: HTMLStyleElement[] = [];
const injectedScripts: HTMLScriptElement[] = [];
await loadExternalCompositions({
...defaultParams,
injectedStyles,
injectedScripts,
});
const authoredRoot = host.querySelector('[data-hf-authored-id="scene-root"]');
expect(authoredRoot).toBeTruthy();
expect(authoredRoot?.id).toBe("");
expect(authoredRoot?.getAttribute("data-composition-id")).toBeNull();
expect(authoredRoot?.getAttribute("data-hf-inner-root")).toBe("true");
expect(authoredRoot?.getAttribute("data-hf-authored-id")).toBe("scene-root");
expect(injectedStyles[0]?.textContent).toContain(
'[data-composition-id="scene"] .scene-root .title',
);
expect(injectedStyles[0]?.textContent).toContain(
'[data-composition-id="scene"] [data-hf-authored-id="scene-root"]',
);
});
it("does not keep duplicate authored root ids when the same external composition mounts twice", async () => {
const hostA = document.createElement("div");
hostA.setAttribute("data-composition-src", "https://example.com/comp.html");
hostA.setAttribute("data-composition-id", "scene");
document.body.appendChild(hostA);
const hostB = document.createElement("div");
hostB.setAttribute("data-composition-src", "https://example.com/comp.html");
hostB.setAttribute("data-composition-id", "scene");
document.body.appendChild(hostB);
const compositionHtml = `
<html><body>
<div id="scene-root" class="scene-root" data-composition-id="scene" data-width="1920" data-height="1080">
<h1 class="title">Scene</h1>
</div>
</body></html>
`;
vi.spyOn(globalThis, "fetch").mockImplementation(
async () => new Response(compositionHtml, { status: 200 }),
);
await loadExternalCompositions({ ...defaultParams });
const authoredRoots = document.querySelectorAll('[data-hf-authored-id="scene-root"]');
expect(authoredRoots).toHaveLength(2);
expect(document.querySelectorAll("#scene-root")).toHaveLength(0);
expect(Array.from(authoredRoots).every((root) => !root.getAttribute("id"))).toBe(true);
});
it("isolates sibling instances of the same external sub-composition at runtime", async () => {
const hostA = document.createElement("div");
hostA.setAttribute("data-composition-src", "https://example.com/scene.html");
hostA.setAttribute("data-composition-id", "scene");
hostA.setAttribute("data-variable-values", '{"title":"Scene A"}');
document.body.appendChild(hostA);
const hostB = document.createElement("div");
hostB.setAttribute("data-composition-src", "https://example.com/scene.html");
hostB.setAttribute("data-composition-id", "scene");
hostB.setAttribute("data-variable-values", '{"title":"Scene B"}');
document.body.appendChild(hostB);
const compositionHtml = `
<html><body>
<div id="scene-root" data-composition-id="scene" data-width="1920" data-height="1080">
<style>[data-composition-id="scene"] .title { opacity: 0; }</style>
<h1 class="title">Default</h1>
<script>
const titleEl = document.querySelector(".title");
const runtimeId = titleEl?.closest("[data-composition-id]")?.getAttribute("data-composition-id") || "missing";
if (titleEl) titleEl.textContent = runtimeId;
window.__timelines = window.__timelines || {};
window.__timelines["scene"] = { marker: runtimeId };
</script>
</div>
</body></html>
`;
vi.spyOn(globalThis, "fetch").mockImplementation(
async () => new Response(compositionHtml, { status: 200 }),
);
const injectedScripts: HTMLScriptElement[] = [];
await loadExternalCompositions({
...defaultParams,
injectedScripts,
});
const runtimeIdA = hostA.getAttribute("data-composition-id") ?? "";
const runtimeIdB = hostB.getAttribute("data-composition-id") ?? "";
const variables =
(window as Window & { __hfVariablesByComp?: Record<string, { title?: string }> })
.__hfVariablesByComp ?? {};
expect(runtimeIdA).not.toBe("scene");
expect(runtimeIdB).not.toBe("scene");
expect(runtimeIdA).not.toBe(runtimeIdB);
expect(hostA.getAttribute("data-hf-original-composition-id")).toBe("scene");
expect(hostB.getAttribute("data-hf-original-composition-id")).toBe("scene");
expect(hostA.querySelector(".title")?.textContent).toBe(runtimeIdA);
expect(hostB.querySelector(".title")?.textContent).toBe(runtimeIdB);
expect(variables[runtimeIdA]?.title).toBe("Scene A");
expect(variables[runtimeIdB]?.title).toBe("Scene B");
expect(
injectedScripts.some((script) =>
script.textContent?.includes(`var __hfTimelineCompId = "${runtimeIdA}"`),
),
).toBe(true);
expect(
injectedScripts.some((script) =>
script.textContent?.includes(`var __hfTimelineCompId = "${runtimeIdB}"`),
),
).toBe(true);
});
it("keeps the authored composition id stable across repeat loadExternalCompositions runs", async () => {
const hostA = document.createElement("div");
hostA.setAttribute("data-composition-src", "https://example.com/scene.html");
hostA.setAttribute("data-composition-id", "scene");
document.body.appendChild(hostA);
const hostB = document.createElement("div");
hostB.setAttribute("data-composition-src", "https://example.com/scene.html");
hostB.setAttribute("data-composition-id", "scene");
document.body.appendChild(hostB);
const compositionHtml = `
<html><body>
<div id="scene-root" data-composition-id="scene" data-width="1920" data-height="1080">
<h1 class="title">Scene</h1>
</div>
</body></html>
`;
vi.spyOn(globalThis, "fetch").mockImplementation(
async () => new Response(compositionHtml, { status: 200 }),
);
await loadExternalCompositions({ ...defaultParams });
const runtimeIdA1 = hostA.getAttribute("data-composition-id");
const runtimeIdB1 = hostB.getAttribute("data-composition-id");
expect(hostA.getAttribute("data-hf-original-composition-id")).toBe("scene");
expect(hostB.getAttribute("data-hf-original-composition-id")).toBe("scene");
await loadExternalCompositions({ ...defaultParams });
expect(hostA.getAttribute("data-hf-original-composition-id")).toBe("scene");
expect(hostB.getAttribute("data-hf-original-composition-id")).toBe("scene");
expect(hostA.getAttribute("data-composition-id")).toBe(runtimeIdA1);
expect(hostB.getAttribute("data-composition-id")).toBe(runtimeIdB1);
expect(hostA.querySelector('[data-hf-authored-id="scene-root"]')).toBeTruthy();
expect(hostB.querySelector('[data-hf-authored-id="scene-root"]')).toBeTruthy();
});
it("normalizes a runtime composition id back to the authored id when only one host remains", async () => {
const hostA = document.createElement("div");
hostA.setAttribute("data-composition-src", "https://example.com/scene.html");
hostA.setAttribute("data-composition-id", "scene");
document.body.appendChild(hostA);
const hostB = document.createElement("div");
hostB.setAttribute("data-composition-src", "https://example.com/scene.html");
hostB.setAttribute("data-composition-id", "scene");
document.body.appendChild(hostB);
const compositionHtml = `
<html><body>
<div id="scene-root" data-composition-id="scene" data-width="1920" data-height="1080">
<h1 class="title">Scene</h1>
</div>
</body></html>
`;
vi.spyOn(globalThis, "fetch").mockImplementation(
async () => new Response(compositionHtml, { status: 200 }),
);
await loadExternalCompositions({ ...defaultParams });
expect(hostB.getAttribute("data-composition-id")).toBe("scene__hf2");
expect(hostB.getAttribute("data-hf-original-composition-id")).toBe("scene");
hostA.remove();
await loadExternalCompositions({ ...defaultParams });
expect(hostB.getAttribute("data-composition-id")).toBe("scene");
expect(hostB.hasAttribute("data-hf-original-composition-id")).toBe(false);
expect(hostB.querySelector('[data-hf-authored-id="scene-root"]')).toBeTruthy();
});
it("clears stale variable entries when a host runtime composition id changes", async () => {
const hostA = document.createElement("div");
hostA.setAttribute("data-composition-src", "https://example.com/scene.html");
hostA.setAttribute("data-composition-id", "scene");
document.body.appendChild(hostA);
const hostB = document.createElement("div");
hostB.setAttribute("data-composition-src", "https://example.com/scene.html");
hostB.setAttribute("data-composition-id", "scene");
hostB.setAttribute("data-variable-values", '{"title":"Scene B"}');
document.body.appendChild(hostB);
const compositionHtml = `
<html><body>
<div id="scene-root" data-composition-id="scene" data-width="1920" data-height="1080">
<h1 class="title">Scene</h1>
</div>
</body></html>
`;
vi.spyOn(globalThis, "fetch").mockImplementation(
async () => new Response(compositionHtml, { status: 200 }),
);
await loadExternalCompositions({ ...defaultParams });
const byCompAfterFirstMount = (window as WindowWithScopedVars).__hfVariablesByComp ?? {};
expect(byCompAfterFirstMount["scene__hf2"]).toEqual({ title: "Scene B" });
hostA.remove();
hostB.innerHTML = "";
await loadExternalCompositions({ ...defaultParams });
const byCompAfterSecondMount = (window as WindowWithScopedVars).__hfVariablesByComp ?? {};
expect(byCompAfterSecondMount["scene"]).toEqual({ title: "Scene B" });
expect(byCompAfterSecondMount["scene__hf2"]).toBeUndefined();
});
it("handles multiple compositions in parallel", async () => {
const host1 = document.createElement("div");
host1.setAttribute("data-composition-src", "https://example.com/a.html");
@@ -348,6 +606,42 @@ describe("loadExternalCompositions", () => {
expect(byComp?.["card-empty"]).toBeUndefined();
});
it("clears stale registered variables when a repeat mount has no values left", async () => {
const host = document.createElement("div");
host.setAttribute("data-composition-src", "https://example.com/card.html");
host.setAttribute("data-composition-id", "card-clear");
host.setAttribute("data-variable-values", '{"title":"Pro"}');
document.body.appendChild(host);
const firstCompositionHtml = `
<html data-composition-variables='[
{"id":"title","type":"string","label":"Title","default":"Default Title"}
]'>
<body><div data-composition-id="card-clear"><p>x</p></div></body>
</html>
`;
const secondCompositionHtml = `
<html><body><div data-composition-id="card-clear"><p>y</p></div></body></html>
`;
vi.spyOn(globalThis, "fetch")
.mockResolvedValueOnce(new Response(firstCompositionHtml, { status: 200 }))
.mockResolvedValueOnce(new Response(secondCompositionHtml, { status: 200 }));
await loadExternalCompositions({ ...defaultParams });
const byCompAfterFirstMount = (window as WindowWithScopedVars).__hfVariablesByComp ?? {};
expect(byCompAfterFirstMount["card-clear"]).toEqual({ title: "Pro" });
host.removeAttribute("data-variable-values");
host.innerHTML = "";
await loadExternalCompositions({ ...defaultParams });
const byCompAfterSecondMount = (window as WindowWithScopedVars).__hfVariablesByComp;
expect(byCompAfterSecondMount?.["card-clear"]).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");
@@ -401,6 +695,76 @@ describe("loadExternalCompositions", () => {
expect(byComp["card-A"]).toEqual({ title: "Pro", price: "$29" });
expect(byComp["card-B"]).toEqual({ title: "Enterprise", price: "Custom" });
});
it("clears stale variable entries when a previous host was removed from the DOM", async () => {
const hostA = document.createElement("div");
hostA.setAttribute("data-composition-src", "https://example.com/card-a.html");
hostA.setAttribute("data-composition-id", "card-a");
hostA.setAttribute("data-variable-values", '{"title":"A"}');
document.body.appendChild(hostA);
const hostB = document.createElement("div");
hostB.setAttribute("data-composition-src", "https://example.com/card-b.html");
hostB.setAttribute("data-composition-id", "card-b");
hostB.setAttribute("data-variable-values", '{"title":"B"}');
document.body.appendChild(hostB);
vi.spyOn(globalThis, "fetch").mockImplementation(async (input) => {
const url = String(input);
if (url.includes("card-a.html")) {
return new Response(
`<html><body><div data-composition-id="card-a"><p>A</p></div></body></html>`,
{ status: 200 },
);
}
return new Response(
`<html><body><div data-composition-id="card-b"><p>B</p></div></body></html>`,
{ status: 200 },
);
});
await loadExternalCompositions({ ...defaultParams });
const byCompAfterFirstMount = (window as WindowWithScopedVars).__hfVariablesByComp ?? {};
expect(byCompAfterFirstMount["card-a"]).toEqual({ title: "A" });
expect(byCompAfterFirstMount["card-b"]).toEqual({ title: "B" });
hostB.remove();
hostA.innerHTML = "";
await loadExternalCompositions({ ...defaultParams });
const byCompAfterSecondMount = (window as WindowWithScopedVars).__hfVariablesByComp ?? {};
expect(byCompAfterSecondMount["card-a"]).toEqual({ title: "A" });
expect(byCompAfterSecondMount["card-b"]).toBeUndefined();
});
it("clears stale variable entries when the last host was removed from the DOM", async () => {
const host = document.createElement("div");
host.setAttribute("data-composition-src", "https://example.com/card-last.html");
host.setAttribute("data-composition-id", "card-last");
host.setAttribute("data-variable-values", '{"title":"Last"}');
document.body.appendChild(host);
vi.spyOn(globalThis, "fetch").mockResolvedValue(
new Response(
`<html><body><div data-composition-id="card-last"><p>Last</p></div></body></html>`,
{ status: 200 },
),
);
await loadExternalCompositions({ ...defaultParams });
const byCompAfterFirstMount = (window as WindowWithScopedVars).__hfVariablesByComp ?? {};
expect(byCompAfterFirstMount["card-last"]).toEqual({ title: "Last" });
host.remove();
await loadExternalCompositions({ ...defaultParams });
const byCompAfterSecondMount = (window as WindowWithScopedVars).__hfVariablesByComp;
expect(byCompAfterSecondMount?.["card-last"]).toBeUndefined();
});
});
});
@@ -606,4 +970,187 @@ describe("loadInlineTemplateCompositions", () => {
expect(host.getAttribute("data-width")).toBe("1920");
expect(host.getAttribute("data-height")).toBe("1080");
});
it("keeps authored template lookup stable across repeat inline loads for duplicate hosts", async () => {
const template = document.createElement("template");
template.id = "scene-template";
template.innerHTML = `
<div id="scene-root" data-composition-id="scene" data-width="1920" data-height="1080">
<p>Inline scene</p>
</div>
`;
document.body.appendChild(template);
const hostA = document.createElement("div");
hostA.setAttribute("data-composition-id", "scene");
document.body.appendChild(hostA);
const hostB = document.createElement("div");
hostB.setAttribute("data-composition-id", "scene");
document.body.appendChild(hostB);
await loadInlineTemplateCompositions({ ...defaultParams });
const runtimeIdA = hostA.getAttribute("data-composition-id");
const runtimeIdB = hostB.getAttribute("data-composition-id");
expect(runtimeIdA).not.toBe("scene");
expect(runtimeIdB).not.toBe("scene");
expect(runtimeIdA).not.toBe(runtimeIdB);
expect(hostA.getAttribute("data-hf-original-composition-id")).toBe("scene");
expect(hostB.getAttribute("data-hf-original-composition-id")).toBe("scene");
hostA.innerHTML = "";
hostB.innerHTML = "";
await loadInlineTemplateCompositions({ ...defaultParams });
expect(hostA.getAttribute("data-hf-original-composition-id")).toBe("scene");
expect(hostB.getAttribute("data-hf-original-composition-id")).toBe("scene");
expect(hostA.getAttribute("data-composition-id")).toBe(runtimeIdA);
expect(hostB.getAttribute("data-composition-id")).toBe(runtimeIdB);
expect(hostA.querySelector('[data-hf-authored-id="scene-root"]')).toBeTruthy();
expect(hostB.querySelector('[data-hf-authored-id="scene-root"]')).toBeTruthy();
});
it("does not rewrite ids for duplicate inline hosts that are skipped", async () => {
const filledTemplate = document.createElement("template");
filledTemplate.id = "filled-scene-template";
filledTemplate.innerHTML = `
<div data-composition-id="filled-scene" data-width="1920" data-height="1080">
<p>Filled scene</p>
</div>
`;
document.body.appendChild(filledTemplate);
const filledHostA = document.createElement("div");
filledHostA.setAttribute("data-composition-id", "filled-scene");
filledHostA.innerHTML = "<span>Existing A</span>";
document.body.appendChild(filledHostA);
const filledHostB = document.createElement("div");
filledHostB.setAttribute("data-composition-id", "filled-scene");
filledHostB.innerHTML = "<span>Existing B</span>";
document.body.appendChild(filledHostB);
const orphanHostA = document.createElement("div");
orphanHostA.setAttribute("data-composition-id", "orphan-scene");
document.body.appendChild(orphanHostA);
const orphanHostB = document.createElement("div");
orphanHostB.setAttribute("data-composition-id", "orphan-scene");
document.body.appendChild(orphanHostB);
await loadInlineTemplateCompositions({ ...defaultParams });
expect(filledHostA.getAttribute("data-composition-id")).toBe("filled-scene");
expect(filledHostB.getAttribute("data-composition-id")).toBe("filled-scene");
expect(filledHostA.hasAttribute("data-hf-original-composition-id")).toBe(false);
expect(filledHostB.hasAttribute("data-hf-original-composition-id")).toBe(false);
expect(orphanHostA.getAttribute("data-composition-id")).toBe("orphan-scene");
expect(orphanHostB.getAttribute("data-composition-id")).toBe("orphan-scene");
expect(orphanHostA.hasAttribute("data-hf-original-composition-id")).toBe(false);
expect(orphanHostB.hasAttribute("data-hf-original-composition-id")).toBe(false);
});
it("uniquifies a mounted inline host when a skipped sibling already uses the authored id", async () => {
const template = document.createElement("template");
template.id = "scene-template";
template.innerHTML = `
<div id="scene-root" data-composition-id="scene" data-width="1920" data-height="1080">
<p>Inline scene</p>
</div>
`;
document.body.appendChild(template);
const skippedHost = document.createElement("div");
skippedHost.setAttribute("data-composition-id", "scene");
skippedHost.innerHTML = "<span>Existing scene</span>";
document.body.appendChild(skippedHost);
const mountedHost = document.createElement("div");
mountedHost.setAttribute("data-composition-id", "scene");
document.body.appendChild(mountedHost);
await loadInlineTemplateCompositions({ ...defaultParams });
expect(skippedHost.getAttribute("data-composition-id")).toBe("scene");
expect(skippedHost.hasAttribute("data-hf-original-composition-id")).toBe(false);
expect(mountedHost.getAttribute("data-composition-id")).not.toBe("scene");
expect(mountedHost.getAttribute("data-hf-original-composition-id")).toBe("scene");
expect(mountedHost.querySelector('[data-hf-authored-id="scene-root"]')).toBeTruthy();
});
it("re-numbers duplicate inline runtime ids when the mount set grows", async () => {
const template = document.createElement("template");
template.id = "scene-template";
template.innerHTML = `
<div id="scene-root" data-composition-id="scene" data-width="1920" data-height="1080">
<p>Inline scene</p>
</div>
`;
document.body.appendChild(template);
const hostA = document.createElement("div");
hostA.setAttribute("data-composition-id", "scene");
hostA.innerHTML = "<span>Existing A</span>";
document.body.appendChild(hostA);
const hostB = document.createElement("div");
hostB.setAttribute("data-composition-id", "scene");
document.body.appendChild(hostB);
await loadInlineTemplateCompositions({ ...defaultParams });
expect(hostA.getAttribute("data-composition-id")).toBe("scene");
expect(hostB.getAttribute("data-composition-id")).toBe("scene__hf1");
hostA.innerHTML = "";
hostB.innerHTML = "";
await loadInlineTemplateCompositions({ ...defaultParams });
expect(hostA.getAttribute("data-composition-id")).toBe("scene__hf1");
expect(hostB.getAttribute("data-composition-id")).toBe("scene__hf2");
expect(hostA.getAttribute("data-hf-original-composition-id")).toBe("scene");
expect(hostB.getAttribute("data-hf-original-composition-id")).toBe("scene");
});
it("uniquifies duplicate sub-compositions across inline-template and external hosts", async () => {
const template = document.createElement("template");
template.id = "scene-template";
template.innerHTML = `
<div id="scene-root" data-composition-id="scene" data-width="1920" data-height="1080">
<p>Inline scene</p>
</div>
`;
document.body.appendChild(template);
const inlineHost = document.createElement("div");
inlineHost.setAttribute("data-composition-id", "scene");
document.body.appendChild(inlineHost);
const externalHost = document.createElement("div");
externalHost.setAttribute("data-composition-id", "scene");
externalHost.setAttribute("data-composition-src", "https://example.com/scene.html");
document.body.appendChild(externalHost);
const compositionHtml = `
<html><body>
<div data-composition-id="scene" data-width="1920" data-height="1080">
<p>External scene</p>
</div>
</body></html>
`;
vi.spyOn(globalThis, "fetch").mockResolvedValue(new Response(compositionHtml, { status: 200 }));
await loadExternalCompositions({ ...defaultParams });
await loadInlineTemplateCompositions({ ...defaultParams });
expect(inlineHost.getAttribute("data-composition-id")).toBe("scene__hf1");
expect(externalHost.getAttribute("data-composition-id")).toBe("scene__hf2");
expect(inlineHost.getAttribute("data-hf-original-composition-id")).toBe("scene");
expect(externalHost.getAttribute("data-hf-original-composition-id")).toBe("scene");
expect(inlineHost.querySelector("p")?.textContent).toBe("Inline scene");
expect(externalHost.querySelector("p")?.textContent).toBeTruthy();
});
});
+224 -37
View File
@@ -27,6 +27,10 @@ type PendingScript =
const EXTERNAL_SCRIPT_LOAD_TIMEOUT_MS = 8000;
const BARE_RELATIVE_PATH_RE = /^(?![a-zA-Z][a-zA-Z\d+\-.]*:)(?!\/\/)(?!\/)(?!\.\.?\/).+/;
function uniqueCompositionId(baseId: string, index: number): string {
return `${baseId}__hf${index}`;
}
const waitForExternalScriptLoad = (
scriptEl: HTMLScriptElement,
): Promise<{ status: "load" | "error" | "timeout"; elapsedMs: number }> =>
@@ -57,6 +61,33 @@ function resetCompositionHost(host: Element) {
host.textContent = "";
}
const FLATTENED_INNER_ROOT_STRIP_ATTRS = [
"data-composition-id",
"data-composition-file",
"data-start",
"data-duration",
"data-end",
"data-track-index",
"data-track",
"data-composition-src",
"data-hf-authored-duration",
"data-hf-authored-end",
];
function prepareFlattenedInnerRoot(innerRoot: HTMLElement): HTMLElement {
const prepared = document.importNode(innerRoot, true) as HTMLElement;
const authoredRootId = prepared.getAttribute("id")?.trim();
for (const attrName of FLATTENED_INNER_ROOT_STRIP_ATTRS) {
prepared.removeAttribute(attrName);
}
if (authoredRootId) {
prepared.removeAttribute("id");
prepared.setAttribute("data-hf-authored-id", authoredRootId);
}
prepared.setAttribute("data-hf-inner-root", "true");
return prepared;
}
function resolveScriptSourceUrl(scriptSrc: string, compositionUrl: URL | null): string {
const trimmedSrc = scriptSrc.trim();
if (!trimmedSrc) return scriptSrc;
@@ -87,9 +118,138 @@ function parseHostVariableValues(host: Element): Record<string, unknown> {
return parsed as Record<string, unknown>;
}
type HostCompositionIdentity = {
authoredCompositionId: string | null;
runtimeCompositionId: string | null;
};
function getHostCompositionIdentity(host: Element): HostCompositionIdentity {
const currentCompositionId = (host.getAttribute("data-composition-id") || "").trim() || null;
const authoredCompositionId =
(host.getAttribute("data-hf-original-composition-id") || currentCompositionId || "").trim() ||
null;
return {
authoredCompositionId,
runtimeCompositionId: currentCompositionId,
};
}
function countAuthoredCompositionIds(hosts: Element[]): Map<string, number> {
const hostCountsByCompositionId = new Map<string, number>();
for (const host of hosts) {
const compId = getHostCompositionIdentity(host).authoredCompositionId || "";
if (!compId) continue;
hostCountsByCompositionId.set(compId, (hostCountsByCompositionId.get(compId) || 0) + 1);
}
return hostCountsByCompositionId;
}
function hasMatchingInlineTemplate(host: Element): boolean {
const authoredCompositionId = getHostCompositionIdentity(host).authoredCompositionId;
if (!authoredCompositionId) return false;
return !!document.querySelector(`template#${CSS.escape(authoredCompositionId)}-template`);
}
function isMountedInlineCompositionHost(host: Element): boolean {
return !!host.querySelector('[data-hf-inner-root="true"]');
}
function shouldAssignRuntimeCompositionId(host: Element): boolean {
if (host.hasAttribute("data-composition-src")) return true;
if (!hasMatchingInlineTemplate(host)) return false;
if (host.children.length === 0) return true;
if (host.hasAttribute("data-hf-original-composition-id")) return true;
return isMountedInlineCompositionHost(host);
}
function getTrackedCompositionHosts(): Element[] {
const hosts = Array.from(
document.querySelectorAll<Element>("[data-composition-src], [data-composition-id]"),
);
return hosts.filter((host) => {
if (host.hasAttribute("data-composition-src")) return true;
return hasMatchingInlineTemplate(host);
});
}
function cleanupDetachedScopedVariables() {
const byComp = window.__hfVariablesByComp;
if (!byComp) return;
const activeRuntimeCompositionIds = new Set(
getTrackedCompositionHosts()
.map((host) => getHostCompositionIdentity(host).runtimeCompositionId)
.filter((compositionId): compositionId is string => !!compositionId),
);
for (const runtimeCompositionId of Object.keys(byComp)) {
if (!activeRuntimeCompositionIds.has(runtimeCompositionId)) {
delete byComp[runtimeCompositionId];
}
}
}
function assignRuntimeCompositionIds(
hosts: Element[],
hostCountsByCompositionId: Map<string, number> = countAuthoredCompositionIds(hosts),
): Map<Element, HostCompositionIdentity> {
const hostInstanceByCompositionId = new Map<string, number>();
const hostIdentityByElement = new Map<Element, HostCompositionIdentity>();
for (const host of hosts) {
const { authoredCompositionId, runtimeCompositionId: previousRuntimeCompositionId } =
getHostCompositionIdentity(host);
const shouldAssign = shouldAssignRuntimeCompositionId(host);
if (!authoredCompositionId) {
hostIdentityByElement.set(host, {
authoredCompositionId: null,
runtimeCompositionId: previousRuntimeCompositionId,
});
continue;
}
const duplicateInstance = (hostCountsByCompositionId.get(authoredCompositionId) || 0) > 1;
let runtimeCompositionId = previousRuntimeCompositionId || authoredCompositionId;
if (shouldAssign) {
const instanceIndex = duplicateInstance
? (hostInstanceByCompositionId.get(authoredCompositionId) || 0) + 1
: 0;
if (duplicateInstance) {
hostInstanceByCompositionId.set(authoredCompositionId, instanceIndex);
}
runtimeCompositionId = duplicateInstance
? uniqueCompositionId(authoredCompositionId, instanceIndex)
: authoredCompositionId;
if (duplicateInstance) {
host.setAttribute("data-hf-original-composition-id", authoredCompositionId);
} else {
host.removeAttribute("data-hf-original-composition-id");
}
host.setAttribute("data-composition-id", runtimeCompositionId);
if (
previousRuntimeCompositionId &&
previousRuntimeCompositionId !== runtimeCompositionId &&
window.__hfVariablesByComp
) {
delete window.__hfVariablesByComp[previousRuntimeCompositionId];
}
}
hostIdentityByElement.set(host, {
authoredCompositionId,
runtimeCompositionId,
});
}
return hostIdentityByElement;
}
async function mountCompositionContent(params: {
host: Element;
hostCompositionId: string | null;
authoredCompositionId: string | null;
runtimeCompositionId: string | null;
hostCompositionSrc: string;
sourceNode: ParentNode;
hasTemplate: boolean;
@@ -117,18 +277,25 @@ async function mountCompositionContent(params: {
}) => void;
}): Promise<void> {
let innerRoot: Element | null = null;
if (params.hostCompositionId) {
if (params.authoredCompositionId) {
const candidateRoots = Array.from(
params.sourceNode.querySelectorAll<Element>("[data-composition-id]"),
);
innerRoot =
candidateRoots.find(
(candidate) => candidate.getAttribute("data-composition-id") === params.hostCompositionId,
(candidate) =>
candidate.getAttribute("data-composition-id") === params.authoredCompositionId,
) ?? null;
}
const contentNode = innerRoot ?? params.sourceNode;
const scopeCompositionId =
innerRoot?.getAttribute("data-composition-id")?.trim() || params.hostCompositionId || null;
const authoredScopeCompositionId =
innerRoot?.getAttribute("data-composition-id")?.trim() || params.authoredCompositionId || null;
const runtimeScopeCompositionId =
params.runtimeCompositionId || authoredScopeCompositionId || null;
const authoredRootId = innerRoot?.getAttribute("id")?.trim() || null;
const runtimeScopeSelector = runtimeScopeCompositionId
? `[data-composition-id="${CSS.escape(runtimeScopeCompositionId)}"]`
: undefined;
// Inject <head> styles from non-template sub-compositions first (they define
// element styles like backgrounds and positioning that the composition needs).
@@ -136,10 +303,12 @@ async function mountCompositionContent(params: {
for (const style of params.headStyles) {
const clonedStyle = style.cloneNode(true);
if (!(clonedStyle instanceof HTMLStyleElement)) continue;
if (scopeCompositionId) {
if (authoredScopeCompositionId) {
clonedStyle.textContent = scopeCssToComposition(
clonedStyle.textContent || "",
scopeCompositionId,
authoredScopeCompositionId,
runtimeScopeSelector,
authoredRootId,
);
}
document.head.appendChild(clonedStyle);
@@ -151,10 +320,12 @@ async function mountCompositionContent(params: {
for (const style of styles) {
const clonedStyle = style.cloneNode(true);
if (!(clonedStyle instanceof HTMLStyleElement)) continue;
if (scopeCompositionId) {
if (authoredScopeCompositionId) {
clonedStyle.textContent = scopeCssToComposition(
clonedStyle.textContent || "",
scopeCompositionId,
authoredScopeCompositionId,
runtimeScopeSelector,
authoredRootId,
);
}
document.head.appendChild(clonedStyle);
@@ -178,7 +349,7 @@ async function mountCompositionContent(params: {
kind: "inline",
content: scriptText,
type: scriptType,
scopeCompositionId,
scopeCompositionId: authoredScopeCompositionId,
});
}
}
@@ -204,7 +375,7 @@ async function mountCompositionContent(params: {
kind: "inline",
content: scriptText,
type: scriptType,
scopeCompositionId,
scopeCompositionId: authoredScopeCompositionId,
});
}
}
@@ -216,7 +387,6 @@ async function mountCompositionContent(params: {
}
if (innerRoot) {
const imported = document.importNode(innerRoot, true) as HTMLElement;
const widthRaw = innerRoot.getAttribute("data-width");
const heightRaw = innerRoot.getAttribute("data-height");
const widthPx = params.parseDimensionPx(widthRaw);
@@ -225,9 +395,7 @@ async function mountCompositionContent(params: {
if (heightRaw) params.host.setAttribute("data-height", heightRaw);
if (widthPx && params.host instanceof HTMLElement) params.host.style.width = widthPx;
if (heightPx && params.host instanceof HTMLElement) params.host.style.height = heightPx;
while (imported.firstChild) {
params.host.appendChild(imported.firstChild);
}
params.host.appendChild(prepareFlattenedInnerRoot(innerRoot));
} else if (params.hasTemplate) {
params.host.appendChild(document.importNode(contentNode, true));
} else {
@@ -238,14 +406,16 @@ async function mountCompositionContent(params: {
// `getVariables()` injected by `compositionScoping.ts` reads from
// `window.__hfVariablesByComp[compId]`, so this table must be populated
// before the wrapped IIFE evaluates.
if (scopeCompositionId) {
if (runtimeScopeCompositionId) {
const merged = {
...(params.declaredVariableDefaults ?? {}),
...parseHostVariableValues(params.host),
};
if (Object.keys(merged).length > 0) {
if (!window.__hfVariablesByComp) window.__hfVariablesByComp = {};
window.__hfVariablesByComp[scopeCompositionId] = merged;
window.__hfVariablesByComp[runtimeScopeCompositionId] = merged;
} else if (window.__hfVariablesByComp) {
delete window.__hfVariablesByComp[runtimeScopeCompositionId];
}
}
@@ -264,6 +434,10 @@ async function mountCompositionContent(params: {
injectedScript.textContent = wrapScopedCompositionScript(
scriptPayload.content,
scriptPayload.scopeCompositionId,
"[HyperFrames] composition script error:",
runtimeScopeSelector,
runtimeScopeCompositionId || scriptPayload.scopeCompositionId,
authoredRootId,
);
} else {
injectedScript.textContent = `(function(){${scriptPayload.content}})();`;
@@ -276,7 +450,8 @@ async function mountCompositionContent(params: {
params.onDiagnostic?.({
code: "external_composition_script_load_issue",
details: {
hostCompositionId: params.hostCompositionId,
hostCompositionId: params.authoredCompositionId,
runtimeCompositionId: params.runtimeCompositionId,
hostCompositionSrc: params.hostCompositionSrc,
resolvedScriptSrc: scriptPayload.src,
loadStatus: loadResult.status,
@@ -291,23 +466,24 @@ async function mountCompositionContent(params: {
export async function loadInlineTemplateCompositions(
params: LoadExternalCompositionsParams,
): Promise<void> {
// Find all elements with data-composition-id but WITHOUT data-composition-src
// that are empty (no children) and have a matching <template id="[compId]-template">
const hosts = Array.from(
document.querySelectorAll<Element>("[data-composition-id]:not([data-composition-src])"),
).filter((host) => {
// Only process empty hosts (no meaningful content)
const trackedHosts = getTrackedCompositionHosts();
cleanupDetachedScopedVariables();
if (trackedHosts.length === 0) return;
const hostIdentityByElement = assignRuntimeCompositionIds(trackedHosts);
const hosts = trackedHosts.filter((host) => {
if (host.hasAttribute("data-composition-src")) return false;
if (host.children.length > 0) return false;
const compId = host.getAttribute("data-composition-id");
const compId = hostIdentityByElement.get(host)?.authoredCompositionId;
if (!compId) return false;
// Check for matching template
return !!document.querySelector(`template#${CSS.escape(compId)}-template`);
});
if (hosts.length === 0) return;
for (const host of hosts) {
const compId = host.getAttribute("data-composition-id")!;
const hostIdentity = hostIdentityByElement.get(host);
const compId = hostIdentity?.authoredCompositionId;
if (!compId) continue;
const template = document.querySelector<HTMLTemplateElement>(
`template#${CSS.escape(compId)}-template`,
)!;
@@ -315,7 +491,8 @@ export async function loadInlineTemplateCompositions(
resetCompositionHost(host);
await mountCompositionContent({
host,
hostCompositionId: compId,
authoredCompositionId: compId,
runtimeCompositionId: hostIdentity?.runtimeCompositionId || compId,
hostCompositionSrc: `template#${compId}-template`,
sourceNode: template.content,
hasTemplate: true,
@@ -332,13 +509,21 @@ export async function loadInlineTemplateCompositions(
export async function loadExternalCompositions(
params: LoadExternalCompositionsParams,
): Promise<void> {
const hosts = Array.from(document.querySelectorAll("[data-composition-src]"));
const trackedHosts = getTrackedCompositionHosts();
cleanupDetachedScopedVariables();
if (trackedHosts.length === 0) return;
const hostIdentityByElement = assignRuntimeCompositionIds(trackedHosts);
const hosts = trackedHosts.filter((host) => host.hasAttribute("data-composition-src"));
if (hosts.length === 0) return;
await Promise.all(
hosts.map(async (host) => {
const src = host.getAttribute("data-composition-src");
if (!src) return;
const hostIdentity = hostIdentityByElement.get(host);
const authoredCompositionId = hostIdentity?.authoredCompositionId || null;
const runtimeCompositionId =
hostIdentity?.runtimeCompositionId || authoredCompositionId || null;
let compositionUrl: URL | null = null;
try {
compositionUrl = new URL(src, document.baseURI);
@@ -347,17 +532,17 @@ export async function loadExternalCompositions(
}
resetCompositionHost(host);
try {
const hostCompositionId = host.getAttribute("data-composition-id");
const localTemplate =
hostCompositionId != null
authoredCompositionId != null
? document.querySelector<HTMLTemplateElement>(
`template#${CSS.escape(hostCompositionId)}-template`,
`template#${CSS.escape(authoredCompositionId)}-template`,
)
: null;
if (localTemplate) {
await mountCompositionContent({
host,
hostCompositionId,
authoredCompositionId,
runtimeCompositionId,
hostCompositionSrc: src,
sourceNode: localTemplate.content,
hasTemplate: true,
@@ -378,9 +563,9 @@ export async function loadExternalCompositions(
const parser = new DOMParser();
const doc = parser.parseFromString(html, "text/html");
const template =
(hostCompositionId
(authoredCompositionId
? doc.querySelector<HTMLTemplateElement>(
`template#${CSS.escape(hostCompositionId)}-template`,
`template#${CSS.escape(authoredCompositionId)}-template`,
)
: null) ?? doc.querySelector<HTMLTemplateElement>("template");
const sourceNode = template ? template.content : doc.body;
@@ -398,7 +583,8 @@ export async function loadExternalCompositions(
: undefined;
await mountCompositionContent({
host,
hostCompositionId,
authoredCompositionId,
runtimeCompositionId,
hostCompositionSrc: src,
sourceNode,
hasTemplate: Boolean(template),
@@ -416,7 +602,8 @@ export async function loadExternalCompositions(
params.onDiagnostic?.({
code: "external_composition_load_failed",
details: {
hostCompositionId: host.getAttribute("data-composition-id"),
hostCompositionId: authoredCompositionId,
runtimeCompositionId,
hostCompositionSrc: src,
errorMessage: error instanceof Error ? error.message : "unknown_error",
},