Skip to content

Unused CommonJS re-exports are concatenated into a call chain, breaking semicolon-free packages (regression in 5.110.3) #21955

Closed

Description

@albo72

Have you used AI?

Yes

Which part of webpack is affected?

optimization (tree-shaking, splitChunks, concatenation)

Bug Description

With module concatenation enabled (the default in `mode: "production"`), an unused CommonJS
re-export is emitted as a bare parenthesised module reference. When the source file is written
without semicolons, consecutive statements merge into a call chain and the bundle throws while
it is still loading.

`[email protected]` is written in semicolon-free style and re-exports like this:

```js
module.exports.Type = require('./lib/type')
module.exports.Schema = require('./lib/schema')
module.exports.FAILSAFE_SCHEMA = require('./lib/schema/failsafe')
module.exports.JSON_SCHEMA = require('./lib/schema/json')
module.exports.CORE_SCHEMA = require('./lib/schema/core')
module.exports.DEFAULT_SCHEMA = require('./lib/schema/default')
module.exports.load = loader.load

electron-updater only reads load, so the six re-exports above are unused and their
assignments are dropped. 5.110.3 emits what is left as:

Ge()(Fe())(Be())(Ve())(je())(Ue()),e.exports.load=r.load, …

Ge() returns js-yaml's Type constructor, so it ends up invoked as a plain function with
Fe() as its argument. The module is in strict mode, so this is undefined and the first
assignment inside Type throws.

The same input builds and runs correctly on 5.110.2.


### Link to Minimal Reproduction and steps to reproduce

https://github.com/albo72/webpack-cjs-unused-reexport-repro

Steps:

  1. npm install
  2. npm run build
  3. npm start

Expected: prints loaded object
Actual: throws TypeError while loading the bundle

To confirm the regression:

Search out/bundle.js for is removed in js-yaml 4 to see the emitted code.

Notes:

  • An ES module entry is required: concatenation starts at the ESM root and pulls the CommonJS
    dependencies in. With a CommonJS entry the output is fine.
  • [email protected] is unaffected — it is written with semicolons, so dropping the assignments
    still leaves separate statements.
  • electron-stub.js in the repro only exists so the bundle can run under plain Node;
    Electron is not needed to reproduce the bad output.

### Expected Behavior

Dropping an unused re-export must not change how the remaining expression binds to the
following statement. Each re-export should stay a separate statement, as it does in 5.110.2.


### Actual Behavior

The re-exports are emitted as a single chained call expression, and loading the bundle throws:

TypeError: Cannot set properties of undefined (setting 'options')
at e.exports (out/bundle.js:2:185867)
at Object. (out/bundle.js:2:152584)
at out/bundle.js:2:55215
at Object. (out/bundle.js:2:77397)


### Environment

```shell
System:
    OS: Linux 6.8 Ubuntu 22.04.5 LTS 22.04.5 LTS (Jammy Jellyfish)
    CPU: (12) x64 11th Gen Intel(R) Core(TM) i5-11400H @ 2.70GHz
    Memory: 18.07 GB / 30.99 GB
  Binaries:
    Node: 18.19.0
    npm: 10.2.3
  Packages:
    webpack: 5.110.3 => 5.110.3
    webpack-cli: 4.10.0 => 4.10.0

Is this a regression?

Yes (please specify version below)

Last Working Version

v5.110.2

Additional Context

Likely cause.

`ConcatenatedModule` gained this branch in 5.110.3, as part of "Fix `new` on a default import
of a wrapped CommonJS module" (#21876):

```js
} else if (accessorCall && !asCall) {
  // without parentheses `new X()` would construct the accessor rather
  // than what it returns
  return asiSafe === false ? `;(${reference})` : `(${reference})`;
}

The reference is now wrapped in parentheses, and the guarding semicolon is only emitted when
the caller passes asiSafe === false.

CommonJsExportRequireDependency passes asiSafe: true, which holds while the statement keeps
its module.exports.X = left-hand side. For an unused re-export that assignment is removed:

source.replace(
  dep.range[0],
  dep.range[1] - 1,
  used
    ? `${base}${propertyAccess(used)} = ${requireExpr}`
    : `/* unused reexport */ ${requireExpr}`
);

What is left is a bare expression, so the parenthesised reference on the next line attaches to
it and ASI never happens.

For comparison, CommonJsExportsDependency handles its unused case by keeping an assignment
(__webpack_unused_export__ = …), which is immune to this. Making the re-export path do the
same produced correct output against the reproduction above, with both concatenateModules
and usedExports left enabled.

Workarounds that avoid the broken output: optimization.concatenateModules: { commonjs: false }
or optimization.usedExports: false.

Possibly related: a hand-written semicolon-free package that hits the binding.info.wrapped
branch produces the same shape of output on 5.110.2 as well, so that path may share the root
cause and predate this regression.

Activity

  1. added theissue type on Sep 4, 2026
  2. added
    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
    on Sep 4, 2026
  3. blankse commented on Sep 4, 2026

    @blankse

    I hit the same regression independently and filed #21956 before seeing this one — closing mine as a duplicate. Adding what my investigation covers that this report does not, in case it helps narrow the fix.

    The trigger does not need a dropped re-export

    Here the bare parenthesized reference appears because unused module.exports assignments are eliminated. It also happens with nothing eliminated at all: two adjacent require() expression statements, where the first has no trailing semicolon, are enough.

    Four files, webpack and webpack-cli only, no third-party dependency:

    src/index.js — the import is what makes this module ESM and therefore the concatenation root:

    import X from "./x";
    
    window.X = X;
    
    require("./a")
    require("./b");

    src/x.js:

    export default function X() {
    	return 42;
    }

    src/a.js and src/b.js:

    window.a = true;
    window.b = true;

    webpack.config.js:

    const path = require("path");
    
    module.exports = {
    	mode: "production",
    	entry: path.resolve(__dirname, "src/index.js"),
    	output: {
    		path: path.resolve(__dirname, "dist"),
    		filename: "bundle.js"
    	}
    };

    node -e "global.window={}; require('./dist/bundle.js')" throws TypeError: o(...) is not a function on 5.110.3 and runs on 5.110.2.

    Unminified, the difference is only the parentheses:

    // 5.110.2                  // 5.110.3
    a_namespaceFn()             (a_namespaceFn())
    b_namespaceFn();            (b_namespaceFn());

    So the fix needs to hold for a plain CommonJsRequireDependency too, not only for the CommonJsExportRequireDependency path — no export elimination is involved above.

    It is the accessorCall && !asCall branch, not the binding.info.wrapped one

    ConcatenatedModule.js has the same parenthesizing expression twice. Neutralizing them one at a time in an installed 5.110.3 isolates it:

    patched collapsed
    nothing yes
    line 984, else if (binding.info.wrapped) yes
    line 989, else if (accessorCall && !asCall) no

    That is the branch #21876 added and the one quoted above, so both reports land on the same line.

    Two more data points from patching line 989 in place:

    variant collapsed
    `(${reference})` → `${reference}` no
    always `;(${reference})` no

    Bisect, and terser is not involved

    Same reproduction, only the version changed:

    webpack collapsed
    5.109.2 no
    5.110.0 no
    5.110.1 no
    5.110.2 no
    5.110.3 yes

    The collapse is already present in the unminified output. Crossing the versions confirms terser is not a factor:

    webpack terser collapsed
    5.110.3 5.51.2 yes
    5.110.3 5.49.0 yes
    5.109.2 5.51.2 no
    5.109.2 5.49.0 no

    For completeness: #21907 landed in the same release and removed the ConcatenationScope.registerReplacedRequire / isInsideReplacedRequire containment guard. It looks related in the source diff but is not the cause — reverting only the parenthesization is sufficient.

    Impact note

    In our case this took down a production site after a routine dependency bump. The two semicolon-free require() lines had been in the source for over two years and were harmless until this release, and there is no build error or warning.

    Reproduction, bisect and branch attribution were produced with Claude Code; every number above comes from an actual build in an isolated directory.

  4. added 2 commits that reference this issue on Sep 4, 2026
    6205eea
    7988deb
  5. added a commit that references this issue on Sep 4, 2026
    c17742f
Sign up for free to join this conversation on GitHub. Already have an account? Sign in to comment

Metadata

Metadata

Assignees

No one assigned

    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