Skip to content

resolver: compare browser map keys with forward slashes on every platform - #41183

Open
robobun wants to merge 4 commits into
mainfrom
robobun/67a55237/browser-map-slash-keys-windows
Open

robobun wants to merge 4 commits into
mainfrom
robobun/67a55237/browser-map-slash-keys-windows

Conversation

@robobun

@robobun robobun commented Sep 2, 2026 •

Copy link
Copy Markdown
Collaborator

Problem

  • On Windows, a browser map key with a slash never matches a bare import under --target=browser. {"pkg/sub": false} and {"@scope/pkg": false} bundle the Node file, and {"pkg/sub": "./shim.js"} is ignored. Linux and macOS apply the same entries.
  • parse_package_json normalized each key with the native separator (src/resolver/package_json.rs:617), so pkg/sub was stored as pkg\sub. check_browser_map (src/resolver/resolver.rs:5042) compares a package path as written, with forward slashes.

Fix

  • Store the keys with forward slashes on every platform. Normalize the lookup path the same way, and build the <path>/index probe with forward slashes.
  • A path inside the package (AbsolutePath mode) is compared in its normalized form, so foo\bar.js matches ./foo/bar.js on Windows. A package path is compared as written. esbuild does the same: keys go through path.Clean and the relative path gets forward slashes.
  • POSIX behavior does not change, because the normalization already used forward slashes there. The new tests pass on Linux with and without the fix. On Windows they fail on bun 1.4.1 and on a debug build of main, and pass with this change.
  • Verified: test/bundler/bundler_browser.test.ts (2 new cases) on Linux and Windows. Also esbuild/packagejson, bundler_edgecase and bun/resolve on both, plus bundler_npm and the bake browser field is used test.

Background

  • The browser field maps a specifier, or a file in the package, to another file or to false (an empty module). Bundlers apply it for --target=browser.
  • check_browser_map has two modes. PackagePath takes the import specifier as written. AbsolutePath takes a resolved file path and strips the package directory.
  • bundler: port esbuild's "browser" map lookup and bundle a disabled path as an empty module #40832 reworks the browser field to match esbuild and stores the keys as written. It is not mergeable right now. This is the small fix for the released behavior.
Notes

Repro on Windows with bun 1.4.1, bun build --target=browser entry.js:

package.json:                  {"name":"app","browser":{"pkg/sub":false}}
node_modules/pkg/package.json: {"name":"pkg"}
node_modules/pkg/sub.js:       module.exports = 1
entry.js:                      import x from "pkg/sub"; console.log(x)

Linux prints var { default: x} = (() => ({}));. Windows bundles node_modules/pkg/sub.js. Every scoped package key (@scope/pkg) has a slash, so those are affected too. A slash-free key such as "pkg": false works on both.

Why only the PackagePath mode broke: DirInfo.abs_path ends with a separator, so the AbsolutePath input is the path below the package directory, with native separators on both sides of the lookup. The bare exact match for a package path was the only probe that compared a raw specifier against a normalized key.

The first version of this change added map.get(cleaned) as a fallback, which is the input cleaning esbuild removed in 0.11.16 (#1209, over-matching). The self-review pointed that out, so the change now mirrors esbuild's current shape instead. A specifier with redundant segments (pkg/./sub) still does not match the key pkg/sub, on any platform, as before.

The fail-before can only be observed on Windows. The gate runs the proof on Linux, where the two tests pass with and without the fix. Windows proof: with src/resolver/ checked out from origin/main and the rest of the branch in place, bun bd test test/bundler/bundler_browser.test.ts -t "BrowserField.*PackageSubpath" fails both tests (string string and node-only-sub instead of undefined undefined and shim). With the branch's src/, both pass.

Suites run: test/bundler/bundler_browser.test.ts (27 tests, Linux and Windows), test/bundler/esbuild/packagejson.test.ts, test/bundler/bundler_edgecase.test.ts, test/js/bun/resolve/resolve.test.ts (Linux and Windows), test/bundler/bundler_npm.test.ts (Linux), test/bake/dev/esm.test.ts -t "browser field is used" (Linux and Windows).


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

fails on main (without fix)
ASAN without fix: 2 skipped
$ BUN_DEBUG_QUIET_LOGS=1 bun scripts/build.ts --profile=debug --quiet test "--reporter=junit" "--reporter-outfile=/tmp/pr_gate.xml" test/bundler/bundler_browser.test.ts
bun test v1.4.1 (a6c4cc276)

test/bundler/bundler_browser.test.ts:
(pass) bundler > browser/NodeBuffer#21522 [1420.93ms]
(pass) bundler > browser/NodeBuffer#12272 [704.92ms]
(pass) bundler > browser/NodeFS [368.65ms]
(pass) bundler > browser/NodeTTY [352.24ms]
(pass) bundler > browser/NodeEventsOnce [504.62ms]
(pass) bundler > browser/NodeUrlProtocolTablesIgnorePrototype [520.80ms]
(pass) bundler > browser/NodePolyfills [7310.56ms]
(todo) bundler > browser/NodePolyfillExternal
(pass) bundler > browser/BrowserFieldDisablesPolyfilledBuiltin#4928 [466.13ms]
(pass) bundler > browser/BrowserFieldDisablesPolyfilledBuiltinNodePrefix#4928 [459.18ms]
(pass) bundler > browser/BrowserFieldRemapsPolyfilledBuiltin#4928 [372.88ms]
(pass) bundler > browser/BrowserFieldDisabledBuiltinStillPolyfillsOutsideScope [457.85ms]
(pass) bundler > browser/EntryPointDisabledByBrowserField [246.66ms]
(pass) bundler > browser/EntryPointDisabledByBrowserFieldNextToLiveEntryPoint [221.06ms]
(pass) bundler > browser/EntryPointDisab
... (truncated)

release without fix: 1 failed, 2 skipped
bun test v1.4.1-canary.1 (a6c4cc276)

test/bundler/bundler_browser.test.ts:
(pass) bundler > browser/NodeBuffer#21522 [29.91ms]
(pass) bundler > browser/NodeBuffer#12272 [14.96ms]
(pass) bundler > browser/NodeFS [8.03ms]
(pass) bundler > browser/NodeTTY [7.70ms]
(pass) bundler > browser/NodeEventsOnce [10.88ms]
(pass) bundler > browser/NodeUrlProtocolTablesIgnorePrototype [10.49ms]
(pass) bundler > browser/NodePolyfills [141.68ms]
(todo) bundler > browser/NodePolyfillExternal
(pass) bundler > browser/BrowserFieldDisablesPolyfilledBuiltin#4928 [11.09ms]
(pass) bundler > browser/BrowserFieldDisablesPolyfilledBuiltinNodePrefix#4928 [10.18ms]
(pass) bundler > browser/BrowserFieldRemapsPolyfilledBuiltin#4928 [10.17ms]
(pass) bundler > browser/BrowserFieldDisabledBuiltinStillPolyfillsOutsideScope [10.77ms]
(pass) bundler > browser/EntryPointDisabledByBrowserField [4.64ms]
(pass) bundler > browser/EntryPointDisabledByBrowserFieldNextToLiveEntryPoint [4.10ms]
(pass) bundler > browser/EntryPointDisabledByBrowserFieldOnlyAppliesToBrowserTarget [11.70ms]
(pass) bundler > browser/EntryPointDisabledByPackageMainBrowserField [4.66ms]
1114 |                 errorsLeft.splice(i, 1)
... (truncated)
passes on PR (with fix)
ASAN with fix: 2 skipped
$ BUN_DEBUG_QUIET_LOGS=1 bun scripts/build.ts --profile=debug --quiet test "--reporter=junit" "--reporter-outfile=/tmp/pr_gate.xml" test/bundler/bundler_browser.test.ts
bun test v1.4.1 (a6c4cc276)

test/bundler/bundler_browser.test.ts:
(pass) bundler > browser/NodeBuffer#21522 [1435.64ms]
(pass) bundler > browser/NodeBuffer#12272 [691.74ms]
(pass) bundler > browser/NodeFS [355.71ms]
(pass) bundler > browser/NodeTTY [429.81ms]
(pass) bundler > browser/NodeEventsOnce [500.24ms]
(pass) bundler > browser/NodeUrlProtocolTablesIgnorePrototype [456.51ms]
(pass) bundler > browser/NodePolyfills [7429.11ms]
(todo) bundler > browser/NodePolyfillExternal
(pass) bundler > browser/BrowserFieldDisablesPolyfilledBuiltin#4928 [399.60ms]
(pass) bundler > browser/BrowserFieldDisablesPolyfilledBuiltinNodePrefix#4928 [466.64ms]
(pass) bundler > browser/BrowserFieldRemapsPolyfilledBuiltin#4928 [380.59ms]
(pass) bundler > browser/BrowserFieldDisabledBuiltinStillPolyfillsOutsideScope [465.07ms]
(pass) bundler > browser/EntryPointDisabledByBrowserField [252.26ms]
(pass) bundler > browser/EntryPointDisabledByBrowserFieldNextToLiveEntryPoint [222.80ms]
(pass) bundler > browser/EntryPointDisab
... (truncated)

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

23 deps, 131 codegen, 1172 objects in 646ms

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

Checked 26 installs across 63 packages (no changes) [5.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 (a6c4cc276)

Checked 1 install across 2 packages (no changes) [1.00ms]
[5/1244] fetch libjpeg-turbo
[libjpeg-turbo] up to date
[6/1217] gen .bind.ts → GeneratedBindings.cpp
[7/1217] install /workspace/bun/src/node-fallbacks
bun install v1.4.1-canary.1 (a6c4cc276)

Checked 111 installs across 104 packages (no changes) [7.00ms]
[8/1217] fetch tinycc
[tinycc] up to date
[9/1216] fetch zlib
[zlib] up to date
[10/1216] gen bake.{client,server,error}.js
-> bake.client.js, bake.server.js, bake.error.js
[11/1216] gen ProcessBindingConstants.lut.h
Generating /workspace/bun/build/release/codegen/Proce
... (truncated)
diff hotspot
src/resolver/package_json.rs         |  6 +++-
 src/resolver/resolver.rs             | 28 +++++++++------
 test/bundler/bundler_browser.test.ts | 70 ++++++++++++++++++++++++++++++++++++
 3 files changed, 93 insertions(+), 11 deletions(-)

gate history · 1 passed · 0 rejected · iteration 0

evidence per changed file
file                                  reads  edits  tests
src/resolver/package_json.rs              1      1     22
src/resolver/resolver.rs                 20      4     22
test/bundler/bundler_browser.test.ts      3      4     22

…form

The browser map keys were normalized with the native separator when the
package.json was parsed, so on Windows a key like "pkg/sub" was stored
as pkg\sub. The lookup for a package path uses the import specifier as
written, with forward slashes, so a key with a slash never matched on
Windows. Store the keys with forward slashes and compare a path inside
the package in the same form, as esbuild does.
@coderabbitai

coderabbitai Bot commented Sep 2, 2026 •

Copy link
Copy Markdown
Contributor

Review Change Stack

No actionable comments were generated in the recent review. 🎉

ℹ️ Recent review info
⚙️ Run configuration

Configuration used: Path: .coderabbit.yaml

Review profile: ASSERTIVE

Plan: Essentials

Run ID: b79a6a6a-6bc1-42e7-bac7-b12916d1d523

📥 Commits

Reviewing files that changed from the base of the PR and between 19620da and f3178ae.

📒 Files selected for processing (2)
  • src/resolver/package_json.rs
  • src/resolver/resolver.rs
💤 Files with no reviewable changes (1)
  • src/resolver/package_json.rs

Included review availability: Your plan provides up to 10 included reviews per hour; 1 remains after this review.


Walkthrough

Browser-field path handling now uses POSIX normalization for consistent slash matching. Resolver comparisons and index probing use POSIX semantics. Bundler tests cover disabled and redirected package subpath mappings.

Changes

Browser-map path handling

Layer / File(s) Summary
Resolver path normalization
src/resolver/package_json.rs, src/resolver/resolver.rs
Browser mapping keys, absolute paths, and directory index probes now use POSIX path semantics. Package-path comparisons retain the original spelling.
Browser mapping validation
test/bundler/bundler_browser.test.ts
Browser-target tests verify disabled mappings for package and scoped package subpaths, plus redirection to a local shim. The tests confirm that original implementations are excluded from the bundle.

Suggested reviewers: jarred-sumner, dylan-conway

Merge Risk: ⚪ Minimal · up to f3178

This localized resolver change makes browser-map keys match consistently across platforms and is merge-ready after normal checks and review; no actionable merge-blocking risk remains.

🚥 Pre-merge checks | ✅ 4
✅ Passed checks (4 passed)
Check name Status Explanation
Title check ✅ Passed The title clearly and concisely describes the primary change: browser map keys now use forward slashes on every platform.
Description check ✅ Passed The description explains the problem, fix, behavior, background, and verification results. It does not use the exact template headings, but it fully covers both required topics.
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.

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

@github-actions github-actions Bot added the claude label Sep 2, 2026
@robobun

robobun commented Sep 2, 2026 •

Copy link
Copy Markdown
Collaborator Author
Updated 5:34 AM PT - Sep 2nd, 2026

✅ @robobun, your commit f3178ae164b960dd39520c123328494e2ff003ad passed in Build #109373! 🎉


🧪   To try this PR locally:

bunx bun-pr 41183

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

bun-41183 --bun

@robobun

robobun commented Sep 2, 2026 •

Copy link
Copy Markdown
Collaborator Author

Status

Reproduced on Windows (x64, bun 1.4.1) with bun build --target=browser and a root package.json of {"browser": {"pkg/sub": false}}: the import pkg/sub bundles node_modules/pkg/sub.js. Linux emits an empty module for the same input. {"@scope/pkg": false} and {"pkg/sub": "./shim.js"} fail the same way. A key without a slash works on both.

The two new tests fail on Windows with bun 1.4.1 and with a debug build that has src/resolver/ from origin/main. They pass with this branch on Windows and on Linux. The fix has no effect on POSIX, so the Linux proof cannot show a failure before the fix. The Windows runs above are the proof.

CI (build 109366): test/bundler/bundler_browser.test.ts passed on Windows 2019 x64 (job) and Windows 11 aarch64 (job), 25 pass and 0 fail on each. The one red lane is test/cli/install/bun-patch.test.ts (the 1024-byte label case) on Windows x64. It fails on main too (build 109318) and does not touch this diff. Reported separately.

Self-review: 1 concern on the first version (a map.get(cleaned) fallback, the input cleaning esbuild removed in 0.11.16). Addressed by switching to esbuild's shape: forward-slash keys on every platform. The review also prefers to wait for #40832, which reworks the browser field and is not mergeable right now. This PR is the small fix for the released behavior. A maintainer can pick either.

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

I reviewed this and found no bugs. The resolver is a hot shared path and the observable fix is Windows-only (the new tests pass on Linux with or without the change), so a human confirming the Windows CI lane is worthwhile.

What was reviewed

  • Traced normalize_string_buf::<_, Posix, _> → normalize_string_loose_buf_t, which uses is_sep_any_t on input and writes / — so the AbsolutePath case on Windows (foo\bar.js after the abs_path strip) still normalizes to foo/bar.js and matches the now-/-keyed map.
  • POSIX behavior is byte-identical: platform::Auto aliases Posix on unix, fs.normalize_buf was already <false, Auto, false>, and SEP/SEP_STR/Platform::AUTO were already / there.
  • The new input_path = cleaned rebinding for AbsolutePath is a no-op on POSIX (resolved abs paths have no ./.. after the prefix strip); on Windows it's what makes the \→/ conversion reach the map.get probe.
  • check_path's index-probe inputs are all forward-slash after this change (raw specifier, cleaned, or ./+either), so trimming only / and joining with Platform::Posix is consistent.
Extended reasoning...

Overview

The PR fixes package.json browser field remapping on Windows for keys containing a slash ("pkg/sub", "@ scope/pkg"). Three files change: src/resolver/package_json.rs swaps key normalization from platform::Auto to platform::Posix; src/resolver/resolver.rs swaps the lookup-side normalization to Posix, rebinds input_path to the cleaned form for the AbsolutePath kind, and switches the <path>/index probe from native SEP/SEP_STR/Platform::AUTO to hardcoded / and Platform::Posix; test/bundler/bundler_browser.test.ts gains two itBundled cases covering disable and remap of a slash-containing key.

Security risks

None. This is a build-time module resolution comparison — no untrusted input crosses a trust boundary, no allocation sizing changes, no unsafe blocks are added. The existing unsafe { Interned::assume } sites in check_path are unchanged.

Level of scrutiny

Medium-high. The resolver runs for every import in every bundle, so a mistake here is high blast radius. I read normalize_string_buf_t and confirmed the non-obvious fact this change hinges on: Platform::Posix in that function dispatches to normalize_string_loose_buf_t, which recognizes both separators on input (is_sep_any_t) and emits /. That is what makes the AbsolutePath case still work on Windows after the key side switches to /. I also verified that on POSIX every swapped constant/type resolves to the same value it did before (Auto == Posix, SEP == '/', Platform::AUTO == Posix), so unix behavior is byte-for-byte unchanged. The join_string_buf call goes through the file-local value-dispatch shim (line 59 mod bun_paths), so only the argument value changed, not the call shape.

Other factors

The tests are in the right file, use itBundled, are hermetic, and assert both runtime stdout and bundle contents. Per the PR description they pass on Linux with and without the fix — expected, since the bug is Windows-only — which means the fail-before proof lives only in the Windows CI lane. That, plus the resolver's criticality, is the reason to defer rather than approve outright: someone should eyeball the Windows job for bundler_browser.test.ts and the sibling suites the description lists (esbuild/packagejson, bundler_edgecase, bun/resolve). The comments added are on the informative side but each answers a "why Posix here?" question a future reader would otherwise have to trace through resolve_path.rs.

Comment thread src/resolver/package_json.rs Resolved
Comment thread src/resolver/resolver.rs Resolved
Comment thread src/resolver/resolver.rs Resolved
Comment thread src/resolver/package_json.rs Resolved
Comment thread src/resolver/resolver.rs Resolved

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

Code review found no issues

No high-confidence issues detected in this change.

robobun added a commit that referenced this pull request Oct 28, 2026
Resolver:
- Restore the `package_json` condition of the lookup after resolution.
  Remove the chain marks and the tests that belong to #46985.
- A package candidate takes a key in this order: a key for the path as the
  import spells it, the key of the file that loads, the lookups of a
  directory, then a key with an implicit extension for a file that does not
  exist. A key for `x.mjs` no longer applies to a `require` that loads `x.js`.
- One constructor for a disabled path (`disabled_path`): no trailing
  separator, and one interned copy of each distinct path. The `main` and
  index lookups use it too.
- A live `main` stands in for a disabled `module` entry where the pair is
  built, so `finalize_result` sees the path that loads.
- A remap cycle stops at the entry that is already being resolved.
- The `esm_subpath` scratch is on the frame of `load_node_modules`.

Bundler:
- The empty module of a disabled file has a typed mark,
  `Path.is_disabled_module`. `PathMap` keeps marked paths in a second table,
  for the module graph and for the queue of each importer. One input gives
  one bundle.
- `export *` from a disabled file gives no name. An import of such a name is
  `undefined` with a warning, and not a build error.

Tests:
- A table of 564 layouts (`test/bundler/browser-field-matrix.ts`) with a
  snapshot, and `scripts/browser-field-matrix.ts` to compare it with esbuild.
- Cases for each decision above, and the two cases of #41183.

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