From f2d399d7eebe700313ff13fc7bdfbd7dd1058e86 Mon Sep 17 00:00:00 2001 From: Sam Attard Date: Sun, 5 Apr 2026 00:27:44 +0000 Subject: [PATCH] cache the result of inline require() of bundled ESM modules MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit When a bundled CJS-shaped file calls require() on a bundled ESM-shaped module, esbuild emits `(init_foo(), __toCommonJS(foo_exports))` at each call site. Since f4ff26d3 the __toCommonJS helper allocates a fresh wrapper on every call, so two require() calls for the same module return different objects — diverging from Node's CJS cache semantics, webpack's __webpack_require__ cache, and esbuild ≤ 0.14.26. This adds a separate __toCommonJSCached runtime helper (WeakMap- memoized, falling back to no-cache when WeakMap is unavailable) and routes only the inline-require code path to it. The entry-point path (`module.exports = __toCommonJS(exports)`, called once) continues to use the uncached helper, so bundles with no inline ESM require() pay no extra cost. Fixes #4440. --- .../bundler_tests/bundler_importstar_test.go | 25 +++++++++++++++++++ .../bundler_tests/snapshots/snapshots_dce.txt | 2 +- .../snapshots/snapshots_default.txt | 14 +++++------ .../snapshots/snapshots_importstar.txt | 24 ++++++++++++++++-- .../snapshots/snapshots_importstar_ts.txt | 2 +- .../snapshots/snapshots_packagejson.txt | 4 +-- .../snapshots/snapshots_splitting.txt | 12 ++++----- internal/js_printer/js_printer.go | 8 ++++-- internal/linker/linker.go | 11 ++++++-- internal/runtime/runtime.go | 11 ++++++++ 10 files changed, 90 insertions(+), 23 deletions(-) diff --git a/internal/bundler_tests/bundler_importstar_test.go b/internal/bundler_tests/bundler_importstar_test.go index 7c603d05bb1..4fb8b0821ed 100644 --- a/internal/bundler_tests/bundler_importstar_test.go +++ b/internal/bundler_tests/bundler_importstar_test.go @@ -1810,6 +1810,31 @@ entry-nope.js: WARNING: Import "nope" will always be undefined because the file }) } +// https://github.com/evanw/esbuild/issues/4440 — repeated `require()` of a +// bundled ESM module must return the same wrapper object so that identity +// matches Node.js CJS cache semantics. The inline-require path uses +// "__toCommonJSCached" so the two `require()` calls below produce a single +// wrapper. +func TestRequireOfESMPreservesIdentity(t *testing.T) { + importstar_suite.expectBundled(t, bundled{ + files: map[string]string{ + "/entry.js": ` + const a = require('./esm') + const b = require('./esm') + console.log(a === b) + `, + "/esm.js": ` + export const value = 123 + `, + }, + entryPaths: []string{"/entry.js"}, + options: config.Options{ + Mode: config.ModeBundle, + AbsOutputFile: "/out.js", + }, + }) +} + // Failure case due to a bug in https://github.com/evanw/esbuild/pull/2059 func TestReExportStarEntryPointAndInnerFile(t *testing.T) { importstar_suite.expectBundled(t, bundled{ diff --git a/internal/bundler_tests/snapshots/snapshots_dce.txt b/internal/bundler_tests/snapshots/snapshots_dce.txt index 80d8e975646..58b02dae61c 100644 --- a/internal/bundler_tests/snapshots/snapshots_dce.txt +++ b/internal/bundler_tests/snapshots/snapshots_dce.txt @@ -3002,7 +3002,7 @@ var init_cjs = __esm({ // entry.js init_lib(); -console.log(keep1(), (init_cjs(), __toCommonJS(cjs_exports))); +console.log(keep1(), (init_cjs(), __toCommonJSCached(cjs_exports))); ================================================================================ TestTreeShakingJSWithAssociatedCSS diff --git a/internal/bundler_tests/snapshots/snapshots_default.txt b/internal/bundler_tests/snapshots/snapshots_default.txt index 903ef5dd019..c5f62dace55 100644 --- a/internal/bundler_tests/snapshots/snapshots_default.txt +++ b/internal/bundler_tests/snapshots/snapshots_default.txt @@ -793,9 +793,9 @@ var init_bar = __esm({ }); // entry.js -var { foo: foo2 } = (init_foo(), __toCommonJS(foo_exports)); +var { foo: foo2 } = (init_foo(), __toCommonJSCached(foo_exports)); console.log(foo2(), bar2()); -var { bar: bar2 } = (init_bar(), __toCommonJS(bar_exports)); +var { bar: bar2 } = (init_bar(), __toCommonJSCached(bar_exports)); ================================================================================ TestConditionalImport @@ -1230,7 +1230,7 @@ var init_types = __esm({ }); // entry.js -console.log((init_types(), __toCommonJS(types_exports))); +console.log((init_types(), __toCommonJSCached(types_exports))); ================================================================================ TestEntryNamesChunkNamesExtPlaceholder @@ -1593,7 +1593,7 @@ var init_lib = __esm({ }); // entry.js -var lib = (init_lib(), __toCommonJS(lib_exports)); +var lib = (init_lib(), __toCommonJSCached(lib_exports)); console.log(lib.__proto__); ================================================================================ @@ -3858,7 +3858,7 @@ var require_cjs = __commonJS({ // entry-cjs.js var require_entry_cjs = __commonJS({ "entry-cjs.js"(exports) { - var { b: esm_foo_2 } = (init_esm(), __toCommonJS(esm_exports)); + var { b: esm_foo_2 } = (init_esm(), __toCommonJSCached(esm_exports)); var { a: cjs_foo_ } = require_cjs(); exports.c = [ esm_foo_2, @@ -6561,7 +6561,7 @@ var require_es6_import_stmt = __commonJS({ // es6-import-assign.ts var require_es6_import_assign = __commonJS({ "es6-import-assign.ts"(exports) { - var x2 = (init_dummy(), __toCommonJS(dummy_exports)); + var x2 = (init_dummy(), __toCommonJSCached(dummy_exports)); console.log(exports); } }); @@ -6757,7 +6757,7 @@ console.log(void 0); var import_es6_export_assign = __toESM(require_es6_export_assign()); // es6-export-import-assign.ts -var x = (init_dummy(), __toCommonJS(dummy_exports)); +var x = (init_dummy(), __toCommonJSCached(dummy_exports)); console.log(void 0); // entry.js diff --git a/internal/bundler_tests/snapshots/snapshots_importstar.txt b/internal/bundler_tests/snapshots/snapshots_importstar.txt index af86cb07fa9..19f76be05ad 100644 --- a/internal/bundler_tests/snapshots/snapshots_importstar.txt +++ b/internal/bundler_tests/snapshots/snapshots_importstar.txt @@ -78,7 +78,7 @@ var foo; var init_entry = __esm({ "entry.js"() { foo = 123; - console.log((init_entry(), __toCommonJS(entry_exports))); + console.log((init_entry(), __toCommonJSCached(entry_exports))); } }); init_entry(); @@ -459,7 +459,7 @@ var init_foo = __esm({ // entry.js init_foo(); -var ns2 = (init_foo(), __toCommonJS(foo_exports)); +var ns2 = (init_foo(), __toCommonJSCached(foo_exports)); console.log(foo, ns2.foo); ================================================================================ @@ -1039,3 +1039,23 @@ var x = 1; // entry.js console.log(x); + +================================================================================ +TestRequireOfESMPreservesIdentity +---------- /out.js ---------- +// esm.js +var esm_exports = {}; +__export(esm_exports, { + value: () => value +}); +var value; +var init_esm = __esm({ + "esm.js"() { + value = 123; + } +}); + +// entry.js +var a = (init_esm(), __toCommonJSCached(esm_exports)); +var b = (init_esm(), __toCommonJSCached(esm_exports)); +console.log(a === b); diff --git a/internal/bundler_tests/snapshots/snapshots_importstar_ts.txt b/internal/bundler_tests/snapshots/snapshots_importstar_ts.txt index c4fee5b6b7c..bb6728cbc1b 100644 --- a/internal/bundler_tests/snapshots/snapshots_importstar_ts.txt +++ b/internal/bundler_tests/snapshots/snapshots_importstar_ts.txt @@ -14,7 +14,7 @@ var init_foo = __esm({ // entry.js init_foo(); -var ns2 = (init_foo(), __toCommonJS(foo_exports)); +var ns2 = (init_foo(), __toCommonJSCached(foo_exports)); console.log(foo, ns2.foo); ================================================================================ diff --git a/internal/bundler_tests/snapshots/snapshots_packagejson.txt b/internal/bundler_tests/snapshots/snapshots_packagejson.txt index 0fba60ecbcb..05743120193 100644 --- a/internal/bundler_tests/snapshots/snapshots_packagejson.txt +++ b/internal/bundler_tests/snapshots/snapshots_packagejson.txt @@ -503,7 +503,7 @@ var init_module = __esm({ }); // Users/user/project/src/test-main.js -console.log((init_module(), __toCommonJS(module_exports))); +console.log((init_module(), __toCommonJSCached(module_exports))); // Users/user/project/src/test-module.js init_module(); @@ -542,7 +542,7 @@ var init_module = __esm({ }); // Users/user/project/src/test-index.js -console.log((init_module(), __toCommonJS(module_exports))); +console.log((init_module(), __toCommonJSCached(module_exports))); // Users/user/project/src/test-module.js init_module(); diff --git a/internal/bundler_tests/snapshots/snapshots_splitting.txt b/internal/bundler_tests/snapshots/snapshots_splitting.txt index 071fb909084..c0ed9286fd5 100644 --- a/internal/bundler_tests/snapshots/snapshots_splitting.txt +++ b/internal/bundler_tests/snapshots/snapshots_splitting.txt @@ -370,7 +370,7 @@ TestSplittingHybridESMAndCJSIssue617 import { foo, init_a -} from "./chunk-PDZFCFBH.js"; +} from "./chunk-GBWBY5I7.js"; init_a(); export { foo @@ -378,18 +378,18 @@ export { ---------- /out/b.js ---------- import { - __toCommonJS, + __toCommonJSCached, a_exports, init_a -} from "./chunk-PDZFCFBH.js"; +} from "./chunk-GBWBY5I7.js"; // b.js -var bar = (init_a(), __toCommonJS(a_exports)); +var bar = (init_a(), __toCommonJSCached(a_exports)); export { bar }; ----------- /out/chunk-PDZFCFBH.js ---------- +---------- /out/chunk-GBWBY5I7.js ---------- // a.js var a_exports = {}; __export(a_exports, { @@ -402,7 +402,7 @@ var init_a = __esm({ }); export { - __toCommonJS, + __toCommonJSCached, foo, a_exports, init_a diff --git a/internal/js_printer/js_printer.go b/internal/js_printer/js_printer.go index b6542bd8fd0..0fdbdaea6d4 100644 --- a/internal/js_printer/js_printer.go +++ b/internal/js_printer/js_printer.go @@ -1583,10 +1583,13 @@ func (p *printer) printRequireOrImportExpr(importRecordIndex uint32, level js_as p.print(",") p.printSpace() - // Wrap this with a call to "__toCommonJS()" if this is an ESM file + // Wrap this with a call to "__toCommonJSCached()" if this is an ESM + // file. The cached variant is used (rather than "__toCommonJS") so + // that two require() calls for the same bundled ESM module return + // the same wrapper object, matching Node.js CJS cache semantics. wrapWithTpCJS := record.Flags.Has(ast.WrapWithToCJS) if wrapWithTpCJS { - p.printIdentifier(p.renamer.NameForSymbol(p.options.ToCommonJSRef)) + p.printIdentifier(p.renamer.NameForSymbol(p.options.ToCommonJSCachedRef)) p.print("(") } p.printIdentifier(p.renamer.NameForSymbol(meta.ExportsRef)) @@ -4929,6 +4932,7 @@ type Options struct { LineOffsetTables []sourcemap.LineOffsetTable ToCommonJSRef ast.Ref + ToCommonJSCachedRef ast.Ref ToESMRef ast.Ref RuntimeRequireRef ast.Ref UnsupportedFeatures compat.JSFeature diff --git a/internal/linker/linker.go b/internal/linker/linker.go index bdff3162ed0..9b2080de2db 100644 --- a/internal/linker/linker.go +++ b/internal/linker/linker.go @@ -1979,8 +1979,11 @@ func (c *linkerContext) scanImportsAndExports() { c.graph.GenerateRuntimeSymbolImportAndUse(sourceIndex, uint32(partIndex), "__toESM", toESMUses) // If there's a CommonJS require of an ES6 module, then we're going to need the - // "__toCommonJS" symbol from the runtime to wrap the exports object - c.graph.GenerateRuntimeSymbolImportAndUse(sourceIndex, uint32(partIndex), "__toCommonJS", toCommonJSUses) + // "__toCommonJSCached" symbol from the runtime to wrap the exports object. + // The cached variant is used here (rather than "__toCommonJS") so that + // repeated require() of the same ESM module returns the same wrapper, + // matching Node.js CJS cache semantics. + c.graph.GenerateRuntimeSymbolImportAndUse(sourceIndex, uint32(partIndex), "__toCommonJSCached", toCommonJSUses) // If there are unbundled calls to "require()" and we're not generating // code for node, then substitute a "__require" wrapper for "require". @@ -4659,6 +4662,7 @@ func (c *linkerContext) generateCodeForFileInChunkJS( waitGroup *sync.WaitGroup, partRange partRange, toCommonJSRef ast.Ref, + toCommonJSCachedRef ast.Ref, toESMRef ast.Ref, runtimeRequireRef ast.Ref, result *compileResultJS, @@ -4957,6 +4961,7 @@ func (c *linkerContext) generateCodeForFileInChunkJS( LineLimit: c.options.LineLimit, ASCIIOnly: c.options.ASCIIOnly, ToCommonJSRef: toCommonJSRef, + ToCommonJSCachedRef: toCommonJSCachedRef, ToESMRef: toESMRef, RuntimeRequireRef: runtimeRequireRef, TSEnums: c.graph.TSEnums, @@ -5577,6 +5582,7 @@ func (c *linkerContext) generateChunkJS(chunkIndex int, chunkWaitGroup *sync.Wai compileResults := make([]compileResultJS, 0, len(chunkRepr.partsInChunkInOrder)) runtimeMembers := c.graph.Files[runtime.SourceIndex].InputFile.Repr.(*graph.JSRepr).AST.ModuleScope.Members toCommonJSRef := ast.FollowSymbols(c.graph.Symbols, runtimeMembers["__toCommonJS"].Ref) + toCommonJSCachedRef := ast.FollowSymbols(c.graph.Symbols, runtimeMembers["__toCommonJSCached"].Ref) toESMRef := ast.FollowSymbols(c.graph.Symbols, runtimeMembers["__toESM"].Ref) runtimeRequireRef := ast.FollowSymbols(c.graph.Symbols, runtimeMembers["__require"].Ref) r := c.renameSymbolsInChunk(chunk, chunkRepr.filesInChunkInOrder, timer) @@ -5609,6 +5615,7 @@ func (c *linkerContext) generateChunkJS(chunkIndex int, chunkWaitGroup *sync.Wai &waitGroup, partRange, toCommonJSRef, + toCommonJSCachedRef, toESMRef, runtimeRequireRef, compileResult, diff --git a/internal/runtime/runtime.go b/internal/runtime/runtime.go index 5f95dcdc88f..eee48a430f6 100644 --- a/internal/runtime/runtime.go +++ b/internal/runtime/runtime.go @@ -246,6 +246,17 @@ func Source(unsupportedJSFeatures compat.JSFeature) logger.Source { // to "true", which overwrites any existing export named "__esModule". export var __toCommonJS = mod => __copyProps(__defProp({}, '__esModule', { value: true }), mod) + // Same conversion as __toCommonJS but memoized. Used for inline + // "require()" of a bundled ESM module, where the call is emitted at + // every require site; without the cache, two require() calls for the + // same module would return distinct objects, breaking Node.js CJS + // cache semantics. Falls back to no cache when WeakMap is unavailable. + export var __toCommonJSCached = /* @__PURE__ */ (cache => (mod, result) => ( + cache && (result = cache.get(mod)) || ( + result = __toCommonJS(mod), cache && cache.set(mod, result)), + result + ))(typeof WeakMap !== 'undefined' ? new WeakMap : 0) + // For TypeScript experimental decorators // - kind === undefined: class // - kind === 1: method, parameter