Repository navigation
Conversation
A module disabled with `"browser": {"./x": false}` printed as `(() => ({}))`,
so `require("x")` returned a function. A disabled import is now an empty
CommonJS module, `(disabled):<path>`, so `require` returns `{}` and a star or
default import gets a namespace object. A named import warns that it is
always undefined.
The resolver now checks a package's "browser" map for a subpath import
(`require("pkg/foo/bar")`) before the file lookup, against the extensionless
path, so `"./foo/bar"` matches whether or not `foo/bar.js` exists. Keys are
kept as written instead of having `./` stripped. The Browserify bare-name
rule (`"./sub"` matching `require("sub")`) is checked relative to the
importer's directory inside the package and without implicit extensions.
Externals are checked before the browser polyfills, so `--external=fs`
keeps `fs` external in a browser build.
The post-resolution browser-map check in `check_package_path` is removed.
It replaced a disabled result with an empty path ("File not found").
Tests port esbuild's browser-field end-to-end matrix and enable the six
browser tests that were marked todo.
|
Note Reviews pausedIt looks like this branch is under active development. To avoid overwhelming you with review comments due to an influx of new commits, CodeRabbit has automatically paused this review. You can configure this behavior by changing the Use the following commands to manage reviews:
Use the checkboxes below for quick actions:
No actionable comments were generated in the recent review.
|
| Layer / File(s) | Summary |
|---|---|
Browser-map resolution and resolver state src/resolver/... |
Browser-map lookup now supports disabled results, package-subpath remaps, extension and index probes, fallback resolution, and bounded remap resolution. The resolver also changes external-package matching order and preserves browser-map key spelling. |
Disabled-module bundling and output src/bundler/..., src/js_printer/lib.rs |
Disabled paths use distinct source keys and pretty paths. Parsing uses empty JavaScript content. Linking applies CommonJS handling, and printing emits ({}). |
Browser-map regression coverage test/bundler/..., test/js/bun/resolve/resolve.test.ts, test/bake/dev/bundle.test.ts |
Tests cover browser-map path forms, package boundaries, disabled modules, external imports, plugin behavior, long specifiers, and updated object-stub output. |
Suggested reviewers: jarred-sumner, alii
Priority:
Merge Risk: 
Moderate · up to 76bd6
-
Code-split dynamic imports of disabled modules can fail at runtime because their namespace lacks the expected default object; this should be fixed before merging.
Pre-merge checks: 
4
-
Full details: Description check
-
Explanation
The description follows the template with both required sections, 'Problem/Fix/Background' content describing what the PR does, and a 'Verified' and test section covering how the change was verified. It is detailed and specific, covering the affected files, behavior changes, measured performance impact, and known downsides. The 'How did you verify your code works?' content is present: it names the test files run, failing-before/passing-after counts, real-package builds, and measurements. Minor gaps: the 'What does this PR do?' section is not labeled with that exact heading (it uses 'Problem', 'Fix', 'Background'), and the verification is partly deferred to CI for two platform-specific test files, which the robobun evidence block flags. These are non-critical, so the description is mostly complete.
-
Finishing Touches: 💡 1
-
🛠️ Fix failing CI checks: 💡
-
- Commit to this branch
- Create a new PR
-
- 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.
Comment @coderabbitai help to get the list of available commands.
|
Status: ready for review at 32804a4, merged with main at 11c3671. The open decisions for a maintainer are in #40832 (comment). Reproduced on main with the cases in the PR body: This revision keeps main's browser map lookup after resolution. A table of 564 browser-field layouts is now a test ( 59 of the 104 new or enabled test cases fail without the CI on 32804a4 (build 134535): 184 jobs passed when this was written, and 4 were still queued. The 7 failed jobs are all |
There was a problem hiding this comment.
Additional findings (outside the current diff — GitHub can't attach inline comments there):
-
🟡
src/bundler/bundle_v2.rs—run_resolver(the plugin-onResolvefallback path) still marks a browser-disabled import as an externalis_disabledrecord instead of materializing the empty CJS module the siblingresolve_import_recordspath now createsExtended reasoning...
resolve_import_records(line 6658-6864) was updated so aresolve_result.path() == Nonebrowser-disabled import becomes an empty CommonJS module keyed by(disabled):<path>, givingimport def from xa{}default via__toESMand everyrequire(x)the same shared object. The structurally identical fallback inrun_resolverat 2575-2587 — reached whenever anonResolveplugin's filter matched the specifier and the plugin returnedundefined(ResolveValue::NoMatch→on_resolveline 4835) — still just setsrecord.path.is_disabled = trueand returns, so the record hassource_index = INVALIDand no(disabled):pretty. The printer then emits the inline({})fromprint_disabled_import:import def from './node.js'printsvar {default: def} = ({})(def isundefined),import * as nsprintsvar ns = ({})(no__toESM, nodefault), and eachrequire()call site gets a fresh{}. So a browser build with any plugin whoseonResolvefilter matches the disabled path (e.g.filter: /.*/) yields different semantics for the same import than a build without a…Verification: nit — the sibling
Nonearm inrun_resolverwas not updated to match the new empty-CJS-module handling. Atsrc/bundler/bundle_v2.rs:2575-2587:rust let mut path: Fs::Path<'static> = match resolve_result.path() { Some(p) => *p, None => { let record: &mut ImportRecord = ...; record.path.is_disabled = true; return; } };This is reached from… | normal —… -
🟡
src/resolver/resolver.rs— Browser-map subpath check added to only one of two parallelload_node_modulesblocks; the auto-install/global-cache path is missing itExtended reasoning...
The new
load_package_subpath_from_browser_mapcall was inserted at line ~2734 in the regular node_modules-walk block, but the structurally identical auto-install/global-cache block (dir_info_for_resolution(.., resolved_package_id)at ~3097, which reachesload_as_file_or_directory(abs_path, ..)at ~3235) was left unchanged. Whenbun build --target=browserresolves a package through the global cache / auto-install path,require("pkg/foo/bar")with"browser": {"./foo/bar": "./file"}still falls through to the file lookup and either bundles the Node file or fails withCould not resolve— the exact behavior this PR fixes for the normal path but not for its sibling. REVIEW.md requires fixing the whole class (parallel copy-pasted blocks) in the same PR; the fix should callload_package_subpath_from_browser_map(pkg_dir_info, abs_path, ..)beforeload_as_file_or_directoryin both blocks.Verification: nit — The structural asymmetry is real. The regular node_modules-walk block gained the new pre-lookup browser-map check at src/resolver/resolver.rs:2733-2750 (
if self.care_about_browser_field && self.load_package_subpath_from_browser_map(pkg_dir_info, abs_path, ...).is_success()) immediately beforeload_as_file_or_directory(abs_path, ..)at :2755. The parallel auto-install/global-cache block…
Resolving a "browser" remap target that is a bare package re-enters load_node_modules, which reuses the threadlocal scratch buffers the outer call's abs_path and esm.subpath live in. The helper now copies abs_path before the re-entrant lookup and runs esbuild's fallback to the file itself on that copy. The caller skips its own file lookup afterwards. The ESM subpath moves to a stack buffer. The same browser-map check now also runs in the auto-install lookup, and the onResolve plugin fallback path (run_resolver) creates the empty module for a disabled import like resolve_import_records does. ParseTask::init owns the empty-contents rule for disabled paths. The "(disabled):" pretty path is built with an exact-size allocation instead of a fixed path buffer.
There was a problem hiding this comment.
I reviewed the merged version (d587c94) and did not find any bugs, but with the design change in this push a human look is still worthwhile.
What was reviewed:
- The re-added post-resolution browser-map pass in
check_package_path(src/resolver/resolver.rs) with the newbrowser_remappedgate andrelative_remap_scopeexclusion; tracedbrowser_remappedthroughload_package_file_with_browser_map,load_from_main_field,load_as_indexand the auto-main path, and checked the removedis_disabled && secondary.is_none()early return is covered by the new!primary.is_disabledguard. - Scratch-buffer lifetime across the re-entrant remap lookup:
abs_pathis copied into a pooled buffer beforeresolve_without_remapping, the 512-byteesm_subpathscratch is saved/restored, and neitherload_node_modulescall site readsabs_pathafter the call. - The bundler side (
select_import_module,PathToSourceIndexMapdisabled map,ParseTask::initfd/contents selection,scanImportsAndExportsCjs wrap for disabled targets). - One mismatch between description and code: this push drops the previous tip's "externals before polyfills" check (
user_external) and itsbrowser/ExternalWinsOverBuiltinPolyfillAndStubtest, so--external=fswith--target=browseris back to the pre-PR stub behavior while the description still says it stays external. Not a regression against main, but the description and the deleted test deserve a stated reason.
Extended reasoning...
The change touches the resolver's "browser" field handling (src/resolver/resolver.rs, ~575 lines), the bundler's module keying and parse-task setup for disabled modules (bundle_v2.rs, ParseTask.rs, PathToSourceIndexMap.rs, LinkerContext.rs, scanImportsAndExports.rs), plus ~1700 lines of tests; it touches no auth, crypto, or injection surface. The latest commit is a merge of main that also reverses an earlier design decision (keeps the post-resolution browser-map pass, gated by a new browser_remapped flag) and silently drops the --external reorder and its test, so the PR description is now stale on that point. The size, the re-entrant threadlocal-scratch resolver paths, and the twelfth round of changes make this a change a human maintainer should still read rather than one safe to auto-approve.
|
@dylan-conway this revision needs one decision from you. Your brief asked for esbuild's behaviour. esbuild does not apply the So this revision ports esbuild's lookup and empty module, and keeps main's lookup after resolution for a file that no map lookup picked. If you want esbuild's rule there, say so and I will remove that lookup and list the packages under Downsides. |
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.
|
@dylan-conway the head is now e5a4d14. It replaces the earlier revision, and the description is rewritten for it. Five decisions need a maintainer. Each item gives the current choice and the alternative.
Two notes on other open PRs. #46985 and #47034 edit the same resolver lines, so the second one to land needs a rebase. The typed mark on |
There was a problem hiding this comment.
Nothing blocking. The comments below are optional suggestions. There is no need to push a fix for them before merging.
Beyond the inline finding, I also checked the new thread_local! DISABLED_PATHS in src/resolver/resolver.rs (around line 7307) against the no-thread-local rule: it is a dedup-only intern cache whose keys point into the process-global DirnameStore, so a miss on another thread only appends a second copy of the same path, never a wrong or stale Path.
Extended reasoning...
The re-cut touches the resolver's browser-map lookup, the bundler's disabled-module keying, and ~2,500 lines of tests; it touches no auth, crypto or injection surface. One pre-existing finding is posted inline; this note records the thread-local intern map examined and ruled out this run.
The AOT suite (test/aot/minifying-first-saves-no-code) draws every itBundled input of esbuild/extra.test.ts and builds it for another target. A fixture with a "browser" map is another program there: the file that the map disables is loaded, and the build fails. The 41 cases are now in esbuild/packagejson.test.ts, which that suite does not draw. extra.test.ts is as on main. Two kinds of test in bundler_browser.test.ts that are not itBundled (`bun build --check`, and the table of layouts) get more time in a local debug build, as itBundled gives its own tests.
| let path = &mut result.path_pair.primary; | ||
| // Bun's stubs for Node.js builtins are in the `node` namespace and stay external. | ||
| let is_javascript_import = importer.is_javascript_like() | ||
| && matches!( | ||
| kind, | ||
| ImportKind::Stmt | ImportKind::Require | ImportKind::Dynamic | ||
| ); | ||
| if self.dev_server.is_some() || !is_javascript_import || !path.is_file() { |
There was a problem hiding this comment.
🔴 pre-existing, widened: DevServer users whose app imports a package subpath that the package's "browser" map disables now get a runtime ReferenceError where the base served the file. select_import_module at src/bundler/bundle_v2.rs:6481 returns None for every dev_server build, so the record becomes an is_disabled external and convertStmtsForChunkForDevServer.rs:81 drops the whole import statement without declaring its bindings. Fix: in the dev server give a disabled JavaScript import the same empty module (or declare its namespace as {} like the IS_UNUSED branch does) so named and default bindings exist, rather than dropping the statement. The PR notes this and points to #46579, but nothing in this branch prevents it.
Why this was flagged
Trigger: a Bun DevServer app has import { x } from "@ s/pkg/node" and node_modules/@ s/pkg/package.json contains "browser": {"./node.js": false}. On the base branch the scoped package never received the post-resolution browser lookup, so node.js was bundled and x was defined. With this branch load_package_file_with_browser_map (src/resolver/resolver.rs:5440-5453) remaps the loaded file through remap_loaded_file and returns a disabled primary. In resolve_import_records, select_import_module at src/bundler/bundle_v2.rs:6481 returns None because self.dev_server.is_some(), so src/bundler/bundle_v2.rs:7194-7197 sets import_record.path.is_disabled = true. convert_stmts_for_chunk_for_dev_server then continues on the disabled record at src/bundler/linker_context/convertStmtsForChunkForDevServer.rs:81-83, so no namespace var is declared for the import and the module body throws ReferenceError when it reads x. The same drop already hit paths the base disabled, so the mechanism is pre-existing; this diff widens the population to scoped packages, ./foo/bar keys without extension, and NODE_PATH packages.
Verification: select_import_module at src/bundler/bundle_v2.rs:6481 if self.dev_server.is_some() || !is_javascript_import || !path.is_file() { return None; }, and the caller at 7194-7198 sets import_record.path.is_disabled = true. src/bundler/linker_context/convertStmtsForChunkForDevServer.rs:81-83 if record.path.is_disabled { continue; } skips the SImport entirely. The non-dev bun build path correctly gets the empty module (6484-6487).
There was a problem hiding this comment.
Correct, and the description lists it under Downsides. On main the DevServer drops the binding of every disabled import. This PR disables more paths (a scoped subpath, a key without an extension, a NODE_PATH package), so more imports reach that defect.
The repair is #46579. It declares the binding in convertStmtsForChunkForDevServer.rs and keeps stylesheets apart with a record flag, because the DevServer also sets path.is_disabled for a stylesheet. I did not copy those hunks here, so that the two PRs do not overlap.
The order of the two is decision 4 in #40832 (comment): #46579 lands first, or I carry its hunks and tests in this PR. This thread stays open until a maintainer answers.
test/CLAUDE.md asks for no time limit on a test. The limits applied to a local debug build only, so CI runs the same tests as before. The check test has a minimal tsconfig, which keeps it inside the default limit.
There was a problem hiding this comment.
Findings marked 🟡 are optional suggestions and need no follow-up push.
Still open from earlier reviews (1):
- 🔴
src/bundler/bundle_v2.rs:6481—pre-existing, widened: DevServer users whose app imports a package subpath that the package's "browser" map disables no…
If you have decided not to act on one of these findings, resolve its thread (a reply alone leaves it open) and the next review stops counting it. To review this commit again now, use Re-run on its "Claude Code Review" check.
Each writes its own temporary directory and spawns bun or runs a build. The table loop stays serial: each of its cases builds up to 160 entry points.
There was a problem hiding this comment.
Code review found no new issues
No new issues were found in this update; 1 finding from earlier reviews is still open above.
Still open from earlier reviews (1):
- 🔴
src/bundler/bundle_v2.rs:6481—pre-existing, widened: DevServer users whose app imports a package subpath that the package's "browser" map disables no…
If you have decided not to act on one of these findings, resolve its thread (a reply alone leaves it open) and the next review stops counting it. To review this commit again now, use Re-run on its "Claude Code Review" check.
|
A note on stylesheets for the decision list above. It adds a sixth item. 6. A stylesheet The cause is one guard in the CSS import order pass ( I built the change of that guard to
If the answer is esbuild's output, I suggest a mark of its own on the record. This PR can set it where it selects the empty module, and all four readers can test it. The raw |
Problem
--target=browser,require()of a path that abrowsermap sets tofalsereturns a function:(() => ({}))(print_disabled_import,src/js_printer/lib.rs).require("pkg/foo/bar")with the key"./foo/bar"bundles the Node file or fails withCould not resolve.load_node_modules(src/resolver/resolver.rs) reads the map after the file lookup.pkg/sub/foo.js,require("sub")with the key"./sub"resolves tofoo.jsitself.Fix
check_browser_mapis a port of esbuild'scheckBrowserMap.load_node_modulescalls it for each package candidate.(disabled):<path>, as in esbuild.test/bundler/esbuild/packagejson.test.ts,test/bundler/{bundler_browser,metafile}.test.ts. 59 of 104 new cases fail on main. Self-reviewed: 24 concerns raised, 16 addressed, 8 in Notes.Background
browserfield maps a file or a bare specifier to a file, a package, orfalse(disabled).Path.is_disabled_modulegives the empty module its own key.{}(bundler: emit an empty object for modules disabled via the browser field #33059) gives eachrequirea new object.Downsides
ReferenceError)."./x.js"no longer matchesrequire("x")by name. Binary: +9,984 bytes of text.Notes
Repro (main,
bun build e.js --target=browser):History of this PR. There are three revisions.
require("typeorm")(0.3.20) stopped building (Browser build cannot require() Node.js builtin: "dns"), and yaml@1.10.2 silently switched to its Node build../dirand./dir/were two modules.export * from <disabled>became a build error (@aws-sdk/token-providers@3.664.0stopped building). Thepackage_jsoncondition of the lookup after resolution was gone. It also carried resolver: do not look up a "browser" map target again after resolution #46985 whole.mainwith a trailing separator was a second module, a disabled.mjsmoduleentry with a livemaingave one of two bundles, and the body had wrong claims. Those are fixed too. The next sections describe the result.The lookups for a package subpath (
load_package_file_with_browser_map, called for each package candidate of thenode_moduleswalk and ofNODE_PATH):./foo/barorfoo/barforpkg/foo/bar, or itsindex) wins over the file, as in esbuild../foo/bar.js).mainand index lookups. That is main's code.esbuild does one lookup with implicit extensions before the file, with one extension order for
importandrequire. Bun has an order for each import kind, with.mjsand.cjsin it. With esbuild's order of steps, a key forx.mjsapplies torequire("pkg/x"), which loadsx.js(packagejson/BrowserMapKeyNamesTheFileThatLoads). #47034 fixes the same class of defect formainby a newextension_orderparameter. This PR does not touch themainand index lookups, so the two do not depend on each other. Both editcheck_browser_map, so the second one to land needs a rebase.The lookup after resolution. After a bare specifier resolves, main looks the resolved file up in the map once more (
check_package_path), when the result has apackage_json. That is how a file thatexportsorimportsresolves to gets remapped, and a file below a nested package.json. The result has apackage_jsononly for a file belownode_modules/<name>/, so a project file or a workspace package that tsconfigpathsresolves does not get this lookup. esbuild has no such lookup. typeorm, yaml@1 and @huggingface/hub depend on it. This PR keeps it, with itspackage_jsoncondition, and adds one condition: a result that a candidate lookup already handled (MatchResult.browser_remapped) is not looked up again.load_as_file_or_directorytakespackage_jsonfrom the path segment after the lastnode_modules/, which isnode_modules/@scopefor a scoped package. So a scoped package never got main's lookup. The candidate lookup covers it now.#46985 adds the same
browser_remappedfield for another purpose (a chain{"./main.js": "./a.js", "./a.js": "./b.js"}applies twice on main). This PR does not contain that fix or its 11 tests. The two PRs conflict in 3 files (resolver.rs,result.rs,packagejson.test.ts), so the second one to land needs a rebase.The empty module.
import,require()orimport()from a JavaScript file gets it, for a disabled path in thefilenamespace. Everything else keeps the external record that main has: Bun's stubs for Node.js builtins (nodenamespace), a stylesheet's@importandurl(), an HTML tag,require.resolve, and all of the DevServer.Path.is_disabled_moduleplus the path text.PathMap(wasPathToSourceIndexMap) keeps marked paths in a second table. The module graph and the queue of each importer both use it, so one input gives one bundle (browser/BrowserFieldDisabledAndLiveFileIn*, four orders)../dirand./dir/are one module.pkg/xandpkg/x.jsare two, as in esbuild.bun build --checkchecks the file as written.undefined, with esbuild's warning (a debug message when the importer is inside node_modules). esbuild has that as a general rule for a file with no exports. Bun's copy of the rule (LinkerContext.rs, "Is this a named import of a file without any exports?") needs a lazy-export file that does not usemodule, and every lazy-export file usesmodule. So it never fires, and the linker asks for the mark here.mainstands in for amoduleentry that the map disables. The resolver decides that where it builds the pair, before it picks the module type.export *isundefinedtoo, with a warning. esbuild fails the build there, and main builds it.Differences from esbuild that stay:
./dir.jsforpkg/dirwhendir/is a directory. Main and this branch load the directory..mjsor.mtsname: a default import of a disablednode.mjsfails its build (No matching export in "(disabled):node.mjs" for import "default"), and other forms give other values.moduleentry is disabled and whosemainis not:importgetsmain. esbuild gives{}. Main fails withFile not found "".export *from a disabled file, then an import of a name that no other export gives:undefinedand a warning. esbuild:No matching export.--splitting,import()of a disabled module resolves to{ default: {} }, as without splitting. esbuild resolves to{}.@import "@s/p/x.css"and failed on@import "u/x.css"(File not found ""). esbuild drops a disabled@import. In a sample of 1,490 packages, 83 have abrowsermap, and none of them has a stylesheet, HTML or asset key.NODE_PATHpackage withexportsgets the plain file lookup, as on main. Bun has noexportsstep forNODE_PATH(resolver: apply package.json exports to packages found via NODE_PATH #36566)."./x.js": "") is stored likefalse, as main's parser does. esbuild ignores such an entry. No package in the sample has one."browser": {}is not a scope of its own, as on main: the map of the project still applies inside that package. In esbuild it does not."./x"and"x.js", or"./x.js"and"./x/index") can give another answer than esbuild, because of the order above. The table has one key in each layout../x) keeps main's single lookup before the file. A package subpath (pkg/x) has the four steps. With sibling filesx.jsandx.mjsand one key, the two can differ. Main differs more often.Behavior changes relative to main:
typeof require(disabled)is"object".import def from disabledis{}and notundefined.import()of a disabled module resolves to its namespace.TypeError(main's stub is callable). iconv-lite 0.4.24 has"./lib/streams": falseand runsrequire("./streams")(iconv)whenprocess.versions.nodeis set. A defaultbun buildof it prints nothing on both builds. Main's bundle runs under Bun and Node.js, and this branch's bundle throws at load:TypeError: require_streams() is not a function. Packages that load iconv-lite 0.4 (raw-body@2, body-parser@1) inherit that. In a browser the call does not run. esbuild 0.21.5 does not build iconv-lite for the browser without polyfills (Could not resolve "string_decoder"), so there is no esbuild output to compare.--splitting, the empty module is a file of the graph. Animport()of a disabled path becomes a chunk of its own, as in esbuild. Two disabled bare names with one display name ((disabled):wsfrom two places) fail such a build withMultiple files share the same output path.baseUrlor an identitypathsentry resolves no longer gets the project's map by name (the next item)."./x"matchesrequire("x")only inside the package that owns the map, relative to the importer's directory, with no implicit extension. On main"./x.js"matchedrequire("x")by name, also from a dependency. Keys are stored as written (main normalized them, with the native separator on Windows, which resolver: compare browser map keys with forward slashes on every platform #41183 reports).NODE_PATH.require("./node/")matches the key"./node"..jsbefore.ts(packagejson/BrowserMapTargetExtensionOrderInNodeModules).(disabled):<path>, and the import of it is notexternal.The table test.
test/bundler/browser-field-matrix.tsgenerates 564 layouts: the package location (unscoped, scoped, withexports, the project itself), ten spellings of the key, a file orfalseas the value, and the import (from outside by six spellings, from inside by relative and bare specifiers, from the package root and from a subdirectory). One build per group gives the files that each import reaches, from the metafile.bundler_browser.test.tspins the table (6 cases, 5 fail on main).bun scripts/browser-field-matrix.tsbuilds the layouts with esbuild too: 560 of 564 are equal to esbuild 0.21.5, names of the empty modules included. The 4 others are theexportslayouts. Main is equal in 463.Real packages, built for the browser with main and with this branch: typeorm@0.3.20 (
importandrequire), yaml@1.10.2 (root,yaml/parse-cst,yaml/types,yaml/util), @huggingface/hub@2.17.9 (importandrequire), @analytics/core@0.13.2, react-select@3.2.0 (root,/async,dist/react-select.esm), axios@1.7.7, nanoid@3.3.7, uuid@9.0.1 and@aws-sdk/token-providers@3.664.0give byte-identical bundles. Two change as intended: simple-get@4.0.1 teststypeof decompressResponse === 'function'and simple-websocket@9.1.0 teststypeof ws !== 'function'on a module their map disables. On main the websocket fails withfunction is not a constructor. iconv-lite changes as described above.Measurements. Release builds of main (9b8347a) and of this branch on that commit, linux-x64.
Bundle bytes,
--target=browser, not minified /--minify. The project's package.json has"./node.js": false:const x = require("./node.js")import x from "./node.js"import * as x from "./node.js"import { y } from "./node.js"import "./node.js"import("./node.js")Binary (
size): text 108,863,485 to 108,873,469 (+9,984), data unchanged, bss +72, file +8,192 bytes.Instructions in user mode, all threads, median of 7 builds (
perf_event_open, the outputs are byte-identical):browsermap, 800 imports, browser--target=bunimport { DataSource } from "typeorm", browserPer call, read from the diff. Targets other than browser: one
care_about_browser_fieldbranch per package candidate, oneis_disabledcheck per resolved path infinalize_result, oneis_disabledbranch per import record. For the browser target, each package candidate also costs one copy of its path into a pooled buffer and one or twocheck_browser_mapcalls. A package with nobrowsermap in scope leaves each call at its first check.Tests. New or enabled cases, and how many fail on a build of main:
packagejson.test.ts60 new (32) and 6 enabledtodocases (6),bundler_browser.test.ts29 (19),metafile.test.ts2 (1),bake/dev/bundle.test.ts1 (1),resolve.test.ts6 (0). The cases that pass both ways are guards: esbuild's end-to-end cases that main already gets right, the remap cycles, the runtime and thenodeandbuntargets (which ignore the map), the specifier length limits, and the references that stay as written.TestPackageJsonDisabledTypeModuleIssue3367andTestPackageJsonBrowserMatchingTrailingSlashIssue4187are ported from esbuild's Go tests. Cases that pin an answer esbuild does not give haveskipOnEsbuildwith the reason. The two cases of #41183 are here too: they fail on main on Windows only. esbuild's end-to-end cases are inpackagejson.test.tsand not inextra.test.ts: the AOT suite draws the inputs ofextra.test.tsand builds them for another target, where abrowsermap is not read.Self-review. Three review passes ran on three revisions. The last one raised 24 concerns. Fixed here: the
mainand index lookups usedisabled_path, the livemainis chosen in the resolver, dead branches andabs_allocare gone, one helper holds the node_modules warning rule, the table helper no longer blames the wrong layout, the comments name the differences from esbuild, and four test gaps are closed (import()without--splitting, default plus star under--minify-whitespace, themainwith a trailing separator, the builtin stub case no longer pinsfunction). The rest are decisions or work outside this diff, and they are in the lists above and below. The review also proposed to land this as two PRs, cut atselect_import_module(the resolver port, and the empty module). That is a question for the maintainer, in a comment on this PR.Not in this PR.
bun buildand the DevServer now give different values forrequire()of a disabled path ({}and a function). Main's two pipelines agree. An ESM import of a disabled path has no binding in the DevServer on main, and this PR disables more paths (a scoped subpath, a key with no extension). dev server: declare the namespace of an import that the browser target disables #46579 declares that binding and adds a parity test. It should land first, or the second of the two to land updates the expectation.require("fs")in a browser build) stay a function. A package whose map has"fs": falsegets{}, so one bundle can hold both values. bundler: emit an empty object for modules disabled via the browser field #33059 changed the stubs and was closed in favor of this PR. Revision 1 carried that change, and a review took it out as a rider. It needs an owner.import d, * as nsunder--minifyprints no semicolon, andimport()of a disabled path is printed as written. They remain for the builtin stubs.browserkey spellednode:xis never read (the lookup strips the prefix first). esbuild reads it.--target=bunwith an HTML import) uses a resolver that was set up for another target, and gets none of this (bundler: derive the options of a server build's page from the browser target #46548, resolver: keep every main field of a package.json in the shared cache, select per resolver #46652).mainextension order of resolver: look up "main" in the "browser" map with the extension order of its load, for script imports #47034, theexportsstep forNODE_PATHof resolver: apply package.json exports to packages found via NODE_PATH #36566, the--externalorder of resolver: check user externals before browser node builtin polyfills #35212.browserand which resolver reads it first (resolver: keep every main field of a package.json in the shared cache, select per resolver #46652, Bun can't bundleisomorphic-ws#22887).Suites run with the debug build:
test/bundler/esbuild/{packagejson,extra,default,importstar,splitting,loader,dce,css,tsconfig}.test.ts,test/bundler/{bundler_browser,bundler_cjs,bundler_edgecase,bundler_npm,bundler_regressions,bundler_splitting,bundler_plugin,bundler_html,bundler_barrel,metafile,bun-build-api}.test.ts,test/js/bun/resolve/{resolve,resolve-error}.test.ts,test/bake/dev/bundle.test.ts,test/bake/dev-and-prod.test.ts. The cases that fail there fail by a time limit of the debug build, in tests this PR does not touch:default/TopLevelAwaitForbiddenRequireSourceMap{API,CLI},edgecase/DeepImportDiamondDAG, twosplitting/FoldingNeverMakesABundleWorsegraphs, and three cases ofbun-build-api.test.ts(twobytecode:cases and "thousands of times").no test proof · iteration 11 · platform-specific test(s) that do not run on this machine, deferring to CI, which covers all platforms: test/js/bun/resolve/resolve.test.ts, test/bundler/metafile.test.ts