diff --git a/src/bundler/barrel_imports.rs b/src/bundler/barrel_imports.rs index 39715de37b48..cb6c70282c3a 100644 --- a/src/bundler/barrel_imports.rs +++ b/src/bundler/barrel_imports.rs @@ -11,12 +11,14 @@ use crate::bundled_ast as JSAst; use crate::mal_prelude::*; use bun_alloc::AllocError; use bun_ast::{ImportKind, import_record}; -use bun_collections::{ArrayHashMap, AutoBitSet, StringArrayHashMap}; +use bun_collections::{ArrayHashMap, ArrayIdentityContextU64, AutoBitSet, StringArrayHashMap}; use crate::Graph::InputFileFlags; use crate::Index; +use crate::bake_types; use crate::bundle_v2::BundleV2; use crate::bundle_v2::bv2_impl::{PatchImportRecordsCtx, ResolveImportRecordCtx}; +use crate::options_impl::TargetExt as _; use crate::parse_task; bun_core::declare_scope!(barrel, hidden); @@ -32,6 +34,102 @@ impl Default for RequestedExports { } } +/// Dev server: what every build requested from each barrel that has deferred a record. +#[derive(Default)] +pub struct TrackedBarrels { + /// By `bake_types::Side`: the server and SSR copies of a file share one graph record. + by_side: [StringArrayHashMap; 2], +} + +impl TrackedBarrels { + #[inline] + fn get(&self, side: bake_types::Side, path: &[u8]) -> Option<&RequestedExports> { + let barrels = &self.by_side[side as usize]; + // A lookup hashes the path: skip it for the projects that track no barrel. + if barrels.is_empty() { + return None; + } + barrels.get(path) + } + + /// True if the copy of this file on `side` has an entry. + pub fn is_tracked(&self, side: bake_types::Side, path: &[u8]) -> bool { + self.get(side, path).is_some() + } + + /// A client component boundary reads the copy of the other side: either side's entry counts. + pub fn must_reparse(&self, path: &[u8]) -> bool { + self.by_side + .iter() + .any(|barrels| !barrels.is_empty() && barrels.contains_key(path)) + } + + pub fn memory_cost(&self) -> usize { + let mut cost = 0; + for barrels in &self.by_side { + cost += barrels.capacity() + * (size_of::>() + size_of::() + size_of::()); + for (path, requested) in barrels.iter() { + cost += path.len(); + if let RequestedExports::Partial(names) = requested { + cost += names.capacity() * (size_of::>() + size_of::()); + cost += names.keys().iter().map(|name| name.len()).sum::(); + } + } + } + cost + } +} + +/// Dev server: the source indices of a build by the hash of their path. +#[derive(Default)] +pub struct SourcesByPath { + newest: ArrayHashMap, + /// For each indexed source, the next older source with the same hash, or `u32::MAX`. + older: Vec, +} + +impl SourcesByPath { + fn index_new_sources(&mut self, sources: &[bun_ast::Source]) -> Result<(), AllocError> { + for index in self.older.len()..sources.len() { + let gop = self + .newest + .get_or_put(bun_wyhash::hash(sources[index].path.text))?; + self.older.push(if gop.found_existing { + *gop.value_ptr + } else { + u32::MAX + }); + *gop.value_ptr = index as u32; + } + Ok(()) + } + + /// The other indexed sources that have the path of `source_index`. + fn others_with_path<'a>( + &'a self, + sources: &'a [bun_ast::Source], + path: &'a [u8], + source_index: u32, + ) -> impl Iterator + 'a { + let mut next = self + .newest + .get(&bun_wyhash::hash(path)) + .copied() + .unwrap_or(u32::MAX); + core::iter::from_fn(move || { + while next != u32::MAX { + let index = next; + next = self.older[index as usize]; + if index != source_index && sources[index as usize].path.text == path { + return Some(index); + } + } + None + }) + } +} + impl RequestedExports { /// `get_or_put`-shaped entry on the dense `Vec>` storage. /// Returns `(found_existing, &mut value)`; inserts `Default` when absent. @@ -50,6 +148,23 @@ impl RequestedExports { fn lookup(map: &[Option], idx: u32) -> Option<&Self> { map.get(idx as usize).and_then(Option::as_ref) } + + /// Adds what `other` requests. Returns the number of names that are new. + fn add(&mut self, other: &Self) -> Result { + let RequestedExports::Partial(kept) = self else { + return Ok(0); + }; + let mut added = 0; + match other { + RequestedExports::All => *self = RequestedExports::All, + RequestedExports::Partial(names) => { + for name in names.keys() { + added += !kept.get_or_put(name)?.found_existing as usize; + } + } + } + Ok(added) + } } // `original_alias` is stored as the raw arena `*const [u8]` (the @@ -190,19 +305,56 @@ fn apply_barrel_optimization_impl( // `DevServerHandle` is `Copy`; copied out so the `&self` borrow // doesn't conflict with later `&mut this.requested_exports`. let dev_handle = this.dev_server_handle().copied(); + let side = ast.target.bake_graph().side(); if let Some(dev) = dev_handle { - // SAFETY: barrel_needed_exports is owned by DevServer; bundler runs on the bundle - // thread which holds the DevServer lock during this callback. - let needed = unsafe { &*dev.barrel_needed_exports() }; - if let Some(persisted) = needed.get(result.source.path.text) { - for alias in persisted.keys() { - if let Some(resolution) = - resolve_barrel_export(alias, &ast.named_exports, &ast.named_imports) - { - needed_records.put(resolution.import_record_index, ())?; + // A file that was not there to read has a second index: count the requests under the first. + let sources = this.graph.input_files.items_source(); + this.sources_by_path.index_new_sources(sources)?; + for other in + this.sources_by_path + .others_with_path(sources, result.source.path.text, source_index) + { + let Some(requested) = RequestedExports::lookup(&this.requested_exports, other) else { + continue; + }; + if copy_side(this, other).is_some_and(|other_side| other_side != side) { + continue; + } + match requested { + RequestedExports::All => return Ok(()), + RequestedExports::Partial(partial) => { + for key in partial.keys() { + if let Some(resolution) = + resolve_barrel_export(key, &ast.named_exports, &ast.named_imports) + { + needed_records.put(resolution.import_record_index, ())?; + } + } } } } + + // SAFETY: the DevServer owns `tracked_barrels` and outlives the bundle. Only the JS thread + // writes it, and no reference from this pointer is held across a call into the DevServer. + let is_tracked = match unsafe { &*dev.tracked_barrels() }.get(side, result.source.path.text) + { + Some(RequestedExports::All) => return Ok(()), + Some(RequestedExports::Partial(persisted)) => { + for alias in persisted.keys() { + if let Some(resolution) = + resolve_barrel_export(alias, &ast.named_exports, &ast.named_imports) + { + needed_records.put(resolution.import_record_index, ())?; + } + } + true + } + None => false, + }; + // Bundled and not tracked: no copy deferred a record, and no build recorded a request. + if !is_tracked && dev.has_file(result.source.path.key_for_incremental_graph(), side) { + return Ok(()); + } } // When HMR is active, ConvertESMExportsForHmr deduplicates import records @@ -284,16 +436,90 @@ fn apply_barrel_optimization_impl( // Merge with existing entry (keep already-requested names) or create new let _ = RequestedExports::entry(&mut this.requested_exports, source_index); + } - // Register with DevServer so isFileCached returns null for this barrel, - // ensuring it gets re-parsed on every incremental build. This is needed - // because the set of needed exports can change when importing files change. - if let Some(dev) = dev_handle { - dev.register_barrel_with_deferrals(result.source.path.text) - .map_err(|_| AllocError)?; - } + if has_deferrals && let Some(dev) = dev_handle { + // SAFETY: see the read of `tracked_barrels` above. + unsafe { &mut *dev.tracked_barrels() }.by_side[side as usize] + .get_or_put(result.source.path.text)?; + } + + Ok(()) +} + +/// Dev server: the side of the copy with this source index. `None`: the file was not there to read. +fn copy_side(this: &BundleV2, source_index: u32) -> Option { + let index = source_index as usize; + if this.graph.ast.items_parts()[index].len() != 0 { + return Some(this.graph.ast.items_target()[index].bake_graph().side()); } + // A copy that did not parse has no target: the path map that still has it tells the side. + let path = this.graph.input_files.items_source()[index].path.text; + this.graph + .build_graphs + .iter() + .find_map(|(target, path_to_source_index)| { + (path_to_source_index.get(path) == Some(source_index)) + .then(|| target.bake_graph().side()) + }) +} +/// Dev server, once per build: adds the requests of this build to the entries of `TrackedBarrels`. +pub(crate) fn persist_requests_of_tracked_barrels( + this: &BundleV2, + dev: &crate::dispatch::DevServerHandle, +) -> Result<(), AllocError> { + // SAFETY: see the read of `tracked_barrels` in `apply_barrel_optimization_impl`. + let tracked = unsafe { &mut *dev.tracked_barrels() }; + if tracked.by_side.iter().all(|barrels| barrels.is_empty()) { + return Ok(()); + } + let sources = this.graph.input_files.items_source(); + let mut lookups: usize = 0; + let mut names_added: usize = 0; + // By source index: the path maps can point a path at another copy within a build. + for (index, requested) in this.requested_exports.iter().enumerate() { + let Some(requested) = requested else { + continue; + }; + let side = copy_side(this, index as u32); + for (barrels_side, barrels) in tracked.by_side.iter_mut().enumerate() { + if barrels.is_empty() || side.is_some_and(|side| side as usize != barrels_side) { + continue; + } + lookups += 1; + if let Some(kept) = barrels.get_mut(sources[index].path.text) { + names_added += kept.add(requested)?; + } + } + } + // A request under the generated copy of a client component boundary is for the original file. + let boundaries = this.graph.server_component_boundaries.slice(); + for (&original, &generated) in boundaries + .list + .items_source_index() + .iter() + .zip(boundaries.list.items_reference_source_index()) + { + let Some(requested) = RequestedExports::lookup(&this.requested_exports, generated) else { + continue; + }; + let Some(side) = copy_side(this, original) else { + continue; + }; + lookups += 1; + if let Some(kept) = + tracked.by_side[side as usize].get_mut(sources[original as usize].path.text) + { + names_added += kept.add(requested)?; + } + } + bun_core::scoped_log!( + barrel, + "persisted requests of tracked barrels: {} lookups, {} names added", + lookups, + names_added, + ); Ok(()) } @@ -556,10 +782,6 @@ pub(crate) fn schedule_barrel_deferred_imports( p.put(alias, ())?; } } - // Persist the export request on DevServer so it survives across builds. - if let Some(dev) = dev_handle { - persist_barrel_export(&dev, resolved_path_text, alias); - } } } @@ -995,10 +1217,54 @@ pub(crate) fn schedule_barrel_deferred_imports( Ok(newly_scheduled) } -/// Persist an export name for a barrel file on the DevServer. Called during -/// seeding so that exports requested in previous builds are not lost when the -/// barrel is re-parsed in an incremental build where the requesting file is -/// not stale. -fn persist_barrel_export(dev: &crate::dispatch::DevServerHandle, barrel_path: &[u8], alias: &[u8]) { - dev.register_barrel_export(barrel_path, alias) +#[cfg(test)] +mod tests { + use super::SourcesByPath; + + fn sources(paths: &[&'static [u8]]) -> Vec { + paths + .iter() + .map(|&text| { + let mut source = bun_ast::Source::default(); + source.path.text = text; + source + }) + .collect() + } + + fn others(index: &SourcesByPath, sources: &[bun_ast::Source], of: u32) -> Vec { + index + .others_with_path(sources, sources[of as usize].path.text, of) + .collect() + } + + #[test] + fn sources_by_path_finds_the_other_indices_of_a_path() { + let mut index = SourcesByPath::default(); + let mut all = sources(&[b"/p/a.js", b"/p/b.js"]); + index.index_new_sources(&all).unwrap(); + assert_eq!(others(&index, &all, 0), [0u32; 0]); + + all.extend(sources(&[b"/p/a.js", b"/p/c.js", b"/p/a.js"])); + index.index_new_sources(&all).unwrap(); + assert_eq!(others(&index, &all, 4), [2, 0]); + assert_eq!(others(&index, &all, 2), [4, 0]); + assert_eq!(others(&index, &all, 0), [4, 2]); + assert_eq!(others(&index, &all, 1), [0u32; 0]); + assert_eq!(others(&index, &all, 3), [0u32; 0]); + } + + #[test] + fn sources_by_path_keeps_two_paths_with_one_hash_apart() { + // A 16-byte chunk that starts with wyhash's secret word zeroes the state: the next word is lost. + const A: &[u8] = b"\xdb\x28\xb4\xa0\xd1\x7e\x03\xe7AAAAAAAA/node_modules/lib/index.js"; + const B: &[u8] = b"\xdb\x28\xb4\xa0\xd1\x7e\x03\xe7BBBBBBBB/node_modules/lib/index.js"; + assert_eq!(bun_wyhash::hash(A), bun_wyhash::hash(B)); + + let all = sources(&[A, B, A]); + let mut index = SourcesByPath::default(); + index.index_new_sources(&all).unwrap(); + assert_eq!(others(&index, &all, 2), [0]); + assert_eq!(others(&index, &all, 1), [0u32; 0]); + } } diff --git a/src/bundler/bundle_v2.rs b/src/bundler/bundle_v2.rs index 201b32587eeb..2ceb93228ffd 100644 --- a/src/bundler/bundle_v2.rs +++ b/src/bundler/bundle_v2.rs @@ -143,6 +143,8 @@ pub struct BundleV2<'a> { /// dense and this is probed once per import in `on_parse_task_complete` /// (the main-thread parse-phase throughput limiter). pub(crate) requested_exports: Vec>, + /// Dev server: lets a barrel find the requests under another source index of its path. + pub(crate) sources_by_path: crate::barrel_imports::SourcesByPath, } bun_core::declare_scope!(Bundle, visible); @@ -386,6 +388,15 @@ pub mod bv2_impl { } } } + impl Graph { + /// The dev server keeps the server and SSR copies of a file in one graph. + pub fn side(self) -> Side { + match self { + Graph::Client => Side::Client, + Graph::Server | Graph::Ssr => Side::Server, + } + } + } /// The type of `CacheEntry.kind`. #[repr(u8)] #[derive(Copy, Clone, Eq, PartialEq, Debug)] @@ -3315,6 +3326,7 @@ pub mod bv2_impl { asynchronous: false, has_any_top_level_await_modules: false, requested_exports: Vec::new(), + sources_by_path: Default::default(), }); if let Some(bo) = bake_options { // SAFETY: `bo.client_transpiler` is the caller's live, write-capable @@ -5943,6 +5955,8 @@ pub mod bv2_impl { .cast::() }; + barrel_imports::persist_requests_of_tracked_barrels(self, dev_server)?; + /* arena: help_catch_memory_issues — no-op (mimalloc TLH check) */ self.clone_ast().map_err(|_| AllocError)?; diff --git a/src/bundler/lib.rs b/src/bundler/lib.rs index 7bb80e2f5695..dd92e3bee1d9 100644 --- a/src/bundler/lib.rs +++ b/src/bundler/lib.rs @@ -394,17 +394,16 @@ pub use bundle_v2::dispatch; // it mutates through, hence `*mut`. bun_dispatch::link_interface! { pub DevServerHandle[Bake] { - fn barrel_needed_exports() -> *mut bun_collections::StringArrayHashMap>; + fn tracked_barrels() -> *mut barrel_imports::TrackedBarrels; fn log_for_resolution_failures(abs_path: &[u8], graph: bake_types::Graph) -> *mut bun_ast::Log; fn finalize_bundle(bv2: *mut bundle_v2::BundleV2<'_>, result: *mut bundle_v2::DevServerOutput<'_>) -> Result<(), crate::Error>; fn handle_parse_task_failure(err: crate::Error, graph: bake_types::Graph, abs_path: &[u8], log: *const bun_ast::Log, bv2: *mut bundle_v2::BundleV2<'_>) -> Result<(), crate::Error>; fn put_or_overwrite_asset(path: *const (), contents: &[u8], content_hash: u64) -> Result<(), crate::Error>; fn track_resolution_failure(import_source: &[u8], specifier: &[u8], renderer: bake_types::Graph, loader: bun_ast::Loader) -> Result<(), crate::Error>; fn is_file_cached(abs_path: &[u8], side: bake_types::Graph) -> Option; + fn has_file(abs_path: &[u8], side: bake_types::Side) -> bool; fn asset_hash(abs_path: &[u8]) -> Option; fn current_bundle_start_data() -> *mut (); - fn register_barrel_with_deferrals(path: &[u8]) -> Result<(), crate::Error>; - fn register_barrel_export(barrel_path: &[u8], alias: &[u8]); } } // SAFETY: the handle is `{ kind, owner: *mut () }`; the raw pointer is what diff --git a/src/runtime/bake/dev_server/bundle.rs b/src/runtime/bake/dev_server/bundle.rs index 4d89a6307d11..db9f518e1cad 100644 --- a/src/runtime/bake/dev_server/bundle.rs +++ b/src/runtime/bake/dev_server/bundle.rs @@ -86,7 +86,7 @@ pub(crate) struct CacheEntry { impl DevServer { pub(crate) fn is_file_cached(&mut self, path: &[u8], side: bake::Graph) -> Option { // Barrel files with deferred records must always be re-parsed. - if self.barrel_files_with_deferrals.contains_key(path) { + if self.tracked_barrels.must_reparse(path) { return None; } @@ -124,6 +124,21 @@ impl DevServer { } } + /// True if an earlier build can have bundled a copy of this file for `side`, stale or not. + pub(crate) fn has_file(&self, path: &[u8], side: bake::Side) -> bool { + let _g = self.graph_safety_lock.guard(); + match side { + // A failure resets the kind of a client record, so every record counts. + bake::Side::Client => self.client_graph.bundled_files.contains_key(path), + // Only a bundled copy gives a server record this kind, and a failure keeps it. + bake::Side::Server => self + .server_graph + .bundled_files + .get(path) + .is_some_and(|file| file.file_kind() == FileKind::Js), + } + } + fn append_opaque_entry_point( &self, file_names: &[Box<[u8]>], diff --git a/src/runtime/bake/dev_server/bundler_vtable.rs b/src/runtime/bake/dev_server/bundler_vtable.rs index db7b6e155b59..9c480f11a0d2 100644 --- a/src/runtime/bake/dev_server/bundler_vtable.rs +++ b/src/runtime/bake/dev_server/bundler_vtable.rs @@ -8,7 +8,7 @@ use super::*; bun_bundler::link_impl_DevServerHandle! { Bake for DevServer => |this| { - barrel_needed_exports() => &raw mut (*this).barrel_needed_exports, + tracked_barrels() => &raw mut (*this).tracked_barrels, log_for_resolution_failures(abs_path, graph) => { match (*this).get_log_for_resolution_failures(abs_path, graph) { Ok(log) => log, @@ -54,6 +54,7 @@ bun_bundler::link_impl_DevServerHandle! { } }) }, + has_file(abs_path, side) => (*this).has_file(abs_path, side), asset_hash(abs_path) => (*this).assets.get_hash(abs_path), current_bundle_start_data() => { (*this) @@ -62,20 +63,6 @@ bun_bundler::link_impl_DevServerHandle! { .map(|c| (&raw mut c.start_data).cast::<()>()) .unwrap_or(core::ptr::null_mut()) }, - register_barrel_with_deferrals(path) => { - let _ = (*this) - .barrel_files_with_deferrals - .get_or_put(path) - .map_err(|_| bun_alloc::out_of_memory()); - Ok(()) - }, - register_barrel_export(barrel_path, alias) => { - // Silently drop on alloc failure. - let Ok(gop) = (*this).barrel_needed_exports.get_or_put(barrel_path) else { - return; - }; - let _ = gop.value_ptr.get_or_put(alias); - }, } } diff --git a/src/runtime/bake/dev_server/finalize.rs b/src/runtime/bake/dev_server/finalize.rs index bb475fa8d50e..99a6b394a20c 100644 --- a/src/runtime/bake/dev_server/finalize.rs +++ b/src/runtime/bake/dev_server/finalize.rs @@ -328,6 +328,19 @@ pub(super) fn finalize_bundle( } continue; } + // Only an entry makes a later build parse this barrel and keep its importers' requests. + debug_assert!( + !import_records[index.get() as usize] + .iter() + .any(|record| record + .flags + .contains(bun_ast::ImportRecordFlags::IS_BARREL_DEFERRED)) + || dev.tracked_barrels.is_tracked( + targets[index.get() as usize].bake_graph().side(), + ctx.sources[index.get() as usize].path.text, + ), + "a barrel with a deferred record is not in tracked_barrels" + ); let source_map: bun_sourcemap::Chunk = match compile_result.source_map_chunk() { Some(c) => c.clone(), None => 'brk: { diff --git a/src/runtime/bake/dev_server/lifecycle.rs b/src/runtime/bake/dev_server/lifecycle.rs index bab406f28ded..0944c0eac5fb 100644 --- a/src/runtime/bake/dev_server/lifecycle.rs +++ b/src/runtime/bake/dev_server/lifecycle.rs @@ -71,8 +71,7 @@ pub(crate) fn init(options: Options) -> JsResult> { ); w!(client_graph, IncrementalGraph::default()); w!(server_graph, IncrementalGraph::default()); - w!(barrel_files_with_deferrals, Default::default()); - w!(barrel_needed_exports, Default::default()); + w!(tracked_barrels, Default::default()); w!(incremental_result, IncrementalResult::EMPTY); w!(route_lookup, Default::default()); w!(route_bundles, Vec::new()); @@ -559,8 +558,7 @@ impl Drop for DevServer { graph_safety_lock: _, client_graph: _, server_graph: _, - barrel_files_with_deferrals: _, - barrel_needed_exports: _, + tracked_barrels: _, incremental_result: _, route_lookup: _, html_router: _, diff --git a/src/runtime/bake/dev_server/memory_cost.rs b/src/runtime/bake/dev_server/memory_cost.rs index 963a6f439991..8b95e763acab 100644 --- a/src/runtime/bake/dev_server/memory_cost.rs +++ b/src/runtime/bake/dev_server/memory_cost.rs @@ -52,8 +52,7 @@ pub(crate) fn memory_cost_detailed(dev: &DevServer) -> MemoryCost { graph_safety_lock: _, client_graph: _, server_graph: _, - barrel_files_with_deferrals: _, - barrel_needed_exports: _, + tracked_barrels: _, incremental_result: _, route_lookup: _, html_router: _, @@ -146,8 +145,8 @@ pub(crate) fn memory_cost_detailed(dev: &DevServer) -> MemoryCost { js_code += cost.code; source_maps += cost.source_maps; } - // .barrel_files_with_deferrals - // .barrel_needed_exports + // .tracked_barrels + other_bytes += dev.tracked_barrels.memory_cost(); // .assets assets += dev.assets.memory_cost(); // .active_websocket_connections diff --git a/src/runtime/bake/dev_server/mod.rs b/src/runtime/bake/dev_server/mod.rs index 8c6b010c3698..3585b8f01545 100644 --- a/src/runtime/bake/dev_server/mod.rs +++ b/src/runtime/bake/dev_server/mod.rs @@ -504,15 +504,8 @@ pub(crate) struct DevServer { pub(crate) graph_safety_lock: ThreadLock, pub(crate) client_graph: IncrementalGraph<{ bake::Side::Client }>, pub(crate) server_graph: IncrementalGraph<{ bake::Side::Server }>, - /// Barrel files with deferred (is_unused) import records. These files must - /// be re-parsed on every incremental build because the set of needed exports - /// may have changed. Populated by applyBarrelOptimization. - pub(crate) barrel_files_with_deferrals: bun_collections::StringArrayHashMap<()>, - /// Accumulated barrel export requests across all builds. Maps barrel file - /// path → set of export names that have been requested. This ensures that - /// when a barrel is re-parsed in an incremental build, exports requested - /// by non-stale files (from previous builds) are still kept. - pub(crate) barrel_needed_exports: bun_collections::StringArrayHashMap>, + /// What every build requested from each barrel that has deferred a record. + pub(crate) tracked_barrels: bun_bundler::barrel_imports::TrackedBarrels, /// State populated during bundling and hot updates. Often cleared pub(crate) incremental_result: IncrementalResult, /// Quickly retrieve a framework route's index from its entry point file. These diff --git a/test/bake/dev/bundle.test.ts b/test/bake/dev/bundle.test.ts index 6e7e8359aa46..55d75486329a 100644 --- a/test/bake/dev/bundle.test.ts +++ b/test/bake/dev/bundle.test.ts @@ -1511,6 +1511,511 @@ devTest("barrel optimization: namespace re-export cycle through a star-exported await c.expectMessage("result: object Y KEEP DEEP OTHER"); }, }); + +// A build of the dev server parses only the new and the changed files. A barrel that it +// parses again has to keep what the files of the earlier builds read from it. +async function expectPage(dev: Dev, route: string, message: string) { + await using c = await dev.client(route); + await c.expectMessage(message); +} +// A request for a page builds it. The scripts of an html page run only in a client. +async function build(dev: Dev, ...routes: string[]) { + for (const route of routes) { + expect({ route, status: (await dev.fetch(route)).status }).toEqual({ route, status: 200 }); + } +} +// The strings of `marks` that the client bundle of an html page holds. +async function clientBundleMarks(dev: Dev, route: string, marks: string[]) { + const html = await dev.fetch(route).text(); + const code = await dev.fetch(html.match(/]+src="([^"]+)"/)![1]).text(); + return marks.filter(mark => code.includes(mark)); +} +const serverPage = (imports: string, text: string) => ` + ${imports} + export default function () { + return new Response(${text}); + } +`; + +const wholeBarrels = ["by-export-star", "by-export-namespace", "by-namespace", "by-import", "by-require"]; +const readsAOfEachBarrel = (label: string) => ` + ${wholeBarrels.map((name, i) => `import { a as a${i} } from '${name}';`).join("\n ")} + console.log('${label}: ' + [${wholeBarrels.map((_, i) => `a${i}`).join(", ")}].join(' ')); +`; +devTest("barrel optimization: a later build keeps a request for every export", { + files: { + "first.html": emptyHtmlFile({ scripts: ["first.ts"] }), + "whole.html": emptyHtmlFile({ scripts: ["whole.ts"] }), + "last.html": emptyHtmlFile({ scripts: ["last.ts"] }), + "first.ts": readsAOfEachBarrel("first"), + "whole.ts": ` + import { b as b1 } from './export-star'; + import { ns as ns2 } from './export-namespace'; + import * as ns3 from 'by-namespace'; + const { b: b4 } = await import('by-import'); + const b5 = require('by-require').b; + const read = (ns: any) => ns.b; + console.log('whole: ' + [b1, read(ns2), read(ns3), b4, b5].map(b => typeof b).join(' ')); + `, + "export-star.ts": `export * from 'by-export-star';`, + "export-namespace.ts": `export * as ns from 'by-export-namespace';`, + "last.ts": readsAOfEachBarrel("last"), + ...Object.assign({}, ...wholeBarrels.map(name => abPackage(name, abBarrel))), + }, + async test(dev) { + // Each barrel defers b.js, then loads it. No file of the last build reads b. + await build(dev, "/first", "/whole", "/last"); + await expectPage(dev, "/whole", "whole: function function function function function"); + }, +}); + +// The bundler loads a file that a barrel re-exports only when it has parsed that barrel. This plugin +// holds the load of late.ts until then, so the request of late.ts reaches a barrel that has deferred. +const loadsLateAfter = (reExported: string) => ({ + "bunfig.toml": `[serve.static]\nplugins = ["./plugin.ts"]\n`, + "plugin.ts": ` + const barrelIsParsed = Promise.withResolvers(); + export default { + name: "loads late.ts after the barrel is parsed", + setup(build) { + build.onLoad({ filter: /${reExported}$/ }, () => barrelIsParsed.resolve()); + build.onLoad({ filter: /late\\.ts$/ }, () => barrelIsParsed.promise); + }, + }; + `, +}); +devTest("barrel optimization: a later build keeps a name requested through an outer barrel", { + files: { + ...loadsLateAfter("own\\.js"), + "outer.html": emptyHtmlFile({ scripts: ["outer.ts"] }), + "inner.html": emptyHtmlFile({ scripts: ["inner.ts"] }), + // outer-lib defers its record of inner-lib, and then late.ts asks it for b. + "outer.ts": ` + import { own } from 'outer-lib'; + import { b } from './late'; + console.log('outer: ' + own + ' ' + typeof b); + `, + "late.ts": `export { b } from 'outer-lib';`, + "inner.ts": ` + import { a } from 'inner-lib'; + console.log('inner: ' + a); + `, + ...abPackage("outer-lib", `export { b } from 'inner-lib'; export { own } from './own.js';`), + "node_modules/outer-lib/own.js": `export const own = "OWN";`, + ...abPackage("inner-lib", abBarrel), + }, + async test(dev) { + // The second build parses inner-lib again, and not outer-lib. + await build(dev, "/outer", "/inner"); + await expectPage(dev, "/outer", "outer: OWN function"); + }, +}); + +devTest("barrel optimization: a save of one importer keeps the requests of the other importers", { + files: { + ...loadsLateAfter("a\\.js"), + "index.html": emptyHtmlFile({ scripts: ["index.ts"] }), + // lib defers b.js, and then late.ts asks it for every export. + "index.ts": ` + import { a } from 'lib'; + import { b } from './late'; + console.log('first: ' + a + ' ' + typeof b); + `, + "late.ts": `export * from 'lib';`, + ...abPackage("lib", abBarrel), + }, + async test(dev) { + await build(dev, "/"); + await dev.patch("index.ts", { find: "first", replace: "saved", errors: null }); + await expectPage(dev, "/", "saved: A function"); + }, +}); + +devTest("barrel optimization: a later build keeps a name that an onResolve plugin let pass", { + files: { + "bunfig.toml": `[serve.static]\nplugins = ["./plugin.ts"]\n`, + "plugin.ts": ` + export default { + name: "lets lib pass", + setup(build) { + build.onResolve({ filter: /^lib$/ }, () => undefined); + }, + }; + `, + "first.html": emptyHtmlFile({ scripts: ["first.ts"] }), + "second.html": emptyHtmlFile({ scripts: ["second.ts"] }), + "first.ts": ` + import { a } from 'lib'; + console.log('first: ' + a); + `, + "second.ts": ` + import { b } from 'lib'; + console.log('second: ' + typeof b); + `, + ...abPackage("lib", abBarrel), + }, + async test(dev) { + await build(dev, "/first", "/second"); + await expectPage(dev, "/first", "first: A"); + }, +}); + +devTest("barrel optimization: a barrel that was not there to read keeps the requests of that build", { + files: { + "bunfig.toml": `[serve.static]\nplugins = ["./plugin.ts"]\n`, + // The barrels are there again when first.ts, their earlier importer, is loaded for the second time. + "plugin.ts": ` + import fs from "node:fs"; + import path from "node:path"; + export default { + name: "writes the barrels back", + setup(build) { + build.onLoad({ filter: /first\\.ts$/ }, args => { + for (const name of ["named", "whole"]) { + fs.writeFileSync(path.join(path.dirname(args.path), "node_modules", name, "index.js"), ${JSON.stringify(abBarrel)}); + } + return { contents: fs.readFileSync(args.path, "utf8"), loader: "ts" }; + }); + }, + }; + `, + "first.html": emptyHtmlFile({ scripts: ["first.ts"] }), + "named.html": emptyHtmlFile({ scripts: ["named.ts"] }), + "whole.html": emptyHtmlFile({ scripts: ["whole.ts"] }), + "first.ts": ` + import { a as a1 } from 'named'; + import { a as a2 } from 'whole'; + console.log('first: ' + a1 + a2); + `, + "named.ts": ` + import { b } from 'named'; + console.log('named: ' + b); + `, + "whole.ts": ` + import * as whole from 'whole'; + const read = (ns: any) => ns.b; + console.log('whole: ' + read(whole)); + `, + ...abPackage("named", abBarrel), + "node_modules/named/b.js": `export const b = "NAMED_B";`, + ...abPackage("whole", abBarrel), + "node_modules/whole/b.js": `export const b = "WHOLE_B";`, + }, + async test(dev) { + const bOfEachBarrel = ["NAMED_B", "WHOLE_B"]; + await build(dev, "/first"); + // One barrel is missing in each build. The dev server loads first.ts again for it. + fs.unlinkSync(dev.join("node_modules/named/index.js")); + expect(await clientBundleMarks(dev, "/named", bOfEachBarrel)).toEqual(["NAMED_B"]); + fs.unlinkSync(dev.join("node_modules/whole/index.js")); + expect(await clientBundleMarks(dev, "/whole", bOfEachBarrel)).toEqual(["WHOLE_B"]); + }, +}); + +const abcMarks = ["EXPORT_A", "EXPORT_B", "EXPORT_C"]; +const abcBarrel = (directive = "") => ({ + "ui/package.json": JSON.stringify({ name: "ui", sideEffects: false }), + "ui/index.ts": `${directive}export { a } from './a'; export { b } from './b'; export { c } from './c';`, + "ui/a.ts": `export const a = "EXPORT_A";`, + "ui/b.ts": `export const b = "EXPORT_B";`, + "ui/c.ts": `export const c = "EXPORT_C";`, +}); +devTest("barrel optimization: a barrel that was saved defers again in the next build", { + files: { + "first.html": emptyHtmlFile({ scripts: ["first.ts"] }), + "second.html": emptyHtmlFile({ scripts: ["second.ts"] }), + "first.ts": ` + import { a } from './ui'; + console.log(a); + `, + "second.ts": ` + import { b } from './ui'; + console.log(b); + `, + ...abcBarrel(), + }, + async test(dev) { + expect(await clientBundleMarks(dev, "/first", abcMarks)).toEqual(["EXPORT_A"]); + // The saved barrel is an entry point of its build, and loads every record. + await dev.patch("ui/index.ts", { find: "export { c }", replace: "export { c }", errors: null }); + expect(await clientBundleMarks(dev, "/first", abcMarks)).toEqual(abcMarks); + // This build parses the barrel again, with the requests of both pages. + expect(await clientBundleMarks(dev, "/second", abcMarks)).toEqual(["EXPORT_A", "EXPORT_B"]); + }, +}); + +devTest("barrel optimization: a later build of server code keeps a request for every export", { + framework: minimalFramework, + files: { + "routes/first.ts": serverPage(`import { a } from 'lib';`, `'first: ' + a`), + "routes/whole.ts": serverPage( + `import * as lib from 'lib'; const read = (ns: any) => ns.b;`, + `'whole: ' + typeof read(lib)`, + ), + "routes/last.ts": serverPage(`import { a } from 'lib';`, `'last: ' + a`), + ...abPackage("lib", abBarrel), + }, + async test(dev) { + await dev.fetch("/first").equals("first: A"); + await dev.fetch("/whole").equals("whole: function"); + await dev.fetch("/last").equals("last: A"); + await dev.fetch("/whole").equals("whole: function"); + }, +}); + +devTest("barrel optimization: a barrel that is parsed again still defers what no file requested", { + framework: minimalFramework, + files: { + "routes/first.ts": serverPage(`import { a } from 'lib';`, `'first: ' + a`), + "routes/second.ts": serverPage(`import { b } from 'lib';`, `'second: ' + typeof b`), + ...abPackage("lib", `${abBarrel} export { unused } from './unused.js';`), + "node_modules/lib/unused.js": `export const unused = <<>>;`, + }, + async test(dev) { + await dev.fetch("/first").equals("first: A"); + await dev.fetch("/second").equals("second: function"); + await dev.fetch("/first").equals("first: A"); + }, +}); + +devTest("barrel optimization: the SSR copy of a barrel that is parsed again still defers what no file requested", { + framework: { ...minimalFramework, serverComponents: separateSSRGraph }, + files: { + "routes/first.ts": serverPage( + `import { read } from '../ssr-a' with { bunBakeGraph: 'ssr' };`, + `'first: ' + read()`, + ), + "routes/second.ts": serverPage( + `import { read } from '../ssr-b' with { bunBakeGraph: 'ssr' };`, + `'second: ' + typeof read()`, + ), + "ssr-a.ts": `import { a } from 'lib'; export const read = () => a;`, + "ssr-b.ts": `import { b } from 'lib'; export const read = () => b;`, + ...abPackage("lib", `${abBarrel} export { unused } from './unused.js';`), + "node_modules/lib/unused.js": `export const unused = <<>>;`, + }, + async test(dev) { + await dev.fetch("/first").equals("first: A"); + await dev.fetch("/second").equals("second: function"); + await dev.fetch("/first").equals("first: A"); + }, +}); + +devTest("barrel optimization: a rebuild of a failing route keeps the requests made while the barrel was cached", { + framework: minimalFramework, + files: { + "routes/whole.ts": serverPage( + `import * as lib from 'lib'; const read = (ns: any) => ns.b;`, + `'whole: ' + typeof read(lib)`, + ), + "routes/named.ts": serverPage(`import { b } from 'lib';`, `'named: ' + typeof b`), + "routes/failing.ts": serverPage( + `import { a } from 'lib'; import { state } from '../state';`, + `'failing: ' + a + ' ' + state`, + ), + "state.ts": `export const state = "ok";`, + ...abPackage("lib", abBarrel), + }, + async test(dev) { + // lib loads every record, so the later builds take it from the cache. + await dev.fetch("/whole").equals("whole: function"); + await dev.fetch("/named").equals("named: function"); + await dev.fetch("/failing").equals("failing: A ok"); + await dev.write("state.ts", `import './missing';`, { errors: null }); + // A request of a route with a build error builds every file of that route again: lib too. + expect((await dev.fetch("/failing")).status).toBe(500); + await dev.fetch("/named").equals("named: function"); + await dev.fetch("/whole").equals("whole: function"); + }, +}); + +devTest("barrel optimization: a barrel that fails to parse keeps the request for every export of that build", { + framework: minimalFramework, + files: { + "routes/first.ts": serverPage(`import { a } from 'lib';`, `'first: ' + a`), + "routes/whole.ts": serverPage( + `import * as lib from 'lib'; const read = (ns: any) => ns.b;`, + `'whole: ' + typeof read(lib)`, + ), + ...abPackage("lib", abBarrel), + }, + async test(dev) { + await dev.fetch("/first").equals("first: A"); + // The dev server does not watch node_modules: the next build that reaches lib reads this. + await Bun.write( + dev.join("node_modules/lib/index.js"), + `export { a } from './a.js'; export { b } from <<>>;`, + ); + expect((await dev.fetch("/whole")).status).toBe(500); + await Bun.write(dev.join("node_modules/lib/index.js"), abBarrel); + // This request builds first.ts and lib again, and not whole.ts. + await dev.fetch("/first").equals("first: A"); + await dev.fetch("/whole").equals("whole: function"); + }, +}); + +// A re-export of a file in node_modules is a value that the importer copies when it is evaluated. The +// server evaluated outer-lib before lib loaded a.js and c.js, so only a new copy of outer-lib has them. +const outerBarrelOverLib = { + ...abPackage("outer-lib", `export { a, b, c } from 'lib'; export { own } from './own.js';`), + "node_modules/outer-lib/own.js": `export const own = "OWN";`, + ...abPackage("lib", `export { a } from './a.js'; export { b } from './b.js'; export { c } from './c.js';`), + "node_modules/lib/b.js": `export const b = "B";`, + "node_modules/lib/c.js": `export const c = "C";`, +}; +devTest("barrel optimization: a loaded outer barrel gets the exports that a later build loads", { + framework: minimalFramework, + files: { + "routes/r1.ts": serverPage(`import { own } from 'outer-lib'; import { b } from 'lib';`, `'r1: ' + own + ' ' + b`), + "routes/r2.ts": serverPage(`import { b } from 'outer-lib';`, `'r2: ' + b`), + "routes/r3.ts": serverPage(`import { a } from 'outer-lib';`, `'r3: ' + a`), + "routes/r4.ts": serverPage(`import { c } from 'outer-lib';`, `'r4: ' + c`), + ...outerBarrelOverLib, + }, + async test(dev) { + await dev.fetch("/r1").equals("r1: OWN B"); + await dev.fetch("/r2").equals("r2: B"); + await dev.fetch("/r3").equals("r3: A"); + await dev.fetch("/r4").equals("r4: C"); + await dev.fetch("/r3").equals("r3: A"); + }, +}); + +// The same for a page that stays open: each save is a hot update that the page takes in place. +const selfAcceptingPage = (imports: string, read: string) => ` + ${imports} + globalThis.read = () => ${read}; + console.log('loaded'); + import.meta.hot.accept(); +`; +devTest("barrel optimization: an open page gets the exports that a later save loads through an outer barrel", { + files: { + "index.html": emptyHtmlFile({ scripts: ["index.ts"] }), + "index.ts": selfAcceptingPage(`import { own } from 'outer-lib'; import { b } from 'lib';`, `own + ' ' + b`), + ...outerBarrelOverLib, + }, + async test(dev) { + await using c = await dev.client("/"); + await c.expectMessage("loaded"); + const read = [await c.js`read()`]; + for (const name of ["b", "a", "c"]) { + await dev.write("index.ts", selfAcceptingPage(`import { ${name} } from 'outer-lib';`, name)); + await c.expectMessage("loaded"); + read.push(await c.js`read()`); + } + expect(read).toEqual(["OWN B", "B", "A", "C"]); + }, +}); + +for (const [mode, serverComponents] of [ + ["one server graph", oneServerGraph], + ["a separate SSR graph", separateSSRGraph], +] as const) { + // The server copy of lib loads every record, and the client copy defers b.js. Every + // later build parses both copies again. + devTest(`barrel optimization: a copy that one side loaded in full stays so when the other side defers (${mode})`, { + framework: { ...minimalFramework, serverComponents }, + files: { + "routes/whole.ts": serverPage( + `import * as lib from 'lib'; const read = (ns: any) => ns.b;`, + `'whole: ' + typeof read(lib)`, + ), + "routes/client.ts": serverPage(`import * as Component from '../Component';`, `'client: ' + typeof Component`), + "Component.ts": `"use client";\nimport { a } from 'lib';\nexport const readA = () => a;\n`, + "routes/named.ts": serverPage(`import { a } from 'lib';`, `'named: ' + a`), + ...abPackage("lib", abBarrel), + }, + async test(dev) { + await dev.fetch("/whole").equals("whole: function"); + await dev.fetch("/client").equals("client: object"); + await dev.fetch("/named").equals("named: A"); + await dev.fetch("/whole").equals("whole: function"); + }, + }); +} + +// The bundler gives the path of a client component boundary to the copy that it generates for the +// other side. The later importers of a build record their requests under that copy. +devTest('barrel optimization: a later build keeps a name requested from a "use client" barrel', { + framework: { ...minimalFramework, serverComponents: oneServerGraph }, + files: { + "routes/first.ts": serverPage(`import { a } from '../ui';`, `'first: ' + a`), + "routes/second.ts": serverPage(`import { c } from '../ui';`, `'second: ' + c`), + ...abcBarrel(`"use client";\n`), + }, + async test(dev) { + await dev.fetch("/first").equals("first: EXPORT_A"); + await dev.fetch("/second").equals("second: EXPORT_C"); + await dev.fetch("/first").equals("first: EXPORT_A"); + // The barrel is bundled again for the new file of c, and no importer of it is. + { + await using batch = await dev.batchChanges({ errors: null }); + fs.renameSync(dev.join("ui/c.ts"), dev.join("ui/c.tsx")); + } + await dev.fetch("/first").equals("first: EXPORT_A"); + await dev.fetch("/second").equals("second: EXPORT_C"); + }, +}); + +// With a client entry point, a page gets the url of its client bundle in `meta.modules`. +const clientBundleUrl = (imports: string) => ` + ${imports} + export default function (req, meta) { + return Response.json(meta.modules); + } +`; +devTest('barrel optimization: a later build keeps a request for every export of a "use client" barrel', { + framework: { + fileSystemRouterTypes: [{ ...minimalFramework.fileSystemRouterTypes![0], clientEntryPoint: "./client.ts" }], + serverComponents: separateSSRGraph, + }, + files: { + "client.ts": `export {};`, + "routes/first.ts": clientBundleUrl(`import { a } from '../ui'; a;`), + "routes/whole.ts": clientBundleUrl(`import * as ui from '../ui'; ((ns: any) => ns.c)(ui);`), + "routes/second.ts": clientBundleUrl(`import { b } from '../ui'; b;`), + ...abcBarrel(`"use client";\n`), + }, + async test(dev) { + // The copy of the barrel that defers is in the client bundle. + const clientBundle = async (route: string) => { + const code = await dev.fetch((await dev.fetch(route).json())[0]).text(); + return abcMarks.filter(mark => code.includes(mark)); + }; + expect(await clientBundle("/first")).toEqual(["EXPORT_A"]); + expect(await clientBundle("/whole")).toEqual(abcMarks); + // No file of this build reads c. + expect(await clientBundle("/second")).toEqual(abcMarks); + }, +}); + +devTest("barrel optimization: the SSR copy keeps a request for every export when a file of the barrel moves", { + framework: { ...minimalFramework, serverComponents: separateSSRGraph }, + files: { + "routes/index.ts": ` + import { a } from '../ui'; + import { read } from '../ssr-read' with { bunBakeGraph: 'ssr' }; + export default function () { + return new Response('page: ' + a + ' ' + read()); + } + `, + "ssr-read.ts": ` + import * as ui from './ui'; + const all = (ns: any) => [ns.b, ns.c].join(' '); + export const read = () => all(ui); + `, + ...abcBarrel(), + }, + async test(dev) { + await dev.fetch("/").equals("page: EXPORT_A EXPORT_B EXPORT_C"); + // Both copies of the barrel are bundled again, and no importer of them is. + { + await using batch = await dev.batchChanges({ errors: null }); + fs.renameSync(dev.join("ui/b.ts"), dev.join("ui/b.tsx")); + } + await dev.fetch("/").equals("page: EXPORT_A EXPORT_B EXPORT_C"); + }, +}); // The dev server's runtime threw `$ERR_SSR_RESPONSE_EXPECTED(...)`, a name only src/js resolves: what the // user saw was "ReferenceError: $ERR_SSR_RESPONSE_EXPECTED is not defined". devTest("a render() that does not return a Response is reported as that", {