Skip to content

bundler: keep the CommonJS wrapper of a package that a file writes to through require() - #46216

Draft
robobun wants to merge 1 commit into
robobun/35dadfe5/unwrapped-require-module-exportsfrom
robobun/35dadfe5/unwrapped-require-write
Draft

robobun wants to merge 1 commit into
robobun/35dadfe5/unwrapped-require-module-exportsfrom
robobun/35dadfe5/unwrapped-require-write

Conversation

@robobun

@robobun robobun commented Oct 20, 2026 •

Copy link
Copy Markdown
Collaborator

On top of #46215.

Problem

  • const React = require("react"); React.other = "patched" fails in bun build: error: Cannot assign to import "other". Node runs it.
  • Cause: require() of a listed package is an import * as, and src/js_parser/fold.rs makes the write target an import item.

Fix

  • The parser leaves the write a property access and sets ImportRecordFlags::REQUIRE_IS_WRITTEN on the record. Linker step 1 (scan_imports_and_exports) keeps the target a CommonJS module, so every require() of it is module.exports.
  • The error stays for an ES module, an external package, or a variable that is assigned again (Notes).
  • Verified: test/bundler/bundler_cjs2esm.test.ts (41 new tests, 27 fail without it), all 107 files of test/bundler/ on release builds.
  • Self-reviewed: 22 concerns raised, 20 addressed (Notes).

Background

Downsides

  • A written package is not tree-shaken: a minimal React 19.3.1 --production entry goes from 927 B to 8,040 B.
  • The package still runs when its importer loads, not at the require() call.
  • Not detected, same output as main: a write through a copy (const R = React) or Object.assign.
Notes

No GitHub issue reports this. It comes from a comparison of bundles against Node. Node, bun run, bun build --format=cjs and esbuild run the program. Published code hits it inside node_modules: @legendapp/state 2.1.15, react-hooks/createObservableHook.js (var React = require('react'); React.useState = function ...).

Repro (main 91e7e3b, also 1.4.2 and 1.3.13):

mkdir -p node_modules/react
echo '{"name":"react","version":"19.0.0","main":"index.js"}' > node_modules/react/package.json
echo 'exports.version = "19.0.0"; exports.useEffect = function () {};' > node_modules/react/index.js
echo 'const React = require("react"); React.other = "patched"; console.log(React.other, require("react").other);' > entry.js
node entry.js                          # patched patched
bun build entry.js --outfile=out.mjs   # error: Cannot assign to import "other"

The same error for React.version = "x", +=, ++, delete React.x and a destructuring target. Also for a TypeScript import x = require("react") and the inline require("react").extra = 1. Real React 18.3.1 and 19.3.1 give it too.

What builds and what keeps the error

The require() main this PR
resolves to a CommonJS file in the bundle (lifted, wrapped, or a module.exports = require() index) error builds, the write reaches module.exports
resolves to an ES module error error
is of an external package, on every target error error
resolves to a file that requires an external package itself error error
resolves to a file that has no wrapper symbol error error
is held by a variable that is assigned again, or that a second declaration names error error
is held by a var in a block or in a statement body without braces error error
  • An external package is an import * as in the output, and an ES namespace rejects a write. A file that requires an external package can have that namespace as its module.exports (module.exports = require("x")), so it keeps the error too.
  • Every CommonJS file and every file of a listed package has a wrapper symbol (Hardening: fix crashes across the runtime, bundler, dev server, shell and CLI #46021). A file of another package with no module syntax has one only with code splitting, or when a statement needs the wrapper. A write that reaches such a file from inside a listed package keeps the error.
  • A var in a block can be skipped while code of its function still runs. The parser treats such a variable as the package from the top of the function, for reads too. bundler: do not fold an unwrapped require() declaration when its variable is rebound #45343 changes that.
  • A computed key (React[k] = v) and delete React?.x build on main. Here they also keep the wrapper in the first row. In the other rows they build as on main.
  • The error that stays has the same text. The linker reports it once for each key, at the first write. Main reports each write, and a write in dead code. For the var in a block the parser reports it, as on main.

How the parser tells the linker. There is no new AST field. The write is one flag on the import record. For the error that stays, the linker needs the key and its place. The fold already makes an import item for a key of a namespace. For a write, the parser marks that item with the symbol flag has_been_assigned_to and puts it at the first write. The AST does not use the item. report_writes_through_require reads the items of the record from named_imports, only on the error path. Three writes do not pass through the fold: a computed key, a key that --minify-syntax folds to a name, and delete x?.a. visit_expr.rs notes them in branches that run only when bundling.

Which variable holds the package. variable_holds_require (linker) reads the final symbol table: the variable has no assignment and no other symbol links to it. The parser links the symbols of declarations that share a name. Only the parser knows the var in a block. visit_decls records its scope in DeferredImportNamespace::scope, and the write then takes the path it takes on main.

A destructured require(). When the written package keeps its wrapper, const { version } = require("react") in the same build has to call that wrapper. require_or_import_meta_for_source printed the exports symbol of the package, which nothing declares then. It now tests the wrap kind. #46072 (draft) replaces that print path for each kind of target. If it merges first, this hunk goes.

Write cells (17 write programs x 6 package shapes x 6 flag sets, 578 cells, each bundle run with Node, release builds):

main the PR below this PR
prints what Node prints 15 29 292
Cannot assign to import 476 476 234
builds, different output 87 73 52

The 234 are an ES-module target (52), an external package (104), and a package with import and require conditions (78). The import file of that package is an ES module. Of the 52, 46 give the same output on the PR below. They are a computed key, a folded key or delete x?.a on those same targets, which builds on main. The other 6 are a write in dead code in the package with the two conditions. Main reports it. Here it builds like the same program without the write.

What this PR does not change

  • The rule does not see these writes. They give the same output as main.
    • A copy of the variable: const R = React; R.x = 1.
    • Object.assign, Object.defineProperty, (0, React).x = 1.
    • A require() in try.
    • A write in a function that is declared above the require() line.
  • A new key through import React from "react" is not visible through require("react").key when the package is lifted. bundler: keep the CommonJS wrapper when code holds a lifted module's default import as a value #42569 keeps the wrapper for a held default import, not for a write.
  • Real React 19.3.1 under --production: the copy and the default-import write print patched undefined. Object.assign prints undefined undefined. Main, the PR below and this PR give the same. A development build prints Node's output for all three with this stack.

What still differs from Node in a build with a detected write

  • Order: an unwrapped require() is a hoisted import, so the package runs when its importer loads. Main does the same for each require() of a listed package.
  • A cycle inside the written package: only the written file keeps its wrapper, so the order of the files in the cycle differs.
  • With NODE_ENV=production defined and no minification, react/index.js keeps its wrapper and the production file behind it stays lifted. An assignment reaches it through the setters. A delete does not.
  • A package with import and require conditions resolves the import file, as on main. None of the 7 listed packages has that split.

Other open PRs on these lines. None of them fixes this write.

Designs weighed

  • A second parse round: take the written package off the unwrap list for that build and parse its files and its requirers again. I built it in full first. It printed Node's output in more cells, because the require() was a real call. It also ran macros twice, called a plugin's onResolve twice and resolved one require() with two kinds. Its cost grew with the number of files that require the package.
  • The unwrap in the linker: keep require() of a listed package an ImportKind::Require record and move the unwrap decision from the parser to linker step 1. By its design it also gives Node's order, the cycle and the require condition for a written package, and it sees a held value. It moves every build that uses a listed package to new linker code (about 2,200 lines by estimate, not built). REVIEW.md asks for a maintainer's agreement before a change of that kind. That question is open on bundler: do not fold an unwrapped require() declaration when its variable is rebound #45343 ("bind at link time" is its third option). If the answer is yes, the linker half and the tests of this PR carry over, and the parser half goes.

Measured (release builds at 91e7e3b; A is the PR below this one)

  • Builds with no write (32 entries x 7 flag sets x React 18.3.1 and 19.3.1, 448 builds): 438 are byte-identical to A and 2 fail on both. The other 8 are a destructured require("react") of development React, which goes from ReferenceError: exports is not defined to a run.
  • The entry const React = require("react"); React.useLayoutEffect = React.useEffect builds in 5 of its 6 ESM flag sets and prints Node's output. With --external react it keeps the error.
  • @legendapp/state 2.1.15 with React 18.3.1, through its CommonJS entry: main and A stop with Cannot assign to import "useState". This PR prints <p>5</p>, as Node does, in a development build and with --production.
  • 55 programs that assign or declare the variable again: main rejects 47. This PR rejects 45 of them and builds 2, which print Node's output (a control with no second value, and a direct eval). The 8 that main builds are byte-identical.
  • Instructions (user mode, median of 5 interleaved runs), A against this PR. React 19.3.1 with react-dom/server: 102.4 M against 102.5 M (spread 0.7%). A file of 100,000 React.count + o[k] reads: 1,266.0 M against 1,267.0 M (spread 0.1%), 10 instructions for each line. A file of 100,000 o[k] = s; o.a = 1 writes with no require(): 1,469.0 M against 1,470.1 M, 12 for each line. With one require("react") in that file: 1,508.8 M against 1,511.3 M, 25 for each line. With --no-bundle: 826.45 M against 826.35 M.
  • Allocator calls for a build of React 19.3.1 with react-dom/server: 95,299 against 95,330 (lowest of three runs, which spread by about 1,200).
  • Release .text: 7,168 B larger than A.
  • A build with a write: 8,040 B against 927 B read-only (minimal React 19.3.1 --production entry), 801,444 B against 801,401 B (React 19.3.1 with react-dom/server, development). A computed-key write, which builds on main, goes from 9,466 B to 8,040 B.

Suites run. Debug build with assertions, on main f2de590: bundler_cjs2esm (166 pass), bundler_dynamic_import_dce (333), bundler_splitting (209), esbuild/default (155), esbuild/importstar (99), esbuild/importstar_ts (23), esbuild/ts (59), transpiler/assign-to-import (12), transpiler/transpiler (257), bundler_cjs (62), bundler_npm (2), bake/dev/react-spa (10), bundler_edgecase (187 pass, 1 fail: DeepImportDiamondDAG passes its 60 s limit in this container, on main too). Release build at 91e7e3b, before the last rebase: all 107 files of test/bundler/, 7,712 pass, 4 fail. The same 4 fail with a build of main in this container: bundler_bytecode_portable x2, native-plugin, and one bytecode test of bun-build-api that passes with a longer timeout.

Self-review. Not taken:

  • A tracking issue for the writes that the rule does not see. They are listed above with their output.
  • A fix for a file with no wrapper symbol. Hardening: fix crashes across the runtime, bundler, dev server, shell and CLI #46021 landed it on main for every CommonJS file and every file of a listed package, so a write to an empty listed package now builds (cjs2esm/UnwrappedRequireWriteOfEmptyPackage). The guard stays for the file in the table above.
  • A run-time require() for a written external package with target bun or node. An earlier cut of this PR had it. It raised four questions of its own, so it is a follow-up.
  • One more hop in linker step 1 for the NODE_ENV=production case above. The limit is stated.

@robobun

robobun commented Oct 20, 2026 •

Copy link
Copy Markdown
Collaborator Author
Updated 3:50 AM PT - Oct 22nd, 2026

❌ @robobun, your commit 4e77a31 has 1 failures in Build #130693 (All Failures):


🧪   To try this PR locally:

bunx bun-pr 46216

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

bun-46216 --bun

@robobun

robobun commented Oct 20, 2026 •

Copy link
Copy Markdown
Collaborator Author

Status

Reproduced on main (91e7e3b), on 1.4.2 and on 1.3.13 with the repro in the Notes of the PR body. bun build entry.js --outfile=out.mjs stops with error: Cannot assign to import "other", and node entry.js prints patched patched. 27 of the 41 new tests in test/bundler/bundler_cjs2esm.test.ts fail without this commit and pass with it.

PR: #46216. It is a draft on top of #46215.

@robobun
robobun force-pushed the robobun/35dadfe5/unwrapped-require-module-exports branch from 2f0cee8 to 90c5182 Compare October 22, 2026 03:08
@robobun
robobun force-pushed the robobun/35dadfe5/unwrapped-require-write branch from f496bf7 to b41bdee Compare October 22, 2026 03:08
Comment thread src/ast/import_record.rs Outdated
Comment thread src/bundler/linker_context/scanImportsAndExports.rs Outdated
Comment thread src/bundler/linker_context/scanImportsAndExports.rs Outdated
Comment thread src/bundler/linker_context/scanImportsAndExports.rs Outdated
Comment thread src/bundler/linker_context/scanImportsAndExports.rs Outdated
Comment thread src/bundler/linker_context/scanImportsAndExports.rs Outdated
Comment thread src/bundler/linker_context/scanImportsAndExports.rs Outdated
Comment thread src/js_parser/fold.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/parser.rs Outdated
… through require()

For ESM output, `require()` of a package on the CommonJS unwrap list is
an `import * as`. A property write through it failed the build with
`Cannot assign to import "<name>"`. Node writes `module.exports` there.

The parser leaves the write a property access and sets
`ImportRecordFlags::REQUIRE_IS_WRITTEN` on the record. Step 1 of
`scan_imports_and_exports` makes the target of that record a CommonJS
module, so the value is the call of its wrapper: `module.exports`.

The error stays when no `module.exports` is sure to get the write:
- The target is an ES module or an external package, or it requires an
  external package itself.
- The variable is assigned again or has a second declaration.
- The variable is a `var` in a block or in a statement body without
  braces.
The linker reports it, with the same text, at the first write to each
key. For the `var` in a block the parser reports it, as before.

`require_or_import_meta_for_source` prints the call of the wrapper for a
destructured `require()` of a file that keeps its wrapper. It printed the
`exports` symbol, which such a file does not have.
@robobun
robobun force-pushed the robobun/35dadfe5/unwrapped-require-module-exports branch from 90c5182 to 455a99d Compare October 22, 2026 04:58
@robobun
robobun force-pushed the robobun/35dadfe5/unwrapped-require-write branch from b41bdee to 4e77a31 Compare October 22, 2026 04:58

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