Skip to content

parser: .mjs/"type":"module" is authoritative over module/exports identifier refs - #33807

Closed
robobun wants to merge 7 commits into
mainfrom
farm/6f37db24/mjs-module-ref-esm
Closed

robobun wants to merge 7 commits into
mainfrom
farm/6f37db24/mjs-module-ref-esm

Conversation

@robobun

@robobun robobun commented Jul 9, 2026 •

Copy link
Copy Markdown
Collaborator

Problem

A bare identifier reference to module or exports (for example the common dual-mode guard typeof module !== "undefined") makes Bun's parser classify a file as CommonJS even when the .mjs/.mts extension or package.json "type":"module" already says it is ESM.

Two observable faces:

  • If the file also has an import statement (valid ESM, Node runs it), Bun refuses to load it with Cannot use import statement with CommonJS-only features / note: This file is CommonJS because 'module' was used.
  • If the file has no import/export, it silently runs with full CJS semantics: typeof module === "object", top-level this is the exports object.
// guard.mjs
import path from "node:path";
console.log(typeof module !== "undefined" ? "cjs" : "esm", path.sep);

Node prints esm /; Bun errors.

Cause

In src/js_parser/parse/parse_entry.rs, the exports_kind decision runs the CJS content sniff (uses_exports_ref || uses_module_ref || ...) before ever consulting options.module_type. Only an export keyword or top-level await out-ranks the sniff; the extension and "type" field do not.

Fix

  • parse_entry.rs: treat options.module_type == Esm the same as export/top-level await when choosing exports_kind, on the runtime path only (!p.options.bundle). The bundler's resolver can return Esm for files that are actually CJS (a nameless nested {"type":"commonjs"} scope that enclosing_package_json skips, or an exports "import" condition pointing at a .js file), so the content sniff is still needed there; the runtime path is fed the correct value by the change below.
  • p.rs value_for_this: substitute the ESM value for top-level this when module_type == Esm (not only when ESM syntax is present), so a bare .mjs with top-level this does not rewrite to exports.
  • RuntimeTranspilerStore: thread the caller's extension-aware module_type through transpile() onto TranspilerJob and use it in the worker, instead of re-deriving from the package.json tag. The resolved_source.tag (used for ignoreESModuleAnnotation) is left as-is.
  • jsc_hooks.rs: derive the runtime module_type for .js/.ts from the nearest package.json's "type" by walking DirInfo parents, including nameless ones that enclosing_package_json skips. Without this a .js file under parse5/dist/cjs/ (which ships {"type":"commonjs"} with no name) was seeing the outer parse5 "type":"module". Carried as a separate LoaderResult field so pkg_name semantics are unchanged.
  • resolver.rs resolve_with_framework: stop hard-coding ModuleType::Esm for embedded BuiltInModule::Code; the react-refresh fallback it carries is CJS. Use Unknown so the parser decides from content; the ESM .tsx built-ins still classify as ESM via their import syntax.
  • Cache: hash module_type into the runtime transpiler features hash (byte-identical source now transpiles differently under different module types) and bump EXPECTED_VERSION to 23 so stale entries with the old classification are invalidated.

The es-module-lexer test fixture declared "type":"module" while its index.ts is CJS and relied on the sniff override; the stray "type" is dropped.

Tests

test/cli/run/run-cjs.test.ts gains a describe block covering .mjs, .mts, "type":"module" .js, with and without import statements, loaded directly and via dynamic import(), plus negative controls (.cjs, ambiguous .js, .cjs inside "type":"module", .mjs inside "type":"commonjs", and a nested nameless {"type":"commonjs"} under a "type":"module" root). 7 of the 11 new tests fail on the unfixed build and all pass with this change.

test/bundler/bundler_edgecase.test.ts adds NamelessNestedTypeCommonjsUnderTypeModule and ExportsMapImportConditionPointsAtCjs to lock in the bundler keeping its content-sniff fallback for those layouts.

Also ran: test/js/bun/resolve/, test/bundler/bundler_cjs*.test.ts, test/bundler/bundler_edgecase.test.ts, test/bundler/esbuild/default.test.ts, test/bundler/transpiler/, test/cli/run/transpiler-cache.test.ts, test/integration/jsdom/, test/bake/dev/react-spa.test.ts, test/bake/dev/esm.test.ts with no new failures.

Related: #32173 independently makes the same value_for_this change (plus changing null to undefined there) and the same cache-key addition; whichever lands second will have a small conflict in those two spots.


no test proof · iteration 5 · Platform-specific test(s) that do not run on this machine. Deferring to CI, which covers all platforms: test/bundler/bundler_edgecase.test.ts

@robobun

robobun commented Jul 9, 2026 •

Copy link
Copy Markdown
Collaborator Author
Updated 6:38 PM PT - Jul 11th, 2026

❌ @robobun, your commit 23c6fcf has 2 failures in Build #71988 (All Failures):


🧪   To try this PR locally:

bunx bun-pr 33807

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

bun-33807 --bun

@github-actions github-actions Bot added the claude label Jul 9, 2026
@github-actions

github-actions Bot commented Jul 9, 2026

Copy link
Copy Markdown
Contributor

Found 1 issue this PR may fix:

  1. pyodide.mjs throws Cannot determine runtime environment when run under Bun, even though it works in Node #28864 - pyodide.mjs contains typeof module / typeof module.exports dual-mode guard patterns; Bun's content-sniffing misclassifies the .mjs file as CJS, causing the environment detection to take the wrong branch and throw "Cannot determine runtime environment"

If this is helpful, copy the block below into the PR description to auto-close this issue on merge.

Fixes #28864

🤖 Generated with Claude Code

@robobun

robobun commented Jul 9, 2026

Copy link
Copy Markdown
Collaborator Author

Checked #28864: the pyodide failure there is caused by process.browser being defined (as false) in Bun, which makes pyodide's typeof process.browser > "u" check fail and fall through to the browser branch. The file has export statements so it was already classified as ESM; typeof module is "undefined" there both before and after this change. Not adding the Fixes line.

@github-actions

github-actions Bot commented Jul 9, 2026

Copy link
Copy Markdown
Contributor

This PR may be a duplicate of:

  1. Substitute top-level this with undefined in ES modules #32173 - Also fixes value_for_this and exports_kind logic in the parser to respect module_type from .mjs/"type":"module", touching the same code paths in p.rs and parse_entry.rs
  2. Apply implicit strict mode to files forced to ESM by .mjs or package.json type module #32177 - Fixes the same authoritative ESM signals (.mjs/"type":"module") not being respected by the parser, adding strict mode enforcement based on the same module_type signals
  3. fix: handle import statements in CJS files (#20718) #30367 - Changes exports_kind classification in parse_entry.rs when CJS and ESM signals coexist, addressing the same "Cannot use import statement with CommonJS-only features" error
  4. bundler: let DCE drop unreachable top-level await before rejecting CJS #29246 - Modifies the same exports_kind CJS-vs-ESM decision logic in parse_entry.rs, specifically around top-level await interaction with CJS classification

🤖 Generated with Claude Code

@coderabbitai

coderabbitai Bot commented Jul 9, 2026 •

Copy link
Copy Markdown
Contributor

Review Change Stack

Walkthrough

This PR makes explicit module type authoritative across loader resolution, runtime transpilation, parser ESM handling, cache hashing, and tests covering extension and package.json type interactions.

Changes

module_type authoritative propagation

Layer / File(s) Summary
Loader derives nearest module type
src/resolver/resolver.rs, src/runtime/jsc_hooks.rs
Embedded built-in code is resolved with module_type = Unknown, and loader resolution computes the nearest package-json-derived module type for transpilation.
Transpiler jobs and cache use module_type
src/jsc/RuntimeTranspilerStore.rs, src/jsc/RuntimeTranspilerCache.rs, src/js_parser/parse/parse_entry.rs
Transpiler jobs persist module_type, parse options read it directly, and cache hashing and versioning account for module type.
Parser treats module_type as ESM signal
src/js_parser/p.rs, src/js_parser/parse/parse_entry.rs
this substitution and initial ESM export-kind selection recognize explicit ESM module type when not bundling.
Module type behavior tests
test/cli/run/run-cjs.test.ts, test/bundler/bundler_edgecase.test.ts, test/js/third_party/es-module-lexer/package.json
Tests cover .mjs, .mts, .js, .cjs, dynamic imports, package type settings, and exports-map resolution; the fixture no longer declares "type": "module".
🚥 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 is concise and accurately summarizes the main change about ESM taking precedence over module and exports refs.
Description check ✅ Passed It explains the problem, fix, and verification steps, covering the template's required information even with different headings.

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

@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: 1

🤖 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 `@test/cli/run/run-cjs.test.ts`:
- Around line 129-152: The last CJS sniff tests use inconsistent failure
handling, so align them with the earlier tests by capturing stderr and guarding
JSON parsing. Update the "ambiguous .js with typeof module still runs as CJS"
and ".cjs with typeof module still runs as CJS" cases in run-cjs.test.ts to
destructure stderr from run() and parse stdout with a safe fallback like the
other tests, so failures report exitCode/stderr instead of an opaque
SyntaxError. Use the existing tempDir, run, and JSON.parse(stdout || "null")
pattern as the reference for consistency.
🪄 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: cd246ccb-c320-4bdb-9021-be93f8d1e8ad

📥 Commits

Reviewing files that changed from the base of the PR and between b05b4fa and ecf8749.

📒 Files selected for processing (6)
  • src/js_parser/p.rs
  • src/js_parser/parse/parse_entry.rs
  • src/jsc/RuntimeTranspilerCache.rs
  • src/jsc/RuntimeTranspilerStore.rs
  • src/runtime/jsc_hooks.rs
  • test/cli/run/run-cjs.test.ts

Comment thread test/cli/run/run-cjs.test.ts
@robobun

robobun commented Jul 9, 2026

Copy link
Copy Markdown
Collaborator Author

On the dedup suggestions:

@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: 1

🤖 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/p.rs`:
- Around line 5613-5615: In `value_for_this`, explicit ESM should take
precedence over `commonjs_named_exports`, since that set can still be populated
in `.mjs`/`.mts` or `"type":"module"` files when `unwrap_commonjs_to_esm` is
enabled. Update the conditional so `self.options.module_type ==
options::ModuleType::Esm` bypasses the `commonjs_named_exports.count() == 0`
gate, and only use that count check for inferred ESM cases based on
`self.has_es_module_syntax`. Keep the top-level `this` rewrite to `exports` only
for non-explicit ESM scenarios.
🪄 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: c35b7c15-2b53-48f5-9ce8-53ab8dbb19d3

📥 Commits

Reviewing files that changed from the base of the PR and between ecf8749 and 636faf7.

📒 Files selected for processing (2)
  • src/js_parser/p.rs
  • test/cli/run/run-cjs.test.ts

Comment thread src/js_parser/p.rs Outdated
Comment thread test/cli/run/run-cjs.test.ts Outdated
Comment thread src/js_parser/parse/parse_entry.rs

@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: 1

🤖 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 `@test/cli/run/run-cjs.test.ts`:
- Around line 38-46: The parsing in the run-cjs test helper can throw on
malformed non-empty stdout, preventing crash diagnostics from being surfaced.
Update the stdout handling around the `out` निर्माण in the helper used by
`run-cjs.test.ts` so that JSON parsing failures are caught and fall back to the
same `{ crashed: stderr }` shape used for empty stdout. Keep the fix localized
to the helper that builds `out`/`exitCode`, ensuring partial or invalid lines do
not leak a raw `SyntaxError`.
🪄 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: cef22b14-f0ca-4f93-a26a-1ba3c06970cd

📥 Commits

Reviewing files that changed from the base of the PR and between 636faf7 and d92882b.

📒 Files selected for processing (6)
  • src/js_parser/parse/parse_entry.rs
  • src/resolver/resolver.rs
  • src/runtime/jsc_hooks.rs
  • test/bundler/bundler_edgecase.test.ts
  • test/cli/run/run-cjs.test.ts
  • test/js/third_party/es-module-lexer/package.json
💤 Files with no reviewable changes (1)
  • test/js/third_party/es-module-lexer/package.json

Comment thread test/cli/run/run-cjs.test.ts
Comment thread src/js_parser/p.rs Outdated

@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.

Thanks for addressing all three rounds of feedback — the !bundle gates and the bundler regression tests look right. I'm not auto-approving because this changes core CJS/ESM classification semantics across parser/resolver/runtime and has a stated design disagreement with #32177, so a maintainer should sign off on the direction.

Extended reasoning...

Overview

This PR makes .mjs/.mts/"type":"module" authoritative over the parser's CJS content sniff (bare module/exports identifier references). It touches 6 source files across four subsystems: the parser's exports_kind decision and value_for_this rewrite (parse_entry.rs, p.rs), the runtime transpiler store's module-type threading (RuntimeTranspilerStore.rs), the runtime loader's nearest-package.json walk (jsc_hooks.rs), the resolver's built-in-module classification (resolver.rs), and the transpiler cache's version bump + hash key (RuntimeTranspilerCache.rs, parse_entry.rs). Test coverage is thorough: 11 runtime cases in run-cjs.test.ts and 2 bundler regression cases in bundler_edgecase.test.ts.

Security risks

None identified. This is module-classification logic with no auth, crypto, network, or untrusted-input parsing surface. The nearest-package.json walk reuses the existing resolver's DirInfo cache and does not introduce new filesystem traversal.

Level of scrutiny

High. Module classification (CJS vs ESM) is one of the most semantically load-bearing decisions in the runtime — getting it wrong silently changes this, module, exports, strict mode, and import/require interop for user code. The PR already went through three rounds of review-driven fixes here (a real bundler regression was caught and gated on !bundle), which is evidence the interaction surface is subtle. The change is well-reasoned and well-tested, but it is not mechanical.

Other factors

  • The PR description and the author's own dedup analysis note that this overlaps with #32173 (same value_for_this + cache-key change) and disagrees on a design point with #32177 (whether explicit ESM should override the CJS-interop fallback for files that reference module/exports). That's a direction call a maintainer should make, not a bot.
  • The resolver.rs change (built-in module_type: Esm → Unknown) and the es-module-lexer fixture change are small but each shift existing behavior; they're justified in the description but warrant a human glance.
  • All prior inline feedback (mine and CodeRabbit's) is resolved; the bug hunter found nothing on 7408ef4.

@robobun

robobun commented Jul 9, 2026

Copy link
Copy Markdown
Collaborator Author

CI on 7408ef4 is red on tests that are failing across multiple unrelated PR builds right now (napi.test.ts on builds 70852-70862, update_interactive_install.test.ts on 70853/70857/70858/70862, bun-install.test.ts on 70856, complex-workspace.test.ts on 70853/70858, postgres-invalid-message-length on 70852-70858, express/res.send on 70853-70858). The one bundler_compile.test.ts failure is a bytecode disk-cache hit/miss ordering diff on a single lane, unrelated to module classification.

The diff itself is green: the new runtime tests in run-cjs.test.ts (7 fail before, 11 pass after) and the new bundler_edgecase tests (NamelessNestedTypeCommonjsUnderTypeModule, ExportsMapImportConditionPointsAtCjs) pass locally, as do jsdom, react-spa, es-module-lexer, bundler_cjs*, esbuild/default, and transpiler/ with no new failures. Ready for review.

@robobun

robobun commented Jul 10, 2026

Copy link
Copy Markdown
Collaborator Author

Ran into the mirror of this from the .cjs side: a .cjs with no module/exports/require markers (e.g. comment-only, or a conditional that never assigns) is content-sniffed as an export-less ES module when imported from .mjs, so import D from './b.cjs' throws Missing 'default' export. The RuntimeTranspilerStore change here already fixes that by threading module_type through to the worker, so no extra src/ change is needed for it.

One case this PR doesn't yet cover: an empty (or whitespace-only) file declared as CommonJS. parse_maybe_return_file_only short-circuits before the parser runs and returns ParseResult::empty_with(...) with exports_kind = None, so the new module_type never reaches the parser. The sync path has a special case in jsc_hooks.rs that returns a synthetic (function(){}) for .cjs/.cts only; the async path has nothing, and neither covers .js under "type":"commonjs".

Pushed claude/farm/4a8127f4/fix-cjs-extension-async-transpiler with:

  • the same TranspilerJob.module_type threading as here
  • the empty-file check in RuntimeTranspilerStore::run_impl and a widened one in jsc_hooks.rs (keyed on module_type == Cjs rather than a literal extension match)
  • tests for marker-less .cjs/.cts imported from .mjs/.mts across comment-only / empty / side-effect-only content, .js under "type":"commonjs", and "use strict" in .mjs

Feel free to cherry-pick the empty-file bits and tests if useful; happy to rebase on top of this once it lands otherwise.

robobun and others added 6 commits July 12, 2026 00:25
…exports refs

A bare reference to the identifiers module or exports (e.g. the common
feature-detection guard typeof module !== "undefined") caused the
parser's CJS content sniff to classify a file as CommonJS even when the
.mjs/.mts extension or package.json "type":"module" unambiguously
marks it as ESM. If the file also had an import statement, it failed to
load with "Cannot use import statement with CommonJS-only features";
without one it silently ran with CJS semantics (module/exports bound,
top-level this = exports).

Consult options.module_type before the uses_module_ref/uses_exports_ref
sniff so an explicit ESM type wins, matching Node. The sniff still
applies to ambiguous .js files. Also:

- value_for_this: substitute the ESM value for top-level this when
  module_type == Esm (not just when ESM syntax is present).
- RuntimeTranspilerStore: thread the caller's extension-aware
  module_type through to the worker instead of re-deriving it from
  package.json alone, so imported .mjs files see Esm like the sync path.
- Hash module_type into the runtime transpiler cache features hash and
  bump EXPECTED_VERSION, since identical source now transpiles
  differently under different module types.
Guard JSON.parse with a fallback and include stderr in the combined
assertion so a crashing subprocess surfaces its real output instead of
an opaque parse error.
…ones

Making .mjs/"type":"module" authoritative exposed two places where the
module_type fed to the parser was wrong; the CJS content sniff had been
masking both.

- jsc_hooks: enclosing_package_json skips package.json files without a
  "name" (kept for dependency resolution), so a .js file under
  parse5/dist/cjs/ (which ships {"type":"commonjs"} with no name) was
  seeing the outer parse5 "type":"module". Walk the DirInfo parent chain
  to read "type" from the nearest package.json regardless of name, and
  thread that as a separate LoaderResult field so pkg_name semantics are
  unchanged.
- resolver: resolve_with_framework hard-coded ModuleType::Esm for
  embedded BuiltInModule::Code, but the react-refresh fallback it
  carries is CJS. Use Unknown so the parser decides from content; the
  ESM .tsx built-ins still classify as ESM via their import syntax.

Also: drop the stray "type":"module" from the es-module-lexer test
fixture (its index.ts is CJS and was relying on the sniff override), and
rewrite the run-cjs assertions so stderr only appears in the failure
diff instead of being asserted empty. Adds a test for the
nested-nameless-package.json case.
The bundler's resolver can return ModuleType::Esm for files that are
actually CJS (a nameless nested {"type":"commonjs"} scope that
enclosing_package_json skips, or an exports-map "import" condition
pointing at a .js file). The content sniff masked this; making Esm
authoritative in the bundler path broke bundling such packages.

Restrict the new check to the runtime path, where jsc_hooks now derives
module_type from the nearest package.json regardless of name. The
bundler keeps falling back to the sniff until its resolver is fixed.
Adds itBundled coverage for both layouts.
Same rationale as d92882b: the bundler's resolver can return Esm for
CJS files, so a misclassified CJS file using top-level this (with no
exports.<name> assignment to populate commonjs_named_exports) would
rewrite this -> null instead of this -> exports, even though
parse_entry.rs then correctly classifies the file as CJS via the
content sniff. Extend the NamelessNestedTypeCommonjsUnderTypeModule
bundler test to cover this.viaThis.
@robobun
robobun force-pushed the farm/6f37db24/mjs-module-ref-esm branch from 7408ef4 to a46baf7 Compare July 12, 2026 00:29
@robobun

robobun commented Jul 12, 2026

Copy link
Copy Markdown
Collaborator Author

CI on a46baf7 (build 71980, post-rebase): the three failing tests (30205.test.ts napi-finalizer SIGABRT, fetch-backpressure, bake/dev-and-prod) are failing on other PR builds 71972/71976/71977/71978/71979 as well; none touch module classification. Diff remains green on all new runtime and bundler tests locally.

Comment thread src/runtime/jsc_hooks.rs
Node's LOOKUP_PACKAGE_SCOPE stops when it reaches a directory named
node_modules and defaults the format to commonjs. Without this, a .js
file at node_modules/foo/index.js with no foo/package.json would inherit
an outer project's "type":"module" and fail at runtime with module
undefined. Adds a test for the layout.

@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 new issues after 23c6fcf — the four concerns I raised earlier are all addressed. Deferring to a human reviewer because this changes core module-classification semantics, has stated design overlap with #32173/#32177/#30367, and the two !bundle gates are an intentional workaround pending a resolver-side finalize_result fix; those are design calls worth a maintainer's sign-off.

Checked this pass:

  • nearest_module_type walk now breaks at is_node_modules() before consulting package_json(), matching Node's LOOKUP_PACKAGE_SCOPE; new runtime test covers it.
  • Both !bundle gates (parse_entry.rs exports_kind, p.rs value_for_this) are consistent; the two bundler-resolver misclassification layouts are locked in by itBundled tests.
  • module_type is threaded through TranspilerJob, hashed into the features hash, and EXPECTED_VERSION bumped to 23.
  • resolve_with_framework → ModuleType::Unknown for embedded code — the ESM .tsx built-ins still classify via their import syntax.
Extended reasoning...

Overview

The PR makes .mjs/.mts/"type":"module" authoritative over the parser's CJS content sniff (bare module/exports references). It touches: parse_entry.rs (the exports_kind decision + hashing module_type), p.rs (value_for_this top-level this substitution), RuntimeTranspilerStore.rs (thread caller-derived module_type onto TranspilerJob and use it in the worker), jsc_hooks.rs (new nearest_module_type walk over DirInfo parents including nameless package.json, stopping at node_modules), resolver.rs (resolve_with_framework embedded code → Unknown), RuntimeTranspilerCache.rs (version 23), plus 12 new runtime tests, 2 new bundler tests, and dropping a stray "type":"module" from the es-module-lexer fixture.

Security risks

None identified. This is module-format classification; no auth, crypto, network, or untrusted-input parsing is introduced. The nearest_module_type walk reads already-cached DirInfo entries via read_dir_info.

Level of scrutiny

High. Module classification governs whether a file is wrapped as CJS or run as ESM — misclassification breaks real packages (the PR itself found and fixed three such regressions during review: nameless nested {"type":"commonjs"}, exports-map "import" condition, and the node_modules boundary). The runtime path now trusts module_type == Esm unconditionally, which is only correct because nearest_module_type derives it per Node's rule; any remaining divergence there is a runtime regression. The bundler path is deliberately kept on the old content-sniff via !bundle gates as a workaround.

Other factors

  • Design overlap: the author's own dedup analysis notes #32177 explicitly preserves the CJS-interop fallback for forced-ESM files that reference module/exports, while this PR removes it. #32173 makes overlapping changes to value_for_this and the cache key. Whichever direction is chosen, a maintainer should reconcile these.
  • Workaround shape: the !bundle gates are documented as temporary until finalize_result is fixed to re-derive module_type from extension + nearest package.json. That's a reasonable scoping choice, but it leaves the parser's behavior forked between runtime and bundler on the same input — worth explicit maintainer acknowledgment.
  • Companion branch: the author mentioned a separate branch handling the .cjs mirror (empty/marker-less CJS files) that isn't folded in here.
  • Test coverage: the new tests are thorough (extension × package.json × direct/dynamic-import × nested scopes × node_modules boundary, plus negative controls). All four issues I raised during earlier passes are addressed with tests.

Given the critical code path, the intentional runtime/bundler behavioral fork, and the open design question vs. #32177, this warrants human review rather than bot approval.

robobun added a commit that referenced this pull request Jul 18, 2026
A .js file under "type":"commonjs" (or a .cjs file) containing
`export` or top-level `await` was silently classified and run as ESM.
In Node those are a hard SyntaxError: an explicit "type":"commonjs"
pins the format of every .js in the tree, and .cjs is always CommonJS.

The exports_kind decision in parse_entry.rs checked
`esm_export_keyword || top_level_await_keyword` before ever consulting
`options.module_type`, so the syntax out-ranked the explicit declaration.

- parse_entry.rs: when `module_type == Cjs` at runtime (not bundling, not
  TypeScript), emit a range error pointing at the `export`/`await` with a
  note saying why the file is CommonJS. TypeScript is excluded because
  `export` in a CommonJS-typed .ts/.cts is idiomatic (tsc compiles it to
  `exports.x = ...`).
- RuntimeTranspilerStore.rs: thread the caller's extension-aware
  `module_type` onto `TranspilerJob` and use it in the worker. The worker
  was re-deriving it from `resolved_source.tag`, which never carried the
  .cjs/.mjs extension signal. Also check `log.errors` after a successful
  parse (matching the sync path in transpile_source_code_inner); without
  this, parser-emitted errors on the async path were silently dropped and
  the output executed anyway.
- jsc_hooks.rs: pass the computed `module_type` to
  `transpiler_store.transpile()`.
- parse_entry.rs/RuntimeTranspilerCache.rs: hash `module_type` into the
  runtime transpiler features hash and bump EXPECTED_VERSION to 23, since
  byte-identical sources now parse differently under different module types.
- resolve-ts.test.ts: the `type:commonjs && jsFile` matrix rows wrote ESM
  `export` into a .js under "type":"commonjs" and relied on the old
  lenient behaviour; write CJS-shaped sources for that combination.

Sibling of #33807, which makes "type":"module" authoritative over
bare `module`/`exports` references (the ESM direction of the same
decision-order bug). The RuntimeTranspilerStore module_type threading
and cache-hash change here are the same as in that PR; whichever lands
second resolves a small conflict.
robobun added a commit that referenced this pull request Jul 18, 2026
A .js file under "type":"commonjs" (or a .cjs file) containing
`export` or top-level `await` was silently classified and run as ESM.
In Node those are a hard SyntaxError: an explicit "type":"commonjs"
pins the format of every .js in the tree, and .cjs is always CommonJS.

The exports_kind decision in parse_entry.rs checked
`esm_export_keyword || top_level_await_keyword` before ever consulting
`options.module_type`, so the syntax out-ranked the explicit declaration.

- parse_entry.rs: when `module_type == Cjs` at runtime (not bundling, not
  TypeScript), emit a range error pointing at the `export`/`await` with a
  note saying why the file is CommonJS. TypeScript is excluded because
  `export` in a CommonJS-typed .ts/.cts is idiomatic (tsc compiles it to
  `exports.x = ...`).
- RuntimeTranspilerStore.rs: thread the caller's extension-aware
  `module_type` onto `TranspilerJob` and use it in the worker. The worker
  was re-deriving it from `resolved_source.tag`, which never carried the
  .cjs/.mjs extension signal. Also check `log.errors` after a successful
  parse (matching the sync path in transpile_source_code_inner); without
  this, parser-emitted errors on the async path were silently dropped and
  the output executed anyway.
- jsc_hooks.rs: pass the computed `module_type` to
  `transpiler_store.transpile()`.
- parse_entry.rs/RuntimeTranspilerCache.rs: hash `module_type` into the
  runtime transpiler features hash and bump EXPECTED_VERSION to 23, since
  byte-identical sources now parse differently under different module types.
- resolve-ts.test.ts: the `type:commonjs && jsFile` matrix rows wrote ESM
  `export` into a .js under "type":"commonjs" and relied on the old
  lenient behaviour; write CJS-shaped sources for that combination.

Sibling of #33807, which makes "type":"module" authoritative over
bare `module`/`exports` references (the ESM direction of the same
decision-order bug). The RuntimeTranspilerStore module_type threading
and cache-hash change here are the same as in that PR; whichever lands
second resolves a small conflict.
robobun added a commit that referenced this pull request Jul 18, 2026
A .js file under "type":"commonjs" (or a .cjs file) containing
`export` or top-level `await` was silently classified and run as ESM.
In Node those are a hard SyntaxError: an explicit "type":"commonjs"
pins the format of every .js in the tree, and .cjs is always CommonJS.

The exports_kind decision in parse_entry.rs checked
`esm_export_keyword || top_level_await_keyword` before ever consulting
`options.module_type`, so the syntax out-ranked the explicit declaration.

- parse_entry.rs: when `module_type == Cjs` at runtime (not bundling, not
  TypeScript), emit a range error pointing at the `export`/`await` with a
  note saying why the file is CommonJS. TypeScript is excluded because
  `export` in a CommonJS-typed .ts/.cts is idiomatic (tsc compiles it to
  `exports.x = ...`).
- RuntimeTranspilerStore.rs: thread the caller's extension-aware
  `module_type` onto `TranspilerJob` and use it in the worker. The worker
  was re-deriving it from `resolved_source.tag`, which never carried the
  .cjs/.mjs extension signal. Also check `log.errors` after a successful
  parse (matching the sync path in transpile_source_code_inner); without
  this, parser-emitted errors on the async path were silently dropped and
  the output executed anyway.
- jsc_hooks.rs: pass the computed `module_type` to
  `transpiler_store.transpile()`.
- parse_entry.rs/RuntimeTranspilerCache.rs: hash `module_type` into the
  runtime transpiler features hash and bump EXPECTED_VERSION to 23, since
  byte-identical sources now parse differently under different module types.
- resolve-ts.test.ts: the `type:commonjs && jsFile` matrix rows wrote ESM
  `export` into a .js under "type":"commonjs" and relied on the old
  lenient behaviour; write CJS-shaped sources for that combination.

Sibling of #33807, which makes "type":"module" authoritative over
bare `module`/`exports` references (the ESM direction of the same
decision-order bug). The RuntimeTranspilerStore module_type threading
and cache-hash change here are the same as in that PR; whichever lands
second resolves a small conflict.
robobun added a commit that referenced this pull request Jul 18, 2026
A .js file under "type":"commonjs" (or a .cjs file) containing
`export` or top-level `await` was silently classified and run as ESM.
In Node those are a hard SyntaxError: an explicit "type":"commonjs"
pins the format of every .js in the tree, and .cjs is always CommonJS.

The exports_kind decision in parse_entry.rs checked
`esm_export_keyword || top_level_await_keyword` before ever consulting
`options.module_type`, so the syntax out-ranked the explicit declaration.

- parse_entry.rs: when `module_type == Cjs` at runtime (not bundling, not
  TypeScript), emit a range error pointing at the `export`/`await` with a
  note saying why the file is CommonJS. TypeScript is excluded because
  `export` in a CommonJS-typed .ts/.cts is idiomatic (tsc compiles it to
  `exports.x = ...`).
- RuntimeTranspilerStore.rs: thread the caller's extension-aware
  `module_type` onto `TranspilerJob` and use it in the worker. The worker
  was re-deriving it from `resolved_source.tag`, which never carried the
  .cjs/.mjs extension signal. Also check `log.errors` after a successful
  parse (matching the sync path in transpile_source_code_inner); without
  this, parser-emitted errors on the async path were silently dropped and
  the output executed anyway.
- jsc_hooks.rs: pass the computed `module_type` to
  `transpiler_store.transpile()`.
- parse_entry.rs/RuntimeTranspilerCache.rs: hash `module_type` into the
  runtime transpiler features hash and bump EXPECTED_VERSION to 23, since
  byte-identical sources now parse differently under different module types.
- resolve-ts.test.ts: the `type:commonjs && jsFile` matrix rows wrote ESM
  `export` into a .js under "type":"commonjs" and relied on the old
  lenient behaviour; write CJS-shaped sources for that combination.

Sibling of #33807, which makes "type":"module" authoritative over
bare `module`/`exports` references (the ESM direction of the same
decision-order bug). The RuntimeTranspilerStore module_type threading
and cache-hash change here are the same as in that PR; whichever lands
second resolves a small conflict.
Jarred-Sumner pushed a commit that referenced this pull request Sep 3, 2026
…ed or not (#41232)

### Problem
- Regression on main from #41150. No release has it. `bun build` reads
`"type"` from the wrong package.json, so it misses a `{ "type": "module"
}` that has no `"name"`. Two common places: a project root, and a dual
package's `dist/esm/package.json`.
- A `.js` or `.ts` file there gets `exports.default` for the default
import of a CommonJS module with `__esModule`. Node, esbuild and bun
1.4.0 give the whole `module.exports`. The bundle has
`__toESM(require_tsdep())`, without `, 1`.
- Cause: `finalize_result` (`src/resolver/resolver.rs:1669`) read
`"type"` from the package root, or else from `enclosing_package_json`.
`dir_info_uncached` (`src/resolver/resolver.rs:6357`) sets that field
only for a named package.json (#229).

### Fix
- `DirInfo` gets `package_json_for_module_type`: the nearest
package.json in the directory or above it, named or not.
`finalize_result` reads `"type"` from it for the primary path. The
extension still wins.
- Correct because esbuild uses this rule, and Node ignores `"name"` too.
- Only the bundler reads `Result.module_type`. `enclosing_package_json`
does not change. The four runtime lookups in `src/runtime/jsc_hooks.rs`
are out of scope.
- Verified: `test/bundler/bundler_cjs.test.ts`, 10 new cases, 9 fail on
main. Self-reviewed: 3 concerns raised, 3 addressed. Other suites in
Notes.

### Background
- `__toESM(mod, isNodeMode)` builds the ESM view of a CommonJS module.
With `, 1` (Node mode), `default` is the whole `module.exports`. Without
it, `default` is `mod.default` when `__esModule` is set.
- `DirInfo` is the resolver's cached record for one directory. Its
"enclosing" fields come from the parent.
- Open PRs in this area: #33883, #33807, #33890, #40940. This PR
supersedes none. Notes cover #40940.

<details><summary>Notes</summary>

Found by comparing `bun build` on main with bun 1.4.0, Node 26 and
esbuild 0.25. No issue is open for it.

Repro for the dual-package face:

```sh
D=$(mktemp -d); cd $D; mkdir -p node_modules/pkg/dist/esm node_modules/tsdep
echo '{"name":"tsdep","version":"1.0.0","main":"index.js"}' > node_modules/tsdep/package.json
echo 'Object.defineProperty(exports,"__esModule",{value:true}); exports.default=function styled(){}; exports.css="css";' > node_modules/tsdep/index.js
echo '{"name":"pkg","version":"1.0.0","main":"./dist/esm/index.js"}' > node_modules/pkg/package.json
echo '{"type":"module"}' > node_modules/pkg/dist/esm/package.json
echo 'import styled from "tsdep"; export const seen = typeof styled + "/" + typeof styled.default;' > node_modules/pkg/dist/esm/index.js
echo 'import { seen } from "pkg"; console.log(seen);' > app.mjs
node app.mjs                                                              # object/function
bun build ./app.mjs --target=node --outfile=out.mjs && node out.mjs       # main: function/undefined, this PR: object/function
```

For the project-root face, put `{ "type": "module" }` (no `"name"`) in
the project's package.json and bundle a `.js` file that imports `tsdep`.

Faces of the bug on main. Each has a test:

- A project package.json with `"type"` and no `"name"` (case 58).
- The nested marker reached through `"main"`, `"module"` or a relative
path (cases 53, 55, 56). Through an exports map it worked, because
`handle_esm_resolution` reads the file's own directory.
- A nested package.json with a `"name"`, reached through `"main"` (case
54). `result.package_json` was the package root, so the nested file was
not read at all.
- A file in a subdirectory of the marker (case 57).

Why a new field instead of widening `enclosing_package_json`: that field
also names the package for `sideEffects`, the auto-install version gate
and `bun run` script discovery. #33883 widens it for every consumer and
had to rework the `sideEffects` loop in `finalize_result` to keep the
DCE tests passing.

Four cases pin the lookup rule. Each result matches esbuild 0.25.1:

- Case 59: a nameless `{ "type": "commonjs" }` below a `"type":
"module"` package wins, because it is the nearest.
- Case 60: a nearest package.json without `"type"` is the scope. The
lookup does not continue to a typed package root, so the importer is not
ESM by type. Main read the root's `"type"` here. Node prints
`object/function` for this shape, but only because it detects ESM syntax
in a file with no `"type"`. #41150 chose the esbuild rule for such
files.
- Case 61: the lookup does not stop at a `node_modules` directory. A
package without a package.json of its own takes the `"type"` above it.
Node prints the same result.
- Case 62: only the primary path decides. With the default target,
`"module"` is the primary path and `"main"` is the fallback for
`require()`. The fallback's package.json does not count. esbuild has the
same check. The case fails when the check is removed.

Overlap with #40940: it adds a field with the same name, but its lookup
stops at `node_modules`, and the runtime reads it too. If #40940 lands
after this PR, it must choose one rule for the field. Case 61 pins the
crossing for the bundler. Node's stop can still apply at the runtime
read sites. #40940 also calls the lookup for the fallback path, which
case 62 rejects.

Out of scope: the runtime's four lookups (`src/runtime/jsc_hooks.rs`
lines 1474, 2972, 3220 and 4071) keep
`package_json().or(enclosing_package_json)`. So `bun run` still skips a
nameless package.json above the file's own directory. That gap predates
#41150. For this import, `bun run` 1.4.1 gives `exports.default` for
every importer, even `.mjs`.

Self-review, the three concerns and what changed:

- Document the `node_modules` rule on the field. Done in
`src/resolver/dir_info.rs`.
- Add a default-target case with both `"main"` and `"module"`. That is
case 62.
- Say in this body that no release has the bug, lead with the
project-root face, and name the runtime lookups that stay.

Suites run with the fix on a debug ASAN build, after a rebase on main:
`bundler_cjs` (62), `esbuild/packagejson`, `esbuild/dce`,
`esbuild/default`, `bundler_cjs2esm`, `bundler_npm`, `bundler_edgecase`,
`bundler_regressions`, `bundler_splitting`, `bundler_barrel`,
`cli/run/run-cjs`, `test/js/bun/resolve`. All pass except the second
case of `test/js/bun/resolve/load-same-js-file-a-lot.test.ts`. It times
out at 5 s on this build with and without this change (back-to-back runs
on the same machine).
</details>

<!-- robobun:evidence:begin -->

---

**[human-review]** gate passed · iteration 0 · 4 files touched

<details><summary>fails on main (without fix)</summary>

```console
ASAN without fix: 9 FAILED
$ BUN_DEBUG_QUIET_LOGS=1 bun scripts/build.ts --profile=debug --quiet test "--reporter=junit" "--reporter-outfile=/tmp/pr_gate.xml" test/bundler/bundler_cjs.test.ts
bun test v1.4.1 (a6c4cc2)

test/bundler/bundler_cjs.test.ts:
(pass) bundler > cjs/__toESM_import_syntax_with_esModule [945.38ms]
(pass) bundler > cjs/__toESM_import_syntax_without_esModule [445.45ms]
(pass) bundler > cjs/__toESM_import_syntax_function [371.94ms]
(pass) bundler > cjs/__toESM_import_syntax_primitive [387.72ms]
(pass) bundler > cjs/__toESM_import_syntax_named_and_default [361.78ms]
(pass) bundler > cjs/__toESM_import_syntax_namespace [367.76ms]
(pass) bundler > cjs/__toESM_target_node [455.64ms]
(pass) bundler > cjs/__toESM_target_browser [413.38ms]
(pass) bundler > cjs/__toESM_target_bun [455.22ms]
(pass) bundler > cjs/__toESM_format_esm [462.40ms]
(pass) bundler > cjs/__toESM_format_cjs_with_import [377.93ms]
(pass) bundler > cjs/__toESM_mjs_reexport [431.60ms]
(pass) bundler > cjs/__toESM_mjs_reexport_with_esModule [432.63ms]
(pass) bundler > cjs/__toESM_deep_reexport_chain [368.70ms]
(pass) bundler > cjs/__toESM_reexport_with_rename [443.84ms]
(pass) bundler > cjs/__toESM_default_prop
... (truncated)

release without fix: 20 FAILED
bun test v1.4.1-canary.1 (a6c4cc2)

test/bundler/bundler_cjs.test.ts:
runtime failed file: /tmp/bun-build-tests/bun-4t1lr0/cjs/__toESM_import_syntax_with_esModule/out.js
stdout output:
{"__esModule":true,"default":{"value":"default export"},"named":"named export"}
---
expected stdout:
{"value":"default export"}
---
1913 |               console.log(`---`);
1914 |               console.log(`expected ${name}:`);
1915 |               console.log(expected);
1916 |               console.log(`---`);
1917 |             }
1918 |             expect(result).toBe(expected);
                                  ^
error: expect(received).toBe(expected)

Expected: "{"value":"default export"}"
Received: "{"__esModule":true,"default":{"value":"default export"},"named":"named export"}"

      at <anonymous> (/workspace/bun/test/bundler/expectBundled.ts:1918:28)
(fail) bundler > cjs/__toESM_import_syntax_with_esModule [29.17ms]
(pass) bundler > cjs/__toESM_import_syntax_without_esModule [11.71ms]
(pass) bundler > cjs/__toESM_import_syntax_function [9.90ms]
(pass) bundler > cjs/__toESM_import_syntax_primitive [9.43ms]
(pass) bundler > cjs/__toESM_import_syntax_named_and_default [9.97ms]
... (truncated)
```

</details>

<details><summary>passes on PR (with fix)</summary>

```console
ASAN with fix: all passed
$ BUN_DEBUG_QUIET_LOGS=1 bun scripts/build.ts --profile=debug --quiet test "--reporter=junit" "--reporter-outfile=/tmp/pr_gate.xml" test/bundler/bundler_cjs.test.ts
bun test v1.4.1 (a6c4cc2)

test/bundler/bundler_cjs.test.ts:
(pass) bundler > cjs/__toESM_import_syntax_with_esModule [1024.06ms]
(pass) bundler > cjs/__toESM_import_syntax_without_esModule [473.45ms]
(pass) bundler > cjs/__toESM_import_syntax_function [489.71ms]
(pass) bundler > cjs/__toESM_import_syntax_primitive [494.25ms]
(pass) bundler > cjs/__toESM_import_syntax_named_and_default [388.89ms]
(pass) bundler > cjs/__toESM_import_syntax_namespace [381.97ms]
(pass) bundler > cjs/__toESM_target_node [389.92ms]
(pass) bundler > cjs/__toESM_target_browser [453.17ms]
(pass) bundler > cjs/__toESM_target_bun [481.14ms]
(pass) bundler > cjs/__toESM_format_esm [415.53ms]
(pass) bundler > cjs/__toESM_format_cjs_with_import [376.77ms]
(pass) bundler > cjs/__toESM_mjs_reexport [451.67ms]
(pass) bundler > cjs/__toESM_mjs_reexport_with_esModule [380.06ms]
(pass) bundler > cjs/__toESM_deep_reexport_chain [453.02ms]
(pass) bundler > cjs/__toESM_reexport_with_rename [430.08ms]
(pass) bundler > cjs/__toESM_default_pro
... (truncated)

release with fix: all passed
$ bun scripts/build.ts --profile=release
[configured] bun-profile → bun (stripped)
  target       linux-x64-gnu
  build type   Release
  build dir    ./build/release
  revision     822e3b2
  features     baseline

23 deps, 131 codegen, 1172 objects in 647ms

ninja: Entering directory `/workspace/bun/build/release'
[1/1244] install /workspace/bun
bun install v1.4.1-canary.1 (a6c4cc2)

Checked 25 installs across 62 packages (no changes) [10.00ms]
[2/1244] gen bindgenv2
[3/1244] gen ErrorCode+*.h
[4/1244] install /workspace/bun/packages/bun-error
bun install v1.4.1-canary.1 (a6c4cc2)

Checked 1 install across 2 packages (no changes) [3.00ms]
[5/1244] fetch libjpeg-turbo
[libjpeg-turbo] up to date
[6/1217] gen ProcessBindingConstants.lut.h
Generating /workspace/bun/build/release/codegen/ProcessBindingConstants.lut.h from /workspace/bun/src/jsc/bindings/ProcessBindingConstants.cpp
[7/1217] gen bake.{client,server,error}.js
-> bake.client.js, bake.server.js, bake.error.js
[8/1217] fetch tinycc
[tinycc] up to date
[9/1216] install /workspace/bun/src/node-fallbacks
bun install v1.4.1-canary.1 (a6c4cc2)

Checked 111 installs across 104 packages (no changes) [15.00
... (truncated)
```

</details>

<details><summary>diff hotspot</summary>

```
src/resolver/dir_info.rs         |   9 ++
 src/resolver/resolver.rs         |  20 +++--
 src/resolver/result.rs           |  15 +---
 test/bundler/bundler_cjs.test.ts | 182 ++++++++++++++++++++++++++++++++++++++-
 4 files changed, 207 insertions(+), 19 deletions(-)
```

</details>

**gate history** · 1 passed · 0 rejected · iteration 0

<details><summary>evidence per changed file</summary>

```
file                              reads  edits  tests
src/resolver/dir_info.rs              3      4     33
src/resolver/resolver.rs              8      5     34
src/resolver/result.rs                1      1     33
test/bundler/bundler_cjs.test.ts      3     11     33
```

</details>

<!-- robobun:evidence:end -->
@robobun

robobun commented Sep 13, 2026

Copy link
Copy Markdown
Collaborator Author

Closing as part of a cleanup of stale pull requests. This PR has had no new commits since 2026-07-12, it conflicts with main, and its last CI run failed. This is not a judgment on the fix itself. If the problem still reproduces on a current build, reopen this PR after a rebase or open a new one against main.

@robobun robobun closed this Sep 13, 2026
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.

1 participant