Skip to content

bundler: compare CSS import orders before two entry points share a stylesheet - #46357

Open
robobun wants to merge 2 commits into
mainfrom
robobun/87de9fa0/css-chunk-key-compares-import-order
Open

robobun wants to merge 2 commits into
mainfrom
robobun/87de9fa0/css-chunk-key-compares-import-order

Conversation

@robobun

@robobun robobun commented Oct 21, 2026 •

Copy link
Copy Markdown
Collaborator

Problem

  • Two entry points can get one stylesheet although their CSS differs. In html/SeparateCSSWhenImportConditionsDiffer the second page needs @layer b; first, and it gets @media screen{@layer b;}.
  • With CSS chunking on, a CSS entry point that has this collision gets no output file: Bundle was not written to disk: out/s.css.
  • The cause: compute_chunks keys a CSS chunk by CssImportOrder::hash (src/bundler/Chunk.rs:1462). The hash leaves out the import conditions, so css_chunks.get_or_put (computeChunks.rs:183, :263) returns the chunk of another import order.

Fix

  • css_chunk_key compares the import order of the chunk that has the hash. If the orders differ, it takes the next free key.
  • Correct because entry points share a chunk only when their import orders are equal. html/SharedCSSProductionMultipleEntries passes.
  • Verified: test/bundler/bundler_html.test.ts (3 new cases, each fails on main).
  • Self-reviewed: 3 concerns raised, 3 addressed.

Background

  • An import order is the list that the bundler prints as one stylesheet: files, external @import rules, and lists of @layer names. Each entry has the conditions of its @import chain.
  • Considered a hash that includes the conditions: about 15 CSS types have no hasher, and a hash is not an identity.

Downsides

  • A build that had this collision writes one more stylesheet.
  • Each CSS chunk costs one more map lookup: 43 to 110 instructions, no allocation. The binary grows by 1,773 bytes.
  • Not fixed: a CSS entry point whose import order equals the stylesheet of an earlier page still gets no output file.
Notes

Report. No user reported this. A fuzz run over random CSS import graphs with two entry points found it.

The three cases. Each fails on main and on Bun 1.4.3.

  • html/SeparateCSSWhenImportConditionsDiffer: the import orders differ in the media query of one layer list. Without screen media the layer order is a, b on the first page and b, a on the second page.
  • html/SeparateCSSWhenLayerNamesDiffer: the import orders differ in the names of one layer list, x.y, z against x, yz. CssImportOrder::hash writes both as x.yz.. The layer yz wins on the first page and the layer z wins on the second page.
  • html/SeparateCSSForCSSEntryPointProduction: a page and a CSS entry point. The stylesheet of the page starts with @layer a;. The entry point needs @media print{@layer a;}, and main writes no s.css.

Not changed. CssImportOrder::hash stays as it is. A change there moves the key of each chunk, and the key sorts the CSS chunks in the output.

Still open. bun build --production page.html s.css, where the page links only s.css, writes no s.css and exits with 0. The two import orders are equal, so the entry point shares the chunk of the page, and the page owns that chunk. With the entry points in the other order the page links ./s.css. This is older than this PR and is not a collision. #38341 fills the metafile inputs of a chunk that a CSS entry point creates.

Other suites. test/bundler/esbuild/css.test.ts, bundler_splitting.test.ts, esbuild/splitting.test.ts, bundler_html_server.test.ts, html-import-manifest.test.ts and css/css-modules.test.ts pass on a debug build.

Measurements. x64 release builds, nm -S and a qemu instruction trace of css_chunk_key.

  • Size: css_chunk_key 1,655 bytes, compute_chunks 46,141 to 46,259 bytes. The build pair is from the same hunks on top of 0ffddcd, one day before this merge base. A release build of main was not repeated for this PR.
  • Instructions inside css_chunk_key: 43 for one CSS entry point, 13,113 for 200 CSS entry points (66 each), 5,384 for 50 JS entry points that import one stylesheet (108 each, with the compare).

Related. A change to the cascade layer order of bundled @import rules is in preparation on top of #45675. It adds layer lists with conditions, so it makes this collision more likely. This PR does not need it.


[human-review] gate passed · iteration 0 · 4 files touched

fails on main (without fix)
ASAN without fix: 3 FAILED
$ BUN_DEBUG_QUIET_LOGS=1 bun scripts/build.ts --profile=debug --quiet test "--reporter=junit" "--reporter-outfile=/tmp/pr_gate.xml" test/bundler/bundler_html.test.ts
bun test v1.4.4 (367d939d9)

test/bundler/bundler_html.test.ts:
(pass) bundler > html/basic [605.16ms]
(pass) bundler > html/implicit-relative-paths [158.38ms]
(pass) bundler > html/multiple-assets [200.58ms]
(pass) bundler > html/image-hashing [153.84ms]
(pass) bundler > html/external-assets [143.33ms]
(pass) bundler > html/mixed-assets [182.83ms]
(pass) bundler > html/js-imports [161.55ms]
(pass) bundler > html/script-that-is-also-an-entry-point [142.47ms]
(pass) bundler > html/css-imports [187.59ms]
(pass) bundler > html/multiple-entries [236.76ms]
(pass) bundler > html/script-src-is-entry-chunk-with-splitting [257.37ms]
(pass) bundler > html/modulepreload-links [240.56ms]
(pass) bundler > html/shared-chunks [322.74ms]
(pass) bundler > html/js-importing-html [199.53ms]
// in/entry.js
console.log("Loaded HTML!");

// in/2nd.js
console.log("2nd");

(pass) bundler > html/js-importing-html-and-entry-point-side-effect-import [211.66ms]
(pass) bundler > html/js-importing-html-and-entry-point-default-import
... (truncated)

release without fix: 4 FAILED
bun test v1.4.3-canary.1 (367d939d9)

test/bundler/bundler_html.test.ts:
(pass) bundler > html/basic [79.66ms]
(pass) bundler > html/implicit-relative-paths [28.55ms]
(pass) bundler > html/multiple-assets [14.49ms]
(pass) bundler > html/image-hashing [12.95ms]
(pass) bundler > html/external-assets [10.00ms]
(pass) bundler > html/mixed-assets [13.76ms]
(pass) bundler > html/js-imports [14.09ms]
(pass) bundler > html/script-that-is-also-an-entry-point [16.57ms]
(pass) bundler > html/css-imports [42.52ms]
(pass) bundler > html/multiple-entries [26.85ms]
(pass) bundler > html/script-src-is-entry-chunk-with-splitting [22.40ms]
(pass) bundler > html/modulepreload-links [17.59ms]
(pass) bundler > html/shared-chunks [49.20ms]
(pass) bundler > html/js-importing-html [13.39ms]
// in/entry.js
console.log("Loaded HTML!");

// in/2nd.js
console.log("2nd");

(pass) bundler > html/js-importing-html-and-entry-point-side-effect-import [18.46ms]
(pass) bundler > html/js-importing-html-and-entry-point-default-import-fails [35.27ms]
(pass) bundler > html/circular-js-html [16.41ms]
(pass) bundler > html/css-only [16.07ms]
(pass) bundler > html/absolute-paths [24.08ms]
(pass) bundler > h
... (truncated)
passes on PR (with fix)
ASAN with fix: all passed
$ BUN_DEBUG_QUIET_LOGS=1 bun scripts/build.ts --profile=debug --quiet test "--reporter=junit" "--reporter-outfile=/tmp/pr_gate.xml" test/bundler/bundler_html.test.ts
bun test v1.4.4 (367d939d9)

test/bundler/bundler_html.test.ts:
(pass) bundler > html/basic [633.42ms]
(pass) bundler > html/implicit-relative-paths [178.63ms]
(pass) bundler > html/multiple-assets [190.61ms]
(pass) bundler > html/image-hashing [179.08ms]
(pass) bundler > html/external-assets [144.90ms]
(pass) bundler > html/mixed-assets [189.81ms]
(pass) bundler > html/js-imports [151.24ms]
(pass) bundler > html/script-that-is-also-an-entry-point [169.54ms]
(pass) bundler > html/css-imports [237.75ms]
(pass) bundler > html/multiple-entries [384.73ms]
(pass) bundler > html/script-src-is-entry-chunk-with-splitting [461.09ms]
(pass) bundler > html/modulepreload-links [263.13ms]
(pass) bundler > html/shared-chunks [488.14ms]
(pass) bundler > html/js-importing-html [237.13ms]
// in/entry.js
console.log("Loaded HTML!");

// in/2nd.js
console.log("2nd");

(pass) bundler > html/js-importing-html-and-entry-point-side-effect-import [273.19ms]
(pass) bundler > html/js-importing-html-and-entry-point-default-import
... (truncated)

release with fix: all passed
$ bun scripts/build.ts --profile=release
[configured] bun-profile → bun (stripped) in 3389ms (unchanged)
ninja: Entering directory `/workspace/bun/build/release'
[1/170] cxx obj/src/jsc/bindings/ProcessBindingUV.cpp.o
[2/170] cxx obj/src/jsc/bindings/ProcessBindingConstants.cpp.o
[3/170] cxx obj/src/jsc/bindings/JSBuffer.cpp.o
[4/170] cxx obj/src/jsc/bindings/sqlite/JSSQLStatement.cpp.o
[5/170] cxx obj/src/jsc/bindings/webcore/HTTPParsers.cpp.o
[6/170] cxx obj/src/jsc/bindings/highway_xml.cpp.o
[7/170] cxx obj/src/jsc/bindings/highway_json.cpp.o
[8/170] cxx obj/src/jsc/bindings/BunObject.cpp.o
[9/170] cxx obj/src/jsc/bindings/napi.cpp.o
[10/170] cxx obj/src/jsc/bindings/webcore/JSMIMEType.cpp.o
[11/170] cxx obj/src/jsc/bindings/webcore/JSDOMPromiseDeferred.cpp.o
[12/170] cxx obj/src/jsc/bindings/webcore/JSMessageEventCustom.cpp.o
[13/170] cxx obj/src/jsc/bindings/image_resize.cpp.o
[14/170] cxx obj/src/jsc/bindings/highway_sourcemap.cpp.o
[15/170] cxx obj/src/jsc/bindings/webcore/JSWasmStreamingCompiler.cpp.o
[16/170] cxx obj/src/jsc/bindings/webcore/streams/CrossRealmTransform.cpp.o
[17/170] cxx obj/src/jsc/bindings/sqlite/NodeSqlite.cpp.o
[18/170] cxx obj/src/js
... (truncated)
diff hotspot
src/bundler/Chunk.rs                               |   2 +-
 src/bundler/linker_context/computeChunks.rs        |  30 +++++-
 .../linker_context/findImportedFilesInCSSOrder.rs  |  24 +++++
 test/bundler/bundler_html.test.ts                  | 108 +++++++++++++++++++++
 4 files changed, 159 insertions(+), 5 deletions(-)

gate history · 1 passed · 0 rejected · iteration 0

evidence per changed file
file                                                      reads  edits  tests
src/bundler/Chunk.rs                                          1      0     20
src/bundler/linker_context/computeChunks.rs                   0      0     17
…c/bundler/linker_context/findImportedFilesInCSSOrder.rs      8      6     26
test/bundler/bundler_html.test.ts                             0      0     13

…ylesheet

compute_chunks keys each CSS chunk by a hash of its import order.
CssImportOrder::hash leaves the import conditions out, and it writes
the layer names "x.y, z" and "x, yz" as the same bytes. Two entry
points whose import orders differ only there got one stylesheet. With
CSS chunking on, a CSS entry point with the hash of an earlier chunk
got no output file.

css_chunk_key now compares the import order of the chunk that has the
hash. If the orders differ, it takes the next free key. Entry points
with equal import orders share one chunk as before.
@robobun

robobun commented Oct 21, 2026 •

Copy link
Copy Markdown
Collaborator Author

Status: ready for review. The diff is green.

Reproduced on Bun 1.4.3-canary.1+367d939d9 with the three new cases of test/bundler/bundler_html.test.ts:

  • html/SeparateCSSWhenImportConditionsDiffer: second.html links the stylesheet of first.html, which starts with @media screen{@layer b;}. Its own import order starts with @layer b;.
  • html/SeparateCSSWhenLayerNamesDiffer: second.html links a stylesheet that starts with @layer x.y,z;. Its own import order starts with @layer x,yz;.
  • html/SeparateCSSForCSSEntryPointProduction: bun build --production ./page.html ./s.css writes no s.css.

With this branch the 28 tests of the file pass on a debug build. The three new cases fail without the change and pass with it.

CI: build 130197 has 180 of 181 jobs green. The one red job is test/js/bun/css/nth-anplusb-ident.test.ts on debian 13 x64-asan. That test counts allocator heaps in the CSS minifier (expected 1, received 2). This diff does not touch that code, and the same test was flaky on macOS aarch64 in the same build.

@robobun

robobun commented Oct 21, 2026 •

Copy link
Copy Markdown
Collaborator Author
Updated 2:56 PM PT - Oct 21st, 2026

❌ @robobun, your commit e620e0b has 1 failures in Build #130197 (All Failures):


🧪   To try this PR locally:

bunx bun-pr 46357

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

bun-46357 --bun

@coderabbitai

coderabbitai Bot commented Oct 21, 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: 08a6735d-8ae7-49bf-a4b1-a5cdb83ad4d8
📥 Commits

Reviewing files that changed from the base of the PR and between 9634520 and e620e0b.

📒 Files selected for processing: 4
  • src/bundler/Chunk.rs
  • src/bundler/linker_context/computeChunks.rs
  • src/bundler/linker_context/findImportedFilesInCSSOrder.rs
  • test/bundler/bundler_html.test.ts

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

Walkthrough

CSS chunk-key generation now checks import-order equality when hashes match and probes subsequent keys for differing orders. Regression tests cover distinct CSS output for differing media conditions and layer names.

Changes

CSS chunk key collision handling

Layer / File(s) Summary
Import-order comparison
src/bundler/Chunk.rs, src/bundler/linker_context/findImportedFilesInCSSOrder.rs
Added comparison for CSS import-order entries and conditions. Clarified that the hash can collide for different import orders.
Collision-aware chunk keys
src/bundler/linker_context/computeChunks.rs, test/bundler/bundler_html.test.ts
CSS entry-point and JavaScript-associated CSS chunks probe occupied keys when the import orders differ. Tests verify separate stylesheets for differing media conditions and layer names, including production-mode CSS entry points.

Suggested reviewers: jarred-sumner

Priority: ➖ Normal

Merge Risk: ⚪ Minimal · up to be832

Merge Risk: ⚪ Minimal · up to be832

The CSS chunk-key change is ready to merge after normal checks; no outstanding defect was identified.

Pre-merge checks: Passed 4
Check name Status Explanation
Title check Passed The title clearly summarizes the main change: comparing CSS import orders before sharing a stylesheet between entry points.
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.
Description check Passed The description clearly explains the problem, implementation, verification steps, regression tests, tradeoffs, and known limitations. It does not use the template headings exactly, but it provides the…
Full details: Description check

Explanation

The description clearly explains the problem, implementation, verification steps, regression tests, tradeoffs, and known limitations. It does not use the template headings exactly, but it provides the required information in equivalent sections.

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

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 the linear probe in css_chunk_key: it only advances past an occupied key whose stored order differs, so a non-chunking CSS entry (keyed by entry bits) can only merge with a content-keyed chunk when both the 64-bit keys and the full import orders match, which is the same 2^-64 event the base already had. I also checked whether a second entry that joins an existing CSS chunk should update that chunk's entry_bits — the found_existing skip is unchanged from the base at both call sites, so it is not something this PR introduces.

Extended reasoning...

The change touches the bundler's CSS chunk computation (computeChunks.rs, Chunk.rs, findImportedFilesInCSSOrder.rs) and adds three itBundled tests; it touches no security-sensitive surface. Two confirmed findings are posted inline, so a human look is already signalled; this note only records the probe-chain and entry_bits concerns that were examined and ruled out as unchanged from the base.

@robobun

robobun commented Oct 21, 2026

Copy link
Copy Markdown
Collaborator Author

Both inline findings are correct, and both are older than this PR. Replies are in the threads.

  • A CSS entry point whose import order equals the stylesheet of an earlier page still gets no output file. The share is correct there, and the page owns the chunk. This PR does not change it. The PR body lists it under Downsides as not fixed.
  • The empty metafile inputs of a chunk that a CSS entry point creates: bundler: read the metafile size of a copied input from its asset file #38341 has that fix.

No code change in this round.

@robobun

robobun commented Oct 23, 2026

Copy link
Copy Markdown
Collaborator Author

A follow-up for the case that this PR lists under "Not fixed" (a CSS entry point whose import order equals the stylesheet of an earlier entry point gets no output file) is in #46599. It is stacked on this branch, because it is correct only when a hit in css_chunks means equal import orders. It is a separate PR because it needs a ruling on the output (one shared stylesheet or two), and this PR does not.

@robobun

robobun commented Oct 27, 2026

Copy link
Copy Markdown
Collaborator Author

A note from #36974, which touched the same function in its first design.

#36974 no longer changes CssImportOrder::hash. Its first design put a length prefix on each layer name, and that hid the @layer x.y, z against @layer x, yz case. That change is gone. With a.css = @layer x.y, z; and b.css = @layer x, yz; in two pages, bun build first.html second.html writes one stylesheet on main and with #36974, and second.html links the stylesheet of first.html. This PR is now the only one that fixes that case (html/SeparateCSSWhenLayerNamesDiffer).

#36974 also removes the sort of CSS chunks by key. After it, the key that css_chunk_key picks tells two chunks apart and does not decide where a chunk is in the output. The two PRs change different hunks of computeChunks.rs.

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