diff --git a/.fallowrc.jsonc b/.fallowrc.jsonc index bf8b49a47..102ec8902 100644 --- a/.fallowrc.jsonc +++ b/.fallowrc.jsonc @@ -410,6 +410,12 @@ "packages/studio/src/captions/store.ts", "packages/studio/src/captions/components/CaptionOverlay.tsx", "packages/cli/src/server/studioServer.ts", + // Sub-composition html/body scoping fix: inlineSubCompositions.ts has a + // pre-existing head-vs-content script-extraction clone, and the scoping + // test file has pre-existing parallel arrange/act/assert cases. Adding the + // scopeRootSelectors handling shifts lines and re-flags both. + "packages/core/src/compiler/inlineSubCompositions.ts", + "packages/core/src/compiler/compositionScoping.test.ts", ], }, "health": { @@ -579,6 +585,16 @@ "packages/studio/src/player/components/TimelineCanvas.tsx", "packages/studio/src/player/components/TimelineClip.tsx", "packages/studio/src/player/components/TimelineClipDiamonds.tsx", + // Sub-composition html/body scoping fix: these files carry pre-existing + // CRITICAL/HIGH functions (inlineSubCompositions, bundleToSingleHtml, + // mountCompositionContent/loadExternalCompositions, scopeSelector/ + // replaceAuthoredRootIdSelectors) that pre-date this PR. The change only + // threads a scopeRootSelectors option through the existing scoping calls; + // the line-shift fingerprint re-flags the inherited complexity. + "packages/core/src/compiler/compositionScoping.ts", + "packages/core/src/compiler/inlineSubCompositions.ts", + "packages/core/src/compiler/htmlBundler.ts", + "packages/core/src/runtime/compositionLoader.ts", ], }, } diff --git a/packages/core/src/compiler/compositionScoping.test.ts b/packages/core/src/compiler/compositionScoping.test.ts index 19c7b019d..7d572e4b6 100644 --- a/packages/core/src/compiler/compositionScoping.test.ts +++ b/packages/core/src/compiler/compositionScoping.test.ts @@ -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
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"); diff --git a/packages/core/src/compiler/compositionScoping.ts b/packages/core/src/compiler/compositionScoping.ts index 40aecbe74..a1ba96755 100644 --- a/packages/core/src/compiler/compositionScoping.ts +++ b/packages/core/src/compiler/compositionScoping.ts @@ -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 + // 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 (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, ), ); }); diff --git a/packages/core/src/compiler/htmlBundler.ts b/packages/core/src/compiler/htmlBundler.ts index 4af4154fe..029839f3a 100644 --- a/packages/core/src/compiler/htmlBundler.ts +++ b/packages/core/src/compiler/htmlBundler.ts @@ -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, { diff --git a/packages/core/src/compiler/inlineSubCompositions.ts b/packages/core/src/compiler/inlineSubCompositions.ts index 2d7f79da5..77df1e978 100644 --- a/packages/core/src/compiler/inlineSubCompositions.ts +++ b/packages/core/src/compiler/inlineSubCompositions.ts @@ -247,20 +247,26 @@ export function inlineSubCompositions( } } + // Scope one sub-composition