Skip to content

bundler: append a CSS module's exports without a lookup per name - #45824

Open
robobun wants to merge 2 commits into
mainfrom
robobun/a2070fbd/css-module-exports-append
Open

robobun wants to merge 2 commits into
mainfrom
robobun/a2070fbd/css-module-exports-append

Conversation

@robobun

@robobun robobun commented Oct 18, 2026

Copy link
Copy Markdown
Collaborator

Problem

  • The linker builds the exports object of a *.module.css file with E::Object::put for each local name (src/bundler/linker_context/generateCodeForLazyExport.rs:357). put searches all earlier properties (src/ast/e.rs:1398): N names cost N(N-1)/2 comparisons.
  • A generated module of 40,000 names takes 1.92 s of CPU, and 0.08 s as plain CSS. Found by inspection.

Fix

  • The loop calls E::Object::append_property and reserves the capacity once.
  • No key repeats: each one is a key of the local_scope map. A debug_assert! checks it.
  • 40,000 names: 21.06 G to 1.25 G user instructions. bulma.css as a module (3,313 names): 404 M to 301 M. The output is the same for 17 fixtures and 2,000 random modules.
  • Verified: test/bundler/css/css-modules.test.ts (new case), bundler_loader.test.ts and esbuild/css.test.ts. Self-reviewed: 12 concerns raised, 12 addressed.

Behaviour change: none

Background

  • A CSS module exports an object that maps each local name to its scoped name. The linker builds it.
  • local_scope maps each local name of a file to its symbol.
  • Considered a key index in E::Object: +8 B on each object literal. Considered the composes visited set too: it costs 1 ms at 40,000 names, and bun test: Vitest compatibility #44833 deletes it.

Downsides

  • Rules that do not merge stay quadratic in the CSS minifier (css: hash full Ref identity in IdentOrRef::hash #30784). 20,000 such rules: 30.3 G to 25.7 G instructions.
  • Release .text grows 512 B. Peak RSS at 20,000 names: 75.8 MB to 85.2 MB (cause in Notes).
  • The new test passes on main too: the output is the same.
Notes

Method. Release builds (bun scripts/build.ts --profile=release) of main ff8ad61 and of this branch, linux x64, in one build directory. The command is bun build entry.js --outdir out. perf is not installed on the machine, so a small perf_event_open wrapper counts instructions:u of the process and all its threads. Each instruction count is the minimum of 3 runs (1 run for the 40,000 cells of the two slow shapes). CPU is user + sys from wait4, the minimum of 5 interleaved runs (3 runs for those cells). Peak RSS is ru_maxrss, the median of 9 runs.

Fixtures. Three module shapes and the same rules as plain CSS:

python3 - merge 40000 <<'EOF'
import sys
shape, n = sys.argv[1], int(sys.argv[2])
rule = {
    "merge": lambda i: f".name{i} {{ color: red }}",
    "nomerge": lambda i: f".name{i} {{ width: {i}px }}",
    "composes": lambda i: f".name{i} {{ composes: base; color: red }}",
}[shape]
css = [".base { color: blue }"] if shape == "composes" else []
css += [rule(i) for i in range(n)]
open("styles.module.css", "w").write("\n".join(css) + "\n")
open("styles.css", "w").write("\n".join(css) + "\n")
open("entry.js", "w").write("import styles from './styles.module.css';\nconsole.log(Object.keys(styles).length);\n")
open("plain.js", "w").write("import './styles.css';\n")
EOF
bun build entry.js --outdir out    # module
bun build plain.js --outdir outp   # plain CSS (use the merge shape)

User instructions, in millions: main, then this PR.

Names Rules that merge Rules that do not merge Each class has composes Plain CSS
3,000 208.7, 97.0 779.4, 667.7 797.6, 685.6 33.8
10,000 1,668.7, 313.0 7,946.6, 6,591.2 8,006.6, 6,650.8 100.2
20,000 5,229.6, 622.3 30,285.3, 25,678.1 30,406.8, 25,800.2 195.2
40,000 21,056.3, 1,247.4 121,168.5, 101,359.0 121,408.9, 101,598.4 384.5

CPU in ms: main, then this PR. The runs of one binary spread by 6 ms to 65 ms below 1 s of CPU, and by up to 161 ms above.

Names Rules that merge Rules that do not merge Each class has composes Plain CSS
3,000 39, 30 66, 60 71, 66 21
10,000 158, 63 466, 374 482, 389 31
20,000 497, 110 1,729, 1,347 1,740, 1,371 42
40,000 1,918, 214 6,767, 5,221 6,941, 5,204 77

Real stylesheets. Files from test/js/bun/css/files/, copied to styles.module.css and built with the same entry.js. User instructions in millions, minimum of 9 runs. The CPU of bulma.css is 59 ms, then 54 ms (minimum of 9), which is less than the spread of the runs.

File Names main This PR
bulma.css 3,313 403.5 301.2
tachyons.css 2,070 116.0 75.2
bootstrap-4.css 1,547 101.5 78.6
tailwind.css 1,010 78.7 69.1
uikit.css 932 105.6 97.3

The output of these five, and of materialize.css, foundation.min.css, animate.css and pico.css, is the same on both binaries.

Doubling. Instructions of the module build (rules that merge) minus the same rules as plain CSS. Each ratio is to the row above.

Names main Ratio This PR Ratio
5,000 428.7 M 106.1 M
10,000 1,567.8 M 3.66 213.0 M 2.01
20,000 5,034.9 M 3.21 428.1 M 2.01
40,000 20,671.6 M 4.11 862.8 M 2.02
80,000 86,754.7 M 4.20 1,742.8 M 2.02
160,000 298,801.9 M 3.44 3,541.7 M 2.03
320,000 not run 7,297.9 M 2.06
640,000 not run 15,337.6 M 2.10

The ratio of this PR rises above 2 because the loop still clears the composes bit set for each name (see "What stays").

Calls, counted with gdb breakpoints, for a module of 2,000 names. E::Object::put: 2,000 on main, 0 here. E::Object::as_property: 2,000 on main, 0 here. Grows of an AST-allocated vector (RawVecInner<AstAlloc>::finish_grow): 8,585 on main, 8,576 here. The reserve removes 9 grows of the property list. The plain CSS build makes 0, 0 and 486 of these calls on both binaries.

Same output. stdout, stderr, the exit code and each byte of each output file, compared between the two release builds.

  • 17 fixed cases, 0 differ: the 5,000 and 40,000 name modules with no flag, --format=esm, --format=cjs, --format=iife and --minify, a 3,000 name module with the composes shapes of the new test (as a JS import in two formats, and as the entry point), rules that do not merge, 2,000 classes that each compose 16 classes, and a module with three composes errors (exit code 1).
  • 2,000 random cases, 0 differ (3,456 output files). Each case has 1 to 3 module files with up to 120 names: classes, ids, keyframes, :global(), names such as __proto__ and foo-bar, repeated names, composes from the same file, from another file and from global, cycles through the exported name, and rare errors. The entry is a default import, a namespace import, a dynamic import, or the module file. Flags are random formats, targets and minify options. 277 cases end with an error, with the same diagnostics.

Size. size on the release bun: .text 106,483,760 B on main, 106,484,272 B here. nm -S on bun-profile: scan_imports_and_exports (the loop is inlined there) 45,328 B to 45,879 B. One drop function of 111 B is gone. No other symbol changes size.

Peak RSS in MB: main, then this PR. With the default settings: 3,000 names 29.0, 28.8. 10,000 names 48.8, 52.7. 20,000 names 75.8, 85.2. 40,000 names 124.9, 127.4. With MIMALLOC_PURGE_DELAY=0: 24.9, 24.9. 40.7, 39.5. 65.3, 62.4. 113.1, 106.0. So this PR allocates less. The likely cause of the default numbers: mimalloc returns freed pages after a delay, and main spends 0.1 s to 1.7 s more in the search before the build ends.

What stays, with its owner.

Overlap with open PRs. A three-way merge of this branch with #44833, #38966, #42058 and #45487 has no conflict in the three files of this PR. #44743 moves the whole block into a new function, so the two changed lines must move with it.

The test. css-module/ExportsObjectKeysAndComposedValues builds a module of 100 plain names around a chain, a diamond, a cycle through the exported name, two classes that compose one class, from global and two cross-file composes, a repeated class and an id that reuses a class name. It runs the bundle and compares the entries of the default export, in order. It takes about 1 s on the debug build. It passes on main, because this PR does not change the output. A test that fails on main needs a time limit or a counter in production code, and neither is wanted. bun/loader-css-module-proto-class-is-own-property covers the __proto__ flag that append_property sets like put. Its comment named E::Object::put, so this PR rewords it.

Suites, on the debug build. test/bundler/css/css-modules.test.ts 15 pass. test/bundler/esbuild/css.test.ts 56 pass, 1 todo. test/bundler/bundler_loader.test.ts 59 pass.

Why no key repeats. Parser::add_symbol_for_name (src/css/css_parser.rs:3009) is the only writer of local_scope. On a new key it makes one symbol whose original_name is that key. The loop reads the key back from that symbol. The debug_assert! checks that local_scope maps the key to the entry in hand.

Where the loop runs. generate_code_for_lazy_export has one call site (scanImportsAndExports.rs:517), reached from LinkerContext::link: bun build, Bun.build and production builds. Hot reload in the dev server skips the linker (bundle_v2.rs:6045).

Other designs. A key index inside E::Object makes each object literal 8 bytes larger (40 B to 48 B) and changes no line of this loop. A shared index type for all put loops repeats #37449 and #45368.

The loop that builds the exports object of a CSS module called
`E::Object::put` for each local name. `put` searches all earlier
properties for the key, so N names cost N(N-1)/2 comparisons. The keys
are the keys of `local_scope`, a map, so the search never finds one.

The loop now calls `E::Object::append_property` and reserves the
capacity once. A `debug_assert!` checks that each key is the
`local_scope` key of its entry. The output does not change.
@robobun

robobun commented Oct 18, 2026

Copy link
Copy Markdown
Collaborator Author

Status: ready for review.

How I reproduced it. I generated a styles.module.css of 40,000 classes (.nameN { color: red }) and a JS file that imports it, then ran bun build entry.js --outdir out on a release build of main ff8ad61. The build takes 1.92 s of CPU. The same rules as plain CSS take 0.08 s. Stack samples put the time in E::Object::put, in its search through as_property. The generator is in the Notes of the description.

PR: #45824

@coderabbitai

coderabbitai Bot commented Oct 18, 2026

Copy link
Copy Markdown
Contributor

Review in Change Stack →

No actionable comments were generated in the recent review. 🎉

ℹ️ Recent review info
⚙️ Run configuration
  • Configuration used: Repository: oven-sh/bun/.coderabbit.yaml
  • Review profile: ASSERTIVE
  • Plan: Advanced
  • Run ID: 9c4f6bb5-f0e5-40c7-95d6-7444772a8e9e
📥 Commits

Reviewing files that changed from the base of the PR and between fd539ca and 9b4d49b.

📒 Files selected for processing: 3
  • src/bundler/linker_context/generateCodeForLazyExport.rs
  • test/bundler/bundler_loader.test.ts
  • 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

The linker now reserves capacity and appends CSS-module exports after checking key-to-symbol mappings in debug builds. A new bundler test covers export-key ordering, deduplication, composed values, and hash consistency.

Changes

CSS-module exports

Layer / File(s) Summary
Linker export construction
src/bundler/linker_context/generateCodeForLazyExport.rs
The linker reserves capacity for local-scope entries, checks each key-to-symbol mapping in debug builds, and appends export properties.
Export ordering tests
test/bundler/css/css-modules.test.ts, test/bundler/bundler_loader.test.ts
A new test checks export-key ordering, repeated-name deduplication, composed-value ordering, and hash consistency. A comment now attributes export-object construction to the linker.

Suggested reviewers: jarred-sumner

Priority: ➖ Normal

Merge Risk: ⚪ Minimal · up to 9b4d4

No issue requiring a fix before merge was identified.

Pre-merge checks: Passed 4
Check name Status Explanation
Title check Passed The title clearly and concisely describes the main change: using append behavior to build CSS module exports without a per-name lookup.
Description check Passed The description provides a detailed explanation of the problem, fix, behavior impact, performance results, testing, and limitations. It does not use the exact template headings, but it includes the re…
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 provides a detailed explanation of the problem, fix, behavior impact, performance results, testing, and limitations. It does not use the exact template headings, but it includes the required information for what the PR does and how it was verified.

✨ Finishing Touches: 💡 1
🛠️ Fix failing CI checks: 💡
  • Commit to this branch
  • Create a new PR
  • 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.

LGTM — a small, behavior-preserving swap of put for append_property whose no-duplicate-keys precondition holds structurally.

What was reviewed:

  • Key uniqueness: add_symbol_for_name in src/css/css_parser.rs pushes a symbol only on a local_scope miss and sets original_name to the map key, and nothing else inserts into local_scope, so each iterated entry yields a distinct key regardless of the debug_assert! compiling out.
  • put vs append_property for a fresh key: same own_key_property_flags, same key loc (the value's loc is stmt.loc), so __proto__ still becomes an own property; the dropped ? is safe because put never actually returned an error.
  • ensure_total_capacity on Vec<Property, AstAlloc> is the infallible VecExt method; the reservation ahead of the fallible BitSet::init_empty only matters on OOM.
  • New test asserts exact Object.entries order and composed values in the existing css-modules.test.ts; it passes on main, which is expected for a refactor with no behavior change.
Extended reasoning...

The change touches one loop in the CSS-modules branch of the bundler linker's lazy-export codegen (src/bundler/linker_context/generateCodeForLazyExport.rs), replacing a linear-scan dedupe insert with an unconditional append plus a capacity reservation, and adds one itBundled test. It touches no security-sensitive surface. Approval rests on the uniqueness invariant being enforced by the CSS parser's symbol creation (one symbol per local_scope key), on the two insert paths producing identical properties for new keys, and on the small size of the diff; no CODEOWNERS entry covers the changed files and the hunt exited on dry_streak with no findings.

@robobun

robobun commented Oct 18, 2026 •

Copy link
Copy Markdown
Collaborator Author
Updated 12:11 PM PT - Oct 19th, 2026

✅ @robobun, your commit 66c7268d1f56fab77c3f5b48906431ef773693fc passed in Build #128930! 🎉


🧪   To try this PR locally:

bunx bun-pr 45824

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

bun-45824 --bun

@robobun

robobun commented Oct 18, 2026

Copy link
Copy Markdown
Collaborator Author
Updated 8:05 PM PT - Oct 17th, 2026

@robobun, your commit 9b4d49b is building: #127894

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