Skip to content

fix(core): preserve nested-rule selectors in composition CSS scoping (#2721) - #2733

Merged
jrusso1020 merged 1 commit into
mainfrom
jerrai/fix-nested-css-composition-scoping-2721
Jul 22, 2026
Merged

jrusso1020 merged 1 commit into
mainfrom
jerrai/fix-nested-css-composition-scoping-2721

Conversation

@jrusso1020

Copy link
Copy Markdown
Collaborator

Description

Fixes #2721. External-user report from qd3v: a nested CSS rule inside a composition's <style> block gets silently ignored.

Reproducer (from the issue, exact):

<style>
  /* All good */
  [data-composition-id="intro-comp"] h1 { color: orangered; font-size: 5rem; }

  /* Just ignored */
  [data-composition-id="intro-comp"] {
    .title { color: brown; font-size: 6rem; }
  }
</style>
<h1>Hyperframes</h1>
<div class="title">Welcome!</div>

The h1 gets styled; the .title doesn't.

Root cause

Modern browsers support native CSS Nesting (Chrome 112+, Firefox 117+, Safari 16.5+). A nested rule .title { … } inside [data-composition-id="intro-comp"] { … } resolves at match time to <parent> .title via the implicit & prefix.

scopeCssToComposition in packages/core/src/compiler/compositionScoping.ts walks every Rule via root.walkRules and re-scopes selectors:

root.walkRules((rule) => {
  if (isInsideGlobalAtRule(rule)) return;
  rule.selectors = rule.selectors.map((selector) => scopeSelector(selector, scope, …));
});

PostCSS's walkRules visits nested rules too. The nested .title selector was scoped to [data-composition-id="intro-comp"] .title — but the parent's selector was also scoped, so when CSS Nesting resolved the inner rule it prepended & (the parent scope) AGAIN, producing effectively:

[data-composition-id="intro-comp"]:not(:has(…)) [data-composition-id="intro-comp"] .title { … }

The composition root only appears once in the DOM, so <scope> <scope> .title matches nothing — the rule appears "just ignored" as the reporter described.

Fix

Add isNestedInsideAnotherRule predicate — mirrors the existing isInsideGlobalAtRule — and skip nested Rules in the walkRules callback. Top-level rules still get scoped; their nested descendants inherit scope naturally via CSS Nesting at match time.

 root.walkRules((rule) => {
   if (isInsideGlobalAtRule(rule)) return;
+  if (isNestedInsideAnotherRule(rule)) return;
   rule.selectors = rule.selectors.map((selector) => scopeSelector(…));
 });

Testing

  • bunx vitest run packages/core/src/compiler/compositionScoping.test.ts — 37 tests pass (35 pre-existing + 2 new).
  • New test: preserves nested-rule selectors so CSS Nesting inheritance works (#2721) — asserts nested .title and h2 selectors stay verbatim while top-level rules keep scoping, and no […] .title false-scoped selector appears.
  • New test: preserves deeply-nested CSS Nesting rules (#2721) — same guarantee at depth 3.
  • Traced the exact code path from the postcss root.walkRules bit; reproduced the double-scoping via a local postcss probe before writing the fix.

Note on other issues raised in the ticket

The reporter also mentioned two secondary points that are out of scope for this PR:

  1. "If I try to use body tag instead of template, the rendering fails" — that's the standalone sub-composition contract (must have a <template> or <body> with [data-composition-id]). The error is correct; the docs could be clearer. Filing separately if it needs a doc update.
  2. "linter or checker wants me to add data-layout-allow-overlap, but there's no such attribute in the docs" — that's a docs gap on hyperframes.heygen.com/concepts/data-attributes. Filing separately if needed.

— Rames Jusso

…2721)

Chrome 112+ / Firefox 117+ / Safari 16.5+ support native CSS Nesting.
A nested rule like '.title { … }' inside '[data-composition-id="intro"]
{ … }' resolves at match time to '<parent> .title' via the implicit
'&' prefix.

'scopeCssToComposition' walks every rule via 'root.walkRules' and re-
scopes selectors, but it did so for nested rules too — producing
'[…scope…] .title' inside '[…scope…] { … }', which nesting then
prepends AGAIN to '[…scope…] […scope…] .title'. Since the composition
root only appears once in the DOM, the doubly-scoped selector never
matches — the nested rule appears 'just ignored' as the reporter
described (#2721).

Reproduced on 0.7.66 with the reporter's exact composition. Fix: add
'isNestedInsideAnotherRule' predicate — mirrors the existing
'isInsideGlobalAtRule' — and skip nested rules in the walkRules
callback. Top-level rules still get scoped; their nested descendants
inherit scope naturally via CSS Nesting at match time.

Added two focused tests:
- 'preserves nested-rule selectors so CSS Nesting inheritance works
  (#2721)' — asserts nested '.title' and 'h2' selectors stay verbatim
  while top-level rules keep scoping.
- 'preserves deeply-nested CSS Nesting rules (#2721)' — same rule at
  depth 3.

All 37 existing scopeCssToComposition tests still pass.

Fixes #2721.

Co-Authored-By: Claude Opus 4.7 <[email protected]>

@miguel-heygen miguel-heygen 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.

Clean fix at exact head 34501006b72822defb4586d8ef8e172416c68573.

Strengths

  • packages/core/src/compiler/compositionScoping.ts:212–:237 walks the full ancestor chain, including intervening group at-rules, and scopes only the outer style rule; nested selectors therefore inherit the single parent scope exactly as native CSS Nesting specifies.
  • packages/core/src/compiler/compositionScoping.test.ts:803–:845 pins the reporter case and a three-deep nesting case while retaining the existing top-level/global-at-rule suite.

Verification

  • Focused suite: 37/37 pass.
  • git diff --check passes.
  • Chromium probe on the exact shape: top-level h1 computes orangered, nested .title computes brown, and a nested @media child computes blue; the serialized CSS keeps nested selectors verbatim and scopes the parent once.
  • GitHub Build, Typecheck, and runtime-contract are green; remaining required Test/Windows lanes are still running, not failing.

Verdict: APPROVE
Reasoning: The ancestor-rule guard fixes the double-scope root cause without disturbing top-level or global at-rule handling, and both AST-level and browser-level evidence confirm the intended match behavior.

— Magi

@jrusso1020
jrusso1020 merged commit 1e2c7d6 into main Jul 22, 2026
53 checks passed
@jrusso1020
jrusso1020 deleted the jerrai/fix-nested-css-composition-scoping-2721 branch July 22, 2026 04:25
dahans-msft2 pushed a commit to dahans-msft2/hyperframes that referenced this pull request Aug 6, 2026
…eygen-com#2721) (heygen-com#2733)

Chrome 112+ / Firefox 117+ / Safari 16.5+ support native CSS Nesting.
A nested rule like '.title { … }' inside '[data-composition-id="intro"]
{ … }' resolves at match time to '<parent> .title' via the implicit
'&' prefix.

'scopeCssToComposition' walks every rule via 'root.walkRules' and re-
scopes selectors, but it did so for nested rules too — producing
'[…scope…] .title' inside '[…scope…] { … }', which nesting then
prepends AGAIN to '[…scope…] […scope…] .title'. Since the composition
root only appears once in the DOM, the doubly-scoped selector never
matches — the nested rule appears 'just ignored' as the reporter
described (heygen-com#2721).

Reproduced on 0.7.66 with the reporter's exact composition. Fix: add
'isNestedInsideAnotherRule' predicate — mirrors the existing
'isInsideGlobalAtRule' — and skip nested rules in the walkRules
callback. Top-level rules still get scoped; their nested descendants
inherit scope naturally via CSS Nesting at match time.

Added two focused tests:
- 'preserves nested-rule selectors so CSS Nesting inheritance works
  (heygen-com#2721)' — asserts nested '.title' and 'h2' selectors stay verbatim
  while top-level rules keep scoping.
- 'preserves deeply-nested CSS Nesting rules (heygen-com#2721)' — same rule at
  depth 3.

All 37 existing scopeCssToComposition tests still pass.

Fixes heygen-com#2721.

Co-authored-by: Claude Opus 4.7 <[email protected]>
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.

Nested CSS just ignored

2 participants