Repository navigation
Conversation
A class, id or view-transition class inside `:global(...)` of a CSS module was registered as a local symbol, so it was exported from the module under a generated name while the printer wrote the plain name. A `:local(...)` nested in `:global(...)` was exported but printed plain. `ParserOpts::MAKE_LOCAL_SYMBOLS` is the scope. `:global(...)` clears it for its argument and `:local(...)` sets it again. `new_local_identifier` reads it, so a global name is a plain `Ident` and is never in `local_scope`. `ViewTransitionPartName::parse` reads it too: the printer hashes a view-transition name, so a global one is a `GlobalName`. The printer no longer keeps a scope of its own: the `Global` arm does not hide `css_module`, and `write_ident_or_ref` always prints a `Ref` under its generated name.
…elector
`.parent { :global(& .x) {} }` got a second, implicit `&` in front, so it
printed the parent twice. The argument is now parsed with
`Selector::parse_inner`, which reports `AFTER_NESTING` to the selector that
holds it, as `:is()` and `:not()` do.
|
Updated 1:32 PM PT - Oct 26th, 2026
✅ @robobun, your commit 1c3a61c36845ce40914a6707993f04358b164a1d passed in 🧪 To try this PR locally: bunx bun-pr 45487That installs a local version of the PR into your bun-45487 --bun |
|
No actionable comments were generated in the recent review.
|
| Layer / File(s) | Summary |
|---|---|
Scoped selector parsing src/css/css_parser.rs, src/css/selectors/parser.rs |
Parser options track CSS Module scope and local-symbol creation. :local() and :global() parse their arguments in the matching scope, including nested selectors and view-transition names. |
Scoped selector serialization src/css/selectors/selector.rs, src/css/printer.rs, src/css/values/ident.rs |
Selector serialization handles leading scope wrappers and uses the updated identifier/reference printer behavior. |
Scope behavior coverage and documentation test/bundler/css/css-modules.test.ts, test/bundler/esbuild/css.test.ts, docs/bundler/css.mdx |
Tests cover global and local names, nested rules, exports, view-transition names, and no-bundle output. The documentation describes global names and locally scoped names. |
Possibly related PRs
- oven-sh/bun#46720: Extends local-symbol handling to
@keyframes, animation names, and their exports, using related symbol and printer behavior. - oven-sh/bun#46787: Changes selector-list wrapping and splitting counts, which connect to nesting serialization behavior.
Suggested reviewers: jarred-sumner
Priority:
Merge Risk: 
Minimal · up to 1c3a6
-
No issue identified here needs resolution before merge; normal checks still apply.
Pre-merge checks: 
4
-
Full details: Description check
-
Explanation
The description explains the problem, implementation, behavior changes, limitations, and verification results. It does not use the exact template headings, but it provides the required content 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.
Comment @coderabbitai help to get the list of available commands.
|
Status: ready for review. Reproduced on 1.4.3-canary (367d939) and on a debug build and a release build of main (a407eae) with |
There was a problem hiding this comment.
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 for sibling sites the new scope gate could miss: add_symbol_for_name has a single caller (local_identifier in src/css/selectors/parser.rs), so no other symbol-creation path bypasses MAKE_LOCAL_SYMBOLS; every ViewTransitionPartName match (to_css/eql/hash) lives in parser.rs and covers the new GlobalName arm; and the remaining css_modules() checks (composes handling in src/css/declaration.rs and css_parser.rs) run on declarations after with_css_module_scope has restored the enclosing flags, so they are unaffected.
Extended reasoning...
The change reworks CSS-module local/global scoping in the Rust CSS parser and printer (src/css/css_parser.rs, printer.rs, selectors/parser.rs, selectors/selector.rs) plus docs and tests, touching no security-sensitive surface. Two confirmed findings are posted inline, so a human should look; the ruled-out note records the sibling-site audit that found no further gaps.
…pper is
The wrapper prints only its argument. A `&` that starts the argument starts
the printed selector only when the wrapper does. `.a .b { .q:global(&.x) {} }`
printed `.q.a .b.x`. It now prints `.q:is(.a .b).x`, as `.q&.x` does.
`.p { .q :global(&div) {} }` printed `.q .pdiv` after the previous commit,
because the argument of a wrapper that does not lead was printed as if its
`&` did not start a compound. The `&` still starts its compound, so the
type selector moves in front of it: `.q div.p`. Only the choice between the
parent as written and `:is(parent)` depends on where the wrapper is.
The position is now a `NestingPosition`, not a second bare bool.
#45487 adds a ParserOpts flag at 0b10. Two flags with the same value compile, and the prelude flag would then be set for every CSS module.
|
#45604 adds a second Keep the two values different when you resolve it. With one value the code still compiles. Then the prelude flag is set for every CSS module, and each |
#45487 adds a ParserOpts flag at 0b10. Two flags with the same value compile, and the prelude flag would then be set for every CSS module.
|
The follow-up for three "Left for later" items is built on a branch. It is not opened as a PR: diff against this PR ( The parser splices the argument of
Effect on this PR
Why it is not a PRIt needs this PR first. It should also land after four open PRs.
Decisions for a maintainer
|
|
An addition to the comment above. It is about the three rows of this review thread: The rows stay with the branchA build of A patch for main alone is built and is not opened
If a maintainer wants these rows fixed on main before the branch can land, say so here. Three more PRs that the branch should wait forWith the branch, a wrapped selector prints like the plain one. So it takes over open items of plain CSS. On the same build:
6 of the 7 tests of the patch pass on the branch with no edit. The seventh, What the branch changes in real modulesOf the same 5,833 modules, 1,806 print different CSS with the branch than with this PR. 1,712 of them keep every selector and declaration and only merge rules. 94 print different selectors or declarations. 57 of those get |
main added `ParserOpts::RAW_URL_TOKENS` as the second flag bit, so `MAKE_LOCAL_SYMBOLS` moves to the third. The test file keeps the tests of both sides.
Supersedes #38573.
Problem
.parent { :global(& .x) {} }prints.parent_<hash> .parent .x, which cannot match.:global(...)prints plain, but is exported as<name>_<hash>.styles[n] || ngives a class no rule has.new_local_identifier(src/css/selectors/parser.rs:1191) makes every class and id a local symbol. The printer'sGlobalarm (src/css/selectors/selector.rs:1111) prints their original names.Fix
:global(...)clearsParserOpts::MAKE_LOCAL_SYMBOLSfor its argument,:local(...)sets it,new_local_identifierreads it. A global name is a plainIdentand never enterslocal_scope.Refalways prints its generated name.&inside:global(...)is the nesting selector.test/bundler/css/css-modules.test.ts(14 new tests fail on main),test/bundler/esbuild/css.test.ts. Self-reviewed: 17 concerns raised, 15 addressed, 2 rejected (Notes).Background
*.module.css, a class or id is aRefto a symbol inlocal_scope. The linker builds the exports object from it before printing.:global(...)and:local(...)switch the scope for their argument. The innermost wins, like esbuild.:global(...): the printer keeps its decision, so&and nested:local(...)stay wrong.Downsides
import { g }andcomposes: gwhengis only inside:global(...). Usecomposes: g from global.styles.gof such a name isundefined. Export key order can change.::view-transition-*()argument. Class or id: 0 at parse, 4 fewer at print.Notes
Origin. A fuzzer found this. No user reported it. For ordinary
:global(.x)use the stylesheet does not change. Only keys that matched no rule leave the exports object.Repro (
bun build entry.js --outdir out,entry.jsprintsJSON.stringify(styles)):g, local, h, a, b, c, gid, gid2, n, ng.a .blocal, b, c, gid, n.a .b_<hash>local, b, c, gid, n.a .w_bWhat changes for users
:global(...)is not exported.styles.gisundefined(as in esbuild).import { g }isNo matching export in "styles.module.css" for import "g".composes: gof such a name is the existing errorThe name "g" never appears in "styles.module.css" as a CSS modules locally scoped class name. Before, it composedg_<hash>, which no rule had. esbuild builds the same-file form, because it makesga local from thecomposesreference.composes: g from globalgives"g a_<hash>".:local(...)inside:global(...)is renamed in the stylesheet. esbuild does the same. lightningcss keeps that name global.&inside:global(...)or:local(...)prints the parent once, under its generated name..p { :global(&.x) {} }printed.p_<hash> .p.xand now prints.p_<hash>.x. When the wrapper does not start the selector, a parent with a combinator goes through:is(), as it does without the wrapper:.a .b { .q:global(&.x) {} }prints.q_<hash>:is(.a_<hash> .b_<hash>).x. A type selector after the&moves in front of it:.p { .q :global(&div) {} }prints.q_<hash> div.p_<hash>.:global(...)and later as a local is exported at its first local use.:global(.b) {} .a {} .b {}gave keysb, aand now givesa, b, as in esbuild.:global(...)prints as written. Inside a nested:local(...)it is scoped again.Not changed
@keyframesandanimation-name,@container,@counter-style,view-transition-name) are hashed at print and are not exported, as on main. See CSS modules:@containernames are scoped butcontainer/container-nameare not, so a named container query never matches (still in 1.4.3) #45089 and fix(css): keep container names global in CSS modules #45247 for container names. The expected objects in the tests of bun test: Vitest compatibility #44833 have no key for these names..x:is(.y !, .z) {}exportsy. esbuild does the same.composeson a rule whose selector is:local(.x)or:global(.x)still fails the file at print time (css modules: drop a rejected composes instead of failing the file at print time #38538 covers that path).Deleted
css_module.take()in theGlobalarm ofserialize_pseudo_class.write_ident_or_refthat printed aRefunder its original name (added by fix(css)::globalin css modules #18257 for CSS Modules:global()selector causes crash #18170), and itshandle_css_moduleparameter.Printer::lookup_ident_or_ref, which has no caller left.Measurements. Release builds (
bun run build:release) of main 288aca3 and of this branch at ad8b947. They predate the merge with main in 1c3a61c. That merge only movesMAKE_LOCAL_SYMBOLSfrom the second flag bit to the third, because main addedRAW_URL_TOKENS. valgrind, perf, strace and bloaty are not installed in my environment, so I used objdump, nm, size and gdb.new_local_identifier, non-module path: 25 instructions / 1 load / 1 branch (main: 25 / 1 / 1). Module path delta: 0 instructions. The function is 1,720 B in both builds. The one changed instruction istestb $0x1,0x19(%rdi)totestb $0x2,0x19(%rdi)(objdump ofbun-profile).Identpath: 19 instructions, 2 loads, 1 branch (main: 23, 3, 2). Counted into_css_componentfrom after the.is written to the call ofserialize_identifier.write_ident_or_refgoes from 503 B to 355 B (objdump).ViewTransitionPartName::parse, any sheet: +5 instructions, +1 load (the flags byte), +0 branches per::view-transition-*()argument (objdump).size_ofunchanged:ParserOpts1 B,ViewTransitionPartName24 B,PseudoElement48 B,Component48 B,SelectorParser16 B,Printer496 B (asize_ofprobe compiled intobun_csson both trees, taken at the first commit of this branch. The later commits change no type).size). Thebun_cssfunctions sum to 1,564,820 B and 1,565,075 B, +255 B (nm -S).serialize_selector: 1,796 B and 471 instructions on main. It isserialize_selector_athere, with 1,927 B and 516 instructions. For a selector with no wrapper, the first compound runs 4 more instructions (1 compare, 1 branch, 2 register moves on the tag of its first simple selector). Later compounds run the same code as before (objdump)..css4.0 to 4.0, local-only module 25.2 to 25.2,:global(.c)module 27.2 to 8.0,:local(.c)module 27.2 to 27.2.mi_heap_newper:global(.c)rule: 2 to 0. With--no-bundle: 4.0, 8.1, 8.0 and 11.1 in both builds (gdb hit counts on 16 mimalloc entry points, (400 rules - 200 rules) / 200, bundled runs compared at the same number of worker threads).entry.js45,667 B to 22,887 B,entry.css38,816 B in both (wc -c).test/bundler/css/**andtest/bundler/esbuild/css.test.tshave on main, bundled and with--no-bundle, with both builds. 487 give byte-identical output. 3 differ, and each uses:global()or composes from a file that does:css-module/GlobalPseudoFunction(exports{ foo }to{}, CSS identical) and the two module files ofcss/ImportCSSFromJSComposesFromMissingImport(theykey leaves, andcomposes: y from "file.module.css"gets thenever appearserror) (cmp).StyleSheet::parse_withandStyleAttribute::parse, 1 helperParser::with_css_module_scope), 3 read sites (new_local_identifier,ViewTransitionPartName::parse, thedebug_assertinadd_symbol_for_name) (grep oversrc/css).Order against open PRs
new_local_identifierand its test cases, moves the scope to aParser.flagsbit, and adds the view-transition name marker that its printer change needs since css: scope view transition names in CSS modules #40716.&inside :nth-child(... of S), :host(), ::slotted() and ::cue() #43024 adds the sameGenericSelector::parse_inner. The PR that lands second drops its copy.ViewTransitionPartNamewithout a wildcard (src/css/selectors/selector.rs:345there). It needs aGlobalNamearm after this lands.Printer::lookup_ident_or_ref, which this PR deletes.composeserror to thecomposesline. Theyexpectation added here intest/bundler/esbuild/css.test.tsthen moves under/styles.module.css.local_scope(src/runtime/jsc_hooks.rs:3779there) and prints no CSS. So it takes this fix with no change.Left for later. Each of these is the same before and after this PR.
composesof a global name asCannot use global name "y" with "composes". Bun reportsnever appears.:local()and:global()stay wrapper nodes in the selector..x:global(div.a)prints.x_<hash>div.a.:global(.a), .b {}becomes two rules in reverse order.:global .aand:local .ado not parse in a module (css: only reject bare :global / :local pseudo-classes in CSS modules #38534 covers the non-module case).:local()or:global()is parsed without the restrictions of the selector that holds it, so:host(:global(.a > .b))and::slotted(:global(.a .b))parse. The state after the argument does not reach the outer selector either, so:global(.a::part(x)).bparses.:global(.a .b) { .q& {} }prints.q_<hash>.a .b, and:global(div) { .x& {} }prints.x_<hash>div.Review after opening. A review found that
.a .b { .q:global(&.x) {} }printed the parent as if the&started the selector (.q_<hash>.a_<hash> .b_<hash>.x). 6f3f701 lowers it by the position of the wrapper. That commit lost the&divswap for a wrapper that does not lead (.p { .q :global(&div) {} }printed.q_<hash> .p_<hash>div), a second review found it, and ad8b947 restores it. The reviews also found two cases that main prints the same way and that this PR does not change: the last two items of "Left for later".Self-review. Rejected: (1) roll back the symbols of a selector that a forgiving list drops. esbuild keeps those names and #44833 has a test for it. (2) open a new issue for this bug and a tracker issue for each item above. This PR is the record of the bug, and the items are listed here.
Suites run on a debug build of this branch:
test/bundler/css/(197 pass),test/bundler/esbuild/css.test.ts(56 pass, 1 todo),test/bundler/bundler_loader.test.ts(59 pass), the CSS module tests oftest/bundler/cli.test.tsandtest/bundler/bundler_splitting.test.ts,test/js/bun/css/(2511 pass, the 4css-fuzzloops time out on a debug build),test/internal/source-lints/,cargo clippy -p bun_css. The 14 new tests and the changed esbuild expectation fail on a release build of main 288aca3. The seven tests of #38573 pass unchanged on this branch.[human-review] gate passed · iteration 0 · 8 files touched
fails on main (without fix)
passes on PR (with fix)
diff hotspot
gate history · 1 passed · 0 rejected · iteration 0
evidence per changed file