Skip to content
Open
Show file tree
Hide file tree
Changes from 21 commits
Commits
Show all changes
26 commits
Select commit Hold shift + click to select a range
acd2993
bundler: make the "browser" field behave like esbuild
robobun Aug 28, 2026
8d62417
bundler: keep the browser-map subpath lookup safe across re-entry
robobun Aug 28, 2026
8dc7bc6
bundler: shorten the browser-field comments
robobun Aug 28, 2026
bc45281
resolver: give the browser-map index probe its own scratch buffer
robobun Aug 28, 2026
95aa112
bundler: test a package whose "module" entry is disabled by the brows…
robobun Aug 28, 2026
4da0dc0
bundler: key a disabled "module" entry by its own path, not by "main"
robobun Aug 28, 2026
7ddbc29
resolver: stop a "browser" map remap chain that loops back on itself
robobun Aug 28, 2026
6524e93
resolver: shorten the remap-depth comments
robobun Aug 28, 2026
bdb312e
bundler: port the browser-field tests from #36642 and #37532
robobun Aug 29, 2026
82d5b2f
bundler: DevServer uses "main" when the "browser" field disables "mod…
robobun Aug 29, 2026
a354c85
Merge remote-tracking branch 'origin/main' into farm/e1cd2044/browser…
robobun Sep 8, 2026
53bf075
bundler: test every import form of a node: builtin without a browser …
robobun Sep 8, 2026
7f7465f
Merge remote-tracking branch 'origin/main' into farm/e1cd2044/browser…
robobun Oct 26, 2026
f8cd8d0
resolver: apply a package's "browser" map to a subpath import found t…
robobun Oct 26, 2026
1a344ba
resolver: keep the ESM subpath scratch in Bufs; restore it after a re…
robobun Oct 26, 2026
2a4595b
resolver: shorten a comment
robobun Oct 26, 2026
76bd6d9
bundler: test import() of a disabled module with code splitting
robobun Oct 26, 2026
4a2f76e
bundler: only a JavaScript import of a disabled path becomes the empt…
robobun Oct 26, 2026
cbb1634
bundler: shorten three comments
robobun Oct 26, 2026
d587c94
Merge main. Keep the "browser" map lookup after resolution
robobun Oct 27, 2026
a17bfcb
Shorten comments. Keep the nesting of main where only the indentation…
robobun Oct 27, 2026
165de34
Merge branch 'main' into farm/e1cd2044/browser-field-resolution
robobun Oct 28, 2026
e5a4d14
Re-cut the "browser" map change after review
robobun Oct 28, 2026
96a4a40
Move the browser-field end-to-end cases to packagejson.test.ts
robobun Oct 28, 2026
2b5eeaf
Drop the per-test time limits in bundler_browser.test.ts
robobun Oct 28, 2026
32804a4
Run three independent cases of bundler_browser.test.ts concurrently
robobun Oct 28, 2026
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
41 changes: 31 additions & 10 deletions src/bundler/LinkerContext.rs
Original file line number Diff line number Diff line change
Expand Up @@ -4012,27 +4012,48 @@ impl<'a> LinkerContext<'a> {
}
}

// The default import of a disabled module is its `{}` exports object.
let is_disabled_named_import = status == ImportTrackerStatus::Disabled
&& !named_import.alias_is_star
&& named_import.alias.is_some_and(|a| a.slice() != b"default");
// Warn about importing from a file that is known to not have any exports
if status == ImportTrackerStatus::CjsWithoutExports
if (status == ImportTrackerStatus::CjsWithoutExports
|| is_disabled_named_import)
&& !self.is_call_record(prev_source_index, named_import.import_record_index)
{
if is_disabled_named_import {
// SAFETY: the symbol slot is disjoint from `named_import` (`graph.ast`).
unsafe { self.graph.symbol_mut(tracker.import_ref) }
.import_item_status = ImportItemStatus::Missing;
}
let source = self.get_source(tracker.source_index.get());
let imported = self.get_source(next_tracker.source_index.get());
// SAFETY: `alias` is an arena `*const [u8]` valid for the link pass.
let alias = named_import
.alias
.expect("infallible: alias present")
.slice();
let message = format_args!(
"Import \"{}\" will always be undefined because the file \"{}\" has no exports",
bstr::BStr::new(alias),
bstr::BStr::new(&imported.path.pretty),
);
// Split-borrow with `named_import` (`&self.graph`) —
// `log_disjoint` returns the disjoint `Transpiler.log` backref.
self.log_disjoint().add_range_warning_fmt(
Some(source),
source.range_of_identifier(named_import.alias_loc),
format_args!(
"Import \"{}\" will always be undefined because the file \"{}\" has no exports",
bstr::BStr::new(alias),
bstr::BStr::new(&source.path.pretty),
),
);
if is_disabled_named_import && source.path.is_node_module() {
// Like esbuild: the user cannot fix an import inside node_modules.
self.log_disjoint().add_debug_fmt(
Some(source),
named_import.alias_loc,
message,
);
} else {
self.log_disjoint().add_range_warning_fmt(
Some(source),
source.range_of_identifier(named_import.alias_loc),
message,
);
}
}
}

Expand Down
37 changes: 30 additions & 7 deletions src/bundler/ParseTask.rs
Original file line number Diff line number Diff line change
Expand Up @@ -24,6 +24,7 @@ use bun_js_parser as js_parser;
// ASTs (arena outlives the link step). Use the crate-level alias so the
// `Success`/helper signatures don't carry an explicit `'static` everywhere.
use crate::JSAst;
use crate::PathToSourceIndexMap::PathLike as _;
/// `js_parser.Parser.Options` — the real parser-entry options struct.
pub use bun_js_parser::parser::ParserOptions;

Expand Down Expand Up @@ -257,7 +258,10 @@ impl<'a> ParseTask<'a> {
// provenance to read-only, making the later `&mut *ctx` UB).
ctx: *mut BundleV2<'a>,
) -> ParseTask<'a> {
let (package_name, package_version) = match resolve_result.package_json {
// Every empty module gets the same attributes, whichever import creates it.
let is_empty_module = path.is_disabled_module();
let package_json = resolve_result.package_json.filter(|_| !is_empty_module);
let (package_name, package_version) = match package_json {
// SAFETY: `package_json` is `Option<*const PackageJSON>`; the resolver
// arena outlives the bundle pass, so deref'ing the raw pointer here to
// borrow `name`/`version` is sound.
Expand All @@ -278,17 +282,36 @@ impl<'a> ParseTask<'a> {
ParseTask {
ctx: Some(ctx_ref),
path,
contents_or_fd: ContentsOrFd::Fd {
dir: resolve_result.dirname_fd,
file: resolve_result.file_fd,
contents_or_fd: if is_empty_module {
ContentsOrFd::Contents(b"")
} else if path.text == resolve_result.path_pair.primary.text {
ContentsOrFd::Fd {
dir: resolve_result.dirname_fd,
file: resolve_result.file_fd,
}
} else {
// The fds are the primary path's. The secondary path is opened by name.
ContentsOrFd::Fd {
dir: Fd::INVALID,
file: Fd::INVALID,
}
},
side_effects: if is_empty_module {
crate::PathToSourceIndexMap::DISABLED_MODULE_SIDE_EFFECTS
} else {
resolve_result.primary_side_effects_data
},
side_effects: resolve_result.primary_side_effects_data,
// D042: resolver-side and bundler-side `jsx::Pragma` are the SAME
// nominal type (`bun_options_types::jsx::Pragma`). Preserves
// jsxImportSource/runtime/etc. from tsconfig.json.
jsx: resolve_result.jsx.clone(),
source_index,
module_type: resolve_result.module_type,
// `module.exports = {}`, also for a disabled ".mjs" or ".mts".
module_type: if is_empty_module {
options::ModuleType::Cjs
} else {
resolve_result.module_type
},
emit_decorator_metadata: resolve_result.flags.emit_decorator_metadata(),
experimental_decorators: resolve_result.flags.experimental_decorators(),
use_define_for_class_fields: resolve_result.flags.use_define_for_class_fields(),
Expand Down Expand Up @@ -1640,7 +1663,7 @@ pub mod parse_worker {
break 'brk false;
}

if file_path.namespace == b"node" {
if file_path.namespace == b"node" || file_path.is_disabled {
break 'brk false;
}
true
Expand Down
50 changes: 49 additions & 1 deletion src/bundler/PathToSourceIndexMap.rs
Original file line number Diff line number Diff line change
Expand Up @@ -2,11 +2,21 @@ use bun_collections::StringHashMap;

use crate::IndexStringMap::IndexInt;

/// Pretty-path prefix of the empty module of a disabled path, as in esbuild.
pub(crate) const DISABLED_PRETTY_PREFIX: &[u8] = b"(disabled):";

/// An unused import of that empty module is dropped, as esbuild drops it.
pub(crate) const DISABLED_MODULE_SIDE_EFFECTS: bun_ast::SideEffects =
bun_ast::SideEffects::NoSideEffectsPureData;

/// Abstracts over the two structurally-identical `Path` ports (`bun_paths::fs::Path`
/// and `bun_resolver::fs::Path`) so the bundler can key the map with either while
/// the crates converge. Both expose `.text: &[u8]`, which is all we need.
pub trait PathLike {
fn path_text(&self) -> &[u8];

/// The path names the empty module of a disabled file, not an external disabled record.
fn is_disabled_module(&self) -> bool;
}

// `bun_resolver::fs::Path` is now a re-export of `bun_paths::fs::Path` (D090),
Expand All @@ -16,6 +26,11 @@ impl PathLike for bun_paths::fs::Path<'_> {
fn path_text(&self) -> &[u8] {
self.text
}

#[inline]
fn is_disabled_module(&self) -> bool {
self.is_disabled && self.pretty.starts_with(DISABLED_PRETTY_PREFIX)
}
}

/// The lifetime of the keys are not owned by this map.
Expand All @@ -24,6 +39,8 @@ impl PathLike for bun_paths::fs::Path<'_> {
#[derive(Default)]
pub struct PathToSourceIndexMap {
pub(crate) map: Map,
/// The empty modules of disabled files, keyed by the same text as the files.
disabled: Map,
}

pub type Map = StringHashMap<IndexInt>;
Expand All @@ -33,8 +50,39 @@ pub type Map = StringHashMap<IndexInt>;
pub(crate) type GetOrPutResult<'a> = bun_collections::string_hash_map::GetOrPutResult<'a, IndexInt>;

impl PathToSourceIndexMap {
fn map_for(&self, path: &impl PathLike) -> &Map {
if path.is_disabled_module() {
&self.disabled
} else {
&self.map
}
}

fn map_for_mut(&mut self, path: &impl PathLike) -> &mut Map {
if path.is_disabled_module() {
&mut self.disabled
} else {
&mut self.map
}
}

pub(crate) fn get_path(&self, path: &impl PathLike) -> Option<IndexInt> {
self.get(path.path_text())
self.map_for(path).get(path.path_text()).copied()
}

pub(crate) fn put_path(
&mut self,
path: &impl PathLike,
value: IndexInt,
) -> Result<(), bun_alloc::AllocError> {
self.map_for_mut(path).put(path.path_text(), value)
}

pub(crate) fn get_or_put_path(
&mut self,
path: &impl PathLike,
) -> Result<GetOrPutResult<'_>, bun_alloc::AllocError> {
self.map_for_mut(path).get_or_put(path.path_text())
}

pub(crate) fn get(&self, text: impl AsRef<[u8]>) -> Option<IndexInt> {
Expand Down
Loading
Loading