mirror of
https://github.com/heygen-com/hyperframes.git
synced 2026-09-03 04:38:33 +00:00
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:
@@ -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,
|
||||
),
|
||||
);
|
||||
});
|
||||
|
||||
@@ -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);
|
||||
|
||||
Reference in New Issue
Block a user