Skip to content

feat: support require(esm) "module.exports" named-export interop#20981

Merged
alexander-akait merged 8 commits into
mainfrom
claude/investigate-issue-20896-P4mzT
May 20, 2026
Merged

feat: support require(esm) "module.exports" named-export interop#20981
alexander-akait merged 8 commits into
mainfrom
claude/investigate-issue-20896-P4mzT

Conversation

@alexander-akait

Copy link
Copy Markdown
Member

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

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.
Copilot AI review requested due to automatic review settings May 19, 2026 18:20
@changeset-bot

changeset-bot Bot commented May 19, 2026

Copy link
Copy Markdown

🦋 Changeset detected

Latest commit: c4ea8da

The changes in this PR will be included in the next version bump.

This PR includes changesets to release 1 package
Name Type
webpack Patch

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

@github-actions

github-actions Bot commented May 19, 2026

Copy link
Copy Markdown
Contributor

This PR is packaged and the instant preview is available (0b7de2f).

Install it locally:

  • npm
npm i -D webpack@https://pkg.pr.new/webpack@0b7de2f
  • yarn
yarn add -D webpack@https://pkg.pr.new/webpack@0b7de2f
  • pnpm
pnpm add -D webpack@https://pkg.pr.new/webpack@0b7de2f

Comment thread test/configCases/require/esm-module-exports/plain.mjs Fixed
@codecov

codecov Bot commented May 19, 2026

Copy link
Copy Markdown

Codecov Report

❌ Patch coverage is 97.72727% with 2 lines in your changes missing coverage. Please review.
✅ Project coverage is 90.94%. Comparing base (3032402) to head (c4ea8da).
⚠️ Report is 13 commits behind head on main.

Files with missing lines Patch % Lines
...ib/dependencies/CommonJsExportRequireDependency.js 96.15% 1 Missing ⚠️
lib/dependencies/CommonJsFullRequireDependency.js 95.00% 1 Missing ⚠️
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     
Flag Coverage Δ
integration 89.71% <97.72%> (?)
test262 45.36% <12.50%> (?)
unit 36.58% <12.50%> (+0.15%) ⬆️

Flags with carried forward coverage won't be shown. Click here to find out more.

☔ View full report in Codecov by Sentry.
📢 Have feedback on the report? Share it here.

🚀 New features to boost your workflow:
  • ❄️ Test Analytics: Detect flaky tests, report on failures, and find test suite problems.
  • 📦 JS Bundle Analysis: Save yourself from yourself by tracking and limiting bundle sizes in JS merges.

Copilot AI 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.

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.

Comment on lines +46 to +48
const usedName = exportsInfo.getUsedName([ESM_MODULE_EXPORTS_NAME], runtime);
if (usedName === false) return null;
return propertyAccess(/** @type {readonly string[]} */ (usedName));

Copy link
Copy Markdown
Member Author

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

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 three getReferencedExports paths and getExports.
  • getRequireEsmModuleExportsAccess(module, moduleGraph, runtime) — calls the eligibility check, then getUsedName. 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.
@codspeed-hq

codspeed-hq Bot commented May 19, 2026

Copy link
Copy Markdown

Merging this PR will improve performance by 34.82%

⚡ 5 improved benchmarks
❌ 4 regressed benchmarks
✅ 135 untouched benchmarks
⏩ 72 skipped benchmarks1

Warning

Please fix the performance issues or acknowledge them on CodSpeed.

Performance Changes

Mode Benchmark BASE HEAD Efficiency
Memory benchmark "asset-modules-source", scenario '{"name":"mode-development-rebuild","mode":"development","watch":true}' 235.3 KB 400.3 KB -41.24%
Memory benchmark "asset-modules-bytes", scenario '{"name":"mode-development-rebuild","mode":"development","watch":true}' 321.8 KB 136.1 KB ×2.4
Memory benchmark "cache-filesystem", scenario '{"name":"mode-development-rebuild","mode":"development","watch":true}' 845.9 KB 171 KB ×4.9
Memory benchmark "context-esm", scenario '{"name":"mode-development-rebuild","mode":"development","watch":true}' 663.3 KB 155.2 KB ×4.3
Memory benchmark "many-modules-esm", scenario '{"name":"mode-development","mode":"development"}' 837.5 KB 1,356.3 KB -38.25%
Memory benchmark "many-chunks-commonjs", scenario '{"name":"mode-production","mode":"production"}' 9.8 MB 7.4 MB +32.32%
Memory benchmark "asset-modules-resource", scenario '{"name":"mode-development-rebuild","mode":"development","watch":true}' 355.1 KB 214.9 KB +65.21%
Memory benchmark "many-modules-esm", scenario '{"name":"mode-production","mode":"production"}' 9.1 MB 11.4 MB -20.22%
Memory benchmark "concatenate-modules", scenario '{"name":"mode-development-rebuild","mode":"development","watch":true}' 135.2 KB 290.8 KB -53.5%

Tip

Investigate this regression by commenting @codspeedbot fix this regression on this PR, or directly use the CodSpeed MCP with your agent.


Comparing claude/investigate-issue-20896-P4mzT (c4ea8da) with main (f8076be)

Open in CodSpeed

Footnotes

  1. 72 benchmarks were skipped, so the baseline results were used instead. If they were deleted from the codebase, click here and archive them to remove them from the performance reports.

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.
@github-actions

Copy link
Copy Markdown
Contributor

Types Coverage

Coverage after merging claude/investigate-issue-20896-P4mzT into main will be
98.96%
Coverage Report
FileStmtsBranchesFuncsLinesUncovered Lines
bin
   webpack.js98.77%100%100%98.77%91
examples
   build-common.js100%100%100%100%
   buildAll.js100%100%100%100%
   examples.js100%100%100%100%
   template-common.js98.21%100%100%98.21%72
examples/custom-javascript-parser
   test.filter.js100%100%100%100%
examples/custom-javascript-parser/internals
   acorn-parse.js100%100%100%100%
   meriyah-parse.js100%100%100%100%
   oxc-parse.js91.30%100%100%91.30%140, 142–143, 145, 147, 153–154, 161, 168, 90
examples/markdown
   webpack.config.mjs100%100%100%100%
examples/typescript
   test.filter.js100%100%100%100%
examples/typescript-non-erasable
   test.filter.js50%100%100%50%5
examples/virtual-modules
   test.filter.js100%100%100%100%
examples/wasm-bindgen-esm
   test.filter.js100%100%100%100%
examples/wasm-complex
   test.filter.js100%100%100%100%
examples/wasm-simple
   test.filter.js100%100%100%100%
examples/wasm-simple-source-phase
   test.filter.js100%100%100%100%
lib
   APIPlugin.js100%100%100%100%
   AsyncDependenciesBlock.js100%100%100%100%
   AutomaticPrefetchPlugin.js100%100%100%100%
   BannerPlugin.js100%100%100%100%
   Cache.js98.21%100%100%98.21%101
   CacheFacade.js100%100%100%100%
   Chunk.js99.72%100%100%99.72%37
   ChunkGraph.js100%100%100%100%
   ChunkGroup.js100%100%100%100%
   ChunkTemplate.js100%100%100%100%
   CleanPlugin.js99.15%100%100%99.15%206, 226
   CodeGenerationResults.js100%100%100%100%
   CompatibilityPlugin.js100%100%100%100%
   Compilation.js98.55%100%100%98.55%1554, 1850, 1857, 1865, 1887, 2783, 3208, 3870, 3899, 3952–3953, 3957, 3962, 3978–3979, 3993–3994, 3999–4000, 4477, 4503, 493, 498, 5211, 5292, 5307, 5332–5333, 5335, 5659, 5664, 5670, 5673, 5685, 5687, 5691, 5707, 5722, 5754, 5808, 5832, 5946, 712–713
   Compiler.js99.55%100%100%99.55%1116–1117, 1125
   ConcatenationScope.js98.59%100%100%98.59%189
   ConditionalInitFragment.js100%100%100%100%
   ConstPlugin.js100%100%100%100%
   ContextExclusionPlugin.js100%100%100%100%
   ContextModule.js100%100%100%100%
   ContextModuleFactory.js97.75%100%100%97.75%258, 393, 418, 443, 447, 458
   ContextReplacementPlugin.js100%100%100%100%
   DefinePlugin.js98.92%100%100%98.92%158–159, 175, 194, 268
   DependenciesBlock.js100%100%100%100%
   Dependency.js98.20%100%100%98.20%379, 425
   DependencyTemplate.js100%100%100%100%
   DependencyTemplates.js100%100%100%100%
   DotenvPlugin.js98.41%100%100%98.41%378, 391–392
   DynamicEntryPlugin.js100%100%100%100%
   EntryOptionPlugin.js100%100%100%100%
   EntryPlugin.js100%100%100%100%
   Entrypoint.js100%100%100%100%
   EnvironmentPlugin.js97.14%100%100%97.14%49
   ErrorHelpers.js100%100%100%100%
   EvalDevToolModulePlugin.js100%100%100%100%
   EvalSourceMapDevToolPlugin.js100%100%100%100%
   ExportsInfo.js100%100%100%100%
   ExportsInfoApiPlugin.js100%100%100%100%
   ExternalModule.js98.96%100%100%98.96%424–428, 576
   ExternalModuleFactoryPlugin.js100%100%100%100%
   ExternalsPlugin.js100%100%100%100%
   FileSystemInfo.js99.50%100%100%99.50%182, 2252–2253, 2256, 2267, 2278, 2289, 278, 3694, 3709, 3733
   FlagAllModulesAsUsedPlugin.js100%100%100%100%
   FlagDependencyExportsPlugin.js98.74%100%100%98.74%399, 401, 405
   FlagDependencyUsagePlugin.js100%100%100%100%
   FlagEntryExportAsUsedPlugin.js100%100%100%100%
   Generator.js100%100%100%100%
   HotModuleReplacementPlugin.js100%100%100%100%
   HotUpdateChunk.js100%100%100%100%
   IgnorePlugin.js100%100%100%100%
   IgnoreWarningsPlugin.js100%100%100%100%
   InitFragment.js100%100%100%100%
   JavascriptMetaInfoPlugin.js100%100%100%100%
   LibraryTemplatePlugin.js100%100%100%100%
   LoaderOptionsPlugin.js100%100%100%100%
   LoaderTargetPlugin.js100%100%100%100%
   MainTemplate.js100%100%100%100%
   ManifestPlugin.js100%100%100%100%
   Module.js98.50%100%100%98.50%1305, 1310, 1371, 1385, 1447, 1456
   ModuleFactory.js100%100%100%100%
   ModuleFilenameHelpers.js98.85%100%100%98.85%106, 108
   ModuleGraph.js99.73%100%100%99.73%1004
   ModuleGraphConnection.js100%100%100%100%
   ModuleInfoHeaderPlugin.js100%100%100%100%
   ModuleProfile.js100%100%100%100%
   ModuleSourceTypeConstants.js100%100%100%100%
   ModuleTemplate.js100%100%100%100%
   ModuleTypeConstants.js100%100%100%100%
   MultiCompiler.js99.69%100%100%99.69%645
   MultiStats.js100%100%100%100%
   MultiWatching.js100%100%100%100%
   NoEmitOnErrorsPlugin.js100%100%100%100%
   NodeStuffPlugin.js100%100%100%100%
   NormalModule.js97.78%100%100%97.78%1020, 1036, 1123, 1774, 1779–1789, 708, 711, 728, 745, 986
   NormalModuleFactory.js99.47%100%100%99.47%1075, 1384, 474, 486
   NormalModuleReplacementPlugin.js100%100%100%100%
   NullFactory.js100%100%100%100%
   OptimizationStages.js100%100%100%100%
   OptionsApply.js100%100%100%100%
   Parser.js100%100%100%100%
   PlatformPlugin.js100%100%100%100%
   PrefetchPlugin.js100%100%100%100%
   ProgressPlugin.js98.85%100%100%98.85%519–520, 525, 527, 591
   ProvidePlugin.js100%100%100%100%
   RawModule.js100%100%100%100%
   RecordIdsPlugin.js100%100%100%100%
   RequestShortener.js100%100%100%100%
   ResolverFactory.js100%100%100%100%
   RuntimeGlobals.js100%100%100%100%
   RuntimeModule.js100%100%100%100%
   RuntimePlugin.js100%100%100%100%
   RuntimeTemplate.js100%100%100%100%
   SelfModuleFactory.js100%100%100%100%
   SingleEntryPlugin.js100%100%100%100%
   SourceMapDevToolModuleOptionsPlugin.js100%100%100%100%
   SourceMapDevToolPlugin.js99.16%100%100%99.16%267–268, 610
   Stats.js100%100%100%100%
   Template.js100%100%100%100%
   TemplatedPathPlugin.js98.86%100%100%98.86%134–135
   UseStrictPlugin.js100%100%100%100%
   WarnCaseSensitiveModulesPlugin.js100%100%100%100%
   WarnDeprecatedOptionPlugin.js100%100%100%100%
   WarnNoModeSetPlugin.js100%100%100%100%
   WatchIgnorePlugin.js100%100%100%100%
   Watching.js100%100%100%100%
   WebpackError.js100%100%100%100%
   WebpackIsIncludedPlugin.js100%100%100%100%
   WebpackOptionsApply.js100%100%100%100%
   WebpackOptionsDefaulter.js100%100%100%100%
   buildChunkGraph.js99.87%100%100%99.87%325
   cli.js98.46%100%100%98.46%10, 119, 471, 503, 545, 815
   index.js99.72%100%100%99.72%163
   validateSchema.js94.67%100%100%94.67%100, 87, 89, 98
   webpack.js96.33%100%100%96.33%10, 198, 220, 222
lib/asset
   AssetBytesGenerator.js100%100%100%100%
   AssetBytesParser.js100%100%100%100%
   AssetGenerator.js100%100%100%100%
   AssetModulesPlugin.js97.33%100%100%97.33%287, 311, 314, 366, 37, 42
   AssetParser.js100%100%100%100%
   AssetSourceGenerator.js100%100%100%100%
   AssetSourceParser.js100%100%100%100%
   RawDataUrlModule.js100%100%100%100%
lib/async-modules
   AsyncModuleHelpers.js100%100%100%100%
   AwaitDependenciesInitFragment.js100%100%100%100%
   InferAsyncModulesPlugin.js100%100%100%100%
lib/cache
   AddBuildDependenciesPlugin.js100%100%100%100%
   AddManagedPathsPlugin.js100%100%100%100%
   IdleFileCachePlugin.js97.92%100%100%97.92%71, 83, 91
   MemoryCachePlugin.js95.83%100%100%95.83%33
   MemoryWithGcCachePlugin.js93.15%100%100%93.15%106, 113–114, 122, 89
   PackFileCacheStrategy.js96.40%100%100%96.40%1250, 1350, 1354, 1416, 628, 647, 657–659, 661, 677–678, 683, 686, 688, 693, 698, 722, 728, 762, 768, 774, 779, 790, 799, 804–805, 807, 824, 830–831, 833
   ResolverCachePlugin.js100%100%100%100%
   getLazyHashedEtag.js100%100%100%100%
   mergeEtags.js100%100%100%100%
lib/config
   browserslistTargetHandler.js100%100%100%100%
   defaults.js99.29%100%100%99.29%1411–1413, 1421, 271, 274, 279, 283
   normalization.js99%100%100%99%191–192, 258, 273
   target.js100%100%100%100%
lib/container
   ContainerEntryDependency.js100%100%100%100%
   ContainerEntryModule.js100%100%100%100%
   ContainerEntryModuleFactory.js100%100%100%100%
   ContainerExposedDependency.js100%100%100%100%
   ContainerPlugin.js100%100%100%100%
   ContainerReferencePlugin.js100%100%100%100%
   FallbackDependency.js100%100%100%100%
   FallbackItemDependency.js100%100%100%100%
   FallbackModule.js100%100%100%100%
   FallbackModuleFactory.js100%100%100%100%
   HoistContainerReferencesPlugin.js100%100%100%100%
   ModuleFederationPlugin.js100%100%100%100%
   RemoteModule.js<

@alexander-akait alexander-akait merged commit 0b7de2f into main May 20, 2026
60 of 61 checks passed
@alexander-akait alexander-akait deleted the claude/investigate-issue-20896-P4mzT branch May 20, 2026 00:20
Sign up for free to join this conversation on GitHub. Already have an account? Sign in to comment

Labels

None yet

Projects

None yet

Development

Successfully merging this pull request may close these issues.

ESM/CJS interop: return the named export "module.exports" when require(esm)

2 participants