Files
hyperframes/packages/core/src/runtime/compositionLoader.ts
T
Miguel Ángel 6f0b57d608 fix(core): scope sub-composition html/body styles to the composition box (#2089)
Sub-composition <head> styles targeting html/body/:root (width/height/
overflow/background) were injected into the parent document unscoped by both
the Studio runtime mount (compositionLoader) and the render-time inliner
(inlineSubCompositions/htmlBundler). scopeCssToComposition deliberately passed
html/body/:root through unchanged, so a sub-composition smaller than the root
clobbered the host <body> dimensions and its overflow:hidden clipped the
composite to the last sub-comp's size. Only the top-left element painted;
everything else (and framework-owned video positioned outside that box) was
clipped away.

Add a scopeRootSelectors option to scopeCssToComposition that remaps
html/body/:root to the composition's own box, and enable it everywhere
sub-composition styles are scoped. The universal selector stays untouched.
Top-level composition scoping is unchanged (it legitimately owns the document).

Covered by new compositionScoping tests.
2026-07-08 22:21:47 -04:00

752 lines
29 KiB
TypeScript

import { scopeCssToComposition, wrapScopedCompositionScript } from "../compiler/compositionScoping";
import { markFlattenedInnerRoot } from "./flattenedRoot";
import {
applyCssVariables,
clearAppliedCssVariables,
parseHostVariableValues,
readDeclaredDefaults,
readRenderOverrides,
} from "./getVariables";
type LoadExternalCompositionsParams = {
injectedStyles: HTMLStyleElement[];
injectedScripts: HTMLScriptElement[];
parseDimensionPx: (value: string | null) => string | null;
onDiagnostic?: (payload: {
code: string;
details: Record<string, string | number | boolean | null | string[]>;
}) => void;
};
type PendingScript =
| {
kind: "inline";
content: string;
type: string;
scopeCompositionId: string | null;
}
| {
kind: "external";
src: string;
type: string;
};
const EXTERNAL_SCRIPT_LOAD_TIMEOUT_MS = 8000;
const BARE_RELATIVE_PATH_RE = /^(?![a-zA-Z][a-zA-Z\d+\-.]*:)(?!\/\/)(?!\/)(?!\.\.?\/).+/;
const CSS_URL_RE = /\burl\(\s*(["']?)([^)"']+)\1\s*\)/g;
const PATH_ATTRS = ["src", "href"] as const;
/**
* Return true for URLs/prefixes that should never be rewritten — absolute
* URLs, protocol-relative, data:, hash fragments, root-relative. Mirrors
* the compiler's `isNonRelativeUrl` so server-side bundling and client-side
* runtime rewrite use the same rules.
*/
function isNonRelativeRuntimeUrl(value: string): boolean {
return (
!value ||
value.startsWith("http://") ||
value.startsWith("https://") ||
value.startsWith("//") ||
value.startsWith("data:") ||
value.startsWith("#") ||
value.startsWith("/")
);
}
/**
* Resolve a relative asset path from a sub-composition's URL to one that
* works in the live document.
*
* Server-side `inlineSubCompositions` rewrites `../foo.svg` from
* `compositions/scene.html` to `foo.svg` (project root). When the runtime
* mounts a sub-composition by fetching its HTML and importing its nodes
* into the main document, no such rewriting happens — so a `<video
* src="../../assets/x.mp4">` authored from `compositions/frames/*.html`
* resolves against the main document's base, climbing **above** the
* project root (e.g. `/api/projects/assets/x.mp4`) and 404s. This is the
* Studio-preview-vs-render divergence noted in the bug report.
*
* For each path that traverses up with `../`, resolve against the
* sub-composition's URL and return an absolute URL the browser can use
* directly. Plain relative paths (`assets/x.mp4`) and absolute / special
* URLs are returned unchanged — they already resolve correctly via the
* main document's base.
*/
function rewriteRuntimeAssetPath(value: string, compositionUrl: URL | null): string {
if (!compositionUrl) return value;
const trimmed = value.trim();
if (isNonRelativeRuntimeUrl(trimmed)) return value;
if (!trimmed.startsWith("../") && trimmed !== "..") return value;
try {
return new URL(trimmed, compositionUrl).href;
} catch {
return value;
}
}
function rewriteRuntimeCssAssetUrls(cssText: string, compositionUrl: URL | null): string {
if (!compositionUrl || !cssText) return cssText;
return cssText.replace(CSS_URL_RE, (full, quote: string, rawUrl: string) => {
const rewritten = rewriteRuntimeAssetPath(rawUrl || "", compositionUrl);
if (rewritten === rawUrl) return full;
return `url(${quote || ""}${rewritten}${quote || ""})`;
});
}
function rewritePathAttrsInTree(root: ParentNode, compositionUrl: URL): void {
for (const el of Array.from(root.querySelectorAll<Element>("[src], [href]"))) {
for (const attr of PATH_ATTRS) {
const value = el.getAttribute(attr);
if (value == null) continue;
const rewritten = rewriteRuntimeAssetPath(value, compositionUrl);
if (rewritten !== value) el.setAttribute(attr, rewritten);
}
}
}
function rewriteInlineStyleUrlsInTree(root: ParentNode, compositionUrl: URL): void {
for (const el of Array.from(root.querySelectorAll<Element>("[style]"))) {
const value = el.getAttribute("style");
if (value == null) continue;
const rewritten = rewriteRuntimeCssAssetUrls(value, compositionUrl);
if (rewritten !== value) el.setAttribute("style", rewritten);
}
}
function rewriteStyleElementUrlsInTree(root: ParentNode, compositionUrl: URL): void {
for (const styleEl of Array.from(root.querySelectorAll<HTMLStyleElement>("style"))) {
const text = styleEl.textContent || "";
const rewritten = rewriteRuntimeCssAssetUrls(text, compositionUrl);
if (rewritten !== text) styleEl.textContent = rewritten;
}
}
/**
* Rewrite relative asset paths in a parsed sub-composition document so
* that `../`-traversing paths resolve against the sub-composition's URL
* rather than the main document's base. Touches `[src]`, `[href]`,
* `[style]` url(...) references, and `<style>` element CSS — the same
* surface the server-side `inlineSubCompositions` rewrites.
*
* Recurses into `<template>` content because authored compositions wrap
* their rendered body in a `<template>` and querySelectorAll does not
* enter template content (it lives in a detached DocumentFragment).
* Without recursion, the rewrite would miss every `<video>` and
* `<img>` that an author placed inside the canonical template wrapper.
*/
function rewriteSubCompositionAssetPaths(root: ParentNode, compositionUrl: URL | null): void {
if (!compositionUrl) return;
rewritePathAttrsInTree(root, compositionUrl);
rewriteInlineStyleUrlsInTree(root, compositionUrl);
rewriteStyleElementUrlsInTree(root, compositionUrl);
for (const templateEl of Array.from(root.querySelectorAll<HTMLTemplateElement>("template"))) {
rewriteSubCompositionAssetPaths(templateEl.content, compositionUrl);
}
}
function uniqueCompositionId(baseId: string, index: number): string {
return `${baseId}__hf${index}`;
}
const waitForExternalScriptLoad = (
scriptEl: HTMLScriptElement,
): Promise<{ status: "load" | "error" | "timeout"; elapsedMs: number }> =>
new Promise((resolve) => {
let settled = false;
const startedAt = Date.now();
let timeoutId: number | null = null;
const settle = (status: "load" | "error" | "timeout") => {
if (settled) return;
settled = true;
if (timeoutId != null) {
window.clearTimeout(timeoutId);
}
resolve({
status,
elapsedMs: Math.max(0, Date.now() - startedAt),
});
};
scriptEl.addEventListener("load", () => settle("load"), { once: true });
scriptEl.addEventListener("error", () => settle("error"), { once: true });
timeoutId = window.setTimeout(() => settle("timeout"), EXTERNAL_SCRIPT_LOAD_TIMEOUT_MS);
});
function resetCompositionHost(host: Element) {
while (host.firstChild) {
host.removeChild(host.firstChild);
}
host.textContent = "";
}
function prepareFlattenedInnerRoot(innerRoot: HTMLElement): HTMLElement {
const prepared = document.importNode(innerRoot, true) as HTMLElement;
markFlattenedInnerRoot(prepared);
const w = prepared.getAttribute("data-width");
const h = prepared.getAttribute("data-height");
prepared.style.width = w ? `${w}px` : "100%";
prepared.style.height = h ? `${h}px` : "100%";
return prepared;
}
function resolveScriptSourceUrl(scriptSrc: string, compositionUrl: URL | null): string {
const trimmedSrc = scriptSrc.trim();
if (!trimmedSrc) return scriptSrc;
try {
if (BARE_RELATIVE_PATH_RE.test(trimmedSrc)) {
// Composition payloads may use root-relative semantics without a leading slash.
return new URL(trimmedSrc, document.baseURI).toString();
}
if (compositionUrl) {
return new URL(trimmedSrc, compositionUrl).toString();
}
return new URL(trimmedSrc, document.baseURI).toString();
} catch {
return scriptSrc;
}
}
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;
authoredCompositionId: string | null;
runtimeCompositionId: string | null;
hostCompositionSrc: string;
sourceNode: ParentNode;
hasTemplate: boolean;
fallbackBodyInnerHtml: string;
compositionUrl: URL | null;
injectedStyles: HTMLStyleElement[];
injectedScripts: HTMLScriptElement[];
parseDimensionPx: (value: string | null) => string | null;
/** Extra <style> elements from the parsed document <head> (non-template sub-compositions). */
headStyles?: HTMLStyleElement[];
/** Extra <script> elements from the parsed document <head> (non-template sub-compositions). */
headScripts?: HTMLScriptElement[];
/** Extra <link> elements from the parsed document <head> (font stylesheets, preconnects). */
headLinks?: HTMLLinkElement[];
/**
* Defaults extracted from the sub-composition's own
* `<html data-composition-variables="...">` attribute. Layered under the
* host element's `data-variable-values` to produce the per-instance
* variables visible inside the sub-comp's scoped `getVariables()`.
* Populated only by `loadExternalCompositions`; inline templates have no
* separate document root so no declared defaults are passed.
*/
declaredVariableDefaults?: Record<string, unknown>;
onDiagnostic?: (payload: {
code: string;
details: Record<string, string | number | boolean | null | string[]>;
}) => void;
}): Promise<void> {
let innerRoot: Element | null = null;
if (params.authoredCompositionId) {
const candidateRoots = Array.from(
params.sourceNode.querySelectorAll<Element>("[data-composition-id]"),
);
innerRoot =
candidateRoots.find(
(candidate) =>
candidate.getAttribute("data-composition-id") === params.authoredCompositionId,
) ?? null;
}
const contentNode = innerRoot ?? params.sourceNode;
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;
if (params.headLinks) {
for (const link of params.headLinks) {
const href = link.getAttribute("href") || "";
if (!href) continue;
if (document.head.querySelector(`link[href="${CSS.escape(href)}"]`)) continue;
document.head.appendChild(link.cloneNode(true));
}
}
const injectScopedStyles = (styleEls: Iterable<HTMLStyleElement>): void => {
for (const style of styleEls) {
const clonedStyle = style.cloneNode(true);
if (!(clonedStyle instanceof HTMLStyleElement)) continue;
if (authoredScopeCompositionId) {
clonedStyle.textContent = scopeCssToComposition(
clonedStyle.textContent || "",
authoredScopeCompositionId,
runtimeScopeSelector,
authoredRootId,
// Sub-comp styles are injected into the PARENT preview document, so
// remap html/body/:root to the composition box — otherwise a sub-comp
// `body { width/height/overflow }` clobbers the host body and clips
// the preview to the last-mounted sub-comp's size.
{ scopeRootSelectors: true },
);
}
document.head.appendChild(clonedStyle);
params.injectedStyles.push(clonedStyle);
}
};
// Inject <head> styles from non-template sub-compositions first (they define
// element styles like backgrounds and positioning that the composition needs),
// then the content styles.
if (params.headStyles) injectScopedStyles(params.headStyles);
injectScopedStyles(Array.from(contentNode.querySelectorAll<HTMLStyleElement>("style")));
// Collect head scripts first (e.g. GSAP CDN loaded in <head> of non-template sub-comps),
// then content scripts. Head scripts must execute before content scripts.
const headScriptPayloads: PendingScript[] = [];
if (params.headScripts) {
for (const script of params.headScripts) {
const scriptType = script.getAttribute("type")?.trim() ?? "";
const scriptSrc = script.getAttribute("src")?.trim() ?? "";
if (scriptSrc) {
const resolvedSrc = resolveScriptSourceUrl(scriptSrc, params.compositionUrl);
headScriptPayloads.push({ kind: "external", src: resolvedSrc, type: scriptType });
} else {
const scriptText = script.textContent?.trim() ?? "";
if (scriptText) {
headScriptPayloads.push({
kind: "inline",
content: scriptText,
type: scriptType,
scopeCompositionId: authoredScopeCompositionId,
});
}
}
}
}
const scripts = Array.from(contentNode.querySelectorAll<HTMLScriptElement>("script"));
const scriptPayloads: PendingScript[] = [...headScriptPayloads];
for (const script of scripts) {
const scriptType = script.getAttribute("type")?.trim() ?? "";
const scriptSrc = script.getAttribute("src")?.trim() ?? "";
if (scriptSrc) {
const resolvedSrc = resolveScriptSourceUrl(scriptSrc, params.compositionUrl);
scriptPayloads.push({
kind: "external",
src: resolvedSrc,
type: scriptType,
});
} else {
const scriptText = script.textContent?.trim() ?? "";
if (scriptText) {
scriptPayloads.push({
kind: "inline",
content: scriptText,
type: scriptType,
scopeCompositionId: authoredScopeCompositionId,
});
}
}
script.parentNode?.removeChild(script);
}
const remainingStyles = Array.from(contentNode.querySelectorAll<HTMLStyleElement>("style"));
for (const style of remainingStyles) {
style.parentNode?.removeChild(style);
}
if (innerRoot) {
const widthRaw = innerRoot.getAttribute("data-width");
const heightRaw = innerRoot.getAttribute("data-height");
const widthPx = params.parseDimensionPx(widthRaw);
const heightPx = params.parseDimensionPx(heightRaw);
if (widthRaw) params.host.setAttribute("data-width", widthRaw);
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;
if (innerRoot.hasAttribute("data-timeline-locked")) {
params.host.setAttribute("data-timeline-locked", "");
}
params.host.appendChild(prepareFlattenedInnerRoot(innerRoot));
} else if (params.hasTemplate) {
params.host.appendChild(document.importNode(contentNode, true));
} else {
params.host.innerHTML = params.fallbackBodyInnerHtml;
}
// Stash the per-instance variables BEFORE running scripts. The scoped
// `getVariables()` injected by `compositionScoping.ts` reads from
// `window.__hfVariablesByComp[compId]`, so this table must be populated
// before the wrapped IIFE evaluates.
if (runtimeScopeCompositionId) {
stashInstanceVariables(params, contentNode, runtimeScopeCompositionId);
}
for (const scriptPayload of scriptPayloads) {
const injectedScript = document.createElement("script");
if (scriptPayload.type) {
injectedScript.type = scriptPayload.type;
}
// Preserve deterministic script execution order across injected composition scripts.
injectedScript.async = false;
if (scriptPayload.kind === "external") {
injectedScript.src = scriptPayload.src;
} else if (scriptPayload.type.toLowerCase() === "module") {
injectedScript.textContent = scriptPayload.content;
} else if (scriptPayload.scopeCompositionId) {
injectedScript.textContent = wrapScopedCompositionScript(
scriptPayload.content,
scriptPayload.scopeCompositionId,
"[HyperFrames] composition script error:",
runtimeScopeSelector,
runtimeScopeCompositionId || scriptPayload.scopeCompositionId,
authoredRootId,
);
} else {
injectedScript.textContent = `(function(){${scriptPayload.content}})();`;
}
document.body.appendChild(injectedScript);
params.injectedScripts.push(injectedScript);
if (scriptPayload.kind === "external") {
const loadResult = await waitForExternalScriptLoad(injectedScript);
if (loadResult.status !== "load") {
params.onDiagnostic?.({
code: "external_composition_script_load_issue",
details: {
hostCompositionId: params.authoredCompositionId,
runtimeCompositionId: params.runtimeCompositionId,
hostCompositionSrc: params.hostCompositionSrc,
resolvedScriptSrc: scriptPayload.src,
loadStatus: loadResult.status,
elapsedMs: loadResult.elapsedMs,
},
});
}
}
}
}
export async function loadInlineTemplateCompositions(
params: LoadExternalCompositionsParams,
): Promise<void> {
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 = hostIdentityByElement.get(host)?.authoredCompositionId;
if (!compId) return false;
return !!document.querySelector(`template#${CSS.escape(compId)}-template`);
});
if (hosts.length === 0) return;
for (const host of hosts) {
const hostIdentity = hostIdentityByElement.get(host);
const compId = hostIdentity?.authoredCompositionId;
if (!compId) continue;
const template = document.querySelector<HTMLTemplateElement>(
`template#${CSS.escape(compId)}-template`,
)!;
resetCompositionHost(host);
await mountCompositionContent({
host,
authoredCompositionId: compId,
runtimeCompositionId: hostIdentity?.runtimeCompositionId || compId,
hostCompositionSrc: `template#${compId}-template`,
sourceNode: template.content,
hasTemplate: true,
fallbackBodyInnerHtml: "",
compositionUrl: null,
injectedStyles: params.injectedStyles,
injectedScripts: params.injectedScripts,
parseDimensionPx: params.parseDimensionPx,
onDiagnostic: params.onDiagnostic,
});
}
}
export async function loadExternalCompositions(
params: LoadExternalCompositionsParams,
): Promise<void> {
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);
} catch {
compositionUrl = null;
}
resetCompositionHost(host);
try {
const localTemplate =
authoredCompositionId != null
? document.querySelector<HTMLTemplateElement>(
`template#${CSS.escape(authoredCompositionId)}-template`,
)
: null;
if (localTemplate) {
await mountCompositionContent({
host,
authoredCompositionId,
runtimeCompositionId,
hostCompositionSrc: src,
sourceNode: localTemplate.content,
hasTemplate: true,
fallbackBodyInnerHtml: "",
compositionUrl,
injectedStyles: params.injectedStyles,
injectedScripts: params.injectedScripts,
parseDimensionPx: params.parseDimensionPx,
onDiagnostic: params.onDiagnostic,
});
return;
}
const response = await fetch(src);
if (!response.ok) {
throw new Error(`HTTP ${response.status}`);
}
const html = await response.text();
const parser = new DOMParser();
const doc = parser.parseFromString(html, "text/html");
// Rewrite project-root-traversing (`../`) asset paths against the
// sub-composition's URL before extracting any nodes. Without this,
// `<video src="../../assets/x.mp4">` authored from
// `compositions/frames/scene.html` resolves against the main
// document's base (the project preview root) and climbs above it
// to 404 — the Studio-preview-vs-render divergence reported by
// OSS users. The server-side bundler already does this for the
// baked render via `inlineSubCompositions`; this is the runtime
// mirror so live preview matches.
rewriteSubCompositionAssetPaths(doc, compositionUrl);
const template =
(authoredCompositionId
? doc.querySelector<HTMLTemplateElement>(
`template#${CSS.escape(authoredCompositionId)}-template`,
)
: null) ?? doc.querySelector<HTMLTemplateElement>("template");
const sourceNode = template ? template.content : doc.body;
// When loading a non-template sub-composition (full HTML document),
// extract <style> and <script> elements from the parsed document's
// <head>. These contain critical CSS (backgrounds, positioning, fonts)
// and library scripts (e.g. GSAP CDN) that would otherwise be lost
// because mountCompositionContent only looks inside the composition
// root element.
const headStyles = !template
? Array.from(doc.head.querySelectorAll<HTMLStyleElement>("style"))
: undefined;
const headScripts = !template
? Array.from(doc.head.querySelectorAll<HTMLScriptElement>("script"))
: undefined;
const headLinks = !template
? Array.from(
doc.head.querySelectorAll<HTMLLinkElement>(
'link[rel="stylesheet"], link[rel="preconnect"]',
),
)
: undefined;
await mountCompositionContent({
host,
authoredCompositionId,
runtimeCompositionId,
hostCompositionSrc: src,
sourceNode,
hasTemplate: Boolean(template),
fallbackBodyInnerHtml: doc.body.innerHTML,
compositionUrl,
injectedStyles: params.injectedStyles,
injectedScripts: params.injectedScripts,
parseDimensionPx: params.parseDimensionPx,
headStyles,
headScripts,
headLinks,
declaredVariableDefaults: readDeclaredDefaults(doc.documentElement),
onDiagnostic: params.onDiagnostic,
});
} catch (error) {
params.onDiagnostic?.({
code: "external_composition_load_failed",
details: {
hostCompositionId: authoredCompositionId,
runtimeCompositionId,
hostCompositionSrc: src,
errorMessage: error instanceof Error ? error.message : "unknown_error",
},
});
// Keep host empty on load failures to avoid rendering escaped fallback HTML.
resetCompositionHost(host);
}
}),
);
}
/**
* Stash per-instance variables BEFORE running scripts (the scoped
* getVariables() reads window.__hfVariablesByComp[compId]) and mirror them
* as CSS custom properties on the host so imported var(--slug, literal)
* fills inside the sub-comp resolve per instance (cascade beats the document
* root). Inline templates carry declared defaults on the content root;
* external loads pass them explicitly. Render-time overrides (--variables)
* always win. Stale custom properties from a previous mount are cleared
* before (re)applying.
*/
function stashInstanceVariables(
params: { host: Element; declaredVariableDefaults?: Record<string, unknown> },
contentNode: Node,
runtimeScopeCompositionId: string,
): void {
const declaredDefaults =
params.declaredVariableDefaults ??
(contentNode instanceof Element ? readDeclaredDefaults(contentNode) : {});
const merged = {
...declaredDefaults,
...parseHostVariableValues(params.host),
};
clearAppliedCssVariables(params.host);
if (Object.keys(merged).length > 0) {
if (!window.__hfVariablesByComp) window.__hfVariablesByComp = {};
window.__hfVariablesByComp[runtimeScopeCompositionId] = merged;
applyCssVariables(params.host, { ...merged, ...readRenderOverrides() });
} else if (window.__hfVariablesByComp) {
delete window.__hfVariablesByComp[runtimeScopeCompositionId];
}
}