Skip to content

css modules: keep names inside :global(...) out of the exports object - #38573

Open
robobun wants to merge 2 commits into
mainfrom
farm/3feb7773/css-modules-global-exports
Open

robobun wants to merge 2 commits into
mainfrom
farm/3feb7773/css-modules-global-exports

Conversation

@robobun

@robobun robobun commented Aug 14, 2026

Copy link
Copy Markdown
Collaborator

Problem

  • In a CSS module, a class or id written inside :global(...) is printed unhashed in the stylesheet (correct), but it is also exported from the JS exports object, under a hashed name that no rule in the output uses. For :global(.g) {} plus .local :global(.h) {}, import styles from "./w.module.css" gives { g: "g_V1RUew", local: "local_V1RUew", h: "h_V1RUew" } while the CSS contains .g and .local_V1RUew .h. esbuild exports { local } only.
  • :local(...) nested inside :global(...) had the opposite mismatch: :global(.a :local(.b)) printed .a .b but exported b: "b_V1RUew" (esbuild prints .a .b_hash).
  • A name that only exists inside :global(...) could be composesd, producing the same phantom hashed name in the export.
  • Cause: :global(...) (src/css/selectors/parser.rs, parse_non_ts_functional_pseudo_class) parses its argument with the ordinary selector path, so parse_one_simple_selector runs new_local_identifier for every class and id inside it and registers them in the sheet's local_scope. The exports object is built from local_scope (src/bundler/linker_context/generateCodeForLazyExport.rs). The printer only hid this on the CSS side: the PseudoClass::Global arm in src/css/selectors/selector.rs cleared dest.css_module while printing its argument, which made those refs print under their original names (the fallback added by fix(css): :global in css modules #18257 for CSS Modules :global() selector causes crash #18170, which was the crash this caused before).

Fix

  • SelectorParser gets an in_global_scope flag. :global(...) sets it and :local(...) clears it for the duration of their argument (restored afterwards, also on a parse error, since :is()-style recovery keeps going with the same parser). new_local_identifier returns a plain identifier instead of registering a symbol while the flag is set, so global names are never in local_scope: not renamed, not exported, not composable.
  • The printer's Global arm now prints its argument like the Local arm does. Scoping is decided once, at parse time, per class/id (local = Ref, global = Ident), and both the stylesheet and the exports object follow that one decision, which is also what makes the nested :local(...) case print the name it is exported under.
  • Why this is right: it matches esbuild's local-css semantics for all of the above (:global() argument is global, a nested :local() is local again, a global-only name cannot be composed), and it is what the exports object is documented to be: a map from the file's local names to the generated names.
  • css/ImportCSSFromJSComposesFromMissingImport (test/bundler/esbuild/css.test.ts) now also expects the error for y, which that test defines only as :global(.y); esbuild errors on it too.
  • Tests: test/bundler/css/css-modules.test.ts (GlobalPseudoFunctionNamesAreNotExported, LocalPseudoFunctionInsideGlobal, GlobalScopeRestoredAfterInvalidArgument, GlobalAndLocalUseOfSameName, GlobalPseudoFunctionDoesNotLeakIntoNestedRules, OnlyGlobalSelectorsExportsNothing, ComposesNameOnlyDefinedAsGlobal). Without the src change 5 of them fail (plus the esbuild test); with it all pass.
  • Also run: test/bundler/css/, test/bundler/esbuild/css.test.ts, test/bundler/cli.test.ts, test/bundler/bundler_loader.test.ts, test/js/bun/css/css.test.ts, test/internal/source-lints/, cargo clippy -p bun_css.

Background

  • CSS modules in the bundler: for *.module.css, every class and id selector is parsed into a Ref to a per-file symbol (Parser::add_symbol_for_name, which also records the name in the sheet's local_scope). The linker later picks a generated name per symbol (mangle_local_css), the CSS printer looks refs up in that map, and the JS side of the import is an object literal with one entry per local_scope entry. A selector component can instead hold a plain Ident, which the printer writes as is; that is what every class in a non-module stylesheet is, and now also what a global name in a module is.
  • :global(...) / :local(...) are the CSS modules pseudo-classes for opting a piece of a selector out of (or back into) local scoping. The wrappers themselves are not printed; only their argument is.
  • Printer.css_module is the printer's CSS-modules state; it is Some for the whole of a module stylesheet. Class/id components use it to decide between printing the generated name (lookup_symbol) and the original one, which is the switch the old Global arm flipped.
Repro before / after (bun build entry.js --outdir out)

w.module.css:

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

Bun 1.4.0 exports:

{"g":"g_V1RUew","local":"local_V1RUew","h":"h_V1RUew","a":"a_V1RUew","b":"b_V1RUew","c":"c_V1RUew","gid":"gid_V1RUew","gid2":"gid2_V1RUew"}

and prints .g, .local_V1RUew .h, .a .b, .c_V1RUew, #gid_V1RUew, #gid2.

With this change the exports are local, b, c, gid, and the third rule prints as .a .b_V1RUew. esbuild 0.25.1 on the same input exports local, b, c, gid and prints .a .w_b.

In a CSS module, the argument of :global(...) was parsed with the same
path as any other selector, so every class and id inside it was
registered as a local symbol. The printer hid that by dropping the
css_module while printing :global, but the JS exports object is built
from the local scope, so those names were exported under hashed names
that no rule in the output uses.

SelectorParser now tracks whether it is inside :global(...) (a nested
:local(...) switches back), and classes and ids parsed there stay plain
identifiers: printed as written, never registered, never exported. The
printer no longer special-cases :global, so :local(...) nested inside
:global(...) prints the generated name it is exported under.
@robobun

robobun commented Aug 14, 2026

Copy link
Copy Markdown
Collaborator Author
Updated 8:47 AM PT - Aug 14th, 2026

@robobun, your commit b25fe5a is building: #96202

@coderabbitai

coderabbitai Bot commented Aug 14, 2026 •

Copy link
Copy Markdown
Contributor

Warning

Review limit reached

@robobun, you've reached your PR review limit, so we couldn't start this review.

Next review available in: 23 seconds

Enable usage-based reviews in Billing to review now. Otherwise, wait until the next included review is available.
You're only billed for reviews past your plan's rate limits ($0.25/file).

How can I continue?

After more reviews become available, a review can be triggered using the @coderabbitai review command as a PR comment. Alternatively, push new commits to this PR.

To avoid repeated limits, reduce automatic review volume by pausing incremental auto-reviews earlier, using label-based review opt-in, excluding WIP or generated PR titles, or requesting reviews manually when the PR is ready. If your team needs uninterrupted high-volume reviews, an organization admin can enable usage-based reviews.

How do review limits work?

CodeRabbit enforces per-developer PR review limits for each organization. Most developers receive the normal plan review availability.

For paid Pro and Pro+ PR reviews, CodeRabbit uses adaptive limits for sustained high-volume activity. When a developer's recent PR review activity reaches the 95th percentile or higher among CodeRabbit users, additional reviews become available more gradually as earlier reviews age out of the rolling window.

Please refer docs for additional details.

Review details
⚙️ Run configuration

Configuration used: Path: .coderabbit.yaml

Review profile: ASSERTIVE

Plan: Pro

Run ID: c4e09724-c138-4753-b30e-dbec6c6ce0f1

📥 Commits

Reviewing files that changed from the base of the PR and between eabb96d and e70f995.

📒 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/bundler/esbuild/css.test.ts

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

@robobun

robobun commented Aug 14, 2026

Copy link
Copy Markdown
Collaborator Author

Status: ready for review.

Reproduced on Bun 1.4.0 and on main with bun build entry.js --outdir out over a module containing :global(.g) and .local :global(.h): the exports object contained g and h under hashed names while the stylesheet printed .g and .local_V1RUew .h. esbuild 0.25.1 exports local only. The new tests in test/bundler/css/css-modules.test.ts fail without the src change (5 of 7, plus the updated expectation in test/bundler/esbuild/css.test.ts) and pass with it.

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

LGTM — moves the local/global scoping decision to parse time so the exports object and printed CSS agree, matching esbuild.

What was reviewed:

  • in_global_scope save/restore in parse_css_module_scoped_selector: mem::replace + unconditional restore before ?, so nested :local() and :is() error-recovery both hand back the enclosing scope.
  • All three SelectorParser { .. } construction sites initialize the new field; no others exist.
  • Printer's merged Local/Global arm: write_ident_or_ref → lookup_ident_or_ref already branches on Ident vs Ref, so global names (now plain Ident) print as-written without the old css_module.take() workaround.
  • The removed exports were phantom (hashed names matching no rule in the output), so this is a bugfix, not a break.
Extended reasoning...

Overview

The PR fixes CSS-module scoping for :global(...) and :local(...). Previously, classes/ids inside :global(...) were registered as local symbols during parsing (so they appeared in the JS exports object under hashed names), while the printer worked around this by clearing dest.css_module when serializing the :global argument. That left the exports object out of sync with the printed CSS, and made :local(...) nested inside :global(...) print unhashed while still being exported hashed. The fix adds an in_global_scope flag on SelectorParser, toggled by :global(...)/:local(...) around their argument, so new_local_identifier produces a plain Ident (not a symbol Ref) for global-scoped names. The printer's Global arm is then merged with the Local arm since scoping is decided per-component at parse time. Three SelectorParser struct-literals in css_parser.rs initialize the new field to false.

Security risks

None. This is bundler output shaping for CSS modules — no untrusted-input parsing changes, no allocation/size arithmetic changes, no FFI.

Level of scrutiny

Medium. It changes user-visible bundler output (the shape of the default export from a .module.css import), but the removed keys mapped to hashed names that no rule in the output CSS used, so any consumer relying on them was already broken. The change aligns Bun with esbuild's local-css semantics, which is the reference for this feature. The Rust change is ~30 net lines and mechanical.

Other factors

Test coverage is thorough: seven new itBundled cases covering the primary export-shape fix, nested :local inside :global, error-path scope restoration under :is() recovery, same name used both locally and globally, nested rule blocks not inheriting the paren scope, an all-global module exporting nothing, and composes of a global-only name now erroring. One existing esbuild-parity test is tightened to also expect the y error (esbuild errors on it too). I confirmed via grep that only three SelectorParser { .. } literals exist and all initialize in_global_scope: false, and that lookup_ident_or_ref already handles the plain-Ident branch independent of css_module, so dropping the printer's css_module.take() is safe.

Comment on lines +1168 to +1171
/// CSS modules: set while parsing the argument of `:global(...)` and
/// cleared again inside a nested `:local(...)`. Classes and ids parsed in
/// this state keep their written name instead of becoming local symbols,
/// so they are neither renamed nor exported.

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.

If you need a paragraph-long comment to justify why the workaround is OK, the code is wrong — fix the code

Comment on lines +1380 to +1383
/// Parses the argument of `:local(...)` / `:global(...)`. The scope covers
/// only the argument: it is restored even when the argument fails to parse,
/// because `:is()`-style error recovery keeps parsing the rest of the
/// selector list with this same parser.

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.

If you need a paragraph-long comment to justify why the workaround is OK, the code is wrong — fix the code

Comment on lines +1120 to +1123
// The parser already scoped the classes and ids inside these: a
// local one is a `Ref` (printed under its generated name), a
// global one is a plain `Ident` (printed as written). That also
// covers `:local(...)` nested inside `:global(...)`.

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.

If you need a paragraph-long comment to justify why the workaround is OK, the code is wrong — fix the code

@robobun

robobun commented Oct 17, 2026

Copy link
Copy Markdown
Collaborator Author

#45487 replaces this PR. It makes the same change at parse time: a name inside :global(...) is not a local symbol and is not exported. It also fixes & inside :global(...). The seven tests of this PR pass unchanged on that branch.

This branch should not merge as it is. Its printer change removes the only thing that keeps a view-transition name plain inside :global(...) on current main, where those names are hashed at print time since #40716. This PR can be closed in favor of #45487.

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.

1 participant