bundler: fold code-splitting chunks that always load together, drop dead cross-chunk imports
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:
- 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 moremain.jsthat is justimport {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'spreserveEntrySignatures: "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--compileentry chunks are left alone as in #40430. - 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. import()of another chunk no longer drags__requireinto the bundle (esbuild only counts it whenimport()is lowered; we counted it always). This is what created the extra ~200-byte runtime chunk in split builds.- 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:
| before | after | |
|---|---|---|
| output files | 219 | 151 |
| output bytes | 124,240 | 75,481 (−39%) |
import statements (all files) | 2,475 | 978 |
modules loaded at startup (main.js static closure) | 70 | 2 |
| bytes loaded at startup | 23,127 | 13,066 |
bundler instructions:u (median of 9) | 109.3M | 90.2M (−17%) |
| top-level side-effect order vs. unbundled ESM | 16 of 23 lines out of place | identical |
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": falsemodule 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 onUSE_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.