Skip to content

Regression in 5.110.0: "X_namespaceFn is not a constructor" when strict ESM constructs a CommonJS default import #21873

Description

@rproserpio

Have you used AI?

Yes

Which part of webpack is affected?

optimization (tree-shaking, splitChunks, concatenation)

Bug Description

Since 5.110.0, new X(...) is mis-compiled when X is the default import of a
CommonJS module and the importing module is strict ESM (.mjs, or .js under
"type": "module").

The default import binds directly to the inner module's lazy accessor, and the
emitted code is

new thing_namespaceFn()({ a: 1 })

which JavaScript parses as (new thing_namespaceFn())({ a: 1 }). The new is
applied to the accessor itself rather than to its return value. Since
__webpack_require__.cw returns an arrow function, and arrow functions are not
constructors, this throws at runtime.

The required output is new (thing_namespaceFn())({ a: 1 }).

Link to Minimal Reproduction and steps to reproduce

https://github.com/rproserpio/webpack-cjs-concat-new-repro

Two source files and a config, no dependencies beyond webpack.

src/thing.cjs

"use strict";
class Thing {
      constructor(options) {
              this.options = options;
      }
}
module.exports = Thing;

src/entry.mjs

import Thing from "./thing.cjs";

export function make() {
      return new Thing({ a: 1 });
}

webpack.config.mjs

export default {
      mode: "production",
      target: "node",
      entry: { index: "./src/entry.mjs" },
      optimization: { minimize: false },
      experiments: { outputModule: true },
      output: {
              filename: "[name].js",
              library: { type: "module", export: "make" },
              module: true,
      },
};

Steps:

  1. npm install
  2. npx webpack
  3. node -e "import('./dist/index.js').then(m => m.make())"

Expected Behavior

make() returns a Thing instance. This is what 5.109.2 and earlier do.

dist/index.js, webpack 5.109.2:

function make() {
      return new thing_namespaceObject({ a: 1 });
}

Actual Behavior

 TypeError: thing_namespaceFn is not a constructor

dist/index.js, webpack 5.110.0 and later:

var thing_namespaceFn = /*#__PURE__*/__webpack_require__.cw(function(module, exports) {
      class Thing {
              constructor(options) {
                      this.options = options;
              }
      }
      module.exports = Thing;
});

;// ./src/thing.cjs
thing_namespaceFn();

;// ./src/entry.mjs
function make() {
      return new thing_namespaceFn()({ a: 1 });
}

Environment

Binaries:
    Node: 24.20.0
    npm:  11.19.0
    pnpm: 10.34.5
Packages:
    webpack:     5.110.2
    webpack-cli: 6.0.1

Is this a regression?

Yes (please specify version below)

Last Working Version

v5.109.2

Additional Context

Version bisect

Same repro, only the webpack version changed. Each row is an actual make()
call, not just a reading of the emitted text:

webpack emitted make()
5.108.4 new _thing_cjs__WEBPACK_IMPORTED_MODULE_0__({ a: 1 }) Thing { options: { a: 1 } }
5.109.0 new thing_namespaceObject({ a: 1 }) Thing { options: { a: 1 } }
5.109.1 new thing_namespaceObject({ a: 1 }) Thing { options: { a: 1 } }
5.109.2 new thing_namespaceObject({ a: 1 }) Thing { options: { a: 1 } }
5.110.0 new thing_namespaceFn()({ a: 1 }) TypeError
5.110.1 new thing_namespaceFn()({ a: 1 }) TypeError
5.110.2 new thing_namespaceFn()({ a: 1 }) TypeError

The three 5.109.x bundles are byte-identical to each other, as are the three
5.110.x ones, so the boundary is exactly 5.109.2 -> 5.110.0. The repro repo keeps
one emitted bundle per version under builds/.

Diffing the two emitted bundles across that boundary shows the whole mechanism:

-var thing_namespaceObject = /*#__PURE__*/__webpack_require__.cjs(function(module, exports) {
+var thing_namespaceFn = /*#__PURE__*/__webpack_require__.cw(function(module, exports) {

 class Thing {
      constructor(options) {
              this.options = options;
      }
 }
 module.exports = Thing;

 });

+;// ./src/thing.cjs
+thing_namespaceFn();
+
 ;// ./src/entry.mjs

 function make() {
-     return new thing_namespaceObject({ a: 1 });
+     return new thing_namespaceFn()({ a: 1 });
 }

5.109 already had CommonJS concatenation, but under __webpack_require__.cjs,
which produced an eagerly evaluated namespaceObject identifier.
new <identifier>(...) needs no parentheses, so it was correct.

5.110.0 replaced that with __webpack_require__.cw, a lazy memoized accessor
(#21519, listed in the 5.110.0 release notes). Module evaluation is now forced by
a separate thing_namespaceFn(); statement, and the reference itself became a
call expression — but the substitution site still emits it bare, so the new
binds to the accessor instead of to its return value.

The javascript/auto path is already correct

Renaming entry.mjs to entry.js (so the importer is javascript/auto rather
than strict ESM) makes webpack route through the __webpack_require__.n()
interop, and there the parentheses are emitted correctly:

return new (thing_default()())({ a: 1 });

So the escaping rule exists — it is just not reached on the direct binding path
that strict-ESM importers take, where the default import resolves straight to
module.exports.

Suspected cause

getFinalName in lib/optimize/ConcatenatedModule.js (unchanged on main as of
2026-08-31) only parenthesises inside the isPropertyAccess branch:

if (isPropertyAccess) {
      if (asCall && callContext === false) {
              return asiSafe
                      ? `(0,${reference})`
                      : asiSafe === false
                              ? `;(0,${reference})`
                              : `/*#__PURE__*/Object(${reference})`;
      } else if (binding.info.wrapped) {
              return asiSafe === false ? `;(${reference})` : `(${reference})`;
      }
}

return reference;

isPropertyAccess is ids.length > 0 for a raw-name binding. A strict-ESM
default import of a wrapped CommonJS module has ids.length === 0, so the
binding.info.wrapped check never runs and the bare thing_namespaceFn() is
returned. binding.info.wrapped already encodes "this reference is a call
expression and needs wrapping"; it just is not consulted when there is no
property access.

Impact

Not limited to experiments.outputModuleoutput.library.type: "commonjs2"
produces the same broken new thing_namespaceFn()(...).

This is silent: the build succeeds with no warning, and the failure only appears
when the affected line executes. We hit it in production as
ajv_namespaceFn is not a constructor, from new Ajv(...) in
@modelcontextprotocol/sdk's dist/esm/validation/ajv-provider.js (that package
is "type": "module", so every file in it takes the strict-ESM path). The same
build also mis-compiled new HttpsProxyAgent(...) inside axios, which would
only have thrown once a proxy env var was set.

Workaround

optimization: {
      concatenateModules: { commonjs: false },
},

ESM concatenation and tree shaking are unaffected, and in our case the bundle
came out marginally smaller.

Activity

Sign up for free to join this conversation on GitHub. Already have an account? Sign in to comment

Metadata

Metadata

Labels

area: optimizationTree-shaking, splitChunks, concatenation, ids (lib/optimize, lib/ids)area: runtimeEmitted runtime and chunk loading across targets (lib/runtime, lib/web, lib/node, lib/esm)regressionWorked in a previous release; pair with the Affected version field

Type

Fields

Priority

None yet

Effort

None yet

Projects

No projects

    Milestone

    No milestone

    Relationships

    None yet

    Development

    No branches or pull requests

    Issue actions

    Sponsor
    SponsoredKunjungi sekarang
    Promo