Skip to content

bundler: make each read of a run-time export * name depend on the barrel's exports object - #46903

Open
robobun wants to merge 4 commits into
mainfrom
robobun/8165b4a6/export-star-dynamic-fallback-dependency
Open

robobun wants to merge 4 commits into
mainfrom
robobun/8165b4a6/export-star-dynamic-fallback-dependency

Conversation

@robobun

@robobun robobun commented Oct 25, 2026 •

Copy link
Copy Markdown
Collaborator

Fixes #18008
Merge after #41220.

Problem

  • The bundle throws ReferenceError: exports_barrel is not defined when a "sideEffects": false barrel does export * of a CommonJS or external module.
  • The read prints as exports_barrel.x. The DynamicFallback arm (src/bundler/LinkerContext.rs:4060) records no dependency on that object, so tree shaking drops the barrel.

Fix

  • The arm pushes that dependency onto the import's re_exports, which a direct import now keeps. Each part that prints the read keeps the barrel.
  • LoadTimeReads does not count it as a read of the getters. Split chunks keep their order.
  • Bundles with no such read stay byte-identical. Verified: bundler_barrel.test.ts, bundler_jsx.test.ts (21 of 29 new tests fail on main), all of test/bundler/.
  • Self-reviewed: 23 concerns raised, 21 addressed (Notes).

Background

  • The names behind export * from <CommonJS> are run-time names: __reExport() fills the exports object.
  • The check at scanImportsAndExports.rs:1290 (named here) stays. The esbuild rule makes langium import { stream } 981,548 B, not 11,206 B.

Downsides

  • Without bundler: name the namespace of an external export * in a non-entry module #41220, a kept barrel with an external or browser-disabled export * target throws at load in ES module output: 112 of 31,796 random-graph bundles.
  • --splitting with an import cycle through the barrel can run the read before __reExport() and print undefined, as main does without the flag (Notes).
  • A run-time name through a root barrel keeps what --ignore-dce-annotations keeps (langium import { Emitter }: 932,625 B). Release .text +1,280 B, 7 more is_empty() tests per 697-module link.
Notes

Repro (no install, the shape of the issue)

mkdir -p test node_modules/dep
echo '{ "name": "name", "sideEffects": false }' > test/package.json
echo '{ "name": "dep", "version": "1.0.0" }' > node_modules/dep/package.json
printf '"use strict";\nObject.defineProperty(exports, "__esModule", { value: true });\nclass CancellationTokenSource { ok() { return "ok"; } }\nexports.CancellationTokenSource = CancellationTokenSource;\n' > node_modules/dep/cancellation.js
echo "export * from 'dep/cancellation.js';" > test/cancellation.ts
printf "import { CancellationTokenSource } from './test/cancellation.ts';\nconsole.log(new CancellationTokenSource().ok());\n" > test.ts
bun build test.ts --target=node --outfile=o.mjs && node o.mjs
output
bun test.ts ok
bundle from main 179664e and from 1.4.3 ReferenceError: exports_cancellation is not defined
bundle from esbuild 0.21.5 ok
bundle from this branch ok

On main the whole output is console.log(new exports_cancellation.CancellationTokenSource().ok());. This branch prints var exports_cancellation = {}; __reExport(exports_cancellation, __toESM(require_cancellation(), 1)); above it.

langium 4.2.4 has "sideEffects": false and export * from 'vscode-jsonrpc/lib/common/events.js'. An entry that builds its grammar services throws ReferenceError: exports_event is not defined from the bundle of main. The bundle of this branch prints what node prints.

Merge order

A barrel that this PR keeps prints each run-time export * it has. If the target is not in the bundle and the output keeps ES module syntax, that statement is __reExport(exports_barrel, ns) beside a bare import "path", and it throws ReferenceError: ns is not defined. Main prints the same for every live barrel of that kind (#15997). #41220 sets CONTAINS_IMPORT_STAR on the record, which names the namespace. #46786 carries the same commit. This PR does not copy it.

"Not in the bundle" means an external target (node:path, --external, --packages=external) or a target that a browser build disables (fs, a "browser" field entry set to false). The second kind needs no flag.

input, ES module output main this branch this branch + the #41220 hunk
export * from "node:path", read at load throws exports_barrel is not defined throws node_path is not defined right
the same, read in a function that nothing calls loads throws at load loads
"browser": { "./node.js": false } target, read at load throws exports_barrel is not defined throws node is not defined loads, the name is undefined
the same, read in a function that nothing calls loads throws at load loads

With --format=cjs every row loads on this branch. So a bundle whose read of the name never runs works on main and breaks here until the hunk is on main: 3 of 3 fixtures. I built this branch with the source change of #41220 on top to fill the last column. The four rows are the same on the rebased branch (debug builds of main 9b8347a, of 755cd73, and of 755cd73 with that source change).

After #41220 is on main, this PR gets the ES module twins of barrel/ExportStarOfExternalFormatCjs and barrel/ExportStarOfDisabledFormatCjs.

The check that stays

#42552 (comment) names the export_kind check at scanImportsAndExports.rs:1290 as the bug. esbuild tests the imported file there (linker.go:1958 in 0.21.5), and so did the first port (636d2e4). 77eb61a (2023-05-11) changed it to the importing file.

  • To put the imported file back is the esbuild rule: each import statement of a file with run-time exports keeps its exports object, also for names that bind statically. import { fromEsm } of a barrel with one CommonJS export * and one ES module export * is 72 B on main and 2,129 B in esbuild. langium import { stream } is 11,206 B on main and 981,548 B in esbuild. This PR adds the dependency per printed read, so both stay as on main.
  • What the check does today: a file with run-time exports keeps the exports object of each unwrapped file that it imports. Nothing reads those objects.
  • To delete it is right once bundler: keep the export * statement of a lazily loaded module when a name is read through it #46786 is in. I built that variant on top of this branch. langium import { Emitter } goes from 932,625 to 909,077 B and a mixed barrel from 2,852 to 2,699 B. One shape breaks: a file with run-time exports that reads a lazily loaded ES module through a side-effect-free barrel of ES modules prints x undefined, because the check keeps that barrel live by accident. bundler: keep the export * statement of a lazily loaded module when a name is read through it #46786 does it on purpose. barrel/ExportStarOfCommonJSInReaderOfLazyModule pins the shape. In 15 linker test files no other test changes.
  • The comment at the check is now one line that points to the DynamicFallback arm. The comment of barrel/ExportStarOfCommonJSInReaderOfLazyModule says what the check keeps.

How the dependency reaches each reader

  • advance_import_tracker returns DynamicFallback for a name that a file with run-time exports does not list. match_import_with_export_inner then makes the import a property read of that file's exports object (Symbol.namespace_alias). It is the only place that makes a read of the exports object of another file.
  • That arm now pushes the part that declares the object (part 0 of the barrel) onto re_exports.
  • When the name came through a named re-export first (export { x } from "./barrel" in another file), the match is NormalAndNamespace and bind_matched_import already stores re_exports.
  • When the import is direct, the match is Namespace, and the list was dropped. It is now stored in an imports_to_bind entry that binds the import to itself. The import still prints through its alias.
  • The readers of imports_to_bind then add the dependency where the read is printed. None of them changed:
    • step 6, for each part that uses the import (scanImportsAndExports.rs:935),
    • step 6, for the entry point part when an entry point exports the name (:1018),
    • step 5, for the namespace object of a file that re-exports the name (doStep5.rs:527), also in a third file through export *,
    • the member binding for X.name where X is a namespace that a file re-exports (LinkerContext.rs:4960).
  • A live part 0 makes the barrel live. A live file keeps its export * statement, which prints __reExport(...).
  • With --splitting the dependency also gives the barrel the entry bits of each reader, so the barrel goes to a chunk that every reader loads.

An entry of imports_to_bind that binds a ref to itself is not new. generate_symbol_import_and_use and add_exports write such entries for a symbol of another file. New is that the ref is an import of the same file. I read every reader of the map for that shape: scanImportsAndExports.rs:716, :935-993 (symbols.merge(ref, ref) returns at once), :1018-1028, doStep5.rs:173-185, :335, :527-533, computeCrossChunkDependencies.rs:228-243, :289-304, postProcessJSChunk.rs:955-972, LinkerContext.rs:4532, :4787, :4904, :4937.

The load order check

LoadTimeReads (findAllImportedPartsInJSOrder.rs) decides if the order of a split chunk with an import cycle puts a part that runs at load ahead of a binding that it reads. It reads part.dependencies as "this part names a binding of that part". The new dependency ends at part 0 of the barrel, and part 0 names each export that the namespace object lists. So the check saw the reader of exports_barrel.x as a reader of all of them, found no safe order, and kept an order that printed NaN (right on main, also for a barrel without "sideEffects": false). The first version of this branch had that regression. The self-review found it.

A read of a run-time name calls none of the getters. LoadTimeReads::new now skips a dependency on part 0 of another file unless the part uses the exports object itself (import * as ns as a value). With that, 3 of 3 cycle fixtures are byte-identical to main. The check does not know that the read must run after the __reExport(...) statement of the barrel. Main does not know it either. "Not fixed here" has a fixture.

Shapes that threw on main and are right on this branch

  • import { x }, and the same read inside a function.
  • import * as ns with ns.x.
  • One and two export * hops, and two run-time export * in one barrel.
  • A run-time name beside a name that binds statically, in one statement.
  • export { x } from "./barrel" in another file, and import { x }; export { x }.
  • The namespace object of a file that re-exports the name, and of a third file above it.
  • X.x where X is export * as X of a file that re-exports the name.
  • An entry point that re-exports the name (var export_x = exports_barrel.x), formats esm and cjs.
  • A CommonJS file that could bind statically, kept CommonJS by import(). A JSON file kept CommonJS by import().
  • An external or a disabled target with --format=cjs.
  • A JSX runtime that is export * of a CommonJS file (jsxImportSource). Main throws exports_jsx_runtime is not defined for it with and without "sideEffects": false, because the JSX import is never kept for side effects.
  • --format=esm, cjs and iife, --target=node, bun and browser, --minify.
  • --splitting: two entry points that read through the barrel.

Two shapes do not throw on main and change too:

  • --splitting, one entry point keeps the barrel in another way (its namespace object) and another chunk reads a name. On main that chunk imports exports_barrel from the entry point's file, which runs the entry point's code or, for a chunk that import() loads, imports the unhashed entry file (the symptom of --splitting: chunks import from the entry chunk, breaking entry URLs versioned with a query string #42290). Here the barrel goes to a chunk of its own.
  • A read that does not run at load, for example in a function that nothing calls. Main prints the read and drops the barrel (110 B, loads). This branch keeps the barrel, so the CommonJS file runs at load (2,225 B), as in esbuild.

What does not change

Every output that prints no read of a run-time name is byte-identical to main. So is every output of a barrel without "sideEffects": false, with or without such a read: 4,735 of 4,735 random-graph bundles (1,752 graphs). The JSX shape above is the one exception, because main drops that barrel. The unchanged shapes include:

  • A barrel with only ES module targets.
  • import { fromEsm } of a barrel with a CommonJS export * and an ES module export *: 72 B.
  • The same with an unread run-time name in the statement (import { fromEsm, movable }): 72 B.
  • An unused import and a bare import of the barrel.
  • A reader that is wrapped (import() without --splitting), and a barrel that is wrapped.

Measurements (x64 Linux. Release builds of main 179664e and of this branch. node 26.3.0 runs the bundles. The line numbers in this description are from the branch on main 9b8347a.)

  • Fixtures: 27 shapes, each as esm and cjs. 24 outputs throw on main. 23 of them print what node prints on the source. The last one is the external target with ES module output. The other 28 outputs are byte-identical (cmp).

  • Random graphs: 11,700 graphs of CommonJS and ES module leaves under 1 to 4 barrels (export *, export { x } from, export * as, own exports). The readers are named imports, ns.x, a captured namespace, re-exports, entry point exports, require() and import(). The generator also makes import cycles through a barrel, reads that do not run at load, external targets and targets that a browser build disables. Each graph is bundled as esm, cjs and --splitting with two entry points: 31,796 bundles that node can judge. node on the source is the reference, line by line in order.

    bundles
    right on both builds, byte-identical 16,455
    right on both builds, bytes differ (the output prints a read that does not run at load, or one in an entry point that node cannot judge) 336
    wrong on main, right on this branch 7,614
    right on main, wrong on this branch 112
    differ from node on both builds 7,279
    • The 112 are all one class: a target of export * that is not in the bundle, ES module output, and a read that does not run at load (72 with a disabled target, 40 with an external one). With --format=cjs there are 0. I ran those 69 graphs again on this branch with the source change of bundler: name the namespace of an external export * in a non-entry module #41220: 0 of the 112 stay wrong.
    • Import cycles: 3,851 graphs have one. With --splitting, 0 go from right to wrong. The first version of this branch had 4.
    • If the lines of each --splitting entry point are compared as a set, 1 more bundle goes from right to wrong. It is the import() chunk under "Not fixed here".
    • The 7,279 come from the generator more than from this PR: main runs the readers of a shared chunk in another order than node, a disabled target has other names in node, and an unbundled target that is read at load throws on both builds (exports_barrel on main, the namespace here).
  • Real packages, no run-time name read: a 697-module bundle (hono, svelte/compiler, stripe, 1,640,435 B) and a 205-module bundle (express, lodash, axios, react-dom/server, immutable, 1,899,573 B) are byte-identical.

  • langium 4.2.4:

    entry main this branch esbuild 0.21.5
    grammar services 1,338,647 B, throws 1,347,364 B, right
    import { Emitter } (run-time name) 98 B, throws 932,625 B, right 981,715 B
    the same from langium/lib/utils/event.js 106 B, throws 6,182 B, right
    import { stream } (binds statically) 11,206 B, right 11,206 B, same bytes 981,548 B

    The import { Emitter } bundle is byte-identical to the one that main prints with --ignore-dce-annotations.

  • A read in a function that nothing calls, CommonJS target: 110 B on main, 2,225 B here, 1,837 B in esbuild. Here and in esbuild the CommonJS file runs at load.

  • test/bundler/, release builds, 108 files: main 7,767 pass and 24 fail, this branch 7,809 pass and 1 fail. The 1 is "encoder output is identical on every platform" in bundler_bytecode_portable.test.ts, which fails on main too in this container. The other 23 on main are the 21 new tests and 2 cold-start failures of the first run (native-plugin.test.ts setup, one bytecode cache test). Both pass on main when run again.

  • 15 linker test files with the debug builds of main and of the first version of this branch (1,703 tests): the new tests are the only difference. default/TopLevelAwaitForbiddenRequireSourceMapCLI hits its 5 s timeout on both debug builds in this container. The two changed test files pass in full on the final debug build (164 pass).

  • After the rebase onto main 9b8347a, debug build: the two changed test files pass (164 pass). With the src/ of that main, 21 tests fail.

  • Link cost, counted with gdb breakpoints on the debug build:

    bundle imports matched new is_empty() tests inserts pushes
    697 modules, no run-time name 2,142 7 0 0
    205 modules, no run-time name 154 10 0 0
    langium import { Emitter }, 321 modules 1,152 17 15 15
    three run-time names in one part 4 3 3 3

    The test runs once per import that binds to a namespace (CommonJS, external, or a run-time exports object). Each run-time name adds two dependency entries to a part that reads it: the import statement of the name and part 0 of the barrel. The new code in LoadTimeReads::new runs only for a split chunk with an import cycle and two or more entry points.

  • Release binary: .text 82,164,617 to 82,165,897 B (+1,280 B, size -A). The file is 107,730,656 B on both builds. match_import_with_export_inner 7,463 to 7,995 B, scan_imports_and_exports 56,421 to 56,533 B (nm -S).

  • Instruction counts are not measured: valgrind and perf are not in the build container. bloaty and strace are not there either.

Other PRs

Not fixed here

  • --splitting, a chunk of two entry points, and an import cycle through the barrel. The chunk takes the order of one entry point. If that order runs the read ahead of the barrel's __reExport(...), the read is undefined for both entry points.

    // a.js (entry point)
    import { r } from './lib/barrel.js';
    console.log("a", r);
    // b.js (entry point)
    import './reader.js';
    console.log("b");
    // reader.js
    import { x } from './lib/barrel.js';
    console.log("x", x);
    export const r = 1;
    // lib/barrel.js
    export * from '../reader.js';
    export * from './impl.cjs';
    // lib/impl.cjs
    const x = 200;
    module.exports = { x };
    --splitting --format=esm a.js b.js
    node on the source x undefined x 200
    main 9b8347a, lib has "sideEffects": false throws exports_barrel is not defined throws
    main 9b8347a, no flag x undefined x undefined
    this branch, with the flag x undefined x undefined
    this branch, no flag same bytes as main same bytes as main
    bun 1.4.3, no flag x 200 x 200

    Without --splitting each entry point prints what node prints. With export * from './impl.cjs' first in the barrel, every row but the one that throws prints x 200. LoadTimeReads has no edge from a read of a run-time name to the export * part that fills the object, and find_safe_walks has one verdict per order. The edge must stay apart from the edges for bindings: the first version of this branch mixed them and lost the one order that was safe for a binding. It is a change for barrels with and without the flag.

  • --splitting, import() of a barrel that a static import also uses, where the barrel has an external export *: the chunk of the import() is only import "./shared.js", so the names of the external module are missing (xa undefined). Main does this when the static import binds a name statically, and when the barrel is not side-effect free. A read of a run-time name now counts as a use, so that shape joins them: 1 fixture that is right on main.

  • One run-time name through a root barrel keeps as much as --ignore-dce-annotations does for that barrel. The read is a property of the root barrel's exports object, and __reExport(exports_root, exports_inner) needs each object on the way. langium import { Emitter } is 932,625 B here and 981,715 B in esbuild. The same name from the innermost file (langium/lib/utils/event.js) is 6,182 B. To bind such a read to the innermost file that can have the name is a change in advance_import_tracker with its own guards.

  • bundler: take one copy per key for a top-level destructuring of a module namespace #46163 refuses a destructuring pattern over such a barrel because of this bug. It can drop that refusal after this PR. Its mark_held_namespaces reads !imports_to_bind.contains(&item), and the entry that this PR adds changes the answer for an import of a run-time name.

  • One CommonJS name, re-exported by name through two files and then through export * of both, reads undefined on main and here. No run-time exports object is involved.

Self-review

23 concerns. 21 addressed: the load order check, the comment at the check, the JSX route, the split shapes, a read that does not run, the disabled target, an assertion in each test that the output has the read, and this description. Not done:

  • The import() chunk above. The cause is in how a chunk of an import() target that lives in another chunk prints its exports. Main has it for a static binding.
  • Tracking issues for the check, for the innermost binding and for the import() chunk. They are listed here.

Tests

test/bundler/bundler_barrel.test.ts has 26 new tests and test/bundler/bundler_jsx.test.ts has 3. 21 fail on main: 18 with a ReferenceError, 3 with a wrong output or chunk graph. 8 pass on both builds and pin what must not change:

test what it pins
StaticNameOnlyStaysTreeShaken a name that binds statically does not keep the barrel or the CommonJS file
UnreadRunTimeNameStaysTreeShaken the same when the statement also names a run-time name that nothing reads
SplittingImportCycle, ...SideEffectsFalse, ...WithLazyChunk the order of a split chunk with an import cycle through the barrel (they fail without the LoadTimeReads change)
SplittingNamespaceInImportCycle a part that uses the namespace object still counts as a reader of its names
InReaderOfLazyModule the shape that the check at scanImportsAndExports.rs:1290 keeps right
jsx/AutoImportDroppedWhenJsxUnusedExportStarOfCjs unused JSX does not keep the runtime

@robobun

robobun commented Oct 25, 2026 •

Copy link
Copy Markdown
Collaborator Author
Updated 1:38 PM PT - Oct 27th, 2026

✅ @robobun, your commit 755cd73c2b5a18f53583746580fe86c8c1431fe8 passed in Build #133868! 🎉


🧪   To try this PR locally:

bunx bun-pr 46903

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

bun-46903 --bun

@robobun

robobun commented Oct 25, 2026 •

Copy link
Copy Markdown
Collaborator Author

Status: ready for review. Merge #41220 first. Without its source change, a barrel that this PR keeps throws at load if its export * target is external or disabled in a browser build and the output is an ES module (see "Merge order" in the description).

How I reproduced #18008: the commands under "Repro" in the description, on main 179664e and on 1.4.3. bun build test.ts --target=node --outfile=o.mjs && node o.mjs throws ReferenceError: exports_cancellation is not defined. With this branch it prints ok.

Tests: 21 of the 29 new tests in test/bundler/bundler_barrel.test.ts and test/bundler/bundler_jsx.test.ts fail with the src/ of main 9b8347a. All pass on this branch.

CI: build 133868 on 755cd73 has no failed job. At 20:07 UTC, 181 of 183 jobs passed and 2 macOS x64 test jobs wait for an agent. The failure annotations name 5 tests that failed once and passed when they ran again. This PR touches none of them: test/js/bun/css/nth-anplusb-ident.test.ts, test/js/bun/http/bun-serve-html.test.ts, test/js/bun/spawn/spawn-stdin-readable-stream.test.ts, test/js/node/process/process.test.js, test/js/node/process/process-stdin.test.ts.

@robobun

robobun commented Oct 25, 2026

Copy link
Copy Markdown
Collaborator Author

#46911 (the resolver change for #8993) depends on this PR. It makes a package.json with no name decide sideEffects for the files below it, so the nameless variant of this layout reaches this bug too.

10 layouts (5 shapes of the import, each below a package.json with and without a name), --target=bun:

build layouts that print the expected output
main 179664e 5 of 10
#46911 alone 0 of 10
#46911 merged with this branch (50174bd, no conflict) 10 of 10

#46911 asks for this PR to merge first. It has two todo cases for the layout in test/bundler/esbuild/dce.test.ts: dce/PackageJsonSideEffectsFalseExportStarOfCommonJS{Named,Nameless}. Both pass on the merged tree. The PR that merges second turns them on.

…arrel's exports object

An import that reaches a CommonJS or external module through `export *`
prints as a read of the barrel's exports object (`exports_barrel.x`).
The import tracker recorded no dependency for that read. In a package
with `"sideEffects": false`, tree shaking dropped the barrel and the
bundle threw `ReferenceError: exports_barrel is not defined` at load.

The `DynamicFallback` arm of the import tracker now adds the part that
declares the exports object to `re_exports`. A direct import keeps that
list in `imports_to_bind`, so the existing readers of `re_exports` give
the dependency to each part that prints the read.
…or namespace getters

The dependency of such a read on the part that declares the exports
object keeps the barrel in the bundle. `LoadTimeReads` took the same
dependency as a read of every binding that the object's getters name.
With `--splitting`, a chunk with an import cycle through the barrel then
lost its safe order and printed `NaN`. Only a part that uses the object
itself counts as a reader of those bindings.

Also state what the step-6 arm does, document the shape of
`imports_to_bind` entries, and add tests: the cycle shapes, a read in a
chunk that `import()` loads, a read that does not run at load, and a
JSX runtime behind `export *` of a CommonJS file.
@robobun
robobun force-pushed the robobun/8165b4a6/export-star-dynamic-fallback-dependency branch from 50174bd to 8b916d0 Compare October 27, 2026 18:08
Comment thread src/bundler/LinkerContext.rs Outdated
Comment thread src/bundler/LinkerContext.rs Outdated
Comment thread src/bundler/LinkerGraph.rs Outdated
Comment thread src/bundler/linker_context/findAllImportedPartsInJSOrder.rs Outdated
Comment thread src/bundler/linker_context/scanImportsAndExports.rs Outdated
…e line

The test of the reader of a lazily loaded module now names the step-6 check that keeps its barrel.
@robobun
robobun marked this pull request as ready for review October 27, 2026 18:21
@coderabbitai

coderabbitai Bot commented Oct 27, 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 12 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 9b8347a and 755cd73.

Files selected for processing: 6
  • src/bundler/LinkerContext.rs
  • src/bundler/linker_context/README.md
  • src/bundler/linker_context/findAllImportedPartsInJSOrder.rs
  • src/bundler/linker_context/scanImportsAndExports.rs
  • test/bundler/bundler_barrel.test.ts
  • test/bundler/bundler_jsx.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.

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

Beyond the inline findings, I also checked the new .expect("unreachable") on the imports_to_bind.put — it mirrors the existing NormalAndNamespace put a few lines below, so it is not a new failure mode — and the LoadTimeReads dependents refactor in findAllImportedPartsInJSOrder.rs, which emits the same edges as before for every dependency except cross-file part-0 dependencies whose part never uses that file's exports object, which is the intended exclusion.

Extended reasoning...

The change touches the bundler linker's import matching (DynamicFallback arm and the Namespace bind in LinkerContext.rs), the LoadTimeReads dependents graph build, a comment in scanImportsAndExports.rs, a README bullet, and ~620 lines of new itBundled tests; it touches no security-sensitive surface. Two confirmed findings are posted inline (the ESM load-time regression for external/disabled export-star targets, which the author's own description acknowledges as a merge-order dependency on another PR, and a split-chunk cycle ordering case), so a human needs to weigh them; this note only records the two adjacent concerns examined and ruled out.

Additional findings (outside the current diff — GitHub can't attach inline comments there):

  • 🟣 src/bundler/linker_context/findAllImportedPartsInJSOrder.rs — Users of split ESM bundles with a cycle through a "sideEffects": false barrel can get undefined from a name that resolved on main's dropped-barrel path only by throwing. LoadEffect::of at src/bundler/linker_context/findAllImportedPartsInJSOrder.rs:600 keeps SExportStar as Hoisted, yet a barrel's export * from <CJS> prints __reExport(exports_barrel, ...), which writes the run-time names at load. Fix: classify an export-star part whose target is dynamic (is_dynamic() at scanImportsAndExports.rs:1354) as Evaluates/Assigns so is_broken_by treats it as a written part that readers must follow.

    Why this was flagged

    Trigger: the newly kept barrel (this PR makes exports_barrel.x readers depend on part 0 at src/bundler/LinkerContext.rs:4087, so barrels in "sideEffects": false packages now stay live) sits in an import cycle inside a chunk shared by two entry points. find_safe_walks (:345) only reorders when is_broken_by finds an Evaluates dependent positioned before a non-Hoisted written part. The barrel's __reExport statement is the only thing that gives exports_barrel.x a value, but for_each_part_backward (:525) skips it because :600 maps SExportStar to Hoisted, so it is never a written candidate. No order is ever judged broken for this read. The user gets undefined for x where main's bundle threw ReferenceError: exports_barrel is not defined on load. On base the population that reaches it (side-effect-free barrels) never had a live barrel; this PR creates the live-barrel-plus-reader shape and then exempts it from the only ordering safeguard.

    Verification: LoadEffect::of classifies the export-star part Hoisted (src/bundler/linker_context/findAllImportedPartsInJSOrder.rs:600), and for_each_part_backward (:525) only visits non-Hoisted parts as written, so is_broken_by (:545-567) never starts from the __reExport write. On base a "sideEffects": false barrel throws ReferenceError unconditionally; a live barrel yields the same undefined by the same route.

Comment on lines +4080 to +4091
// Depend on the part that declares the exports object that this read prints
let deps = self.top_level_symbols_to_parts(
next_tracker.source_index.get(),
next_tracker.import_ref,
);
re_exports.reserve(deps.len());
for &dep in deps {
re_exports.push(Dependency {
part_index: dep,
source_index: bun_ast::Index::init(next_tracker.source_index.get()),
});
}

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.

🔴 Users of a "sideEffects": false barrel that does export * of an external or browser-disabled module get an ES-module bundle that throws at load, where main loads it. The new re_exports dependency keeps the barrel live for any read of a run-time name, even one inside a function nothing calls. The kept barrel prints __reExport(exports_barrel, ns) but scanImportsAndExports.rs:1347-1387 never sets CONTAINS_IMPORT_STAR for an external record, so ns is never declared. Fix: before this lands, make a kept export * of a not-in-bundle target declare its namespace in ESM output, and add the ESM twins of barrel/ExportStarOfExternalFormatCjs and barrel/ExportStarOfDisabledFormatCjs.

Why this was flagged

Input: /lib/package.json with "sideEffects": false, /lib/barrel.js = export * from 'node:path', and an entry that does import { basename } from './lib/barrel.js'; export function later() { return basename("/a"); } bundled with the default --format=esm through bun build. On the base branch the barrel is dropped and the bundle loads (the read never runs). With this diff src/bundler/LinkerContext.rs:4080-4091 pushes the barrel's part 0 onto re_exports, src/bundler/LinkerContext.rs:4641-4655 stores it in imports_to_bind, and step 6 at src/bundler/linker_context/scanImportsAndExports.rs:989 makes the reading part depend on it, so the barrel is live. For the external record src/bundler/linker_context/scanImportsAndExports.rs:1347-1387 sets happens_at_runtime and CALLS_RUNTIME_RE_EXPORT_FN but not CONTAINS_IMPORT_STAR (that flag is only set on the lifted-CommonJS path at :1331-1335), so the chunk prints __reExport(exports_barrel, node_path) beside a bare import "node:path", and the bundle throws ReferenceError: node_path is not defined at load.

Verification: normal — acknowledged in diff: the PR description and the new test comment at test/bundler/bundler_barrel.test.ts:2437-2439 ("With ES module output the kept statement still needs #41220") both flag this; #41220 is not in this diff or its base, so merging this head alone ships the regression.

Copy link
Copy Markdown
Collaborator Author

Choose a reason for hiding this comment

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

This is the merge order condition on line 2 of the description. The statement is wrong on main for every live barrel of this kind (#15997): without "sideEffects": false, the barrel of this input throws node_path is not defined at load on main too, with the same bytes as on this branch. #41220 is the change that declares the namespace. It sets CONTAINS_IMPORT_STAR on that record. This PR does not copy it, so this PR must merge after #41220.

I ran the input of this comment on three debug builds:

build result
main 9b8347a loads, and later() throws exports_barrel is not defined
this branch (755cd73) throws at load: node_path is not defined
this branch with the source change of #41220 loads, and later() returns the name

The same holds for a target that a browser build disables. The "Merge order" table in the description has the four shapes.

The ES module twins of barrel/ExportStarOfExternalFormatCjs and barrel/ExportStarOfDisabledFormatCjs fail until #41220 is on main. I add them when I rebase onto it. This thread stays open until then.

@robobun

robobun commented Oct 27, 2026

Copy link
Copy Markdown
Collaborator Author

On the additional finding in this review (LoadEffect::of keeps a run-time export * part Hoisted): the shape is real. It is also what main prints for a barrel without "sideEffects": false. This PR makes a barrel with the flag behave like a barrel without it. It does not change the order of a split chunk for either.

Fixture, --splitting --format=esm, two entry points:

// a.js (entry point)
import { r } from './lib/barrel.js';
console.log("a", r);
// b.js (entry point)
import './reader.js';
console.log("b");
// reader.js
import { x } from './lib/barrel.js';
console.log("x", x);
export const r = 1;
// lib/barrel.js
export * from '../reader.js';
export * from './impl.cjs';
// lib/impl.cjs
const x = 200;
module.exports = { x };
a.js b.js
node on the source x undefined x 200
main 9b8347a, lib has "sideEffects": false throws exports_barrel is not defined throws
main 9b8347a, no flag x undefined x undefined
this branch, with the flag x undefined x undefined
this branch, no flag same bytes as main same bytes as main
bun 1.4.3, no flag x 200 x 200

The shared chunk takes the order of a.js. That order runs reader.js ahead of __reExport(exports_barrel, ...). It is right for a.js and wrong for b.js. Without --splitting each entry point prints what node prints.

I did not change the check in this PR, for these reasons:

  • The classification alone has no effect. No part depends on the export * part, so is_broken_by finds no reader behind it. The read of exports_barrel.x depends on part 0 of the barrel. LoadTimeReads needs a new edge from that read to each run-time export * part of the barrel.
  • That edge must stay apart from the edges for bindings. __reExport(exports_root, exports_inner) uses exports_inner. So a search from a binding of inner goes through that part to the readers of the run-time names of root, and those readers read no binding of inner. The first version of this branch had a false edge of this kind: the read counted as a read of each binding that the namespace object of the barrel lists. find_safe_walks then rejected the one order that was safe for a binding, and the chunk printed NaN. The barrel/ExportStarOfCommonJSSplittingImportCycle tests pin that.
  • If no order is right for the bindings and for the run-time names, find_safe_walks must still take the order that is safe for the bindings. Today it has one verdict per order.
  • The change applies to barrels with and without the flag. It changes what main prints for the barrels without it.

The shape is now in Downsides and under "Not fixed here" in the description.

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.

Bun build does not import values re-exported from another package when sideEffects is false

2 participants