Skip to content

css: count & per selector, so & inside :global() / :local() is the rule's & - #47288

Open
robobun wants to merge 2 commits into
mainfrom
robobun/0c007c62/css-nesting-selector-count
Open

robobun wants to merge 2 commits into
mainfrom
robobun/0c007c62/css-nesting-selector-count

Conversation

@robobun

@robobun robobun commented Oct 29, 2026

Copy link
Copy Markdown
Collaborator

Follow-up to #47025: the & part of #45487.

Problem

  • In a CSS module, .button { :global(&.primary) {} } builds to .button_<hash> .button_<hash>.primary, with exit 0. The & inside :global() or :local() is not seen, so the rule also gets the implied &.
  • Cause: GenericSelector::parse (src/css/selectors/parser.rs:1812) drops SelectorParsingState::AFTER_NESTING, which five other nested parsers copy back by hand. lightningcss 1.33 prints the same.

Fix

  • ParserInput counts each & the selector parser reads. parse_selector adds the implied & only if the count did not move. AFTER_NESTING and its copies are deleted.
  • Second commit (droppable): the argument prints at its wrapper's position.
  • Verified: test/bundler/css/css-modules.test.ts (19 fail on main), test/js/bun/css/css.test.ts (6), and both directories. Self-reviewed: 12 concerns raised, 11 addressed (Notes).

Background

  • .a { .b {} } means .a .b: a nested rule with no & gets an implied one. :global(x) and :local(x) print only x.
  • A fuzzer found this. 0 of 6,869 module files in 305 repositories change. Code search: 31 files in 12 projects, none built with Bun.
  • Considered copying the bit in the two wrapper arms: a sixth hand copy, and ::cue(&) stays wrong.

Downsides

  • Plain CSS changes that css: consolidate the open CSS parser fixes #47025 did not take, in shapes found in no real stylesheet: ::cue(&), a & in a dropped :is() member, @nest. If unwanted, I use the hand copy.
  • .y& under a rule :global(.dark &) prints .y_<hash>.dark .a_<hash>. Main is wrong too.
  • ParserInput: 200 to 208 bytes, one per parse.
Notes

Origin and reach. A fuzzer found the :global() bugs of #45487. #47025 landed the export half. No user reported this half. It was never right: 1.4.2 and a 1.4.3 canary print .button_<hash> .button.primary, also with exit 0. #47025 is in no release yet.

  • Every .module.css file of 305 public repositories (6,869 files), built with main and with this branch: 0 differ in CSS, JS, stderr or exit status.
  • A GitHub code search for :global(& and :local(& in *.module.css gives 43 files. Without forks and copies that is 31 distinct files in 12 projects: WordPress Playground, tapestry-project (11 files), near/bos-web-engine, oasisprotocol/rose-app, riccardoperra/better-comments-for-github and 7 small ones. 30 of the 31 build alone. On main all 30 print selectors that postcss-modules and esbuild do not print. Here 29 print the same selectors as both tools, and 1 differs only in how Bun lowers :not(a, b).
  • All 12 projects build with Vite, Astro or Rollup, where the input works. So this is for a module that moves to Bun.
  • 5 of the 30 files (skni-umcs/czujniki-frontend) write :global :root.lightTheme :local(&). They were a build error before css: consolidate the open CSS parser fixes #47025 made the bare :global build. On main they print .logo_<hash> :root.lightTheme .logo_<hash>, which can match no element.

What main prints (bun build entry.js --outdir out, default targets):

styles.module.css main lightningcss 1.33 this PR, postcss-modules 4.2, esbuild 0.28
.site-notice { :global(& .components-notice__content) {} } .site-notice_<hash> .site-notice_<hash> .components-notice__content .site-notice_<hash> .site-notice .components-notice__content .site-notice_<hash> .components-notice__content
.button { :global(&.primary) {} } .button_<hash> .button_<hash>.primary .button_<hash> .button.primary .button_<hash>.primary
.tip { :global(body.desktop *:not(:hover) > &) {} } .tip_<hash> body.desktop :not(:hover) > .tip_<hash> .tip_<hash> body.desktop :not(:hover) > .tip body.desktop :not(:hover) > .tip_<hash>
.nav { :global(.dark) :local(&) {} } .nav_<hash> .dark .nav_<hash> the same as main .dark .nav_<hash>

The first row is from WordPress Playground.

The plain-CSS changes, and the question. The count has no copy per caller, so it also covers the other callers of GenericSelector::parse and every nested argument. To keep main's output there needs more code, not less.

input main, lightningcss 1.33 this PR Chrome 153
video { ::cue(&) {} } video ::cue(video) ::cue(video) no implied &
.p { ::cue-region(&) {} } .p ::cue-region(.p) ::cue-region(.p) does not parse ::cue-region
.a { :is(:not(& $), .b) {} } .a .b .b no implied &
.a { @nest ::cue(&) {} } error: missing & ::cue(.a) has no @nest
.a { @nest :is(:not(& $), .b) {} } error: missing & .b has no @nest
module: .p { @nest :global(.dark &) {} } error: missing & .dark .p_<hash> has no @nest

Other behaviour changes in modules. 0 real modules have these shapes: none of the 30 rules nested in the 17 wrapper rules of those files has a & after a combinator or after another simple selector.

  • A rule whose selector is a wrapper with a &, used as a parent. On main the implied & gave it a combinator, so a nested & that does not lead got :is(). Now is_simple and has_type_selector see one wrapper and the parent is written as it is. One test.todo pins the right form.

    module main this PR postcss-modules, esbuild
    .a { :global(.dark &) { .y& {} } } .y_<hash>:is(.a_<hash> .dark .a_<hash>) .y_<hash>.dark .a_<hash> .dark .a_<hash>.y_<hash>
    .a { :global(span &) { .y& {} } } .y_<hash>:is(.a_<hash> span .a_<hash>) .y_<hash>span .a_<hash> span .a_<hash>.y_<hash>
    .a { :global(& .t) { .y & {} } } .y_<hash> :is(.a_<hash> .a_<hash> .t) .y_<hash> .a_<hash> .t .y_<hash> :is(.a_<hash> .t)

    The common children are right now: &:hover and .label under .a { :global(.dark &) {} } print .dark .a_<hash>:hover and .dark .a_<hash> .label_<hash>. A top-level wrapper rule has the same defect on main: :global(.a .b) { .q& {} } prints .q_<hash>.a .b.

  • bun build --no-bundle: the parent behind a & inside :global(...) prints with its original name, because the Global print arm takes dest.css_module. .button { :global(&.primary) {} } printed .button_<hash> .button.primary and prints .button.primary. :local(&.on) is right there: .button_<hash>.on_<hash>.

Measurements. Release builds (bun run build:release, same path) of main 11c3671 and of that commit with this diff. src/css did not change between 11c3671 and the base of this branch.

  • The count alone saves 4 to 35 instructions per rule on 28 inputs with selectors. With the state by value it is 8 to 68, which is 0.1% to 0.8% of the parse of those inputs. An input with no selectors: -0.06 with a spread of 0.17. A 2.9 MB stylesheet: -615,569 of 485,427,934 instructions per parse, 69% of that from the signatures. (User instructions through perf_event_open, median of 3, on a bench binary of bun_css built with the release flags. perf and valgrind are not installed here.)
  • Print: parse + minify + print gives the same deltas as parse alone, within 0.5 instructions per rule. serialize_pseudo_class has no symbol of its own in either build.
  • size_of: ParserInput 200 B to 208 B. SelectorParser 16 B, SelectorParsingState 2 B, Component 48 B, Printer 496 B unchanged.
  • Release binary text: 138,037,100 B to 138,035,564 B, -1,536 B (size). parse_selector 24,887 B to 23,952 B, serialize_component 7,641 B to 7,643 B (nm -S).
  • Output outside this fix, byte for byte through bun build, as a plain stylesheet and as a module: 2,067 of 2,067 CSS inputs of test/js/bun/css, 38 of 38 .css files tracked in the repo and 45 of 45 css-loader test cases are identical.
  • 2,496 generated nested rules (4 parents, 13 positions, 16 arguments, with :global(), :local() and no wrapper), each with color: red. Chrome 153 checks on 399 elements whether the printed sheet styles what the nested postcss-modules output styles. Wrapper with a &: main 470 of 1,248, this PR 1,248 of 1,248. The 1,144 shapes with no & in a wrapper are byte-identical. 0 shapes change between build and error. 104 more shapes use &div, which Chrome rejects.
  • A rule three wrappers deep names the parent 8 times on main and once here.
  • Source: src/ +58/-74.

Deleted. SelectorParsingState::AFTER_NESTING, its five copies (parse_inner_compound_selector, parse_nth_pseudo_class, parse_is_or_where, parse_has, parse_negation) and the saw_nesting fold in parse_list_with_state. The eleven selector parsers that took the state as &mut take it by value: the copy was the only write through it.

Left out. Each of these is the same on main and here.

  • :global(> .x), a relative selector as the argument, is a build error (Empty selector is not allowed). Code search: 86 module files. So is :has(:global(> &)).
  • A :global { ... } block and a trailing .a :global { are build errors. Code search: 3,540 module files for :global {. css: consolidate the open CSS parser fixes #47025 pins the trailing form as an error.
  • A & in an argument that is kept as raw tokens is not counted: :unknown-fn(&), and :global(&.x) in a plain stylesheet.
  • The passes for browser targets do not look inside a wrapper. .title { :global(&::after) { inset-inline-start: 4px } } gets its :lang() guard after the pseudo-element. That rule matched nothing on main and the selector is still invalid.
  • :host(), ::slotted(), ::cue() and ::cue-region() print a & in their argument as the parent written out, also when the parent has a combinator. Browsers drop ::cue(.a .b).
  • &div inside a wrapper. css-nesting says to write div&, and Chrome drops &div.

Self-review. 12 concerns raised, 11 addressed. Rejected: a test that pins what --no-bundle prints for :global(&...). That output is known to be wrong, so the test uses :local(&...) there, and the :global case is described above.

Suites run on a debug build of this branch: test/bundler/css/ (237 pass, 1 todo), test/bundler/esbuild/css.test.ts (68 pass, 1 todo), test/js/bun/css/ (3397 pass), test/internal/source-lints/ (194 pass), cargo clippy -p bun_css. The first commit alone also passes its tests.

@robobun

robobun commented Oct 29, 2026 •

Copy link
Copy Markdown
Collaborator Author
Updated 2:06 AM PT - Oct 29th, 2026

❌ @robobun, your commit 60b701d has some failures in Build #134917 (All Failures)


🧪   To try this PR locally:

bunx bun-pr 47288

That installs a local version of the PR into your bun-47288 executable, so you can run:

bun-47288 --bun

@robobun

robobun commented Oct 29, 2026 •

Copy link
Copy Markdown
Collaborator Author

Status: ready for review.

Reproduced on a release build of main (11c3671) with bun build entry.js --outdir out. A module with .button { :global(&.primary) {} } prints .button_<hash> .button_<hash>.primary. postcss-modules 4.2 and esbuild 0.28 print .button_<hash>.primary. 19 tests of test/bundler/css/css-modules.test.ts and 6 rows of test/js/bun/css/css.test.ts fail on that build and pass on this branch.

CI on 60b701d: the GitHub checks pass. In Buildkite build #134917, 184 of 188 jobs passed and no test failed. The other 4 jobs are the macOS x64 test jobs (darwin any x64 - test-bun). They expired because no agent took them, so the build shows as failed. A retry of those 4 jobs is all that it needs.

@coderabbitai

coderabbitai Bot commented Oct 29, 2026 •

Copy link
Copy Markdown
Contributor

Review in Change Stack →

Important

Review skipped

Review was skipped as selected files did not have any reviewable changes.

Run configuration
Commits

Reviewing files that changed from the base of the PR and between 4db48d7 and 60b701d.

You can disable this status message by setting the reviews.review_status to false in the CodeRabbit configuration file.

Use the checkbox below for a quick retry:

  • 🔍 Trigger review

No actionable comments were generated in the recent review.

Recent review info
Run configuration
Commits

Reviewing files that changed from the base of the PR and between 37a5e3a and 4db48d7.

Files selected for processing: 5
  • src/css/css_parser.rs
  • src/css/selectors/parser.rs
  • src/css/selectors/selector.rs
  • test/bundler/css/css-modules.test.ts
  • test/js/bun/css/css.test.ts

Included review availability: This review used your included allowance. Your plan provides up to 10 included reviews per hour; 1 remain after this review.

Walkthrough

The selector parser now tracks nesting selectors in parser input and passes parsing state by value through selector and pseudo-class parsing. CSS Modules serialization carries the current nesting position into :local() and :global() arguments. Tests cover nested selectors, pseudo-classes, bundler targets, and CLI builds.

Changes

Selector nesting

Layer / File(s) Summary
Nesting selector parsing
src/css/css_parser.rs, src/css/selectors/parser.rs, test/js/bun/css/css.test.ts
Parser input tracks nesting selectors. Selector and pseudo-class parsing pass state by value, and selector-list parsing restores parser flags after each attempt. Tests cover nesting in pseudo-classes, forgiving selector lists, and @nest.
CSS Modules selector serialization
src/css/selectors/selector.rs, test/bundler/css/css-modules.test.ts
Pseudo-class serialization passes the current nesting position into :local() and :global() arguments. Tests check selector output in nested rules, at-rules, selector lists, and CLI builds.

Suggested reviewers: jarred-sumner, alii

Priority: Normal

Merge Risk: Minimal · previous review 4db48

The changes address explicit nesting detection and CSS Modules selector serialization. No concrete merge-blocking issue is established; mergeable subject to normal checks.

Pre-merge checks: Passed 4
Check name Status Explanation
Title check Passed The title clearly identifies the main change: counting & per selector so nesting inside :global() and :local() uses the rule's explicit nesting selector.
Description check Passed The description is detailed and covers the problem, cause, fix, behavior changes, verification results, tests, and known limitations. It does not use the exact template headings, but it provides the r…
Linked Issues check Passed Check skipped because no linked issues were found for this pull request.
Out of Scope Changes check Passed Check skipped because no linked issues were found for this pull request.
Full details: Description check

Explanation

The description is detailed and covers the problem, cause, fix, behavior changes, verification results, tests, and known limitations. It does not use the exact template headings, but it provides the required information in equivalent sections.

  • Autofix · Keep fixing CodeRabbit findings and required CI, and resolving merge conflicts

Thanks for using CodeRabbit! It's free for OSS, and your support helps us grow. If you like it, consider giving us a shout-out.

❤️ Share

Comment @coderabbitai help to get the list of available commands.

@claude claude Bot left a comment

Copy link
Copy Markdown
Contributor

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Nothing blocking. The comments below are optional suggestions. There is no need to push a fix for them before merging.

Beyond the inline findings, I also checked that the counter covers every & consumer the deleted AFTER_NESTING propagation did: the only two sites that consume a & delimiter (parse_compound_selector and the & arm of parse_one_simple_selector) both call note_nesting_selector, so :is(), :not(), :has(), :nth-child(of), ::slotted(), and the :global()/:local() wrappers all count through the one path. A & inside a vendor or unknown functional pseudo-class (:-webkit-foo(&)) is parsed by parse_non_ts_functional_pseudo_class as a raw token list, never as a Nesting component, so its behavior is unchanged from the base.

Extended reasoning...

The change touches the CSS selector parser (src/css/selectors/parser.rs, src/css/css_parser.rs) and selector printer (src/css/selectors/selector.rs), replacing the AFTER_NESTING bitflag with a per-ParserInput & counter and printing :global()/:local() arguments at the wrapper's position; it touches no security-sensitive surface. Three inline findings were posted, so a human look is already signalled; this note only records the sibling-site sweep that was ruled out.

Comment on lines +1273 to 1276
PseudoClass::Local { selector } => {
serialize_selector_impl(selector, dest, context, false, position)?
}
PseudoClass::Global { selector } => {

Copy link
Copy Markdown
Contributor

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

🟡 (optional) CSS-module users whose nested rule is a :global()/:local() holding a & get a flat, differently-wrong selector for a child & that does not lead; the author's test.todo pins this. fits_at (selector.rs:635) calls is_simple and has_type_selector on the parent, which see one NonTsPseudoClass component and never look inside the wrapper, so the parent is pasted as written at MidCompound. Fix: make fits_at (and so the :is() unwrap at selector.rs:981 and serialize_nesting) look through Local/Global wrappers for combinators and type selectors, so .a { :global(.dark &) { .y& {} } } prints .y_H:is(.dark .a_H) and .a { :global(span &) { .y& {} } } does not print .y_Hspan .a_H. [also at: src/css/selectors/parser.rs:662 - CSS-module users get a selector with a different meaning when a & that does not lead sits under a rule whose selector is :global(<x> <combinator> &).; src/css/selectors/selector.rs:1280 - CSS-module users whose parent rule is a :global()/:local() wrapper holding a & and a combinator now get a child & substituted inline, producing a selector that matches the wrong elements.]

Why this was flagged

Input .a { :global(.dark &) { .y& { color: red } } } in a .module.css. After the first commit the parent rule's selector is the single component :global(.dark &) with no implicit &, so when the child's & is printed at NestingPosition::MidCompound, fits_at at src/css/selectors/selector.rs:635-640 asks is_simple (selector.rs:1563) and has_type_selector (selector.rs:1529), both of which only walk the outer component list and report no combinator and no type selector. The parent is then written as-is through PseudoClass::Global at selector.rs:1273-1278, giving .y_H.dark .a_H, which matches an element with both classes y_H and dark under .a_H instead of .dark .a_H.y_H. The base printed .y_H:is(.a_H .dark .a_H) because the implicit & gave the parent a combinator; that was also wrong but at least kept the parent inside :is(). The PR's test.todo at test/bundler/css/css-modules.test.ts:909 documents the shape but no code change addresses it, and no error or warning is emitted.

Verification: The test.todo at test/bundler/css/css-modules.test.ts documents the shape. fits_at (selector.rs:635-640) for MidCompound is !has_type_selector(selector) && is_simple(selector); the single NonTsPseudoClass passes both, so the parent is printed as-is through the Global arm (selector.rs:1276-1278), yielding .y_H.dark .a_H instead of .y_H:is(.dark .a_H). On the base this same input is also wrong.

Copy link
Copy Markdown
Collaborator Author

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Confirmed. The PR body lists it under "Other behaviour changes in modules", and the test.todo pins the right form. I keep the fix out of this PR, for two reasons.

  • The cause is older than this PR. is_simple and has_type_selector read a wrapper as one simple selector on main too: :global(.a .b) { .q& {} } prints .q_<hash>.a .b there, and :global(div) { .x& {} } prints .x_<hash>div. On main the implied & hid this for a nested wrapper rule, because it gave the rule a combinator.
  • The two predicates have more readers than fits_at. should_unwrap_is also decides the :-webkit-any() copy of a :is() (selector.rs:166) and the target check in is_compatible (selector.rs:402). A change there needs its own tests and its own comparison of output.

No real module has the shape. In the 31 files that write & inside a wrapper, 17 wrapper rules hold 30 nested rules, and none of those has a & after a combinator or after another simple selector. The common children are right now: &:hover and .label under .a { :global(.dark &) {} } print .dark .a_<hash>:hover and .dark .a_<hash> .label_<hash>.

If a maintainer wants the look-through in this PR, I add it as a third commit.

Comment thread src/css/selectors/parser.rs Resolved
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;

Copy link
Copy Markdown
Contributor

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

🟣 pre-existing, not blocking: pre-existing: with bun build --no-bundle, a CSS module rule .button { :global(&.primary) {} } prints .button.primary, so the rule can match no hashed element; main printed .button_<hash> .button.primary, also dead. The Global arm at selector.rs:1277 clears dest.css_module for the whole argument, and the parent substituted for & is printed through the same dest, so the parent class loses its hash. Fix: restore css_module around the parent print inside serialize_nesting (or scope the take to the non-& components), covering :global(... &) at any position, and add the :global(&...) --no-bundle case beside the :local(&.on) CLI test, which currently avoids this shape. [also at: src/css/selectors/selector.rs:1280 - pre-existing: with bun build --no-bundle, a CSS module rule .button { :global(&.primary) {} } prints .button.primary, so the rule never matches the hashed element.]
A small fix can ride a push you are already making; otherwise a short reply is enough.

Why this was flagged

A user runs bun build --no-bundle styles.module.css on .button { :global(&.primary) { color: red } }. The Global arm at src/css/selectors/selector.rs:1277 does dest.css_module.take() before serializing the argument and restores it at 1279. Inside the argument, the & reaches serialize_nesting (selector.rs:1465), which prints the parent .button via serialize_selector_impl on the same dest while css_module is None, so .button is written unhashed. The output is .button.primary while the module's export maps button to button_<hash>, so the rule targets nothing. The base branch printed .button_<hash> .button.primary for the same input, which is equally dead, so this is pre-existing in a line the diff rewrites. The new CLI test in test/bundler/css/css-modules.test.ts only exercises :local(&.on) under --no-bundle, where the Local arm at 1273 keeps css_module, so the defect is not covered by a test.

Verification: With bun build --no-bundle on .button { :global(&.primary) {} }, src/css/selectors/selector.rs:1276-1280 does dest.css_module.take() before serializing the argument, so the parent substituted for & by serialize_nesting (selector.rs:1482-1484) prints with css_module == None. The output is .button.primary; on the base the same arm produced .button_<hash> .button.primary, so both match no element. The only --no-bundle test (css-modules.test.ts:923-951) exercises :local(&.on).

Copy link
Copy Markdown
Collaborator Author

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Confirmed, and the PR body lists it. Main prints .button_<hash> .button.primary and this branch prints .button.primary. Both match nothing.

I did not change it here. The fix is to replace the take() with a flag on the printer that only the names hashed at print time read (the view-transition names). #45487 removed the take() for that reason, and #47025 kept it when it took the scope bit, so I left that choice alone.

The CLI test uses :local(&.on) on purpose. It checks that the parse fix reaches --no-bundle, and it does not pin the wrong output of :global(&...). bun build --no-bundle styles.module.css prints only the stylesheet, with no map of the generated names.

…s 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_<hash> .button_<hash>.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.
…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_<hash> .b_<hash> .a_<hash> .b_<hash>.x`, and `div { & .q:global(&) {} }`
printed `div .q_<hash>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_<hash> .b_<hash> :is(.a_<hash> .b_<hash>).x`
and `div .q_<hash>:is(div)`.

This branch has not been deployed

No deployments
Sign up for free to join this conversation on GitHub. Already have an account? Sign in to comment

Labels

Projects

None yet

Development

Successfully merging this pull request may close these issues.

2 participants