Repository navigation
Conversation
…eps its CommonJS wrapper For ESM output, the parser turns require() of a package on the CommonJS unwrap list, and each require() in a file of such a package, into an `import * as` record. Nothing after that knew the record was a require(). When the target kept its __commonJS wrapper, the linker wrapped the import in __toESM: the value was a copy with getters and a `default` key, not `module.exports`. transpose_require now sets ImportRecordFlags::WAS_ORIGINALLY_REQUIRE on the record, and the linker does not wrap a record with the flag. The flag had three readers and no writer. This removes them. Two cannot see a record of an unwrapped require(). The third would keep a type-only `import x = require()` in a TypeScript file.
StatusReproduced on main (91e7e3b) with React 19.3.1. The file PR: #46215 |
|
No actionable comments were generated in the recent review. 🎉 ℹ️ Recent review info
WalkthroughThe parser now marks imports converted from ChangesCommonJS require exports
Suggested reviewers: Priority: ➖ Normal Merge Risk: ⚪ Minimal · up to
|
There was a problem hiding this comment.
Nothing blocking. The comments below are optional suggestions. There is no need to push a fix for them before merging.
Beyond the inline findings, I also checked the rewrite of splitting/SplitRequireOfRewrappedLiftedCommonJS#41236 in test/bundler/bundler_splitting.test.ts: the old m.default() assertion pinned exactly the __toESM copy this PR removes, and the cross-chunk regression it guards (import.meta.require("./chunk").default in main.js plus the module running) is still asserted, so it is an expected-output update rather than a weakened test.
Extended reasoning...
The change sets the previously never-written WAS_ORIGINALLY_REQUIRE flag in the parser's require-to-namespace-import path, gates the linker's __toESM insertion on it, and deletes three prior readers in scan_imports.rs and js_printer/lib.rs; it touches no security-sensitive surface. The inline findings cover the --production inconsistency and two pre-existing gaps, so a human still needs to weigh those; the mutated splitting test was examined and found to be a legitimate update of the behavior under change.
Additional findings (outside the current diff — GitHub can't attach inline comments there):
-
🟣
src/js_printer/lib.rs— Users withconst { useState } = require("react")in an ESM bundle of development React still crash at load withReferenceError: exports is not defined; this PR changes what is printed there but not the crash. The parser keeps theE::RequireStringwithunwrapped_idfor an object binding (visit/mod.rs:400-408 only rewrites identifier bindings), and lib.rs:2869-2873 prints the bareexports_refof the wrapped target without calling its wrapper. WithWRAP_WITH_TO_ESMnow cleared at scanImportsAndExports.rs:1237 the output is the unboundexportssymbol alone. Fix: whenwas_unwrapped_requireand the target keeps a__commonJSwrapper, print the wrapper call (require_react()), the same value :1237 now chooses for the identifier form.Why this was flagged
Trigger: an ESM build (ParseTask.rs:2511) containing
const { useState } = require("react"), with React kept in its__commonJSwrapper (default development build). p.rs:1725-1746 returnsE::RequireString { unwrapped_id }because the require is immediately assigned to a decl; visit/mod.rs:397-408 converts onlyBIdentifierbindings into the namespace ref, so theBObjectform survives to the printer. LinkerContext.rs:2548-2562 returnsexports_refandwas_unwrapped_require = truebecause the react file carriesFORCE_CJS_TO_ESM(p.rs:9369). lib.rs:2869-2873 then prints just that symbol. For a wrapped CJS file the exports symbol is the wrapper closure's parameter, unbound at module top level, so the bundle throwsReferenceError: exports is not definedon load. Before this PR the same site printed__toESM(exports)and threw the same error; the PR gates the__toESMdecision on the new flag at :1237 but leaves this sibling print path still emitting a value that is nevermodule.exports.Verification: pre-existing; acknowledged in diff. mod.rs:397-408 rewrites the namespace only for
BData::BIdentifier, so theBObjectform keeps theERequireString. lib.rs:2869-2873 prints onlymeta.exports_ref, theexportsparameter of the__commonJSwrapper, with norequire_react()call. Both base and PR throwReferenceError: exports is not definedat load by the same route.
|
On the additional finding in the review above, The fix is not in this PR on purpose. #46072 is open for this crash. It removes the The two inline comments have replies in their threads. |
Part of #12463
Problem
require("react")gives a copy with getters and adefaultkey when the package keeps its__commonJSwrapper (development React). A write through an interop helper orimport React from "react"throwsTypeError: Cannot set property useLayoutEffect of #<Object> which has only a getter.transpose_require(src/js_parser/p.rs) makes therequire()an unmarkedimport * as, and the linker wraps it in__toESM.Fix
transpose_requiresetsImportRecordFlags::WAS_ORIGINALLY_REQUIRE. The linker does not wrap a record with the flag in__toESM. The value isrequire_x():module.exports.test/bundler/bundler_cjs2esm.test.ts(11 new tests fail on main), all 107 files oftest/bundler/on release builds.Background
require()ofreact,react-dom,schedulerand four more packages, and eachrequire()in their files, becomesimport * as ns, so the package tree-shakes.exports.x = ...turned into ES exports. Any other package keeps its__commonJSwrapper:require_x()ismodule.exports.Downsides
.defaultof the value isundefinedin a development build, as in Node. It was the exports object. A--productionbuild gives the object.--external react, and for arequire()intry.Notes
Repro (React 19.3.1,
bun build <file> --target=node --outfile=out.mjs && node out.mjs, main 91e7e3b, also 1.4.2 and 1.3.13):The same TypeError for
import React from "react"; React.useLayoutEffect = React.useEffect, because react/index.js ismodule.exports = require('./cjs/react.development.js')and main printsvar react_development = __toESM(require_react_development())there.Published code hits it. The ES module entry of
@legendapp/state2.1.15 (react-hooks/createObservableHook.mjs) hasimport React from 'react'; React.useState = function .... With React 18.3.1 a development build of main throwsTypeError: Cannot set property useState of #<Object> which has only a getter. With this PR it prints what Node prints.Which builds keep the wrapper. A default build (development React). A build with
NODE_ENV=productionin the environment and no--productionflag. A package that can be lifted but that the linker wraps again, for example when another file holds its default import as a value (#42569). With--production, React is lifted and the cases above run on main.What this PR does not change. The value of an unwrapped
require()is still notmodule.exportsin these cases. Each prints the same with main and with this PR (React 19.3.1,--target=node):--production,held.jsfunction object falseundefined undefined false--external react,held.jsundefined object trueundefined undefined false--external react,helper.jsTypeError: Cannot assign to property 'useLayoutEffect' of [object Module]--production,let T; try { T = require("react") } catch {}besideconst U = require("react"):T.__esModule, T === Utrue falseundefined trueexportshasimportandrequireconditionsimportfilerequirefileconst { useState } = require("react")of a package that keeps its wrapper still throwsReferenceError: exports is not defined. #46072 fixes that.In a TypeScript file,
const React = require("react")that nothing reads is dropped with the package, and so is a type-onlyimport React = require("react"). Main does the same. Node and esbuild run the package. Bun runs it for a package that is not on the list, and with--format=cjs.No switch. This changes the value of an expression, so code can depend on the old value: a read of
.defaultoffrequire("react"), or off the default import of a package that keeps its wrapper, gave the exports object and now givesundefinedin a development build. Node,bun run, esbuild andbun build --format=cjsgiveundefined. #41162 and #42569 changed the value of the default import toward Node in the same way, with no switch.The three old readers of the flag, removed here:
src/js_parser/scan/scan_imports.rs, in the unused-import check for TypeScript (// require statements cannot be stripped). With a writer it keeps the two statements of the paragraph above. I built that: 991 tests of nine bundler files pass with no change. It also changes the output of TypeScript files. React is bundled where main drops it, and an unusedrequire()of a listed package that is not installed becomesCould not resolve. That is not the change this PR describes, so the reader goes and the output stays as on main.src/js_parser/scan/scan_imports.rs, in the branch that does not bundle. Arequire()is unwrapped only when bundling.src/js_printer/lib.rs. It needs a namespace alias that is not a property access and that has a record index. The parser makes those only for hot module reloading, which does not unwrap.The flag is read at one site, the
__toESMdecision for a target that has a wrapper (scanImportsAndExports.rs). Every other test ofImportKind::Requirestill sees an import: the resolve condition, the order of evaluation, whether the target is wrapped.Other open PRs on these lines
require(). No line of this PR overlaps it.import *of a lifted CommonJS module its own namespace object #41820 has its own test for "thisimport *was arequire()" (import_star_was_require_call). It can read the flag.Builds with no write (release builds of main and of this PR, 33 entries x 7 flag sets x React 18.3.1 and 19.3.1):
The run output changes in 36 builds. In each one this PR prints what Node prints for the unbundled entry: the value has no
defaultkey and no getters,require("react") === require("react")through a function, and the write through the default import runs.Existing tests that change, each pinned the copy:
cjs2esm/UnwrappedModuleRequireAssignedexpected__toESM(in the output.cjs2esm/ReactSpecificUnwrappingTargetIsCommonJSexpectedtypeof ReactDOMto beobjectfor a package whosemodule.exportsis a function.splitting/SplitRequireOfRewrappedLiftedCommonJS#41236calledm.default()on that package.npm/ReactSSR: three source map columns move by 8, and the bundle is 221,992 B (main 222,012 B).Measured (release builds, main 91e7e3b against this PR)
react-dom/server: 101.7 M against 101.8 M, with a spread of 0.7% between runs. For a file of 100,000React.count + o[k]reads: 1,265.4 M against 1,266.0 M (spread 0.05%)..text: 1,024 B smaller.The larger design. A design review ranked one design above this one: keep
require()of a listed package anImportKind::Requirerecord and move the unwrap decision from the parser to linker step 1. It needs no flag, and it would also close the cases in the table above. It moves every build that uses a listed package to new linker code (about 2,200 lines by estimate, not built). It has to reach the size of today's unwrap: for React 19.3.1--production,const R = require("react"); R.useStateis 910 B, and the same read through a real require record is 1,679 B when the linker can track it and 10,131 B when it cannot. REVIEW.md asks for a maintainer's agreement before a change of that kind. The question is already open on #45343 ("bind at link time" is its third option), so I do not ask it again here. This PR does not depend on the answer: it is the one-pass form, and the flag is also what a link-time design would read.Self-review. Not taken:
require()is held (two concerns). That belongs to bundler: keep the CommonJS wrapper of a package that a file writes to through require() #46216, the PR on top of this one, and there it would take the wrapper of React for each file that passesrequire("react")to an interop helper.