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.
This commit is contained in:
Miguel Ángel
2026-07-08 22:21:47 -04:00
committed by GitHub
parent 57b3c78987
commit 6f0b57d608
6 changed files with 136 additions and 21 deletions
@@ -36,6 +36,66 @@ body { margin: 0; }
expect(scoped).toContain("body { margin: 0; }");
});
it("leaves html/body/:root untouched by default (top-level composition owns the document)", () => {
const scoped = scopeCssToComposition(
`html, body { width: 560px; height: 360px; overflow: hidden; }\n:root { --x: 1; }`,
"scene",
);
expect(scoped).toContain("html, body { width: 560px");
expect(scoped).toContain(":root { --x: 1; }");
});
it("remaps html/body/:root to the composition box when scopeRootSelectors is set (sub-comp mount/inline)", () => {
const scoped = scopeCssToComposition(
`html, body { width: 560px; height: 360px; overflow: hidden; background: #14141c; }\n:root { color: red; }\n.title { opacity: 0; }`,
"scene",
undefined,
undefined,
{ scopeRootSelectors: true },
);
// No bare document-level selectors survive — they must not clobber the host document's body.
expect(scoped).not.toMatch(/(^|[\s,{})])html\s*[,{]/);
expect(scoped).not.toMatch(/(^|[\s,{})]):root\s*\{/);
expect(scoped).not.toMatch(/(^|[\s,{}])body\s*\{/);
// They are remapped to the composition's own box (host or flattened inner root).
expect(scoped).toContain('[data-composition-id="scene"]');
expect(scoped).toContain("data-hf-inner-root");
expect(scoped).toContain("width: 560px");
// Regular selectors still scope as usual.
expect(scoped).toContain('[data-composition-id="scene"] .title { opacity: 0; }');
});
it("does not scope the universal selector even with scopeRootSelectors", () => {
const scoped = scopeCssToComposition(
`* { box-sizing: border-box; }`,
"scene",
undefined,
undefined,
{
scopeRootSelectors: true,
},
);
expect(scoped).toContain("* { box-sizing: border-box; }");
});
it("pins the concrete parent-body clobber pattern that motivated the fix", () => {
// The exact sub-comp rule that used to shrink the host <body> and clip the
// preview. Assert the concrete remapped output, not just abstract shape, so a
// future rewrite that reorders/splits declarations can't leave the shape
// assertions green while re-breaking this case.
const scoped = scopeCssToComposition(
`html, body { width: 560px; height: 360px; overflow: hidden; background: #14141c; }`,
"scene",
undefined,
undefined,
{ scopeRootSelectors: true },
);
expect(scoped).toContain('[data-composition-id="scene"]:not(:has([data-hf-inner-root]))');
expect(scoped).toContain('[data-composition-id="scene"] > [data-hf-inner-root]');
expect(scoped).toContain("width: 560px");
expect(scoped).toContain("overflow: hidden");
});
it("wraps classic scripts without render-loop requestAnimationFrame waits", () => {
const wrapped = wrapScopedCompositionScript("window.__ran = true;", "scene");
@@ -97,12 +97,22 @@ function normalizeAuthoredRootIdSelector(selector: string, authoredRootId?: stri
);
}
/** The composition's own box: the host when it renders content directly, or the
* flattened inner root when one is preserved below the host. Used both for a
* bare composition-root selector and for remapped document-level selectors.
* Relies on `:has()` (Chrome 105 / Safari 15.4 / Firefox 121) — an existing
* baseline for the bare-root case, noted here for new callers. */
function compositionBoxSelector(scope: string): string {
return `${scope}:not(:has([${INNER_ROOT_ATTR}])), ${scope} > [${INNER_ROOT_ATTR}]`;
}
function scopeSelector(
selector: string,
scope: string,
compositionId: string,
authoredRootId?: string | null,
compoundAuthoredRoot?: boolean,
scopeRootSelectors?: boolean,
): string {
const selectorWithoutAuthoredRootId = normalizeAuthoredRootIdSelector(selector, authoredRootId);
const selectorWithoutRootTiming = normalizeCompositionRootSelector(
@@ -112,7 +122,21 @@ function scopeSelector(
);
const trimmed = selectorWithoutRootTiming.trim();
if (!trimmed) return selector;
if (/^(html|body|:root|\*)$/i.test(trimmed)) return selector;
if (trimmed === "*") return selector;
if (/^(html|body|:root)$/i.test(trimmed)) {
// A mounted/inlined sub-comp's document-level selectors must not style the
// PARENT (a sub-comp `body { width/height/overflow }` would clobber the host
// <body> and clip the preview/render). Remap to the comp's own box. A
// top-level compile (scopeRootSelectors falsy) legitimately owns the document.
//
// Coverage is intentionally BARE-only: compound forms (`body.dark`,
// `body[data-theme]`, `body:hover`, `html body`, `:root .x`) fall through to
// general scoping below. That is byte-identical to pre-fix behavior — those
// selectors never matched the parent <body> (it has no data-composition-id),
// so there was no clobber to fix. The bare forms are the ones that actually
// caused the parent-body clobber, which is what this remap targets.
return scopeRootSelectors ? compositionBoxSelector(scope) : selector;
}
const compositionIdPattern = new RegExp(
`\\[\\s*data-composition-id\\s*=\\s*(["'])${escapeRegExp(compositionId)}\\1\\s*\\]`,
"g",
@@ -128,7 +152,7 @@ function scopeSelector(
// exactly one of the two: applying it to both compounds any additive
// property (padding, margin, non-zero transform) since the wrapper
// sits nested inside the host and would inherit the effect twice.
return `${scope}:not(:has([${INNER_ROOT_ATTR}])), ${scope} > [${INNER_ROOT_ATTR}]`;
return compositionBoxSelector(scope);
}
return selectorWithoutRootTiming.replace(compositionIdPattern, scope);
}
@@ -181,7 +205,7 @@ export function scopeCssToComposition(
compositionId: string,
scopeSelectorOverride?: string,
authoredRootId?: string | null,
options?: { compoundAuthoredRoot?: boolean },
options?: { compoundAuthoredRoot?: boolean; scopeRootSelectors?: boolean },
): string {
const trimmedCompositionId = compositionId.trim();
if (!css || !trimmedCompositionId) return css;
@@ -199,6 +223,7 @@ export function scopeCssToComposition(
trimmedCompositionId,
authoredRootId,
options?.compoundAuthoredRoot,
options?.scopeRootSelectors,
),
);
});
+12 -2
View File
@@ -875,7 +875,11 @@ export async function bundleToSingleHtml(
for (const styleEl of [...innerRoot.querySelectorAll("style")]) {
const css = styleEl.textContent || "";
compStyleChunks.push(
compId ? scopeCssToComposition(css, compId, runtimeScope, authoredRootId) : css,
compId
? scopeCssToComposition(css, compId, runtimeScope, authoredRootId, {
scopeRootSelectors: true,
})
: css,
);
styleEl.remove();
}
@@ -901,7 +905,13 @@ export async function bundleToSingleHtml(
// 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);
compStyleChunks.push(
compId
? scopeCssToComposition(css, compId, runtimeScope, undefined, {
scopeRootSelectors: true,
})
: css,
);
styleEl.remove();
}
hoistCompositionScripts(innerDoc, {
@@ -247,20 +247,26 @@ export function inlineSubCompositions(
}
}
// Scope one sub-composition <style> body. scopeRootSelectors keeps the
// sub-comp's html/body/:root rules from clobbering the host document (they
// are remapped to the composition box); see compositionScoping.
const scopeSubStyle = (raw: string): string => {
const css = rewriteCssAssetUrls(raw, src);
return scopeCompId
? scopeCssToComposition(css, scopeCompId, runtimeScope || undefined, authoredRootId, {
compoundAuthoredRoot: compoundAuthoredRoot === true,
scopeRootSelectors: true,
})
: css;
};
// When a sub-composition is a full HTML document (no <template>), styles
// and scripts in <head> are not part of contentDoc (which only has body
// content). Extract them so backgrounds, positioning, fonts, and library
// scripts (e.g. GSAP CDN) are not silently dropped.
if (!contentRoot && compDoc.head) {
for (const s of [...compDoc.head.querySelectorAll("style")]) {
const css = rewriteCssAssetUrls(s.textContent || "", src);
styles.push(
scopeCompId
? scopeCssToComposition(css, scopeCompId, runtimeScope || undefined, authoredRootId, {
compoundAuthoredRoot: compoundAuthoredRoot === true,
})
: css,
);
styles.push(scopeSubStyle(s.textContent || ""));
}
for (const s of [...compDoc.head.querySelectorAll("script")]) {
const externalSrc = (s.getAttribute("src") || "").trim();
@@ -288,14 +294,7 @@ export function inlineSubCompositions(
// Extract styles from content
for (const s of [...contentDoc.querySelectorAll("style")]) {
const css = rewriteCssAssetUrls(s.textContent || "", src);
styles.push(
scopeCompId
? scopeCssToComposition(css, scopeCompId, runtimeScope || undefined, authoredRootId, {
compoundAuthoredRoot: compoundAuthoredRoot === true,
})
: css,
);
styles.push(scopeSubStyle(s.textContent || ""));
s.remove();
}
@@ -406,6 +406,11 @@ async function mountCompositionContent(params: {
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);