From 1e2c7d673fc0fe8da9a822bb7a8744f84f44d9d4 Mon Sep 17 00:00:00 2001 From: James Russo Date: Wed, 22 Jul 2026 00:24:59 -0400 Subject: [PATCH] fix(core): preserve nested-rule selectors in composition CSS scoping (#2721) (#2733) MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit 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 ' .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 --- .../src/compiler/compositionScoping.test.ts | 45 +++++++++++++++++++ .../core/src/compiler/compositionScoping.ts | 19 ++++++++ 2 files changed, 64 insertions(+) diff --git a/packages/core/src/compiler/compositionScoping.test.ts b/packages/core/src/compiler/compositionScoping.test.ts index 55302aeb8..7985c9f80 100644 --- a/packages/core/src/compiler/compositionScoping.test.ts +++ b/packages/core/src/compiler/compositionScoping.test.ts @@ -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 ` .title` via the implicit `&` prefix. + // The scoper must NOT re-apply the composition scope to the nested selector — + // that produces ` .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(`
diff --git a/packages/core/src/compiler/compositionScoping.ts b/packages/core/src/compiler/compositionScoping.ts index 6224270a5..cbe75b0f8 100644 --- a/packages/core/src/compiler/compositionScoping.ts +++ b/packages/core/src/compiler/compositionScoping.ts @@ -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 + * ` .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,