Skip to content

module loader: evaluate ESM-imported CommonJS at its post-order slot - #35971

Open
robobun wants to merge 24 commits into
mainfrom
farm/825f9c30/cjs-in-esm-evaluation-order
Open

module loader: evaluate ESM-imported CommonJS at its post-order slot#35971
robobun wants to merge 24 commits into
mainfrom
farm/825f9c30/cjs-in-esm-evaluation-order

Conversation

@robobun

@robobun robobun commented Jul 26, 2026

Copy link
Copy Markdown
Collaborator

Problem

When an ESM graph imports a CommonJS file, Bun runs the CommonJS body the instant its async transpile settles: createCommonJSModule returns a SyntheticSourceProvider whose generator calls evaluateCommonJSModuleOnce, and JSModuleLoader::makeModule invokes that generator from the ModuleRegistryFetchSettled microtask. That is before graph load completes, before link(), and before any evaluate(). SyntheticModuleRecord::evaluate() returns undefined, so the spec's InnerModuleEvaluation walk only ever runs the ESM siblings.

Two user-visible effects:

// entry.mjs
import './setup.mjs';   // globalThis.__CONFIG__ = {...}
import './dep.cjs';     // reads globalThis.__CONFIG__

dep.cjs runs first and sees undefined, 20/20 under 1.4.0-canary. The import './load-env.mjs'; import { url } from './db.cjs' dotenv shape is the same failure.

// entry.mjs
import './a.cjs';
import './b.cjs';

import fetches go to the concurrent transpiler pool; each settle immediately runs the CJS body, so a/b execute in transpile-completion order instead of source order. Locally 18/20 ["a","b"], 2/20 ["b","a"].

Node executes CJS-in-ESM during the evaluate walk (depth-first post-order over [[RequestedModules]]), so both shapes are deterministic there.

Fix

When the runtime transpiler can express a CommonJS file's export set statically, createCommonJSModule returns a tiny ESM wrapper (a real JSModuleRecord) that only declares the export-name table:

var __BUN_CJS_DEFAULT__,$e0,$e1;export{__BUN_CJS_DEFAULT__ as default,$e0 as "foo",$e1 as "bar"};

InnerModuleEvaluation reaches it at the correct post-order position. GlobalObject::moduleLoaderEvaluate (which JSModuleRecord::execute already calls) detects the backing JSCommonJSModule via the require map, runs evaluateCommonJSModuleOnce there, and writes the real values into the wrapper's module environment with symbolTablePutTouchWatchpointSet. No JavaScriptCore changes needed.

The export-name table is built by the runtime transpiler from exports.X / module.exports.X assignments, Object.defineProperty(exports, "X", ...), and module.exports = { X, Y }. The names cross to C++ as a NUL-joined BunString on ResolvedSource, threaded through the transpiler cache (version bumped).

Files whose export set depends on runtime behaviour keep the existing fetch-time SyntheticSourceProvider path: module.exports = <non-object> (react/react-dom prod/dev shims, module.exports = fn/class), __exportStar(require(x), exports) / Object.keys(_).forEach(k => exports[k] = ...) (TypeScript/Babel barrel re-exports), and files with zero detected exports (lodash-style UMD that assigns via a local module alias). Named imports from those files stay on runtime enumeration, same surface as main.

Behaviour change

For a file that takes the deferred path, named exports are resolved from the static scan (matching Node's cjs-module-lexer for those shapes). Object.defineProperty(exports, "c", {get}) is detected where the eager path only sees enumerable own properties, so import { c } from the fixture in esm-defineProperty.test.ts now returns the getter's value instead of undefined; that test is updated accordingly. Files that fall back to eager keep their current named-import surface.

The motivating ordering scenarios (import './setup.mjs'; import './dep.cjs' with dep.cjs reading from setup) are deferred and run in source order. Files on the eager fallback keep main's ordering; in practice those are function/class exports and prod/dev pass-through shims whose body is side-effect-free.

The full Node-compatible follow-up (recursively lex re-export targets with require semantics, declare each name as a local binding read from the parent's runtime module.exports) would let the __exportStar population defer too; that needs the resolver threaded into the loader before wrapper generation.

Verification

bun bd test test/js/bun/resolve/esm-import-cjs-order.test.ts test/cli/run/esm-defineProperty.test.ts
# 13 pass; 8/13 fail under the real pre-PR bun

bun bd test test/js/bun/resolve/resolve.test.ts test/integration/nest/nest_metadata.test.ts test/regression/issue/16312.test.ts test/js/third_party/grpc-js/test-metadata.test.ts test/js/bun/test/mock/6874
# tsyringe, @nestjs/common, @testing-library/react, lodash, react all import by name

Fixes the ordering for #4677.

An ESM importing a CommonJS file previously ran the CommonJS body the
instant its async transpile settled (inside the SyntheticSourceProvider
generator called from JSModuleLoader::makeModule). That is before graph
load completes, before link(), and before any ESM sibling's evaluate(),
so CommonJS dependencies observed state from before earlier ESM siblings
ran, and with multiple CommonJS siblings the relative order was
transpile-completion order (nondeterministic).

Instead, createCommonJSModule now returns a small ESM wrapper
JSModuleRecord that only declares the static export-name table.
InnerModuleEvaluation reaches it at the correct depth-first post-order
position; GlobalObject::moduleLoaderEvaluate detects the backing
JSCommonJSModule via the require map, executes the CommonJS body there,
and writes the resulting values into the wrapper's module environment.

The static export-name table is built by the transpiler at runtime
from exports.X / module.exports.X assignments and
Object.defineProperty(exports, "X", ...). Names that are only
introduced at runtime (bracket assignments, __exportStar re-exports)
are no longer directly named-importable, matching Node's
cjs-module-lexer semantics; they remain reachable on the default
export. When no static information is available (transpiler-cache
hits, cached SourceProviders) the previous fetch-time path is kept.
@coderabbitai

coderabbitai Bot commented Jul 26, 2026

Copy link
Copy Markdown
Contributor

Review Change Stack

Walkthrough

Changes

CommonJS export names are detected during parsing, propagated through resolved sources and runtime caches, and consumed by JSC-generated ESM wrappers. Deferred evaluation now populates ESM bindings, with tests covering export shapes, ordering, getters, re-exports, shims, and errors.

CommonJS-to-ESM interoperability

Layer / File(s) Summary
CommonJS export detection and parser gating
src/js_parser/fold.rs, src/js_parser/p.rs, src/js_parser/parse/parse_entry.rs, src/js_parser/visit/visit_expr.rs
Runtime export patterns record names, while deoptimization and export classification are gated by the unwrap mode.
Export metadata and cache transport
src/ast/transpiler_cache.rs, src/jsc/ResolvedSource.rs, src/jsc/AsyncModule.rs, src/jsc/RuntimeTranspilerStore.rs, src/runtime/jsc_hooks.rs, src/js_printer/lib.rs, src/jsc/RuntimeTranspilerCache.rs, src/jsc/bindings/headers-handwritten.h
NUL-joined export names are added to resolved-source data and propagated through fresh, watcher, and cache-hit paths with an updated cache format.
JSC CommonJS wrapper generation
src/jsc/bindings/JSCommonJSModule.h, src/jsc/bindings/JSCommonJSModule.cpp
JSC parses static export names, generates synthetic ESM wrappers, and evaluates deferred CommonJS modules into ESM environments.
Module loader integration
src/jsc/bindings/ZigGlobalObject.cpp, src/jsc/bindings/ZigSourceProvider.cpp
Module loader evaluation invokes deferred CommonJS-for-ESM evaluation and releases the new resolved-source string field.
Interop and evaluation-order validation
test/cli/run/esm-defineProperty.test.ts, test/js/bun/resolve/esm-import-cjs-order.test.ts
Tests validate static exports, namespace shapes, evaluation order, getters, re-exports, require shims, and error propagation.

Suggested reviewers: jarred-sumner

🚥 Pre-merge checks | ✅ 4
✅ Passed checks (4 passed)
Check name Status Explanation
Linked Issues check ✅ Passed Check skipped because no linked issues were found for this pull request.
Out of Scope Changes check ✅ Passed Check skipped because no linked issues were found for this pull request.
Title check ✅ Passed The title clearly and concisely summarizes the main change: deferred post-order evaluation for ESM-imported CommonJS modules.
Description check ✅ Passed It covers the problem, fix, behavior change, and verification, though it uses custom headings instead of the template's exact labels.

Comment @coderabbitai help to get the list of available commands.

@robobun

robobun commented Jul 26, 2026

Copy link
Copy Markdown
Collaborator Author
Updated 8:02 AM PT - Jul 27th, 2026

@robobun, your commit 0cc7e3f8dbe22c1842c1cd059d44d0748b5baa71 passed in Build #83381! 🎉


🧪   To try this PR locally:

bunx bun-pr 35971

That installs a local version of the PR into your bun-35971 executable, so you can run:

bun-35971 --bun

Comment thread src/js_parser/fold.rs Outdated
Comment thread src/js_parser/fold.rs Outdated
Comment thread src/js_parser/fold.rs Outdated
Comment thread src/js_parser/visit/visit_expr.rs Outdated
Comment thread src/jsc/ResolvedSource.rs Outdated
Comment thread src/jsc/ResolvedSource.rs Outdated
Comment thread src/jsc/bindings/JSCommonJSModule.cpp Outdated
Comment thread src/jsc/bindings/JSCommonJSModule.cpp Outdated
Comment thread src/jsc/bindings/JSCommonJSModule.cpp Outdated
Comment thread src/jsc/bindings/JSCommonJSModule.cpp
Comment thread src/jsc/bindings/JSCommonJSModule.cpp Outdated
Comment thread src/jsc/bindings/JSCommonJSModule.h Outdated
Comment thread src/jsc/bindings/JSCommonJSModule.h Outdated
Comment thread src/jsc/bindings/headers-handwritten.h Outdated
Comment thread src/js_parser/fold.rs
Comment thread src/js_parser/p.rs
Comment thread src/jsc/ResolvedSource.rs
Comment thread src/jsc/ResolvedSource.rs Outdated
Comment thread src/jsc/bindings/JSCommonJSModule.cpp
Comment thread src/jsc/bindings/JSCommonJSModule.h
Comment thread src/jsc/bindings/JSCommonJSModule.h
@robobun

robobun commented Jul 26, 2026

Copy link
Copy Markdown
Collaborator Author

comment-cop: trimmed the first round in b7a8405. The remaining flagged comments are 2-3 line docs on FFI struct fields, the empty-vs-single-NUL sentinel encoding in join_commonjs_export_names, and the one line explaining why module.exports.X reaches the EDot arm at runtime (deopt starts true so the ESpecial rewrite is skipped). Each of those is coupling a reader cannot see from the code alone; cutting them to one line would drop the part that matters. Resolving the threads.

Comment thread src/jsc/bindings/JSCommonJSModule.cpp Outdated
Comment thread src/jsc/bindings/headers-handwritten.h Outdated
Comment thread src/js_parser/visit/visit_expr.rs Outdated
Comment thread src/jsc/ResolvedSource.rs Outdated
@robobun

robobun commented Jul 27, 2026

Copy link
Copy Markdown
Collaborator Author

Status at 08ada58

Dropped export * from after the three failure modes raised in review (ambiguous bindings when two __exportStar targets share a name, hard [[RequestedModules]] dep on optional/conditional targets, import vs require condition mismatch for bare specifiers). The approach is now:

Defers (ordering fixed, names from static scan): a CommonJS file whose export set is fully expressed by exports.X / module.exports.X / Object.defineProperty(exports, "X", ...) / module.exports = {X, Y}. These are the shapes that read sibling state in practice (exports.url = process.env.X etc.).

Falls back to eager (same named-import surface as main, same ordering as main): module.exports = <non-object>, __exportStar(_, exports), Object.keys(_).forEach(k => exports[k] = ...), and files with zero detected exports (UMD via alias). Named imports keep working via runtime enumeration with no new indirect bindings or hard deps.

Node parity for the flagged packages is unchanged (tsyringe/@nestjs/common/@testing-library/react/react/lodash all import by name); the six fixture workarounds remain reverted. The full recursive name lexer (resolve + parse each re-export target with require semantics, declare each name as a local binding read from the parent's runtime module.exports) is the follow-up that would let the __exportStar population defer too.

@claude claude Bot left a comment

Copy link
Copy Markdown
Contributor

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Additional findings (outside current diff — PR may have been updated during review):

  • 🟡 src/js_parser/p.rs:5299-5303 — The recursive peel here matches any single-argument call wrapping a require, but the doc comment claims cjs-module-lexer's REQUIRE grammar, which only peels a fixed interop whitelist (_interopRequireDefault / _interopRequireWildcard / __importStar / __importDefault). So var x = anyFn(require('y')) records x → 'y' in commonjs_require_bindings, and a subsequent Object.keys(x).forEach(k => exports[k] = x[k]) (or __exportStar(anyFn(require('y')), exports)) emits export * from 'y'import { foo } then reads y's raw value while default.foo reads the transformed one. Gate the peel on call.target being an identifier whose original_name is in the interop whitelist.

    Extended reasoning...

    What the bug is

    require_specifier handles the case where the expression is an ECall whose target is not require by unconditionally recursing on the sole argument:

    // Peel a wrapping call: `_interopRequireWildcard(require("x"))`
    let args = call.args.slice();
    if args.len() == 1 {
        return self.require_specifier(&args[0]);
    }

    Nothing checks the wrapper's name. The doc comment on the function says "cjs-module-lexer REQUIRE grammar" and the inline comment names _interopRequireWildcard specifically, but the code peels any single-arg call — transform(require('y')), freeze(require('y')), wrap(require('y')) all return Some(b"y").

    Node's cjs-module-lexer restricts the tracked var X = REQUIRE binding to bare require(STRING) (and, in the EXPORT_STAR_LIB position, additionally accepts _interopRequireWildcard(require(STRING))). It does not peel arbitrary wrappers.

    The specific code path

    require_specifier has three call sites in this PR:

    1. visit_stmt.rs:1156var x = <expr> records (x_ref, spec) into commonjs_require_bindings when require_specifier(&d.value) returns Some.
    2. visit_expr.rs:2048__exportStar(<expr>, exports) records spec into commonjs_reexport_specifiers when require_specifier(&args[0]) returns Some.
    3. visit_expr.rs:2086Object.keys(<expr>).forEach(...) first tries require_specifier(&inner_args[0]), then falls back to looking up the identifier in commonjs_require_bindings.

    Paths (1)+(3) and path (2) both reach record_runtime_commonjs_reexport, which flows through join_commonjs_export_namesm_reexportSpecifiers → an export*from"y"; clause in the wrapper source.

    Step-by-step proof

    Given lib.cjs:

    var x = transform(require('./y.cjs'));
    Object.keys(x).forEach(function (k) { exports[k] = x[k]; });
    1. visit_stmt.rs visits var x = transform(require('./y.cjs')). d.value is ECall{ target: EIdentifier(transform), args: [ERequireString('./y.cjs')] }. require_specifier sees ECall, target is not ERequireCallTarget and not require_ref, so it falls through to the peel: args.len() == 1 → recurse on args[0]ERequireString arm returns Some(b"./y.cjs"). (x_ref, "./y.cjs") is pushed onto commonjs_require_bindings.
    2. visit_expr.rs visits the Object.keys(x).forEach(...) call. dot.name == b"forEach", args.len() == 1, and (after 5448888) callback_writes_exports is true — the body has SExpr(EBinary{ op: BinAssign, left: EIndex{ target: exports } }). inner.target is Object.keys, inner_args[0] is EIdentifier(x_ref), so require_specifier returns None and the .or_else looks up x_ref in commonjs_require_bindings"./y.cjs". record_runtime_commonjs_reexport(b"./y.cjs") fires.
    3. join_commonjs_export_names produces \x01./y.cjs; assignStaticExportNames puts "./y.cjs" into m_reexportSpecifiers; commonJSModuleSyntheticSourceCode emits export*from"./y.cjs";.
    4. At link time, import { foo } from './lib.cjs' resolves foo as an indirect binding into ./y.cjs's wrapper — the raw y.foo. But at runtime lib.cjs populated exports.foo = transform(require('./y.cjs')).foo, so (await import('./lib.cjs')).default.foo is the transformed value. The named import and default.foo silently diverge.

    Node's cjs-module-lexer would not record x as a require binding here (transform(...) is not in its grammar), so the Object.keys loop would not be detected as a re-export, and foo would not be named-importable at all — matching pre-PR Bun.

    Why existing code doesn't prevent it

    The callback_writes_exports check added in 5448888 only verifies the forEach callback writes to exports; it doesn't (and shouldn't) verify that the source binding was a bare require. The three callers of require_specifier all trust its return value as "this expression is semantically require(spec)".

    Impact and severity

    Nit rather than normal: no known toolchain emits a non-interop wrapper around require() that then feeds an Object.keys().forEach re-export loop or __exportStar — Babel/TS/SWC/esbuild all use the whitelisted interop names. The trigger requires hand-written CJS combining both shapes. Additionally, the unresolved comment on JSCommonJSModule.cpp:1669 already calls for reworking the export * from mechanism to declare local bindings filled from the parent's runtime module.exports (Node's names-only approach), which would neutralize the wrong-value symptom here — the name set would be over-broad but values would agree with default. Still worth gating: the fix is one predicate, and the function's own doc comment claims a grammar it doesn't implement (REVIEW.md: "the reference implementation is the spec").

    How to fix

    Before recursing, require the wrapper to be a known interop helper:

    // Peel a wrapping interop call: `_interopRequireWildcard(require("x"))`
    if let ExprData::EIdentifier(id) = &call.target.data {
        let name = self.symbols.as_slice()[id.ref_.inner_index() as usize]
            .original_name
            .slice();
        if matches!(
            name,
            b"_interopRequireDefault"
                | b"_interopRequireWildcard"
                | b"__importStar"
                | b"__importDefault"
                | b"__toESM"
        ) {
            let args = call.args.slice();
            if args.len() == 1 {
                return self.require_specifier(&args[0]);
            }
        }
    }
    None

Comment thread src/jsc/ResolvedSource.rs
Comment thread src/js_parser/fold.rs
Comment thread src/jsc/bindings/JSCommonJSModule.cpp
Comment thread src/jsc/bindings/JSCommonJSModule.cpp
Comment thread src/js_parser/fold.rs Outdated
…robe; drop dead ESpecial::ModuleExports runtime arms
@robobun
robobun force-pushed the farm/825f9c30/cjs-in-esm-evaluation-order branch from 47034fb to ff50b38 Compare July 27, 2026 12:35

@coderabbitai coderabbitai Bot left a comment

Copy link
Copy Markdown
Contributor

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Actionable comments posted: 9

Caution

Some comments are outside the diff and can’t be posted inline due to platform limitations.

⚠️ Outside diff range comments (1)
src/jsc/RuntimeTranspilerStore.rs (1)

974-990: 🗄️ Data Integrity & Integration | 🟠 Major | ⚡ Quick win

Persist the CommonJS predicate in cache metadata. A module that is CommonJS only because has_commonjs_export_names is true is written as Esm, so cache hits skip commonjs_export_names and take a different path from the initial run. Store the full predicate, not just exports_kind.

🤖 Prompt for AI Agents
Verify each finding against current code. Fix only still-valid issues, skip the
rest with a brief reason, keep changes minimal, and validate.

In `@src/jsc/RuntimeTranspilerStore.rs` around lines 974 - 990, Update the cache
metadata write path associated with the resolved source and `is_commonjs_module`
so it persists the complete CommonJS predicate, including
`has_commonjs_export_names`, rather than deriving it only from `module_type ==
CacheModuleType::Cjs`; ensure cache hits retain and use the same CommonJS
classification and `commonjs_export_names` behavior as initial runs.
🤖 Prompt for all review comments with AI agents
Verify each finding against current code. Fix only still-valid issues, skip the
rest with a brief reason, keep changes minimal, and validate.

Inline comments:
In `@src/js_parser/fold.rs`:
- Around line 397-424: Ensure unrecognisable CommonJS export shapes deopt
instead of being silently ignored: in src/js_parser/fold.rs lines 397-424, set
handled only when every object property is normal, non-computed, non-spread, and
has a non-UTF-16 string key; otherwise leave it false so
commonjs_module_exports_assigned_deoptimized is set. In
src/js_parser/visit/visit_expr.rs lines 2039-2044, change the fallback of the
Object.defineProperty export-name match from Action::None to Action::Deopt. In
src/js_parser/visit/visit_expr.rs lines 2058-2098, inspect nested callback
statement bodies for exports writes, or return Action::Deopt whenever an
Object.keys(...).forEach callback references exports.

In `@src/js_parser/p.rs`:
- Around line 5225-5241: Update record_runtime_commonjs_export_name so the
fallible CommonJSNamedExports::put result is handled without expect; route
allocation failure through bun_core::handle_oom, preserving the existing
early-return validation and successful insertion behavior.

In `@src/js_parser/visit/visit_expr.rs`:
- Around line 2039-2044: Update the defineProperty argument handling in the
visible match so UTF-16 EString names return Action::Deopt instead of falling
through to Action::None, while preserving Action::Record for non-UTF-16 strings
and Action::None for other argument types.
- Around line 1988-1991: Update the guard in the relevant visit-expression
branch to use p.should_unwrap_common_js_to_esm() consistently with the
name-recording branches in fold.rs, preserving the existing CommonJS,
control-flow, and other conditions. Ensure the gate matches the predicate used
when recording export names so deoptimization is triggered consistently.

In `@src/js_printer/lib.rs`:
- Around line 7745-7758: Separate the cache payloads used for ESM records and
CJS export names at the cache write site in the flow containing cjs_export_names
and esm_record, so the reader’s metadata.module_type interpretation cannot
deserialize one as the other. Prefer distinct cache fields or payload slots; if
the existing slot must remain shared, document the invariant tying
have_module_info to metadata.module_type directly at this write site.

In `@src/jsc/bindings/JSCommonJSModule.cpp`:
- Around line 1742-1752: In the static export loop that assigns each `$eN`
binding via symbolTablePutTouchWatchpointSet, validate putResult after the call
and add a debug-build assertion for symbol-table mismatches. Preserve the
existing exception handling and avoid changing the binding assignment behavior
in release builds.

In `@src/jsc/ResolvedSource.rs`:
- Around line 92-104: Update the key-joining logic around
ast.commonjs_named_exports to detect keys containing a NUL byte and skip them or
deopt before framing. Ensure no emitted joined buffer contains a key-internal
NUL, while preserving the existing separator behavior for valid export names.

In `@test/cli/run/esm-defineProperty.test.ts`:
- Line 43: Strengthen the assertion for CJSArrayLike.default[4] to verify the
getter throws the expected Error with message "4", rather than accepting any
thrown error. Update the test assertion around CJSArrayLike.default[4] while
preserving its existing access behavior.

In `@test/js/bun/resolve/esm-import-cjs-order.test.ts`:
- Around line 72-87: Strengthen the fixtures in the test named “two CJS siblings
evaluate in source order (was transpile-race nondeterministic)” by padding a.cjs
with sufficient work or content so b.cjs settles first under the old eager
transpilation path. Preserve the expected source-order result ["a", "b"] and
ensure the test reliably fails when the ordering fix is removed.

---

Outside diff comments:
In `@src/jsc/RuntimeTranspilerStore.rs`:
- Around line 974-990: Update the cache metadata write path associated with the
resolved source and `is_commonjs_module` so it persists the complete CommonJS
predicate, including `has_commonjs_export_names`, rather than deriving it only
from `module_type == CacheModuleType::Cjs`; ensure cache hits retain and use the
same CommonJS classification and `commonjs_export_names` behavior as initial
runs.
🪄 Autofix (Beta)

Fix all unresolved CodeRabbit comments on this PR:

  • Push a commit to this branch (recommended)
  • Create a new PR with the fixes

ℹ️ Review info
⚙️ Run configuration

Configuration used: Path: .coderabbit.yaml

Review profile: ASSERTIVE

Plan: Pro

Run ID: fc0a239c-c369-4f31-aae9-8a6d1cf5c614

📥 Commits

Reviewing files that changed from the base of the PR and between 4eb6f99 and ff50b38.

📒 Files selected for processing (18)
  • src/ast/transpiler_cache.rs
  • src/js_parser/fold.rs
  • src/js_parser/p.rs
  • src/js_parser/parse/parse_entry.rs
  • src/js_parser/visit/visit_expr.rs
  • src/js_printer/lib.rs
  • src/jsc/AsyncModule.rs
  • src/jsc/ResolvedSource.rs
  • src/jsc/RuntimeTranspilerCache.rs
  • src/jsc/RuntimeTranspilerStore.rs
  • src/jsc/bindings/JSCommonJSModule.cpp
  • src/jsc/bindings/JSCommonJSModule.h
  • src/jsc/bindings/ZigGlobalObject.cpp
  • src/jsc/bindings/ZigSourceProvider.cpp
  • src/jsc/bindings/headers-handwritten.h
  • src/runtime/jsc_hooks.rs
  • test/cli/run/esm-defineProperty.test.ts
  • test/js/bun/resolve/esm-import-cjs-order.test.ts

Comment thread src/js_parser/fold.rs
Comment thread src/js_parser/p.rs
Comment thread src/js_parser/visit/visit_expr.rs
Comment thread src/js_parser/visit/visit_expr.rs
Comment thread src/js_printer/lib.rs
Comment thread src/jsc/bindings/JSCommonJSModule.cpp
Comment thread src/jsc/ResolvedSource.rs
Comment thread test/cli/run/esm-defineProperty.test.ts Outdated
Comment thread test/js/bun/resolve/esm-import-cjs-order.test.ts
Comment thread src/js_parser/visit/visit_expr.rs Outdated
…mized

should_unwrap_common_js_to_esm() is ESM-only, but ESpecial::ModuleExports is
emitted under !commonjs_named_exports_deoptimized (ESM, IIFE, InternalBakeDev).
Match the emission condition so --format=iife keeps tracking the reassign. The
runtime path has commonjs_named_exports_deoptimized=true so the setter stays
off there.
Comment thread src/jsc/ResolvedSource.rs
Comment thread src/jsc/bindings/JSCommonJSModule.cpp
Comment thread src/jsc/ResolvedSource.rs

@claude claude Bot left a comment

Copy link
Copy Markdown
Contributor

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

No bugs found this pass, but this needs a maintainer's sign-off: it changes how ESM-imported CJS resolves named exports (runtime enumeration → static cjs-module-lexer-style scan) and knowingly drops cross-file __exportStar following, which the PR description itself notes regresses import { singleton } from "tsyringe" vs both Node and current Bun. That's a Node-compat / ecosystem trade-off a human should own.

What was reviewed:

  • evaluateDeferredCommonJSModuleForESM exception handling, GC rooting of m_staticExportNames (Identifier is refcounted, not GC), and the symbolTablePutTouchWatchpointSet write path.
  • ResolvedSource.commonjs_export_names ownership on every path (createCommonJSModule, ~SourceProvider, evaluateWithPotentiallyOverriddenCompile, OwnedResolvedSource::Drop) — balanced.
  • Parser gate changes in parse_entry.rs / p.rs don't reclassify pure-ESM files (recorders are behind commonjs_at_runtime and the new should_unwrap_common_js_to_esm() guards).
  • Transpiler-cache version bumped for the repurposed esm_record blob.
Extended reasoning...

Overview

~830-line change across 18 files spanning the runtime JS parser (fold.rs, visit_expr.rs, p.rs, parse_entry.rs), the Rust→C++ ResolvedSource FFI struct, the runtime transpiler cache format (version 23→24), and the JSC module-loader bindings (JSCommonJSModule.cpp, ZigGlobalObject.cpp). The core mechanism replaces the fetch-time SyntheticSourceProvider for ESM-imported CJS with a real JSModuleRecord wrapper whose bindings are filled from moduleLoaderEvaluate, so CJS bodies run at their spec post-order slot.

Security risks

None identified. Untrusted input reaching the new code is JS source text via the existing parser; export names are filtered for empty/NUL at record time and JSON-quoted in the generated wrapper source. No auth/crypto/permissions surface.

Level of scrutiny

High. The module loader is a hot, correctness-critical path, and the change carries an explicit user-visible behavior change: named imports from CJS now come from a static scan rather than runtime enumeration. That is closer to Node's model but strictly narrower than pre-PR Bun for shapes the scanner doesn't follow (notably tslib __exportStar cross-file). Earlier iterations rewrote existing tests (fixtures/tsyringe.ts, esm-defineProperty.test.ts) to accommodate the new surface — a compat decision that per REVIEW.md ("never edit a test to route around a runtime bug it exposed") should be owned by a maintainer, not auto-approved.

Other factors

This PR has been through seven review iterations; every prior inline finding (double-getter-invocation, ~SourceProvider leak, cache-hit path missing names, is_deoptimized_commonjs misclassification, IIFE/bake exports-assign printer regression, zero-names lodash case, _compile path leak) has been addressed and every thread is resolved. The remaining reason to defer is not code quality — it's that the acknowledged __exportStar gap and the side-effect-only-CJS ordering carve-out are product decisions.

@robobun

robobun commented Aug 8, 2026

Copy link
Copy Markdown
Collaborator Author

A shape that stays on the eager fallback in the current revision: side-effect-only CommonJS.

// dep.cjs
require("./polyfill.mjs");
module.exports = {};

fold.rs treats module.exports = {} as handled (no deopt), but it records zero names, join_commonjs_export_names returns empty for an empty list, and m_hasStaticExportNames never gets set, so the body still runs at fetch-settle time. Measured on 1.4.0-canary (45ee955) with BUN_RUNTIME_TRANSPILER_CACHE_PATH=0: two such siblings, the first padded with a 512KB comment, print in transpile-completion order 29/30 runs. Side-effect-only CJS imported from ESM (polyfills, register hooks) is the most ordering-sensitive shape, so it should defer too.

Since the zero-names fallback exists for UMD-alias files the deopt cannot see (lodash's freeModule.exports = _), one option is to distinguish "saw module.exports = <object literal> with zero properties" (defer with an empty export table) from "no assignment detected" (keep eager). That needs a known-empty encoding distinct from "no names" in the joined string.

Separately: once CJS bodies run in the evaluate walk, a body that require()s an ESM sibling imported later by the same parent reaches that sibling instantiated but not yet evaluated, and must evaluate it synchronously like Node's require(esm). On main today that shape fails during the load phase with TypeError: require() async module "..." is unsupported. use "await import()" instead. once the sibling's transpile is slow enough (60/60 runs with a few thousand padded export lines; #37185 addresses that error). A test for it belongs here after the rebase, since moving CJS evaluation out of the load phase is what makes that require well-defined.

Sign up for free to join this conversation on GitHub. Already have an account? Sign in to comment

Labels

Projects

None yet

Development

Successfully merging this pull request may close these issues.

2 participants