fix(core): preserve nested-rule selectors in composition CSS scoping (#2721) (#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 (#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 <noreply@anthropic.com>
This commit is contained in:
James Russo
2026-07-22 00:24:59 -04:00
committed by GitHub
co-authored by Claude Opus 4.7
parent a637f394ee
commit 1e2c7d673f
2 changed files with 64 additions and 0 deletions
@@ -800,6 +800,51 @@ window.__afterTimeline = window.__timelines.scene;
expect(result).toContain('[id="intro"]');
});
it("preserves nested-rule selectors so CSS Nesting inheritance works (#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.
// The scoper must NOT re-apply the composition scope to the nested selector —
// that produces `<scope> <scope> .title`, which matches nothing because the
// composition root only appears once in the DOM.
const scoped = scopeCssToComposition(
`
[data-composition-id="intro"] h1 { color: red; }
[data-composition-id="intro"] {
.title { color: brown; }
h2 { color: blue; }
}
`,
"intro",
);
// Top-level rules still get scoped.
expect(scoped).toContain('[data-composition-id="intro"] h1');
// Nested rules keep their author-original selectors verbatim so CSS Nesting
// can prepend the parent's `&` at match time.
expect(scoped).toMatch(/\.title\s*\{/);
expect(scoped).toMatch(/h2\s*\{/);
// The pre-fix bug re-scoped nested rules to `[…] .title`, which never matched.
expect(scoped).not.toContain('[data-composition-id="intro"] .title');
expect(scoped).not.toContain('[data-composition-id="intro"] h2');
});
it("preserves deeply-nested CSS Nesting rules (#2721)", () => {
const scoped = scopeCssToComposition(
`
[data-composition-id="intro"] {
.card {
.header { font-weight: bold; }
}
}
`,
"intro",
);
expect(scoped).toMatch(/\.card\s*\{/);
expect(scoped).toMatch(/\.header\s*\{/);
expect(scoped).not.toContain('[data-composition-id="intro"] .card');
expect(scoped).not.toContain('[data-composition-id="intro"] .header');
});
it("wraps scripts with authored root id normalization for #id GSAP selectors", () => {
const { document } = parseHTML(`
<div data-composition-id="intro">
@@ -200,6 +200,24 @@ function isInsideGlobalAtRule(rule: Rule): boolean {
return false;
}
/**
* A Rule nested inside another Rule (CSS Nesting Module Level 1) already
* inherits scope from its parent's `&` prefix at match time — re-applying
* the composition scope to the nested selector produces
* `<scope> <scope> .child`, which matches nothing when the composition
* root only appears once in the DOM. Only top-level rules get scoped;
* their nested descendants inherit the scope naturally via CSS nesting.
* See #2721 for the reproducer that motivated this.
*/
function isNestedInsideAnotherRule(rule: Rule): boolean {
let current: Node["parent"] = rule.parent;
while (current) {
if (current.type === "rule") return true;
current = current.parent;
}
return false;
}
export function scopeCssToComposition(
css: string,
compositionId: string,
@@ -216,6 +234,7 @@ export function scopeCssToComposition(
root.walkRules((rule) => {
if (isInsideGlobalAtRule(rule)) return;
if (isNestedInsideAnotherRule(rule)) return;
rule.selectors = rule.selectors.map((selector) =>
scopeSelector(
selector,