Skip to content

bundler: no code-splitting chunk for a file whose live parts print nothing - #40595

Open
robobun wants to merge 9 commits into
mainfrom
farm/de84f1f2/no-chunk-for-re-export-only-files
Open

robobun wants to merge 9 commits into
mainfrom
farm/de84f1f2/no-chunk-for-re-export-only-files

Conversation

@robobun

@robobun robobun commented Aug 27, 2026 •

Copy link
Copy Markdown
Collaborator

Problem

  • With --splitting, a file whose only live parts are re-exports of unwrapped bundled files (export { x } from "./impl.js", export * from, export * as ns from, or an import plus export {}) still got its own chunk when its set of entry points was unique. The chunk held only // @bun and the banner, and nothing imported it. A large split build in the wild ended up with 5 such chunks, each compiled to bytecode for nothing.
  • bundler: don't create a code-splitting chunk for a file with no live parts #40581 skips files with no live parts. These files have live parts: the re-export part is kept for the side effects of loading its target. But convert_stmts_for_chunk (src/bundler/linker_context/convertStmtsForChunk.rs) drops the statement, because the importers are bound to the target's symbols directly.

Fix

  • Tree shaking records which files print code. mark_part_live_step (src/bundler/LinkerContext.rs) runs once per part that becomes live. If the file's bit in the new LinkerGraph::files_with_code is not set yet, it classifies the part: a wrapped file prints its __esm / __commonJS closure, and otherwise part_prints_code checks the tag of the part's single statement (with tree shaking, every top-level statement is its own part). One tag check per live part, nothing per dead part, nothing once the bit is set, nothing without code splitting.
  • part_prints_code mirrors what convert_stmts_for_chunk drops outright: an import, export … from or export * of a bundled file with wrap == None, an import record the barrel optimization marked unused, and an export {} clause. export * from counts as printing when the record calls __reExport. Anything else counts as printing.
  • compute_chunks (src/bundler/linker_context/computeChunks.rs) puts every live file in its chunk, as before bundler: don't create a code-splitting chunk for a file with no live parts #40581, and records per chunk whether any of its files prints code. A code-splitting chunk where none does is skipped when the chunks are sorted, before any index is handed out. The per-file pass bundler: don't create a code-splitting chunk for a file with no live parts #40581 added is gone.
  • Correct because the files of a dropped chunk are never printed, and nothing references them: a re-export declares no symbol of its own, and an import or re-export of a wrapped file (which prints init_x() or require_x() and owns import_x) counts as printing. Keeping every file in its chunk matters for order: the walk that orders a chunk's parts and imports starts from the chunk's files, so an import() target that only re-exports must stay in its own chunk to load its targets in source order.
  • Verified: test/bundler/bundler_splitting.test.ts (NoChunkForReExportOnlyFiles is the report's repro, 5 output files; NoChunkForExportStarOrClauseOnlyFiles; ReExportOnlyEntryKeepsImportOrder and ReExportOnlyFileKeepsChunkOrder for the order). Also esbuild/splitting, esbuild/dce, esbuild/default, esbuild/importstar*, bundler_barrel, bundler_compile_splitting, bundler_edgecase, bundler_html, bun-build-api, metafile, bundler_cjs2esm, bundler_regressions, standalone, css-modules, bake/dev-and-prod (1043 tests).

Background

  • With code splitting, compute_chunks keys a chunk by the set of entry points that reach a file (entry_bits). Every live JS file with that key joins the chunk. A file with a unique key gets a chunk of its own.
  • Tree shaking keeps a part live for its statements or for the side effects of the files it imports. export { x } from "./impl.js" is kept for the second reason. When bundling, the linker resolves the re-export at link time: an importer of x binds to impl.js's symbol, so the statement itself prints nothing.
  • A wrapped file (WrapKind::Cjs or Esm) is one whose body runs lazily inside __commonJS(...) or __esm(...). An import of it prints a require_x() or init_x() call, so a re-export of a wrapped file does print code.
Notes

Output of the report's repro with this change (--splitting --target=bun --minify --banner): entry.js, entry-[hash].js (impl.js), a-*.js, b-*.js, e-*.js. Program output unchanged: hi a A / hi b / E.

Earlier revisions computed the answer in compute_chunks (a pass over every reachable file's parts, then a lazy scan when a file would start a chunk) and kept such files out of every chunk. The scan moved into tree shaking per review. Keeping the files out of their chunks changed evaluation order in two cases (a review finding): an import() target that only re-exports loaded its targets in chunk-index order, and inside a shared chunk a re-exported file kept only for its side effects could print after a sibling. Both are covered by the two order tests; the first fails on the previous revision.

Probes run by hand against the debug build, all with correct program output:

  • re-export of a CommonJS file that stays wrapped (module.exports = {...}; if (cond) module.exports.x = 1): the barrel's chunk is kept and exports import_impl.
  • export * from a wrapped CommonJS file: kept, prints __reExport(...).
  • re-export of an ESM file that is require()d elsewhere (--target=node, so it is __esm-wrapped): kept, prints init_wrapped(), and the importers carry the bare import "./chunk".
  • import "./side.js"; export {} as the only statements: no chunk; side.js still runs.
  • CommonJS files that the unwrap-to-ESM path turns into plain ESM: the barrel prints nothing and its chunk is dropped.

The post-print alternative (drop a chunk whose pieces are empty and remove the import "./chunk" statements that point at it) was not taken: chunk indices are baked into unique_key, cross_chunk_imports, css_chunks and the output file list by then, and the imports are already printed text.

Two tests in bun-build-api.test.ts (bytecode: function record on an encoder page boundary, bytecode: repeated builds don't retain the generated code) time out at the 5 s default on this debug build and pass with --timeout 120000; Bun.build can be called thousands of times passes alone and times out under the load of 22 other files. None use splitting. compile/HelloWorldWithProcessVersionsBun fails on a debug build because process.versions.bun is 1.4.1-debug; it passes with the release binary. None is related to this change.


[review] gate passed · iteration 3 · 4 files touched

fails on main (without fix)
ASAN without fix: 2 FAILED
$ BUN_DEBUG_QUIET_LOGS=1 bun scripts/build.ts --profile=debug --quiet test "--reporter=junit" "--reporter-outfile=/tmp/mechgate.xml" test/bundler/bundler_splitting.test.ts
bun test v1.4.1 (65362b53b)

test/bundler/bundler_splitting.test.ts:
(pass) bundler > splitting/DynamicImportCSSFile [1246.98ms]
(pass) bundler > splitting/DynamicImportMultipleCSSImports [952.06ms]
(pass) bundler > splitting/StaticAndDynamicCSSImports [1087.18ms]
(pass) bundler > splitting/NestedDynamicImportWithCSS [918.65ms]
(pass) bundler > splitting/SharedCSSBetweenChunks [978.84ms]
(pass) bundler > splitting/DynamicImportChainWithCSS [1044.52ms]
(pass) bundler > splitting/ConditionalDynamicImportWithCSS [841.44ms]
(pass) bundler > splitting/MultipleEntryPointsWithSharedCSS [1331.79ms]
(pass) bundler > splitting/DynamicImportWithOnlyCSSNoJS [977.81ms]
(pass) bundler > splitting/CircularDynamicImportsWithCSS [1071.63ms]
(pass) bundler > splitting/DeadDynamicImportTargetGetsNoChunk-cli [835.50ms]
(pass) bundler > splitting/DeadDynamicImportTargetGetsNoChunk-api [594.54ms]
(pass) bundler > splitting/DeadDynamicImportTargetNoSplittingReference [1283.26ms]
(pass) bundler > splitting/DeadDynamicImp
... (truncated)

release without fix: 3 FAILED
bun test v1.4.1-canary.1 (65362b53b)

test/bundler/bundler_splitting.test.ts:
(pass) bundler > splitting/DynamicImportCSSFile [33.48ms]
(pass) bundler > splitting/DynamicImportMultipleCSSImports [21.23ms]
(pass) bundler > splitting/StaticAndDynamicCSSImports [19.05ms]
(pass) bundler > splitting/NestedDynamicImportWithCSS [18.06ms]
(pass) bundler > splitting/SharedCSSBetweenChunks [22.77ms]
(pass) bundler > splitting/DynamicImportChainWithCSS [22.54ms]
(pass) bundler > splitting/ConditionalDynamicImportWithCSS [21.95ms]
(pass) bundler > splitting/MultipleEntryPointsWithSharedCSS [21.93ms]
(pass) bundler > splitting/DynamicImportWithOnlyCSSNoJS [18.16ms]
(pass) bundler > splitting/CircularDynamicImportsWithCSS [19.69ms]
(pass) bundler > splitting/DeadDynamicImportTargetGetsNoChunk-cli [20.96ms]
(pass) bundler > splitting/DeadDynamicImportTargetGetsNoChunk-api [14.88ms]
(pass) bundler > splitting/DeadDynamicImportTargetNoSplittingReference [25.37ms]
(pass) bundler > splitting/DeadDynamicImportTargetKeptWithoutTreeShaking [16.88ms]
(pass) bundler > splitting/LiveDynamicImportTargetStillGetsChunk [20.64ms]
(pass) bundler > splitting/DynamicImportChainLiveness [22.26ms]
(
... (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/mechgate.xml" test/bundler/bundler_splitting.test.ts
bun test v1.4.1 (65362b53b)

test/bundler/bundler_splitting.test.ts:
(pass) bundler > splitting/DynamicImportCSSFile [1088.73ms]
(pass) bundler > splitting/DynamicImportMultipleCSSImports [693.07ms]
(pass) bundler > splitting/StaticAndDynamicCSSImports [761.13ms]
(pass) bundler > splitting/NestedDynamicImportWithCSS [872.93ms]
(pass) bundler > splitting/SharedCSSBetweenChunks [1071.07ms]
(pass) bundler > splitting/DynamicImportChainWithCSS [722.96ms]
(pass) bundler > splitting/ConditionalDynamicImportWithCSS [677.26ms]
(pass) bundler > splitting/MultipleEntryPointsWithSharedCSS [1046.32ms]
(pass) bundler > splitting/DynamicImportWithOnlyCSSNoJS [760.69ms]
(pass) bundler > splitting/CircularDynamicImportsWithCSS [783.82ms]
(pass) bundler > splitting/DeadDynamicImportTargetGetsNoChunk-cli [729.64ms]
(pass) bundler > splitting/DeadDynamicImportTargetGetsNoChunk-api [502.81ms]
(pass) bundler > splitting/DeadDynamicImportTargetNoSplittingReference [1148.05ms]
(pass) bundler > splitting/DeadDynamicImpor
... (truncated)

release with fix: all passed
$ bun scripts/build.ts --profile=release
[configured] bun-profile → bun (stripped) in 794ms (unchanged)
ninja: Entering directory `/workspace/bun/build/release'
[1/22] gen generated_host_exports.rs
generated_host_exports.rs: 122 exports (host=5, lazy=10, generic=107, rust=0); 243 extern-C blocks audited
[2/22] gen JS modules (bundle-modules)
Preprocess modules (8617ms)
Bundle modules (85ms)
Postprocesss modules (554ms)
Bundle Functions (665ms)
Generate Code (36ms)

[9.97s] Bundled "src/js" for production
  2595 kb
  197 internal modules
  13 native modules
  50 internal functions across 16 files
[2/7] cargo bun_runtime → libbun_runtime.a
�[1m�[92m   Compiling�[0m bun_core v0.0.0 (/workspace/bun/src/bun_core)
�[1m�[92m   Compiling�[0m bun_errno v0.0.0 (/workspace/bun/src/errno)
�[1m�[92m   Compiling�[0m bun_ptr v0.0.0 (/workspace/bun/src/ptr)
�[1m�[92m   Compiling�[0m bun_boringssl_sys v0.0.0 (/workspace/bun/src/boringssl_sys)
�[1m�[92m   Compiling�[0m bun_safety v0.0.0 (/workspace/bun/src/safety)
�[1m�[92m   Compiling�[0m bun_base64 v0.0.0 (/workspace/bun/src/base64)
�[1m�[92m   Compiling�[0m bun_cares_sys v0.0.0 (/workspace/bun/src/cares_sys)
�[1m�[92m   Compil
... (truncated)
diff hotspot
src/bundler/LinkerContext.rs                |  39 +++++++++
 src/bundler/LinkerGraph.rs                  |   4 +
 src/bundler/linker_context/computeChunks.rs |  50 +++++------
 test/bundler/bundler_splitting.test.ts      | 124 ++++++++++++++++++++++++++++
 4 files changed, 189 insertions(+), 28 deletions(-)

gate history · 3 passed · 1 rejected · iteration 3

evidence per changed file
file                                         reads  edits  tests
src/bundler/LinkerContext.rs                    13     10      0
src/bundler/LinkerGraph.rs                       2      4      0
src/bundler/linker_context/computeChunks.rs     16     25      0
test/bundler/bundler_splitting.test.ts           3      6      0

@coderabbitai

coderabbitai Bot commented Aug 27, 2026 •

Copy link
Copy Markdown
Contributor

Review Change Stack

No actionable comments were generated in the recent review. 🎉

ℹ️ Recent review info
⚙️ Run configuration

Configuration used: Path: .coderabbit.yaml

Review profile: ASSERTIVE

Plan: Pro

Run ID: f0883995-6dae-4a6f-b6f3-e64c762ea86d

📥 Commits

Reviewing files that changed from the base of the PR and between 5c41615 and 1c161ef.

📒 Files selected for processing (3)
  • src/bundler/LinkerContext.rs
  • src/bundler/LinkerGraph.rs
  • src/bundler/linker_context/computeChunks.rs

Included review availability: Your plan provides up to 5 included reviews per hour; 0 remain after this review.


Walkthrough

Changes

Chunk splitting

Layer / File(s) Summary
Classify printable file output
src/bundler/LinkerGraph.rs, src/bundler/LinkerContext.rs
LinkerGraph tracks files with emitted code. LinkerContext marks live files based on printable parts and wrappers.
Create chunks from printable files
src/bundler/linker_context/computeChunks.rs, test/bundler/bundler_splitting.test.ts
Chunk eligibility uses files_with_code. Regression tests cover re-export-only files and runtime namespace output.

Suggested reviewers: jarred-sumner, sosukesuzuki, alii

Merge Risk: ⚪ Minimal · up to 1c161

The change prevents empty code-splitting chunks for re-export-only files while preserving emitted code for wrapped or side-effectful modules. No actionable merge-blocking risk remains beyond normal checks and review.

🚥 Pre-merge checks | ✅ 4
✅ Passed checks (4 passed)
Check name Status Explanation
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.
Title check ✅ Passed The title clearly and concisely describes the main change: preventing code-splitting chunks for files whose live parts produce no code.
Description check ✅ Passed The description explains the problem, implementation, expected behavior, edge cases, and verification results. It does not use the exact template headings, but it provides the required information and…
Full details: Description check

Explanation

The description explains the problem, implementation, expected behavior, edge cases, and verification results. It does not use the exact template headings, but it provides the required information and is substantially complete.


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

@robobun

robobun commented Aug 27, 2026 •

Copy link
Copy Markdown
Collaborator Author
Updated 12:53 AM PT - Aug 28th, 2026

❌ @robobun, your commit 469d433 has 1 failures in Build #107469 (All Failures):


🧪   To try this PR locally:

bunx bun-pr 40595

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

bun-40595 --bun

Comment thread src/bundler/LinkerContext.rs Outdated
Comment thread src/bundler/LinkerContext.rs Outdated
Comment thread src/bundler/linker_context/computeChunks.rs Outdated
Comment thread src/bundler/linker_context/computeChunks.rs Outdated
Comment thread src/bundler/LinkerContext.rs Outdated
Comment thread src/bundler/linker_context/computeChunks.rs Outdated
Comment thread src/bundler/linker_context/computeChunks.rs Outdated
@robobun

robobun commented Aug 27, 2026 •

Copy link
Copy Markdown
Collaborator Author

Status: ready for review.

Reproduced with the build from the report (bun build --splitting --target=bun --minify --banner='// LICENSE BANNER' over entry.js with three import() targets and two re-export-only barrels). On main the output has 7 files, two of them 26-byte banner-only chunks. With this branch it has 5 files and the program output is unchanged.

Current shape (2ed8679): tree shaking sets a per-file bit when a live part prints code. compute_chunks keeps every live file in its chunk and drops a code-splitting chunk only when none of its files has the bit. The earlier revisions kept such files out of their chunks, which a review found changes the order a re-export-only import() target loads its targets in; the two ReExportOnly* tests cover that.

CI on 469d433 (build 107469): every test lane that ran is green for this diff. The one red job is darwin aarch64, where the step failed outside the test runner (agent setup, also failing on main). The other entries are flakes that passed on retry. No bundler test fails.

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

@Jarred-Sumner Jarred-Sumner left a comment

Copy link
Copy Markdown
Collaborator

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

do not loop through every statement in every file to answer if it prints code. do something smarter.

Comment thread src/bundler/LinkerContext.rs Outdated
Comment thread src/bundler/LinkerGraph.rs Outdated
Comment thread src/bundler/linker_context/computeChunks.rs Outdated
@robobun

robobun commented Aug 27, 2026

Copy link
Copy Markdown
Collaborator Author

Reworked in 8dabdd3: no scan at chunk time anymore.

Tree shaking already visits each part exactly once when it becomes live (mark_part_live_step). That step now sets a per-file bit, LinkerGraph::files_with_code, the first time a live part of the file prints something: a wrapped file prints its closure, and otherwise the part's single statement is checked by tag (with tree shaking every top-level statement is its own part). An import, export … from or export * of an unwrapped bundled file, an unused barrel record, and an export {} clause do not count; everything else does. Once the bit is set the check is skipped for the rest of that file's parts, and nothing runs without code splitting.

compute_chunks then only reads the bit, and the per-file pass from #40581 is gone too. Same results: 5 output files for the repro, 1029 bundler tests green, and the wrapped-target probes (re-exports of __commonJS / __esm files) still keep their chunks.

…thing

With --splitting, a file whose only live parts are re-exports of unwrapped
bundled files (export { x } from "./impl.js", export * from, export * as,
or an import plus export {}) still got its own chunk when its set of entry
points was unique. Those statements print nothing: the importers bind to
the re-exported file directly. The chunk held only the banner, and nothing
imported it.

compute_chunks now counts a live part as contributing code only when one
of its statements survives convert_stmts_for_chunk: imports and re-exports
of unwrapped bundled files, unused import records and export clauses are
dropped at print time. The wrapper part of a wrapped file counts, since it
prints the __esm / __commonJS closure.
… chunk

Drop the pass over every reachable file's parts. compute_chunks now asks
file_prints_code only for a file whose chunk key has no chunk yet; a file
whose key already has one joins it as before. The check walks the file's
live parts through the bitset iterator and stops at the first part that
prints.
Instead of scanning a file's parts when it would start a chunk, set a per-file
bit in mark_part_live_step as each part becomes live: one tag check on the
part's single statement, skipped once the file's bit is set, and only with code
splitting. compute_chunks reads the bit and otherwise goes back to the #40581
flow: a file without it joins no chunk.
@robobun
robobun force-pushed the farm/de84f1f2/no-chunk-for-re-export-only-files branch from 1c161ef to 9ff5baf Compare August 28, 2026 00:12

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

This pull request has now been reviewed several times and this review found new issues. Before patching these one by one, step back: would one root-cause fix close several of them? Is the pull request's scope growing with each push? Prefer root-cause fixes, keep scope fixed, and note out-of-scope improvements as follow-ups.

Comment thread src/bundler/linker_context/computeChunks.rs Outdated

@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 new issues

No new issues were found in this update; 1 finding from earlier reviews is still open above.

@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 new issues

No new issues were found in this update; 1 finding from earlier reviews is still open above.

…prints

The walk that orders a chunk's parts and imports starts from the chunk's
files, so a file whose live parts print nothing must still join the chunk:
an import() target that only re-exports otherwise loads its targets in
chunk-index order instead of source order, and inside a shared chunk the
files it re-exports could print in a different order.

compute_chunks therefore puts every live file in its chunk again and records
per chunk whether any file prints code; a code-splitting chunk where none
does is skipped when the chunks are sorted, before any index is handed out.
Comment thread src/bundler/linker_context/computeChunks.rs Outdated

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

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