Skip to content

[5.107.1 regression] CommonJS tree-shaking drops exports used through a const X = require(...) binding declared at the bottom of the file #21122

Description

@DmitryMarkov

Bug report

What is the current behavior?

Since webpack 5.107.1, CommonJS tree-shaking (introduced in #21003, "tree-shake CommonJS exports through const NAME = require(LITERAL) bindings") removes exports that are only referenced lexically before the const X = require(...) declaration — e.g. inside functions defined above a deferred require at the bottom of the file.

This is the exact code shape that webidl2js generates for every wrapper class in whatwg-url (the require is placed at the end of the file to handle circular dependencies):

// whatwg-url/lib/URL.js (generated by webidl2js)
exports.setup = (wrapper, globalObject, constructorArgs, privateData) => {
  // ...
  new Impl.implementation(constructorArgs, globalObject, privateData)
  // ...
}

// ~700 lines later, last line of the file:
const Impl = require("./URL-impl.js");
// whatwg-url/lib/URL-impl.js
exports.implementation = class URLImpl { /* ... */ };

With webpack >= 5.107.1 (production mode, target: 'node'), exports.implementation is removed from the bundle entirely, and the first new URL(...) call throws at runtime:

TypeError: Impl.implementation is not a constructor
    at exports.setup (bundle.js)
    at new URL (bundle.js)

Real-world impact: any server bundle containing whatwg-url breaks at runtime — including mongodb-connection-string-url → the mongodb driver → mongoose (MongoDB connections fail), and jsdom. We hit this when our Lambda bundle could no longer connect to MongoDB after a routine webpack bump.

Minimal reproduction

Three files, no dependencies:

// src/impl.js
exports.implementation = class URLImpl {
  constructor(args) {
    this.href = args[0]
  }
}
// src/wrapper.js
exports.setup = (obj, constructorArgs) => {
  obj._impl = new Impl.implementation(constructorArgs) // ref BEFORE the declaration below
  return obj
}

class URL {
  constructor(url) {
    return exports.setup(Object.create(new.target.prototype), [url])
  }
}

exports.interface = URL

// deferred require (handles circular dependencies), as generated by webidl2js
const Impl = require("./impl.js")
// src/entry.js
const { interface: URL } = require("./wrapper.js")
const u = new URL("https://example.com/")
console.log("OK", u._impl.href)
// webpack.config.js
module.exports = {
  entry: './src/entry.js',
  mode: 'production',
  optimization: { minimize: false }, // minimization not required to reproduce
  output: { filename: 'bundle.js' },
  target: 'node',
}
$ npx webpack && node dist/bundle.js
TypeError: Impl.implementation is not a constructor
    at exports.setup (dist/bundle.js:21:15)
    at new URL (dist/bundle.js:27:20)

The emitted bundle contains no assignment to implementation at all — the export is tree-shaken even though it is referenced through the Impl binding.

Moving const Impl = require("./impl.js") to the top of wrapper.js makes the bundle work, which suggests the new analysis only collects property accesses that appear after the binding's declaration, missing references hoisted into earlier function bodies (valid code — the functions run after module evaluation completes).

Alternatively, reproducible with the real packages:

// entry.js
const ConnectionString = require('mongodb-connection-string-url').default
new ConnectionString('mongodb://localhost/test') // throws with webpack >= 5.107.1

Bisection

webpack result
5.106.2 ✅ works
5.107.0 ✅ works
5.107.1 Impl.implementation is not a constructor
5.107.2 ❌ same

Introduced by #21003.

What is the expected behavior?

Property reads on the required-module binding should count as export usages regardless of where they appear lexically relative to the const X = require(...) declaration (function bodies above the declaration execute after it), or the optimization should bail out in that case.

Environment

  • webpack: 5.107.1 / 5.107.2 (5.107.0 and earlier OK)
  • Node.js: 22.x
  • OS: reproduced on macOS and Linux (Docker)
  • mode: production; also reproduces with mode: 'none' + optimization: { providedExports: true, sideEffects: true, usedExports: true } and optimization.minimize: false, so it is the usedExports analysis, not minification

Metadata

Metadata

Assignees

Labels

No labels
No labels

Type

No type

Fields

No fields configured for issues without a type.

Projects

No projects

Milestone

No milestone

Relationships

None yet

Development

No branches or pull requests

Issue actions