Skip to content
Open
Show file tree
Hide file tree
Changes from 2 commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
5 changes: 5 additions & 0 deletions src/ast/ast_result.rs
Original file line number Diff line number Diff line change
Expand Up @@ -102,6 +102,10 @@ pub struct Ast<'a> {
pub has_commonjs_export_names: bool,
pub has_import_meta: bool,
pub import_meta_ref: Ref,
/// Set by the HMR lowering: the module function has only imports, and a file outside
/// node_modules has a getter for each export. A file in node_modules has a copy of each
/// export, which no save changes if it is of a file in node_modules.
Comment thread
robobun marked this conversation as resolved.
Resolved
pub hmr_stable_reexports: bool,
}

// `parts`/`symbols`/`import_records` are now `ArenaVec`s and need an allocator,
Expand Down Expand Up @@ -147,6 +151,7 @@ impl<'a> Ast<'a> {
has_commonjs_export_names: false,
has_import_meta: false,
import_meta_ref: Ref::NONE,
hmr_stable_reexports: false,
}
}
}
Expand Down
3 changes: 3 additions & 0 deletions src/bundler/Graph.rs
Original file line number Diff line number Diff line change
Expand Up @@ -152,6 +152,9 @@ bitflags::bitflags! {
const IS_EXPORT_STAR_TARGET = 1 << 1;
/// `source.contents` is what an `onLoad` plugin returned, not what is in the file.
const IS_LOADED_BY_PLUGIN = 1 << 2;
/// Dev server: a file of re-exports with `Ast::hmr_stable_reexports`, see
/// `apply_barrel_optimization`. Not a sign that a re-export is deferred.
Comment thread
robobun marked this conversation as resolved.
Resolved
const IS_BARREL = 1 << 3;
}
}

Expand Down
7 changes: 7 additions & 0 deletions src/bundler/barrel_imports.rs
Original file line number Diff line number Diff line change
Expand Up @@ -90,6 +90,8 @@ fn resolve_barrel_export(
/// 1. It has `sideEffects: false` or is in `optimize_imports`, AND
/// 2. All named exports are re-exports (no local definitions), AND
/// 3. It is not an export star target of another barrel.
/// `InputFileFlags::IS_BARREL` (dev server) is 1 and 2 for a file with `Ast::hmr_stable_reexports`
/// that is not an entry point.
Comment thread
robobun marked this conversation as resolved.
Resolved
///
/// Export * records are never deferred (always resolved) to avoid circular races.
pub(crate) fn apply_barrel_optimization(
Expand Down Expand Up @@ -148,6 +150,11 @@ fn apply_barrel_optimization_impl(
}
}

if ast.flags.contains(JSAst::Flags::HMR_STABLE_REEXPORTS) {
this.graph.input_files.items_flags_mut()[source_index as usize] |=
InputFileFlags::IS_BARREL;
}

// If this barrel is a star target of another barrel, can't safely defer
if this.graph.input_files.items_flags()[source_index as usize]
.contains(InputFileFlags::IS_EXPORT_STAR_TARGET)
Expand Down
6 changes: 5 additions & 1 deletion src/bundler/bundled_ast.rs
Original file line number Diff line number Diff line change
Expand Up @@ -157,7 +157,9 @@ bitflags::bitflags! {
const HAS_IMPORT_META = 1 << 8;
/// See `Ast::commonjs_lifted_to_esm`.
const COMMONJS_LIFTED_TO_ESM = 1 << 9;
// _padding: u6 fills the rest
/// See `Ast::hmr_stable_reexports`.
const HMR_STABLE_REEXPORTS = 1 << 10;
// _padding: u5 fills the rest
}
}

Expand Down Expand Up @@ -275,6 +277,7 @@ impl<'arena> BundledAst<'arena> {
None
},
has_import_meta: self.flags.contains(Flags::HAS_IMPORT_META),
hmr_stable_reexports: self.flags.contains(Flags::HMR_STABLE_REEXPORTS),
..Ast::empty_in(arena)
}
}
Expand All @@ -297,6 +300,7 @@ impl<'arena> BundledAst<'arena> {
ast.directive.is_some_and(|d| d == b"use strict"),
);
flags.set(Flags::HAS_IMPORT_META, ast.has_import_meta);
flags.set(Flags::HMR_STABLE_REEXPORTS, ast.hmr_stable_reexports);

Self {
approximate_newline_count: ast.approximate_newline_count as u32,
Expand Down
12 changes: 12 additions & 0 deletions src/js_parser/p.rs
Original file line number Diff line number Diff line change
Expand Up @@ -8823,6 +8823,7 @@ impl<'a, const TYPESCRIPT: bool, const SCAN_ONLY: bool> P<'a, TYPESCRIPT, SCAN_O

let bundling = self.options.bundle;
let mut parts_end: usize = usize::from(bundling);
let mut hmr_stable_reexports = false;

// When bundling with HMR, we need every module to be just a
// single part, as we later wrap each module into a function,
Expand Down Expand Up @@ -8877,6 +8878,16 @@ impl<'a, const TYPESCRIPT: bool, const SCAN_ONLY: bool> P<'a, TYPESCRIPT, SCAN_O
)?;
}

hmr_stable_reexports = hmr_transform_ctx
.stmts
.iter()
.all(|stmt| matches!(stmt.data, js_ast::StmtData::SImport(_)))
&& (hmr_transform_ctx.is_in_node_modules
|| (hmr_transform_ctx.export_star_props.is_empty()
&& hmr_transform_ctx
.export_props
.iter()
.all(|prop| prop.kind == js_ast::G::PropertyKind::Get)));
hmr_transform_ctx.finalize(self, head_parts)?;
} else {
// Handle import paths after the whole file has been visited because we need
Expand Down Expand Up @@ -9395,6 +9406,7 @@ impl<'a, const TYPESCRIPT: bool, const SCAN_ONLY: bool> P<'a, TYPESCRIPT, SCAN_O
// const_values: self.const_values,
ts_enums,
import_meta_ref: self.import_meta_ref,
hmr_stable_reexports,

symbols,
parts: parts_list,
Expand Down
8 changes: 8 additions & 0 deletions src/runtime/bake/client/css-reloader.ts
Original file line number Diff line number Diff line change
Expand Up @@ -168,6 +168,14 @@ export function editCssArray(array: string[]) {
}
}

/** True if a stylesheet of the route has no content on this page. The page gets it when it loads again. */
export function lacksStylesheet(): boolean {
for (const css of cssStore.values()) {
if (css.active && !css.sheet && !css.link) return true;
}
return false;
}

export function editCssContent(id: string, newContent: string) {
let entry = cssStore.get(id);
if (!entry) return;
Expand Down
3 changes: 3 additions & 0 deletions src/runtime/bake/dev_server/finalize.rs
Original file line number Diff line number Diff line change
Expand Up @@ -9,6 +9,8 @@ pub(crate) struct HotUpdateContext<'a> {
pub import_records: &'a [bun_ast::import_record::List<'a>],
/// bundle_v2.Graph.input_files.items(.loader)
pub loaders: &'a [Loader],
/// bundle_v2.Graph.input_files.items(.flags)
pub input_file_flags: &'a [bundler::Graph::InputFileFlags],
/// Which files have a server-component boundary.
pub server_to_client_bitset: DynamicBitSet,
/// Used to reduce calls to the IncrementalGraph hash table.
Expand Down Expand Up @@ -274,6 +276,7 @@ pub(super) fn finalize_bundle(
import_records,
sources: input_file_sources,
loaders: input_file_loaders,
input_file_flags: bv2.graph.input_files.items_flags(),
server_to_client_bitset: scb_bitset,
resolved_index_cache: &mut resolved_index_cache,
server_seen_bit_set: DynamicBitSet::default(), // assigned below
Expand Down
108 changes: 107 additions & 1 deletion src/runtime/bake/dev_server/incremental_graph.rs
Original file line number Diff line number Diff line change
Expand Up @@ -11,6 +11,7 @@
use core::mem::offset_of;
use std::io::Write as _;

use bun_bundler::Graph::InputFileFlags;
use bun_collections::{ArrayHashMap, StringArrayHashMap, bit_set::DynamicBitSetUnmanaged};
use bun_core::strings;

Expand Down Expand Up @@ -185,6 +186,14 @@ pub(crate) struct CurrentChunkSourceMapData {
// Method-argument types (formerly re-exported from `incremental_graph_body`)
// ──────────────────────────────────────────────────────────────────────────

/// A module of the current client chunk with the text and the live import records of the last
/// print of the file: see `barrel_print`.
Comment thread
robobun marked this conversation as resolved.
Resolved
pub(crate) struct ReprintedBarrel<const SIDE: bake::Side> {
pub(crate) file: FileIndex<SIDE>,
/// The key of the module in the chunk.
pub(crate) id: Box<[u8]>,
}

#[derive(Copy, Clone, Eq, PartialEq)]
pub(crate) enum ProcessMode {
Normal,
Expand Down Expand Up @@ -294,6 +303,8 @@ pub(crate) struct IncrementalGraph<const SIDE: bake::Side> {
pub(crate) edges: Vec<Edge<SIDE>>,
/// Freed edge slots for reuse by `new_edge`.
pub(crate) edges_free_list: Vec<EdgeIndex>,
/// Client side: for each barrel, the hash that `barrel_print` made for its last print.
pub(crate) barrel_prints: ArrayHashMap<FileIndex<SIDE>, u64>,
// ── per-bundle scratch (`current_chunk_*`) ─────────────────────────
/// Total byte length of the current JS chunk being assembled.
pub(crate) current_chunk_len: usize,
Expand All @@ -307,6 +318,8 @@ pub(crate) struct IncrementalGraph<const SIDE: bake::Side> {
pub(crate) current_chunk_source_maps: Vec<CurrentChunkSourceMapData>,
/// Client side: CSS asset content-hashes referenced by the current chunk.
pub(crate) current_css_files: Vec<u64>,
/// Client side: see `ReprintedBarrel`.
pub(crate) current_chunk_reprinted_barrels: Vec<ReprintedBarrel<SIDE>>,
}

/// Byte-count breakdown of an incremental graph's memory usage (graph
Expand Down Expand Up @@ -398,6 +411,12 @@ impl<const SIDE: bake::Side> IncrementalGraph<SIDE> {
graph += self.edge_lists.capacity() * size_of::<EdgeLists>();
graph += self.edges.capacity() * size_of::<Edge<SIDE>>();
graph += self.edges_free_list.capacity() * size_of::<EdgeIndex>();
graph += self.barrel_prints.count() * (size_of::<FileIndex<SIDE>>() + size_of::<u64>());
graph +=
self.current_chunk_reprinted_barrels.capacity() * size_of::<ReprintedBarrel<SIDE>>();
for barrel in &self.current_chunk_reprinted_barrels {
graph += barrel.id.len();
}
match SIDE {
Side::Client => {
graph += self.current_chunk_parts.capacity() * size_of::<FileIndex<SIDE>>();
Expand Down Expand Up @@ -440,6 +459,62 @@ impl<const SIDE: bake::Side> IncrementalGraph<SIDE> {
}
}
self.current_chunk_parts.clear();
self.current_chunk_reprinted_barrels.clear();
}

fn is_reprinted_barrel(&self, file: FileIndex<SIDE>) -> bool {
self.current_chunk_reprinted_barrels
.iter()
.any(|barrel| barrel.file == file)
}

/// True if the file is a barrel that a page can keep (`InputFileFlags::IS_BARREL`), and its
/// last print had the same text and the same live import records. The hash of the two is
/// stored for the next print. Any other print of the file forgets it. A saved file is an
/// entry point of its build and no barrel there, so the print of a save is never the same.
/// The file of a live record is not in the hash: the caller looks at the import edges, and a
/// page at the module ids.
Comment thread
robobun marked this conversation as resolved.
Resolved
fn barrel_print(
&mut self,
ctx: &HotUpdateContext<'_>,
index: bun_ast::Index,
file_index: FileIndex<SIDE>,
) -> Result<bool, crate::Error> {
let source = &ctx.sources[index.get() as usize];
let records = ctx.import_records[index.get() as usize].as_slice();
let is_live = |record: &bun_ast::ImportRecord| {
!record.flags.contains(bun_ast::ImportRecordFlags::IS_UNUSED)
};
// A barrel in node_modules has a copy of each export. No save changes a file there.
let is_barrel = ctx.input_file_flags[index.get() as usize]
.contains(InputFileFlags::IS_BARREL)
&& (!source.path.is_node_module()
|| records
.iter()
.filter(|record| is_live(record))
.all(|record| {
let path = if record.source_index.is_valid() {
&ctx.sources[record.source_index.get() as usize].path
} else {
&record.path
};
!path.is_file() || path.is_node_module()
}));
if !is_barrel {
if self.barrel_prints.count() > 0 {
self.barrel_prints.swap_remove(&file_index);
}
return Ok(false);
}
let mut hasher = bun_wyhash::Wyhash::init(bun_wyhash::hash(source.contents()));
for record in records {
hasher.update(&[u8::from(is_live(record))]);
}
let print = hasher.final_();
let gop = self.barrel_prints.get_or_put(file_index)?;
let same = gop.found_existing && *gop.value_ptr == print;
*gop.value_ptr = print;
Ok(same)
}

/// Grows `stale_files` to cover all currently-known files, filling new
Expand Down Expand Up @@ -605,6 +680,9 @@ impl<const SIDE: bake::Side> IncrementalGraph<SIDE> {
self.edge_lists.push(EdgeLists::default());
}

let reprinted_barrel =
matches!(SIDE, Side::Client) && self.barrel_print(ctx, index, file_index)?;

if self.stale_files.bit_length > file_index.get() as usize {
self.stale_files.unset(file_index.get() as usize);
}
Expand Down Expand Up @@ -695,6 +773,12 @@ impl<const SIDE: bake::Side> IncrementalGraph<SIDE> {
if let Some(len) = code_len {
self.current_chunk_parts.push(file_index);
self.current_chunk_len += len;
if reprinted_barrel {
self.current_chunk_reprinted_barrels.push(ReprintedBarrel {
file: file_index,
id: Box::from(path.pretty),
});
}
}
}
Side::Server => {
Expand Down Expand Up @@ -877,16 +961,27 @@ impl<const SIDE: bake::Side> IncrementalGraph<SIDE> {
self.edge_lists[file_index.get() as usize].first_import = None;

// '.seen = false' means an import was removed and should be freed.
let mut lost_an_import = false;
for val in &quick_lookup.values()[0..quick_lookup_values_to_care_len] {
if !val.seen {
self.dev_incremental_result().had_adjusted_edges = true;
self.disconnect_edge_from_dependency_list(val.edge_index);
self.free_edge(val.edge_index);
lost_an_import = true;
}
}

self.edge_lists[file_index.get() as usize].first_import = new_imports;

// A listed barrel that imports the files that it imported: no route has another script.
if matches!(SIDE, Side::Client)
&& !lost_an_import
&& quick_lookup.count() == quick_lookup_values_to_care_len
&& self.is_reprinted_barrel(file_index)
{
return Ok(());
}

// Follow this file to the route / HMR root to mark it stale.
self.trace_dependencies(
file_index,
Expand Down Expand Up @@ -1906,7 +2001,18 @@ impl<const SIDE: bake::Side> IncrementalGraph<SIDE> {
"{}",
bun_core::fmt::hex_lower(&options.script_id.get().to_ne_bytes())
);
end_list.extend_from_slice(b"\")");
end_list.push(b'"');
for (i, barrel) in self.current_chunk_reprinted_barrels.iter().enumerate() {
end_list.extend_from_slice(if i == 0 { b", [" } else { b", " });
bun_js_printer::write_json_string::<_, { bun_js_printer::Encoding::Utf8 }>(
&barrel.id,
&mut end_list,
)?;
}
if !self.current_chunk_reprinted_barrels.is_empty() {
end_list.push(b']');
}
end_list.push(b')');
}
}
// sourceMappingURL footer (client only).
Expand Down
Loading
Loading