diff --git a/src/css/css_parser.rs b/src/css/css_parser.rs index 9aec1fb989a9..499abb953f8c 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,8 @@ 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` is an `@scope`: its style rules are scoped rules. + pub(crate) is_in_scope_rule: bool, pub(crate) allow_declarations: bool, pub(crate) composes_state: ComposesState, @@ -1400,6 +1403,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 +1429,11 @@ 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), 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 +1487,22 @@ 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: scoped style rules, and declarations for 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 { @@ -1489,10 +1511,8 @@ mod rule_parsers { column: srcloc.column, }; - // 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)?; + // Declarations directly in a nested @media, @supports or @scope block act as a `& { ... }` rule. + let (declarations, mut rules) = self.parse_nested(input, false, is_scope_rule)?; if declarations.len() > 0 { rules.v.insert( @@ -1605,13 +1625,22 @@ mod rule_parsers { is_nesting_allowed: true, options: this.options, }; + // nests like a style rule selector: https://drafts.csswg.org/css-cascade-6/#scope-nesting + let scope_start_nesting = if this.is_in_scope_rule { + selector_parser::NestingRequirement::Scoped + } else if this.is_in_style_rule { + selector_parser::NestingRequirement::Implicit + } else { + selector_parser::NestingRequirement::None + }; + // Both selector lists are unforgiving (w3c/csswg-drafts#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 +1652,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 +1769,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 +1887,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 +1980,15 @@ 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 . + 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 +2071,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..d30e4021f184 100644 --- a/src/css/printer.rs +++ b/src/css/printer.rs @@ -147,6 +147,8 @@ pub struct Printer<'a> { /// `serialize::serialize_nesting` so deeply nested rules with multiple /// `&` references per level cannot expand exponentially. pub(crate) nesting_expansions: u32, + /// Inside an `@scope` rule, where a context-less `&` is the scoping root and compiles to `:where(:scope)`. + 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 +312,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..a5598dfe5e51 100644 --- a/src/css/rules/scope.rs +++ b/src/css/rules/scope.rs @@ -40,29 +40,31 @@ impl ScopeRule { dest.write_char(b')')?; dest.whitespace()?; } - if let Some(scope_end) = &self.scope_end { - if dest.minify { - dest.write_char(b' ')?; + // In and in the nested rules, `&` is the scoping root. + 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 (")?; + 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_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. - // 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(()) + dest.write_char(b'{')?; + dest.indent(); + dest.newline()?; + 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..70e5d2701bf7 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,8 @@ pub enum NestingRequirement { Prefixed, Contained, Implicit, + /// A scoped style rule (nested in `@scope`): a leading combinator is anchored to `&`, nothing else is added. + 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..28653422490d 100644 --- a/src/css/selectors/selector.rs +++ b/src/css/selectors/selector.rs @@ -1326,12 +1326,13 @@ pub(crate) mod serialize { dest.write_char(b')')?; } } else { - // If there is no context, we are at the root if nesting is supported. This is equivalent to :scope. - // Otherwise, if nesting is supported, serialize the nesting selector directly. - if dest.targets.should_compile_same(Feature::Nesting) { - dest.write_str(b":scope")?; - } else { + // With no context, `&` is `:scope` at the root and `:where(:scope)` in `@scope` (css-cascade-6). + 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..57695dbaa24b 100644 --- a/test/js/bun/css/css.test.ts +++ b/test/js/bun/css/css.test.ts @@ -7710,82 +7710,88 @@ 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}}}", + ], + // A relative scope-start selector in another `@scope` has the same anchor. + [ + "@scope (.p) { @scope (> .a) { .c { color: red } } }", + "@scope(.p){@scope(&>.a){.c{color:red}}}", + "@scope(.p){@scope(:where(:scope)>.a){.c{color:red}}}", ], // No scope-start selector. The scoping root is the parent of the `