Skip to content

fix(core): scope sub-composition html/body styles so they don't clobber the host document#2089

Merged
miguel-heygen merged 1 commit into
mainfrom
worktree-fix-subcomp-body-style-leak
Jul 9, 2026
Merged

fix(core): scope sub-composition html/body styles so they don't clobber the host document#2089
miguel-heygen merged 1 commit into
mainfrom
worktree-fix-subcomp-body-style-leak

Conversation

@miguel-heygen

Copy link
Copy Markdown
Collaborator

Problem

Sub-composition <head> styles that target html/body/:root (typically width/height/overflow/background) were injected into the parent document unscoped. scopeCssToComposition deliberately passed html/body/:root through unchanged, so when a sub-composition is smaller than the root, its body { width/height; overflow: hidden } clobbered the host composition's <body> and clipped the whole composite down to the last-mounted sub-comp's size.

Symptom: in a composition with multiple sub-compositions (or a sub-comp smaller than the root), only the top-left element painted in the Studio preview; everything positioned outside that clipped box (including framework-owned video) appeared missing/black. This affected both the Studio runtime mount and the baked render.

Root cause: packages/core/src/compiler/compositionScoping.tsscopeSelector returned html/body/:root/* selectors verbatim. Correct for a top-level document, but wrong for sub-composition styles that get lifted into a parent document.

Fix

Add a scopeRootSelectors option to scopeCssToComposition that remaps html/body/:root to the composition's own box (host or flattened inner root) instead of leaving them document-level. Enable it everywhere sub-composition styles are scoped:

  • runtime/compositionLoader.ts (Studio live preview mount)
  • compiler/inlineSubCompositions.ts (render inliner)
  • compiler/htmlBundler.ts (bundle path)

The universal selector * is left untouched, and top-level composition scoping is unchanged (scopeRootSelectors defaults off), so a composition still legitimately owns its own document.

Tests

New compositionScoping cases cover: default pass-through of html/body/:root, remapping under scopeRootSelectors, and * staying untouched. Full compiler suite green (130 tests).

Verified live in Studio: a project with image / video / shape / text / sub-composition scenes now renders all of them (body stays at the composition's 1920x1080); before the fix the body collapsed to a sub-comp's size and clipped everything but the top-left.

Notes

The .fallowrc.jsonc entries added here are file-level exemptions for pre-existing complexity/duplication in the touched files (large scoping/bundler functions and old test/script-loop clones), re-flagged only by the line-shift from this change, following the existing convention in that file.

Copy link
Copy Markdown
Collaborator Author

@james-russo-rames-d-jusso james-russo-rames-d-jusso left a comment

Copy link
Copy Markdown

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Reviewed at 22aa93f.

Clean, narrow fix — LGTM from my side, leaving as a comment. The extracted compositionBoxSelector DRY-refactors the existing bare-composition-root case (previous line 146) and reuses that same "host OR flattened inner-root, exactly one" invariant for the new remap. All four sub-composition scoping call sites correctly opt in (compositionLoader.mountCompositionContent, inlineSubCompositions head + content style loops, both htmlBundler template-match + fallback paths), top-level compositions keep scopeRootSelectors default-off so they still legitimately own the document, and * stays untouched. CI is green across the full matrix — Perf/parity/Preview-parity/Windows-render/Smoke all passing at HEAD.

Findings below are all yellow — none blocking.

Concerns

  • Compound html/body/:root selectors bypass the remap (packages/core/src/compiler/compositionScoping.ts:124). The guard /^(html|body|:root)$/i.test(trimmed) matches only bare selectors, so a sub-comp writing body.dark { overflow: hidden }, html body { ... }, :root .foo { ... }, body[data-theme="x"] { ... }, or body:hover { ... } skips the remap and drops to general scoping (${scope} body.dark), which selects nothing under the composition box. Behavior is byte-identical to pre-fix (those selectors were dead before too — the parent <body> doesn't have data-composition-id on it), so this is not a regression, but the fix's promise ("sub-comp html/body no longer clobbers") is only true for the bare forms actually enumerated. Two consequences worth flagging:

    1. A sub-comp that authors body { overflow: hidden } gets the fix; the same sub-comp rewritten as body.scene-a { overflow: hidden } silently doesn't — the visual clobber symptom won't manifest either (since it doesn't match the parent <body> anymore under general scoping), but the intended styling on the sub-comp is also dead. It's a "both branches misfire" fingerprint — the sub-comp author sees no styling, easy to misdiagnose.
    2. Consider extending the regex to accept a bare tag/pseudo prefix (^(html|body|:root)([.:\[]|\s|$)), or at minimum document the current bare-only coverage in the scoping docstring so future maintainers don't assume broader semantics.
  • :root custom-property leak — small backwards-compat. Pre-fix, a sub-comp's :root { --brand: red } set --brand on the parent <html>, which any parent-doc CSS could var(--brand). Post-fix, --brand is scoped to the composition box only. Overwhelmingly likely to be an accidental leak (the docs describe :root as document-level), but any published composition that unknowingly depended on sub-comp :root variables reaching upward loses that. Not worth a code change; worth a line in the release notes / CHANGELOG so a customer hitting "my var(--brand) stopped resolving after upgrade" has a hit to search for.

Nits

  • compositionBoxSelector at line 100-105 uses :has([data-hf-inner-root]). That baseline is Chrome 105 / Safari 15.4 / Firefox 121. This constraint already existed at the previous line-146 use so nothing's newly at risk here — but promoting it to a shared helper is a good moment to note the :has() dependency in the docstring, since new callers might not realize.

  • The new does not scope * even with scopeRootSelectors test is good defensive coverage. Consider one more test that pins the concrete clobber pattern verbatim from the PR body — something like:

    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("overflow: hidden");

    The existing test at line 48-66 asserts the negative space (no bare html/body/:root survives) and the presence of some [data-composition-id="scene"] + data-hf-inner-root fragment, which is solid — but pinning the exact multi-property declaration that motivated the fix ensures a future rewrite that changes the output shape (e.g. splits html, body into two rules or reorders declarations) can't leave the abstract-shape assertions green while re-breaking the concrete case.

  • .fallowrc.jsonc re-flags are appropriate line-shift exemptions per the file's convention — no objection, just noting how many touched files now carry re-flags (5 across dup/health), all pre-existing complexity carried by the sub-comp scoping subsystem. Not something for this PR to solve.

What I didn't verify

  • The Studio-live "verified" claim in the PR body — CI's Preview parity + Perf: parity + Perf: drift are all green at 22aa93f, so I trust the end-to-end observation without re-running.
  • Nested sub-composition (sub-comp A containing sub-comp B): the bundler's single-pass over the subCompositionHosts snapshot suggests depth-1 inlining, so B's <style> would need a second pass to be scoped at all. This is pre-existing pipeline shape independent of this PR — if depth>1 works today via some other mechanism, scopeRootSelectors: true propagates through the shared inliner and the fix applies at every level.
  • Actual browser matrix for Studio's minimum-supported :has() — pre-existing dependency, not introduced here.

Cross-PR

  • #2090 (always-on crop) — no file overlap (2090 is packages/studio/* only, this PR is packages/core/src/compiler + runtime). Direction is complementary — 2089 keeps the composition preview at its authored dimensions, which is exactly what the crop overlay in 2090 needs to measure against. No conflict.

Review by Rames D Jusso

@vanceingalls vanceingalls left a comment

Copy link
Copy Markdown
Collaborator

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

R1 — hyperframes #2089 at 22aa93fb

🟢 LGTM. Concurring with Rames on verdict and on the compound-selector + :root-var-leak concerns; the notes below are the delta I saw independently.

Root cause is called correctly — a mounted/inlined sub-composition's body { width/height; overflow: hidden } was hitting the parent <body> and collapsing the host preview to the last-mounted sub-comp's box. Fix is minimal, opt-in, and coverage is complete.

Verified independently at head SHA

  • Consumer coverage — all four sub-comp scoping call sites flipped. Cross-checked at 22aa93fb:
    • runtime/compositionLoader.ts:404 (Studio live-mount) — scopeRootSelectors: true
    • compiler/inlineSubCompositions.ts:256 (DRYed via scopeSubStyle; the two previous call sites for compDoc.head styles and contentDoc styles now share one path) — scopeRootSelectors: true
    • compiler/htmlBundler.ts:879 (inner-root path, authoredRootId present) — ✅
    • compiler/htmlBundler.ts:910 (no-inner-root path, authoredRootId = undefined) — ✅
    • htmlCompiler.ts / htmlDocument.ts don't call scopeCssToComposition. That's the whole write-site list.
  • compositionBoxSelector DRY. compositionScoping.ts:103-105 extracts ${scope}:not(:has([data-hf-inner-root])), ${scope} > [data-hf-inner-root] and shares it between the new html/body/:root remap (:129) and the pre-existing bare-composition-root branch (:146). If one drifts they drift together — right invariant for a two-site pattern.
  • isInsideGlobalAtRule still gates keyframes/font-face. Rules nested in @keyframes / @font-face bypass rescoping (unchanged). Rules nested in @media still walk — so @media (max-width: 600px) { body { ... } } inside a sub-comp does get remapped under the flag, which is what you want.
  • CI is fully green at head — Fallow, Test, Typecheck, Format, Lint, Build, Render on windows, Preview parity, SDK unit/contract/smoke, Studio load smoke, all Perf lanes, regression, Preflights. 39 checks pass.

Concurring with Rames

  • Compound body.dark / html body / body:hover still fall through to the dead-code prepend path. Rames spelled this out fully; same shape as before this PR — not a regression, but a follow-up-worthy gap. My independent read agrees on his suggested ^(html|body|:root)([.:\[]|\s|$) extension being the honest fix if we ever pick it up.
  • :root custom-property backwards-compat. Good catch — I'd missed this. A sub-comp's :root { --brand: red } used to leak to the parent <html>, so any parent-doc var(--brand) would resolve. Post-fix, --brand is bounded to the composition box. Overwhelmingly likely to be an accidental leak, but agree a CHANGELOG line for "sub-composition :root custom properties no longer reach the host document" would save the debug for anyone who tripped on it.
  • :has() baseline note in the docstring. Chrome 105 / Safari 15.4 / Firefox 121. The dependency already existed on the pre-fix bare-composition-root branch, but promoting it to a shared helper is a fair moment to document it.

Unique observations

O1 — * universal selector is the last document-level leak path. A sub-comp with * { color: red } still applies to every element in the parent document, not just descendants of [data-composition-id="..."]. The pre-fix code was /^(html|body|:root|\*)$/i (all four pass-through); this PR splits * out and preserves the pass-through explicitly (compositionScoping.ts:123 + test lock at :70-79). Idempotent rules like box-sizing: border-box are harmless; something like * { color: red } would color the whole host doc. Doesn't affect the reported clipping symptom and it's out of scope for this fix — worth naming because the test freezes the behavior as intentional, so if it's really "we'll never remap *", great, and if it's "we should, later," a follow-up is called for. A parallel ${scope} * remap under the same flag would slot in without churn.

O2 — Test's negative-match regex for body is a touch looser than for html/:root. compositionScoping.test.ts:55 uses [,{] (catches body, in compound + body {); :56 (:root) and :57 (body) use \{ only. If a future regression somehow produced body, at the front of a compound remapped selector, only the html line would catch it. Not a real risk (postcss splits selectors on , before each hits scopeSelector, so compound output shouldn't happen), just a symmetry nit. Trivial: unify all three on [,{].

Cross-PR

No conflict with #2090 — that PR is packages/studio/* only; this PR is packages/core/src/compiler + runtime. The composition preview staying at authored dimensions (this PR) is exactly what 2090's crop overlay needs to measure against.

R1 by Via

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.
@miguel-heygen miguel-heygen force-pushed the worktree-fix-subcomp-body-style-leak branch from 22aa93f to da4ba8b Compare July 9, 2026 01:47
@miguel-heygen

Copy link
Copy Markdown
Collaborator Author

Thanks for the thorough pass. Addressed the actionable yellow items in the latest push:

  • Concrete-clobber test — added a test that pins the exact html, body { width/height/overflow/background } input to its remapped output ([data-composition-id="scene"]:not(:has([data-hf-inner-root])) + > [data-hf-inner-root], width: 560px, overflow: hidden), so an output-shape rewrite can't leave the abstract-shape assertions green while re-breaking the concrete case.
  • :has() dependency — noted in the compositionBoxSelector docstring.
  • Bare-only coverage — documented in the html/body/:root block: compound forms (body.dark, body[data-theme], body:hover, html body, :root .x) intentionally fall through to general scoping. As you noted this is byte-identical to pre-fix (they never matched the parent <body>), so no clobber to fix; extending the remap to compound forms would be new behavior, out of scope here.

Deferring (agreed non-blocking): the :root custom-property upward-leak is an accidental-leak edge; I'll add a CHANGELOG line rather than change code. Fallow re-flags are pre-existing complexity per the file's convention.

@miguel-heygen miguel-heygen merged commit 6f0b57d into main Jul 9, 2026
50 checks passed
@miguel-heygen miguel-heygen deleted the worktree-fix-subcomp-body-style-leak branch July 9, 2026 02:21
Sign up for free to join this conversation on GitHub. Already have an account? Sign in to comment

Labels

None yet

Projects

None yet

Development

Successfully merging this pull request may close these issues.

3 participants