From 5e8324d34e5c60abce51009b0ae3f8bebe020ee9 Mon Sep 17 00:00:00 2001 From: robobun <117481402+robobun@users.noreply.github.com> Date: Thu, 17 Sep 2026 22:45:31 +0000 Subject: [PATCH 1/8] bundler: give a file that onResolve resolved the settings of its package.json and tsconfig.json A Bun.build onResolve plugin that answers with a path in the file namespace made the bundler create the module with fixed defaults: it has side effects, the module type is unknown, and the JSX and decorator settings come from the build options. The same file found by the resolver takes these from the package.json and tsconfig.json that enclose it. When one build reaches the file both ways, the import that lands first creates the module, so the output depended on timing. Resolver::result_for_resolved_file returns what the resolver attaches to an absolute path, without probing extensions or following symlinks. The plugin answer builds its ParseTask from that result. A path whose directory is not on disk, or that is in another namespace, keeps the defaults. --- src/bundler/bundle_v2.rs | 64 ++++++------ src/resolver/resolver.rs | 33 +++++- test/bundler/bundler_plugin.test.ts | 156 ++++++++++++++++++++++++++++ 3 files changed, 221 insertions(+), 32 deletions(-) diff --git a/src/bundler/bundle_v2.rs b/src/bundler/bundle_v2.rs index 37994f596d5c..75d4831e80ed 100644 --- a/src/bundler/bundle_v2.rs +++ b/src/bundler/bundle_v2.rs @@ -5005,6 +5005,29 @@ pub mod bv2_impl { .filter(|_| path.text == &*resolve.import_record.specifier), ); + // A file on disk gets the package.json and tsconfig.json that enclose it, as it + // does when the resolver finds it: another import can reach the same file that + // way, and whichever import lands first creates the module. + let transpiler = + this.transpiler_for_target(resolve.import_record.original_target); + let resolve_result = path + .is_file() + .then(|| { + transpiler + .resolver + .result_for_resolved_file(path, resolve.import_record.kind) + }) + .flatten() + .unwrap_or_else(|| _resolver::Result { + path_pair: _resolver::PathPair { + primary: path, + secondary: None, + }, + jsx: transpiler.options.jsx.clone(), + ..Default::default() + }); + let jsx_development = transpiler.options.forced_jsx_development(); + this.graph .input_files .append(crate::Graph::InputFile { @@ -5016,39 +5039,20 @@ pub mod bv2_impl { ..Default::default() }, loader, - side_effects: bun_ast::SideEffects::HasSideEffects, + side_effects: resolve_result.primary_side_effects_data, ..Default::default() }) .expect("unreachable"); - let task_val = ParseTask { - // SAFETY: `from_mut(this)` is the live bundle (write provenance); - // outlives the task. - ctx: Some(unsafe { - bun_ptr::ParentRef::from_raw_mut( - std::ptr::from_mut::(this) - .cast::>(), - ) - }), - path, - // unknown at this point: - contents_or_fd: parse_task::ContentsOrFd::Fd { - dir: bun_sys::Fd::INVALID, - file: bun_sys::Fd::INVALID, - }, - side_effects: bun_ast::SideEffects::HasSideEffects, - jsx: this - .transpiler_for_target(resolve.import_record.original_target) - .options - .jsx - .clone(), - source_index: bun_ast::Index::init(source_index.get()), - module_type: options::ModuleType::Unknown, - loader: Some(loader), - known_target: resolve.import_record.original_target, - is_entry_point: resolve.import_record.kind - == ImportKind::EntryPointBuild, - ..Default::default() - }; + let mut task_val = ParseTask::init( + &resolve_result, + bun_ast::Index::init(source_index.get()), + this, + ); + task_val.jsx.development = jsx_development; + task_val.loader = Some(loader); + task_val.known_target = resolve.import_record.original_target; + task_val.is_entry_point = + resolve.import_record.kind == ImportKind::EntryPointBuild; // Arena-owned. // SAFETY: arena outlives the bundle pass. let task: &mut ParseTask = this.arena_create(task_val); diff --git a/src/resolver/resolver.rs b/src/resolver/resolver.rs index 31dd498e7972..d361ea9b2285 100644 --- a/src/resolver/resolver.rs +++ b/src/resolver/resolver.rs @@ -1397,7 +1397,7 @@ impl<'a> Resolver<'a> { if result.path_pair.primary.namespace() != b"node" && !result.flags.is_standalone_module() { - if let Err(err) = self.finalize_result(&mut result, kind) { + if let Err(err) = self.finalize_result(&mut result, kind, true) { self.extension_order = original_order; return ResultUnion::Failure(err); } @@ -1508,10 +1508,39 @@ impl<'a> Resolver<'a> { self.resolve(source_dir, import_path, kind) } + /// The `Result` an import of the absolute path `path` gets from the resolver, for a file + /// that something else resolved (a bundler plugin's `onResolve`). The path stays as given: + /// no extension is probed and no symlink is followed. `None` when the directory of `path` + /// is not on disk. + pub fn result_for_resolved_file( + &mut self, + path: Path, + kind: ast::ImportKind, + ) -> Option { + // As in `resolve`: a path with a null byte names nothing on disk, and the directory + // cache asserts that it gets none. + if strings::index_of_char(path.text, 0).is_some() { + return None; + } + self.read_dir_info_ignore_error(path.name().dir)?; + let mut result = Result { + path_pair: PathPair { + primary: path, + secondary: None, + }, + jsx: self.opts.jsx.clone(), + import_kind: kind, + ..Default::default() + }; + self.finalize_result(&mut result, kind, false).ok()?; + Some(result) + } + pub(crate) fn finalize_result( &mut self, result: &mut Result, kind: ast::ImportKind, + follow_symlinks: bool, ) -> crate::CrateResult<()> { if result.flags.is_external() { return Ok(()); @@ -1581,7 +1610,7 @@ impl<'a> Resolver<'a> { // concurrent resolver at a newer generation rewrites this `DirEntry`'s // map in place under that lock. The entry pointer stays valid after // unlock (EntryStore-owned). - if let Some(query) = dir.get_entry(self.generation, name.filename) { + if follow_symlinks && let Some(query) = dir.get_entry(self.generation, name.filename) { // SAFETY: rfs points at the process-global RealFS; the lazy-stat // rewrite inside `symlink()` is serialized on the per-entry mutex. let symlink_path = unsafe { query.entry().symlink(self.rfs_ptr(), self.store_fd) }; diff --git a/test/bundler/bundler_plugin.test.ts b/test/bundler/bundler_plugin.test.ts index e9312694e2b2..0fc816349da1 100644 --- a/test/bundler/bundler_plugin.test.ts +++ b/test/bundler/bundler_plugin.test.ts @@ -1893,4 +1893,160 @@ describe("bundler", () => { }).toEqual({ success: true, logs: [], outputs: ["second-name.js"] }); }); } + + // Each fake JSX runtime returns its own name, so a rendered element tells which one a file was compiled for. + const jsxRuntime = (name: string) => + `export const jsx = () => "${name}"; export const jsxs = jsx; export const jsxDEV = jsx; export const Fragment = "";`; + const fakeJsxRuntimes = { + "node_modules/preact/jsx-runtime.js": jsxRuntime("preact"), + "node_modules/preact/jsx-dev-runtime.js": jsxRuntime("preact"), + "node_modules/react/jsx-runtime.js": jsxRuntime("react"), + "node_modules/react/jsx-dev-runtime.js": jsxRuntime("react"), + }; + + // A file on disk that onResolve names is compiled and tree-shaken with the package.json and + // tsconfig.json that enclose it, like the same file when the resolver finds it. + for (const resolvedBy of ["onResolve", "resolver"] as const) { + test.concurrent(`plugin/file resolved by ${resolvedBy} uses its package.json and tsconfig.json`, async () => { + const targets = { + "component": "app/component.tsx", + "decorated": "app/decorated.ts", + "unused": "node_modules/side-effect-free/unused.js", + "importer": "esm-pkg/importer.js", + }; + using dir = tempDir("plugin-resolved-file-metadata", { + ...fakeJsxRuntimes, + "entry.js": ` + import { Component } from "${resolvedBy === "onResolve" ? "alias/component" : "./app/component.tsx"}"; + import { secondDecoratorArgument } from "${resolvedBy === "onResolve" ? "alias/decorated" : "./app/decorated.ts"}"; + import "${resolvedBy === "onResolve" ? "alias/unused" : "side-effect-free/unused.js"}"; + import { defaultImportOfCommonJS } from "${resolvedBy === "onResolve" ? "alias/importer" : "./esm-pkg/importer.js"}"; + console.log(JSON.stringify({ + jsxImportSource: Component(), + secondDecoratorArgument, + unusedRan: globalThis.unusedRan === true, + defaultImportOfCommonJS, + })); + `, + "app/tsconfig.json": JSON.stringify({ + compilerOptions: { jsx: "react-jsx", jsxImportSource: "preact", experimentalDecorators: true }, + }), + "app/component.tsx": `export const Component = () =>
;`, + // A legacy decorator gets the method name, a standard decorator gets a context object. + "app/decorated.ts": ` + let secondArgument; + function decorator(...args) { secondArgument = typeof args[1]; } + class Decorated { @decorator method() {} } + export const secondDecoratorArgument = secondArgument; + `, + "node_modules/side-effect-free/package.json": JSON.stringify({ name: "side-effect-free", sideEffects: false }), + "node_modules/side-effect-free/unused.js": `globalThis.unusedRan = true; export const unused = 1;`, + // In a "type": "module" package a default import of CommonJS is module.exports, whatever __esModule says. + "esm-pkg/package.json": JSON.stringify({ name: "esm-pkg", type: "module" }), + "esm-pkg/importer.js": `import cjs from "./cjs.cjs"; export const defaultImportOfCommonJS = typeof cjs;`, + "esm-pkg/cjs.cjs": `exports.__esModule = true; exports.default = "the default export";`, + }); + const root = String(dir); + + const result = await Bun.build({ + entrypoints: [join(root, "entry.js")], + outdir: join(root, "out"), + target: "bun", + throw: false, + plugins: + resolvedBy === "onResolve" + ? [ + { + name: "alias", + setup(build) { + build.onResolve({ filter: /^alias\// }, args => ({ + path: join(root, targets[args.path.slice("alias/".length) as keyof typeof targets]), + })); + }, + }, + ] + : [], + }); + expect({ success: result.success, logs: result.logs.map(log => log.message) }).toEqual({ + success: true, + logs: [], + }); + + await using proc = Bun.spawn({ + cmd: [bunExe(), join(root, "out", "entry.js")], + env: bunEnv, + cwd: root, + stdout: "pipe", + stderr: "pipe", + }); + const [stdout, stderr, exitCode] = await Promise.all([proc.stdout.text(), proc.stderr.text(), proc.exited]); + expect(stderr).toBe(""); + expect(JSON.parse(stdout)).toEqual({ + jsxImportSource: "preact", + secondDecoratorArgument: "string", + unusedRan: false, + defaultImportOfCommonJS: "object", + }); + expect(exitCode).toBe(0); + }); + } + + // The import that lands first creates the module, so a file that onResolve and the resolver both reach + // has to come out the same in either order. The onLoad callback reports that the module exists, and the + // other import waits for it. + test.concurrent("plugin/file reached by onResolve and by the resolver builds the same in either order", async () => { + using dir = tempDir("plugin-resolved-file-join-order", { + ...fakeJsxRuntimes, + "entry.js": ` + import "late"; + import { Component } from "alias/component"; + console.log(Component()); + `, + "late.js": `import "./app/component.tsx";`, + "app/tsconfig.json": JSON.stringify({ compilerOptions: { jsx: "react-jsx", jsxImportSource: "preact" } }), + "app/component.tsx": `export const Component = () =>
;`, + }); + const root = String(dir); + + async function build(first: "onResolve" | "resolver") { + const componentExists = Promise.withResolvers(); + const result = await Bun.build({ + entrypoints: [join(root, "entry.js")], + throw: false, + plugins: [ + { + name: "order", + setup(build) { + build.onLoad({ filter: /component\.tsx$/ }, () => { + componentExists.resolve(); + return undefined; + }); + build.onResolve({ filter: /^alias\/component$/ }, async () => { + if (first === "resolver") await componentExists.promise; + return { path: join(root, "app", "component.tsx") }; + }); + build.onResolve({ filter: /^late$/ }, async () => { + if (first === "onResolve") await componentExists.promise; + return { path: join(root, "late.js") }; + }); + }, + }, + ], + }); + expect({ success: result.success, logs: result.logs.map(log => log.message) }).toEqual({ + success: true, + logs: [], + }); + const text = await result.outputs[0].text(); + return { usesPreact: text.includes(`"preact"`), usesReact: text.includes(`"react"`), text }; + } + + const onResolveFirst = await build("onResolve"); + const resolverFirst = await build("resolver"); + expect({ usesPreact: onResolveFirst.usesPreact, usesReact: onResolveFirst.usesReact }).toEqual({ + usesPreact: true, + usesReact: false, + }); + expect(onResolveFirst.text).toBe(resolverFirst.text); + }); }); From ea26d128fb9fe3fad326c27f9217331dfed40240 Mon Sep 17 00:00:00 2001 From: robobun <117481402+robobun@users.noreply.github.com> Date: Fri, 18 Sep 2026 01:08:22 +0000 Subject: [PATCH 2/8] bundler: adopt the resolver's result only for a path the resolver gives, after onLoad Review findings on the first version: - It applied package.json and tsconfig.json settings to every onResolve path whose directory exists. A module that only the plugin can name (a path that is not on disk, a file in `files`, a spelling the resolver does not print) lost a side-effect-only import under "sideEffects": false, and an exact "sideEffects" list missed a path that was not normalized. - It read the directory when onResolve answered, before onLoad ran. A file that onLoad writes next to the module was then missing from the directory list. - The path where every onResolve callback returns nothing still dropped the tsconfig JSX and the package.json side effects, so that path and the onResolve path disagreed. Now the bundler asks the resolver for the path itself and takes the result only when the resolver answers with the same bytes. It asks after onLoad is done, or at once when no onLoad callback matches. ParseTask::set_resolver_result holds the field mapping for this and for ParseTask::init. enqueue_parse_task keeps the resolver's JSX and side effects, as resolve_import_records does, and an in-memory file on that path gets the JSX options of the build. Resolver::result_for_resolved_file and the follow_symlinks flag are gone. --- src/bundler/ParseTask.rs | 74 +++++---- src/bundler/bundle_v2.rs | 119 +++++++++----- src/resolver/resolver.rs | 33 +--- test/bundler/bundler_plugin.test.ts | 232 ++++++++++++++++++++-------- 4 files changed, 286 insertions(+), 172 deletions(-) diff --git a/src/bundler/ParseTask.rs b/src/bundler/ParseTask.rs index b70ee99f16f5..21f7a2bdfa81 100644 --- a/src/bundler/ParseTask.rs +++ b/src/bundler/ParseTask.rs @@ -116,6 +116,9 @@ pub struct ParseTask { pub(crate) package_version: ast::StoreStr, pub(crate) package_name: ast::StoreStr, pub(crate) is_entry_point: bool, + /// The kind of the import whose `onResolve` answer created this task, until + /// `BundleV2::adopt_resolver_result` has asked the resolver about the file. + pub(crate) created_by_on_resolve: Option, } pub enum ParseTaskStage { @@ -237,59 +240,50 @@ impl ParseTask { // provenance to read-only, making the later `&mut *ctx` UB). ctx: *mut BundleV2<'_>, ) -> ParseTask { - let (package_name, package_version) = match resolve_result.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. - Some(pj) => unsafe { - let pj = &*pj; - ( - ast::StoreStr::new(&pj.name[..]), - ast::StoreStr::new(&pj.version[..]), - ) - }, - None => (ast::StoreStr::EMPTY, ast::StoreStr::EMPTY), - }; // SAFETY: lifetime erased — `ctx` outlives the ParseTask (BACKREF); // write provenance from the `*mut BundleV2` parameter; caller passes a // live `&mut BundleV2` coerced to `*mut`. let ctx_ref = unsafe { bun_ptr::ParentRef::from_raw_mut(ctx.cast::>()) }; let known_target = ctx_ref.get().transpiler().options.target; - ParseTask { + let mut task = ParseTask { ctx: Some(ctx_ref), path: resolve_result.path_pair.primary, contents_or_fd: ContentsOrFd::Fd { dir: resolve_result.dirname_fd, file: resolve_result.file_fd, }, - 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, - 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(), - package_version, - package_name, known_target, - // defaults: - secondary_path_for_commonjs_interop: None, - external_free_function: ExternalFreeFunction::NONE, - loader: None, - task: ThreadPoolLib::Task { - node: ThreadPoolLib::Node::default(), - callback: task_callback, - }, - io_task: ThreadPoolLib::Task { - node: ThreadPoolLib::Node::default(), - callback: io_task_callback, + ..Default::default() + }; + task.set_resolver_result(resolve_result); + task + } + + /// What the resolver says about the file, apart from its path and its descriptors. + pub(crate) fn set_resolver_result(&mut self, resolve_result: &_resolver::Result) { + (self.package_name, self.package_version) = match resolve_result.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. + Some(pj) => unsafe { + let pj = &*pj; + ( + ast::StoreStr::new(&pj.name[..]), + ast::StoreStr::new(&pj.version[..]), + ) }, - stage: ParseTaskStage::NeedsSourceCode, - is_entry_point: false, - } + None => (ast::StoreStr::EMPTY, ast::StoreStr::EMPTY), + }; + self.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. + self.jsx = resolve_result.jsx.clone(); + self.module_type = resolve_result.module_type; + self.emit_decorator_metadata = resolve_result.flags.emit_decorator_metadata(); + self.experimental_decorators = resolve_result.flags.experimental_decorators(); + self.use_define_for_class_fields = resolve_result.flags.use_define_for_class_fields(); } /// Re-export of `parse_worker::get_runtime_source` as an associated fn so @@ -329,6 +323,7 @@ impl Default for ParseTask { package_version: ast::StoreStr::EMPTY, package_name: ast::StoreStr::EMPTY, is_entry_point: false, + created_by_on_resolve: None, } } } @@ -608,6 +603,7 @@ pub mod parse_worker { package_version: ast::StoreStr::EMPTY, package_name: ast::StoreStr::EMPTY, is_entry_point: false, + created_by_on_resolve: None, }; let source = Source { // `bun_ast::Source.path` is `bun_paths::fs::Path<'static>`, distinct diff --git a/src/bundler/bundle_v2.rs b/src/bundler/bundle_v2.rs index 75d4831e80ed..c0b0c415158a 100644 --- a/src/bundler/bundle_v2.rs +++ b/src/bundler/bundle_v2.rs @@ -2372,7 +2372,10 @@ pub mod bv2_impl { &import_record.source_file, &import_record.specifier, ) { - let file_map_result = _file_map_result; + let mut file_map_result = _file_map_result; + // An in-memory file has no tsconfig.json: it gets the JSX options of the build. + // SAFETY: see `transpiler` note above. + file_map_result.jsx = unsafe { &(*transpiler).options.jsx }.clone(); let mut path_primary = file_map_result.path_pair.primary; // reshaped for borrowck — `get_or_put` borrows `*self` mutably via // `self.graph`; capture the slot as `*mut u32` so subsequent `self.*` calls @@ -3746,7 +3749,7 @@ pub mod bv2_impl { self.graph.input_files.append(crate::Graph::InputFile { source: core::mem::take(source), loader, - side_effects: loader.side_effects(), + side_effects: resolve_result.primary_side_effects_data, ..Default::default() })?; // `ParseTask::init` takes `bun_ast::Index`; both Index newtypes @@ -3760,7 +3763,10 @@ pub mod bv2_impl { // SAFETY: arena outlives the bundle pass; reborrow `*mut` as `&mut`. let task: &mut ParseTask = self.arena_create(task_val); task.loader = Some(loader); - task.jsx = self.transpiler_for_target(known_target).options.jsx.clone(); + task.jsx.development = self + .transpiler_for_target(known_target) + .options + .forced_jsx_development(); task.task.node.next = core::ptr::null_mut(); task.io_task.node.next = core::ptr::null_mut(); task.known_target = known_target; @@ -4601,6 +4607,45 @@ pub mod bv2_impl { } impl<'a> BundleV2<'a> { + /// A module that an `onResolve` path created takes what the resolver attaches to the file (side effects, + /// module type, JSX and decorator settings) if the path is byte for byte the one the resolver gives that + /// file. Another import can then reach the same module through the resolver, and the one that lands first + /// creates it, so both have to describe it the same way. Any other path (not on disk, in `files`, through + /// a symlink, not normalized) stays a plugin module. This runs once `onLoad` is done: the resolver lists a + /// directory only once, and `onLoad` can write the files that the module imports. + pub(crate) fn adopt_resolver_result(&mut self, task: &mut ParseTask) { + let Some(kind) = task.created_by_on_resolve.take() else { + return; + }; + if !task.path.is_file() + || self + .file_map + .is_some_and(|files| files.contains(task.path.text)) + { + return; + } + let transpiler = self.transpiler_for_target(task.known_target); + let Ok(result) = transpiler.resolver.resolve( + task.path.name().dir_with_trailing_slash(), + task.path.text, + kind, + ) else { + return; + }; + if result.flags.is_external() + || result + .path_const() + .is_none_or(|resolved| resolved.text != task.path.text) + { + return; + } + let jsx_development = transpiler.options.forced_jsx_development(); + task.set_resolver_result(&result); + task.jsx.development = jsx_development; + self.graph.input_files.items_side_effects_mut()[task.source_index.get() as usize] = + task.side_effects; + } + pub(crate) fn on_load(load: &mut jsc_api::JSBundler::Load, this: &mut BundleV2) { if load.deferred_in.take() == Some(this.graph.defer_epoch) { // Answered while `.defer()`red and before that batch was drained (cancelled, or a plugin @@ -4648,6 +4693,7 @@ pub mod bv2_impl { // If it's a file namespace, we should run it through the parser like normal. // The file could be on disk. if source.path.is_file() { + this.adopt_resolver_result(load.parse_task_mut()); this.graph.pool().schedule(load.parse_task_mut()); return; } @@ -4668,6 +4714,7 @@ pub mod bv2_impl { this.decrement_scan_counter(); } jsc_api::JSBundler::LoadValue::Success(code) => { + this.adopt_resolver_result(load.parse_task_mut()); // `code`: LoadSuccess { source_code, loader } // When a plugin returns a file loader, we always need to populate additional_files let should_copy_for_bundling = code.loader.should_copy_for_bundling(); @@ -5005,29 +5052,6 @@ pub mod bv2_impl { .filter(|_| path.text == &*resolve.import_record.specifier), ); - // A file on disk gets the package.json and tsconfig.json that enclose it, as it - // does when the resolver finds it: another import can reach the same file that - // way, and whichever import lands first creates the module. - let transpiler = - this.transpiler_for_target(resolve.import_record.original_target); - let resolve_result = path - .is_file() - .then(|| { - transpiler - .resolver - .result_for_resolved_file(path, resolve.import_record.kind) - }) - .flatten() - .unwrap_or_else(|| _resolver::Result { - path_pair: _resolver::PathPair { - primary: path, - secondary: None, - }, - jsx: transpiler.options.jsx.clone(), - ..Default::default() - }); - let jsx_development = transpiler.options.forced_jsx_development(); - this.graph .input_files .append(crate::Graph::InputFile { @@ -5039,20 +5063,40 @@ pub mod bv2_impl { ..Default::default() }, loader, - side_effects: resolve_result.primary_side_effects_data, + side_effects: bun_ast::SideEffects::HasSideEffects, ..Default::default() }) .expect("unreachable"); - let mut task_val = ParseTask::init( - &resolve_result, - bun_ast::Index::init(source_index.get()), - this, - ); - task_val.jsx.development = jsx_development; - task_val.loader = Some(loader); - task_val.known_target = resolve.import_record.original_target; - task_val.is_entry_point = - resolve.import_record.kind == ImportKind::EntryPointBuild; + let task_val = ParseTask { + // SAFETY: `from_mut(this)` is the live bundle (write provenance); + // outlives the task. + ctx: Some(unsafe { + bun_ptr::ParentRef::from_raw_mut( + std::ptr::from_mut::(this) + .cast::>(), + ) + }), + path, + // unknown at this point: + contents_or_fd: parse_task::ContentsOrFd::Fd { + dir: bun_sys::Fd::INVALID, + file: bun_sys::Fd::INVALID, + }, + side_effects: bun_ast::SideEffects::HasSideEffects, + jsx: this + .transpiler_for_target(resolve.import_record.original_target) + .options + .jsx + .clone(), + source_index: bun_ast::Index::init(source_index.get()), + module_type: options::ModuleType::Unknown, + loader: Some(loader), + known_target: resolve.import_record.original_target, + is_entry_point: resolve.import_record.kind + == ImportKind::EntryPointBuild, + created_by_on_resolve: Some(resolve.import_record.kind), + ..Default::default() + }; // Arena-owned. // SAFETY: arena outlives the bundle pass. let task: &mut ParseTask = this.arena_create(task_val); @@ -5061,6 +5105,7 @@ pub mod bv2_impl { this.increment_scan_counter(); if !this.enqueue_on_load_plugin_if_needed(task) { + this.adopt_resolver_result(task); if loader.should_copy_for_bundling() { let additional_files: &mut bun_alloc::AstVec< crate::AdditionalFile, diff --git a/src/resolver/resolver.rs b/src/resolver/resolver.rs index d361ea9b2285..31dd498e7972 100644 --- a/src/resolver/resolver.rs +++ b/src/resolver/resolver.rs @@ -1397,7 +1397,7 @@ impl<'a> Resolver<'a> { if result.path_pair.primary.namespace() != b"node" && !result.flags.is_standalone_module() { - if let Err(err) = self.finalize_result(&mut result, kind, true) { + if let Err(err) = self.finalize_result(&mut result, kind) { self.extension_order = original_order; return ResultUnion::Failure(err); } @@ -1508,39 +1508,10 @@ impl<'a> Resolver<'a> { self.resolve(source_dir, import_path, kind) } - /// The `Result` an import of the absolute path `path` gets from the resolver, for a file - /// that something else resolved (a bundler plugin's `onResolve`). The path stays as given: - /// no extension is probed and no symlink is followed. `None` when the directory of `path` - /// is not on disk. - pub fn result_for_resolved_file( - &mut self, - path: Path, - kind: ast::ImportKind, - ) -> Option { - // As in `resolve`: a path with a null byte names nothing on disk, and the directory - // cache asserts that it gets none. - if strings::index_of_char(path.text, 0).is_some() { - return None; - } - self.read_dir_info_ignore_error(path.name().dir)?; - let mut result = Result { - path_pair: PathPair { - primary: path, - secondary: None, - }, - jsx: self.opts.jsx.clone(), - import_kind: kind, - ..Default::default() - }; - self.finalize_result(&mut result, kind, false).ok()?; - Some(result) - } - pub(crate) fn finalize_result( &mut self, result: &mut Result, kind: ast::ImportKind, - follow_symlinks: bool, ) -> crate::CrateResult<()> { if result.flags.is_external() { return Ok(()); @@ -1610,7 +1581,7 @@ impl<'a> Resolver<'a> { // concurrent resolver at a newer generation rewrites this `DirEntry`'s // map in place under that lock. The entry pointer stays valid after // unlock (EntryStore-owned). - if follow_symlinks && let Some(query) = dir.get_entry(self.generation, name.filename) { + if let Some(query) = dir.get_entry(self.generation, name.filename) { // SAFETY: rfs points at the process-global RealFS; the lazy-stat // rewrite inside `symlink()` is serialized on the per-entry mutex. let symlink_path = unsafe { query.entry().symlink(self.rfs_ptr(), self.store_fd) }; diff --git a/test/bundler/bundler_plugin.test.ts b/test/bundler/bundler_plugin.test.ts index 0fc816349da1..a824d6ee25bd 100644 --- a/test/bundler/bundler_plugin.test.ts +++ b/test/bundler/bundler_plugin.test.ts @@ -1904,9 +1904,10 @@ describe("bundler", () => { "node_modules/react/jsx-dev-runtime.js": jsxRuntime("react"), }; - // A file on disk that onResolve names is compiled and tree-shaken with the package.json and - // tsconfig.json that enclose it, like the same file when the resolver finds it. - for (const resolvedBy of ["onResolve", "resolver"] as const) { + // A file is compiled and tree-shaken with the package.json and tsconfig.json that enclose it, whichever + // way the build reaches it: the resolver alone, an onResolve callback that names the file, or the + // resolver after every onResolve callback returned nothing. + for (const resolvedBy of ["the resolver", "onResolve", "the resolver after onResolve declined"] as const) { test.concurrent(`plugin/file resolved by ${resolvedBy} uses its package.json and tsconfig.json`, async () => { const targets = { "component": "app/component.tsx", @@ -1914,13 +1915,14 @@ describe("bundler", () => { "unused": "node_modules/side-effect-free/unused.js", "importer": "esm-pkg/importer.js", }; + const alias = resolvedBy === "onResolve"; using dir = tempDir("plugin-resolved-file-metadata", { ...fakeJsxRuntimes, "entry.js": ` - import { Component } from "${resolvedBy === "onResolve" ? "alias/component" : "./app/component.tsx"}"; - import { secondDecoratorArgument } from "${resolvedBy === "onResolve" ? "alias/decorated" : "./app/decorated.ts"}"; - import "${resolvedBy === "onResolve" ? "alias/unused" : "side-effect-free/unused.js"}"; - import { defaultImportOfCommonJS } from "${resolvedBy === "onResolve" ? "alias/importer" : "./esm-pkg/importer.js"}"; + import { Component } from "${alias ? "alias/component" : "./app/component.tsx"}"; + import { secondDecoratorArgument } from "${alias ? "alias/decorated" : "./app/decorated.ts"}"; + import "${alias ? "alias/unused" : "side-effect-free/unused.js"}"; + import { defaultImportOfCommonJS } from "${alias ? "alias/importer" : "./esm-pkg/importer.js"}"; console.log(JSON.stringify({ jsxImportSource: Component(), secondDecoratorArgument, @@ -1954,18 +1956,22 @@ describe("bundler", () => { target: "bun", throw: false, plugins: - resolvedBy === "onResolve" - ? [ + resolvedBy === "the resolver" + ? [] + : [ { - name: "alias", + name: resolvedBy, setup(build) { - build.onResolve({ filter: /^alias\// }, args => ({ - path: join(root, targets[args.path.slice("alias/".length) as keyof typeof targets]), - })); + if (alias) { + build.onResolve({ filter: /^alias\// }, args => ({ + path: join(root, targets[args.path.slice("alias/".length) as keyof typeof targets]), + })); + } else { + build.onResolve({ filter: /.*/ }, () => undefined); + } }, }, - ] - : [], + ], }); expect({ success: result.success, logs: result.logs.map(log => log.message) }).toEqual({ success: true, @@ -1991,62 +1997,158 @@ describe("bundler", () => { }); } - // The import that lands first creates the module, so a file that onResolve and the resolver both reach - // has to come out the same in either order. The onLoad callback reports that the module exists, and the - // other import waits for it. - test.concurrent("plugin/file reached by onResolve and by the resolver builds the same in either order", async () => { - using dir = tempDir("plugin-resolved-file-join-order", { - ...fakeJsxRuntimes, + // The import that lands first creates the module, so a file that onResolve names and that the resolver + // also finds has to come out the same in either order. The onLoad callback reports that the module + // exists, and the other import waits for it. + for (const otherImport of ["the resolver", "the resolver after onResolve declined"] as const) { + test.concurrent( + `plugin/file reached by onResolve and by ${otherImport} builds the same in either order`, + async () => { + using dir = tempDir("plugin-resolved-file-join-order", { + ...fakeJsxRuntimes, + "entry.js": ` + import "late"; + import { Component } from "alias/component"; + console.log(Component()); + `, + "late.js": `import "./app/component.tsx";`, + "app/tsconfig.json": JSON.stringify({ compilerOptions: { jsx: "react-jsx", jsxImportSource: "preact" } }), + "app/component.tsx": `export const Component = () =>
;`, + }); + const root = String(dir); + + async function build(first: "onResolve" | "the other import") { + const componentExists = Promise.withResolvers(); + const result = await Bun.build({ + entrypoints: [join(root, "entry.js")], + throw: false, + plugins: [ + { + name: "order", + setup(build) { + build.onLoad({ filter: /component\.tsx$/ }, () => { + componentExists.resolve(); + return undefined; + }); + build.onResolve({ filter: /^alias\/component$/ }, async () => { + if (first === "the other import") await componentExists.promise; + return { path: join(root, "app", "component.tsx") }; + }); + build.onResolve({ filter: /^late$/ }, async () => { + if (first === "onResolve") await componentExists.promise; + return { path: join(root, "late.js") }; + }); + if (otherImport === "the resolver after onResolve declined") { + build.onResolve({ filter: /component\.tsx$/ }, () => undefined); + } + }, + }, + ], + }); + expect({ success: result.success, logs: result.logs.map(log => log.message) }).toEqual({ + success: true, + logs: [], + }); + const text = await result.outputs[0].text(); + return { usesPreact: text.includes(`"preact"`), usesReact: text.includes(`"react"`), text }; + } + + const onResolveFirst = await build("onResolve"); + const otherImportFirst = await build("the other import"); + expect({ usesPreact: onResolveFirst.usesPreact, usesReact: onResolveFirst.usesReact }).toEqual({ + usesPreact: true, + usesReact: false, + }); + expect(onResolveFirst.text).toBe(otherImportFirst.text); + }, + ); + } + + // A module that only a plugin can name stays a plugin module: the "sideEffects" of the package.json + // above it does not remove it. The resolver cannot find a path that is not on disk, a file in `files`, + // or a spelling it would not print, so no other import can create these modules. + test.concurrent("plugin/onResolve path the resolver cannot name keeps its side effects", async () => { + using dir = tempDir("plugin-resolved-file-not-on-disk", { + "package.json": JSON.stringify({ name: "app", sideEffects: false }), "entry.js": ` - import "late"; - import { Component } from "alias/component"; - console.log(Component()); + import "alias/not-on-disk"; + import "alias/in-files"; + import "alias/doubled-slash"; + console.log("entry"); `, - "late.js": `import "./app/component.tsx";`, - "app/tsconfig.json": JSON.stringify({ compilerOptions: { jsx: "react-jsx", jsxImportSource: "preact" } }), - "app/component.tsx": `export const Component = () =>
;`, + "src/real.js": `console.log("doubled slash ran");`, }); const root = String(dir); + const targets = { + "not-on-disk": join(root, "src", "not-on-disk.js"), + "in-files": join(root, "src", "in-files.js"), + "doubled-slash": join(root, "src") + path.sep + path.sep + "real.js", + }; - async function build(first: "onResolve" | "resolver") { - const componentExists = Promise.withResolvers(); - const result = await Bun.build({ - entrypoints: [join(root, "entry.js")], - throw: false, - plugins: [ - { - name: "order", - setup(build) { - build.onLoad({ filter: /component\.tsx$/ }, () => { - componentExists.resolve(); - return undefined; - }); - build.onResolve({ filter: /^alias\/component$/ }, async () => { - if (first === "resolver") await componentExists.promise; - return { path: join(root, "app", "component.tsx") }; - }); - build.onResolve({ filter: /^late$/ }, async () => { - if (first === "onResolve") await componentExists.promise; - return { path: join(root, "late.js") }; - }); - }, + const result = await Bun.build({ + entrypoints: [join(root, "entry.js")], + files: { [targets["in-files"]]: `console.log("in files ran");` }, + throw: false, + plugins: [ + { + name: "alias", + setup(build) { + build.onResolve({ filter: /^alias\// }, args => ({ + path: targets[args.path.slice("alias/".length) as keyof typeof targets], + })); + build.onLoad({ filter: /not-on-disk\.js$/ }, () => ({ + contents: `console.log("not on disk ran");`, + loader: "js", + })); }, - ], - }); - expect({ success: result.success, logs: result.logs.map(log => log.message) }).toEqual({ - success: true, - logs: [], - }); - const text = await result.outputs[0].text(); - return { usesPreact: text.includes(`"preact"`), usesReact: text.includes(`"react"`), text }; - } - - const onResolveFirst = await build("onResolve"); - const resolverFirst = await build("resolver"); - expect({ usesPreact: onResolveFirst.usesPreact, usesReact: onResolveFirst.usesReact }).toEqual({ - usesPreact: true, - usesReact: false, + }, + ], + }); + expect({ success: result.success, logs: result.logs.map(log => log.message) }).toEqual({ + success: true, + logs: [], + }); + const text = await result.outputs[0].text(); + expect({ + notOnDisk: text.includes("not on disk ran"), + inFiles: text.includes("in files ran"), + doubledSlash: text.includes("doubled slash ran"), + }).toEqual({ notOnDisk: true, inFiles: true, doubledSlash: true }); + }); + + // The resolver lists a directory once. The bundler asks it about a file that onResolve named only after + // onLoad ran, so a file that onLoad writes next to the module is in that list. + test.concurrent("plugin/onLoad can write a file that the module it loads imports", async () => { + using dir = tempDir("plugin-resolved-file-onload-writes", { + "entry.js": `import "alias/generated";`, + "generated/module.js": `console.log("replaced by onLoad");`, + }); + const root = String(dir); + + const result = await Bun.build({ + entrypoints: [join(root, "entry.js")], + throw: false, + plugins: [ + { + name: "generate", + setup(build) { + build.onResolve({ filter: /^alias\/generated$/ }, () => ({ path: join(root, "generated", "module.js") })); + build.onLoad({ filter: /generated[\\/]module\.js$/ }, async () => { + await Bun.write(join(root, "generated", "sibling.js"), `console.log("sibling ran");`); + return { contents: `import "./sibling.js"; console.log("module ran");`, loader: "js" }; + }); + }, + }, + ], + }); + expect({ success: result.success, logs: result.logs.map(log => log.message) }).toEqual({ + success: true, + logs: [], + }); + const text = await result.outputs[0].text(); + expect({ sibling: text.includes("sibling ran"), module: text.includes("module ran") }).toEqual({ + sibling: true, + module: true, }); - expect(onResolveFirst.text).toBe(resolverFirst.text); }); }); From b468e6dc83620486b2bbf9829cc17d69e8b034ea Mon Sep 17 00:00:00 2001 From: robobun <117481402+robobun@users.noreply.github.com> Date: Fri, 18 Sep 2026 01:15:44 +0000 Subject: [PATCH 3/8] bundler: keep the new comments to one line each --- src/bundler/ParseTask.rs | 7 ++----- src/bundler/bundle_v2.rs | 9 +++------ 2 files changed, 5 insertions(+), 11 deletions(-) diff --git a/src/bundler/ParseTask.rs b/src/bundler/ParseTask.rs index 21f7a2bdfa81..77b014834790 100644 --- a/src/bundler/ParseTask.rs +++ b/src/bundler/ParseTask.rs @@ -116,8 +116,7 @@ pub struct ParseTask { pub(crate) package_version: ast::StoreStr, pub(crate) package_name: ast::StoreStr, pub(crate) is_entry_point: bool, - /// The kind of the import whose `onResolve` answer created this task, until - /// `BundleV2::adopt_resolver_result` has asked the resolver about the file. + /// The import kind of the `onResolve` answer that made this task, until `BundleV2::adopt_resolver_result` runs. pub(crate) created_by_on_resolve: Option, } @@ -276,9 +275,7 @@ impl ParseTask { None => (ast::StoreStr::EMPTY, ast::StoreStr::EMPTY), }; self.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. + // Carries jsxImportSource, runtime and the rest from the tsconfig.json of the file. self.jsx = resolve_result.jsx.clone(); self.module_type = resolve_result.module_type; self.emit_decorator_metadata = resolve_result.flags.emit_decorator_metadata(); diff --git a/src/bundler/bundle_v2.rs b/src/bundler/bundle_v2.rs index c0b0c415158a..36a4ebe3ccd1 100644 --- a/src/bundler/bundle_v2.rs +++ b/src/bundler/bundle_v2.rs @@ -4607,12 +4607,7 @@ pub mod bv2_impl { } impl<'a> BundleV2<'a> { - /// A module that an `onResolve` path created takes what the resolver attaches to the file (side effects, - /// module type, JSX and decorator settings) if the path is byte for byte the one the resolver gives that - /// file. Another import can then reach the same module through the resolver, and the one that lands first - /// creates it, so both have to describe it the same way. Any other path (not on disk, in `files`, through - /// a symlink, not normalized) stays a plugin module. This runs once `onLoad` is done: the resolver lists a - /// directory only once, and `onLoad` can write the files that the module imports. + /// A module that `onResolve` made takes the resolver's result if its path is byte for byte the resolver's. pub(crate) fn adopt_resolver_result(&mut self, task: &mut ParseTask) { let Some(kind) = task.created_by_on_resolve.take() else { return; @@ -4632,6 +4627,7 @@ pub mod bv2_impl { ) else { return; }; + // Only then can another import make this module through the resolver, and the first one to land wins. if result.flags.is_external() || result .path_const() @@ -4714,6 +4710,7 @@ pub mod bv2_impl { this.decrement_scan_counter(); } jsc_api::JSBundler::LoadValue::Success(code) => { + // Not before `onLoad`: it can write files the module imports, and the resolver lists a directory once. this.adopt_resolver_result(load.parse_task_mut()); // `code`: LoadSuccess { source_code, loader } // When a plugin returns a file loader, we always need to populate additional_files From 6007bd2ba0245c837af29bf31ff3ad7cd908b0a3 Mon Sep 17 00:00:00 2001 From: robobun <117481402+robobun@users.noreply.github.com> Date: Fri, 18 Sep 2026 01:48:14 +0000 Subject: [PATCH 4/8] bundler: ask the resolver about an onResolve path only when the path is on disk The resolver remembers a directory that it did not find. A plugin can name a path in a directory that does not exist yet (a virtual module) and create the directory later in the same build. The probe for the virtual module then left the directory marked as not found, and a file that the resolver found there later lost its package.json and tsconfig.json settings. adopt_resolver_result now returns before it calls the resolver when the path is not on disk, so a virtual module touches no resolver cache. --- src/bundler/bundle_v2.rs | 4 +++ test/bundler/bundler_plugin.test.ts | 56 +++++++++++++++++++++++++++++ 2 files changed, 60 insertions(+) diff --git a/src/bundler/bundle_v2.rs b/src/bundler/bundle_v2.rs index 36a4ebe3ccd1..7a7c81e846f1 100644 --- a/src/bundler/bundle_v2.rs +++ b/src/bundler/bundle_v2.rs @@ -4619,6 +4619,10 @@ pub mod bv2_impl { { return; } + // The resolver remembers a directory that it did not find, and a plugin can create that directory later. + if strings::contains_char(task.path.text, 0) || !bun_sys::exists(task.path.text) { + return; + } let transpiler = self.transpiler_for_target(task.known_target); let Ok(result) = transpiler.resolver.resolve( task.path.name().dir_with_trailing_slash(), diff --git a/test/bundler/bundler_plugin.test.ts b/test/bundler/bundler_plugin.test.ts index a824d6ee25bd..bb53f42729bd 100644 --- a/test/bundler/bundler_plugin.test.ts +++ b/test/bundler/bundler_plugin.test.ts @@ -2116,6 +2116,62 @@ describe("bundler", () => { }).toEqual({ notOnDisk: true, inFiles: true, doubledSlash: true }); }); + // The resolver remembers a directory that it did not find. A plugin can name a path in a directory that does + // not exist yet and create the directory later, so the bundler asks the resolver only about a path on disk. + test.concurrent("plugin/onResolve path in a directory that a plugin creates later", async () => { + using dir = tempDir("plugin-resolved-file-late-directory", { + ...fakeJsxRuntimes, + "entry.js": ` + import "alias/virtual"; + import "./creates-directory.js"; + `, + "creates-directory.js": `console.log("replaced by onLoad");`, + "empty.js": ``, + }); + const root = String(dir); + const virtualModuleParsed = Promise.withResolvers(); + + const result = await Bun.build({ + entrypoints: [join(root, "entry.js")], + throw: false, + plugins: [ + { + name: "late-directory", + setup(build) { + build.onResolve({ filter: /^alias\/virtual$/ }, () => ({ path: join(root, "generated", "virtual.js") })); + build.onLoad({ filter: /generated[\\/]virtual\.js$/ }, () => ({ + contents: `import "alias/virtual-parsed";`, + loader: "js", + })); + // The bundler offers this import only after it parsed the virtual module. + build.onResolve({ filter: /^alias\/virtual-parsed$/ }, () => { + virtualModuleParsed.resolve(); + return { path: join(root, "empty.js") }; + }); + build.onLoad({ filter: /creates-directory\.js$/ }, async () => { + await virtualModuleParsed.promise; + await Bun.write( + join(root, "generated", "tsconfig.json"), + JSON.stringify({ compilerOptions: { jsx: "react-jsx", jsxImportSource: "preact" } }), + ); + await Bun.write(join(root, "generated", "component.tsx"), `console.log((() =>
)());`); + return { contents: `import "./generated/component.tsx";`, loader: "js" }; + }); + }, + }, + ], + }); + expect({ success: result.success, logs: result.logs.map(log => log.message) }).toEqual({ + success: true, + logs: [], + }); + const text = await result.outputs[0].text(); + expect({ usesPreact: text.includes(`"preact"`), usesReact: text.includes(`"react"`) }).toEqual({ + usesPreact: true, + usesReact: false, + }); + }); + // The resolver lists a directory once. The bundler asks it about a file that onResolve named only after // onLoad ran, so a file that onLoad writes next to the module is in that list. test.concurrent("plugin/onLoad can write a file that the module it loads imports", async () => { From 2d1cfb179adb241de8e1f5f512361c6d367d4918 Mon Sep 17 00:00:00 2001 From: robobun <117481402+robobun@users.noreply.github.com> Date: Fri, 18 Sep 2026 02:14:08 +0000 Subject: [PATCH 5/8] bundler: keep what the resolver logs out of the build unless the onResolve path is adopted adopt_resolver_result runs Resolver::resolve for a path that onResolve named. The resolver logs into the build log on the way: a directory that it can traverse but not list, a tsconfig.json that does not parse. The result of the call was ignored when the path was not adopted, but the log entry stayed and failed a build that main completes. The call now runs against a scratch log (Resolver::scoped_log). The messages move to the build log only when the module takes the resolver's result, and then they are the messages that the build gets when the resolver finds the file. --- src/bundler/bundle_v2.rs | 25 +++++++++++++---- test/bundler/bundler_plugin.test.ts | 42 +++++++++++++++++++++++++++++ 2 files changed, 62 insertions(+), 5 deletions(-) diff --git a/src/bundler/bundle_v2.rs b/src/bundler/bundle_v2.rs index 7a7c81e846f1..9500191a6369 100644 --- a/src/bundler/bundle_v2.rs +++ b/src/bundler/bundle_v2.rs @@ -4624,11 +4624,25 @@ pub mod bv2_impl { return; } let transpiler = self.transpiler_for_target(task.known_target); - let Ok(result) = transpiler.resolver.resolve( - task.path.name().dir_with_trailing_slash(), - task.path.text, - kind, - ) else { + // What the resolver logs reaches the build log only if the path is adopted. + let mut probe_log = bun_ast::Log { + level: transpiler.resolver.log_mut().level, + ..Default::default() + }; + let result = { + let resolver: *mut _resolver::Resolver<'a> = &raw mut transpiler.resolver; + // SAFETY: the resolver outlives the guard, the guard drops before `probe_log`, and both uses go through `resolver`. + let _restore_log = unsafe { + _resolver::Resolver::scoped_log(resolver, NonNull::from(&mut probe_log)) + }; + // SAFETY: `resolver` points at the live resolver, and no other borrow of it is in use. + unsafe { &mut *resolver }.resolve( + task.path.name().dir_with_trailing_slash(), + task.path.text, + kind, + ) + }; + let Ok(result) = result else { return; }; // Only then can another import make this module through the resolver, and the first one to land wins. @@ -4639,6 +4653,7 @@ pub mod bv2_impl { { return; } + probe_log.append_to_with_recycled(transpiler.resolver.log_mut(), true); let jsx_development = transpiler.options.forced_jsx_development(); task.set_resolver_result(&result); task.jsx.development = jsx_development; diff --git a/test/bundler/bundler_plugin.test.ts b/test/bundler/bundler_plugin.test.ts index bb53f42729bd..bef11af744cf 100644 --- a/test/bundler/bundler_plugin.test.ts +++ b/test/bundler/bundler_plugin.test.ts @@ -2116,6 +2116,48 @@ describe("bundler", () => { }).toEqual({ notOnDisk: true, inFiles: true, doubledSlash: true }); }); + // The bundler asks the resolver about a path that onResolve named. What the resolver logs on the way (here a + // tsconfig.json that does not parse) reaches the build only if the module takes the resolver's result. + test.concurrent( + "plugin/onResolve path that the resolver would print differently adds nothing to the logs", + async () => { + using dir = tempDir("plugin-resolved-file-logs", { + "entry.js": `import "alias/leaf";`, + "not-adopted/tsconfig.json": `{ "compilerOptions": `, + "not-adopted/leaf.js": `console.log("leaf ran");`, + "adopted/tsconfig.json": `{ "compilerOptions": `, + "adopted/leaf.js": `console.log("leaf ran");`, + }); + const root = String(dir); + + async function build(leaf: string) { + const result = await Bun.build({ + entrypoints: [join(root, "entry.js")], + throw: false, + plugins: [ + { + name: "alias", + setup(build) { + build.onResolve({ filter: /^alias\/leaf$/ }, () => ({ path: leaf })); + }, + }, + ], + }); + return { success: result.success, logs: result.logs.map(log => log.message) }; + } + + expect({ + // A doubled separator is not what the resolver prints, so this module stays a plugin module. + notAdopted: await build(join(root, "not-adopted") + path.sep + path.sep + "leaf.js"), + // The same bytes as the resolver's path: the build reports what it reports when the resolver finds the file. + adopted: await build(join(root, "adopted", "leaf.js")), + }).toEqual({ + notAdopted: { success: true, logs: [] }, + adopted: { success: false, logs: ["Unexpected end of file"] }, + }); + }, + ); + // The resolver remembers a directory that it did not find. A plugin can name a path in a directory that does // not exist yet and create the directory later, so the bundler asks the resolver only about a path on disk. test.concurrent("plugin/onResolve path in a directory that a plugin creates later", async () => { From 3f2331902679397112ed69b4d8da8ac39ec1af0c Mon Sep 17 00:00:00 2001 From: robobun <117481402+robobun@users.noreply.github.com> Date: Fri, 18 Sep 2026 02:37:31 +0000 Subject: [PATCH 6/8] bundler: report what the resolver read for an onResolve path, drop only what a failed call logged The resolver parses a package.json or tsconfig.json once per process. When the call for an onResolve path is the first read of a directory, its parse errors are reported then or never: a later read of the cached directory does not repeat them. The previous commit dropped them when the path was not adopted, so a build that main fails (the module imports a sibling and the resolver reads the directory) completed without the error. The messages now reach the build log whenever the call succeeds. They are dropped only when the call fails: then they are about the call itself, for example a directory that the user may traverse but not list, and nothing else in the build asks the resolver about that path. --- src/bundler/bundle_v2.rs | 5 +- test/bundler/bundler_plugin.test.ts | 79 ++++++++++++++++++++--------- 2 files changed, 58 insertions(+), 26 deletions(-) diff --git a/src/bundler/bundle_v2.rs b/src/bundler/bundle_v2.rs index 9500191a6369..e6195f0f63a8 100644 --- a/src/bundler/bundle_v2.rs +++ b/src/bundler/bundle_v2.rs @@ -4624,7 +4624,7 @@ pub mod bv2_impl { return; } let transpiler = self.transpiler_for_target(task.known_target); - // What the resolver logs reaches the build log only if the path is adopted. + // A call that fails logs only about itself (a directory that it may not list), and only this probe makes it. let mut probe_log = bun_ast::Log { level: transpiler.resolver.log_mut().level, ..Default::default() @@ -4645,6 +4645,8 @@ pub mod bv2_impl { let Ok(result) = result else { return; }; + // The resolver parses a package.json or tsconfig.json once per process, so its errors are reported now or never. + probe_log.append_to_with_recycled(transpiler.resolver.log_mut(), true); // Only then can another import make this module through the resolver, and the first one to land wins. if result.flags.is_external() || result @@ -4653,7 +4655,6 @@ pub mod bv2_impl { { return; } - probe_log.append_to_with_recycled(transpiler.resolver.log_mut(), true); let jsx_development = transpiler.options.forced_jsx_development(); task.set_resolver_result(&result); task.jsx.development = jsx_development; diff --git a/test/bundler/bundler_plugin.test.ts b/test/bundler/bundler_plugin.test.ts index bef11af744cf..b8fea0efb058 100644 --- a/test/bundler/bundler_plugin.test.ts +++ b/test/bundler/bundler_plugin.test.ts @@ -1,5 +1,6 @@ import { describe, expect, test } from "bun:test"; -import { bunEnv, bunExe, tempDir } from "harness"; +import { bunEnv, bunExe, isWindows, tempDir } from "harness"; +import { chmodSync } from "node:fs"; import path, { dirname, join, resolve } from "node:path"; import { itBundled } from "./expectBundled"; @@ -2116,21 +2117,56 @@ describe("bundler", () => { }).toEqual({ notOnDisk: true, inFiles: true, doubledSlash: true }); }); - // The bundler asks the resolver about a path that onResolve named. What the resolver logs on the way (here a - // tsconfig.json that does not parse) reaches the build only if the module takes the resolver's result. - test.concurrent( - "plugin/onResolve path that the resolver would print differently adds nothing to the logs", + // The resolver parses a tsconfig.json once per process. When the bundler asks it about a path that onResolve + // named and that read is the first one, the parse error is reported then: a later read would not repeat it. + test.concurrent("plugin/onResolve path reports a tsconfig.json that does not parse", async () => { + using dir = tempDir("plugin-resolved-file-logs", { + "entry.js": `import "alias/leaf";`, + "same-bytes/tsconfig.json": `{ "compilerOptions": `, + "same-bytes/leaf.js": `console.log("leaf ran");`, + "doubled-separator/tsconfig.json": `{ "compilerOptions": `, + "doubled-separator/leaf.js": `console.log("leaf ran");`, + }); + const root = String(dir); + + async function build(leaf: string) { + const result = await Bun.build({ + entrypoints: [join(root, "entry.js")], + throw: false, + plugins: [ + { + name: "alias", + setup(build) { + build.onResolve({ filter: /^alias\/leaf$/ }, () => ({ path: leaf })); + }, + }, + ], + }); + return { success: result.success, logs: result.logs.map(log => log.message) }; + } + + expect({ + sameBytes: await build(join(root, "same-bytes", "leaf.js")), + doubledSeparator: await build(join(root, "doubled-separator") + path.sep + path.sep + "leaf.js"), + }).toEqual({ + sameBytes: { success: false, logs: ["Unexpected end of file"] }, + doubledSeparator: { success: false, logs: ["Unexpected end of file"] }, + }); + }); + + // A directory that the user may traverse but not list: the file in it can be read, and that is all a module + // needs. The resolver cannot list the directory and logs an error. Without the plugin path nothing asks the + // resolver about this directory, so that error must not reach the build. + test.skipIf(isWindows || process.getuid?.() === 0)( + "plugin/onResolve path in a directory that cannot be listed still builds", async () => { - using dir = tempDir("plugin-resolved-file-logs", { + using dir = tempDir("plugin-resolved-file-unlistable", { "entry.js": `import "alias/leaf";`, - "not-adopted/tsconfig.json": `{ "compilerOptions": `, - "not-adopted/leaf.js": `console.log("leaf ran");`, - "adopted/tsconfig.json": `{ "compilerOptions": `, - "adopted/leaf.js": `console.log("leaf ran");`, + "unlistable/leaf.js": `console.log("leaf ran");`, }); const root = String(dir); - - async function build(leaf: string) { + chmodSync(join(root, "unlistable"), 0o311); + try { const result = await Bun.build({ entrypoints: [join(root, "entry.js")], throw: false, @@ -2138,23 +2174,18 @@ describe("bundler", () => { { name: "alias", setup(build) { - build.onResolve({ filter: /^alias\/leaf$/ }, () => ({ path: leaf })); + build.onResolve({ filter: /^alias\/leaf$/ }, () => ({ path: join(root, "unlistable", "leaf.js") })); }, }, ], }); - return { success: result.success, logs: result.logs.map(log => log.message) }; + expect({ success: result.success, logs: result.logs.map(log => log.message) }).toEqual({ + success: true, + logs: [], + }); + } finally { + chmodSync(join(root, "unlistable"), 0o755); } - - expect({ - // A doubled separator is not what the resolver prints, so this module stays a plugin module. - notAdopted: await build(join(root, "not-adopted") + path.sep + path.sep + "leaf.js"), - // The same bytes as the resolver's path: the build reports what it reports when the resolver finds the file. - adopted: await build(join(root, "adopted", "leaf.js")), - }).toEqual({ - notAdopted: { success: true, logs: [] }, - adopted: { success: false, logs: ["Unexpected end of file"] }, - }); }, ); From 0fbe971e41beaa086c29e16ebe50295f30bbc7c5 Mon Sep 17 00:00:00 2001 From: robobun <117481402+robobun@users.noreply.github.com> Date: Fri, 18 Sep 2026 02:54:46 +0000 Subject: [PATCH 7/8] test: do not pin what a doubled separator does to the resolver call on Windows On Windows the path with a doubled separator does not give a resolver result, so the build stays silent, as on main. The case that pins a resolver call that succeeds without adoption now uses a symlinked directory and runs on POSIX only. The byte-identical path runs on every platform. --- test/bundler/bundler_plugin.test.ts | 66 ++++++++++++++--------------- 1 file changed, 33 insertions(+), 33 deletions(-) diff --git a/test/bundler/bundler_plugin.test.ts b/test/bundler/bundler_plugin.test.ts index b8fea0efb058..55b14cac6511 100644 --- a/test/bundler/bundler_plugin.test.ts +++ b/test/bundler/bundler_plugin.test.ts @@ -1,6 +1,6 @@ import { describe, expect, test } from "bun:test"; import { bunEnv, bunExe, isWindows, tempDir } from "harness"; -import { chmodSync } from "node:fs"; +import { chmodSync, symlinkSync } from "node:fs"; import path, { dirname, join, resolve } from "node:path"; import { itBundled } from "./expectBundled"; @@ -2119,40 +2119,40 @@ describe("bundler", () => { // The resolver parses a tsconfig.json once per process. When the bundler asks it about a path that onResolve // named and that read is the first one, the parse error is reported then: a later read would not repeat it. - test.concurrent("plugin/onResolve path reports a tsconfig.json that does not parse", async () => { - using dir = tempDir("plugin-resolved-file-logs", { - "entry.js": `import "alias/leaf";`, - "same-bytes/tsconfig.json": `{ "compilerOptions": `, - "same-bytes/leaf.js": `console.log("leaf ran");`, - "doubled-separator/tsconfig.json": `{ "compilerOptions": `, - "doubled-separator/leaf.js": `console.log("leaf ran");`, - }); - const root = String(dir); + // A path through a symlinked directory is not the path the resolver prints, so the module does not take the + // resolver's result, but the resolver still read the directory. + for (const spelling of ["the resolver's path", "a path through a symlinked directory"] as const) { + test.skipIf(isWindows && spelling !== "the resolver's path")( + `plugin/onResolve with ${spelling} reports a tsconfig.json that does not parse`, + async () => { + using dir = tempDir("plugin-resolved-file-logs", { + "entry.js": `import "alias/leaf";`, + "broken/tsconfig.json": `{ "compilerOptions": `, + "broken/leaf.js": `console.log("leaf ran");`, + }); + const root = String(dir); + if (spelling !== "the resolver's path") symlinkSync(join(root, "broken"), join(root, "link"), "dir"); + const leaf = join(root, spelling === "the resolver's path" ? "broken" : "link", "leaf.js"); - async function build(leaf: string) { - const result = await Bun.build({ - entrypoints: [join(root, "entry.js")], - throw: false, - plugins: [ - { - name: "alias", - setup(build) { - build.onResolve({ filter: /^alias\/leaf$/ }, () => ({ path: leaf })); + const result = await Bun.build({ + entrypoints: [join(root, "entry.js")], + throw: false, + plugins: [ + { + name: "alias", + setup(build) { + build.onResolve({ filter: /^alias\/leaf$/ }, () => ({ path: leaf })); + }, }, - }, - ], - }); - return { success: result.success, logs: result.logs.map(log => log.message) }; - } - - expect({ - sameBytes: await build(join(root, "same-bytes", "leaf.js")), - doubledSeparator: await build(join(root, "doubled-separator") + path.sep + path.sep + "leaf.js"), - }).toEqual({ - sameBytes: { success: false, logs: ["Unexpected end of file"] }, - doubledSeparator: { success: false, logs: ["Unexpected end of file"] }, - }); - }); + ], + }); + expect({ success: result.success, logs: result.logs.map(log => log.message) }).toEqual({ + success: false, + logs: ["Unexpected end of file"], + }); + }, + ); + } // A directory that the user may traverse but not list: the file in it can be read, and that is all a module // needs. The resolver cannot list the directory and logs an error. Without the plugin path nothing asks the From b87696426bdb56bda77239e36256aba8b34360b5 Mon Sep 17 00:00:00 2001 From: robobun <117481402+robobun@users.noreply.github.com> Date: Fri, 18 Sep 2026 03:20:01 +0000 Subject: [PATCH 8/8] bundler: a failed resolver call for an onResolve path drops only its own directory errors When the call fails, the resolver has already parsed and cached the package.json and tsconfig.json of every directory above the one it could not list. Their parse errors were dropped with the rest of the scratch log, and a later import below such a directory did not report them again, so a build that main fails completed. The messages of a failed call now reach the build log too, without the "Cannot read directory" errors: nothing else asks the resolver to list that directory. The two tests that are skipped on some platforms run concurrently like the rest. --- src/bundler/bundle_v2.rs | 17 +++++- test/bundler/bundler_plugin.test.ts | 85 ++++++++++++++++++----------- 2 files changed, 67 insertions(+), 35 deletions(-) diff --git a/src/bundler/bundle_v2.rs b/src/bundler/bundle_v2.rs index e6195f0f63a8..5c01f3aac5e0 100644 --- a/src/bundler/bundle_v2.rs +++ b/src/bundler/bundle_v2.rs @@ -4624,7 +4624,7 @@ pub mod bv2_impl { return; } let transpiler = self.transpiler_for_target(task.known_target); - // A call that fails logs only about itself (a directory that it may not list), and only this probe makes it. + // The resolver logs to a scratch log: the build must not fail because this call cannot list a directory. let mut probe_log = bun_ast::Log { level: transpiler.resolver.log_mut().level, ..Default::default() @@ -4642,11 +4642,22 @@ pub mod bv2_impl { kind, ) }; + if result.is_err() { + // Nothing else asks the resolver about this path, so a directory that it may not list is not a build error. + probe_log + .msgs + .retain(|msg| !msg.data.text.starts_with(b"Cannot read directory")); + let errors = probe_log + .msgs + .iter() + .filter(|msg| msg.kind == bun_ast::Kind::Err); + probe_log.errors = u32::try_from(errors.count()).expect("int cast"); + } + // The resolver parses a package.json or tsconfig.json once per process, so its errors are reported now or never. + probe_log.append_to_with_recycled(transpiler.resolver.log_mut(), true); let Ok(result) = result else { return; }; - // The resolver parses a package.json or tsconfig.json once per process, so its errors are reported now or never. - probe_log.append_to_with_recycled(transpiler.resolver.log_mut(), true); // Only then can another import make this module through the resolver, and the first one to land wins. if result.flags.is_external() || result diff --git a/test/bundler/bundler_plugin.test.ts b/test/bundler/bundler_plugin.test.ts index 55b14cac6511..0dcd3c50505a 100644 --- a/test/bundler/bundler_plugin.test.ts +++ b/test/bundler/bundler_plugin.test.ts @@ -2122,7 +2122,7 @@ describe("bundler", () => { // A path through a symlinked directory is not the path the resolver prints, so the module does not take the // resolver's result, but the resolver still read the directory. for (const spelling of ["the resolver's path", "a path through a symlinked directory"] as const) { - test.skipIf(isWindows && spelling !== "the resolver's path")( + test.concurrent.skipIf(isWindows && spelling !== "the resolver's path")( `plugin/onResolve with ${spelling} reports a tsconfig.json that does not parse`, async () => { using dir = tempDir("plugin-resolved-file-logs", { @@ -2156,38 +2156,59 @@ describe("bundler", () => { // A directory that the user may traverse but not list: the file in it can be read, and that is all a module // needs. The resolver cannot list the directory and logs an error. Without the plugin path nothing asks the - // resolver about this directory, so that error must not reach the build. - test.skipIf(isWindows || process.getuid?.() === 0)( - "plugin/onResolve path in a directory that cannot be listed still builds", - async () => { - using dir = tempDir("plugin-resolved-file-unlistable", { - "entry.js": `import "alias/leaf";`, - "unlistable/leaf.js": `console.log("leaf ran");`, - }); - const root = String(dir); - chmodSync(join(root, "unlistable"), 0o311); - try { - const result = await Bun.build({ - entrypoints: [join(root, "entry.js")], - throw: false, - plugins: [ - { - name: "alias", - setup(build) { - build.onResolve({ filter: /^alias\/leaf$/ }, () => ({ path: join(root, "unlistable", "leaf.js") })); - }, - }, - ], - }); - expect({ success: result.success, logs: result.logs.map(log => log.message) }).toEqual({ - success: true, - logs: [], + // resolver about this directory, so that error must not reach the build. A tsconfig.json above it that does + // not parse is another matter: the resolver parsed it on the way, and the import of the sibling, which + // starts only after that, would not report it again. + for (const tsconfigAbove of ["no tsconfig.json", "a tsconfig.json that does not parse"] as const) { + test.concurrent.skipIf(isWindows || process.getuid?.() === 0)( + `plugin/onResolve path in a directory that cannot be listed, below ${tsconfigAbove}`, + async () => { + const broken = tsconfigAbove !== "no tsconfig.json"; + using dir = tempDir("plugin-resolved-file-unlistable", { + "entry.js": ` + import "alias/leaf"; + import "alias/late"; + `, + "late.js": `import "./lib/sibling.js";`, + "lib/sibling.js": `console.log("sibling ran");`, + "lib/unlistable/leaf.js": `console.log("leaf ran");`, + ...(broken ? { "lib/tsconfig.json": `{ "compilerOptions": ` } : {}), }); - } finally { - chmodSync(join(root, "unlistable"), 0o755); - } - }, - ); + const root = String(dir); + chmodSync(join(root, "lib", "unlistable"), 0o311); + try { + const leafLoads = Promise.withResolvers(); + const result = await Bun.build({ + entrypoints: [join(root, "entry.js")], + throw: false, + plugins: [ + { + name: "alias", + setup(build) { + build.onResolve({ filter: /^alias\/leaf$/ }, () => ({ + path: join(root, "lib", "unlistable", "leaf.js"), + })); + build.onLoad({ filter: /unlistable[\\/]leaf\.js$/ }, () => { + leafLoads.resolve(); + return undefined; + }); + build.onResolve({ filter: /^alias\/late$/ }, async () => { + await leafLoads.promise; + return { path: join(root, "late.js") }; + }); + }, + }, + ], + }); + expect({ success: result.success, logs: result.logs.map(log => log.message) }).toEqual( + broken ? { success: false, logs: ["Unexpected end of file"] } : { success: true, logs: [] }, + ); + } finally { + chmodSync(join(root, "lib", "unlistable"), 0o755); + } + }, + ); + } // The resolver remembers a directory that it did not find. A plugin can name a path in a directory that does // not exist yet and create the directory later, so the bundler asks the resolver only about a path on disk.