Skip to content

bundler: bind exports in a CommonJS wrapper that prints lifted exports - #46565

Open
robobun wants to merge 5 commits into
mainfrom
robobun/c84ad950/cjs-wrapper-exports-parameter
Open

robobun wants to merge 5 commits into
mainfrom
robobun/c84ad950/cjs-wrapper-exports-parameter

Conversation

@robobun

@robobun robobun commented Oct 23, 2026 •

Copy link
Copy Markdown
Collaborator

Problem

  • bun build --target=bun turns exports.a = 1; if (process.browser) { this.b = 1; } into __commonJS(function() { exports.a = 1; ... }): ReferenceError: exports is not defined.
  • The parser lifts exports.a = 1 and counts no use of exports. A top-level this in dead or removed code still marks the file CommonJS (src/js_parser/p.rs:5949). Link step 4 adds exports only for COMMONJS_LIFTED_TO_ESM files (scanImportsAndExports.rs:626).
  • No issue reports this. I found it by reading the code. Most forms fail since 1.0.0 (Notes).

Fix

  • Step 4 gives exports to every wrapped file with lifted exports and no parameter: the rule of bundler: no export table for a lifted CommonJS file that stays wrapped #41334 on the printer's condition, wrap == Cjs.
  • Verified: test/bundler/bundler_cjs2esm.test.ts, 12 new cases fail on main. Three todo tests in esbuild/extra.test.ts now pass.
  • Self-reviewed: 26 concerns raised, 24 addressed. Not taken: a generated test matrix, a parser rule for module.exports (Notes).

Background

  • Lifting turns exports.a = 1 into var $a = 1. A wrapped file prints exports.a = 1 again.
  • The linker adds a wrapper parameter only when a flag says so.
  • Considered: restoring the parser's marks for a removed statement (an input that runs today then throws), and a parser-only use count (a linker-wrapped file still throws).

Downsides

Notes

Repro

printf 'exports.a = 1;\nif (process.browser) {\n  this.b = 1;\n}\n' > lib.cjs
printf 'import lib from "./lib.cjs";\nconsole.log(lib.a);\n' > entry.js
bun build entry.js --outdir=out --target=bun && bun out/entry.js

On main the bundle throws ReferenceError: exports is not defined. Node prints 1 for the source. process.browser is false for --target=bun and --target=node, so the branch is dead. The same happens for if (process.env.NODE_ENV !== "production") { console.log(this === exports); } with --define 'process.env.NODE_ENV="production"'. With --production the message is ReferenceError: O is not defined. A --target=node bundle throws under node (exports is not defined in ES module scope). Under bun it does not throw and prints undefined for the import.

Where it comes from. No GitHub issue and no package that I know of. I found it by reading the code around #45345. Three tests ported from esbuild have recorded it as todo since they were added: extra/DefineExports1, extra/DefineExports2 and extra/DefineExportsArray2 (in.js ends with this;).

Inputs. Each line follows exports.a = 1;. The import form does not matter: default, named and star import, require(), a split import(), and the file as an entry point all fail.

Second statement Fails since
if (false) { console.log(this); } 1.0.0
exports.b = true ? 2 : this; 1.0.0
exports.b = 0 || 2 || this; 1.0.0
false && this.x; 1.0.0
function f() { return 1; } if (false) { f.call(this); } 1.0.0
this; 1.0.0
(() => this); 1.0.0
(function () { module.exports(); }); 1.0.0
(function () { module.exports = {}; }); 1.0.0
(function () { delete exports.a; }); 1.4.1
(function () { delete module.exports; }); 1.4.1
(function () { delete module.exports.a; }); 1.4.1

I ran these on the 1.0.0, 1.1.0, 1.2.0, 1.3.0, 1.4.0 and 1.4.1 release binaries and on main. Up to 1.2.0 the message is ReferenceError: Can't find variable: exports. The file node_modules/react/index.js with import { z } from "./z.js"; exports.a = z;, default-imported, also fails since 1.4.1. Node rejects that source, so the reference for it is Bun up to 1.4.0.

Mechanism

  • value_for_this records a use of exports and sets commonjs_named_exports_deoptimized. record_usage does nothing in dead control flow. For a removed statement, append_part takes the use back. The flag stays in both cases.
  • _parse adds uses of exports for a deoptimized file only on the path that deoptimizes at the end of the visit (parse_entry.rs:1592). That path is skipped when the flag is already set. uses_exports_ref is then false while the file is ExportsKind::Cjs.
  • The wrapper builder (generateCodeForFileInChunkJS.rs:545) adds parameters only for USES_EXPORTS_REF or USES_MODULE_REF. The printer prints a lifted export as exports.name whenever wrap == Cjs.
  • bundler: no export table for a lifted CommonJS file that stays wrapped #41334 added the step-4 rule for files that the linker wraps again (require() of a lifted file). Those have COMMONJS_LIFTED_TO_ESM. A file that the parser made CommonJS does not. The react file above does not either: its import keeps the flag off, and the linker wraps it for the default import.
  • The new condition is wrap == Cjs, neither flag set, and a table of lifted exports that is not empty. A file with either flag already has exports, and its output does not change (the minifier counts one more use of exports for the flag).
  • The debug_assert! in print_code_for_file_in_chunk_js sits where the printer option is set: a wrapped file with lifted exports has USES_EXPORTS_REF or USES_MODULE_REF.

Designs that were weighed

  • Restore the parser's marks in append_part when a statement is removed, and guard value_for_this against dead code. It makes these files lifted and does not reach a file that only the linker wraps. A lifted file is not always right today: exports.a = 1; this; exports.b = Object.keys(module.exports).join(); runs as an entry point now, and without the mark of this; it prints an undeclared exports_lib (bundler: keep the CommonJS wrapper when the lift leaves a module.exports value behind #35665).
  • Count a use of exports in _parse for every deoptimized file. The parser cannot see wrap, so the linker-wrapped react file still throws.
  • Always print both wrapper parameters. It changes the head of every wrapper that does not use module, in every output format.

Not fixed here. Each input follows exports.a = 1; and throws on main and on this branch.

Related open PRs. #42500 applies the unwrap list only to CommonJS files, which changes how the react file of the last test is classified. #45345, #42595 and #41635 change the parser's decision to wrap. None of them changes the wrapper parameters.

Measurements. Release builds of main 5c6d4e2 with and without this diff. valgrind and perf are not installed, so instruction counts are gdb single-step counts.

What main this PR
corpus outputs that differ, 32,425 files 0
corpus files with a different wrapper count 0
.text 82,231,705 82,231,961 (+256)
scan_imports_and_exports 56,421 B 56,617 B (+196)
Parser::_parse, JS and TypeScript 25,981 B, 28,362 B the same
print_code_for_file_in_chunk_js 16,696 B 16,696 B
step 4, ES module 43 instructions 44
step 4, wrapped file that had exports 3,476 3,469
step 4, wrapped file that now gets exports 3,461 3,462
step 4, lifted file 521 517
arena allocations, wrapper that now gets exports 0 2
arena allocations, wrapper that had exports 2 2
  • Corpus: every .js, .cjs and .mjs file of 1,165 package names under test/node_modules and node_modules. Each is built with bun build <file> --target=node --packages=external, and the sha256 of each output is compared. 63 files fail to build in both binaries. The first version of this diff had the same linker change. For it I also ran --minify-identifiers, --minify and --format=cjs: 0 outputs differ in each. This does not cover first-party CommonJS under an application's own defines.
  • Step 4: one iteration of the loop per file, callees included, 12 identical files per kind.
  • Allocations: hits of the binding allocation in the wrapper builder, 1,000 required files.
  • The diff does not touch the parser.

Tests

  • test/bundler/bundler_cjs2esm.test.ts: 12 new cases. The 12 inputs of the table run through a default, named and star import and require(). One more case adds six files that must keep their shape. The others: an entry point, --minify-identifiers, --splitting, the NODE_ENV and process.browser triggers, and the linker-wrapped react file.
  • test/bundler/esbuild/extra.test.ts: todo: true removed from the three tests named above. No other existing expectation is edited.
  • Release builds of main 5c6d4e2 with and without the diff, all 108 files of test/bundler/: the 12 new cases and the three extra tests fail without it and pass with it. bytecode cache portability > encoder output is identical on every platform times out with both on my machine. No other result differs.
  • Debug build of this branch: bundler_cjs2esm 125 pass. esbuild/extra.test.ts 225 pass, 9 todo. bundler_cjs, bundler_edgecase, bundler_regressions, react-compiler, bundler_npm, bundler_splitting, bundler_browser, bundler_minify: 681 pass. The new assertion did not fire.

The parser lifts `exports.a = 1` to `var $a = 1` and counts no use of
`exports` for it. When the file is wrapped after all, the printer writes
`exports.a = 1` again, so the wrapper needs the `exports` parameter.

Link step 4 gave that parameter only to files with the
`COMMONJS_LIFTED_TO_ESM` flag. A file that the parser itself made
CommonJS has no such flag. If the code that made it CommonJS is dead or
its statement is removed, no use of `exports` is counted and the wrapper
had no parameter: `ReferenceError: exports is not defined`.

- Step 4 now also gives `exports` to a wrapped file that has lifted
  exports and neither the `exports` nor the `module` parameter.
- The parser keeps the wrapper when `exports` was assigned and `module`
  is still used. The printer then writes `module.exports` as written,
  and a lifted file has no `module`.
- A debug assertion in `print_code_for_file_in_chunk_js` holds the rule.
@robobun

robobun commented Oct 23, 2026 •

Copy link
Copy Markdown
Collaborator Author

Status: ready for review. CI passed on 3abb9c0 (build 134815).

How I reproduced it, on main:

printf 'exports.a = 1;\nif (process.browser) {\n  this.b = 1;\n}\n' > lib.cjs
printf 'import lib from "./lib.cjs";\nconsole.log(lib.a);\n' > entry.js
bun build entry.js --outdir=out --target=bun && bun out/entry.js

The bundle throws ReferenceError: exports is not defined. With this branch it prints 1, as Node does for the source.

The 12 new cases in test/bundler/bundler_cjs2esm.test.ts and three tests in test/bundler/esbuild/extra.test.ts that were todo fail on a release build of main and pass on this branch.

CI notes: 193 jobs passed. The two test-aot-drawn jobs are soft failures (test/aot/generated-by-python.test.ts, test/aot/perf/generated-code-of-idioms-16.test.ts). This diff does not touch the AOT compiler, and the same lanes fail on main at the commit this branch contains (build 134732). Three unrelated tests failed once and passed when they ran again.

PR: #46565

@robobun

robobun commented Oct 23, 2026 •

Copy link
Copy Markdown
Collaborator Author
Updated 12:18 AM PT - Oct 29th, 2026

❌ @robobun, your commit 3abb9c0 has 2 failures in Build #134815 (All Failures):


🧪   To try this PR locally:

bunx bun-pr 46565

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

bun-46565 --bun

…tests

The parser rule kept the wrapper only for a file with a dead or removed
`exports = ...`. The same `module.exports` read without that assignment
still prints a name that nothing declares, and a file with a
`module.exports.x = ...` store lost its lift. Remove the rule and its
tests. The linker rule stays.

`extra/DefineExports1`, `extra/DefineExports2` and
`extra/DefineExportsArray2` failed with `exports is not defined` and pass
with the linker rule, so they are no longer `todo`.
The name replaces a two-line comment. Shorten two test comments.
@robobun robobun changed the title bundler: give a CommonJS wrapper the exports and module that its lifted statements print bundler: bind exports in a CommonJS wrapper that prints lifted exports Oct 23, 2026
@robobun
robobun marked this pull request as ready for review October 23, 2026 12:30
@coderabbitai

coderabbitai Bot commented Oct 23, 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 7 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 37a5e3a and 3abb9c0.

Files selected for processing: 4
  • src/bundler/LinkerContext.rs
  • src/bundler/linker_context/scanImportsAndExports.rs
  • test/bundler/bundler_cjs2esm.test.ts
  • test/bundler/esbuild/extra.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.

Nothing blocking. The comments below are optional suggestions. There is no need to push a fix for them before merging.

Beyond the inline finding, I also checked that the widened step-4 condition cannot steal inputs from the lifted-setter else if (the new disjunct requires COMMONJS_LIFTED_TO_ESM to be unset, and the setter branch requires it set), and that every wrap = WrapKind::Cjs assignment lives in steps 1-2 of scanImportsAndExports.rs, so no row reaches the new debug_assert! in LinkerContext.rs without passing the step-4 loop.

Extended reasoning...

The change widens one linker condition in src/bundler/linker_context/scanImportsAndExports.rs so a Cjs-wrapped file with lifted named exports and no counted exports/module use gets the exports wrapper parameter, adds a debug-only invariant in src/bundler/LinkerContext.rs, un-todos three esbuild-ported tests, and adds a matrix of itBundled cases. It touches no security-sensitive surface. The one posted finding is a pre-existing sibling gap in doStep5 that the author already lists as out of scope, so a human should weigh whether that is acceptable to land separately.

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

@robobun

robobun commented Oct 29, 2026

Copy link
Copy Markdown
Collaborator Author

Merged main (37a5e3a) into this branch. The PR merges clean again.

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.

1 participant