fix(core): stop dropping a mounted composition's styles and scripts

A composition mounted as a sub-composition lost its entire stylesheet and
scripts whenever they were authored as siblings of the composition root inside
its template. mountCompositionContent collected assets from the composition root
element, so sibling nodes were invisible to it.

The shape is legal and common, so the result was three catalog components
rendering completely unstyled in live preview. oversized-cursor drew its pointer
at 1280px against an authored 7cqw, roughly 134px at 1920, because width: 7cqw
was never declared at all -- the whole stylesheet was missing. Confirmed in the
mounted document, where only the host's own style element was present.

Collect from the source node, which is a superset of the root and the single
point every mount path routes through: external fetch, inline template, nested.

Strip the mounted clone rather than the source. The previous code removed the
extracted nodes from the node it was given, which on the inline-template path is
a live template still in the document -- a remount would have found it emptied.

Why nothing caught it: every CLI gate reaches the compiler path through
bundleToSingleHtml, and the compiler always collected from the whole template.
Nothing in the CLI exercises the mount path, which is reachable only through the
player and Studio. So the rendered video was correct the entire time and only
the preview was wrong. A test spanning both paths lands separately.

The regression test fails on the pre-fix code, asserting that the mounted
document contains the composition's own container-type declaration and finding
an empty string instead -- the stylesheet that never arrived -- and passes
after. Verified in both directions rather than assumed.
This commit is contained in:
Miguel Ángel
2026-08-07 20:48:38 +00:00
parent cf0c85c128
commit a7330da47b
2 changed files with 84 additions and 60 deletions
@@ -90,6 +90,43 @@ describe("loadExternalCompositions", () => {
expect(injectedStyles.length).toBeGreaterThan(0);
});
it("mounts <style>/<script> authored as siblings of the composition root", async () => {
// The canonical sub-composition shape puts <style> and <script> directly
// inside <template>, NEXT TO the root div rather than inside it. Collecting
// only from the root dropped the composition's whole stylesheet, so rules
// keyed on the root (`#root { container-type: size }`) never landed and every
// container-query unit in the composition resolved against the wrong basis.
const host = document.createElement("div");
host.setAttribute("data-composition-src", "https://example.com/scene.html");
host.setAttribute("data-composition-id", "scene");
document.body.appendChild(host);
const compositionHtml =
`
<html><body>
<template>
<style>#root { container-type: size; }</style>
<div id="root" data-composition-id="scene"><p>Scene</p></div>
<script>window.__sceneRan = true;</scr` +
`ipt>
</template>
</body></html>
`;
vi.spyOn(globalThis, "fetch").mockResolvedValue(new Response(compositionHtml, { status: 200 }));
const injectedStyles: HTMLStyleElement[] = [];
const injectedScripts: HTMLScriptElement[] = [];
await loadExternalCompositions({ ...defaultParams, injectedStyles, injectedScripts });
expect(injectedStyles.map((style) => style.textContent).join("")).toContain(
"container-type: size",
);
expect(injectedScripts.map((script) => script.textContent).join("")).toContain("__sceneRan");
// The extracted nodes are stripped from the mounted copy, not duplicated.
expect(host.querySelectorAll("style, script")).toHaveLength(0);
expect(host.querySelector("p")?.textContent).toBe("Scene");
});
it("preserves head stylesheets when an external composition uses a template", async () => {
const host = document.createElement("div");
host.setAttribute("data-composition-src", "https://example.com/compositions/scene.html");
+47 -60
View File
@@ -181,6 +181,20 @@ function resetCompositionHost(host: Element) {
host.textContent = "";
}
/**
* A composition's `<style>`/`<script>` are extracted and re-injected into the
* host document (scoped), so strip them from the copy that gets mounted —
* otherwise the mount re-declares the same CSS unscoped and re-runs the script.
*
* Strips the CLONE, never the source: `sourceNode` is a live `<template>` on the
* inline-template path, and mutating it would leave a remount with no styles.
*/
function stripExtractedCompositionAssets(node: ParentNode): void {
for (const el of Array.from(node.querySelectorAll("style, script"))) {
el.remove();
}
}
function prepareFlattenedInnerRoot(innerRoot: HTMLElement): HTMLElement {
const prepared = document.importNode(innerRoot, true) as HTMLElement;
markFlattenedInnerRoot(prepared);
@@ -450,68 +464,37 @@ async function mountCompositionContent(params: {
// 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 from `sourceNode`, not the composition root: the canonical authored
// shape puts <style>/<script> as SIBLINGS of the root inside <template>, so
// scanning only the root dropped a composition's entire stylesheet (and with
// it `#root { container-type: size }`, leaving every cq* unit unanchored).
// `sourceNode` is a superset of the root, so nothing is collected twice.
injectScopedStyles(Array.from(params.sourceNode.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);
if (params.compositionUrl && isSameDocumentUrl(resolvedSrc, params.compositionUrl)) {
continue;
}
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);
const toPendingScript = (script: HTMLScriptElement): PendingScript | null => {
const type = script.getAttribute("type")?.trim() ?? "";
const src = script.getAttribute("src")?.trim() ?? "";
if (src) {
const resolvedSrc = resolveScriptSourceUrl(src, params.compositionUrl);
// A sub-comp that <script src>s itself would re-enter the mount; skip it.
if (params.compositionUrl && isSameDocumentUrl(resolvedSrc, params.compositionUrl)) {
script.parentNode?.removeChild(script);
continue;
}
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,
});
return null;
}
return { kind: "external", src: resolvedSrc, type };
}
script.parentNode?.removeChild(script);
}
const remainingStyles = Array.from(contentNode.querySelectorAll<HTMLStyleElement>("style"));
for (const style of remainingStyles) {
style.parentNode?.removeChild(style);
}
const content = script.textContent?.trim() ?? "";
if (!content) return null;
return { kind: "inline", content, type, scopeCompositionId: authoredScopeCompositionId };
};
// <head> scripts first (e.g. a GSAP CDN tag in a non-template sub-comp): they
// must execute before the content scripts that call into them.
const scriptPayloads = [
...(params.headScripts ?? []),
...Array.from(params.sourceNode.querySelectorAll<HTMLScriptElement>("script")),
]
.map(toPendingScript)
.filter((payload): payload is PendingScript => payload !== null);
if (innerRoot) {
const widthRaw = innerRoot.getAttribute("data-width");
@@ -525,9 +508,13 @@ async function mountCompositionContent(params: {
if (innerRoot.hasAttribute("data-timeline-locked")) {
params.host.setAttribute("data-timeline-locked", "");
}
params.host.appendChild(prepareFlattenedInnerRoot(innerRoot));
const flattenedRoot = prepareFlattenedInnerRoot(innerRoot);
stripExtractedCompositionAssets(flattenedRoot);
params.host.appendChild(flattenedRoot);
} else if (params.hasTemplate) {
params.host.appendChild(document.importNode(contentNode, true));
const mountedContent = document.importNode(contentNode, true);
stripExtractedCompositionAssets(mountedContent);
params.host.appendChild(mountedContent);
} else {
params.host.innerHTML = params.fallbackBodyInnerHtml;
}