Skip to content

bundler: list the chunks an entry point loads through other chunks in --metafile-md - #42755

Open
robobun wants to merge 2 commits into
mainfrom
robobun/b6eb7485/metafile-md-transitive-chunks
Open

robobun wants to merge 2 commits into
mainfrom
robobun/b6eb7485/metafile-md-transitive-chunks

Conversation

@robobun

@robobun robobun commented Sep 14, 2026 •

Copy link
Copy Markdown
Collaborator

Problem

  • With --splitting, --metafile-md leaves out a chunk that only another chunk imports. The repro (Notes) writes three chunks: meta.md names two.
  • Regression of 1.4.1: bundler: fold code-splitting chunks that always load together, drop dead cross-chunk imports #40506 removed the bare import "./chunk.js" that an entry point's file had for a chunk that runs nothing.
  • "Loads these chunks" in generate_markdown (src/bundler/linker_context/MetafileBuilder.rs:1162) reads one level of outputs[entry].imports.

Fix

  • loaded_chunks walks the import-statement edges of outputs[..].imports from the entry point's output file. An indirect chunk gets via and its importer.
  • Chunks that the file loads with import() or require() follow, as before. The walk skips them: each has its own entry section.
  • meta.json and the bundles do not change.
  • Verified: test/bundler/metafile.test.ts (five new tests, main 449877f fails four), test/bundler/esbuild/metafile.test.ts. Self-reviewed: 6 concerns raised, 4 addressed (Notes).

Background

  • outputs[file].imports lists the files that file imports, each with a kind (import-statement, dynamic-import, require-call). --metafile-md renders that JSON.
  • With --splitting, shared code moves to chunks. An import() target is a chunk with its own "Entry" section.
  • Considered the bare import again: it puts back what bundler: fold code-splitting chunks that always load together, drop dead cross-chunk imports #40506 removed. Considered indirect chunks in outputs[..].imports: the JSON would list imports the file does not have.

Downsides

  • Tools that parse the list see a new order: import-statement chunks, then lazy chunks (before: JSON order). A chunk imported with two kinds is one line.
  • meta.md grows where chunks were missing: 210,864 to 1,703,090 bytes for 200 entry points over 200 chained chunks (1.4.0: 1,225,641).
  • Binary: .text +3,584 bytes, file size unchanged (size, release builds). A report with no missing chunk: same bytes, not slower (Notes).
Notes

Repro:

mkdir t && cd t && mkdir src
printf 'import { v1 } from "./m1.ts";\nimport { v2 } from "./m2.ts";\nconsole.log(v1 + v2);\n' > src/m0.ts
printf 'import c from "./c3.cjs";\nexport const v1 = "V1" + c.v3;\nconsole.log(v1);\n' > src/m1.ts
printf 'import c from "./c3.cjs";\nexport const v2 = "V2" + c.v3;\nconsole.log(v2);\n' > src/m2.ts
printf 'module.exports = { v3: "V3" };\n' > src/c3.cjs
bun build --splitting --target=bun --outdir=out --metafile=meta.json --metafile-md=meta.md src/m0.ts src/m1.ts src/m2.ts
ls out | grep 'm0-'                            # three chunks
grep -o 'm0-[a-z0-9]*\.js' meta.md | sort -u   # before: two names. after: three
build chunks that meta.md names
1.3.14, 1.4.0 3 of 3
1.4.1, 1.4.3-canary, 1.4.4, debug build of main 449877f 2 of 3
this PR, debug build on 449877f 3 of 3

### Entry: src/m0.ts before:

**Loads these chunks** (code-splitting):
- `./m0-5begjve3.js` (195 bytes, import-statement)
- `./m0-wgk908vp.js` (195 bytes, import-statement)

After:

**Loads these chunks** (code-splitting):
- `./m0-5begjve3.js` (195 bytes, import-statement)
- `./m0-wgk908vp.js` (195 bytes, import-statement)
- `./m0-8x1hvvzq.js` (1.40 KB, import-statement, via `./m0-5begjve3.js`)

Where the regression comes from:

  • In 1.4.0, out/m0.js has the statement import"./m0-q20h2fjv.js"; for the chunk that holds c3.cjs. So outputs["./m0.js"].imports has three entries. In 1.4.1 the statement is gone and the list has two entries.
  • The skip is at src/bundler/linker_context/computeCrossChunkDependencies.rs:522 ("Nothing is used from it; skip it if loading it runs nothing"). git blame gives it to bundler: fold code-splitting chunks that always load together, drop dead cross-chunk imports #40506. That change is intended: the file does not need the statement. The JSON is right, and the report has to follow the edges.

About the change:

  • Bun.build({ metafile: { markdown } }) uses the same writer and has the same gap. Four of the new tests use it.
  • The ESM-only fixture in the Bun.build tests shows the same gap without .cjs or --target=bun.
  • Why the walk stops at an import() or require(): the first version of this change walked every edge. When an entry point takes the code that it shares with its lazy routes, each route's chunk imports the entry point's output file, and that file import()s every route. So the section of each route listed all other routes as dynamic-import, via ./entry.js: N routes gave about N x N lines, and to load one route does not load the others. Since bundler(splitting): one edge rule for both walks; no chunk imports an unhashed entry #44023 an entry point takes that code only with [hash] in naming.entry, or when it is an HTML entry point. Each lazy chunk is an entry point in the JSON and has its own section, so the report still names every chunk.
  • Two kinds of line that already existed can move or merge. An output that imports chunks both ways now lists the import-statement chunks before the dynamic-import and require-call chunks (before: JSON order, lazy first). An output that imports one chunk with two kinds now lists it once: under import-statement if that is one of the kinds, else under the first kind in the JSON (before: one line per kind).
  • An index of outputs by path replaces the linear outputs_obj.get scan per listed chunk. The walk does one lookup per edge, so the scan would be quadratic in the number of outputs.
  • The per-chunk sizes are there. This PR adds no total line, to keep the change to the reported gap.
  • New tests: the CLI repro above (every key of outputs is named in the report), the ESM-only shape through Bun.build, a dynamic-import and a require-call chunk (listed last, not walked), and two lazy routes that import the entry point's output file back. The last test passes before and after. It pins that the section of one route does not list the other route.

Rebase onto 449877f:

Measurements (Linux x64):

  • Binary, size on release builds of 449877f and of this PR: text 107,556,528 to 107,560,112. .text 82,164,617 to 82,168,201. No other section changes. The file is 107,730,656 bytes in both.
  • No chunk missing: 200 entry points, e<i>.js imports e<i+1>.js, every file runs code when it loads (399 outputs). meta.md is 1,130,719 bytes in both builds. bun build --splitting --metafile-md, median of 60 interleaved runs: 128.7 ms on main, 122.8 ms with this PR. The same build without --metafile-md: 101.4 ms and 102.5 ms. For one binary, the median of the even runs and of the odd runs differ by 2 to 5 ms.
  • Chunks missing: 200 entry points, e<i>.js imports lib<i>.js, lib<i>.js imports lib<i+1>.js, no lib file runs code when it loads (399 outputs). main prints 200 chunk lines. This PR prints 20,099, and 19,899 of them carry via. 1.4.0 printed 20,099. meta.md: 210,864 bytes on main, 1,703,090 with this PR, 1,225,641 on 1.4.0. Build time, median of 60 interleaved runs: 66.2 ms on main, 75.0 ms with this PR.
  • 1 entry point, 40 import() routes, 300 shared modules, --target=browser (174 outputs): 435 chunk lines and 197,277 bytes on main, 444 chunk lines (9 with via) and 198,002 bytes with this PR.
  • valgrind, perf, bloaty and strace were not available for these runs, so there are no instruction counts and no allocation counts. From the code: each report allocates one map of the outputs, and each entry section allocates one Vec and one set for the walk. A build without --metafile-md does not call generate_markdown.

Checks:

  • Self-review of the source change (first push). Addressed: the route blow-up above, a three-line doc comment (now one line each), no test required a size on a chunk line (the helper now does), and a wrong sentence in this description about merged kinds. Not changed: the last test passes on main too (kept, see above), and the test helper names a chunk that holds only runtime code chunk() (that chunk exists for browser targets, the tests build for bun).
  • cargo clippy -p bun_bundler --no-deps, cargo fmt --all -- --check and prettier --check are clean.

no test proof · iteration 3 · platform-specific test(s) that do not run on this machine, deferring to CI, which covers all platforms: test/bundler/metafile.test.ts

@coderabbitai

coderabbitai Bot commented Sep 14, 2026 •

Copy link
Copy Markdown
Contributor

Warning

Review limit reached

You've used all free OSS reviews for now. Wait for the free limit to reset to keep reviewing this public repository.

Next included review available in 6 minutes.

Check out review usage here.

View limit details

Limit details: You’ve used all 10 included reviews currently available.

Learn how review limits work.

Review configuration:

Run configuration
Commits

Reviewing files that changed from the base of the PR and between 449877f and 67746b0.

Files selected for processing: 2
  • src/bundler/linker_context/MetafileBuilder.rs
  • test/bundler/metafile.test.ts
  • 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 Sep 14, 2026 •

Copy link
Copy Markdown
Collaborator Author

Status: rebased onto main (449877f), ready for review.

Reproduced with the commands under Notes in the description. bun build --splitting writes three m0-*.js chunks. 1.3.14 and 1.4.0 name all three in meta.md. 1.4.1, 1.4.4 and a debug build of main name two. With this change the report names all three.

test/bundler/metafile.test.ts has five new tests. A debug build of main fails four of them, and all pass with this change. The fifth pins that the section of one lazy route does not list the other routes. Its fixture now has [hash] in the entry name, because #44023 changed the chunk layout for a plain name.

@robobun
robobun force-pushed the robobun/b6eb7485/metafile-md-transitive-chunks branch from 79c132b to 2f918ee Compare September 14, 2026 22:55

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

… --metafile-md

The "Loads these chunks" list of each entry point in the markdown
metafile read only the imports of the entry point's own output file. A
chunk that only another chunk imports was not in the report.

Walk the import-statement edges of outputs[..].imports from the entry
point's output file until no new chunk turns up, and list every chunk
that loads with the entry point once. Name the importing chunk for a
chunk that the output file does not import itself. The chunks that the
output file loads with import() or require() follow, as before. They are
not walked, because each has an entry section of its own.
An entry point takes the code that it shares with its import() targets
only with [hash] in its name (#44023). The test "does not list what an
imported entry point loads on demand" needs that layout: the chunk of a
lazy route imports the output file of the entry point.

Cast the metafile option of the helper, as the other tests in this file
do. bun-types declares the option as a boolean.
@robobun
robobun force-pushed the robobun/b6eb7485/metafile-md-transitive-chunks branch from b35c60a to 67746b0 Compare October 26, 2026 12:35
@robobun

robobun commented Oct 26, 2026 •

Copy link
Copy Markdown
Collaborator Author
Updated 7:20 AM PT - Oct 26th, 2026

✅ @robobun, your commit 67746b0044af4ee3ec2594eab7df343e82a2efe3 passed in Build #133079! 🎉


🧪   To try this PR locally:

bunx bun-pr 42755

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

bun-42755 --bun

@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 self-contained change to the markdown metafile report, with tests that pin the new output shape.

What was reviewed:

  • The loaded_chunks walk: the seen set is seeded with the entry's own path, so cycles (a route chunk importing the entry's output back) terminate, and only import-statement edges are followed, so import()/require() targets are listed but not walked.
  • StringHashMap::get_or_put/put semantics match how the same file already uses them; ImportKind::Stmt.label() is b"import-statement", the kind string the metafile emits.
  • The writer's size branch: a path missing from outputs (externals) still prints without a size, same as before; the retained expect("int cast") only sees bytes the bundler itself wrote.
  • Tests use tempDir + using, drain pipes with Promise.all, assert stderr before exit code, and compare whole sections with toEqual.
Extended reasoning...

The diff touches one function family in src/bundler/linker_context/MetafileBuilder.rs (the "Loads these chunks" section of generate_markdown, replaced by a breadth-first helper over outputs[..].imports plus an outputs-by-path index) and adds five tests in test/bundler/metafile.test.ts; meta.json and emitted bundles are untouched. It is report-generation only, with no security-sensitive surface. The change is small (~70 lines of Rust), reads data the bundler itself produced, follows the file's existing StringHashMap patterns, and no CODEOWNERS entry covers the changed paths. The bug hunt ran dry with no findings, and the new tests cover the transitive case, both lazy kinds, and the no-over-listing case.

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