Sitelet https://github.com/evanw/esbuild/pull/4526
Skip to content

fix #4377: expand CommonJS exports for split dynamic imports - #4526

Open
hamodywe wants to merge 1 commit into
evanw:mainfrom
hamodywe:fix-4377-splitting-cjs-dynamic-import
Open

hamodywe wants to merge 1 commit into
evanw:mainfrom
hamodywe:fix-4377-splitting-cjs-dynamic-import

Conversation

@hamodywe

Copy link
Copy Markdown

Fixes #4377 (analysis in this comment).

Problem

With --splitting, a dynamic import() of a CommonJS module that is also an entry point resolves to the chunk's namespace object, which nests the CommonJS exports object on default. Named accesses like mod.foo silently return undefined at run-time, while the same code works unbundled and when bundled without code splitting:

$ esbuild main.js impl.js --bundle --format=esm --splitting --outdir=out
$ node out/main.js
TypeError: mod.foo is not a function

Without splitting, the same import() prints as Promise.resolve().then(() => __toESM(require_impl())), so the two modes disagree about the namespace shape.

Fix

The linker now flags cross-chunk dynamic imports whose target is a CommonJS entry point (WrapDynamicImportWithToESM), and the printer appends a call to a new parameterless runtime helper:

var __toDynamicImportESM = isNodeMode => mod => __toESM(mod.default, isNodeMode)

// output
import("./impl.js").then(__toDynamicImportESM())

A helper is used instead of an inline .then((m) => __toESM(m.default)) because a printed parameter name could collide with minified runtime symbols; a bare identifier goes through the renamer and is collision-free (verified under --minify, where it prints as .then(e())).

The isNodeMode argument mirrors the existing behavior of the non-splitting path (p.moduleType.IsESM()), so the split and non-split namespaces match in the Babel-style __esModule case too.

Compatibility

__toESM keeps default pointing at the CommonJS exports object, so existing code that adapted to the old shape with mod.default.foo keeps working — the existing end-to-end test asserting ns2.default.foo passes unchanged. Only import() records are affected; static cross-chunk imports, external imports, and require() are untouched.

Tests

  • Updated snapshots: TestSplittingDynamicCommonJSIntoES6, TestSplittingDynamicAndNotDynamicCommonJSIntoES6, TestGlobBasicSplitting, TestTSGlobBasicSplitting (glob dynamic imports of CJS get the same treatment), plus hash-only churn in TestSplittingHybridESMAndCJSIssue617 from the runtime edit.
  • Two new end-to-end tests that execute the output in Node: the issue's exact repro (asserting both mod.foo() and mod.default.foo()), and a Babel-style __esModule module asserting parity with the non-splitting output.
  • Revert-checked: with the code reverted and tests kept, exactly the five affected snapshot tests fail.
  • go test ./... and node scripts/end-to-end-tests.js pass (the only failures on this machine are pre-existing environment issues: a Windows symlink-permission test and a Node 26 async-iterator behavior change, both identical on an unmodified tree).

Disclosure: this PR was written with AI assistance (Claude Code); the analysis and output were verified end-to-end locally as described above.

With code splitting, a dynamic import() of a CommonJS module that is
also an entry point resolves to that chunk's namespace object, which
nests the CommonJS exports object on "default". Property accesses like
"mod.foo" then silently return undefined at run-time, while the same
import() works both unbundled and when bundled without code splitting
(where esbuild wraps the require call with __toESM).

Wrap such cross-chunk dynamic imports with a new __toDynamicImportESM
runtime helper that expands the chunk's default export into an ESM
namespace via __toESM. The "default" property still points at the
CommonJS exports object exactly as before, so existing code that
reaches through "mod.default" keeps working; named accesses now work
too, matching the behavior of import() without code splitting.
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.

Dynamic import of CJS entry point fails with --splitting

1 participant