feat: support require(esm) "module.exports" named-export interop#20981
Conversation
When CommonJS `require()` resolves to an ES module that exports a binding
with the literal string name `"module.exports"` (e.g.
`export { value as "module.exports" }`), webpack now returns that export's
value instead of the module's namespace object — matching Node.js v23+
`require(esm)` semantics and easing migration of dual ESM/CJS libraries
that depend on `module.exports = …`.
The unwrapping is wired through the three CommonJS require dependency
templates so it applies to plain `require()`, property access
(`require().foo`), call (`require()(…)`), and destructuring. Tree-shaking
sees `"module.exports"` as referenced when applicable.
Closes #20896
The `require(esm)` "module.exports" interop test now cross-checks webpack's bundled output against Node.js's native result by shelling out to a child `node` process — Jest's runtime intercepts every `require()` in-process (even via `Module.createRequire`), so the comparison has to run outside it. The test also keeps a literal `require(/* webpackIgnore: true */ … )` in the entry to assert webpack preserves the comment in the emitted bundle. A `test.filter.js` skips the case on Node versions older than 22.12.0, where `require(esm)` is not unflagged. `.js` fixtures are renamed to `.mjs` so the child `node` process parses them as ES modules.
…ison
Replaces the child-process approach with a `webpackIgnore: true` comment
on the native side. Webpack leaves the call literal, and at runtime a
new `test.config.js` short-circuits the absolute paths via
`testConfig.modules` to values pre-loaded through Node's `Module._load`
(which, unlike `Module.createRequire`, Jest does not intercept).
The webpack-bundled `require("./*.mjs")` and the
`require(/* webpackIgnore: true */ pathVar)` calls thus go through two
independent paths and their results are compared, with no child
processes.
Adds two fixtures and corresponding cases that pin down behaviors the
Node.js docs guarantee but the previous tests did not exercise:
* `"module.exports"` wins over a sibling `default` (and any other named
export). The unwrapped value is just the binding — `default` /
`named` are not visible on it.
* `export { x as "module.exports" } from "./other"` still unwraps when
required from CJS.
Both cases are cross-checked against `Module._load`'d native values.
`CommonJsExportRequireDependency` (the dependency behind `module.exports = require(…)`, `module.exports.foo = require(…)`, `exports.bar = require(…).baz`) now applies the same Node.js `require(esm)` unwrap as plain `require()` / `require().foo`. When the imported module is an ES module with a `"module.exports"` named export: * `getReferencedExports` reports only `"module.exports"` as referenced; further property access in `ids` lands on the unwrapped value which webpack does not model. * `getExports` prepends `"module.exports"` to the export chain when a single name is being re-exported, and falls back to `exports: true` (canMangle: false) for full re-exports — the unwrapped value's own properties cannot be enumerated statically. * The template emits `__webpack_require__(id)["module.exports"]<ids>` in place of `__webpack_require__(id)<ids>`. Three new fixtures cover the wrapper-CJS shapes against Node's real `Module._load` result.
This change brings webpack into line with Node.js's documented `require(esm)` semantics, so it's a fix for divergent behavior rather than a new feature — patch is the right semver bucket.
🦋 Changeset detectedLatest commit: c4ea8da The changes in this PR will be included in the next version bump. This PR includes changesets to release 1 package
Not sure what this means? Click here to learn what changesets are. Click here if you're a maintainer who wants to add another changeset to this PR |
|
This PR is packaged and the instant preview is available (0b7de2f). Install it locally:
npm i -D webpack@https://pkg.pr.new/webpack@0b7de2f
yarn add -D webpack@https://pkg.pr.new/webpack@0b7de2f
pnpm add -D webpack@https://pkg.pr.new/webpack@0b7de2f |
Codecov Report❌ Patch coverage is
Additional details and impacted files@@ Coverage Diff @@
## main #20981 +/- ##
===========================================
+ Coverage 36.42% 90.94% +54.51%
===========================================
Files 423 573 +150
Lines 48221 58938 +10717
Branches 13222 15889 +2667
===========================================
+ Hits 17565 53599 +36034
+ Misses 30656 5339 -25317
Flags with carried forward coverage won't be shown. Click here to find out more. ☔ View full report in Codecov by Sentry. 🚀 New features to boost your workflow:
|
There was a problem hiding this comment.
Pull request overview
This PR adds Node-compatible CommonJS require(esm) interop for ES modules that export a binding named "module.exports", returning that export instead of the namespace object.
Changes:
- Adds shared helper logic for detecting and generating
"module.exports"unwrapping. - Wires the unwrap into CommonJS require, property/call require chains, and CJS re-export dependency templates.
- Adds a config case covering plain require, property access, calls, destructuring, re-exports, wrappers, and native Node comparison.
Reviewed changes
Copilot reviewed 19 out of 19 changed files in this pull request and generated 1 comment.
Show a summary per file
| File | Description |
|---|---|
.changeset/require-esm-module-exports-named-export.md |
Documents the new interop behavior. |
lib/dependencies/CommonJsDependencyHelpers.js |
Adds the shared "module.exports" detection/access helper. |
lib/dependencies/CommonJsRequireDependency.js |
Applies unwrap access to plain require() and referenced exports. |
lib/dependencies/CommonJsImportsParserPlugin.js |
Passes the full require-call range for unwrap insertion. |
lib/dependencies/CommonJsFullRequireDependency.js |
Applies unwrap to chained property/call require expressions. |
lib/dependencies/CommonJsExportRequireDependency.js |
Applies unwrap to CJS re-export patterns. |
test/configCases/require/esm-module-exports/* |
Adds fixtures and tests for Node-compatible behavior across require shapes and wrappers. |
💡 Add Copilot custom instructions for smarter, more guided reviews. Learn how to get started.
| const usedName = exportsInfo.getUsedName([ESM_MODULE_EXPORTS_NAME], runtime); | ||
| if (usedName === false) return null; | ||
| return propertyAccess(/** @type {readonly string[]} */ (usedName)); |
There was a problem hiding this comment.
Good catch — reproduced and fixed in 13aa68a.
Confirmed via a standalone webpack build with mode: "production", usedExports: true and a fixture where "module.exports" and named are bound to different values: webpack emitted __webpack_require__(id).named and returned "named-value", while Node's require(esm) unwraps first and then accesses .named on the string, yielding undefined.
Split the helper into two:
isRequireEsmModuleExportsModule(module, moduleGraph)— eligibility only (module type + provided), no usage lookup. Used by all threegetReferencedExportspaths andgetExports.getRequireEsmModuleExportsAccess(module, moduleGraph, runtime)— calls the eligibility check, thengetUsedName. Used only at template-apply time, when usage info is final.
The config now enables optimization.usedExports: true and a new distinct.mjs fixture pins this down on every run.
Generated by Claude Code
`getRequireEsmModuleExportsAccess` previously bailed out when `getUsedName(["module.exports"], runtime)` returned `false`, which created a chicken-and-egg in `getReferencedExports`: the helper would refuse to mark `"module.exports"` as the referenced export until it was already marked used, so `usedExports: true` builds fell through to referencing the user-side property (e.g. `named`) and emitted `__webpack_require__(id).named` — disagreeing with Node's `require(esm)`, which would have unwrapped first and then accessed `.named` on the (string) value, yielding `undefined`. Split into `isRequireEsmModuleExportsModule` (usage-independent, used by the three `getReferencedExports` paths and `getExports`) and the existing `getRequireEsmModuleExportsAccess` (which still runs the used-name lookup, but only at template-apply time, when usage is final). A new `distinct.mjs` fixture exports `"module.exports"` and `named` to *different* values; the config now enables `optimization.usedExports` so this regression is locked down on every run. Spotted by the Copilot PR review.
Adds an `underscore-like.mjs` fixture — a callable library that exports
itself both as `default` and `"module.exports"`, plus an `_.partial` that
relies on the library function being its own placeholder identity. This
mirrors the exact shape of the upstream issues the PR description links
to (underscore/issues/3016 — `_.partial.placeholder === _` broke when
bundlers turned `_` into an ESM namespace; esbuild/issues/4459 —
`const _ = require("underscore")` returning a namespace instead of the
callable library).
Four new cases pin the behavior down:
* `const _ = require(lib)` yields the callable library function.
* `_.partial.placeholder === _` (the underscore #3016 identity check).
* `_.partial(fn, _, x, _)` correctly substitutes the placeholder.
* Destructured pull behaves the same as a default import would.
Each case is cross-checked against `Module._load` so any future drift
against Node's `require(esm)` would fail here.
No new lib changes — this is the smallest reproduction of the original
issue's use case wired into the regular CI run.
Types CoverageCoverage after merging claude/investigate-issue-20896-P4mzT into main will be
Coverage Report
|
|||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||
When CommonJS
require()resolves to an ES module that exports a bindingwith the literal string name
"module.exports"(e.g.export { value as "module.exports" }), webpack now returns that export'svalue instead of the module's namespace object — matching Node.js v23+
require(esm)semantics and easing migration of dual ESM/CJS librariesthat depend on
module.exports = ….The unwrapping is wired through the three CommonJS require dependency
templates so it applies to plain
require(), property access(
require().foo), call (require()(…)), and destructuring. Tree-shakingsees
"module.exports"as referenced when applicable.Closes #20896