From 79d4097af49e4b74a8b075810e93f44c89a97bbf Mon Sep 17 00:00:00 2001 From: robobun <117481402+robobun@users.noreply.github.com> Date: Wed, 28 Oct 2026 23:01:24 +0000 Subject: [PATCH 1/2] css: count `&` per selector, so `&` inside `:global()` / `:local()` is the rule's `&` A nested rule gets an implied `&` in front only when its selector holds no `&`. The selector parser kept that fact in `SelectorParsingState::AFTER_NESTING`, and each nested selector parse copied the bit back to its holder by hand. `GenericSelector::parse`, which parses the argument of `:local()`, `:global()`, `::cue()` and `::cue-region()`, did not. So `.button { :global(&.primary) {} }` in a CSS module printed `.button_ .button_.primary`. `ParserInput` now counts each `&` that the selector parser reads, and `parse_selector` adds the implied `&` only when the count did not move while it ran. `AFTER_NESTING`, its five copies and the fold in `parse_list_with_state` are gone. The nested selector parsers take the state by value, because nothing flows back through it. In a plain stylesheet, `video { ::cue(&) {} }` now prints `::cue(video)`. A `&` that the parser reads in a selector that `:is()` or `:where()` then drops counts too, also when it is inside a nested argument: `.a { :is(:not(& $), .b) {} }` prints `.b`. An `@nest` selector whose only `&` is in one of these places was an error and now builds. --- src/css/css_parser.rs | 14 +++ src/css/selectors/parser.rs | 108 ++++++++--------------- test/bundler/css/css-modules.test.ts | 124 ++++++++++++++++++++++++--- test/js/bun/css/css.test.ts | 19 ++++ 4 files changed, 183 insertions(+), 82 deletions(-) diff --git a/src/css/css_parser.rs b/src/css/css_parser.rs index 7401dd18f859..d2fe7beca511 100644 --- a/src/css/css_parser.rs +++ b/src/css/css_parser.rs @@ -3408,6 +3408,17 @@ impl<'a> Parser<'a> { self.input.math_fn_parse_failures += 1; } + /// See `ParserInput::nesting_selectors`. + #[inline] + pub(crate) fn nesting_selectors(&self) -> u32 { + self.input.nesting_selectors + } + + #[inline] + pub(crate) fn note_nesting_selector(&mut self) { + self.input.nesting_selectors = self.input.nesting_selectors.wrapping_add(1); + } + /// See `ParserInput::token_list_parse_failures`. #[inline] pub(crate) fn token_list_parse_failures(&self) -> u64 { @@ -3982,6 +3993,8 @@ pub struct ParserInput<'a> { /// suffix once per backtracking alternative per nesting level. unclosed_block_at_eof: Option, math_fn_parse_failures: u64, + /// How many `&` the selector parser has read, at any depth. A selector holds a `&` if this moved while it was parsed. + nesting_selectors: u32, /// Monotonic count of raw token-list parse failures /// (`TokenList::parse_into`). A token-list parse is context-free: it /// fails or succeeds the same way every time it runs over the same @@ -4020,6 +4033,7 @@ impl<'a> ParserInput<'a> { nesting_depth: 0, unclosed_block_at_eof: None, math_fn_parse_failures: 0, + nesting_selectors: 0, token_list_parse_failures: 0, may_revert_rule: false, } diff --git a/src/css/selectors/parser.rs b/src/css/selectors/parser.rs index ae57b512e5d5..672287eee01b 100644 --- a/src/css/selectors/parser.rs +++ b/src/css/selectors/parser.rs @@ -540,7 +540,7 @@ fn compute_simple_selector_specificity( fn parse_selector( parser: &mut SelectorParser, input: &mut CssParser, - state: &mut SelectorParsingState, + mut state: SelectorParsingState, nesting_requirement: NestingRequirement, ) -> CResult> { if nesting_requirement == NestingRequirement::Prefixed { @@ -554,10 +554,11 @@ fn parse_selector( } let mut builder = SelectorBuilder::::init_in(ArenaPtr::new(input.arena())); + let nesting_selectors = input.nesting_selectors(); 'outer_loop: loop { // Parse a sequence of simple selectors. - let empty = parse_compound_selector::(parser, state, input, &mut builder)?; + let empty = parse_compound_selector::(parser, &mut state, input, &mut builder)?; if empty { let kind: SelectorParseErrorKind = if builder.has_combinators() { SelectorParseErrorKind::DanglingCombinator @@ -658,7 +659,7 @@ fn parse_selector( builder.push_combinator(combinator); } - if !state.contains(SelectorParsingState::AFTER_NESTING) { + if input.nesting_selectors() == nesting_selectors { match nesting_requirement { NestingRequirement::Implicit => { builder.add_nesting_prefix(); @@ -715,7 +716,7 @@ fn parse_compound_selector( let mut empty: bool = true; if parser.is_nesting_allowed() && input.try_parse(|i| i.expect_delim(b'&')).is_ok() { - state.insert(SelectorParsingState::AFTER_NESTING); + input.note_nesting_selector(); builder.push_simple_selector(GenericComponent::Nesting); empty = false; } @@ -849,7 +850,7 @@ fn parse_scope_switch(input: &mut CssParser) -> CResult { fn parse_relative_selector( parser: &mut SelectorParser, input: &mut CssParser, - state: &mut SelectorParsingState, + state: SelectorParsingState, nesting_requirement_: NestingRequirement, ) -> CResult> { // https://www.w3.org/TR/selectors-4/#parse-relative-selector @@ -1640,11 +1641,10 @@ impl GenericSelectorList { error_recovery: ParseErrorRecovery, nesting_requirement: NestingRequirement, ) -> CResult { - let mut state = SelectorParsingState::empty(); Self::parse_with_state( parser, input, - &mut state, + SelectorParsingState::empty(), error_recovery, nesting_requirement, ) @@ -1656,11 +1656,10 @@ impl GenericSelectorList { error_recovery: ParseErrorRecovery, nesting_requirement: NestingRequirement, ) -> CResult { - let mut state = SelectorParsingState::empty(); Self::parse_relative_with_state( parser, input, - &mut state, + SelectorParsingState::empty(), error_recovery, nesting_requirement, ) @@ -1669,7 +1668,7 @@ impl GenericSelectorList { pub(crate) fn parse_with_state( parser: &mut SelectorParser, input: &mut CssParser, - state: &mut SelectorParsingState, + state: SelectorParsingState, recovery: ParseErrorRecovery, nesting_requirement: NestingRequirement, ) -> CResult { @@ -1686,7 +1685,7 @@ impl GenericSelectorList { pub(crate) fn parse_relative_with_state( parser: &mut SelectorParser, input: &mut CssParser, - state: &mut SelectorParsingState, + state: SelectorParsingState, recovery: ParseErrorRecovery, nesting_requirement: NestingRequirement, ) -> CResult { @@ -1703,39 +1702,27 @@ impl GenericSelectorList { fn parse_list_with_state( parser: &mut SelectorParser, input: &mut CssParser, - state: &mut SelectorParsingState, + state: SelectorParsingState, recovery: ParseErrorRecovery, nesting_requirement: NestingRequirement, parse_one: fn( &mut SelectorParser, &mut CssParser, - &mut SelectorParsingState, + SelectorParsingState, NestingRequirement, ) -> CResult>, ) -> CResult { - let original_state = *state; let mut values: SmallList, 1> = SmallList::default(); loop { - // Reshaped for borrowck — capture a local flag instead of a - // raw `*mut SelectorParsingState`, then fold into `state` after return. - let mut saw_nesting = false; let selector = input.parse_until_before(css::Delimiters::COMMA, |input2: &mut CssParser| { - let mut selector_state = original_state; // A bare `:global` ends with its selector. let flags = input2.flags; - let result = - parse_one(parser, input2, &mut selector_state, nesting_requirement); + let result = parse_one(parser, input2, state, nesting_requirement); input2.flags = flags; - if selector_state.contains(SelectorParsingState::AFTER_NESTING) { - saw_nesting = true; - } result }); - if saw_nesting { - state.insert(SelectorParsingState::AFTER_NESTING); - } let was_ok = selector.is_ok(); match selector { @@ -1810,8 +1797,8 @@ pub struct GenericSelector { impl GenericSelector { /// Parse a selector, without any pseudo-element. pub fn parse(parser: &mut SelectorParser, input: &mut CssParser) -> CResult { - let mut state = SelectorParsingState::empty(); - parse_selector::(parser, input, &mut state, NestingRequirement::None) + let state = SelectorParsingState::empty(); + parse_selector::(parser, input, state, NestingRequirement::None) } pub(crate) fn append(&mut self, component: GenericComponent) { @@ -2648,9 +2635,6 @@ bitflags::bitflags! { /// Whether we explicitly disallow pseudo-element-like things. const DISALLOW_PSEUDOS = 1 << 6; - /// Whether we have seen a nesting selector. - const AFTER_NESTING = 1 << 7; - const AFTER_WEBKIT_SCROLLBAR = 1 << 8; const AFTER_VIEW_TRANSITION = 1 << 9; const AFTER_UNKNOWN_PSEUDO_ELEMENT = 1 << 10; @@ -3344,7 +3328,7 @@ pub(crate) fn parse_one_simple_selector( )); } let selector = input.parse_nested_block(|input2: &mut CssParser| { - parse_inner_compound_selector::(parser, input2, state) + parse_inner_compound_selector::(parser, input2, *state) })?; return Ok(Some(S::SlottedPseudo(selector))); } @@ -3375,7 +3359,7 @@ pub(crate) fn parse_one_simple_selector( } else { let pseudo_class: GenericComponent = if is_functional { input.parse_nested_block(|input2: &mut CssParser| { - parse_functional_pseudo_class::(parser, input2, name, state) + parse_functional_pseudo_class::(parser, input2, name, *state) })? } else { parse_simple_pseudo_class::(parser, location, name, *state)? @@ -3407,7 +3391,7 @@ pub(crate) fn parse_one_simple_selector( } Some(b'&') => { if parser.is_nesting_allowed() { - state.insert(SelectorParsingState::AFTER_NESTING); + input.note_nesting_selector(); return Ok(Some(S::SimpleSelector(GenericComponent::Nesting))); } } @@ -3603,30 +3587,26 @@ pub(crate) fn is_css2_pseudo_element(name: &[u8]) -> bool { pub(crate) fn parse_inner_compound_selector( parser: &mut SelectorParser, input: &mut CssParser, - state: &mut SelectorParsingState, + state: SelectorParsingState, ) -> CResult> { - let mut child_state = { - let mut child_state = *state; + let child_state = { + let mut child_state = state; child_state.insert(SelectorParsingState::DISALLOW_PSEUDOS); child_state.insert(SelectorParsingState::DISALLOW_COMBINATORS); child_state }; // A bare `:global` ends with its selector. let flags = input.flags; - let result = parse_selector::(parser, input, &mut child_state, NestingRequirement::None); + let result = parse_selector::(parser, input, child_state, NestingRequirement::None); input.flags = flags; - let result = result?; - if child_state.contains(SelectorParsingState::AFTER_NESTING) { - state.insert(SelectorParsingState::AFTER_NESTING); - } - Ok(result) + result } pub(crate) fn parse_functional_pseudo_class( parser: &mut SelectorParser, input: &mut CssParser, name: Str, - state: &mut SelectorParsingState, + state: SelectorParsingState, ) -> CResult> { crate::match_ignore_ascii_case! { name, { b"nth-child" => return parse_nth_pseudo_class::(parser, input, state, NthType::Child), @@ -3754,7 +3734,7 @@ pub(crate) fn parse_simple_pseudo_class( pub(crate) fn parse_nth_pseudo_class( parser: &mut SelectorParser, input: &mut CssParser, - state: &mut SelectorParsingState, + state: SelectorParsingState, ty: NthType, ) -> CResult> { if !state.allows_tree_structural_pseudo_classes() { @@ -3781,8 +3761,8 @@ pub(crate) fn parse_nth_pseudo_class( // Whitespace between "of" and the selector list is optional // https://github.com/w3c/csswg-drafts/issues/8285 - let mut child_state = { - let mut s = *state; + let child_state = { + let mut s = state; s.insert(SelectorParsingState::SKIP_DEFAULT_NAMESPACE); s.insert(SelectorParsingState::DISALLOW_PSEUDOS); s @@ -3794,13 +3774,10 @@ pub(crate) fn parse_nth_pseudo_class( let selectors = GenericSelectorList::::parse_with_state( parser, input, - &mut child_state, + child_state, ParseErrorRecovery::DiscardList, NestingRequirement::None, )?; - if child_state.contains(SelectorParsingState::AFTER_NESTING) { - state.insert(SelectorParsingState::AFTER_NESTING); - } Ok(GenericComponent::NthOf(NthOfSelectorData { data: nth_data, @@ -3813,7 +3790,7 @@ pub(crate) fn parse_nth_pseudo_class( pub(crate) fn parse_is_or_where( parser: &mut SelectorParser, input: &mut CssParser, - state: &mut SelectorParsingState, + state: SelectorParsingState, func: F, ) -> CResult> where @@ -3825,8 +3802,8 @@ where // Pseudo-elements cannot be represented by the matches-any // pseudo-class; they are not valid within :is(). // - let mut child_state = { - let mut child_state = *state; + let child_state = { + let mut child_state = state; child_state.insert(SelectorParsingState::SKIP_DEFAULT_NAMESPACE); child_state.insert(SelectorParsingState::DISALLOW_PSEUDOS); child_state @@ -3835,13 +3812,10 @@ where let inner = GenericSelectorList::::parse_with_state( parser, input, - &mut child_state, + child_state, parser.is_and_where_error_recovery(), NestingRequirement::None, )?; - if child_state.contains(SelectorParsingState::AFTER_NESTING) { - state.insert(SelectorParsingState::AFTER_NESTING); - } let selector_slice = inner.into_boxed_selectors(); @@ -3854,23 +3828,19 @@ where pub(crate) fn parse_has( parser: &mut SelectorParser, input: &mut CssParser, - state: &mut SelectorParsingState, + state: SelectorParsingState, ) -> CResult> { - let mut child_state = *state; // Since https://github.com/w3c/csswg-drafts/issues/7676, :has() takes a // , which is not forgiving. Forgiving recovery would // leave the list empty, which has no valid serialization (`:has()`). let inner = GenericSelectorList::::parse_relative_with_state( parser, input, - &mut child_state, + state, ParseErrorRecovery::DiscardList, NestingRequirement::None, )?; - if child_state.contains(SelectorParsingState::AFTER_NESTING) { - state.insert(SelectorParsingState::AFTER_NESTING); - } Ok(GenericComponent::Has(inner.into_boxed_selectors())) } @@ -3879,24 +3849,20 @@ pub(crate) fn parse_has( pub(crate) fn parse_negation( parser: &mut SelectorParser, input: &mut CssParser, - state: &mut SelectorParsingState, + state: SelectorParsingState, ) -> CResult> { - let mut child_state = *state; + let mut child_state = state; child_state.insert(SelectorParsingState::SKIP_DEFAULT_NAMESPACE); child_state.insert(SelectorParsingState::DISALLOW_PSEUDOS); let list = GenericSelectorList::::parse_with_state( parser, input, - &mut child_state, + child_state, ParseErrorRecovery::DiscardList, NestingRequirement::None, )?; - if child_state.contains(SelectorParsingState::AFTER_NESTING) { - state.insert(SelectorParsingState::AFTER_NESTING); - } - Ok(GenericComponent::Negation(list.into_boxed_selectors())) } diff --git a/test/bundler/css/css-modules.test.ts b/test/bundler/css/css-modules.test.ts index 2d3fe81011d7..993b0a8726e7 100644 --- a/test/bundler/css/css-modules.test.ts +++ b/test/bundler/css/css-modules.test.ts @@ -719,27 +719,28 @@ describe("bare :global", () => { }); describe("css modules", () => { - /** - * Bundles the first of `files` through a JS import. `css` is the stylesheet on one line, without - * comments and with every `_` written `_H`. `styles` is the default export of the file. - */ - async function bundle(files: Record) { + const unhash = (text: string) => text.replace(/_[\w-]{6}(?![\w-])/g, "_H"); + /** The stylesheet on one line, without comments and with every `_` written `_H`. */ + const oneLine = (css: string) => + unhash(css.replace(/\/\*.*?\*\//g, "")) + .replace(/\s+/g, " ") + .trim(); + + /** Bundles the first of `files` through a JS import. `styles` is the default export of the file. */ + async function bundle(files: Record, options: Partial = {}) { using dir = tempDir("css-modules", { ...files, "entry.js": `export { default } from "./${Object.keys(files)[0]}";`, }); const out = join(String(dir), "out"); - const result = await Bun.build({ entrypoints: [join(String(dir), "entry.js")], outdir: out, throw: false }); + const entrypoints = [join(String(dir), "entry.js")]; + const result = await Bun.build({ ...options, entrypoints, outdir: out, throw: false }); const logs = result.logs.map(log => Bun.stripANSI(log.message)); if (!result.success) return { logs }; - const unhash = (text: string) => text.replace(/_[\w-]{6}(?![\w-])/g, "_H"); - const css = await Bun.file(join(out, "entry.css")).text(); const { default: styles } = await import(join(out, "entry.js")); return { logs, - css: unhash(css.replace(/\/\*.*?\*\//g, "")) - .replace(/\s+/g, " ") - .trim(), + css: oneLine(await Bun.file(join(out, "entry.css")).text()), styles: JSON.parse(unhash(JSON.stringify(styles))) as Record, }; } @@ -849,6 +850,107 @@ describe("css modules", () => { expect(await bundle({ "styles.module.css": css })).toEqual({ logs, css: out, styles }); }); + const scoped = (...names: string[]) => Object.fromEntries(names.map(name => [name, `${name}_H`])); + const printsRule = async (parent: string, nested: string, printed: string, styles = scoped(parent.slice(1))) => { + const css = `${parent} { ${nested} { color: red } }`; + expect(await bundle({ "styles.module.css": css })).toEqual({ logs: [], css: `${printed} { color: red; }`, styles }); + }; + + // The `&` in the argument of `:global()` or `:local()` is the `&` of the rule, so the rule gets no other `&`. + test.concurrent.each([ + // WordPress Playground has this rule. + [".site-notice", ":global(& .components-notice__content)", ".site-notice_H .components-notice__content"], + [".button", ":global(&.primary)", ".button_H.primary"], + [".tip", ":global(body.desktop *:not(:hover) > &)", "body.desktop :not(:hover) > .tip_H"], + [".logo", ":global :root.lightTheme :local(&)", ":root.lightTheme .logo_H"], + [".nav", ":global(.dark) :local(&)", ".dark .nav_H"], + [".title", ":global(&::after)", ".title_H:after"], + [".card", ":not(:global(&.x))", ":not(.card_H.x)"], + [".nested", ":local(&.local)", ".nested_H.local_H", scoped("nested", "local")], + [".a .b", ":global(&.x)", ".a_H .b_H.x", scoped("a", "b")], + // A `&` after a type selector. + [".p", ".q :global(div&)", ".q_H div.p_H", scoped("p", "q")], + [".a .b", ".q :global(div&)", ".q_H div:is(.a_H .b_H)", scoped("a", "b", "q")], + // No `&` in the argument, or a `&` in front of it already: as before. + [".p", ":global(.g)", ".p_H .g"], + [".p", "& :global(&.y)", ".p_H .p_H.y"], + [".p", "> :global(&.x)", ".p_H > .p_H.x"], + ])("%s { %s } prints %s", printsRule); + + test.concurrent("`&` inside :global() under @media, three rules deep, in a list, and with nesting kept", async () => { + const css = ` + .a { @media print { :global(&.x) { color: red } } } + .b { :global(&.c) { :global(&.d) { :global(&.e) { color: blue } } } } + .item { :global(&.on), .alt { color: green } }`; + expect(await bundle({ "styles.module.css": css })).toEqual({ + logs: [], + css: `@media print { .a_H.x { color: red; } } .b_H.c.d.e { color: #00f; } .item_H.on { color: green; } .item_H .alt_H { color: green; }`, + styles: scoped("a", "b", "item", "alt"), + }); + expect( + await bundle({ "styles.module.css": `.button { :global(&.primary) { color: red } }` }, { target: "bun" }), + ).toEqual({ logs: [], css: `.button_H { &.primary { color: red; } }`, styles: scoped("button") }); + }); + + test.concurrent("rules in a rule whose selector is :global() with a `&`", async () => { + const css = `.a { :global(.dark &) { color: red; &:hover { color: green } .label { color: blue } } }`; + expect(await bundle({ "styles.module.css": css })).toEqual({ + logs: [], + css: `.dark .a_H { color: red; } .dark .a_H:hover { color: green; } .dark .a_H .label_H { color: #00f; }`, + styles: scoped("a", "label"), + }); + }); + + // `is_simple` and `has_type_selector` do not look inside `:global()`, so the parent takes the place of this `&` + // without `:is()`. It prints `.y_H.dark .a_H`. + test.todo("a `&` that does not lead, in a rule whose selector is :global() with a combinator", async () => { + const css = `.a { :global(.dark &) { .y& { color: red } } }`; + expect(await bundle({ "styles.module.css": css })).toEqual({ + logs: [], + css: `.y_H:is(.dark .a_H) { color: red; }`, + styles: scoped("a", "y"), + }); + }); + + test.concurrent("`@nest` takes a `&` inside :global()", async () => { + expect(await bundle({ "styles.module.css": `.p { @nest :global(.dark &) { color: red } }` })).toEqual({ + logs: ["The @nest rule is deprecated, use standard CSS nesting instead"], + css: `.dark .p_H { color: red; }`, + styles: scoped("p"), + }); + }); + + test.concurrent("`&` inside :local() through the CLI, bundled and with --no-bundle", async () => { + using dir = tempDir("css-modules-cli", { + "styles.module.css": `.button { :local(&.on) { color: red } }`, + "entry.js": `import "./styles.module.css";`, + }); + const build = async (...args: string[]) => { + await using proc = Bun.spawn({ + cmd: [bunExe(), "build", ...args], + env: bunEnv, + cwd: String(dir), + stdout: "pipe", + stderr: "pipe", + }); + const [stdout, stderr, exitCode] = await Promise.all([proc.stdout.text(), proc.stderr.text(), proc.exited]); + return { stdout, stderr, exitCode }; + }; + + const bundled = await build("entry.js", "--outdir", "out"); + expect({ stderr: bundled.stderr, exitCode: bundled.exitCode }).toEqual({ stderr: "", exitCode: 0 }); + expect(oneLine(await Bun.file(join(String(dir), "out", "entry.css")).text())).toBe( + ".button_H.on_H { color: red; }", + ); + + const unbundled = await build("--no-bundle", "styles.module.css"); + expect({ ...unbundled, stdout: oneLine(unbundled.stdout) }).toEqual({ + stdout: ".button_H.on_H { color: red; }", + stderr: "", + exitCode: 0, + }); + }); + const nested = '"composes" is not allowed inside nested selectors'; const notSingleClass = '"composes" only works inside single class selectors'; diff --git a/test/js/bun/css/css.test.ts b/test/js/bun/css/css.test.ts index 972249a6bd14..2903acfa6f1a 100644 --- a/test/js/bun/css/css.test.ts +++ b/test/js/bun/css/css.test.ts @@ -8947,6 +8947,25 @@ describe("a `&` that does not lead its selector", () => { lowered(".a, .b { :nth-child(1 of &) { color: red } }", ":nth-child(1 of :is(.a,.b)){color:red}"); minify_test(".foo { :nth-child(1 of &) { color: red } }", ".foo{:nth-child(1 of &){color:red}}"); }); + + describe("is the `&` of its rule, so the rule gets no other", () => { + lowered(".p { :not(&) { color: red } }", ":not(.p){color:red}"); + lowered(".p { :where(&) { color: red } }", ":where(.p){color:red}"); + lowered(".p { :is(&) { color: red } }", ".p{color:red}"); + lowered(".p { :has(&) { color: red } }", ":has(.p){color:red}"); + lowered("video { ::cue(&) { color: red } }", "::cue(video){color:red}"); + lowered(".p { ::cue-region(&) { color: red } }", "::cue-region(.p){color:red}"); + lowered(".a { @nest ::cue(&) { color: red } }", "::cue(.a){color:red}"); + minify_test("video { ::cue(&) { color: red } }", "video{::cue(&){color:red}}"); + // A `&` that the parser reads before a forgiving list drops the member counts too, as in Chrome. + // One after the invalid token is not read: `:is($ &, .b)` keeps the implied `&`. + lowered(".a { :is(& $, .b) { color: red } }", ".b{color:red}"); + lowered(".a { :is(:not(& $), .b) { color: red } }", ".b{color:red}"); + lowered(".a { :where(:has(& $), .b) { color: red } }", ":where(.b){color:red}"); + // A rule with no `&` gets one. + lowered(".a { :is($, .b) { color: red } }", ".a .b{color:red}"); + lowered("video { ::cue(b) { color: red } }", "video ::cue(b){color:red}"); + }); }); describe("vendor prefixes the targets need", () => { From 60b701dc91796c4fff84297002617b16026a5473 Mon Sep 17 00:00:00 2001 From: robobun <117481402+robobun@users.noreply.github.com> Date: Wed, 28 Oct 2026 23:01:26 +0000 Subject: [PATCH 2/2] css modules: print the argument of `:local()` and `:global()` at the position of the wrapper The printer started the argument of `:local()` and `:global()` as if nothing came before it. A `&` that starts the argument then took the parent selector as written, also when the wrapper follows a combinator or other simple selectors: `.a .b { & :global(&.x) {} }` printed `.a_ .b_ .a_ .b_.x`, and `div { & .q:global(&) {} }` printed `div .q_div`. `serialize_pseudo_class` now gets the `NestingPosition` of the wrapper and passes it to the argument, as `serialize_component` does for a `:is()` that it unwraps. The two rules print `.a_ .b_ :is(.a_ .b_).x` and `div .q_:is(div)`. --- src/css/selectors/selector.rs | 10 +++++++--- test/bundler/css/css-modules.test.ts | 9 +++++++++ 2 files changed, 16 insertions(+), 3 deletions(-) diff --git a/src/css/selectors/selector.rs b/src/css/selectors/selector.rs index 82b30b02aae6..cbe99fe89056 100644 --- a/src/css/selectors/selector.rs +++ b/src/css/selectors/selector.rs @@ -1034,7 +1034,7 @@ pub(crate) mod serialize { return dest.write_str(b")"); } Component::NonTsPseudoClass(pseudo) => { - return serialize_pseudo_class(pseudo, dest, context); + return serialize_pseudo_class(pseudo, dest, context, position); } Component::PseudoElement(pseudo) => { return serialize_pseudo_element(pseudo, dest, context); @@ -1132,10 +1132,12 @@ pub(crate) mod serialize { Ok(()) } + /// `position` is where the argument of `:local()` or `:global()` lands: only that argument is printed. fn serialize_pseudo_class( pseudo_class: &parser::PseudoClass, dest: &mut Printer, context: Option<&StyleContext>, + position: NestingPosition, ) -> Result<(), PrintErr> { match pseudo_class { PseudoClass::Lang { languages } => { @@ -1268,10 +1270,12 @@ pub(crate) mod serialize { // https://html.spec.whatwg.org/multipage/semantics-other.html#selector-autofill PseudoClass::Autofill(prefix) => write_prefixed(dest, *prefix, b"autofill")?, - PseudoClass::Local { selector } => serialize_selector(selector, dest, context, false)?, + PseudoClass::Local { selector } => { + serialize_selector_impl(selector, dest, context, false, position)? + } PseudoClass::Global { selector } => { let css_module = dest.css_module.take(); - serialize_selector(selector, dest, context, false)?; + serialize_selector_impl(selector, dest, context, false, position)?; dest.css_module = css_module; } diff --git a/test/bundler/css/css-modules.test.ts b/test/bundler/css/css-modules.test.ts index 993b0a8726e7..77202c3e4ec5 100644 --- a/test/bundler/css/css-modules.test.ts +++ b/test/bundler/css/css-modules.test.ts @@ -951,6 +951,15 @@ describe("css modules", () => { }); }); + // The argument of `:global()` or `:local()` is printed where the wrapper is. In a wrapper that does not start the + // selector, a `&` that starts the argument takes a parent with a combinator or a type selector through `:is()`. + test.concurrent.each([ + [".a .b", ".q:global(&.x)", ".q_H:is(.a_H .b_H).x", scoped("a", "b", "q")], + [".a .b", ":global(.dark) :local(&)", ".dark :is(.a_H .b_H)", scoped("a", "b")], + [".a .b", "& :global(&.x)", ".a_H .b_H :is(.a_H .b_H).x", scoped("a", "b")], + ["div", "& .q:global(&)", "div .q_H:is(div)", scoped("q")], + ])("%s { %s } prints %s", printsRule); + const nested = '"composes" is not allowed inside nested selectors'; const notSingleClass = '"composes" only works inside single class selectors';