Skip to content

js_parser: make the ESM/CJS classification and the module/exports bindings agree - #40840

Open
robobun wants to merge 4 commits into
mainfrom
farm/73b8f56f/esm-cjs-classification
Open

robobun wants to merge 4 commits into
mainfrom
farm/73b8f56f/esm-cjs-classification

Conversation

@robobun

@robobun robobun commented Aug 28, 2026 •

Copy link
Copy Markdown
Collaborator

Problem

  • A file with export (or top-level await) that also assigns module.exports or exports.foo is bundled as CommonJS and its export statements vanish with no diagnostic, while bun run loads it as ESM (SyntaxError: Export named 'b' not found). An ESM file that only mentions them gets an undeclared module_<file> in the bundle (ReferenceError: module_tla is not defined) and typeof exports is "object".
  • Cause: prepare_for_visit_pass (src/js_parser/p.rs) binds module/exports to the CommonJS wrapper symbols in every file. P::has_top_level_return is read by the exports_kind selection (parse_entry.rs) but never set, so a top-level return is a SyntaxError under bun run and a bare return in bun build output. Fixes top-level return should hint to the transpiler that a module is commonjs / bundler can emit top-level return #8908.

Fix

  • Bind module/exports as CommonJS symbols only when the file has no export and no top-level await. Otherwise they are unbound globals, as in esbuild, and the bundler warns once per variable on assignment. The file stays ESM, so a missing name is a No matching export error.
  • s_return sets has_top_level_return outside a function and turns off the exports.foo = ... to ESM unwrapping, so the file gets the CommonJS wrapper. Next to export/top-level await it errors at the return with a note pointing at the ESM syntax.
  • Kept: require.main === module still becomes import.meta.main in ESM files. A .mjs or "type": "module" file with only CommonJS syntax stays CommonJS (Bun's content-based rule).
  • Verified: test/cli/run/esm-cjs-detection.test.ts (new, 31 tests; 13 fail on 1.4.1), test/bundler/bundler_cjs.test.ts (22 new; 17 fail before), test/bundler/esbuild/default.test.ts, test/bundler/transpiler/transpiler.test.js. The notes list the other suites.

Background

Notes

Repros, before and after (release 1.4.1 vs. this branch):

// m6.js: export const a = 1; export function fa() { return a } export default 9; module.exports.b = 2;
// e6.js: import def, { a, fa, b } from "./m6.js"
bun build e6.js   before: {"fa":"undefined","b":2,"def":{"b":2}}   after: error: No matching export in "m6.js" for import "b" (+ warning)
bun e6.js         before/after: SyntaxError: Export named 'b' not found in module

// tla.js: await new Promise(r => setTimeout(r, 1)); module.exports = { a: 1 }
bun build tla.js  before: module_tla.exports = { a: 1 }  after: module.exports = { a: 1 } (+ warning with the top-level await note)

// esm.js: export const x = 1; console.log(typeof module, typeof exports)
bundled           before: undefined object   after: undefined undefined

// r0.js: console.log(1); if (globalThis.foo === undefined) return; console.log(2);
bun r0.js         before: SyntaxError   after: 1
bun build r0.js   before: bare top-level return   after: __commonJS wrapper

A fifth case found while testing: exports.foo = 1; if (x) return; exports.bar = 2; failed to bundle with Top-level return cannot be used inside an ECMAScript module, because the unwrapping of the first statement had synthesized the export keyword before the return was visited. It now bundles as a CommonJS wrapper with both assignments.

Runtime output is unchanged for ESM files (the printer already printed module/exports by their original names), except files with a top-level return. RuntimeTranspilerCache EXPECTED_VERSION is bumped so older entries for those files are not replayed.

--format=iife with a CommonJS entry point never invokes the wrapper; that is a separate pre-existing bug (#37843 is open for it), so the iife top-level-return test checks the output shape instead of running it.

The new runtime test matrix: bun run vs bun build for esm/cjs/iife, importers using default, named and namespace imports, .js/.mjs/.cjs with "type" none/module/commonjs.

Tests carried over from #37371 in the second commit: module.id and module.require() reads, minified identifiers, --format=cjs under node, and Bun.Transpiler define for module/exports (applies only in files with ESM exports). test/bundler/esbuild/default.test.ts: WarnCommonJSExportsInESMBundle now asserts the two warnings, and TopLevelReturnForbiddenImport/TopLevelReturnForbiddenImportAndModuleExports are no longer todo.

Other suites run locally (debug, ASAN): test/bundler/esbuild/*, bundler_cjs, bundler_edgecase, bundler_cjs2esm, bundler_bun, bundler_browser, bundler_regressions, bundler_splitting, bundler_npm, bundler_minify, bun-build-api, cli.test.ts, test/bundler/transpiler/, test/cli/run/, test/js/bun/resolve/, test/js/bun/repl/, test/js/node/module/, test/js/bun/test/, test/js/third_party/es-module-lexer. The only failures were timeouts of slow leak/perf tests under the loaded container and PTY-less REPL terminal tests; each passes with a longer timeout or on the release binary.


[review] gate passed · iteration 2 · 14 files touched

fails on main (without fix)
ASAN without fix: BUILD FAILED (no junit output)
$ BUN_DEBUG_QUIET_LOGS=1 bun scripts/build.ts --profile=debug --quiet test "--reporter=junit" "--reporter-outfile=/tmp/mechgate.xml" test/bundler/bundler_cjs.test.ts test/bundler/esbuild/default.test.ts test/bundler/transpiler/transpiler.test.js test/cli/run/esm-cjs-detection.test.ts test/js/bun/transpiler/repl-transform.test.ts
ninja: Entering directory `/workspace/bun/build/debug'
[1/176] gen generated_host_exports.rs
generated_host_exports.rs: 122 exports (host=5, lazy=10, generic=107, rust=0); 244 extern-C blocks audited
[2/176] gen cpp.rs (cppbind)
[3/176] gen JS modules (bundle-modules)
Preprocess modules (7940ms)
Bundle modules (67ms)
Postprocesss modules (275ms)
Bundle Functions (527ms)
Generate Code (56ms)

[8.87s] Bundled "src/js" for development
  2787 kb
  197 internal modules
  13 native modules
  50 internal functions across 16 files
[3/176] cargo bun_runtime → libbun_runtime.a
[14/176] cc obj/packages/bun-usockets/src/quic.c.o
FAILED: obj/packages/bun-usockets/src/quic.c.o 
/usr/bin/ccache /usr/lib/llvm-21/bin/clang -march=nehalem -O0 -glldb -g3 -gz=zstd -fno-standalone-debug -fsanitize=address -fno-exceptions -fno-rtti -fno-omit-frame-pointer -mno-
... (truncated)

release without fix: 35 failed, 61 skipped
bun test v1.4.1-canary.1 (d578a8c70)

test/bundler/bundler_cjs.test.ts:
(pass) bundler > cjs/__toESM_import_syntax_with_esModule [19.72ms]
(pass) bundler > cjs/__toESM_import_syntax_without_esModule [9.57ms]
(pass) bundler > cjs/__toESM_import_syntax_function [8.13ms]
(pass) bundler > cjs/__toESM_import_syntax_primitive [8.08ms]
(pass) bundler > cjs/__toESM_import_syntax_named_and_default [8.95ms]
(pass) bundler > cjs/__toESM_import_syntax_namespace [8.40ms]
(pass) bundler > cjs/__toESM_target_node [8.95ms]
(pass) bundler > cjs/__toESM_target_browser [9.27ms]
(pass) bundler > cjs/__toESM_target_bun [8.38ms]
(pass) bundler > cjs/__toESM_format_esm [8.19ms]
(pass) bundler > cjs/__toESM_format_cjs_with_import [8.66ms]
(pass) bundler > cjs/__toESM_mjs_reexport [8.60ms]
(pass) bundler > cjs/__toESM_mjs_reexport_with_esModule [8.05ms]
(pass) bundler > cjs/__toESM_deep_reexport_chain [7.64ms]
(pass) bundler > cjs/__toESM_reexport_with_rename [7.85ms]
(pass) bundler > cjs/__toESM_default_prop_no_esModule [8.23ms]
(pass) bundler > cjs/__toESM_mixed_import_styles [8.84ms]
(pass) bundler > cjs/__toESM_esModule_non_true [9.90ms]
(pass) bundler > cjs/__toESM_esModule_false [10.4
... (truncated)
passes on PR (with fix)
ASAN with fix: 61 skipped
$ BUN_DEBUG_QUIET_LOGS=1 bun scripts/build.ts --profile=debug --quiet test "--reporter=junit" "--reporter-outfile=/tmp/mechgate.xml" test/bundler/bundler_cjs.test.ts test/bundler/esbuild/default.test.ts test/bundler/transpiler/transpiler.test.js test/cli/run/esm-cjs-detection.test.ts test/js/bun/transpiler/repl-transform.test.ts
bun test v1.4.1 (d578a8c70)

test/bundler/bundler_cjs.test.ts:
(pass) bundler > cjs/__toESM_import_syntax_with_esModule [879.46ms]
(pass) bundler > cjs/__toESM_import_syntax_without_esModule [379.50ms]
(pass) bundler > cjs/__toESM_import_syntax_function [374.88ms]
(pass) bundler > cjs/__toESM_import_syntax_primitive [351.15ms]
(pass) bundler > cjs/__toESM_import_syntax_named_and_default [378.72ms]
(pass) bundler > cjs/__toESM_import_syntax_namespace [364.94ms]
(pass) bundler > cjs/__toESM_target_node [375.59ms]
(pass) bundler > cjs/__toESM_target_browser [370.73ms]
(pass) bundler > cjs/__toESM_target_bun [364.20ms]
(pass) bundler > cjs/__toESM_format_esm [386.07ms]
(pass) bundler > cjs/__toESM_format_cjs_with_import [379.81ms]
(pass) bundler > cjs/__toESM_mjs_reexport [370.14ms]
(pass) bundler > cjs/__toESM_mjs_reexport_with_esModule [379.93
... (truncated)

release with fix: 61 skipped
$ bun scripts/build.ts --profile=release
[configured] bun-profile → bun (stripped)
  target       linux-x64-gnu
  build type   Release
  build dir    ./build/release
  revision     56bb0dd7a7
  features     baseline

23 deps, 131 codegen, 1172 objects in 614ms

ninja: Entering directory `/workspace/bun/build/release'
[1/1244] gen ErrorCode+*.h
[2/1244] install /workspace/bun
bun install v1.4.1-canary.1 (d578a8c70)

Checked 26 installs across 63 packages (no changes) [8.00ms]
[3/1244] install /workspace/bun/packages/bun-error
bun install v1.4.1-canary.1 (d578a8c70)

Checked 1 install across 2 packages (no changes) [5.00ms]
[4/1244] gen bindgenv2
[5/1244] install /workspace/bun/src/node-fallbacks
bun install v1.4.1-canary.1 (d578a8c70)

Checked 111 installs across 104 packages (no changes) [5.00ms]
[6/1244] fetch zlib
[zlib] up to date
[7/1244] fetch libjpeg-turbo
[libjpeg-turbo] up to date
[8/1217] gen node-fallbacks/react-refresh.js
Bundled 1 module in 12ms

  react-refresh.js  4.81 KB  (entry point)

[9/1217] gen .bind.ts → GeneratedBindings.cpp
[10/1217] gen bake.{client,server,error}.js
-> bake.client.js, bake.server.js, bake.error.js
[11/1217] fetch tinycc
[
... (truncated)
diff hotspot
src/ast/expr.rs                               |  12 +-
 src/ast/symbol.rs                             |   4 +
 src/js_parser/lib.rs                          |  13 +-
 src/js_parser/p.rs                            |  90 ++++++-
 src/js_parser/parse/parse_entry.rs            |   9 +-
 src/js_parser/repl_transforms.rs              |   5 -
 src/js_parser/visit/visit_binary.rs           |  21 +-
 src/js_parser/visit/visit_stmt.rs             |  23 +-
 src/jsc/RuntimeTranspilerCache.rs             |   3 +-
 test/bundler/bundler_cjs.test.ts              | 354 +++++++++++++++++++++++++
 test/bundler/esbuild/default.test.ts          |  13 +-
 test/bundler/transpiler/transpiler.test.js    |  28 ++
 test/cli/run/esm-cjs-detection.test.ts        | 362 ++++++++++++++++++++++++++
 test/js/bun/transpiler/repl-transform.test.ts |   5 +
 14 files changed, 891 insertions(+), 51 deletions(-)

gate history · 2 passed · 0 rejected · iteration 2

evidence per changed file
file                                           reads  edits  tests
src/ast/expr.rs                                    3      4      0
src/ast/symbol.rs                                  2      3      0
src/js_parser/lib.rs                               2      3      0
src/js_parser/p.rs                                13     13      0
src/js_parser/parse/parse_entry.rs                 3      3      0
src/js_parser/repl_transforms.rs                   1      1      0
src/js_parser/visit/visit_binary.rs                5      3      0
src/js_parser/visit/visit_stmt.rs                  4      2      0
src/jsc/RuntimeTranspilerCache.rs                  3      3      0
test/bundler/bundler_cjs.test.ts                   1      4      0
test/bundler/esbuild/default.test.ts               0      0      0
test/bundler/transpiler/transpiler.test.js         0      0      0
test/cli/run/esm-cjs-detection.test.ts             0      3      0
test/js/bun/transpiler/repl-transform.test.ts      1      2      0

…dings agree

A file with an `export` statement or a top-level `await` is an ECMAScript
module. The parser still bound `module` and `exports` in such a file to
the CommonJS wrapper symbols. The bundler never declares those symbols
for an ESM file, so the output referenced an undeclared `module_<file>`
and `exports` aliased the ESM namespace object. When the file also
assigned to `exports.foo`, the CommonJS named-export path classified it
as CommonJS and the `export` statements were dropped without a
diagnostic, while the runtime loaded the same file as ESM.

Bind `module` and `exports` as CommonJS symbols only when the file has no
`export` or top-level `await`. Otherwise they are unbound globals, as in
esbuild, and the bundler warns once per variable when one of them is
assigned to. `require.main === module` is still rewritten to
`import.meta.main` in such files. The classification keeps Bun's
content-based rule: a `.mjs` or `"type": "module"` file with only
CommonJS syntax stays CommonJS.

`has_top_level_return` was read but never set. Set it in `s_return` when
the return is outside a function, so a file whose only CommonJS trait is
a top-level `return` gets the CommonJS wrapper instead of a bare `return`
in the output. The unwrapping of `exports.foo = ...` to an ESM export is
turned off for such a file. A top-level `return` next to `export` or
top-level `await` reports the error at the `return` with a note that
points at the ESM syntax.
@coderabbitai

coderabbitai Bot commented Aug 28, 2026 •

Copy link
Copy Markdown
Contributor

Review Change Stack

No actionable comments were generated in the recent review. 🎉

ℹ️ Recent review info
⚙️ Run configuration

Configuration used: Path: .coderabbit.yaml

Review profile: ASSERTIVE

Plan: Pro

Run ID: c26b2069-f887-42f2-b8f0-bc80f0d4a662

📥 Commits

Reviewing files that changed from the base of the PR and between 533b1b0 and 56bb0dd.

📒 Files selected for processing (8)
  • src/ast/expr.rs
  • src/ast/symbol.rs
  • src/js_parser/lib.rs
  • src/js_parser/p.rs
  • src/js_parser/parse/parse_entry.rs
  • src/js_parser/visit/visit_binary.rs
  • src/js_parser/visit/visit_stmt.rs
  • src/jsc/RuntimeTranspilerCache.rs

Included review availability: Your plan provides up to 10 included reviews per hour; 2 remain after this review.


Walkthrough

The parser now prioritizes explicit ESM syntax and top-level await when classifying files. It preserves CommonJS behavior for top-level return, warns about CommonJS globals in ESM, updates module reference tracking, and adds regression coverage.

Changes

ESM and CommonJS classification

Layer / File(s) Summary
CommonJS reference tracking
src/ast/expr.rs, src/js_parser/lib.rs, src/js_parser/visit/visit_binary.rs
Equality::RequireMainAndModule now carries the matched reference. Parser and equality handling use the new CommonJS module predicate.
Explicit ESM binding classification
src/ast/symbol.rs, src/js_parser/p.rs, src/js_parser/parse/parse_entry.rs
Explicit export syntax and top-level await now control module classification and CommonJS binding creation. Assignments to unbound module and exports produce one-time warnings with ESM diagnostic notes.
Top-level return behavior
src/js_parser/visit/visit_stmt.rs, src/jsc/RuntimeTranspilerCache.rs, src/js_parser/repl_transforms.rs
Top-level return is treated as CommonJS without ESM syntax and rejected in ESM. The cache version advances, and REPL transformation preserves the return inside its generated IIFE.
Classification and transform regression coverage
test/bundler/bundler_cjs.test.ts, test/bundler/esbuild/default.test.ts, test/bundler/transpiler/transpiler.test.js, test/cli/run/esm-cjs-detection.test.ts, test/js/bun/transpiler/repl-transform.test.ts
Tests cover classification, warnings, define substitution, module.require() rewriting, wrappers, runtime behavior, package and extension rules, and invalid top-level return combinations.

Suggested reviewers: jarred-sumner, alii, dylan-conway

Merge Risk: ⚪ Minimal · up to 56bb0

The parser changes align ESM/CJS classification with module and exports bindings, with the supplied targeted checks passing; no actionable merge-blocking risk remains.

🚥 Pre-merge checks | ✅ 3 | ❌ 1

❌ Failed checks (1 warning)

Check name Status Explanation Resolution
Out of Scope Changes check ⚠️ Warning The PR includes substantial ESM module/exports binding changes, warnings, and related tests that are not required by the directly linked issue #8908, which focuses on top-level return classification a… Link the issues that cover the ESM/module/exports binding work, or split that work into a separate pull request. If the broader scope is intentional, document why those changes are required for #8908.
✅ Passed checks (3 passed)
Check name Status Explanation
Linked Issues check ✅ Passed The changes satisfy issue #8908 by tracking top-level return, classifying eligible files as CommonJS, emitting a CommonJS wrapper, and rejecting top-level return when ESM syntax is present.
Description check ✅ Passed The description clearly explains the problem, implementation, scope, verification, and regression coverage. It does not use the exact template headings, but it provides the required information in equ…
Title check ✅ Passed The title clearly and concisely summarizes the main change: aligning ESM/CJS classification with module and exports bindings.
Full details: Out of Scope Changes check

Explanation

The PR includes substantial ESM module/exports binding changes, warnings, and related tests that are not required by the directly linked issue #8908, which focuses on top-level return classification and CommonJS wrapping.

Full details: Description check

Explanation

The description clearly explains the problem, implementation, scope, verification, and regression coverage. It does not use the exact template headings, but it provides the required information in equivalent sections.

  • Fix all pre-merge checks with AI

Comment @coderabbitai help to get the list of available commands.

@robobun

robobun commented Aug 28, 2026 •

Copy link
Copy Markdown
Collaborator Author
Updated 12:36 AM PT - Aug 29th, 2026

❌ @robobun, your commit 56bb0dd has 1 failures in Build #108188 (All Failures):


🧪   To try this PR locally:

bunx bun-pr 40840

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

bun-40840 --bun

@robobun

robobun commented Aug 28, 2026 •

Copy link
Copy Markdown
Collaborator Author

Status: ready for review.

Reproduced on 1.4.1 with the four cases from the report (mixed export + module.exports.b, top-level await + module.exports, typeof module/typeof exports in an ESM file, top-level return), plus exports.foo = 1; if (x) return; exports.bar = 2; failing to bundle. All five behave the same under bun run and bun build with this branch. The new tests in test/cli/run/esm-cjs-detection.test.ts and test/bundler/bundler_cjs.test.ts fail on 1.4.1 and pass here.

CI on 56bb0dd (build 108188): 181 of 182 jobs passed. The one red lane is test/js/web/url/url.test.ts on darwin x64 (special-scheme hosts use the Unicode 16 IDNA table), which fails on main as well and is unrelated to this change. The other failures in the build were flaky tests that passed on retry.

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

Comment thread src/js_parser/visit/visit_binary.rs
…efine in ESM files

Carries over the distinct cases from #37371. In a file with ESM exports,
`module.id`, `module.require()` and `exports.foo` print as written,
minified identifiers do not take the names `module` and `exports`, and
`--format=cjs` output runs under node. `Bun.Transpiler` applies `define`
for `module` and `exports` only in such files, and keeps `module.require()`
as written there.
Comment thread src/ast/expr.rs Outdated
Comment thread src/ast/symbol.rs Outdated
Comment thread src/js_parser/lib.rs Outdated
Comment thread src/js_parser/p.rs Outdated
Comment thread src/js_parser/p.rs Outdated
Comment thread src/js_parser/p.rs Outdated
Comment thread src/js_parser/p.rs Outdated
Comment thread src/js_parser/p.rs Outdated
Comment thread src/js_parser/p.rs Outdated
Comment thread src/js_parser/parse/parse_entry.rs Outdated
Comment thread src/js_parser/visit/visit_binary.rs Outdated
Comment thread src/js_parser/visit/visit_stmt.rs Outdated
Comment thread src/jsc/RuntimeTranspilerCache.rs Outdated
@robobun

robobun commented Aug 29, 2026

Copy link
Copy Markdown
Collaborator Author

Also fixes #8908: bun build --target=bun on a file with a top-level return now emits a __commonJS wrapper, verified on this branch. The PR body now links the issue.

#35638 and #37371 are closed as superseded. Their tests pass on this branch, except the three #35638 tests that expected a top-level return in a .mjs, import, or import.meta file to stay ESM. This PR treats those files as CommonJS on purpose. The #37371 tests with no counterpart here are carried over in 533b1b0 (test only): module.id and module.require() reads, minified identifiers, --format=cjs under node, and the Bun.Transpiler define and module.require() cases.

Comment thread src/ast/expr.rs Outdated
Comment thread src/js_parser/p.rs Outdated
@robobun

robobun commented Aug 29, 2026

Copy link
Copy Markdown
Collaborator Author

Every comment the comment-cop flagged is now a single line (9fdb08c, 56bb0dd). No code changed in those two commits.

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

Jarred-Sumner pushed a commit that referenced this pull request Sep 3, 2026
…evel function (#41269)

### Problem
- With ES module output, `bun build` lifts a CommonJS file out of its
`__commonJS` wrapper. With a top-level `function top() {}` and `var
top`, the bundle throws: `SyntaxError: Cannot declare a var variable
that shadows a let/const/class variable: 'top'.`
- In a function, the two declarations share one binding. In module code,
a top-level function is lexical, so the `var` is an early error. The
lifts (`src/js_parser/parse/parse_entry.rs:1441`, `:1605`) do not check
this.
- A named import fails in 1.4.0. Since #41162, a default import fails
too.

### Fix
- `declare_symbol` and `hoist_symbols` now set
`has_top_level_function_merged_with_var` when a `var` and a function
declaration in the module scope share one symbol. A repeated `var` or
function, or a merge inside a nested function, does not set it.
- With the flag, the `exports.foo` lift takes its existing
`needs_decl_count > 0` deoptimization, and the `export *` lift does not
run. That leaves `REDECLARED_BY_VAR` (#41251) unused, so this PR removes
it.
- Verified: `test/bundler/bundler_cjs2esm.test.ts` (three new tests, one
changed), and 13 suites listed in the Notes.
- Self-reviewed: 11 concerns raised, 8 addressed. The other 3 are older
bugs, in the Notes.

### Background
- The lift turns `exports.foo = value` into `var $foo = value` plus an
ES export, only for ESM output (`src/bundler/ParseTask.rs:2503`).
- A symbol merge binds two declarations to one symbol.
- For a sloppy-mode `{ function f() {} }`, the parser emits `let f2 =
function () {}; var f = f2;`. That `var` conflicts too.

<details><summary>Notes</summary>

I found this by reading the output of a lifted file. No issue reports
it.

Repro (a 1.4.1 canary, and main):

```js
// lib.cjs
function top() { return "declaration"; }
var top = function () { return "var"; };
exports.top = top;
```

```js
// entry.mjs
import { top } from "./lib.cjs";
console.log(top());
```

`bun build ./entry.mjs --target=bun --outfile=o.js && bun o.js` throws.
Node prints `var`.

The same error occurs for the reverse order, for a `var` in an `if`
block, for a sloppy block-level function next to a top-level one, and
for `function*`. The first new test covers each case, and a default
import. `--format=cjs` and `--format=iife` do not lift, so they are not
affected.

Test results:

| Test | `USE_SYSTEM_BUN=1` (1.4.1 canary, before #41162) | debug build
of main | `bun bd` with this PR |
| --- | --- | --- | --- |
| `VarWithTheNameOfATopLevelFunctionKeepsWrapper` | fail (1 of 6
wrappers) | fail (0 of 6) | pass |
| `OtherRedeclarationsAreStillLifted` | pass | pass | pass |
| `ReactSpecificUnwrappingVarWithTheNameOfAFunctionKeepsWrapper` | pass
(predates #41188) | fail | pass |

On the debug build of main, the default-import case and the `export *`
case both throw the `SyntaxError` at load.

`OtherRedeclarationsAreStillLifted` guards the checks. I removed the
module-scope check in `declare_symbol` on a local build, and this test
failed.

This PR also changes `MethodCallKeepsThisWhenAVarRedeclaresTheFunction`
from #41251. That test read the printed calls, because the bundle did
not load. The file now keeps its wrapper, so the test runs the bundle.
The output `lib lib lib lib` shows that each call gets `module.exports`
as `this`. On main this test fails too.

`REDECLARED_BY_VAR` marked a function declaration that a `var` merged
into. Its only reader, `mark_commonjs_exports_that_ignore_this`, runs
only for a file that is not deoptimized. A file with that merge is now
deoptimized, so the flag can no longer change the output. The #41251
tests still pass.

Other suites that pass on this branch: `bundler_cjs`, `bundler_barrel`,
`bundler_splitting`, `bundler_minify`, `bundler_dynamic_import_dce`,
`bundler_regressions`, `bundler_edgecase`, `esbuild/default`,
`esbuild/dce`, `esbuild/importstar`, `esbuild/importstar_ts`,
`esbuild/ts`, and `transpiler/transpiler.test.js`.

Follow-ups that this PR does not change: bun runs a `.js` file with no
module syntax and no CommonJS features as module code. So `bun plain.js`
fails on the same two declarations. An HTML entry has the same problem,
because the bundler emits a classic `<script src>` as `type="module"`.
That is a separate decision about plain scripts. A top-level `return`,
`new.target`, or `arguments` in a lifted file also fails at load. #40840
covers the `return` case.

Alternative that I did not take: keep the lift, and print the merged
`var top = x` as the assignment `top = x`. Module code allows an
assignment to a function binding. That keeps tree shaking for these
files, but each `var` form (declaration lists, `for` heads,
destructuring) needs a rewrite. I found no real package with this
pattern, so the wrapper costs nothing in practice.

</details>

<!-- robobun:evidence:begin -->

---

**[human-review]** gate passed · iteration 0 · 4 files touched

<details><summary>fails on main (without fix)</summary>

```console
ASAN without fix: 3 FAILED
$ BUN_DEBUG_QUIET_LOGS=1 bun scripts/build.ts --profile=debug --quiet test "--reporter=junit" "--reporter-outfile=/tmp/pr_gate.xml" "test/bundler/bundler_cjs2esm.test.ts"
bun test v1.4.1 (a6c4cc2)

test/bundler/bundler_cjs2esm.test.ts:
(pass) bundler > cjs2esm/ModuleExportsFunction [720.52ms]
(pass) bundler > cjs2esm/ImportNamedFromExportStarCJSModuleRef [336.99ms]
(pass) bundler > cjs2esm/ImportNamedFromExportStarCJS [401.76ms]
(pass) bundler > cjs2esm/BadNamedImportNamedReExportedFromCommonJS [382.10ms]
(pass) bundler > cjs2esm/ExportsFunction [319.42ms]
(pass) bundler > cjs2esm/ModuleExportsFunctionTreeShaking [389.00ms]
(pass) bundler > cjs2esm/ModuleExportsEqualsRequire [320.29ms]
(pass) bundler > cjs2esm/ModuleExportsEqualsRequireEntryPoint [358.71ms]
(pass) bundler > cjs2esm/ModuleExportsEqualsRequireEntryPointImportedByEntryPoint [446.21ms]
(pass) bundler > cjs2esm/ModuleExportsEqualsRequireEntryPointImportedByEntryPointSplitting [444.19ms]
(pass) bundler > cjs2esm/ModuleExportsEqualsRequireTwoEntryPoints [401.64ms]
(pass) bundler > cjs2esm/ModuleExportsBasedOnNodeEnvProduction [511.62ms]
(pass) bundler > cjs2esm/ModuleExportsBasedOnNodeEnvDevelopment [593
... (truncated)

release without fix: 45 FAILED
bun test v1.4.1-canary.1 (a6c4cc2)

test/bundler/bundler_cjs2esm.test.ts:
(pass) bundler > cjs2esm/ModuleExportsFunction [19.88ms]
(pass) bundler > cjs2esm/ImportNamedFromExportStarCJSModuleRef [9.13ms]
(pass) bundler > cjs2esm/ImportNamedFromExportStarCJS [8.11ms]
(pass) bundler > cjs2esm/BadNamedImportNamedReExportedFromCommonJS [7.37ms]
(pass) bundler > cjs2esm/ExportsFunction [7.41ms]
(pass) bundler > cjs2esm/ModuleExportsFunctionTreeShaking [7.45ms]
(pass) bundler > cjs2esm/ModuleExportsEqualsRequire [7.14ms]
(pass) bundler > cjs2esm/ModuleExportsEqualsRequireEntryPoint [8.64ms]
(pass) bundler > cjs2esm/ModuleExportsEqualsRequireEntryPointImportedByEntryPoint [9.85ms]
(pass) bundler > cjs2esm/ModuleExportsEqualsRequireEntryPointImportedByEntryPointSplitting [9.44ms]
(pass) bundler > cjs2esm/ModuleExportsEqualsRequireTwoEntryPoints [9.54ms]
(pass) bundler > cjs2esm/ModuleExportsBasedOnNodeEnvProduction [10.98ms]
(pass) bundler > cjs2esm/ModuleExportsBasedOnNodeEnvDevelopment [10.61ms]
(pass) bundler > cjs2esm/ModuleExportsEqualsRuntimeCondition [9.39ms]
(pass) bundler > cjs2esm/UnwrappedModuleRequireAssigned [8.74ms]
(pass) bundler > cjs2esm/UnwrappedModuleReq
... (truncated)
```

</details>

<details><summary>passes on PR (with fix)</summary>

```console
ASAN with fix: all passed
$ BUN_DEBUG_QUIET_LOGS=1 bun scripts/build.ts --profile=debug --quiet test "--reporter=junit" "--reporter-outfile=/tmp/pr_gate.xml" "test/bundler/bundler_cjs2esm.test.ts"
bun test v1.4.1 (a6c4cc2)

test/bundler/bundler_cjs2esm.test.ts:
(pass) bundler > cjs2esm/ModuleExportsFunction [719.29ms]
(pass) bundler > cjs2esm/ImportNamedFromExportStarCJSModuleRef [405.07ms]
(pass) bundler > cjs2esm/ImportNamedFromExportStarCJS [405.58ms]
(pass) bundler > cjs2esm/BadNamedImportNamedReExportedFromCommonJS [315.27ms]
(pass) bundler > cjs2esm/ExportsFunction [320.61ms]
(pass) bundler > cjs2esm/ModuleExportsFunctionTreeShaking [324.47ms]
(pass) bundler > cjs2esm/ModuleExportsEqualsRequire [321.18ms]
(pass) bundler > cjs2esm/ModuleExportsEqualsRequireEntryPoint [366.12ms]
(pass) bundler > cjs2esm/ModuleExportsEqualsRequireEntryPointImportedByEntryPoint [399.89ms]
(pass) bundler > cjs2esm/ModuleExportsEqualsRequireEntryPointImportedByEntryPointSplitting [380.48ms]
(pass) bundler > cjs2esm/ModuleExportsEqualsRequireTwoEntryPoints [336.30ms]
(pass) bundler > cjs2esm/ModuleExportsBasedOnNodeEnvProduction [464.06ms]
(pass) bundler > cjs2esm/ModuleExportsBasedOnNodeEnvDevelopment [502
... (truncated)

release with fix: all passed
$ bun scripts/build.ts --profile=release
[configured] bun-profile → bun (stripped)
  target       linux-x64-gnu
  build type   Release
  build dir    ./build/release
  revision     cf871f6
  features     baseline

23 deps, 131 codegen, 1172 objects in 983ms

ninja: Entering directory `/workspace/bun/build/release'
[1/1244] install /workspace/bun
bun install v1.4.1-canary.1 (a6c4cc2)

Checked 25 installs across 62 packages (no changes) [5.00ms]
[2/1244] gen bindgenv2
[3/1244] gen .bind.ts → GeneratedBindings.cpp
[4/1244] fetch zlib
[zlib] up to date
[5/1244] fetch libjpeg-turbo
[libjpeg-turbo] up to date
[6/1217] fetch tinycc
[tinycc] up to date
[7/1216] gen bake.{client,server,error}.js
-> bake.client.js, bake.server.js, bake.error.js
[8/1216] install /workspace/bun/packages/bun-error
bun install v1.4.1-canary.1 (a6c4cc2)

Checked 1 install across 2 packages (no changes) [1.00ms]
[9/1216] gen ErrorCode+*.h
[10/1216] gen JSBuffer.lut.h
Generating /workspace/bun/build/release/codegen/JSBuffer.lut.h from /workspace/bun/src/jsc/bindings/JSBuffer.cpp
[11/1216] install /workspace/bun/src/node-fallbacks
bun install v1.4.1-canary.1 (a6c4cc2)

Checked 111 instal
... (truncated)
```

</details>

<details><summary>diff hotspot</summary>

```
src/ast/symbol.rs                    |   4 -
 src/js_parser/p.rs                   |  31 ++++++--
 src/js_parser/parse/parse_entry.rs   |   3 +-
 test/bundler/bundler_cjs2esm.test.ts | 137 +++++++++++++++++++++++++++++++++--
 4 files changed, 154 insertions(+), 21 deletions(-)
```

</details>

**gate history** · 1 passed · 0 rejected · iteration 0

<details><summary>evidence per changed file</summary>

```
file                                  reads  edits  tests
src/ast/symbol.rs                         1      2     35
src/js_parser/p.rs                        8     12     35
src/js_parser/parse/parse_entry.rs        3      2     34
test/bundler/bundler_cjs2esm.test.ts      6      7     34
```

</details>

<!-- robobun:evidence:end -->
Jarred-Sumner pushed a commit that referenced this pull request Sep 23, 2026
#43745)

### Problem
- 16 struct fields in 11 crates are never read. Two options are never
set: `BufferWriter.append_null_byte` is never `true`,
`TransposeState.import_record_tag` is never `Some`.
- No lint reports them. rustc's `dead_code` pass counts `x.f = v` as a
use of `f`. hawk counts the initializer as a reference.

### Fix
- Delete each field with its initializers and stores, and the two
branches that the options guard. 18 files, 105 lines removed.
- Candidates come from a compiler probe: `#[deprecated]` on 12,134
struct fields, `cargo check --force-warn deprecated` for Linux, Windows,
and macOS, then a syn pass marks each use as read or write. 134 fields
have no read. Most stay (Notes).
- Verified: `bun bd`, `bun run rust:check-all` (12 of 12 targets),
`cargo check --workspace --all-targets`, and the tests in the Notes.
- Self-reviewed: 4 concerns raised, 2 addressed. Rejected as outside a
deletion: the now vestigial `written_without_trailing_zero()` and an
older stale comment.

### Background
- `MultiArrayList<T>` keeps each field of `T` in its own column. Code
reads a column by name as a string (`items::<"name", _>()`), so the
compiler sees no read. Those fields stay.
- `BufferWriter` is the JS printer's output buffer. `done()` could
append a NUL byte. No caller asks for it.

### Downsides
- `bun_core::output::Source` becomes `Send + Sync`, because its raw
pointer fields are gone. It lives in a `thread_local!` only.
- The `bun_resolver::Result` in `_resolve` now drops when `_resolve`
returns. It has no `Drop` effect: paths are borrowed, fds are `Copy`.

<details><summary>Notes</summary>

#### Removed, one line each
- `bun_core::output::Source`: `buffered_stream`,
`buffered_error_stream`, `stream`, `error_stream` (`*mut io::Writer`).
`init()` cached them, nothing read them. The accessors of the same names
return `*_backing.new_interface()`, which is a pointer cast with no side
effect.
- `bun_bundler`: `InputFileInfo.import_count` (`MetafileBuilder.rs`).
- `bun_install`: `SecurityScanSubprocess.stderr_data` (an empty `Vec`
that is never filled, the scanner's stderr is inherited),
`PackageManager.root_progress_node`. The `progress.start(b"", 0)` call
stays, because it starts the progress root.
- `bun_js_parser`: `PropertyOpts.async_range`,
`ScanPassResult.approximate_newline_count`,
`TransposeState.import_record_tag`. #16624 replaced the only setter of
the tag with `import_loader`, which stays.
- `bun_jsc`: `VirtualMachine.has_terminated` (its readers were debug
panics in `enqueue_task_concurrent`, which #37075 removed),
`ResolveFunctionResult.result` (seven stores, no read). Nothing borrows
from the stored value: `path` and `query_string` point into the
resolver's arena and the specifier.
- `bun_js_printer`: `BufferWriter.append_null_byte`, with the seven `=
false` stores in `bun_jsc` and `bun_runtime`.
- `bun_libarchive`: `BufferReadStream.reading`.
- `bun_runtime`: `WindowsState.is_server` (`ipc.rs`, Windows only). Its
last reader went away in #18688, before the Rust port.
- `bun_csrf`: `GenerateOptions.encoding`. `csrf_jsc.rs` encodes the
token itself, and `VerifyOptions.encoding` stays.
- `bun_resolver`: `DataURL.url` (always `String::EMPTY`).
- `bun_s3_signing`: `S3CredentialsWithOptions.virtual_hosted_style`. No
code names it. Every reader uses `credentials.virtual_hosted_style` on
the inner `S3Credentials`.

#### Passed the probe, kept on purpose
- `MultiArrayList` columns: fields of `JSMeta`, `File`, `InputFile`,
`BundledAst`, `Entry`, `Node`, `WatchItem`, `LineOffsetTable`,
`ServerComponentBoundary`, and others.
- Owners of memory that other fields borrow:
`ParseResult.source_contents_backing`, `LinkerContext.unique_key_buf`,
`OutputFile.owned_src_path_text`, `HTTPResponseMetadata.owned_buf`,
`PackageJSON.source_contents` and `json_tape`,
`MatchedRoute.pathname_backing`, `SignResult.content_md5`,
`KEventWaker.machport_buf`.
- Values whose drop has an effect: `Repl.last_error` (a GC protect),
`SecurityScanSubprocess.process`, `Watcher.thread`.
- Options that are unfinished features or open bugs, not dead code:
`md::Options.hard_soft_breaks` and `underline` (#39495, #39493),
`jsc::virtual_machine::Options.dns_result_order` (#40703),
`P.has_top_level_return` (#40840), `ReactRefresh.last_hook_seen` and
`force_reset`, `DebugOptions.dump_environment_variables` (the
`--dump-environment-variables` flag is parsed and ignored).
- Kept by a comment in the source:
`AllocatorConfiguration.long_running`, `DumpStackTraceOptions.skip_*`,
`NameOfSymbol.has_property_key_comment`.
- Removal needs more than a deletion: `PostgresSQLQuery` `Flags.is_done`
(set by the JS `done()` call), `WorkerPipe.done` (leaves two empty
reader callbacks), `WTFTimer.repeat` (leaves an unused FFI parameter),
`MaxHeapAllocator.len` (leaves an empty `reset()`),
`ReadToEndResult.err` (callers drop read errors, which looks like a
bug), `Subcommand::Pack` with `PACK_PARAMS` (`bun pm pack` runs as
`Subcommand::Pm`, so the pack help text is unreachable).

#### Self-review
Three independent read-only passes tried to prove each deletion wrong
(readers through raw pointers, `offset_of!`, C++ layout mirrors, macros,
other `cfg`s, unit tests, the Zig originals, drop effects). None found a
reader. Concerns raised: the `Send + Sync` change of `Source` (now in
Downsides), the style of the kept `progress.start` call (now `let _ =`,
as in `install_with_manager.rs`),
`BufferWriter::written_without_trailing_zero()` (nine callers, it only
strips NUL bytes that the printer never appends now, a follow-up), and a
comment above `ResolveFunctionResult.path` that names a function which
no longer exists (it predates this change).

#### Other scans of this run, all clean or already in an open pull
request
- A relink of the debug binary with `--gc-sections --print-gc-sections`,
and a zero-mention identifier index over `src/`, `packages/`, `scripts/`
and `build/debug/codegen/`.
- `macro_rules!` without an invocation, Cargo features that nothing
enables, `#if 0` and never-true `#if` blocks in the C++ bindings,
patches that no dependency script applies, unused exports under
`scripts/`, `tsc --noUnusedLocals` over `src/js` and `scripts/`.
- A syn scan for enum variants that no expression constructs. The hits
are FFI code tables, derive-constructed variants, or already claimed.

#### Overlap with the open dead-code pull requests
- Every removed line was checked per file against the diffs of the 36
open dead-code pull requests. None of them deletes these lines. Several
touch the same files in other places (`output.rs`, `VirtualMachine.rs`,
`parser.rs`, `p.rs`, `js_printer/lib.rs`, `jsc_hooks.rs`,
`PackageManager.rs`, `data_url.rs`).

#### Tests run with the debug build
`test/js/bun/util/csrf.test.ts` (31 pass),
`test/bundler/metafile.test.ts` (65 pass), `test/js/bun/archive.test.ts`
(108 pass), `test/bundler/transpiler/transpiler.test.js` (222 pass),
`test/js/bun/resolve/import-empty.test.js`,
`test/js/bun/resolve/esModule.test.ts`,
`test/cli/install/bun-install-security-provider.test.ts` (43 pass),
`test/internal/source-lints/` (195 pass). Also a `data:` URL import, and
`bun install` under a pty so that the progress root starts.

No test is added. The change deletes fields that nothing reads, so there
is no behavior to assert, and `test/internal/source-lints/CLAUDE.md`
asks for no tests that pin dead symbols.

</details>

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.

top-level return should hint to the transpiler that a module is commonjs / bundler can emit top-level return

2 participants