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,