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
@@ -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,
),
);
});