Skip to content

css modules: decide local vs global at parse, fixing :global() exports and & inside :global() - #45487

Open
robobun wants to merge 7 commits into
mainfrom
robobun/0c007c62/css-modules-scope-at-parse
Open

robobun wants to merge 7 commits into
mainfrom
robobun/0c007c62/css-modules-scope-at-parse

Conversation

@robobun

@robobun robobun commented Oct 16, 2026 •

Copy link
Copy Markdown
Collaborator

Supersedes #38573.

Problem

  • In a CSS module, .parent { :global(& .x) {} } prints .parent_<hash> .parent .x, which cannot match.
  • A class or id inside :global(...) prints plain, but is exported as <name>_<hash>. styles[n] || n gives a class no rule has.
  • Cause: new_local_identifier (src/css/selectors/parser.rs:1191) makes every class and id a local symbol. The printer's Global arm (src/css/selectors/selector.rs:1111) prints their original names.

Fix

  • :global(...) clears ParserOpts::MAKE_LOCAL_SYMBOLS for its argument, :local(...) sets it, new_local_identifier reads it. A global name is a plain Ident and never enters local_scope.
  • The printer keeps no scope: a Ref always prints its generated name. & inside :global(...) is the nesting selector.
  • Verified: 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

  • In *.module.css, a class or id is a Ref to a symbol in local_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.
  • Considered gating only :global(...): the printer keeps its decision, so & and nested :local(...) stay wrong.

Downsides

  • New build errors: import { g } and composes: g when g is only inside :global(...). Use composes: g from global.
  • styles.g of such a name is undefined. Export key order can change.
  • Cost: +255 B, +4 instructions per printed selector, +5 per ::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.js prints JSON.stringify(styles)):

:global(.g) { color: red }
.local :global(.h) { color: blue }
:global(.a :local(.b)) { color: green }
:local(.c) { color: pink }
#gid { color: black }
:global(#gid2) { color: white }
.n:not(:global(.ng)) { color: gray }
exports third rule
main g, local, h, a, b, c, gid, gid2, n, ng .a .b
this PR local, b, c, gid, n .a .b_<hash>
esbuild 0.21.5 local, b, c, gid, n .a .w_b

What changes for users

  • A class, id or view-transition class used only inside :global(...) is not exported. styles.g is undefined (as in esbuild). import { g } is No matching export in "styles.module.css" for import "g".
  • composes: g of such a name is the existing error The name "g" never appears in "styles.module.css" as a CSS modules locally scoped class name. Before, it composed g_<hash>, which no rule had. esbuild builds the same-file form, because it makes g a local from the composes reference. composes: g from global gives "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.x and 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>.
  • A name used first inside :global(...) and later as a local is exported at its first local use. :global(.b) {} .a {} .b {} gave keys b, a and now gives a, b, as in esbuild.
  • A view-transition name or class inside :global(...) prints as written. Inside a nested :local(...) it is scoped again.

Not changed

Deleted

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 moves MAKE_LOCAL_SYMBOLS from the second flag bit to the third, because main added RAW_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 is testb $0x1,0x19(%rdi) to testb $0x2,0x19(%rdi) (objdump of bun-profile).
  • Class or id print, Ident path: 19 instructions, 2 loads, 1 branch (main: 23, 3, 2). Counted in to_css_component from after the . is written to the call of serialize_identifier. write_ident_or_ref goes 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_of unchanged: ParserOpts 1 B, ViewTransitionPartName 24 B, PseudoElement 48 B, Component 48 B, SelectorParser 16 B, Printer 496 B (a size_of probe compiled into bun_css on both trees, taken at the first commit of this branch. The later commits change no type).
  • Release binary: text 106,331,459 B (main) to 106,331,715 B (PR), delta +256 B (size). The bun_css functions 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 is serialize_selector_at here, 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).
  • Allocations per rule, main to PR, bundled build: plain .css 4.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_new per :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).
  • 1000 global + 1000 local names: export keys 2000 to 1000, entry.js 45,667 B to 22,887 B, entry.css 38,816 B in both (wc -c).
  • Output diff vs main: I built the 490 CSS fixture files that test/bundler/css/** and test/bundler/esbuild/css.test.ts have 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 of css/ImportCSSFromJSComposesFromMissingImport (the y key leaves, and composes: y from "file.module.css" gets the never appears error) (cmp).
  • Scope bit: 3 write sites (2 seeds in StyleSheet::parse_with and StyleAttribute::parse, 1 helper Parser::with_css_module_scope), 3 read sites (new_local_identifier, ViewTransitionPartName::parse, the debug_assert in add_symbol_for_name) (grep over src/css).

Order against open PRs

Left for later. Each of these is the same before and after this PR.

  • esbuild reports cross-file composes of a global name as Cannot use global name "y" with "composes". Bun reports never 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.
  • The bare forms :global .a and :local .a do not parse in a module (css: only reject bare :global / :local pseudo-classes in CSS modules #38534 covers the non-module case).
  • Building the exports object takes quadratic time in the number of local names: 40,000 names take 2.5 s, the same file as plain CSS takes 0.08 s.
  • The argument of :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)).b parses.
  • A parent rule written as a wrapper counts as a simple selector: :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 &div swap 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 of test/bundler/cli.test.ts and test/bundler/bundler_splitting.test.ts, test/js/bun/css/ (2511 pass, the 4 css-fuzz loops 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)
ASAN without fix: 15 failed, 1 skipped
$ BUN_DEBUG_QUIET_LOGS=1 bun scripts/build.ts --profile=debug --quiet test "--reporter=junit" "--reporter-outfile=/tmp/pr_gate.xml" test/bundler/css/css-modules.test.ts test/bundler/esbuild/css.test.ts
bun test v1.4.4 (367d939d9)

test/bundler/esbuild/css.test.ts:
(pass) bundler > css/CSSEntryPoint [744.93ms]
(pass) bundler > css/CSSEntryPointEmpty [148.41ms]
(pass) bundler > css/CSSNesting [193.41ms]
(pass) bundler > css/CSSAtImportMissing [161.56ms]
(pass) bundler > css/CSSAtImportSimple [223.54ms]
(pass) bundler > css/CSSAtImportDiamond [218.90ms]
(pass) bundler > css/CSSAtImportCycle [125.32ms]
(pass) bundler > css/CSSUrlImport [146.56ms]
(pass) bundler > css/ImportCSSFromJSComposes [235.79ms]
1133 |                   expectedErrors.map(formatError).join("\n"),
1134 |               );
1135 |             }
1136 | 
1137 |             if (errorsLeft.length) {
1138 |               throw new Error("Errors were expected while bundling:\n" + errorsLeft.map(formatError).join("\n"));
                               ^
error: Errors were expected while bundling:
/file.module.css: The name "y" never appears in "file.module.css"
      at <anonymous> (/workspace/bun/test/bundl
... (truncated)

release without fix: 1 failed, 1 skipped
bun test v1.4.4-canary.1 (310e1b3ba)

test/bundler/esbuild/css.test.ts:
(pass) bundler > css/CSSEntryPoint [157.11ms]
(pass) bundler > css/CSSEntryPointEmpty [10.26ms]
(pass) bundler > css/CSSNesting [10.82ms]
(pass) bundler > css/CSSAtImportMissing [11.02ms]
(pass) bundler > css/CSSAtImportSimple [11.14ms]
(pass) bundler > css/CSSAtImportDiamond [10.84ms]
(pass) bundler > css/CSSAtImportCycle [10.74ms]
(pass) bundler > css/CSSUrlImport [17.00ms]
(pass) bundler > css/ImportCSSFromJSComposes [18.73ms]
(pass) bundler > css/ImportCSSFromJSComposesFromMissingImport [36.35ms]
(pass) bundler > css/ImportCSSFromJSComposesFromNotCSS [14.93ms]
(pass) bundler > css/ImportCSSFromJSComposesCircular [16.86ms]
(pass) bundler > css/ImportCSSFromJSComposesFromCircular [8.98ms]
(pass) bundler > css/ImportCSSFromJSComposesIDSelector [7.03ms]
(pass) bundler > css/ImportCSSFromJSComposesElementSelector [6.63ms]
(pass) bundler > css/ImportCSSFromJSComposesCompoundSelector [6.14ms]
(pass) bundler > css/ImportCSSFromJSComposesComplexSelector [12.30ms]
(pass) bundler > css/ImportCSSFromJSComposesPseudoClass [12.23ms]
(pass) bundler > css/ImportCSSFromJSComposesAttributeSelector [13.46ms]
(
... (truncated)
passes on PR (with fix)
ASAN with fix: 1 skipped
$ BUN_DEBUG_QUIET_LOGS=1 bun scripts/build.ts --profile=debug --quiet test "--reporter=junit" "--reporter-outfile=/tmp/pr_gate.xml" test/bundler/css/css-modules.test.ts test/bundler/esbuild/css.test.ts
bun test v1.4.4 (367d939d9)

test/bundler/esbuild/css.test.ts:
(pass) bundler > css/CSSEntryPoint [480.31ms]
(pass) bundler > css/CSSEntryPointEmpty [130.93ms]
(pass) bundler > css/CSSNesting [119.08ms]
(pass) bundler > css/CSSAtImportMissing [106.44ms]
(pass) bundler > css/CSSAtImportSimple [113.15ms]
(pass) bundler > css/CSSAtImportDiamond [128.17ms]
(pass) bundler > css/CSSAtImportCycle [115.76ms]
(pass) bundler > css/CSSUrlImport [174.17ms]
(pass) bundler > css/ImportCSSFromJSComposes [143.73ms]
(pass) bundler > css/ImportCSSFromJSComposesFromMissingImport [551.93ms]
(pass) bundler > css/ImportCSSFromJSComposesFromNotCSS [170.34ms]
(pass) bundler > css/ImportCSSFromJSComposesCircular [161.74ms]
(pass) bundler > css/ImportCSSFromJSComposesFromCircular [165.08ms]
(pass) bundler > css/ImportCSSFromJSComposesIDSelector [123.52ms]
(pass) bundler > css/ImportCSSFromJSComposesElementSelector [117.52ms]
(pass) bundler > css/ImportCSSFromJSComposesCompoundSelector [122.89m
... (truncated)

release with fix: 1 skipped
$ bun scripts/build.ts --profile=release
[configured] bun-profile → bun (stripped)
  target       linux-x64-gnu
  build type   Release
  build dir    ./build/release
  revision     ad8b9473f6
  features     lto, baseline

23 deps, 146 codegen, 1179 objects in 2002ms

ninja: Entering directory `/workspace/bun/build/release'
[1/359] gen ErrorCode+*.h
[2/359] gen compressed/completions/bun.fish.zst
[3/359] gen compressed/completions/bun.zsh.zst
[4/359] gen generated_host_exports.rs
generated_host_exports.rs: 103 exports (host=5, lazy=4, generic=94, rust=0); 246 extern-C blocks audited
[5/359] fetch libarchive
[libarchive] up to date
[6/359] gen bake.{client,server,error}.js
-> bake.client.js, bake.server.js, bake.error.js
[7/357] gen JSBuffer.lut.h
Generating /workspace/bun/build/release/codegen/JSBuffer.lut.h from /workspace/bun/src/jsc/bindings/JSBuffer.cpp
[8/357] gen BunProcess.lut.h
Generating /workspace/bun/build/release/codegen/BunProcess.lut.h from /workspace/bun/src/jsc/bindings/BunProcess.cpp
[9/357] gen NodeModuleModule.lut.h
Generating /workspace/bun/build/release/codegen/NodeModuleModule.lut.h from /workspace/bun/src/jsc/modules/NodeModuleModule.cpp
[10/
... (truncated)
diff hotspot
docs/bundler/css.mdx                 |  29 ++
 src/css/css_parser.rs                |  47 +++-
 src/css/printer.rs                   |  47 +---
 src/css/selectors/parser.rs          |  45 ++-
 src/css/selectors/selector.rs        |  50 +++-
 src/css/values/ident.rs              |   2 +-
 test/bundler/css/css-modules.test.ts | 527 ++++++++++++++++++++++++++++++++++-
 test/bundler/esbuild/css.test.ts     |   6 +-
 8 files changed, 694 insertions(+), 59 deletions(-)

gate history · 1 passed · 0 rejected · iteration 0

evidence per changed file
file                                  reads  edits  tests
docs/bundler/css.mdx                      1      3     50
src/css/css_parser.rs                     2      5     49
src/css/printer.rs                        1      3     49
src/css/selectors/parser.rs               4      4     50
src/css/selectors/selector.rs             1      0     50
src/css/values/ident.rs                   1      1     49
test/bundler/css/css-modules.test.ts      1      1     40
test/bundler/esbuild/css.test.ts          0      0     21

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.
@robobun

robobun commented Oct 16, 2026 •

Copy link
Copy Markdown
Collaborator Author
Updated 1:32 PM PT - Oct 26th, 2026

✅ @robobun, your commit 1c3a61c36845ce40914a6707993f04358b164a1d passed in Build #133216! 🎉


🧪   To try this PR locally:

bunx bun-pr 45487

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

bun-45487 --bun

@coderabbitai

coderabbitai Bot commented Oct 16, 2026 •

Copy link
Copy Markdown
Contributor

Review in Change Stack →

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 6f3f701 and 1c3a61c.

Files selected for processing: 3
  • src/css/css_parser.rs
  • src/css/selectors/selector.rs
  • test/bundler/css/css-modules.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

CSS Modules now support :global() and :local() scoping in selectors. Global names remain unchanged and are excluded from module exports, while local names are scoped and exported. Tests cover nested selectors, view-transition names, and no-bundle output.

Changes

CSS Module Scopes

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: Normal

Merge Risk: Minimal · up to 1c3a6

No issue identified here needs resolution before merge; normal checks still apply.

Pre-merge checks: Passed 4
Check name Status Explanation
Title check Passed The title clearly identifies the main change: deciding CSS Modules local versus global scope during parsing and fixing related :global() export and nesting behavior.
Description check Passed 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 equi…
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 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.

❤️ Share

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

@robobun

robobun commented Oct 16, 2026 •

Copy link
Copy Markdown
Collaborator Author

Status: ready for review.

Reproduced on 1.4.3-canary (367d939) and on a debug build and a release build of main (a407eae) with bun build entry.js --outdir out. A module with :global(.g), .local :global(.h) and :global(.a :local(.b)) exports g, h and a under generated names, while the stylesheet prints .g, .h and .a .b. esbuild 0.21.5 exports local and b only. The 14 new tests in test/bundler/css/css-modules.test.ts fail on that release build of main and pass on this branch.

@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 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.

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

@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.

Still open from earlier reviews (1):

  • Unresolved: 1 minor or pre-existing.

Comment thread src/css/selectors/selector.rs
Comment thread src/css/selectors/selector.rs
`.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.

@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.

Code review completed

Nothing new to post: everything this review found is already covered by existing comments on this pull request or didn't merit a separate one.

Still open from earlier reviews (2):

  • Unresolved: 2 minor or pre-existing.

robobun added a commit that referenced this pull request Oct 16, 2026
#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.
@robobun

robobun commented Oct 16, 2026

Copy link
Copy Markdown
Collaborator Author

#45604 adds a second ParserOpts flag, AT_RULE_PRELUDE_TOKENS = 0b100, next to the MAKE_LOCAL_SYMBOLS = 0b10 that this PR adds. The two PRs edit the same bitflags! block in src/css/css_parser.rs, so the one that merges second gets a conflict there.

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 url() in a token list of a CSS module loses its import. The test css/URLInCSSModuleTokenListIsStillAnImport in #45604 fails in that case.

robobun added a commit that referenced this pull request Oct 18, 2026
#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.
@robobun

robobun commented Oct 19, 2026 •

Copy link
Copy Markdown
Collaborator Author

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 (src +107/-87, tests +231/-7).

The parser splices the argument of :local() and :global() into the selector that holds the wrapper. PseudoClass::Local and PseudoClass::Global are gone.

styles.module.css this PR with the branch
.x:global(div.a) .x_<hash>div.a div.x_<hash>.a
:global(.a), .b .b_<hash> {}, then .a {} .a, .b_<hash>
:global(.a .b) { .q& {} } .q_<hash>.a .b .q_<hash>:is(.a .b)
:local(.x) { composes: y } PrintError builds

Effect on this PR

  • The branch deletes Selector::parse_inner, NestingPosition, serialize_selector_at and leading_scope. That is 58 of the 134 lines that this PR adds under src/css.
  • serialize_selector is then the same code as on main, so the "+4 instructions per printed selector" of this PR goes away.
  • The tests of this PR pass with three edits: two expected outputs and one assertion.

Why it is not a PR

It needs this PR first. It should also land after four open PRs.

Decisions for a maintainer

  1. Do you want the wrapped forms checked like the plain forms? With the branch, 31 of 200 probed wrapper shapes go from build to error (:host(:global(.a .b)), .x:global(.a::before).b), and 9 go from error to build (.x::part(p):global(:hover)). Of 661 *.module.css files from 15 public repositories that use a wrapper, none changes between build and error.
  2. One PR or two? The branch can go into this PR, which then also waits for the four PRs above. Or it can follow as a PR against main.

@robobun

robobun commented Oct 21, 2026

Copy link
Copy Markdown
Collaborator Author

An addition to the comment above. It is about the three rows of this review thread: :global(.a .b) { .q& {} }, :global(div) { .x& {} } and .w:is(:global(.a .b)).

The rows stay with the branch

A build of robobun/6de475fe/css-modules-splice-scope-argument (a02efd1) prints .q_<hash>:is(.a .b), .x_<hash>:is(div) and .w_<hash>:is(.a .b). Main and this PR print .q_<hash>.a .b, .x_<hash>div and .w_<hash>.a .b.

A patch for main alone is built and is not opened

robobun/43a6c4f8/css-modules-scope-argument-is fixes only these rows on main, with 66 source lines and 7 tests. serialize_nesting, should_unwrap_is and Selector::has_combinator judge the argument of the wrapper. It stays a branch for these reasons:

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 for

With the branch, a wrapped selector prints like the plain one. So it takes over open items of plain CSS. On the same build:

styles.module.css main and this PR with the branch open PR for the plain form
:global(.btn::before) { .d & {} } .d_<hash> .btn:before .d_<hash> :is(.btn:before), which matches nothing #43078
:global(div) { .x & {} } .x_<hash> div .x_<hash> :is(div) #43047
:is(:global(div)).w div.w_<hash> three rules, one with :-webkit-any(div) #42029

6 of the 7 tests of the patch pass on the branch with no edit. The seventh, css-module/ParentWithPseudoElementInsideGlobal, pins the first row at the output of main. It can be the check for #43078.

What the branch changes in real modules

Of 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 :-webkit-any() and :-moz-any() copies, as plain CSS does. #42029 removes the copies that outrank the rule.

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.

@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.

Code review found no issues

No high-confidence issues detected in this change.

Still open from earlier reviews (1):

  • Unresolved: 1 minor or pre-existing.

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