Repository navigation
Conversation
… extension resolution
import 'demo-pkg/no-ext' where demo-pkg/package.json has
browser: {"./no-ext": "./no-ext-browser.js"} was resolving to
no-ext.js instead of no-ext-browser.js under --target=browser.
load_node_modules went straight from the exports-map check to
load_as_file_or_directory, so the browser map was only consulted
after the extension had been appended. esbuild's tryToResolvePackage
runs checkBrowserMap(pkgDirInfo, absPath, absolutePathKind) between
those two steps; add the equivalent check.
The new check may recurse back into load_node_modules when the remap
target is a bare package name, which overwrites the thread-local
node_modules_check and esm_subpath buffers. Recompute both from
stable inputs before falling through on a failed remap resolve.
Un-todos packagejson/BrowserNodeModulesNoExt and
packagejson/BrowserNodeModulesIndexNoExt, and adds coverage for the
disabled (false) and package-path remap cases.
WalkthroughChangesBrowser-map package resolution now supports bounded recursive remapping, disabled targets, failed-remap retries, and state restoration. Package JSON tests cover extensionless paths, index resolution, cycles, package remaps, disabled subpaths, and Browser-map resolution
Suggested reviewers: 🚥 Pre-merge checks | ✅ 4✅ Passed checks (4 passed)
Comment |
|
Updated 3:05 AM PT - Aug 1st, 2026
❌ @robobun, your commit b2d25a9 has 3 failures in
🧪 To try this PR locally: bunx bun-pr 36642That installs a local version of the PR into your bun-36642 --bun |
There was a problem hiding this comment.
No bugs found, but this adds ~85 lines of new control flow to load_node_modules with manual bufs! slot recomputation after recursion — resolver-critical enough that a human familiar with the buffer-lifetime conventions here should sign off.
What was reviewed:
- Placement mirrors esbuild's
tryToResolvePackage(browser-map check between exports andloadAsFileOrDirectory); disabled/remap/fallthrough branches match. bufs!(node_modules_check)/bufs!(esm_subpath)are only re-entered via the package-path branch ofresolve_without_remapping, so gating the recompute onremap_is_package_pathcovers the clobber; the relative branch usesbufs!(resolve_without_remapping).Package<'a>isCopy, so switchingif let Some(ref esm)→if let Some(esm)doesn't move out ofesm_across loop iterations.
Extended reasoning...
Overview
Adds a "browser" map check inside load_node_modules (src/resolver/resolver.rs) between the exports handling and the fallthrough to load_as_file_or_directory, so a bare subpath import like demo-pkg/no-ext matches a "./no-ext" browser-map key before extension resolution appends .js. Un-todos two ported esbuild tests and adds two new ones (false remap and remap-to-package) in test/bundler/esbuild/packagejson.test.ts.
Security risks
None. This is bundler/resolver path selection driven by a package's own package.json; no new untrusted-input surface, no filesystem writes, no privilege boundaries crossed.
Level of scrutiny
High. Module resolution is on the critical path for both bun build and the runtime — a wrong match here silently loads the wrong file for every user. The change also touches the thread-local bufs! scratch-buffer scheme (slices laundered to &'static), where the recursion into resolve_without_remapping can overwrite the backing storage of abs_path and esm_.subpath. The author handles this by recomputing both from stable inputs (dir_info.abs_path, import_path) when the remap target is itself a package path, and I verified that only the package-path branch of resolve_without_remapping re-enters load_node_modules (the relative branch writes to a distinct bufs!(resolve_without_remapping) slot). check_browser_map returns &'static [u8] so remap itself survives the recursion, and remap_is_package_path is captured before the call.
Other factors
The change is a faithful port of the corresponding esbuild block (cited in the PR body), and the four tests cover the happy path, the disabled path, remap-to-package, and remap-to-missing-package fallthrough. The comment-cop bot flagged verbose comments three times; all were shortened and the threads are resolved. Package<'a> is #[derive(Copy)], so the ref → by-value change in the if let is sound across the outer loop. Given the subtlety of the buffer-clobber handling and that this is core resolver logic, I'm deferring rather than approving.
… tests
A browser map like {"./x":"pkg-a/x"} sends resolve_without_remapping
back into load_node_modules with no bound. Cap browser_remap_depth at 32
so a cyclic remap falls through to the clean resolve error instead of
overflowing the stack. Add a regression test for the cycle and a test
that pins exports precedence over the browser map for subpath imports.
There was a problem hiding this comment.
No issues found, but this adds ~90 lines of new control flow to load_node_modules — the resolver's hot path — including a recursion-depth guard and recomputation of thread-local scratch buffers that a recursive resolve_without_remapping clobbers. That's subtle enough to warrant a human look.
What was reviewed
- Placement matches esbuild's
tryToResolvePackage(browser-map check between exports-map andload_as_file_or_directory). remapis&'static [u8]fromcheck_browser_map, so it survives the recursion; onlyabs_path/esm_need recomputing, and only on the package-path branch (relative remaps go throughbufs!(resolve_without_remapping), not the two clobbered slots).Package<'a>isCopy, so theSome(ref esm)→Some(esm)change is sound and required for the later reassignment.- Cycle guard, disabled (
false) remap, exports-precedence, and fall-through-on-missing-package are each covered by a dedicated test.
Extended reasoning...
Overview
Adds a browser-map lookup in load_node_modules (src/resolver/resolver.rs:2820-2903) between the exports-map check and the load_as_file_or_directory fallback, mirroring esbuild's tryToResolvePackage. This lets "browser": {"./no-ext": ...} match a bare demo-pkg/no-ext import before extension resolution appends .js. Also adds a browser_remap_depth: u8 field on Resolver to bound recursion when a remap target is itself a package path, and un-todos two esbuild-ported tests plus adds four new ones (disabled subpath, remap-to-package, self-cycle, exports-shadows-browser).
Security risks
None. This is module-resolution ordering; no untrusted input parsing, auth, or filesystem-escape surface is introduced.
Level of scrutiny
High. load_node_modules is the core of both the bundler's and the runtime's module resolution. The change is behaviorally correct against esbuild (per the PR's verification table), but the implementation leans on non-obvious invariants: abs_path and esm_.subpath borrow from thread-local bufs! slots that a recursive load_node_modules call overwrites, so they're recomputed after a failed package-path remap. I verified those two slots are only written inside load_node_modules (grep confirms), so gating the recompute on remap_is_package_path is sufficient — the relative-remap branch of resolve_without_remapping uses a separate resolve_without_remapping slot and load_as_file_or_directory doesn't touch either. Still, this is exactly the class of change where a maintainer familiar with the resolver's buffer discipline should sign off.
Other factors
The comment-cop bot flagged the buffer-recompute comment three times; the author reduced it to a single line, which now reads as load-bearing rather than a workaround justification. Package<'a> is #[derive(Copy)] so moving from Some(ref esm) to Some(esm) is a copy, not a move — needed because esm_ is now reassigned. The 32-deep recursion limit is arbitrary but consistent with similar guards elsewhere; the cycle test exercises it. Test coverage is good and each row of the verification table maps to an assertion.
There was a problem hiding this comment.
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/resolver/resolver.rs`:
- Around line 2819-2903: Replace the literal browser remap depth bound in the
resolver block guarded by care_about_browser_field with a named constant set to
32. Define the constant near the related resolver constants and add a brief
comment documenting that it prevents cyclic browser-field remaps; use it in the
browser_remap_depth comparison without changing the recursion behavior.
🪄 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: 06926954-ef51-4dff-890d-eeaaf62c6d68
📒 Files selected for processing (2)
src/resolver/resolver.rstest/bundler/esbuild/packagejson.test.ts
There was a problem hiding this comment.
No bugs found, but this adds ~90 lines of new control flow (including bounded recursion and manual thread-local buffer restoration) inside load_node_modules, which is a hot and subtle resolver path — worth a human pass on the buffer-reuse invariants.
What was reviewed:
- Placement matches esbuild's
tryToResolvePackage: browser-map check sits between the exports-map early return andload_as_file_or_directory, soexportsstill wins (covered byExportsShadowsBrowserSubpath). - Buffer recompute is correctly gated on
remap_is_package_path: the relative-remap branch ofresolve_without_remappingwrites tobufs!(resolve_without_remapping), notnode_modules_check/esm_subpath, soabs_pathstays valid for the fall-through when the remap is relative. - Recursion depth counter (
browser_remap_depth < 32) prevents the self-cycle stack overflow; testSubpathToPackageCyclepins the error path. - All new early returns restore
extension_orderand decrement the debug indent, matching sibling exit sites.
Extended reasoning...
Overview
The PR inserts a check_browser_map call into load_node_modules between the package exports handling and the load_as_file_or_directory fallback, mirroring esbuild's tryToResolvePackage. It handles three remap outcomes: false (disabled → return a is_disabled path), a resolvable target (return via resolve_without_remapping), and an unresolvable target (fall through to normal resolution). Because a bare-package remap target re-enters load_node_modules and overwrites the node_modules_check / esm_subpath thread-local buffers, the fall-through path recomputes abs_path and esm_ from stable inputs. A new browser_remap_depth: u8 field bounds the recursion at 32. Two .todo esbuild-ported tests are un-skipped and four new tests are added covering the disabled, remap-to-package, cycle, and exports-precedence cases.
Security risks
None identified. This is bundler-time module resolution; the browser map is package-author-controlled metadata that already flows through check_browser_map at other call sites. The new recursion is bounded so a malicious cyclic remap cannot overflow the stack (the released bun already errored here; esbuild is the one that crashes).
Level of scrutiny
High. load_node_modules is on the critical path for every bare import in every bundle, and the function relies on a set of shared thread-local scratch buffers (bufs!(...)) with implicit non-overlap invariants. The change deliberately introduces a recursive edge that violates one of those invariants and then compensates by recomputing the clobbered slots. I traced the buffer usage: the recompute is correctly gated on remap_is_package_path because only the package-path branch of resolve_without_remapping re-enters load_node_modules; the relative branch writes to a distinct bufs!(resolve_without_remapping) slot. The is_self_reference case correctly skips the abs_path recompute since it points at arena-stable dir_info.abs_path rather than a buffer. Still, this is exactly the kind of invariant a maintainer who owns the resolver should sign off on — the "clobbered buffers" pattern is fragile enough that a future refactor of resolve_without_remapping could silently break it.
Other factors
Test coverage is good: the un-todo'd tests are direct esbuild ports, and the new tests cover each row of the verification table including the negative regression guards. The comment-cop bot's complaints about long comments were addressed in follow-up commits. The .expect("oom") on dirname_store.append_slice matches existing convention in the file for that arena. I'm deferring rather than approving because the buffer-restoration workaround, while it reads correct, is the kind of subtle cross-function invariant that benefits from a second pair of eyes from someone who maintains this file.
|
The resolver change itself is green: Remaining red lanes are unrelated to this diff (resolver touches nothing in these areas):
Ready for review. |
|
#40832 includes this lookup (browser map checked before the file lookup in |
#36642 and #37532 each fix one part of the "browser" field handling that this branch reworks. Their tests are added here so that closing those PRs loses no coverage: - packagejson/BrowserNodeModulesSubpathDisabled, SubpathToPackage, SubpathToPackageCycle and ExportsShadowsBrowserSubpath (#36642): a package subpath import whose map entry is false, a bare package, a cycle, or shadowed by "exports". - "browser map lookup of a bare specifier that does not fit a path buffer" in test/js/bun/resolve/resolve.test.ts (#37532): a specifier longer than a path buffer must fail to resolve, not abort the build. On main, four of the six packagejson cases and four of the five resolve.test.ts cases fail. All pass with this branch.
|
Superseded by #40832, which reworks the The tests from this PR now live on that branch (bdb312e2b6): Closing in favor of that PR. |
What
import 'demo-pkg/no-ext'wheredemo-pkg/package.jsonhas"browser": {"./no-ext": "./no-ext-browser.js"}was resolving tono-ext.js(the Node file) instead ofno-ext-browser.jsunder--target=browser.Why
load_node_moduleswent straight from the exports-map check toload_as_file_or_directory(abs_path), so the browser map was only consulted by the post-resolution check incheck_package_path, at which point the path already has.jsappended and a"./no-ext"key can no longer match.esbuild's
tryToResolvePackagerunscheckBrowserMap(pkgDirInfo, absPath, absolutePathKind)between those two steps (resolver.go):This change adds the equivalent check at the same position.
The remap target can itself be a bare package name (
"./sub": "other-pkg"), which makesresolve_without_remappingrecurse intoload_node_modules. That recursion overwrites the thread-localnode_modules_checkandesm_subpathbuffers thatabs_pathandesm_.subpathborrow from, so both are recomputed from stable inputs before falling through when the remap resolve fails. The recursion is also bounded via abrowser_remap_depthcounter so a cyclic remap like{"./x": "pkg-a/x"}falls through to the normal resolve error instead of overflowing the stack (esbuild is unbounded here and stack-overflows on the same input; released bun errors cleanly because it never takes this edge).#36620 fixed the adjacent
load_from_main_field/load_as_index_with_browser_remappingcases (wrong argument to an existing check); this is the follow-up it names for bare subpath imports, where the check was missing entirely.Verification
All outputs match esbuild (except the cycle case, which esbuild crashes on):
demo-pkg/no-ext"./no-ext": "./no-ext-browser.js"demo-pkg/no-ext.jsdemo-pkg/no-ext(dir)"./no-ext": "./no-ext-browser/index.js"demo-pkg/disabled"./disabled": falsedemo-pkg/to-pkg"./to-pkg": "other-pkg"demo-pkg/to-missing"./to-missing": "missing-pkg"pkg-a/x"./x": "pkg-a/x"(cycle)demo-pkg/subexports+browserboth setUn-todos
packagejson/BrowserNodeModulesNoExtandpackagejson/BrowserNodeModulesIndexNoExt, and addsSubpathDisabled,SubpathToPackage,SubpathToPackageCycle, andExportsShadowsBrowserSubpath. The first four fail on the released build and pass with this change; the last two are regression guards that pass on both. The rest ofpackagejson.test.tsis unchanged (84 pass, 7 todo).