bundler: fold code-splitting chunks that always load together, drop dead cross-chunk imports

Merged
JA
3mo ago
+1850-38

What does this PR do?

Builds on #40430 (cherry-picked here, credit @sosukesuzuki) and makes the parts of it that never cost an entry point anything always on, plus removes cross-chunk syntax that --splitting emitted for nothing. --min-chunk-size stays opt-in and keeps meaning "also hoist small side-effect-free chunks into a chunk more entries load".

Always on with --splitting now:

  1. Chunks that always load together become one chunk. Code shared by an entry and the import() targets that can only load after it ({main, lazyA}, {main, lazyB}, {main, lazyA, lazyB}, …) no longer gets one chunk per importer set. If the entry has no exports of its own it lands in the entry's chunk (no more main.js that is just import {a,b} from "./main-x.js"; export {a,b}); if the entry has exports, its module namespace is left exactly as written and those chunks fold into one shared chunk instead (same policy as Rollup's preserveEntrySignatures: "exports-only"). An entry whose static graph uses top-level await guarantees nothing (the lazy chunk would import a module that is still mid-evaluation), and --compile entry chunks are left alone as in #40430.
  2. No bare import "./chunk-x.js" for chunks that run nothing. An entry imported every chunk carrying its bit "for side effects" even when tree-shaking left it using nothing from it (e.g. 3 of 5 entries bare-importing the runtime-helpers chunk). Dropped when loading the chunk — and anything its files statically import — runs nothing.
  3. import() of another chunk no longer drags __require into the bundle (esbuild only counts it when import() is lowered; we counted it always). This is what created the extra ~200-byte runtime chunk in split builds.
  4. One export {} per ESM entry chunk — cross-chunk exports join the entry's own clause.

Purity got stricter than #40430 while doing this: a static import of a wrapped (__esm/__commonJS) or external module, a top-level require(), top-level await, and being an entry point all count as side effects even under "sideEffects": false (the annotation vouches for the file's statements, not for what its imports run).

Numbers

Same-revision release builds (base = this branch with the last commit reverted), Linux x64.

Synthetic app — 1 entry (with exports), 40 import() routes, 300 shared modules, 1-in-7 with a top-level side effect:

beforeafter
output files219151
output bytes124,24075,481 (−39%)
import statements (all files)2,475978
modules loaded at startup (main.js static closure)702
bytes loaded at startup23,12713,066
bundler instructions:u (median of 9)109.3M90.2M (−17%)
top-level side-effect order vs. unbundled ESM16 of 23 lines out of placeidentical

Route return values identical across unbundled / before / after (checked all 40 + nested).

No dynamic imports (5 entries over hono/zod/rxjs/lodash-es/dayjs; 8 entries over lodash-es): output byte-identical except the dropped bare imports of the runtime chunk; instructions:u within noise (+0.1% / −0.8%). Non-splitting build: unchanged (−0.1%).

Facade case (main.ts w/o exports + import("./lazy") sharing code): 3 files → 2, main.js contains its code, lazy imports from ./main.js.

Things to look at

  • Lazy chunks can now import {…} from "./<entry>.js" when the entry has no exports (Rollup/Vite do the same). If a page loads the entry under a different URL than its siblings resolve (main.js?v=123, inlined <script type=module>), the entry would be instantiated twice. Entries with exports are never touched.
  • An export-less entry that absorbs shared code gains an export { … } of the bindings its lazy chunks need.
  • (2) means a "sideEffects": false module that an entry imports but uses nothing from is no longer fetched by that entry — same as the non-splitting build and what the annotation permits, but different from before.

How did you verify your code works?

  • bun bd test test/bundler/bundler_splitting.test.ts — new: FoldsSharedIntoEntry, EntryWithExportsKeepsSignature, FoldsSharedCommonJSIntoEntry, FoldsDynamicallyImportedCommonJSIntoEntry, TopLevelAwaitKeepsSharedChunk, NoBareImportOfSideEffectFreeChunk, DynamicImportDoesNotNeedRequireShim, MinChunkSizeRequiresSplittingAPI; #40430's tests updated for the always-on fold and the missing runtime chunk. New tests fail on USE_SYSTEM_BUN=1.
  • esbuild/splitting, bundler_compile_splitting, esbuild/default, bundler_edgecase, bundler_naming, esbuild/css, bundler_html, bundler_html_server, bun-serve-html*, bundler_cjs2esm, esbuild/importstar, esbuild/dce, bake/dev-and-prod, bake/dev/{bundle,esm} green locally.
  • Drove the debug and release binaries on the fixtures above; outputs run under bun and node.