Reproduction link or steps
https://repl.rolldown.rs/#eNp1T8sSgjAM/JVML+iMA/c6/IIHz15KCYITW+wDZRj+3RQUT96yye5mdxKNkJNAE9yY33yajZA/fBCaobbGB1BQgsNH7BzusrxAf2dGtj9ezHqv/t4vpijgZGvkBWilW/Qbcy+TcVmy3Lc2Ug0VQnAR80V1tkS1fRrQ0TmORSP0rjPBQ6PIJ1J6bglzstdd9rGS2eHrmv5zDRQymc48Lbm2pitae+Krty7AWmdQFJErTXNymFk5MIVUQB/E/AYZ32Vb
What is expected?
When bundled code require()s an ES module more than once, rolldown returns a different object on every call, violating Node.js CommonJS semantics where require() caches the module and returns the same object each time.
This is the rolldown equivalent of evanw/esbuild#4440.
Node.js caches by resolved module and returns the same namespace-backed object every time.
What is actually happening?
Rolldown inlines __toCommonJS(...) at each call site, and __toCommonJS allocates a fresh wrapper every time:
const a = (init_esm(), __toCommonJS(esm_exports)); // object #1
const b = (init_esm(), __toCommonJS(esm_exports)); // object #2 → a !== b
System Info
Any additional comments?
I've talked with LLM if there's an easy way to solve this, but didn't seem to:
Approaches considered
Approaches considered
What a fix has to satisfy
- Identity — repeated
require() of the same module returns the same object.
- Cross-chunk — that identity holds even when caller and module land in different chunks.
- Cycle-safe — a
require() that fires during the module's own initialization (self-require or a require cycle) still gets a usable object, not undefined. Covered by bundler_esm_cjs_tests/16-19.
- Clean namespace — the fix must not add observable properties to the module namespace object (the thing you get from
import * as).
module.exports export — for export { x as 'module.exports' }, __toCommonJS returns that value by reference at call time, not via a live getter, so it can't be snapshotted before the body assigns it.
- No WeakMap —
WeakMap isn't guaranteed to be available in all target environments (old runtimes / constrained engines), so relying on it means either dropping those targets or carrying a typeof WeakMap fallback path. Avoiding it entirely keeps the output portable with no fallback branch.
The candidates
Starting from esbuild's fix, each WeakMap-free idea fails a different constraint:
-
esbuild's __toCommonJSCached (#4441) — a WeakMap keyed on the namespace object; builds the wrapper on first call and caches it. Ships a typeof WeakMap fallback precisely because WeakMap may be unavailable.
→ Correct on everything except (6): the WeakMap dependency (and its fallback path) is the bar to clear without one.
-
A — cache in a variable at the call site: (init(), x_cjs ??= __toCommonJS(x_exports)).
→ Fails (2): with code splitting each chunk declares its own x_cjs, so two chunks requiring the same module get two different objects.
-
A′ — promote that cache var to a real per-module binding, exported from the owning chunk.
→ Correct, but the cache slot has to be writable across chunks, and rolldown's cross-chunk exports are getter-based (read-only). Needs new export-mechanism plumbing — the heaviest option.
-
B — stash the cached object on the namespace object under a Symbol.
→ Fails (4): the slot shows up in Object.getOwnPropertySymbols(ns) / console.log(ns), since ns is the same object exposed via import * as.
-
C1 — a separate memoized thunk: var x_cjs = __esmMin(() => (init(), __toCommonJS(x_exports))).
→ Fails (3): the memoized value isn't assigned until the thunk returns, so a re-entrant (cyclic) call gets undefined. Also needs a new cross-chunk symbol.
-
C2 — fold the conversion into the init closure's return: init = __esmMin(() => { …body…; return __toCommonJS(ns) }), and lower each require to a bare init().
→ Gets (1), (2), (4), but still fails (3) for the same reason as C1 (the return value doesn't exist mid-body) — prototyping this regressed bundler_esm_cjs_tests/16-19 — and it entangles the change with TLA / concatenated closures.
A WeakMap-free candidate that clears all six: __toCommonJSInit eager-init wrapper
Wrap the init closure with a new runtime helper that computes and memoizes __toCommonJS(exports) before running the body:
__toCommonJSInit = (exports, fn, res, err) => () => {
if (err) throw err[0];
try { return fn && (res = __toCommonJS(exports), fn(fn = 0)), res; }
catch (e) { throw err = [e], e; }
};
// init_x = __toCommonJSInit(x_exports, () => { …body… });
// require site → init_x()
- (1) Identity, (3) cycle-safe:
res is set before the body runs, so a re-entrant require during init already sees the finished object; every later call returns the same res.
- (2) Cross-chunk: reuses the existing
init_x symbol — no new cross-chunk binding. Consumer chunks import fewer symbols than before (just init_x, no longer x_exports + __toCommonJS).
- (4) Clean namespace: the cache lives in the closure, never on
ns.
- (6): no WeakMap, so no fallback branch and no target restrictions.
Reproduction link or steps
https://repl.rolldown.rs/#eNp1T8sSgjAM/JVML+iMA/c6/IIHz15KCYITW+wDZRj+3RQUT96yye5mdxKNkJNAE9yY33yajZA/fBCaobbGB1BQgsNH7BzusrxAf2dGtj9ezHqv/t4vpijgZGvkBWilW/Qbcy+TcVmy3Lc2Ug0VQnAR80V1tkS1fRrQ0TmORSP0rjPBQ6PIJ1J6bglzstdd9rGS2eHrmv5zDRQymc48Lbm2pitae+Krty7AWmdQFJErTXNymFk5MIVUQB/E/AYZ32Vb
What is expected?
When bundled code
require()s an ES module more than once, rolldown returns a different object on every call, violating Node.js CommonJS semantics whererequire()caches the module and returns the same object each time.This is the rolldown equivalent of evanw/esbuild#4440.
Node.js caches by resolved module and returns the same namespace-backed object every time.
What is actually happening?
Rolldown inlines
__toCommonJS(...)at each call site, and__toCommonJSallocates a fresh wrapper every time:System Info
Any additional comments?
I've talked with LLM if there's an easy way to solve this, but didn't seem to:
Approaches considered
Approaches considered
What a fix has to satisfy
require()of the same module returns the same object.require()that fires during the module's own initialization (self-require or a require cycle) still gets a usable object, notundefined. Covered bybundler_esm_cjs_tests/16-19.import * as).module.exportsexport — forexport { x as 'module.exports' },__toCommonJSreturns that value by reference at call time, not via a live getter, so it can't be snapshotted before the body assigns it.WeakMapisn't guaranteed to be available in all target environments (old runtimes / constrained engines), so relying on it means either dropping those targets or carrying atypeof WeakMapfallback path. Avoiding it entirely keeps the output portable with no fallback branch.The candidates
Starting from esbuild's fix, each WeakMap-free idea fails a different constraint:
esbuild's
__toCommonJSCached(#4441) — aWeakMapkeyed on the namespace object; builds the wrapper on first call and caches it. Ships atypeof WeakMapfallback precisely becauseWeakMapmay be unavailable.→ Correct on everything except (6): the WeakMap dependency (and its fallback path) is the bar to clear without one.
A — cache in a variable at the call site:
(init(), x_cjs ??= __toCommonJS(x_exports)).→ Fails (2): with code splitting each chunk declares its own
x_cjs, so two chunks requiring the same module get two different objects.A′ — promote that cache var to a real per-module binding, exported from the owning chunk.
→ Correct, but the cache slot has to be writable across chunks, and rolldown's cross-chunk exports are getter-based (read-only). Needs new export-mechanism plumbing — the heaviest option.
B — stash the cached object on the namespace object under a
Symbol.→ Fails (4): the slot shows up in
Object.getOwnPropertySymbols(ns)/console.log(ns), sincensis the same object exposed viaimport * as.C1 — a separate memoized thunk:
var x_cjs = __esmMin(() => (init(), __toCommonJS(x_exports))).→ Fails (3): the memoized value isn't assigned until the thunk returns, so a re-entrant (cyclic) call gets
undefined. Also needs a new cross-chunk symbol.C2 — fold the conversion into the init closure's return:
init = __esmMin(() => { …body…; return __toCommonJS(ns) }), and lower eachrequireto a bareinit().→ Gets (1), (2), (4), but still fails (3) for the same reason as C1 (the return value doesn't exist mid-body) — prototyping this regressed
bundler_esm_cjs_tests/16-19— and it entangles the change with TLA / concatenated closures.A WeakMap-free candidate that clears all six:
__toCommonJSIniteager-init wrapperWrap the init closure with a new runtime helper that computes and memoizes
__toCommonJS(exports)before running the body:resis set before the body runs, so a re-entrantrequireduring init already sees the finished object; every later call returns the sameres.init_xsymbol — no new cross-chunk binding. Consumer chunks import fewer symbols than before (justinit_x, no longerx_exports+__toCommonJS).ns.