diff --git a/packages/core/src/compiler/compositionScoping.test.ts b/packages/core/src/compiler/compositionScoping.test.ts
index 49208d30c..6d7407f53 100644
--- a/packages/core/src/compiler/compositionScoping.test.ts
+++ b/packages/core/src/compiler/compositionScoping.test.ts
@@ -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(`
`);
+ const fakeWindow: Record = {
+ 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(``);
const fakeWindow: Record = {
@@ -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(`
+
+ `);
+ 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(`
+
+ `);
+ 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(`
+
+
+ `);
+ 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) {
diff --git a/packages/core/src/compiler/compositionScoping.ts b/packages/core/src/compiler/compositionScoping.ts
index 5fec5b5c2..f6fb503d9 100644
--- a/packages/core/src/compiler/compositionScoping.ts
+++ b/packages/core/src/compiler/compositionScoping.ts
@@ -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) : {};
},
});
diff --git a/packages/core/src/compiler/htmlBundler.test.ts b/packages/core/src/compiler/htmlBundler.test.ts
index 4cf5a75d7..93945b411 100644
--- a/packages/core/src/compiler/htmlBundler.test.ts
+++ b/packages/core/src/compiler/htmlBundler.test.ts
@@ -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": `
+
+
+
+`,
+ "compositions/scene.html": `
+
+
+
Scene
+
+`,
+ });
+
+ 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": `
+
+
+
+`,
+ "compositions/scene.html": `
+
+
Scene
+
+`,
+ });
+
+ 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": `
+
+
+
+
+
Scene
+
+
+
+`,
+ });
+
+ 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": `
+
+
+
+
+
+
Scene
+
+
+
+
+`,
+ });
+
+ 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": `
+
+
+
+
+
+
+`,
+ "compositions/scene.html": `
+
+`,
+ });
+
+ 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": `
+
+
+
+`,
+ "compositions/card.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": `
diff --git a/packages/core/src/compiler/htmlBundler.ts b/packages/core/src/compiler/htmlBundler.ts
index 1ccbed894..3e1377d84 100644
--- a/packages/core/src/compiler/htmlBundler.ts
+++ b/packages/core/src/compiler/htmlBundler.ts
@@ -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("[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 {
+ const counts = new Map();
+ 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 = countBundledAuthoredCompositionIds(hosts),
+): Map {
+ const instanceByCompositionId = new Map();
+ const identities = new Map();
+
+ 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 {
+ 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;
+}
+
+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();
- 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();
+ const compVariablesByComp: Record> = {};
+ 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 ), 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 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 element).
- const hostSelector = `[data-composition-id="${compId}"]:not([data-composition-src])`;
- // linkedom follows the DOM spec: querySelectorAll does not reach inside
- // 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();
+ }
+ // 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); } })();`,
+ );
+ }
+ 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);
- 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);
+ // 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); } })();`,
+ );
}
- } 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();
+
+ 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);
diff --git a/packages/core/src/runtime/compositionLoader.test.ts b/packages/core/src/runtime/compositionLoader.test.ts
index f5ce2312c..261931a75 100644
--- a/packages/core/src/runtime/compositionLoader.test.ts
+++ b/packages/core/src/runtime/compositionLoader.test.ts
@@ -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 = `
+
+
+
+
Scene
+
+
+ `;
+
+ 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 = `
+
+
+
Scene
+
+
+ `;
+
+ 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 = `
+
+
+
+
Default
+
+
+
+ `;
+
+ 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 })
+ .__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 = `
+
+
+
Scene
+
+
+ `;
+
+ 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 = `
+
+
+
Scene
+
+
+ `;
+
+ 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 = `
+
+
+
Scene
+
+
+ `;
+
+ 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 = `
+
+
+
+ `;
+ const secondCompositionHtml = `
+
+ `;
+
+ 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(
+ ``,
+ { status: 200 },
+ );
+ }
+ return new Response(
+ ``,
+ { 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(
+ ``,
+ { 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 = `
+
+ `;
+ 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 = `
+
+ `;
+ document.body.appendChild(filledTemplate);
+
+ const filledHostA = document.createElement("div");
+ filledHostA.setAttribute("data-composition-id", "filled-scene");
+ filledHostA.innerHTML = "Existing A";
+ document.body.appendChild(filledHostA);
+
+ const filledHostB = document.createElement("div");
+ filledHostB.setAttribute("data-composition-id", "filled-scene");
+ filledHostB.innerHTML = "Existing B";
+ 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 = `
+
+ `;
+ document.body.appendChild(template);
+
+ const skippedHost = document.createElement("div");
+ skippedHost.setAttribute("data-composition-id", "scene");
+ skippedHost.innerHTML = "Existing scene";
+ 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 = `
+
+ `;
+ document.body.appendChild(template);
+
+ const hostA = document.createElement("div");
+ hostA.setAttribute("data-composition-id", "scene");
+ hostA.innerHTML = "Existing A";
+ 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 = `
+
+ `;
+ 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 = `
+
+
+
+ `;
+ 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();
+ });
});
diff --git a/packages/core/src/runtime/compositionLoader.ts b/packages/core/src/runtime/compositionLoader.ts
index f69d6fa39..abc0e3b33 100644
--- a/packages/core/src/runtime/compositionLoader.ts
+++ b/packages/core/src/runtime/compositionLoader.ts
@@ -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 {
return parsed as Record;
}
+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 {
+ const hostCountsByCompositionId = new Map();
+ 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("[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 = countAuthoredCompositionIds(hosts),
+): Map {
+ const hostInstanceByCompositionId = new Map();
+ const hostIdentityByElement = new Map();
+
+ 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 {
let innerRoot: Element | null = null;
- if (params.hostCompositionId) {
+ if (params.authoredCompositionId) {
const candidateRoots = Array.from(
params.sourceNode.querySelectorAll("[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 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 {
- // Find all elements with data-composition-id but WITHOUT data-composition-src
- // that are empty (no children) and have a matching
- const hosts = Array.from(
- document.querySelectorAll("[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(
`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 {
- 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(
- `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(
- `template#${CSS.escape(hostCompositionId)}-template`,
+ `template#${CSS.escape(authoredCompositionId)}-template`,
)
: null) ?? doc.querySelector("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",
},