diff --git a/src/bundler/linker_context/README.md b/src/bundler/linker_context/README.md index 5c93841fd926..ed83384ce7fe 100644 --- a/src/bundler/linker_context/README.md +++ b/src/bundler/linker_context/README.md @@ -764,6 +764,7 @@ The renamed symbols are then used during final code generation to produce output - Walks the import graph in evaluation order (`EntryWalk`): depth first along every `import` statement, also through files that tree shaking dropped, so a file prints after the files it imports - One walk per entry point, in parallel. A chunk has one owner: the entry point that loads first among the chunk's entry points (`load_rank`). `load_rank` and `EntryWalk` read the same edges (`for_each_edge`). The owner's walk places the files of the chunk, so no two walks write the same list. Without code splitting, each chunk is owned by its own entry point +- On an import cycle, the owner's order can put a part that evaluates code at load ahead of a binding that it reads, itself or through the functions that it names (`LoadTimeReads`). A walk from the next entry point by rank whose order does not then lays out that chunk (`find_safe_walks`). The other chunks keep the layout of their owner. With no such entry point, so does this one. The read is judged by `part.dependencies`, per statement: `const f = () => x` is no read of `x` (`Expr::can_be_moved`), and `const o = { f: () => x }` is one - Places the parts of a file in runs: a part prints after the files it imports and before the files that the next part imports - Collects the live parts of each run into part ranges. The runtime and the wrapped files go first in the chunk, then the namespace objects: a namespace object exists before any file runs - Records the other chunks in the order a walk from the chunk's files reaches their first file with side effects (`reached_chunks_in_order`), which orders the chunk's cross-chunk `import` statements diff --git a/src/bundler/linker_context/findAllImportedPartsInJSOrder.rs b/src/bundler/linker_context/findAllImportedPartsInJSOrder.rs index 78710de09c93..ef2459b11239 100644 --- a/src/bundler/linker_context/findAllImportedPartsInJSOrder.rs +++ b/src/bundler/linker_context/findAllImportedPartsInJSOrder.rs @@ -1,5 +1,5 @@ use crate::mal_prelude::*; -use bun_ast::{ImportKind, ImportRecord}; +use bun_ast::{ImportKind, ImportRecord, StmtData, StmtOrExpr}; use bun_collections::{AutoBitSet, HashMap, StringHashMap, VecExt}; use crate::linker_context::merge_small_chunks::part_has_no_side_effects; @@ -34,33 +34,11 @@ pub(crate) fn find_all_imported_parts_in_js_order( } } - let (plan, mut walks) = WalkPlan::new(this, chunks); - { - struct WalkCtx<'a, 'f> { - c: bun_ptr::ParentRef, bun_ptr::Mut>, - plan: &'f WalkPlan, - chunks: &'f [Chunk], - } - let walk_ctx = WalkCtx { - // SAFETY: `this` is the live `&mut LinkerContext` for the link step. - c: unsafe { - bun_ptr::ParentRef::from_raw_mut(std::ptr::from_mut::(this)) - }, - plan: &plan, - chunks: &*chunks, - }; - this.worker_pool().each_ptr( - walk_ctx, - |ctx: &WalkCtx, walk: *mut EntryWalk, _: usize| { - // SAFETY: `each_ptr` hands each task a distinct `*mut EntryWalk`. - let walk = unsafe { &mut *walk }; - // SAFETY: the walks only read the graph. - let c: &LinkerContext = unsafe { &*ctx.c.as_mut_ptr() }; - walk.run(c, ctx.plan, ctx.chunks); - }, - &mut walks, - ); - } + let mut plan = WalkPlan::new(this, chunks); + let mut walks = plan.create_walks(this); + run_walks(this, &plan, chunks, &mut walks); + let safe_walks = plan.find_safe_walks(this, chunks, &walks); + walks.extend(safe_walks); let order = WalkOrder::collect(this, chunks.len(), walks); struct Ctx<'a, 'f> { @@ -109,6 +87,31 @@ pub(crate) fn find_all_imported_parts_in_js_order( Ok(()) } +fn run_walks(this: &mut LinkerContext, plan: &WalkPlan, chunks: &[Chunk], walks: &mut [EntryWalk]) { + struct WalkCtx<'a, 'f> { + c: bun_ptr::ParentRef, bun_ptr::Mut>, + plan: &'f WalkPlan, + chunks: &'f [Chunk], + } + let walk_ctx = WalkCtx { + // SAFETY: `this` is the live `&mut LinkerContext` for the link step. + c: unsafe { bun_ptr::ParentRef::from_raw_mut(std::ptr::from_mut::(this)) }, + plan, + chunks, + }; + this.worker_pool().each_ptr( + walk_ctx, + |ctx: &WalkCtx, walk: *mut EntryWalk, _: usize| { + // SAFETY: `each_ptr` hands each task a distinct `*mut EntryWalk`. + let walk = unsafe { &mut *walk }; + // SAFETY: the walks only read the graph. + let c: &LinkerContext = unsafe { &*ctx.c.as_mut_ptr() }; + walk.run(c, ctx.plan, ctx.chunks); + }, + walks, + ); +} + /// Each part range is printed as one task, so a range stops growing once it /// spans this many source bytes and a large unwrapped file prints on several /// threads. A constant, not derived from the thread count: range boundaries @@ -206,6 +209,7 @@ impl WalkOrder { runs_of_chunk: vec![Vec::new(); chunks_len], entered: vec![u32::MAX; c.graph.files.len()], }; + // A walk of `find_safe_walks` comes after the owner's, and replaces it. for walk in walks { for owned in walk.owned { order.runs_of_chunk[owned.chunk_index as usize] = owned.runs; @@ -228,10 +232,12 @@ struct WalkPlan { slot_of_chunk: Vec, /// With code splitting: the entry point id of each entry point's file, `u32::MAX` for the others. entry_id_of_file: Vec, + /// With code splitting: `load_rank`. + rank: Vec, } impl WalkPlan { - fn new(c: &LinkerContext, chunks: &[Chunk]) -> (WalkPlan, Vec) { + fn new(c: &LinkerContext, chunks: &[Chunk]) -> WalkPlan { let files_len = c.graph.files.len(); let entry_points = c.graph.entry_points.items_source_index(); let code_splitting = c.graph.code_splitting; @@ -240,6 +246,7 @@ impl WalkPlan { owner_of_chunk: vec![u32::MAX; chunks.len()], slot_of_chunk: vec![0; chunks.len()], entry_id_of_file: Vec::new(), + rank: Vec::new(), }; if code_splitting { @@ -269,7 +276,6 @@ impl WalkPlan { } // The entry point that loads first among the chunk's entry points owns the chunk. - let mut rank = Vec::new(); if code_splitting { plan.entry_id_of_file = vec![u32::MAX; files_len]; for (entry_id, &source_index) in entry_points.iter().enumerate() { @@ -278,19 +284,17 @@ impl WalkPlan { *slot = entry_id as u32; } } - rank = load_rank(c, &plan.entry_id_of_file); + plan.rank = load_rank(c, &plan.entry_id_of_file); } - let mut walk_of_entry = vec![u32::MAX; entry_points.len()]; - let mut walks: Vec = Vec::new(); for (chunk_index, chunk) in chunks.iter().enumerate() { if !matches!(chunk.content, chunk::Content::Javascript(_)) { continue; } - let owner = if code_splitting { + plan.owner_of_chunk[chunk_index] = if code_splitting { let mut bits = chunk.entry_bits().iterator::(); let mut owner = u32::MAX; while let Some(entry_id) = bits.next() { - if owner == u32::MAX || rank[entry_id] < rank[owner as usize] { + if owner == u32::MAX || plan.rank[entry_id] < plan.rank[owner as usize] { owner = entry_id as u32; } } @@ -298,6 +302,15 @@ impl WalkPlan { } else { chunk.entry_point.entry_point_id() }; + } + plan + } + + /// One walk per entry point that owns a chunk. + fn create_walks(&mut self, c: &LinkerContext) -> Vec { + let mut walk_of_entry = vec![u32::MAX; c.graph.entry_points.len()]; + let mut walks: Vec = Vec::new(); + for (chunk_index, &owner) in self.owner_of_chunk.iter().enumerate() { if owner == u32::MAX { continue; } @@ -311,14 +324,281 @@ impl WalkPlan { }); } let walk = &mut walks[*walk_index as usize]; - plan.owner_of_chunk[chunk_index] = owner; - plan.slot_of_chunk[chunk_index] = walk.owned.len() as u32; + self.slot_of_chunk[chunk_index] = walk.owned.len() as u32; walk.owned.push(OwnedChunk { chunk_index: chunk_index as u32, runs: Vec::new(), + has_cycle: false, }); } - (plan, walks) + walks + } + + /// A chunk prints its files in the order of its owner. On an import cycle, that order can put a part that runs at + /// load ahead of a binding that it reads, where the order of another entry point of the chunk does not. Returns the + /// walks of the first such entry point by rank, each with the chunks that it lays out in place of their owner. + fn find_safe_walks( + &mut self, + this: &mut LinkerContext, + chunks: &[Chunk], + walks: &[EntryWalk], + ) -> Vec { + let mut reads: Option = None; + // With the other entry points of the chunk, last by rank first. + let mut unsafe_chunks: Vec<(usize, Vec)> = Vec::new(); + for owned in walks.iter().flat_map(|walk| &walk.owned) { + let chunk_index = owned.chunk_index as usize; + if !owned.has_cycle || chunks[chunk_index].entry_bits().count() < 2 { + continue; + } + let reads = reads.get_or_insert_with(|| LoadTimeReads::new(this)); + if !reads.is_broken_by(this, &owned.runs) { + continue; + } + let mut candidates: Vec = Vec::new(); + let mut bits = chunks[chunk_index].entry_bits().iterator::(); + while let Some(entry_id) = bits.next() { + if entry_id as u32 != self.owner_of_chunk[chunk_index] { + candidates.push(entry_id as u32); + } + } + candidates + .sort_unstable_by_key(|&entry_id| core::cmp::Reverse(self.rank[entry_id as usize])); + unsafe_chunks.push((chunk_index, candidates)); + } + + let mut safe_walks: Vec = Vec::new(); + let Some(mut reads) = reads else { + return safe_walks; + }; + // One round tries the next entry point of every chunk, so chunks with the same entry points share a walk. + let mut is_safe = vec![false; chunks.len()]; + loop { + self.owner_of_chunk.fill(u32::MAX); + unsafe_chunks.retain_mut(|(chunk_index, candidates)| { + if is_safe[*chunk_index] { + return false; + } + let Some(candidate) = candidates.pop() else { + return false; + }; + self.owner_of_chunk[*chunk_index] = candidate; + true + }); + if unsafe_chunks.is_empty() { + return safe_walks; + } + let mut trials = self.create_walks(this); + run_walks(this, self, chunks, &mut trials); + for mut trial in trials { + trial + .owned + .retain(|owned| !reads.is_broken_by(this, &owned.runs)); + for owned in &trial.owned { + is_safe[owned.chunk_index as usize] = true; + } + trial + .entered + .retain(|&file| is_safe[self.chunk_of_file[file as usize] as usize]); + safe_walks.push(trial); + } + } + } +} + +/// What the live parts do at load, and which parts name a binding of which. +struct LoadTimeReads { + /// Per file: the id of its part 0. The id of a part is that plus its index. + first_part_id: Vec, + file_of_part: Vec, + /// `Hoisted` also for a part that does not run where it prints. + effects: Vec, + /// `dependents[dependents_start[id]..dependents_start[id + 1]]`: the parts that name a binding of part `id`. + dependents_start: Vec, + dependents: Vec, + /// Scratch for `is_broken_by`. Per part of the chunk that is not hoisted: the index of its run. + position: Vec, + visited: Vec, +} + +impl LoadTimeReads { + fn new(c: &LinkerContext) -> LoadTimeReads { + let all_parts = c.graph.ast.items_parts(); + let flags = c.graph.meta.items_flags(); + let css = c.graph.ast.items_css(); + let mut first_part_id: Vec = Vec::with_capacity(all_parts.len()); + let mut file_of_part: Vec = Vec::new(); + for (file, parts) in all_parts.iter().enumerate() { + first_part_id.push(file_of_part.len() as u32); + file_of_part.resize(file_of_part.len() + parts.len(), file as IndexInt); + } + let parts_len = file_of_part.len(); + + let for_each_live_part = |each: &mut dyn FnMut(usize, u32, &bun_ast::Part)| { + for source_index in c.graph.reachable_files.iter() { + let file = source_index.get() as usize; + if !c.graph.files_live.is_set(file) || css[file].is_some() { + continue; + } + for (part_index, part) in all_parts[file].as_slice().iter().enumerate() { + if c.graph.parts_live[file].is_set(part_index) { + each(file, first_part_id[file] + part_index as u32, part); + } + } + } + }; + + let mut effects = vec![LoadEffect::Hoisted; parts_len]; + let mut dependents_start: Vec = vec![0; parts_len + 2]; + for_each_live_part(&mut |file, id, part| { + // A wrapped file runs when it is called, and a namespace object prints ahead of every file. + if flags[file].wrap == Wrap::None + && file as u32 != Index::RUNTIME.value() + && id - first_part_id[file] != bun_ast::NAMESPACE_EXPORT_PART_INDEX + { + effects[id as usize] = LoadEffect::of(part); + } + for dependency in part.dependencies.iter() { + let to = + first_part_id[dependency.source_index.get() as usize] + dependency.part_index; + dependents_start[to as usize + 2] += 1; + } + }); + for i in 2..dependents_start.len() { + dependents_start[i] += dependents_start[i - 1]; + } + let mut dependents: Vec = vec![0; dependents_start[parts_len + 1] as usize]; + for_each_live_part(&mut |_, id, part| { + for dependency in part.dependencies.iter() { + let to = + first_part_id[dependency.source_index.get() as usize] + dependency.part_index; + dependents[dependents_start[to as usize + 1] as usize] = id; + dependents_start[to as usize + 1] += 1; + } + }); + + LoadTimeReads { + first_part_id, + file_of_part, + effects, + dependents_start, + dependents, + position: vec![u32::MAX; parts_len], + visited: vec![false; parts_len], + } + } + + /// Calls `each` with the index of the run and the id of each part of `runs` that is not hoisted, last run first. + fn for_each_part_backward( + &self, + c: &LinkerContext, + runs: &[PartRun], + mut each: impl FnMut(u32, u32) -> bool, + ) -> bool { + for (run_index, run) in runs.iter().enumerate().rev() { + let parts_len = c.graph.ast.items_parts()[run.source_index as usize].len() as u32; + for part_index in run.begin..run.end.min(parts_len) { + let id = self.first_part_id[run.source_index as usize] + part_index; + if self.effects[id as usize] != LoadEffect::Hoisted && each(run_index as u32, id) { + return true; + } + } + } + false + } + + /// Some part of `runs` that evaluates code names, itself or through the functions that it names, a binding that a + /// later part of another file of `runs` gives its value. + fn is_broken_by(&mut self, c: &LinkerContext, runs: &[PartRun]) -> bool { + let mut position = core::mem::take(&mut self.position); + let mut visited = core::mem::take(&mut self.visited); + self.for_each_part_backward(c, runs, |run_index, id| { + position[id as usize] = run_index; + false + }); + // Last run first: the first search that comes to a function is the one from the latest binding that it leads to. + // `import` statements are hoisted, so a file that a part can name does not print between two parts of its file. + let mut queue: Vec = Vec::new(); + let is_broken = self.for_each_part_backward(c, runs, |run_index, written| { + queue.push(written); + while let Some(id) = queue.pop() { + let (start, end) = ( + self.dependents_start[id as usize] as usize, + self.dependents_start[id as usize + 1] as usize, + ); + for &dependent in &self.dependents[start..end] { + if self.effects[dependent as usize] == LoadEffect::Evaluates { + if position[dependent as usize] < run_index + && self.file_of_part[dependent as usize] + != self.file_of_part[written as usize] + { + return true; + } + } else if !visited[dependent as usize] { + visited[dependent as usize] = true; + queue.push(dependent); + } + } + } + false + }); + self.for_each_part_backward(c, runs, |_, id| { + position[id as usize] = u32::MAX; + false + }); + visited.fill(false); + self.position = position; + self.visited = visited; + is_broken + } +} + +/// What happens where a part prints. +#[derive(Clone, Copy, PartialEq, Eq, PartialOrd, Ord)] +enum LoadEffect { + /// Nothing: function declarations, imports, re-exports. + Hoisted, + /// A binding gets a value that evaluates nothing (`Expr::can_be_moved`): a function, a literal. + Assigns, + Evaluates, +} + +impl LoadEffect { + fn of(part: &bun_ast::Part) -> LoadEffect { + let of_moved = |can_be_moved: bool| { + if can_be_moved { + LoadEffect::Assigns + } else { + LoadEffect::Evaluates + } + }; + let effects = part.stmts.slice().iter().map(|stmt| match &stmt.data { + StmtData::SImport(_) + | StmtData::SExportStar(_) + | StmtData::SExportFrom(_) + | StmtData::SExportClause(_) + | StmtData::SFunction(_) + | StmtData::SComment(_) + | StmtData::SDirective(_) + | StmtData::STypeScript(_) + | StmtData::SEmpty(_) => LoadEffect::Hoisted, + StmtData::SExportDefault(default) => match &default.value { + StmtOrExpr::Stmt(stmt) if matches!(stmt.data, StmtData::SFunction(_)) => { + LoadEffect::Hoisted + } + _ => of_moved(default.can_be_moved()), + }, + StmtData::SClass(class) => of_moved(class.class.can_be_moved()), + StmtData::SLocal(local) => of_moved( + local + .decls + .slice() + .iter() + .all(|decl| decl.value.is_none_or(|value| value.can_be_moved())), + ), + _ => LoadEffect::Evaluates, + }); + effects.max().unwrap_or(LoadEffect::Hoisted) } } @@ -509,6 +789,8 @@ struct OwnedChunk { chunk_index: u32, /// The runs placed so far. runs: Vec, + /// The walk came back to a file of the chunk that it had not left. + has_cycle: bool, } /// One walk from an entry point. It lays out the chunks that the entry point owns. @@ -522,10 +804,19 @@ struct EntryWalk { impl EntryWalk { fn run(&mut self, c: &LinkerContext, plan: &WalkPlan, chunks: &[Chunk]) { let mut seen = bun_core::handle_oom(AutoBitSet::init_empty(c.graph.files.len())); + let mut left = bun_core::handle_oom(AutoBitSet::init_empty(c.graph.files.len())); let mut stack: Vec = Vec::new(); let mut css_placed: HashMap = HashMap::default(); let root = c.graph.entry_points.items_source_index()[self.entry_id as usize]; - self.walk(c, plan, &mut seen, &mut stack, &mut css_placed, root); + self.walk( + c, + plan, + &mut seen, + &mut left, + &mut stack, + &mut css_placed, + root, + ); // Chunk folding can move a file into a chunk whose entry points do not import it. It goes last there. let runtime = Index::RUNTIME.value(); @@ -537,6 +828,7 @@ impl EntryWalk { c, plan, &mut seen, + &mut left, &mut stack, &mut css_placed, source_index, @@ -552,6 +844,7 @@ impl EntryWalk { c: &LinkerContext, plan: &WalkPlan, seen: &mut AutoBitSet, + left: &mut AutoBitSet, stack: &mut Vec, css_placed: &mut HashMap, root: IndexInt, @@ -587,6 +880,9 @@ impl EntryWalk { let (source_index, loader) = match frame { WalkFrame::Place { run, slot } => { self.owned[slot as usize].runs.push(run); + if run.end == u32::MAX { + left.set(run.source_index as usize); + } continue; } WalkFrame::PlaceCss { source_index, slot } => { @@ -622,9 +918,18 @@ impl EntryWalk { let mut begin = 0; let mark = stack.len(); + let owned = &mut self.owned; // The parts ahead of the one that imports `other` print before `other` does. let mut import = |part_index: u32, other: IndexInt, loader: u32| { - if other == Index::RUNTIME.value() || seen.is_set(other as usize) { + if other == Index::RUNTIME.value() { + return; + } + if seen.is_set(other as usize) { + if !left.is_set(other as usize) + && let Some(slot) = slot_of(other) + { + owned[slot as usize].has_cycle = true; + } return; } let is_css = css[other as usize].is_some(); diff --git a/test/bundler/bundler_compile_splitting.test.ts b/test/bundler/bundler_compile_splitting.test.ts index eb76a01ed0ce..1e11636cbb14 100644 --- a/test/bundler/bundler_compile_splitting.test.ts +++ b/test/bundler/bundler_compile_splitting.test.ts @@ -674,6 +674,33 @@ describe("bundler", () => { run: { file: "dist/out", stdout: "main ran\nmain main" }, }); + // See splitting/SharedChunkOwnerLoadTimeRead. The main thread enters the cycle of m2.js, m5.js and m6.js at m5.js, + // the worker at m6.js. + itBundled("compile/splitting/SharedChunkOwnerLoadTimeRead", { + backend: "cli", + compile: true, + splitting: true, + files: { + "/index.js": /* js */ ` + const m = await import("./m5.js"); + console.log("ok", m.w5); + new Worker("./worker.js"); + `, + "/worker.js": `import "./flag.js"; import "./m6.js"; console.log("worker");`, + "/flag.js": `globalThis.IS_WORKER = true;`, + "/m2.js": `export { v5 as x } from "./m5.js"; export function v2() { return 2 }`, + "/m5.js": /* js */ ` + import { v2 } from "./m2.js"; import { v6 } from "./m6.js"; + export function v5() { return v2 } + export const w5 = globalThis.IS_WORKER ? null : v6.toUpperCase(); + `, + "/m6.js": `import { v2 } from "./m2.js"; export const w6 = typeof v2; export let v6 = "six";`, + }, + entryPointsRaw: ["./index.js", "./worker.js"], + outfile: "dist/out", + run: { file: "dist/out", stdout: "ok SIX\nworker" }, + }); + // The chunk of the entry point has the name of the executable, and so do its source map and its metafile output. test.concurrent("Bun.build: import() and require() of the main entry point from another entry point", async () => { using dir = tempDir("compile-splitting-import-main", entryPointImportedBack); diff --git a/test/bundler/bundler_splitting.test.ts b/test/bundler/bundler_splitting.test.ts index dc380bd534ce..120d1617be3e 100644 --- a/test/bundler/bundler_splitting.test.ts +++ b/test/bundler/bundler_splitting.test.ts @@ -1491,6 +1491,140 @@ describe("bundler", () => { ], }); + // m2.js, m5.js and m6.js import each other, in a chunk that worker.js and the import() in index.js share. m5.js reads + // v6 at load. worker.js enters the cycle at m6.js, so m5.js comes first in its order. The import() enters it at m5.js, + // so m6.js comes first in its order, and the chunk follows that one. + const cycleWithLoadTimeRead = { + "/index.js": `import("./m5.js").then(m => console.log("ok", m.w5));`, + "/worker.js": `import "./flag.js"; import "./m6.js"; console.log("worker");`, + "/flag.js": `globalThis.IS_WORKER = true;`, + "/m2.js": `export { v5 as x } from "./m5.js"; export function v2() { return 2 }`, + "/m5.js": /* js */ ` + import { v2 } from "./m2.js"; import { v6 } from "./m6.js"; + export function v5() { return v2 } + export const w5 = globalThis.IS_WORKER ? null : v6.toUpperCase(); + `, + "/m6.js": `import { v2 } from "./m2.js"; export const w6 = typeof v2; export let v6 = "six";`, + }; + for (const [name, options] of Object.entries[1]>>({ + "": {}, + "EntriesSwapped": { entryPoints: ["/worker.js", "/index.js"] }, + "Browser": { target: "browser" }, + "Minified": { minifyIdentifiers: true, minifySyntax: true, minifyWhitespace: true }, + "HashedEntry": { + entryNaming: "[name].entry-[hash].[ext]", + onAfterBundle(api) { + launchHashedEntry(api, "index"); + launchHashedEntry(api, "worker"); + }, + }, + })) { + itBundled("splitting/SharedChunkOwnerLoadTimeRead" + name, { + files: cycleWithLoadTimeRead, + entryPoints: ["/index.js", "/worker.js"], + splitting: true, + target: "bun", + outdir: "/out", + format: "esm", + ...options, + run: [ + { file: "/out/index.js", stdout: "ok SIX" }, + { file: "/out/worker.js", stdout: "worker" }, + ], + }); + } + + // Without flag.js, worker.js throws unbundled too. index.js still loads. + itBundled("splitting/SharedChunkOwnerLoadTimeReadThrowingEntry", { + files: { + ...cycleWithLoadTimeRead, + "/worker.js": `import "./m6.js"; console.log("worker");`, + }, + entryPoints: ["/index.js", "/worker.js"], + splitting: true, + target: "bun", + outdir: "/out", + format: "esm", + run: { file: "/out/index.js", stdout: "ok SIX" }, + }); + + // store.js reads api.js only through a function that it calls at load. + itBundled("splitting/SharedChunkOwnerLoadTimeReadThroughFunction", { + files: { + "/main.js": `import { store } from "./store.js"; console.log("main", store);`, + "/worker.js": `import "./flag.js"; import { api } from "./api.js"; console.log("worker", api.name);`, + "/flag.js": `globalThis.IS_WORKER = true;`, + "/store.js": /* js */ ` + import { describe } from "./describe.js"; + export const store = globalThis.IS_WORKER ? "no store" : "store of " + describe(); + `, + "/describe.js": `import { api } from "./api.js"; export function describe() { return api.name; }`, + "/api.js": `import "./store.js"; export const api = { name: "api" };`, + }, + entryPoints: ["/worker.js", "/main.js"], + splitting: true, + outdir: "/out", + format: "esm", + run: [ + { file: "/out/main.js", stdout: "main store of api" }, + { file: "/out/worker.js", stdout: "worker api" }, + ], + }); + + // api.js names store only in a function that it stores, so it can come ahead of store.js. + itBundled("splitting/SharedChunkOwnerLoadTimeReadStoredFunction", { + files: { + "/main.js": `import { store } from "./store.js"; console.log("main", store);`, + "/worker.js": /* js */ ` + import "./flag.js"; import { api, getStore } from "./api.js"; + console.log("worker", api.name, typeof getStore); + `, + "/flag.js": `globalThis.IS_WORKER = true;`, + "/store.js": /* js */ ` + import { api } from "./api.js"; + export const store = globalThis.IS_WORKER ? "no store" : "store of " + api.name; + `, + "/api.js": /* js */ ` + import { store } from "./store.js"; + export const api = { name: "api" }; + export const getStore = () => store; + `, + }, + entryPoints: ["/worker.js", "/main.js"], + splitting: true, + outdir: "/out", + format: "esm", + run: [ + { file: "/out/main.js", stdout: "main store of api" }, + { file: "/out/worker.js", stdout: "worker api function" }, + ], + }); + + // m6.js names v5 only in a function that it stores, so the order of a.js breaks no read and the chunk stays with it. + // m5.js needs what m6.js sets on cfg. + itBundled("splitting/SharedChunkOwnerKeepsChunkWithStoredFunction", { + files: { + "/a.js": `import { label } from "./m5.js"; console.log("a", label);`, + "/b.js": `import { getV5 } from "./m6.js"; console.log("b", typeof getV5);`, + "/m5.js": /* js */ ` + import "./m6.js"; import { cfg } from "./cfg.js"; + export let v5 = 1; + export const label = cfg.name.toUpperCase(); + `, + "/m6.js": /* js */ ` + import { v5 } from "./m5.js"; import { cfg } from "./cfg.js"; + cfg.name = "app"; + export const getV5 = () => v5; + `, + "/cfg.js": `export const cfg = {};`, + }, + entryPoints: ["/a.js", "/b.js"], + splitting: true, + outdir: "/out", + format: "esm", + run: { file: "/out/a.js", stdout: "a APP" }, + }); + // Ported from Rolldown's code_splitting/issue_5276_2. itBundled("splitting/NamespaceImportAndDynamicImportOfSameModule", { files: {