From fd45ac41d2bbc063f1d7d367a89c723a4da5ede1 Mon Sep 17 00:00:00 2001 From: robobun <117481402+robobun@users.noreply.github.com> Date: Thu, 17 Sep 2026 20:34:26 +0000 Subject: [PATCH 1/3] css: fix @scope nesting, relative selectors, and declarations in @scope An @scope nested in a style rule now parses its scope-start selector relative to the parent rule, so the compiled prelude keeps the parent selector. Rules nested in @scope are scoped rules: they take a relative selector list and get no implicit & prefix. In the scope-end selector and in the block, & is the scoping root and compiles to :where(:scope) instead of :scope. Declarations directly in @scope apply to the scoping root. The scope-start and scope-end selector lists are unforgiving, so an invalid selector rejects the rule. --- src/css/css_parser.rs | 69 ++++++++-- src/css/printer.rs | 6 + src/css/rules/scope.rs | 52 ++++---- src/css/selectors/parser.rs | 10 +- src/css/selectors/selector.rs | 10 +- test/js/bun/css/css.test.ts | 119 +++++++++++++++--- .../nested-selector-list-expansion.test.ts | 7 +- 7 files changed, 215 insertions(+), 58 deletions(-) diff --git a/src/css/css_parser.rs b/src/css/css_parser.rs index 9aec1fb989a9..9778291f032e 100644 --- a/src/css/css_parser.rs +++ b/src/css/css_parser.rs @@ -974,6 +974,7 @@ impl<'a, AtRuleParserT: CustomAtRuleParser> TopLevelRuleParser<'a, AtRuleParserT important_declarations: DeclarationList::new_in(bump), rules: &mut *self.rules, is_in_style_rule: false, + is_in_scope_rule: false, allow_declarations: false, composes_state: ComposesState::DisallowEntirely, composes: &mut *self.composes, @@ -1023,6 +1024,10 @@ pub struct NestedRuleParser<'a, T: CustomAtRuleParser> { // todo_stuff.think_mem_mgmt pub(crate) rules: &'a mut CssRuleList, pub(crate) is_in_style_rule: bool, + /// The nearest enclosing style rule or `@scope` rule is an `@scope`. Its + /// style rules are scoped rules: they take relative selectors and get no + /// implicit `&` prefix, because `&` inside `@scope` is the scoping root. + pub(crate) is_in_scope_rule: bool, pub(crate) allow_declarations: bool, pub(crate) composes_state: ComposesState, @@ -1400,6 +1405,7 @@ mod rule_parsers { &mut self, input: &mut Parser, is_style_rule: bool, + is_scope_rule: bool, ) -> CssResult<(DeclarationBlock<'static>, CssRuleList)> { // TODO: think about memory management in error cases let mut rules = CssRuleList::::default(); @@ -1425,9 +1431,13 @@ mod rule_parsers { important_declarations: DeclarationList::new_in(bump), rules: &mut rules, is_in_style_rule: self.is_in_style_rule || is_style_rule, + is_in_scope_rule: is_scope_rule || (self.is_in_scope_rule && !is_style_rule), + // Declarations directly in `@scope` apply to the scoping root. + // https://github.com/w3c/csswg-drafts/issues/10389 allow_declarations: self.allow_declarations || self.is_in_style_rule - || is_style_rule, + || is_style_rule + || is_scope_rule, composes_state, composes: &mut *self.composes, composes_refs: &mut *self.composes_refs, @@ -1481,6 +1491,23 @@ mod rule_parsers { pub(crate) fn parse_style_block( &mut self, input: &mut Parser, + ) -> CssResult> { + self.parse_style_block_impl(input, false) + } + + /// Parses the block of an `@scope` rule. Its style rules are scoped + /// rules, and its declarations apply to the scoping root. + pub(crate) fn parse_scope_block( + &mut self, + input: &mut Parser, + ) -> CssResult> { + self.parse_style_block_impl(input, true) + } + + fn parse_style_block_impl( + &mut self, + input: &mut Parser, + is_scope_rule: bool, ) -> CssResult> { let srcloc = input.current_source_location(); let loc = Location { @@ -1490,9 +1517,9 @@ mod rule_parsers { }; // Declarations can be immediately within @media and @supports blocks - // that are nested within a parent style rule. These act the same way - // as if they were nested within a `& { ... }` block. - let (declarations, mut rules) = self.parse_nested(input, false)?; + // that are nested within a parent style rule, and within @scope. + // These act the same way as if they were nested within a `& { ... }` block. + let (declarations, mut rules) = self.parse_nested(input, false, is_scope_rule)?; if declarations.len() > 0 { rules.v.insert( @@ -1605,13 +1632,24 @@ mod rule_parsers { is_nesting_allowed: true, options: this.options, }; + // Inside a style rule, is relative to the + // parent rule, like a nested style rule's selector. + // https://drafts.csswg.org/css-cascade-6/#scope-nesting + let scope_start_nesting = if this.is_in_style_rule && !this.is_in_scope_rule { + selector_parser::NestingRequirement::Implicit + } else { + selector_parser::NestingRequirement::None + }; + // Both selector lists are unforgiving: one invalid + // selector invalidates the whole rule. + // https://github.com/w3c/csswg-drafts/issues/10042 let scope_start = if input.try_parse(|p| p.expect_parenthesis_block()).is_ok() { Some(input.parse_nested_block(|input2| { SelectorList::parse_relative( &mut selector_parser, input2, - selector_parser::ParseErrorRecovery::IgnoreInvalidSelector, - selector_parser::NestingRequirement::None, + selector_parser::ParseErrorRecovery::DiscardList, + scope_start_nesting, ) })?) } else { @@ -1623,7 +1661,7 @@ mod rule_parsers { SelectorList::parse_relative( &mut selector_parser, input2, - selector_parser::ParseErrorRecovery::IgnoreInvalidSelector, + selector_parser::ParseErrorRecovery::DiscardList, selector_parser::NestingRequirement::None, ) })?) @@ -1740,7 +1778,7 @@ mod rule_parsers { scope_start, scope_end, } => { - let rules = this.parse_style_block(input)?; + let rules = this.parse_scope_block(input)?; this.rules .v .push(CssRule::Scope(css_rules::scope::ScopeRule { @@ -1858,7 +1896,7 @@ mod rule_parsers { Ok(()) } AtRulePrelude::Nest(selectors) => { - let (declarations, rules) = this.parse_nested(input, true)?; + let (declarations, rules) = this.parse_nested(input, true, false)?; this.rules .v .push(CssRule::Nesting(css_rules::nesting::NestingRule { @@ -1951,7 +1989,16 @@ mod rule_parsers { is_nesting_allowed: true, options: this.options, }; - if this.is_in_style_rule { + if this.is_in_scope_rule { + // Scoped style rules take a . + // https://drafts.csswg.org/css-cascade-6/#scoped-rules + SelectorList::parse_relative( + &mut selector_parser, + input, + selector_parser::ParseErrorRecovery::DiscardList, + selector_parser::NestingRequirement::Scoped, + ) + } else if this.is_in_style_rule { SelectorList::parse_relative( &mut selector_parser, input, @@ -2034,7 +2081,7 @@ mod rule_parsers { } } let location = input.position(); - let (declarations, rules) = this.parse_nested(input, true)?; + let (declarations, rules) = this.parse_nested(input, true, false)?; // We parsed a style rule with the `composes` property. Track which // properties it used so we can validate it later. diff --git a/src/css/printer.rs b/src/css/printer.rs index 43ba326b3326..7a78cc56f234 100644 --- a/src/css/printer.rs +++ b/src/css/printer.rs @@ -147,6 +147,11 @@ pub struct Printer<'a> { /// `serialize::serialize_nesting` so deeply nested rules with multiple /// `&` references per level cannot expand exponentially. pub(crate) nesting_expansions: u32, + /// True while the rules nested in an `@scope` block are serialized with no + /// `ctx`. There `&` means the scoping root, which compiles to + /// `:where(:scope)`, and not `:scope` as at the top level of a stylesheet. + /// https://drafts.csswg.org/css-cascade-6/#scoped-rules + pub(crate) in_scope_rule: bool, /// Running total of bytes emitted by duplicate vendor-prefix passes. A rule /// whose selector list carries more than one vendor prefix (e.g. a list /// mixing `:-webkit-autofill` with an unprefixed pseudo-class, or a single @@ -310,6 +315,7 @@ impl<'a> Printer<'a> { css_module: None, ctx: None, nesting_expansions: 0, + in_scope_rule: false, prefix_expansion_bytes: 0, error_kind: None, } diff --git a/src/css/rules/scope.rs b/src/css/rules/scope.rs index 4bd1259dd624..359470b4a44c 100644 --- a/src/css/rules/scope.rs +++ b/src/css/rules/scope.rs @@ -40,29 +40,37 @@ impl ScopeRule { dest.write_char(b')')?; dest.whitespace()?; } - if let Some(scope_end) = &self.scope_end { - if dest.minify { - dest.write_char(b' ')?; - } - dest.write_str("to (")?; - // `&` in is the scoping root (css-cascade-6), not . - dest.with_cleared_context(scope_end, |scope_end, d: &mut Printer| { - serialize_selector_list(scope_end.v.slice(), d, None, false) - })?; - dest.write_char(b')')?; - dest.whitespace()?; - } - dest.write_char(b'{')?; - dest.indent(); - dest.newline()?; - // Nested style rules within @scope are implicitly relative to the - // so clear our style context while printing them to avoid replacing & ourselves. + // From here on `&` is the scoping root: in and in the + // nested rules. With no nesting in the targets it prints as + // `:where(:scope)` (see `serialize::serialize_nesting`). // https://drafts.csswg.org/css-cascade-6/#scoped-rules - dest.with_cleared_context(&self.rules, |rules, d: &mut Printer| rules.to_css(d))?; - dest.dedent(); - dest.newline()?; - dest.write_char(b'}')?; - Ok(()) + let was_in_scope_rule = dest.in_scope_rule; + dest.in_scope_rule = true; + let result = (|dest: &mut Printer| -> Result<(), PrintErr> { + if let Some(scope_end) = &self.scope_end { + if dest.minify { + dest.write_char(b' ')?; + } + dest.write_str("to (")?; + // `&` in is the scoping root (css-cascade-6), not . + dest.with_cleared_context(scope_end, |scope_end, d: &mut Printer| { + serialize_selector_list(scope_end.v.slice(), d, None, false) + })?; + dest.write_char(b')')?; + dest.whitespace()?; + } + dest.write_char(b'{')?; + dest.indent(); + dest.newline()?; + // Nested style rules within @scope are implicitly relative to the + // so clear our style context while printing them to avoid replacing & ourselves. + dest.with_cleared_context(&self.rules, |rules, d: &mut Printer| rules.to_css(d))?; + dest.dedent(); + dest.newline()?; + dest.write_char(b'}') + })(dest); + dest.in_scope_rule = was_in_scope_rule; + result } } diff --git a/src/css/selectors/parser.rs b/src/css/selectors/parser.rs index 40b55bcfbbfb..d8dc1c490a00 100644 --- a/src/css/selectors/parser.rs +++ b/src/css/selectors/parser.rs @@ -835,7 +835,10 @@ fn parse_relative_selector( None }; - let scope: GenericComponent = if nesting_requirement == NestingRequirement::Implicit { + let scope: GenericComponent = if matches!( + nesting_requirement, + NestingRequirement::Implicit | NestingRequirement::Scoped + ) { GenericComponent::Nesting } else { GenericComponent::Scope @@ -2766,6 +2769,11 @@ pub enum NestingRequirement { Prefixed, Contained, Implicit, + /// A scoped style rule (a rule nested in `@scope`). A leading combinator is + /// anchored to `&` (the scoping root), but a selector without one gets no + /// implicit prefix: scoped rules already match within the scope. + /// https://drafts.csswg.org/css-cascade-6/#scoped-rules + Scoped, } #[derive(Clone, Copy, PartialEq, Eq, Hash, strum::IntoStaticStr, CssHash)] diff --git a/src/css/selectors/selector.rs b/src/css/selectors/selector.rs index 310f2e62a883..e8a180eb60fc 100644 --- a/src/css/selectors/selector.rs +++ b/src/css/selectors/selector.rs @@ -1327,11 +1327,15 @@ pub(crate) mod serialize { } } else { // If there is no context, we are at the root if nesting is supported. This is equivalent to :scope. + // Inside @scope, `&` is the scoping root with no specificity: :where(:scope). + // https://drafts.csswg.org/css-cascade-6/#scoped-rules // Otherwise, if nesting is supported, serialize the nesting selector directly. - if dest.targets.should_compile_same(Feature::Nesting) { - dest.write_str(b":scope")?; - } else { + if !dest.targets.should_compile_same(Feature::Nesting) { dest.write_char(b'&')?; + } else if dest.in_scope_rule { + dest.write_str(b":where(:scope)")?; + } else { + dest.write_str(b":scope")?; } } Ok(()) diff --git a/test/js/bun/css/css.test.ts b/test/js/bun/css/css.test.ts index 813450480329..254ce12c7f39 100644 --- a/test/js/bun/css/css.test.ts +++ b/test/js/bun/css/css.test.ts @@ -7710,82 +7710,82 @@ describe("css tests", () => { [ "@scope (.a) to (& > .b) { .c { color: red } }", "@scope(.a) to (&>.b){.c{color:red}}", - "@scope(.a) to (:scope>.b){.c{color:red}}", + "@scope(.a) to (:where(:scope)>.b){.c{color:red}}", ], [ "@scope (.a) to (& .b) { .c { color: red } }", "@scope(.a) to (& .b){.c{color:red}}", - "@scope(.a) to (:scope .b){.c{color:red}}", + "@scope(.a) to (:where(:scope) .b){.c{color:red}}", ], [ "@scope (.a) to (&) { .c { color: red } }", "@scope(.a) to (&){.c{color:red}}", - "@scope(.a) to (:scope){.c{color:red}}", + "@scope(.a) to (:where(:scope)){.c{color:red}}", ], [ "@scope (.a) to (.b&) { .c { color: red } }", "@scope(.a) to (.b&){.c{color:red}}", - "@scope(.a) to (.b:scope){.c{color:red}}", + "@scope(.a) to (.b:where(:scope)){.c{color:red}}", ], [ "@scope (.a) to (.p & > .b) { .c { color: red } }", "@scope(.a) to (.p &>.b){.c{color:red}}", - "@scope(.a) to (.p :scope>.b){.c{color:red}}", + "@scope(.a) to (.p :where(:scope)>.b){.c{color:red}}", ], [ "@scope (.a) to (:not(&) > .b) { .c { color: red } }", "@scope(.a) to (:not(&)>.b){.c{color:red}}", - "@scope(.a) to (:not(:scope)>.b){.c{color:red}}", + "@scope(.a) to (:not(:where(:scope))>.b){.c{color:red}}", ], [ "@scope (.a) to (& > .b, & > .c) { .d { color: red } }", "@scope(.a) to (&>.b,&>.c){.d{color:red}}", - "@scope(.a) to (:scope>.b,:scope>.c){.d{color:red}}", + "@scope(.a) to (:where(:scope)>.b,:where(:scope)>.c){.d{color:red}}", ], // A scope-start selector list, or one with a combinator. These printed as `:is(...)` in the scope-end selector. [ "@scope (.a, .b) to (& > .x) { .c { color: red } }", "@scope(.a,.b) to (&>.x){.c{color:red}}", - "@scope(.a,.b) to (:scope>.x){.c{color:red}}", + "@scope(.a,.b) to (:where(:scope)>.x){.c{color:red}}", ], [ "@scope (.p > .a) to (& > .b) { .c { color: red } }", "@scope(.p>.a) to (&>.b){.c{color:red}}", - "@scope(.p>.a) to (:scope>.b){.c{color:red}}", + "@scope(.p>.a) to (:where(:scope)>.b){.c{color:red}}", ], // A type selector with `&`. With nesting kept, `to (&div)` printed `to (.adiv)`, the class `adiv`. [ "@scope (.a) to (div&) { .c { color: red } }", "@scope(.a) to (div&){.c{color:red}}", - "@scope(.a) to (div:scope){.c{color:red}}", + "@scope(.a) to (div:where(:scope)){.c{color:red}}", ], // `&div` is not valid CSS any more (w3c/csswg-drafts#8662), but the parser takes it. The kept output is // provisional: it is the source as written, as for `&div` in a style rule, and browsers drop it. [ "@scope (.a) to (&div) { .c { color: red } }", "@scope(.a) to (&div){.c{color:red}}", - "@scope(.a) to (div:scope){.c{color:red}}", + "@scope(.a) to (div:where(:scope)){.c{color:red}}", ], // In a style rule, `&` in the scope-start selector is the parent rule. `&` in the scope-end selector is not. [ ".p { @scope (& > .a) to (& > .b) { .c { color: red } } }", - ".p{@scope(&>.a) to (&>.b){& .c{color:red}}}", - "@scope(.p>.a) to (:scope>.b){:scope .c{color:red}}", + ".p{@scope(&>.a) to (&>.b){.c{color:red}}}", + "@scope(.p>.a) to (:where(:scope)>.b){.c{color:red}}", ], // `:host()` reads the context of the printer, not the one that the selector list gets. [ ".p { @scope (.a) to (:host(&)) { .c { color: red } } }", - ".p{@scope(.a) to (:host(&)){& .c{color:red}}}", - "@scope(.a) to (:host(:scope)){:scope .c{color:red}}", + ".p{@scope(& .a) to (:host(&)){.c{color:red}}}", + "@scope(.p .a) to (:host(:where(:scope))){.c{color:red}}", ], // The same in another `@scope`: `&` in the scope-start selector is the outer scoping root. [ "@scope (.p) { @scope (& > .a) to (& > .b) { .c { color: red } } }", "@scope(.p){@scope(&>.a) to (&>.b){.c{color:red}}}", - "@scope(.p){@scope(:scope>.a) to (:scope>.b){.c{color:red}}}", + "@scope(.p){@scope(:where(:scope)>.a) to (:where(:scope)>.b){.c{color:red}}}", ], // No scope-start selector. The scoping root is the parent of the `