Repository navigation
bundler: keep the CommonJS wrapper of a package that a file writes to through require() - #46216
Draft
robobun wants to merge 1 commit into
Conversation
Collaborator
Author
|
Updated 3:50 AM PT - Oct 22nd, 2026
❌ @robobun, your commit 4e77a31 has 1 failures in
🧪 To try this PR locally: bunx bun-pr 46216That installs a local version of the PR into your bun-46216 --bun |
Collaborator
Author
StatusReproduced on main (91e7e3b), on 1.4.2 and on 1.3.13 with the repro in the Notes of the PR body. |
robobun
force-pushed
the
robobun/35dadfe5/unwrapped-require-module-exports
branch
from
October 22, 2026 03:08
2f0cee8 to
90c5182
Compare
robobun
force-pushed
the
robobun/35dadfe5/unwrapped-require-write
branch
from
October 22, 2026 03:08
f496bf7 to
b41bdee
Compare
… 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
force-pushed
the
robobun/35dadfe5/unwrapped-require-module-exports
branch
from
October 22, 2026 04:58
90c5182 to
455a99d
Compare
robobun
force-pushed
the
robobun/35dadfe5/unwrapped-require-write
branch
from
October 22, 2026 04:58
b41bdee to
4e77a31
Compare
This branch has not been deployed
This file contains hidden or bidirectional Unicode text that may be interpreted or compiled differently than what appears below. To review, open the file in an editor that reveals hidden Unicode characters.
Learn more about bidirectional Unicode characters
Sign up for free
to join this conversation on GitHub.
Already have an account?
Sign in to comment
Add this suggestion to a batch that can be applied as a single commit.This suggestion is invalid because no changes were made to the code.Suggestions cannot be applied while the pull request is closed.Suggestions cannot be applied while viewing a subset of changes.Only one suggestion per line can be applied in a batch.Add this suggestion to a batch that can be applied as a single commit.Applying suggestions on deleted lines is not supported.You must change the existing code in this line in order to create a valid suggestion.Outdated suggestions cannot be applied.This suggestion has been applied or marked resolved.Suggestions cannot be applied from pending reviews.Suggestions cannot be applied on multi-line comments.Suggestions cannot be applied while the pull request is queued to merge.Suggestion cannot be applied right now. Please check back later.
On top of #46215.
Problem
const React = require("react"); React.other = "patched"fails inbun build:error: Cannot assign to import "other". Node runs it.require()of a listed package is animport * as, andsrc/js_parser/fold.rsmakes the write target an import item.Fix
ImportRecordFlags::REQUIRE_IS_WRITTENon the record. Linker step 1 (scan_imports_and_exports) keeps the target a CommonJS module, so everyrequire()of it ismodule.exports.test/bundler/bundler_cjs2esm.test.ts(41 new tests, 27 fail without it), all 107 files oftest/bundler/on release builds.Background
require()ofreact,react-dom,schedulerand four more packages becomesimport * as ns. The package loses its__commonJSwrapper and tree-shakes.Downsides
--productionentry goes from 927 B to 8,040 B.require()call.const R = React) orObject.assign.Notes
No GitHub issue reports this. It comes from a comparison of bundles against Node. Node,
bun run,bun build --format=cjsand esbuild run the program. Published code hits it inside node_modules:@legendapp/state2.1.15,react-hooks/createObservableHook.js(var React = require('react'); React.useState = function ...).Repro (main 91e7e3b, also 1.4.2 and 1.3.13):
The same error for
React.version = "x",+=,++,delete React.xand a destructuring target. Also for a TypeScriptimport x = require("react")and the inlinerequire("react").extra = 1. Real React 18.3.1 and 19.3.1 give it too.What builds and what keeps the error
require()module.exports = require()index)module.exportsvarin a block or in a statement body without bracesimport * asin the output, and an ES namespace rejects a write. A file that requires an external package can have that namespace as itsmodule.exports(module.exports = require("x")), so it keeps the error too.varin 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.React[k] = v) anddelete React?.xbuild on main. Here they also keep the wrapper in the first row. In the other rows they build as on main.varin 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_toand puts it at the first write. The AST does not use the item.report_writes_through_requirereads the items of the record fromnamed_imports, only on the error path. Three writes do not pass through the fold: a computed key, a key that--minify-syntaxfolds to a name, anddelete x?.a.visit_expr.rsnotes 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 thevarin a block.visit_declsrecords its scope inDeferredImportNamespace::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_sourceprinted theexportssymbol 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):
Cannot assign to importThe 234 are an ES-module target (52), an external package (104), and a package with
importandrequireconditions (78). Theimportfile 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 ordelete x?.aon 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
const R = React; R.x = 1.Object.assign,Object.defineProperty,(0, React).x = 1.require()intry.require()line.import React from "react"is not visible throughrequire("react").keywhen 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.--production: the copy and the default-import write printpatched undefined.Object.assignprintsundefined 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
require()is a hoisted import, so the package runs when its importer loads. Main does the same for eachrequire()of a listed package.NODE_ENV=productiondefined and no minification,react/index.jskeeps its wrapper and the production file behind it stays lifted. An assignment reaches it through the setters. Adeletedoes not.importandrequireconditions resolves theimportfile, as on main. None of the 7 listed packages has that split.Other open PRs on these lines. None of them fixes this write.
was_unwrapped_requireparameter ofrequire_or_import_meta_for_source. This PR changes that function (see above). This PR is a draft that lands after it.ImportRecordFlags::WAS_ORIGINALLY_REQUIRE. The PR below gives the flag a writer, and this PR reads records that have it.HTML_SCRIPT_SRC = 1 << 19.REQUIRE_IS_WRITTENis1 << 20.visit_decls, which decides when a variable is the namespace.unwrapped_require_recordreads that fact, so the PR that lands second adapts it. bundler: do not fold an unwrapped require() declaration when its variable is rebound #45343 pins this error in two tests.cjs2esm/UnwrappedRequirePropertyWriteBeforeRebindkeeps the error here (cjs2esm/UnwrappedRequireWriteThroughVariableAssignedLater).cjs2esm/UnwrappedRequireInBlockPropertyWritebuilds here and printsreact 1, as Node does (cjs2esm/UnwrappedRequireWriteThroughVariableOfABlock).Designs weighed
require()was a real call. It also ran macros twice, called a plugin'sonResolvetwice and resolved onerequire()with two kinds. Its cost grew with the number of files that require the package.require()of a listed package anImportKind::Requirerecord and move the unwrap decision from the parser to linker step 1. By its design it also gives Node's order, the cycle and therequirecondition 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)
require("react")of development React, which goes fromReferenceError: exports is not definedto a run.const React = require("react"); React.useLayoutEffect = React.useEffectbuilds in 5 of its 6 ESM flag sets and prints Node's output. With--external reactit keeps the error.@legendapp/state2.1.15 with React 18.3.1, through its CommonJS entry: main and A stop withCannot assign to import "useState". This PR prints<p>5</p>, as Node does, in a development build and with--production.eval). The 8 that main builds are byte-identical.react-dom/server: 102.4 M against 102.5 M (spread 0.7%). A file of 100,000React.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,000o[k] = s; o.a = 1writes with norequire(): 1,469.0 M against 1,470.1 M, 12 for each line. With onerequire("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.react-dom/server: 95,299 against 95,330 (lowest of three runs, which spread by about 1,200)..text: 7,168 B larger than A.--productionentry), 801,444 B against 801,401 B (React 19.3.1 withreact-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:DeepImportDiamondDAGpasses its 60 s limit in this container, on main too). Release build at 91e7e3b, before the last rebase: all 107 files oftest/bundler/, 7,712 pass, 4 fail. The same 4 fail with a build of main in this container:bundler_bytecode_portablex2,native-plugin, and one bytecode test ofbun-build-apithat passes with a longer timeout.Self-review. Not taken:
cjs2esm/UnwrappedRequireWriteOfEmptyPackage). The guard stays for the file in the table above.require()for a written external package with targetbunornode. An earlier cut of this PR had it. It raised four questions of its own, so it is a follow-up.NODE_ENV=productioncase above. The limit is stated.