From ca46a53756e03d1c3b7fe47c8dec87e07ba33c53 Mon Sep 17 00:00:00 2001 From: robobun <117481402+robobun@users.noreply.github.com> Date: Fri, 21 Aug 2026 05:13:51 +0000 Subject: [PATCH 1/3] bundler: resolve a barrel's import records once schedule_barrel_deferred_imports resolved a barrel again whenever the requested record had no source_index. An external record, a record whose resolution failed, and a record waiting for an onResolve plugin never get one, so every importer of such a re-export ran the resolver (and the onResolve plugins) over the barrel again. A pass that did un-defer a record also resolved those records again, because resolve_import_records only skipped records with a source_index. Resolve a barrel only when the BFS item un-deferred a record in it, and mark every record that resolve_import_records handles with RESOLVE_STARTED so a later pass skips it. The barrels_to_resolve map and the trailing loop are gone: each un-deferral resolves inline. --- src/ast/import_record.rs | 7 ++ src/bundler/barrel_imports.rs | 89 +++++++------------ src/bundler/bundle_v2.rs | 23 +++-- test/bundler/bundler_barrel.test.ts | 129 ++++++++++++++++++++++++++++ 4 files changed, 183 insertions(+), 65 deletions(-) diff --git a/src/ast/import_record.rs b/src/ast/import_record.rs index 290da2cf7f71..f23be8de25b1 100644 --- a/src/ast/import_record.rs +++ b/src/ast/import_record.rs @@ -72,6 +72,13 @@ bitflags::bitflags! { const WAS_ORIGINALLY_REQUIRE = 1 << 9; + /// The bundler already ran resolution for this record. Also set when + /// resolution leaves `source_index` invalid: the import is external, + /// failed to resolve, or is waiting for an onResolve plugin. The + /// barrel optimization resolves a barrel's records again when it + /// un-defers one of them; records with this flag are skipped. + const RESOLVE_STARTED = 1 << 10; + /// If true, this import can be removed if it's unused const IS_EXTERNAL_WITHOUT_SIDE_EFFECTS = 1 << 11; diff --git a/src/bundler/barrel_imports.rs b/src/bundler/barrel_imports.rs index 86cc210a5783..467e1b05081e 100644 --- a/src/bundler/barrel_imports.rs +++ b/src/bundler/barrel_imports.rs @@ -315,13 +315,11 @@ struct BarrelWorkItem<'a> { is_star: bool, } -/// Resolve, process, and patch import records for a single barrel. -/// Used to inline-resolve deferred records whose source_index is still invalid. -fn resolve_barrel_records( - this: &mut BundleV2, - barrel_idx: u32, - barrels_to_resolve: &mut ArrayHashMap, -) -> i32 { +/// Resolve, process, and patch import records for a single barrel. Called +/// right after the BFS un-defers a record in it; `resolve_import_records` +/// only touches the records it has not handled before, so this resolves the +/// records un-deferred since the previous call. +fn resolve_barrel_records(this: &mut BundleV2, barrel_idx: u32) -> i32 { let idx = barrel_idx as usize; let target = this.graph.ast.items_target()[idx]; let loader = this.graph.input_files.items_loader()[idx]; @@ -360,15 +358,18 @@ fn resolve_barrel_records( this.graph.ast.items_import_records_mut()[idx] = barrel_ir; - let _ = barrels_to_resolve.swap_remove(&barrel_idx); scheduled } /// After a new file's import records are patched with source_indices, /// record what this file requests from each target in requested_exports /// (eagerly, before barrels are known), then BFS through barrel chains -/// to un-defer needed records. Un-deferred records are re-resolved through -/// resolveImportRecords (same path as initial resolution). +/// to un-defer needed records. Un-deferred records are resolved through +/// resolveImportRecords (same path as initial resolution) as soon as they are +/// un-deferred, so the BFS can continue into the modules they point at. A +/// request for a record that was never deferred resolves nothing: that record +/// was handled when the barrel itself was resolved, whether or not it ended +/// up with a source_index (external, unresolved, or onResolve plugin pending). /// Returns the number of newly scheduled parse tasks. pub(crate) fn schedule_barrel_deferred_imports( this: &mut BundleV2, @@ -679,8 +680,6 @@ pub(crate) fn schedule_barrel_deferred_imports( // dedup via requested_exports to prevent cycles. let initial_queue_len = queue.len(); - let mut barrels_to_resolve: ArrayHashMap = ArrayHashMap::default(); - let mut newly_scheduled: i32 = 0; let mut qi: usize = 0; while qi < queue.len() { @@ -740,16 +739,12 @@ pub(crate) fn schedule_barrel_deferred_imports( if flags.contains(import_record::Flags::IS_UNUSED) && !flags.contains(import_record::Flags::IS_INTERNAL) { - if un_defer_record(barrel_ir, idx) { - barrels_to_resolve.put(barrel_idx, ())?; - un_deferred_any = true; - } + un_deferred_any |= un_defer_record(barrel_ir, idx); } } // Resolve now: propagation below needs source indices. if un_deferred_any { - newly_scheduled += - resolve_barrel_records(this, barrel_idx, &mut barrels_to_resolve); + newly_scheduled += resolve_barrel_records(this, barrel_idx); } // A namespace request covers every export: request each @@ -846,18 +841,12 @@ pub(crate) fn schedule_barrel_deferred_imports( continue; } if un_defer_record(barrel_ir, star_idx as usize) { - barrels_to_resolve.put(barrel_idx, ())?; - } - let mut star_rec_si = barrel_ir.as_slice()[star_idx as usize].source_index; - if !star_rec_si.is_valid() { - // Deferred record was never resolved — resolve inline now. - newly_scheduled += - resolve_barrel_records(this, barrel_idx, &mut barrels_to_resolve); - // Re-derive after resolution may have mutated slices. - star_rec_si = this.graph.ast.items_import_records_mut()[barrel_idx as usize] - .as_slice()[star_idx as usize] - .source_index; + // Resolve now: propagation below needs the source index. + newly_scheduled += resolve_barrel_records(this, barrel_idx); } + let star_rec_si = this.graph.ast.items_import_records()[barrel_idx as usize] + .as_slice()[star_idx as usize] + .source_index; if star_rec_si.is_valid() { queue.push(BarrelWorkItem { barrel_source_index: star_rec_si.get(), @@ -872,7 +861,8 @@ pub(crate) fn schedule_barrel_deferred_imports( let barrel_ir = &mut this.graph.ast.items_import_records_mut()[barrel_idx as usize]; if un_defer_record(barrel_ir, resolution.import_record_index as usize) { - barrels_to_resolve.put(barrel_idx, ())?; + // Resolve now: propagation below needs the source index. + newly_scheduled += resolve_barrel_records(this, barrel_idx); } // `original_alias` is an arena-backed `StoreStr` valid for the @@ -881,38 +871,23 @@ pub(crate) fn schedule_barrel_deferred_imports( Some(p) => p.slice(), None => alias, }; - if (resolution.import_record_index as usize) < barrel_ir.len() { - let mut rec_si = - barrel_ir.as_slice()[resolution.import_record_index as usize].source_index; - if !rec_si.is_valid() { - // Deferred record was never resolved — resolve inline now. - newly_scheduled += - resolve_barrel_records(this, barrel_idx, &mut barrels_to_resolve); - rec_si = this.graph.ast.items_import_records_mut()[barrel_idx as usize].as_slice() - [resolution.import_record_index as usize] - .source_index; - } - if rec_si.is_valid() { - // When the barrel re-exports a namespace import (`import * as X; export { X }`), - // propagate as a star import so the target barrel loads all exports. - queue.push(BarrelWorkItem { - barrel_source_index: rec_si.get(), - alias: propagate_alias, - is_star: resolution.alias_is_star, - }); - } + let rec_si = this.graph.ast.items_import_records()[barrel_idx as usize] + .as_slice() + .get(resolution.import_record_index as usize) + .map(|rec| rec.source_index); + if let Some(rec_si) = rec_si.filter(|si| si.is_valid()) { + // When the barrel re-exports a namespace import (`import * as X; export { X }`), + // propagate as a star import so the target barrel loads all exports. + queue.push(BarrelWorkItem { + barrel_source_index: rec_si.get(), + alias: propagate_alias, + is_star: resolution.alias_is_star, + }); } qi += 1; } - // Re-resolve any remaining un-deferred records through the normal resolution path. - while barrels_to_resolve.count() > 0 { - let barrel_source_index = barrels_to_resolve.keys()[0]; - newly_scheduled += - resolve_barrel_records(this, barrel_source_index, &mut barrels_to_resolve); - } - Ok(newly_scheduled) } diff --git a/src/bundler/bundle_v2.rs b/src/bundler/bundle_v2.rs index 7b2eb2dc85fa..d9873824271a 100644 --- a/src/bundler/bundle_v2.rs +++ b/src/bundler/bundle_v2.rs @@ -5946,7 +5946,8 @@ pub mod bv2_impl { impl<'a> BundleV2<'a> { /// Resolve all unresolved import records for a module. Skips records that - /// are already resolved (valid source_index), unused, or internal. + /// are already resolved (valid source_index), that an earlier call + /// already handled (`RESOLVE_STARTED`), unused, or internal. /// Returns a resolve queue of new modules to schedule, plus any fatal error. /// Used by both initial parse resolution and barrel un-deferral. pub(crate) fn resolve_import_records( @@ -5979,13 +5980,11 @@ pub mod bv2_impl { import_record.source_index = Index::INVALID; } - estimated_resolve_queue_count += (!(import_record - .flags - .contains(bun_ast::ImportRecordFlags::IS_INTERNAL) - || import_record - .flags - .contains(bun_ast::ImportRecordFlags::IS_UNUSED) - || import_record.source_index.is_valid())) + estimated_resolve_queue_count += (!(import_record.flags.intersects( + bun_ast::ImportRecordFlags::IS_INTERNAL + | bun_ast::ImportRecordFlags::IS_UNUSED + | bun_ast::ImportRecordFlags::RESOLVE_STARTED, + ) || import_record.source_index.is_valid())) as usize; } let mut resolve_queue = ResolveQueue::default(); @@ -6006,9 +6005,17 @@ pub mod bv2_impl { || import_record.flags.contains(bun_ast::ImportRecordFlags::IS_INTERNAL) // Don't resolve pre-resolved imports || import_record.source_index.is_valid() + // Don't resolve a record twice. Barrel un-deferral runs this over + // the whole list again, and a record that came out external, + // unresolved, or waiting on an onResolve plugin has no + // source_index to show that it was already handled. + || import_record.flags.contains(bun_ast::ImportRecordFlags::RESOLVE_STARTED) { continue; } + import_record + .flags + .insert(bun_ast::ImportRecordFlags::RESOLVE_STARTED); if let Some(fw) = &self.framework { if fw.server_components.is_some() { diff --git a/test/bundler/bundler_barrel.test.ts b/test/bundler/bundler_barrel.test.ts index 6b7177534d49..45fa43ae3f05 100644 --- a/test/bundler/bundler_barrel.test.ts +++ b/test/bundler/bundler_barrel.test.ts @@ -1395,6 +1395,135 @@ describe("bundler", () => { run: { stdout: "resolved-by-plugin" }, }); + // A re-export that resolves as external never gets a source_index. Requests + // for it from importers must not resolve the barrel again, so the onResolve + // plugin runs once for the record. + itBundled("barrel/ExternalReExportResolvesOnce", () => { + const resolved: string[] = []; + return { + files: { + "/entry.js": /* js */ ` + import { React } from 'extlib'; + import { other } from './other.js'; + console.log(React, other); + `, + "/other.js": /* js */ ` + import { React } from 'extlib'; + export const other = React; + `, + "/node_modules/extlib/package.json": JSON.stringify({ + name: "extlib", + main: "./index.js", + sideEffects: false, + }), + "/node_modules/extlib/index.js": /* js */ ` + export { default as React } from 'react'; + export { B } from './b.js'; + `, + // Nobody imports B, so b.js must stay deferred. + "/node_modules/extlib/b.js": /* js */ ` + export const B = <<>>; + `, + }, + outfile: "/out.js", + plugins(builder) { + resolved.length = 0; + builder.onResolve({ filter: /^react$/ }, args => { + resolved.push(args.path); + return { path: args.path, external: true }; + }); + }, + onAfterBundle(api) { + expect(resolved).toEqual(["react"]); + api.expectFile("/out.js").toContain('from "react"'); + }, + }; + }); + + // Un-deferring one record resolves the barrel again. Records that were + // already resolved, including the external one, are skipped by that pass. + // late.js is loaded only after the react answer, so its request for A + // arrives after the barrel deferred a.js. + itBundled("barrel/ExternalReExportNotResolvedAgainOnUnDefer", () => { + const resolved: string[] = []; + return { + files: { + "/entry.js": /* js */ ` + import { React } from 'latelib'; + import './late.js'; + console.log(React); + `, + "/late.js": ``, + "/node_modules/latelib/package.json": JSON.stringify({ + name: "latelib", + main: "./index.js", + sideEffects: false, + }), + "/node_modules/latelib/index.js": /* js */ ` + export { default as React } from 'react'; + export { A } from './a.js'; + export { B } from './b.js'; + `, + "/node_modules/latelib/a.js": /* js */ ` + export const A = "late-lib-a"; + `, + // Nobody imports B, so b.js must stay deferred. + "/node_modules/latelib/b.js": /* js */ ` + export const B = <<>>; + `, + }, + outfile: "/out.js", + plugins(builder) { + resolved.length = 0; + const reactResolved = Promise.withResolvers(); + builder.onResolve({ filter: /^react$/ }, args => { + resolved.push(args.path); + reactResolved.resolve(); + return { path: args.path, external: true }; + }); + builder.onLoad({ filter: /late\.js$/ }, async () => { + await reactResolved.promise; + return { contents: `import { A } from 'latelib'; console.log(A);`, loader: "js" }; + }); + }, + onAfterBundle(api) { + expect(resolved).toEqual(["react"]); + api.expectFile("/out.js").toContain('from "react"'); + api.expectFile("/out.js").toContain("late-lib-a"); + }, + }; + }); + + // When the plugin does not answer, the record falls back to the resolver and + // the failure is reported. Resolving the barrel again reported it twice. + itBundled("barrel/UnresolvableReExportReportedOnce", { + files: { + "/entry.js": /* js */ ` + import { Missing } from 'missinglib'; + console.log(Missing); + `, + "/node_modules/missinglib/package.json": JSON.stringify({ + name: "missinglib", + main: "./index.js", + sideEffects: false, + }), + "/node_modules/missinglib/index.js": /* js */ ` + export { Missing } from 'not-installed-pkg'; + export { B } from './b.js'; + `, + "/node_modules/missinglib/b.js": /* js */ ` + export const B = "b"; + `, + }, + outfile: "/out.js", + plugins(builder) { + builder.onResolve({ filter: /^not-installed-pkg$/ }, () => undefined); + }, + bundleErrors: { + "/node_modules/missinglib/index.js": ['Could not resolve: "not-installed-pkg"'], + }, + }); + // --- Load plugin + barrel optimization --- itBundled("barrel/LoadPlugin", { From 822248b55b674c2056b559751f5424abc9aae29e Mon Sep 17 00:00:00 2001 From: robobun <117481402+robobun@users.noreply.github.com> Date: Fri, 21 Aug 2026 11:53:42 +0000 Subject: [PATCH 2/3] bundler: pass the un-deferred record indices to the barrel resolve pass Replace the RESOLVE_STARTED import record flag with an index list. The BFS knows which records it just un-deferred, so resolve_barrel_records hands exactly those to resolve_import_records and to patch_import_record_source_indices (only_records on both contexts, which also replaces force_save). The barrel's other records are not resolved or patched again, and no ImportRecord flag bit is used. Add a plugin-free test: un-deferring a second record of a barrel used to report its unresolvable sibling a second time. --- src/ast/import_record.rs | 7 ---- src/bundler/barrel_imports.rs | 47 ++++++++++----------- src/bundler/bundle_v2.rs | 65 +++++++++++++++++++---------- test/bundler/bundler_barrel.test.ts | 43 +++++++++++++++++-- 4 files changed, 104 insertions(+), 58 deletions(-) diff --git a/src/ast/import_record.rs b/src/ast/import_record.rs index f23be8de25b1..290da2cf7f71 100644 --- a/src/ast/import_record.rs +++ b/src/ast/import_record.rs @@ -72,13 +72,6 @@ bitflags::bitflags! { const WAS_ORIGINALLY_REQUIRE = 1 << 9; - /// The bundler already ran resolution for this record. Also set when - /// resolution leaves `source_index` invalid: the import is external, - /// failed to resolve, or is waiting for an onResolve plugin. The - /// barrel optimization resolves a barrel's records again when it - /// un-defers one of them; records with this flag are skipped. - const RESOLVE_STARTED = 1 << 10; - /// If true, this import can be removed if it's unused const IS_EXTERNAL_WITHOUT_SIDE_EFFECTS = 1 << 11; diff --git a/src/bundler/barrel_imports.rs b/src/bundler/barrel_imports.rs index 467e1b05081e..b7f055bdefb9 100644 --- a/src/bundler/barrel_imports.rs +++ b/src/bundler/barrel_imports.rs @@ -315,11 +315,11 @@ struct BarrelWorkItem<'a> { is_star: bool, } -/// Resolve, process, and patch import records for a single barrel. Called -/// right after the BFS un-defers a record in it; `resolve_import_records` -/// only touches the records it has not handled before, so this resolves the -/// records un-deferred since the previous call. -fn resolve_barrel_records(this: &mut BundleV2, barrel_idx: u32) -> i32 { +/// Resolve the records (`un_deferred`, ascending indices) that the BFS just +/// un-deferred in one barrel, schedule the modules they point at, and patch +/// their source indices. The barrel's other records were handled when the +/// barrel itself was resolved and are left alone. +fn resolve_barrel_records(this: &mut BundleV2, barrel_idx: u32, un_deferred: &[u32]) -> i32 { let idx = barrel_idx as usize; let target = this.graph.ast.items_target()[idx]; let loader = this.graph.input_files.items_loader()[idx]; @@ -338,6 +338,7 @@ fn resolve_barrel_records(this: &mut BundleV2, barrel_idx: u32) -> i32 { source: &source, loader, target, + only_records: Some(un_deferred), }); this.graph.input_files.items_source_mut()[idx] = source; @@ -351,7 +352,7 @@ fn resolve_barrel_records(this: &mut BundleV2, barrel_idx: u32) -> i32 { source_path, loader, target, - force_save: true, + only_records: Some(un_deferred), ..Default::default() }, ); @@ -364,12 +365,12 @@ fn resolve_barrel_records(this: &mut BundleV2, barrel_idx: u32) -> i32 { /// After a new file's import records are patched with source_indices, /// record what this file requests from each target in requested_exports /// (eagerly, before barrels are known), then BFS through barrel chains -/// to un-defer needed records. Un-deferred records are resolved through -/// resolveImportRecords (same path as initial resolution) as soon as they are -/// un-deferred, so the BFS can continue into the modules they point at. A -/// request for a record that was never deferred resolves nothing: that record -/// was handled when the barrel itself was resolved, whether or not it ended -/// up with a source_index (external, unresolved, or onResolve plugin pending). +/// to un-defer needed records. Each record is resolved through +/// resolveImportRecords (same path as initial resolution) as soon as it is +/// un-deferred, so the BFS can continue into the module it points at. Only +/// the un-deferred records are resolved: a request for a record that was +/// never deferred resolves nothing, whether or not that record has a +/// source_index (external, unresolved, or onResolve plugin pending). /// Returns the number of newly scheduled parse tasks. pub(crate) fn schedule_barrel_deferred_imports( this: &mut BundleV2, @@ -731,20 +732,15 @@ pub(crate) fn schedule_barrel_deferred_imports( let barrel_ir = &mut this.graph.ast.items_import_records_mut()[barrel_idx as usize]; if item_is_star { - // Read flags by index, then mutate (borrowck). - let len = barrel_ir.len(); - let mut un_deferred_any = false; - for idx in 0..len { - let flags = barrel_ir.as_slice()[idx].flags; - if flags.contains(import_record::Flags::IS_UNUSED) - && !flags.contains(import_record::Flags::IS_INTERNAL) - { - un_deferred_any |= un_defer_record(barrel_ir, idx); + let mut un_deferred: Vec = Vec::new(); + for idx in 0..barrel_ir.len() { + if un_defer_record(barrel_ir, idx) { + un_deferred.push(idx as u32); } } // Resolve now: propagation below needs source indices. - if un_deferred_any { - newly_scheduled += resolve_barrel_records(this, barrel_idx); + if !un_deferred.is_empty() { + newly_scheduled += resolve_barrel_records(this, barrel_idx, &un_deferred); } // A namespace request covers every export: request each @@ -842,7 +838,7 @@ pub(crate) fn schedule_barrel_deferred_imports( } if un_defer_record(barrel_ir, star_idx as usize) { // Resolve now: propagation below needs the source index. - newly_scheduled += resolve_barrel_records(this, barrel_idx); + newly_scheduled += resolve_barrel_records(this, barrel_idx, &[star_idx]); } let star_rec_si = this.graph.ast.items_import_records()[barrel_idx as usize] .as_slice()[star_idx as usize] @@ -862,7 +858,8 @@ pub(crate) fn schedule_barrel_deferred_imports( let barrel_ir = &mut this.graph.ast.items_import_records_mut()[barrel_idx as usize]; if un_defer_record(barrel_ir, resolution.import_record_index as usize) { // Resolve now: propagation below needs the source index. - newly_scheduled += resolve_barrel_records(this, barrel_idx); + newly_scheduled += + resolve_barrel_records(this, barrel_idx, &[resolution.import_record_index]); } // `original_alias` is an arena-backed `StoreStr` valid for the diff --git a/src/bundler/bundle_v2.rs b/src/bundler/bundle_v2.rs index d9873824271a..d1bceedd6e23 100644 --- a/src/bundler/bundle_v2.rs +++ b/src/bundler/bundle_v2.rs @@ -5879,6 +5879,7 @@ pub mod bv2_impl { source: &result.source, loader: result.loader, target, + only_records: None, }); if let Some(err) = resolve_result.last_error { @@ -5937,6 +5938,8 @@ pub mod bv2_impl { pub(crate) source: &'a bun_ast::Source, pub(crate) loader: Loader, pub(crate) target: options::Target, + /// See `only_selected_record`. + pub(crate) only_records: Option<&'a [u32]>, } pub(crate) struct ResolveImportRecordResult { @@ -5944,10 +5947,20 @@ pub mod bv2_impl { pub(crate) last_error: Option, } + /// `only_records` is `None` when a file is resolved after it was parsed: every + /// record takes part. Barrel un-deferral passes the indices (ascending) of the + /// records it just un-deferred. The other records of the barrel were handled + /// when the barrel was parsed, and a record that is external, failed to + /// resolve, or waits for an onResolve plugin has no `source_index` to show it. + #[inline] + fn only_selected_record(only_records: Option<&[u32]>, record_index: usize) -> bool { + only_records.is_none_or(|only| only.binary_search(&(record_index as u32)).is_ok()) + } + impl<'a> BundleV2<'a> { - /// Resolve all unresolved import records for a module. Skips records that - /// are already resolved (valid source_index), that an earlier call - /// already handled (`RESOLVE_STARTED`), unused, or internal. + /// Resolve all unresolved import records for a module (or only + /// `ctx.only_records`). Skips records that are already resolved (valid + /// source_index), unused, or internal. /// Returns a resolve queue of new modules to schedule, plus any fatal error. /// Used by both initial parse resolution and barrel un-deferral. pub(crate) fn resolve_import_records( @@ -5957,6 +5970,8 @@ pub mod bv2_impl { let source = ctx.source; let loader = ctx.loader; let source_dir = source.path.source_dir(); + let only_records = ctx.only_records; + debug_assert!(only_records.is_none_or(<[u32]>::is_sorted)); let mut estimated_resolve_queue_count: usize = 0; for import_record in ctx.import_records.iter_mut() { if import_record @@ -5980,19 +5995,28 @@ pub mod bv2_impl { import_record.source_index = Index::INVALID; } - estimated_resolve_queue_count += (!(import_record.flags.intersects( - bun_ast::ImportRecordFlags::IS_INTERNAL - | bun_ast::ImportRecordFlags::IS_UNUSED - | bun_ast::ImportRecordFlags::RESOLVE_STARTED, - ) || import_record.source_index.is_valid())) + estimated_resolve_queue_count += (!(import_record + .flags + .contains(bun_ast::ImportRecordFlags::IS_INTERNAL) + || import_record + .flags + .contains(bun_ast::ImportRecordFlags::IS_UNUSED) + || import_record.source_index.is_valid())) as usize; } + if let Some(only) = only_records { + estimated_resolve_queue_count = estimated_resolve_queue_count.min(only.len()); + } let mut resolve_queue = ResolveQueue::default(); resolve_queue.reserve(estimated_resolve_queue_count); let mut last_error: Option = None; 'outer: for (i, import_record) in ctx.import_records.iter_mut().enumerate() { + if !only_selected_record(only_records, i) { + continue; + } + // Preserve original import specifier before resolution modifies path if import_record.original_path.is_empty() { import_record.original_path = import_record.path.text; @@ -6005,17 +6029,9 @@ pub mod bv2_impl { || import_record.flags.contains(bun_ast::ImportRecordFlags::IS_INTERNAL) // Don't resolve pre-resolved imports || import_record.source_index.is_valid() - // Don't resolve a record twice. Barrel un-deferral runs this over - // the whole list again, and a record that came out external, - // unresolved, or waiting on an onResolve plugin has no - // source_index to show that it was already handled. - || import_record.flags.contains(bun_ast::ImportRecordFlags::RESOLVE_STARTED) { continue; } - import_record - .flags - .insert(bun_ast::ImportRecordFlags::RESOLVE_STARTED); if let Some(fw) = &self.framework { if fw.server_components.is_some() { @@ -6765,9 +6781,10 @@ pub mod bv2_impl { pub(crate) loader: Loader, pub(crate) target: options::Target, pub(crate) redirect_import_record_index: u32, - /// When true, always save source indices regardless of dev_server/loader. - /// Used for barrel un-deferral where records must always be connected. - pub(crate) force_save: bool, + /// See `only_selected_record`. Barrel un-deferral (`Some`) always saves + /// the source indices of the records it passes, regardless of + /// dev_server/loader: the BFS follows them into the next barrel. + pub(crate) only_records: Option<&'a [u32]>, } impl Default for PatchImportRecordsCtx<'_> { @@ -6778,7 +6795,7 @@ pub mod bv2_impl { loader: Loader::File, target: Target::Browser, redirect_import_record_index: u32::MAX, - force_save: false, + only_records: None, } } } @@ -6796,7 +6813,8 @@ pub mod bv2_impl { // across the `&mut self.graph.build_graphs[...]` borrow // below, so address the disjoint `self.graph.*` fields directly instead. let input_file_loaders = self.graph.input_files.items_loader(); - let save_import_record_source_index = ctx.force_save + debug_assert!(ctx.only_records.is_none_or(<[u32]>::is_sorted)); + let save_import_record_source_index = ctx.only_records.is_some() || self.dev_server.is_none() || ctx.loader == Loader::Html || ctx.loader.is_css(); @@ -6823,6 +6841,9 @@ pub mod bv2_impl { // so borrowck sees it as disjoint from `self.graph.input_files` above. let path_to_source_index_map = &mut self.graph.build_graphs[ctx.target]; for (i, record) in import_records.as_mut_slice().iter_mut().enumerate() { + if !only_selected_record(ctx.only_records, i) { + continue; + } if let Some(source_index) = path_to_source_index_map.get_path(&record.path) { if save_import_record_source_index || input_file_loaders[source_index as usize].is_css() @@ -7134,7 +7155,7 @@ pub mod bv2_impl { loader: result.loader, target: result.ast.target, redirect_import_record_index: result.ast.redirect_import_record_index, - force_save: false, + only_records: None, }, ); diff --git a/test/bundler/bundler_barrel.test.ts b/test/bundler/bundler_barrel.test.ts index 45fa43ae3f05..1f93ddb892e5 100644 --- a/test/bundler/bundler_barrel.test.ts +++ b/test/bundler/bundler_barrel.test.ts @@ -1395,6 +1395,41 @@ describe("bundler", () => { run: { stdout: "resolved-by-plugin" }, }); + // --- Each barrel record is resolved once --- + + // a.js is only discovered through the barrel, so its request for Broken and C + // always arrives after the barrel deferred both. Un-deferring Broken reports + // the failure. Un-deferring C must not resolve Broken (and report it) again. + itBundled("barrel/UnDeferReportsUnresolvableSiblingOnce", { + files: { + "/entry.js": /* js */ ` + import { A } from 'oncelib'; + console.log(A); + `, + "/node_modules/oncelib/package.json": JSON.stringify({ + name: "oncelib", + main: "./index.js", + sideEffects: false, + }), + "/node_modules/oncelib/index.js": /* js */ ` + export { A } from './a.js'; + export { Broken } from './missing.js'; + export { C } from './c.js'; + `, + "/node_modules/oncelib/a.js": /* js */ ` + import { Broken, C } from 'oncelib'; + export const A = Broken + C; + `, + "/node_modules/oncelib/c.js": /* js */ ` + export const C = "c"; + `, + }, + outfile: "/out.js", + bundleErrors: { + "/node_modules/oncelib/index.js": ['Could not resolve: "./missing.js"'], + }, + }); + // A re-export that resolves as external never gets a source_index. Requests // for it from importers must not resolve the barrel again, so the onResolve // plugin runs once for the record. @@ -1440,10 +1475,10 @@ describe("bundler", () => { }; }); - // Un-deferring one record resolves the barrel again. Records that were - // already resolved, including the external one, are skipped by that pass. - // late.js is loaded only after the react answer, so its request for A - // arrives after the barrel deferred a.js. + // Un-deferring a.js resolves only that record. The external record, which + // also has no source_index, is not dispatched to the plugin again. late.js is + // loaded only after the react answer, so its request for A arrives after the + // barrel deferred a.js. itBundled("barrel/ExternalReExportNotResolvedAgainOnUnDefer", () => { const resolved: string[] = []; return { From e94e9a01979d91e7ff35a5bb57c3f45f50b9e06a Mon Sep 17 00:00:00 2001 From: robobun <117481402+robobun@users.noreply.github.com> Date: Fri, 21 Aug 2026 12:02:28 +0000 Subject: [PATCH 3/3] bundler: shorten the only_records doc comments --- src/bundler/barrel_imports.rs | 15 +++++---------- src/bundler/bundle_v2.rs | 18 +++++++----------- 2 files changed, 12 insertions(+), 21 deletions(-) diff --git a/src/bundler/barrel_imports.rs b/src/bundler/barrel_imports.rs index b7f055bdefb9..099465bfa446 100644 --- a/src/bundler/barrel_imports.rs +++ b/src/bundler/barrel_imports.rs @@ -315,10 +315,8 @@ struct BarrelWorkItem<'a> { is_star: bool, } -/// Resolve the records (`un_deferred`, ascending indices) that the BFS just -/// un-deferred in one barrel, schedule the modules they point at, and patch -/// their source indices. The barrel's other records were handled when the -/// barrel itself was resolved and are left alone. +/// Resolve the records the BFS just un-deferred in one barrel (`un_deferred`, +/// ascending indices), schedule their modules, and patch their source indices. fn resolve_barrel_records(this: &mut BundleV2, barrel_idx: u32, un_deferred: &[u32]) -> i32 { let idx = barrel_idx as usize; let target = this.graph.ast.items_target()[idx]; @@ -365,12 +363,9 @@ fn resolve_barrel_records(this: &mut BundleV2, barrel_idx: u32, un_deferred: &[u /// After a new file's import records are patched with source_indices, /// record what this file requests from each target in requested_exports /// (eagerly, before barrels are known), then BFS through barrel chains -/// to un-defer needed records. Each record is resolved through -/// resolveImportRecords (same path as initial resolution) as soon as it is -/// un-deferred, so the BFS can continue into the module it points at. Only -/// the un-deferred records are resolved: a request for a record that was -/// never deferred resolves nothing, whether or not that record has a -/// source_index (external, unresolved, or onResolve plugin pending). +/// to un-defer needed records. Each un-deferred record is resolved at once +/// through resolveImportRecords (same path as initial resolution), so the BFS +/// can continue into the module it points at. /// Returns the number of newly scheduled parse tasks. pub(crate) fn schedule_barrel_deferred_imports( this: &mut BundleV2, diff --git a/src/bundler/bundle_v2.rs b/src/bundler/bundle_v2.rs index d1bceedd6e23..06615c4f6923 100644 --- a/src/bundler/bundle_v2.rs +++ b/src/bundler/bundle_v2.rs @@ -5947,20 +5947,17 @@ pub mod bv2_impl { pub(crate) last_error: Option, } - /// `only_records` is `None` when a file is resolved after it was parsed: every - /// record takes part. Barrel un-deferral passes the indices (ascending) of the - /// records it just un-deferred. The other records of the barrel were handled - /// when the barrel was parsed, and a record that is external, failed to - /// resolve, or waits for an onResolve plugin has no `source_index` to show it. + /// `only_records`: barrel un-deferral passes the ascending indices of the records + /// it just un-deferred. A missing `source_index` does not mean a record is still + /// unresolved (external, failed, or waiting for an onResolve plugin). #[inline] fn only_selected_record(only_records: Option<&[u32]>, record_index: usize) -> bool { only_records.is_none_or(|only| only.binary_search(&(record_index as u32)).is_ok()) } impl<'a> BundleV2<'a> { - /// Resolve all unresolved import records for a module (or only - /// `ctx.only_records`). Skips records that are already resolved (valid - /// source_index), unused, or internal. + /// Resolve all unresolved import records for a module, or only `ctx.only_records`. + /// Skips records that are already resolved (valid source_index), unused, or internal. /// Returns a resolve queue of new modules to schedule, plus any fatal error. /// Used by both initial parse resolution and barrel un-deferral. pub(crate) fn resolve_import_records( @@ -6781,9 +6778,8 @@ pub mod bv2_impl { pub(crate) loader: Loader, pub(crate) target: options::Target, pub(crate) redirect_import_record_index: u32, - /// See `only_selected_record`. Barrel un-deferral (`Some`) always saves - /// the source indices of the records it passes, regardless of - /// dev_server/loader: the BFS follows them into the next barrel. + /// See `only_selected_record`. `Some` also saves the source indices regardless + /// of dev_server/loader: the barrel BFS follows them. pub(crate) only_records: Option<&'a [u32]>, }