Skip to content

resolver: look up "main" in the "browser" map with the extension order of its load - #47034

Open
robobun wants to merge 5 commits into
mainfrom
robobun/7b3b182e/browser-map-entry-ext-order
Open

robobun wants to merge 5 commits into
mainfrom
robobun/7b3b182e/browser-map-entry-ext-order

Conversation

@robobun

@robobun robobun commented Oct 27, 2026 •

Copy link
Copy Markdown
Collaborator

Problem

  • bun build --target=browser applies the "browser" entry of a file that "main" does not load. {"main":"./main","browser":{"./main.mjs":"./missing.js"}} beside main.js fails: error: Could not resolve: "p". Keys for ./main.mjs and ./main.js give import and require different files.
  • check_browser_map (src/resolver/resolver.rs) adds extensions in the import kind's order. The load uses the "main" order (.js first, no .mjs).
  • Regression of resolver: pass absolute path to browser-map check for main field and index #36620 (1.4.0). No issue reports it.

Fix

  • check_browser_map takes the order as an argument. load_from_main_field and load_as_index_with_browser_remapping pass their load's order. The other four callers pass self.extension_order.
  • A CSS import now loads "main" in the CSS order, so @import "pkg" finds main.css and its key.
  • The lookup still goes by name and does not read the directory (Notes).
  • Verified: test/bundler/esbuild/packagejson.test.ts (9 new cases, 7 fail on main). Self-reviewed: 24 concerns raised, 23 addressed (Notes).

Background

  • The "browser" field maps a package file to another file or to false. The resolver looks an entry up before it loads it.
  • Considered: set Resolver.extension_order around the two lookups. A new caller would still get the wrong list silently.

Downsides

  • 219 of 449 matrix cells change for --target=browser. 72 leave esbuild's output (a .ts, .tsx or .jsx key, Notes). 25 stop resolving, as on 1.3.14.
  • Until resolver: do not look up a "browser" map target again after resolution #46985 lands, import of a chain map ("./main.js":"./b.js","./b.js":false) fails with File not found "", like require on main.
  • Cost: +256 bytes of text. A runtime package resolve runs 9 more instructions with "main", 5 without.
Notes

Repro. node_modules/p has main.js. Run bun build entry.js --target=browser. The cells show import "p", then require("p").

package.json of p 1.3.14 1.4.4, main this PR esbuild 0.25.1
"main":"./main", "./main.mjs":"./missing.js" main.js Could not resolve: "p" main.js main.js
"main":"./main", "./main.mjs":false main.js import is undefined main.js main.js
"main":"./main", "./main.mjs":"./a.js", "./main.js":"./b.js" b.js, b.js a.js, b.js b.js, b.js b.js, b.js
"main":"./dist/index", "module":"./dist/index.mjs", "./dist/index.mjs":"./dist/browser.mjs", require only "dist/index.js" {"default":"dist/browser.mjs"} "dist/index.js" "dist/index.js"

Reach. #46985 lists this defect under "Not fixed here". I scanned the latest manifest of 17,148 widely used npm packages. 455 have a "browser" map. In none of them does the entry key differ between the two orders. So no sampled package changes, in either direction.

CSS imports. load_as_file_or_directory loaded "main" in the "main" order for a CSS import too. That order has no .css. So @import "pkg" with "main":"./main" and main.css failed with Could not resolve on every target. With main.js beside it, the import loaded main.js and failed with Cannot import a ".jsx" file into a CSS file. On 1.4.x the package resolved only through a ./main.css key, because the lookup used the CSS order. A CSS import now loads "main" in the CSS order, as esbuild does. The package resolves with and without a map, and the lookup follows the load. packagejson/BrowserMapMainFieldNoExtCSSImport covers both.

Not fixed here. The lookup does not read the directory. It can still pick the key of a file that does not load.

  • A lone ./main.cjs or ./main.cts key applies although main.js loads. Bun 1.3.14 and esbuild load main.js.
  • A package with no "main" loads its index in the order of the import kind. Its lookup uses that order. With keys for ./index.mjs and ./index.js and only index.js on disk, import takes the .mjs entry. "./index.mjs":false gives an empty module. A "main":"./index" line now changes that bundle. An extensionless "module" behaves the same way.
  • With keys for ./main.cjs and ./main.js and only main.cjs on disk, both statements take the ./main.js entry. esbuild does the same. Bun 1.3.14 took the ./main.cjs entry.
  • check_relative_path and the lookup before resolution in check_package_path keep the order of the import kind. With keys for ./thing.mjs and ./thing.js, import "./thing" and require("./thing") take different entries, as on 1.3.14. esbuild gives one. The load there has no single order: it is a file, an index, or a directory with its own "main".
  • A string "browser" entry with no extension loads in the order of the import kind. That is the load, not this lookup.
  • A key whose value is a bare package fails at these two lookups, as in esbuild (resolver: skip main-field/index browser remap when the target is a bare package #36668).
  • The first key in the "main" order can name a missing target. Then the package does not resolve, as in esbuild. Bun 1.4.x took the key of another extension. packagejson/BrowserMapMainFieldNoExtMissingTarget pins it.
  • The lookup after resolution turns a key with the value false into File not found "". That is older than 1.3.14. bundler: port esbuild's "browser" map lookup and bundle a disabled path as an empty module #40832 removes that lookup.

packagejson/BrowserMapEntryKeyOfMissingFile (todo) holds the first two shapes. A lookup by the name of the loaded file closes them. It is a larger change than this one and needs its own decision.

Question for a maintainer. Take keys for ./main.jsx or ./main.ts beside ./main.js. Bun loads main.js for "main", so this PR takes the ./main.js entry. esbuild loads main.jsx or main.ts and takes that entry. packagejson/BrowserMapMainFieldNoExtMainOrder pins the two rows. Say so if esbuild's output is the bar there.

Matrix. 449 build cells, built with 1.3.14, main at 99ee03e, this PR and esbuild 0.25.1. 324 cells are generated. Their keys are one or two of the 9 implicit extensions of "main":"./main". Each shape runs in a package and in a relative directory, for import and for require. 125 cells are written by hand.

  • --target=node: 3 cells change. They are CSS imports that now resolve.
  • --target=browser: 219 cells change. 113 return to the 1.3.14 output. 36 more take esbuild's output. 70 are new for Bun.
  • Of the 70, 56 are a relative directory import, where 1.3.14 applied no map. 14 are shapes where no key names the file that loads.
  • I compared the 219 with the file that --target=node loads:
    • 110 now use the entry of that file.
    • 25 load that file as is, because it has no key.
    • 55 apply another key than before. The file that loads has no key.
    • 25 stop resolving. They also fail on 1.3.14 and with --target=node. Their keys are .mjs or .mts.
    • 4 resolve through a key although the plain load fails.
  • Against esbuild: 118 cells move to its output, 72 leave it, and 29 differ before and after.
  • On main, import and require of one package bundle different files in 45 of 220 pairs. With this PR, 7 do. The 7 have "module", or index.mjs beside index.js.
  • A package with "module" that one bundle imports and requires is now one copy, as in esbuild. Bun 1.3.14 and 1.4.x bundle two.
  • These pairs leave esbuild's output when both files are on disk. Outside node_modules: .ts, .tsx or .jsx with .js, .cjs or .cts. Inside: .jsx with .js, .cjs or .cts, and .ts or .tsx with .cts.
  • The other four callers: 0 of 39 cells change.

Order against other open PRs.

Measurements. Linux x64, debug and release builds of main at 99ee03e, without and with this change.

  • Calls of check_browser_map outside browser builds (gdb breakpoint counts): 0 and 0. That holds for bun file.js, for --target=node, and for a browser build of a package with no map.
  • Map lookups per resolve of a package entry (gdb counts at the four map.get sites):
    • no matching key: 80 to 72.
    • matching keys: 42 to 42 for import, 44 to 42 for require.
    • CSS @import with "main":"./main.css": 16 to 16.
  • Instructions in the resolver functions for one resolve (gdb single-step, release):
    • warm Bun.resolveSync("p"), package with "main": 1804 to 1813.
    • the same for a package with an implicit index: 1589 to 1594.
    • browser build with no matching key: 4071 to 3940.
  • The 9 instructions are in load_as_file_or_directory, which now selects the order for "main".
  • Allocations and syscalls in one resolve of a browser build: 24 and 24, 8 and 8. Warm runtime resolve: 0 and 0.
  • Release text: 107,561,497 to 107,561,753 bytes. check_browser_map: 625 to 628 and 685 to 689 bytes. load_from_main_field: 1674 to 1699. load_as_index_with_browser_remapping: 1610 to 1683. load_as_file_or_directory: 2868 to 2855.
  • There are no counts for a whole process: MIMALLOC_SHOW_STATS prints nothing in these builds, and strace is not installed.

Suites run with the debug build. test/bundler/esbuild/{packagejson,default,tsconfig,css,importstar,extra,loader}.test.ts, test/bundler/bundler_{browser,npm,cjs,regressions,edgecase,html}.test.ts, test/bundler/css/css-modules.test.ts, test/js/bun/css/css-loader.test.ts, test/js/bun/resolve/{resolve,resolve-error}.test.ts, test/bake/dev/esm.test.ts. Two cases exceed their time limit under the debug build, with and without this change. Both pass on release builds: default/TopLevelAwaitForbiddenRequireSourceMapCLI and edgecase/DeepImportDiamondDAG.

Self-review. One concern is open: no passing case has a key whose file is missing and that wins. A row for it (only-cjs-file) was removed in review, because the todo case expects the opposite rule. Three suggestions are not taken.

…ts load

`check_browser_map` adds implicit extensions to the path it looks up. It
took their order from `Resolver.extension_order`, which follows the import
kind. `load_from_main_field` and `load_as_index_with_browser_remapping`
load the file with the order their caller passes, and that is the "main"
order for the "main" field and for the index of a package with "module".
So the probe could find the key of a file that the load does not pick.

With `"main": "./main"` and keys for `./main.mjs` and `./main.js`,
`import "p"` took the `./main.mjs` entry and `require("p")` took the
`./main.js` entry, although both load `main.js`.

`check_browser_map` now takes the order as an argument. The two entry
lookups pass the order of their load. The other four callers pass
`self.extension_order`, as before.
The lookup goes by name. It adds the implicit extensions in the order it
is given, the first name that is a key wins, and it does not read the
directory. The comment on `check_browser_map` and the test comments said
more than that.

New cases: a key for a file that is not on disk, a `./main.mjs` key whose
target is missing, a `./main.js` key whose target is missing, a package
that has only `main.mjs`, a `.mjs` key of "module" under `require`, and
the two shapes where the "main" order gives another entry than esbuild.
One `todo` case holds the shapes that still apply the key of a file that
does not load. The cases now sit next to
`BrowserMapMainFieldExtPermutations`.
@coderabbitai

coderabbitai Bot commented Oct 27, 2026 •

Copy link
Copy Markdown
Contributor

Review in Change Stack →

No actionable comments were generated in the recent review.

Recent review info
Run configuration
Commits

Reviewing files that changed from the base of the PR and between 99ee03e and b88011b.

Files selected for processing: 2
  • src/resolver/resolver.rs
  • test/bundler/esbuild/packagejson.test.ts

Included review availability: This review used your included allowance. Your plan provides up to 10 included reviews per hour; 0 remain after this review.

Walkthrough

The resolver now uses the active extension order when matching browser-map entries. CSS main-field lookups use CSS extension order. New tests cover browser-map resolution across package entry points, import kinds, and CSS.

Changes

Browser-map extension order

Layer / File(s) Summary
Extension order and browser-map coverage
src/resolver/resolver.rs, test/bundler/esbuild/packagejson.test.ts
check_browser_map now matches candidates using the supplied extension order, which its callers pass for package-path, absolute-path, main-field, and index lookups. CSS main-field lookups use CSS extension order. Tests cover main-field and fallback probing, remap targets, import kinds, and CSS imports.

Suggested reviewers: jarred-sumner, alii

Priority: Normal

Merge Risk: Minimal · up to b8801

Browser-map lookups now follow the load’s extension order, including for CSS. No merge-blocking regression was established.

Pre-merge checks: Passed 4
Check name Status Explanation
Title check Passed The title clearly identifies the main change: browser-map lookups use the extension order of the load.
Description check Passed The description explains the problem, the fix, and how the author verified it. It does not use the template’s exact headings, but it provides the required information in detail.
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.
  • Autofix · Keep fixing CodeRabbit findings and required CI, and resolving merge conflicts

Thanks for using CodeRabbit! It's free for OSS, and your support helps us grow. If you like it, consider giving us a shout-out.

❤️ Share

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

@robobun

robobun commented Oct 27, 2026 •

Copy link
Copy Markdown
Collaborator Author

Status: ready for review.

Reproduced on linux x64 with a package p that has "main": "./main", the files main.js and main.mjs, and "browser": {"./main.mjs": "./a.js", "./main.js": "./b.js"}. bun build imp.js --target=browser bundles a.js for import "p" and b.js for require("p") on bun 1.4.3, 1.4.4 and a debug build of main. Bun 1.3.14 and esbuild 0.25.1 bundle b.js for both. With this branch both give b.js.

Seven of the nine new cases in test/bundler/esbuild/packagejson.test.ts fail on main and pass with this branch. One of the other two is a control, and one is a todo.

CI: build 133543 on b88011b finished with 180 of 181 jobs passed. The red job is one macOS x64 test shard. It fails in test/js/bun/module-graph/module-graph-isolation.test.ts, in a case that counts the connections a local server receives. This diff does not touch that code. The same test fails on that lane in builds of other branches (133535, 133510).

PR: #47034

@robobun

robobun commented Oct 27, 2026 •

Copy link
Copy Markdown
Collaborator Author
Updated 4:03 AM PT - Oct 27th, 2026

❌ @robobun, your commit b88011b has 1 failures in Build #133543 (All Failures):


🧪   To try this PR locally:

bunx bun-pr 47034

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

bun-47034 --bun

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

Findings marked 🟡 are optional suggestions and need no follow-up push.

Additional findings (outside the current diff — GitHub can't attach inline comments there):

  • 🟣 src/resolver/resolver.rs — pre-existing: browser-target builds fail with File not found "" instead of getting an empty module when a package-path import resolves to a file that the package's own "browser" map sets to false. At src/resolver/resolver.rs:2396-2398 the code sets is_disabled on the old primary and then replaces primary with Fs::Path::init_with_namespace(remap, b"file"), whose is_disabled is false and whose text is the empty remap. Fix: build the disabled path the way src/resolver/resolver.rs:2139-2145 does, keeping the resolved text and setting is_disabled = true on the path that is returned, so Result::path() yields None and the bundler emits an empty module. The base has the same lines; the PR's chain-map example now reaches them for import too.

    Why this was flagged

    A browser-target bundle contains import "pkg/sub" and node_modules/pkg/package.json has "browser": {"./sub.js": false} with sub.js on disk. load_node_modules resolves the file as a plain file, and the post-resolution lookup at src/resolver/resolver.rs:2388-2393 returns an empty remap for it. src/resolver/resolver.rs:2396 sets is_disabled on the current primary, then src/resolver/resolver.rs:2397-2398 overwrites primary with Fs::Path::init_with_namespace(remap, b"file"); src/paths/lib.rs:835 initialises that struct with is_disabled: false, and its text is "". Result::path() at src/resolver/result.rs:246-249 returns the primary because it is not disabled, so the bundler at src/bundler/bundle_v2.rs:7087 enqueues a parse of "" and reports File not found "". The early return at src/resolver/resolver.rs:2375 does not help because it runs before this lookup. The base branch has the same lines; the diff adds only the order argument at 2392, and by making import take the ./main.js key it widens which imports reach this block.

    Verification: src/resolver/resolver.rs:2396 sets result.path_pair.primary.is_disabled = true, then 2397-2398 overwrite primary with Fs::Path::init_with_namespace(remap, b"file"), and init_with_namespace in src/paths/lib.rs:830-838 constructs Self { text, is_disabled: false, .. }, so the flag set on line 2396 is discarded. The base commit has the identical lines.

Comment thread src/resolver/resolver.rs
Comment thread test/bundler/esbuild/packagejson.test.ts Outdated
Comment thread test/bundler/esbuild/packagejson.test.ts
`load_as_file_or_directory` loaded the "main" field in the "main" order
for every import kind. That order has no `.css`, so `@import "pkg"` did
not find `main.css` for `"main": "./main"`. It loaded `main.js` when that
file was there, and the build failed.

The "browser" map lookup of "main" uses the order of the load since the
previous commit. So a `./main.css` key stopped applying to a CSS import.
With the CSS order for the load, the lookup finds the key again, and the
package also resolves when it has no map.

Tests: a CSS case for both packages, the observed output in the comment
of the `todo` case, and no row that expects the key of a file that is not
on disk.
Comment thread src/resolver/resolver.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.

The follow-up commits address all three points from my earlier review (CSS @ import of an extensionless "main" now keeps the Css order and has a test; the only-cjs-file row moved into the todo case; the todo comment now states the output Bun prints today), and this run found no bugs. A human look is still worthwhile because the change alters browser-target bundle output wherever browser-map keys differ between the import-kind order and the "main" order, and it pins a deliberate divergence from esbuild (keys for ./main.jsx / ./main.ts beside ./main.js) that the PR explicitly asks a maintainer to decide.

What was reviewed:

  • All seven check_browser_map callers: the two inside load_from_main_field and load_as_index_with_browser_remapping pass the same extension_order as the load_as_file / load_as_index that follows; the four top-level sites pass self.extension_order, so they are behavior-preserving.
  • The mf_ext_order match at src/resolver/resolver.rs:5708 diverts only ExtOrder::Css; every other variant still maps to MainField, and the same value feeds the module -> main auto fallback, so CSS imports stay consistent on both paths.
  • The new tests exercise both import and require for each shape, with bundle-error expectations for the packages that must not resolve.
Extended reasoning...

The diff is 16 lines in src/resolver/resolver.rs plus 286 lines of itBundled tests: check_browser_map takes an explicit ExtOrder, the main-field and implicit-index lookups pass their load's order, and CSS imports keep ExtOrder::Css for "main". It touches no security-sensitive surface (no auth, crypto, or untrusted parsing beyond paths the resolver already handled). The code is small and internally consistent, no CODEOWNER covers resolver.rs, and the prior inline findings were addressed by later commits. Defer rather than approve because the change affects bundler output for browser builds across many package shapes and the PR itself leaves open a product decision about matching esbuild's .jsx/.ts-first order.

@robobun

robobun commented Oct 27, 2026

Copy link
Copy Markdown
Collaborator Author

On the finding outside the diff (File not found "" from the false arm of the lookup after resolution, src/resolver/resolver.rs:2396-2398):

Confirmed. import "pkg/sub" with "browser": {"./sub.js": false} fails with error: File not found "" on Bun 1.3.14, on main and on this branch. esbuild bundles an empty module. This PR does not change those lines.

I did not change that arm here, for two reasons.

The PR notes list the shape under "Not fixed here".

This branch has not been deployed

No deployments
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