Skip to content

bundler: keep the CommonJS wrapper when a file reads top-level arguments or new.target - #42595

Open
robobun wants to merge 1 commit into
mainfrom
robobun/01d58529/keep-cjs-wrapper-for-arguments-new-target
Open

robobun wants to merge 1 commit into
mainfrom
robobun/01d58529/keep-cjs-wrapper-for-arguments-new-target

Conversation

@robobun

@robobun robobun commented Sep 13, 2026 •

Copy link
Copy Markdown
Collaborator

Problem

  • With ES module output, bun build lifts a CommonJS file out of its __commonJS wrapper. If the file reads top-level arguments or new.target, the bundle fails at load: ReferenceError: arguments is not defined or SyntaxError: new.target is only valid inside functions or static blocks. The build exits 0.
  • The two lifts in src/js_parser/parse/parse_entry.rs (:1462, :1608) do not check for these reads.
  • A named import fails in 1.4.0 too. Since bundler: bind the default import of a lifted CommonJS module to its namespace #41162 (1.4.1), a default import fails as well.

Fix

  • P::reads_wrapper_arguments_or_new_target (src/js_parser/p.rs:2020) is true when module-scope code uses an arguments that the file does not declare, or has a new.target outside every function and class element. The visitor finds the latter with the rule value_for_this uses for this.
  • With that, the exports.foo lift takes its existing deoptimization, and the export * lift does not run. The file keeps its wrapper, a regular function (generateCodeForFileInChunkJS.rs:590) that has both bindings.
  • Verified: test/bundler/bundler_cjs2esm.test.ts (four new tests, three fail on 1.4.3 canary). The Notes list the other suites.
  • Self-reviewed: no blocking concern. The Notes cover the scope concerns (no user report, no measured package affected, plain .js files).

Background

  • The lift turns exports.foo = value into var $foo = value plus an ES export, so the file becomes module code. It runs only for ES module output.
  • A CommonJS file runs in a function, and top-level arguments and new.target are that function's. Arrow functions and computed class keys read them from the enclosing scope.
  • js_parser: make the ESM/CJS classification and the module/exports bindings agree #40840 covers the top-level return. This PR does not change it.
Notes

No GitHub issue reports this. A differential test of bun run against bun build output found it.

The build prints no warning. typeof arguments alone changes from "object" to "undefined" with no error at all. 1.4.0 kept the wrapper for a default import, because the linker put the wrapper back for that import form. #41162 made a default import of a lifted file bind to its namespace, so the linker no longer does that.

The parser puts an identifier that the file does not declare in the module scope as an unbound symbol. The check looks up arguments there and reads its use count, the same way uses_exports_ref and uses_module_ref read theirs.

Repro (1.4.1, 1.4.2, 1.4.3 canary, and main):

// args.cjs
exports.n = arguments.length;
exports.t = typeof arguments[0];
// nt.cjs
exports.nt = typeof new.target;
// e1.mjs
import a from "./args.cjs";
console.log("arguments:", a.n, a.t);
// e2.mjs
import b from "./nt.cjs";
console.log("new.target:", b.nt);

bun build e1.mjs --target=bun --outfile=o.mjs && bun o.mjs

e1 e2
bun run, node arguments: 5 object new.target: undefined
bundle from 1.4.0 arguments: 2 object new.target: undefined
bundle from 1.4.1 to main ReferenceError: arguments is not defined SyntaxError: new.target is only valid inside functions or static blocks.
bundle from this branch arguments: 2 object new.target: undefined

The count is 2 in a bundle because __commonJS calls the wrapper with (exports, module).

Also checked by hand on this branch: --target=node and --target=browser, --minify, --splitting, --format=iife, a named import, require() of the file, import() of the file with and without --splitting, and the file as the entry point. All print the 1.4.0 result. --format=cjs never lifted.

Reach: two scans of published npm packages during self-review (919 packages with 62,704 files, and about 1,127 packages with 30,715 files) found no file that takes the new path. So the bundle of every measured package is unchanged, and the only bundles that change are ones that fail to load today.

What the check does not do:

  • It does not change how bun classifies a file. A .js file with no CommonJS marker and no package.json "type" is still module code, so bun plain.js with a top-level new.target still fails. That is the plain-script decision that bundler: keep the CommonJS wrapper when a var has the name of a top-level function #41269 already lists as a follow-up.
  • A dead arguments reference (if (false) arguments) does not keep the wrapper, because the use count ignores dead code and the bundler removes the branch. This is how uses_exports_ref and uses_module_ref work. new.target has no symbol, so a flag records it, and the flag also counts dead code. A new.target that stays in the output is a parse error, so the flag is conservative.
  • A file that declares its own arguments (var arguments = 1) is not affected. That binding is a strict-mode error in an ES module bundle with or without the wrapper. Reserve "arguments", "eval" and "await" as binding names in bundle output #34361 (open) renames it.
  • The third lift, the module.exports = require("x") redirect (parse_entry.rs:1486), needs that statement to be the only one in the file, so it cannot contain either read.
  • A direct eval that reads them already keeps the wrapper.

Tests:

Test USE_SYSTEM_BUN=1 (1.4.3 canary) bun bd with this PR
TopLevelArgumentsKeepsWrapper fail (0 of 4 wrappers) pass
TopLevelNewTargetKeepsWrapper fail (0 of 4 wrappers) pass
ArgumentsAndNewTargetOfAFunctionAreStillLifted pass pass
ReactSpecificUnwrappingTopLevelArgumentsOrNewTargetKeepsWrapper fail (0 of 2 wrappers) pass

Other suites that pass on this branch (debug, ASAN): all of bundler_cjs2esm, bundler_cjs, bundler_edgecase, esbuild/default, bundler_regressions, bundler_npm.


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

fails on main (without fix)
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.3 (b99371011)

test/bundler/bundler_cjs2esm.test.ts:
(pass) bundler > cjs2esm/ModuleExportsFunction [846.01ms]
(pass) bundler > cjs2esm/ImportNamedFromExportStarCJSModuleRef [443.29ms]
(pass) bundler > cjs2esm/ImportNamedFromExportStarCJS [559.61ms]
(pass) bundler > cjs2esm/BadNamedImportNamedReExportedFromCommonJS [407.35ms]
(pass) bundler > cjs2esm/ExportsFunction [479.78ms]
(pass) bundler > cjs2esm/ModuleExportsFunctionTreeShaking [362.79ms]
(pass) bundler > cjs2esm/ModuleExportsEqualsRequire [611.76ms]
(pass) bundler > cjs2esm/ModuleExportsEqualsRequireEntryPoint [661.18ms]
(pass) bundler > cjs2esm/ModuleExportsEqualsRequireEntryPointImportedByEntryPoint [510.01ms]
(pass) bundler > cjs2esm/ModuleExportsEqualsRequireEntryPointImportedByEntryPointSplitting [521.41ms]
(pass) bundler > cjs2esm/ModuleExportsEqualsRequireTwoEntryPoints [544.32ms]
(pass) bundler > cjs2esm/ModuleExportsBasedOnNodeEnvProduction [569.83ms]
(pass) bundler > cjs2esm/ModuleExportsBasedOnNodeEnvDevelopment [796
... (truncated)

release without fix: 3 FAILED
bun test v1.4.3-canary.1 (b99371011)

test/bundler/bundler_cjs2esm.test.ts:
(pass) bundler > cjs2esm/ModuleExportsFunction [65.19ms]
(pass) bundler > cjs2esm/ImportNamedFromExportStarCJSModuleRef [17.97ms]
(pass) bundler > cjs2esm/ImportNamedFromExportStarCJS [18.02ms]
(pass) bundler > cjs2esm/BadNamedImportNamedReExportedFromCommonJS [39.39ms]
(pass) bundler > cjs2esm/ExportsFunction [15.87ms]
(pass) bundler > cjs2esm/ModuleExportsFunctionTreeShaking [18.54ms]
(pass) bundler > cjs2esm/ModuleExportsEqualsRequire [14.20ms]
(pass) bundler > cjs2esm/ModuleExportsEqualsRequireEntryPoint [93.27ms]
(pass) bundler > cjs2esm/ModuleExportsEqualsRequireEntryPointImportedByEntryPoint [18.10ms]
(pass) bundler > cjs2esm/ModuleExportsEqualsRequireEntryPointImportedByEntryPointSplitting [21.52ms]
(pass) bundler > cjs2esm/ModuleExportsEqualsRequireTwoEntryPoints [19.95ms]
(pass) bundler > cjs2esm/ModuleExportsBasedOnNodeEnvProduction [19.99ms]
(pass) bundler > cjs2esm/ModuleExportsBasedOnNodeEnvDevelopment [17.22ms]
(pass) bundler > cjs2esm/ModuleExportsEqualsRuntimeCondition [15.33ms]
(pass) bundler > cjs2esm/UnwrappedModuleRequireAssigned [20.08ms]
(pass) bundler > cjs2esm/Unwrap
... (truncated)
passes on PR (with fix)
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.3 (b99371011)

test/bundler/bundler_cjs2esm.test.ts:
(pass) bundler > cjs2esm/ModuleExportsFunction [864.54ms]
(pass) bundler > cjs2esm/ImportNamedFromExportStarCJSModuleRef [396.62ms]
(pass) bundler > cjs2esm/ImportNamedFromExportStarCJS [520.08ms]
(pass) bundler > cjs2esm/BadNamedImportNamedReExportedFromCommonJS [532.13ms]
(pass) bundler > cjs2esm/ExportsFunction [419.16ms]
(pass) bundler > cjs2esm/ModuleExportsFunctionTreeShaking [411.24ms]
(pass) bundler > cjs2esm/ModuleExportsEqualsRequire [374.34ms]
(pass) bundler > cjs2esm/ModuleExportsEqualsRequireEntryPoint [468.80ms]
(pass) bundler > cjs2esm/ModuleExportsEqualsRequireEntryPointImportedByEntryPoint [465.36ms]
(pass) bundler > cjs2esm/ModuleExportsEqualsRequireEntryPointImportedByEntryPointSplitting [527.44ms]
(pass) bundler > cjs2esm/ModuleExportsEqualsRequireTwoEntryPoints [425.73ms]
(pass) bundler > cjs2esm/ModuleExportsBasedOnNodeEnvProduction [652.09ms]
(pass) bundler > cjs2esm/ModuleExportsBasedOnNodeEnvDevelopment [584
... (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     144ad5f5ed
  features     baseline

23 deps, 131 codegen, 1176 objects in 790ms

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

Checked 22 installs across 61 packages (no changes) [33.00ms]
[3/1248] install /workspace/bun/packages/bun-error
bun install v1.4.3-canary.1 (b99371011)

Checked 1 install across 2 packages (no changes) [12.00ms]
[4/1248] fetch zlib
[zlib] up to date
[5/1248] fetch tinycc
[tinycc] up to date
[6/1247] install /workspace/bun/src/node-fallbacks
bun install v1.4.3-canary.1 (b99371011)

Checked 111 installs across 104 packages (no changes) [48.00ms]
[7/1247] fetch libjpeg-turbo
[libjpeg-turbo] up to date
[8/1247] gen node-fallbacks/react-refresh.js
Bundled 1 module in 28ms

  react-refresh.js  4.81 KB  (entry point)

[9/1247] gen bake.{client,server,error}.js
-> bake.client.js, bake.server.js, bake.error.js
[10/1247] gen bindgenv2
[11/1247] gen .bind.ts → 
... (truncated)
diff hotspot
src/js_parser/p.rs                   |  19 ++++++
 src/js_parser/parse/parse_entry.rs   |   6 +-
 src/js_parser/visit/visit_expr.rs    |   6 +-
 test/bundler/bundler_cjs2esm.test.ts | 110 +++++++++++++++++++++++++++++++++++
 4 files changed, 139 insertions(+), 2 deletions(-)

gate history · 1 passed · 0 rejected · iteration 0

evidence per changed file
file                                  reads  edits  tests
src/js_parser/p.rs                        7      3     14
src/js_parser/parse/parse_entry.rs        4      2     14
src/js_parser/visit/visit_expr.rs         1      1     14
test/bundler/bundler_cjs2esm.test.ts      2      5     14

…nts or new.target

With ES module output, the parser lifts a CommonJS file out of its
__commonJS wrapper. A file that reads `arguments` or `new.target` at
the module scope then fails at load, because module code has neither.

Both lifts in parse_entry.rs now keep the wrapper for such a file. The
visitor records a `new.target` outside every function and class element
with the nesting rule that `this` already uses.
@robobun

robobun commented Sep 13, 2026 •

Copy link
Copy Markdown
Collaborator Author

Status: CI is green (Buildkite build 115118 passed). The diff is ready for a maintainer.

Reproduced on main (f04caca) and on 1.4.3 canary with the four files in the Notes of the PR body: bun build e1.mjs --target=bun --outfile=o.mjs && bun o.mjs throws ReferenceError: arguments is not defined, and the new.target file throws SyntaxError: new.target is only valid inside functions or static blocks. On this branch both bundles print the 1.4.0 result.

USE_SYSTEM_BUN=1 bun test test/bundler/bundler_cjs2esm.test.ts fails three of the four new tests. bun bd test test/bundler/bundler_cjs2esm.test.ts passes all 105.

The one red check, claude-find-issues, is the duplicate-PR finder action. It failed inside the action itself after 37 seconds and does not run any code from this diff.

@coderabbitai

coderabbitai Bot commented Sep 13, 2026 •

Copy link
Copy Markdown
Contributor

Review Change StackReview 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: Essentials

Run ID: e8761f9d-b154-45f5-b87a-d55bd3641037

📥 Commits

Reviewing files that changed from the base of the PR and between 09bb546 and 144ad5f.

📒 Files selected for processing (4)
  • src/js_parser/p.rs
  • src/js_parser/parse/parse_entry.rs
  • src/js_parser/visit/visit_expr.rs
  • test/bundler/bundler_cjs2esm.test.ts

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


Walkthrough

The parser now tracks top-level new.target and wrapper-scoped arguments usage. CommonJS transformations preserve wrappers when these values are read. Bundler tests cover top-level, nested, local, dead-code, and React-specific cases.

Changes

CommonJS wrapper semantics

Layer / File(s) Summary
Detect top-level wrapper values
src/js_parser/p.rs, src/js_parser/visit/visit_expr.rs
The parser records top-level new.target usage and detects module-scope reads of wrapper-scoped arguments.
Guard CommonJS transformations
src/js_parser/parse/parse_entry.rs
CommonJS named-export deoptimization and CommonJS-to-ESM unwrapping now check wrapper-value usage.
Validate wrapper preservation
test/bundler/bundler_cjs2esm.test.ts
Tests cover top-level, nested, local, dead-code, and React-specific uses of arguments and new.target.

Suggested reviewers: jarred-sumner

Priority: ⬇️ Low

Merge Risk: ⚪ Minimal · up to 144ad

The change preserves CommonJS wrapper bindings when required, with no concrete unresolved merge risk identified.

🚥 Pre-merge checks | ✅ 4
✅ Passed checks (4 passed)
Check name Status Explanation
Linked Issues check ✅ Passed Check skipped because no linked issues were found for this pull request.
Out of Scope Changes check ✅ Passed Check skipped because no linked issues were found for this pull request.
Title check ✅ Passed The title clearly identifies the main change: preserving the CommonJS wrapper when top-level arguments or new.target is read.
Description check ✅ Passed The description explains the problem, fix, scope, limitations, and verification results. It provides more detail than the template requires and covers both requested areas.

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.

LGTM — narrow, well-tested fix that follows the existing sibling-flag pattern at both lift gates.

What was reviewed:

  • Verified is_this_nested has the same scoping as new.target (set in visit_func, class static blocks, and class property values/initializers; not set for arrows or before computed keys are visited), so the flag is accurate.
  • Confirmed the arguments check mirrors the uses_exports_ref/uses_module_ref pattern (module-scope unbound symbol + use count), and the third lift at parse_entry.rs:1486 genuinely can't contain either read given its single-statement module.exports = require("literal") shape.
  • Tests cover the variant matrix (direct/typeof/arrow/computed-key, both gates, and a negative still-lifted case) and are appended to the right file.
Extended reasoning...

Overview

This PR fixes a bundler bug where the CJS→ESM lifting optimization would unwrap CommonJS files that read top-level arguments or new.target, producing bundles that fail at load time (ReferenceError / SyntaxError) despite a successful build. It adds one boolean field to the parser state (has_top_level_new_target), sets it in the e_new_target visitor gated on !fn_only_data_visit.is_this_nested, adds a small helper reads_wrapper_arguments_or_new_target() that also checks for an unbound module-scope arguments with nonzero use count, and consults that helper at both applicable lift gates in parse_entry.rs. Four itBundled tests are appended to test/bundler/bundler_cjs2esm.test.ts.

Security risks

None. This is a bundler correctness change: it only makes two existing deoptimization gates slightly more conservative (keeps the __commonJS wrapper in more cases). No untrusted input parsing changes, no auth/crypto/permissions, no allocation or FFI.

Level of scrutiny

Low-to-medium. The change is ~20 lines of logic plus ~110 lines of tests, and it slots directly alongside three existing sibling flags (has_top_level_return, has_with_scope, has_top_level_function_merged_with_var) checked at the same gates — the pattern is well established. The one non-obvious design choice is reusing is_this_nested to detect "top-level new.target"; I traced every writer of that flag in visit/mod.rs and confirmed it is set exactly where new.target gets its own binding (regular functions, class static blocks, class property values/initializers) and left alone for arrows and computed class keys, which is precisely new.target's scoping. The arguments side reuses the same module-scope unbound-symbol + use-count mechanism as uses_exports_ref/uses_module_ref, so dead references and function-local arguments are handled the same way.

Other factors

REVIEW.md's "fix the whole bug class" and "cover the variant matrix" are both satisfied: both lift gates are patched, the third redirect gate is provably immune by shape (acknowledged in the PR notes), and tests cover direct/typeof/arrow/computed-class-key, the React-specific unwrap path, and a negative case proving nested-function reads and dead top-level arguments still lift. No CODEOWNERS cover the changed paths, no outstanding third-party reviews, and the bug hunt exited on dry_streak with no findings. Comments added are short and load-bearing.

@robobun

robobun commented Sep 13, 2026

Copy link
Copy Markdown
Collaborator Author
Updated 5:47 AM PT - Sep 13th, 2026

✅ @robobun, your commit 144ad5f5edfade3ac8080bf413cd5eb671c122b0 passed in Build #115118! 🎉


🧪   To try this PR locally:

bunx bun-pr 42595

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

bun-42595 --bun

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.

2 participants