Skip to content
Merged
Show file tree
Hide file tree
Changes from all 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
248 changes: 247 additions & 1 deletion crates/rspack_plugin_esm_library/src/optimize_chunks.rs
Original file line number Diff line number Diff line change
@@ -1,4 +1,4 @@
use std::sync::Arc;
use std::{collections::VecDeque, sync::Arc};

use atomic_refcell::AtomicRefCell;
use rayon::prelude::*;
Expand All @@ -15,6 +15,252 @@ use rspack_util::{

use crate::EsmLibraryPlugin;

/// Scan TLA-awaited async chunks for static dependencies that reside in ancestor
/// chunks. When a module with top-level await dynamically imports a chunk, and
/// that chunk has static imports back to ancestor chunks, the ancestor must
/// already have fully executed before the child can resolve its imports — but
/// the ancestor is paused at its top-level await, creating a deadlock.
///
/// This function extracts such shared modules into separate chunks to break
/// the cycle. It returns `true` if modules were actually extracted.
pub(crate) fn extract_tla_shared_modules(compilation: &mut Compilation) -> bool {
let module_graph = compilation.get_module_graph();
let chunk_graph = &compilation.build_chunk_graph_artifact.chunk_graph;
let chunk_group_by_ukey = &compilation.build_chunk_graph_artifact.chunk_group_by_ukey;

// Phase 1: Detect at-risk async chunks.
// Source: modules with `has_top_level_await`.
// We iterate `module.get_blocks()` (blocks attached directly to the module).
// This is conservative: a function-scoped `import()` also shows up here when
// the parser attaches its block to the module. We accept that over-inclusion
// — extraction only actually happens in later phases when there's a real
// cross-chunk static dependency back to an ancestor chunk, so function-only
// imports without cycles incur only a cheap BFS and no chunk mutation.
// Precise filtering would need AST-level "is the import() directly awaited
// at module top level" info which is not exposed on the block.
let mut async_chunks_set: FxHashSet<ChunkUkey> = FxHashSet::default();
for (module_id, module) in module_graph.modules() {
if !module.build_meta().has_top_level_await {
continue;
}
for block_id in module.get_blocks() {
Comment thread
JSerFeng marked this conversation as resolved.
let Some(block) = module_graph.block_by_id(block_id) else {
continue;
};
for dep_id in block.get_dependencies() {
let dep = module_graph.dependency_by_id(dep_id);
if dep.dependency_type() != &DependencyType::DynamicImport {
continue;
}
let Some(target) = module_graph.module_identifier_by_dependency_id(dep_id) else {
continue;
};
if target == module_id {
continue;
}
for &chunk_ukey in chunk_graph.get_module_chunks(*target) {
let chunk = compilation
.build_chunk_graph_artifact
.chunk_by_ukey
.expect_get(&chunk_ukey);
if !chunk.is_only_initial(chunk_group_by_ukey) {
async_chunks_set.insert(chunk_ukey);
}
}
}
}
}

if async_chunks_set.is_empty() {
return false;
}

// Phase 2 + 3: For each at-risk async chunk, compute its ancestor chunks and
// run a BFS through static outgoing edges. Each async chunk uses its OWN
// `visited` set so that a module reachable from multiple async chunks is
// analyzed once per origin — this matters when a shared module lives in
// DIFFERENT ancestor chunks for different async chunks (e.g. multi-entry
// builds where an async chunk hangs off each entry).
let async_chunks: Vec<ChunkUkey> = async_chunks_set.iter().copied().collect();
let mut modules_to_extract: IdentifierMap<FxHashSet<ChunkUkey>> = IdentifierMap::default();

for &async_chunk_ukey in &async_chunks {
// Collect ancestor chunks via chunk group parent traversal
let chunk = compilation
.build_chunk_graph_artifact
.chunk_by_ukey
.expect_get(&async_chunk_ukey);
let mut ancestor_groups = FxHashSet::default();
for group_ukey in chunk.groups() {
let group = chunk_group_by_ukey.expect_get(group_ukey);
ancestor_groups.extend(group.ancestors(chunk_group_by_ukey));
}
let mut ancestor_chunks: FxHashSet<ChunkUkey> = FxHashSet::default();
for g in &ancestor_groups {
ancestor_chunks.extend(chunk_group_by_ukey.expect_get(g).chunks.iter().copied());
}

if ancestor_chunks.is_empty() {
continue;
}

// Per-async-chunk BFS with a local `visited` set. Starts from all modules
// that live in this async chunk.
let mut visited = IdentifierSet::default();
let mut queue: VecDeque<ModuleIdentifier> = chunk_graph
.get_chunk_modules_identifier(&async_chunk_ukey)
.iter()
.copied()
.collect();

while let Some(module_id) = queue.pop_front() {
if !visited.insert(module_id) {
continue;
}

for conn in module_graph.get_outgoing_connections(&module_id) {
// Dynamic imports produce promises; the target is not required to have
// finished evaluating in the current frame, so no sync-load deadlock.
let dep = module_graph.dependency_by_id(&conn.dependency_id);
if dep.dependency_type() == &DependencyType::DynamicImport {
continue;
}
if !conn.is_target_active(
module_graph,
None,
&compilation.module_graph_cache_artifact,
&compilation
.build_module_graph_artifact
.side_effects_state_artifact,
&compilation.exports_info_artifact,
) {
continue;
}
let Some(target) = module_graph.module_identifier_by_dependency_id(&conn.dependency_id)
else {
continue;
};
let target_chunks = chunk_graph.get_module_chunks(*target);

// If the target lives in a chunk that is an ancestor of THIS async
// chunk, extract it from that specific ancestor chunk. Also continue
// BFS into the target — its own static deps may also live in ancestor
// chunks (e.g. async → sharedA(ancestor) → sharedB(ancestor)).
let mut in_ancestor = false;
for &target_chunk in target_chunks {
if ancestor_chunks.contains(&target_chunk) {
modules_to_extract
.entry(*target)
.or_default()
.insert(target_chunk);
in_ancestor = true;
}
}

// Continue BFS if target is inside this async chunk OR in an ancestor
// chunk (to transitively find all ancestor-resident dependencies).
if in_ancestor || target_chunks.contains(&async_chunk_ukey) {
queue.push_back(*target);
}
}
}
}

if modules_to_extract.is_empty() {
return false;
}

// Phase 4: Group modules by their exact source-chunk set (modules that live
// in the same set of chunks go into one new chunk — mirrors
// `RemoveDuplicateModulesPlugin`). Then create new chunks and relocate.
let mut chunk_group_map: FxHashMap<Vec<ChunkUkey>, Vec<ModuleIdentifier>> = FxHashMap::default();
for (module_id, source_chunks) in &modules_to_extract {
let mut sorted: Vec<ChunkUkey> = source_chunks.iter().copied().collect();
sorted.sort();
chunk_group_map.entry(sorted).or_default().push(*module_id);
}

for (source_chunks, modules) in chunk_group_map {
let new_chunk_ukey =
Compilation::add_chunk(&mut compilation.build_chunk_graph_artifact.chunk_by_ukey);
if let Some(mut mutations) = compilation.incremental.mutations_write() {
mutations.add(Mutation::ChunkAdd {
chunk: new_chunk_ukey,
});
}
{
let new_chunk = compilation
.build_chunk_graph_artifact
.chunk_by_ukey
.expect_get_mut(&new_chunk_ukey);
*new_chunk.chunk_reason_mut() = Some("extracted to break TLA circular dependency".into());
}
compilation
.build_chunk_graph_artifact
.chunk_graph
.add_chunk(new_chunk_ukey);

// For each module: collect which source chunks had it registered as an
// entry module, then disconnect from all source chunks and connect to the
// new chunk. Only modules that WERE entry modules get reconnected as entry.
for &module_id in &modules {
let mut entry_reconnections: Vec<ChunkGroupUkey> = Vec::new();
for source_chunk_ukey in &source_chunks {
if let Some(&entrypoint) = compilation
.build_chunk_graph_artifact
.chunk_graph
.get_chunk_entry_modules_with_chunk_group_iterable(source_chunk_ukey)
.get(&module_id)
{
entry_reconnections.push(entrypoint);
compilation
.build_chunk_graph_artifact
.chunk_graph
.disconnect_chunk_and_entry_module(source_chunk_ukey, module_id);
}
compilation
.build_chunk_graph_artifact
.chunk_graph
.disconnect_chunk_and_module(source_chunk_ukey, module_id);
}
compilation
.build_chunk_graph_artifact
.chunk_graph
.connect_chunk_and_module(new_chunk_ukey, module_id);
for entrypoint in entry_reconnections {
compilation
.build_chunk_graph_artifact
.chunk_graph
.connect_chunk_and_entry_module(new_chunk_ukey, module_id, entrypoint);
}
}

// Establish chunk group relationships: new_chunk joins each source chunk's
// groups (making it a sibling — it will be loaded alongside the source).
for source_chunk_ukey in &source_chunks {
let [Some(source_chunk), Some(new_chunk)] = compilation
.build_chunk_graph_artifact
.chunk_by_ukey
.get_many_mut([source_chunk_ukey, &new_chunk_ukey])
else {
unreachable!("both chunks should exist")
};
source_chunk.split(
new_chunk,
&mut compilation.build_chunk_graph_artifact.chunk_group_by_ukey,
);
if let Some(mut mutations) = compilation.incremental.mutations_write() {
mutations.add(Mutation::ChunkSplit {
from: *source_chunk_ukey,
to: new_chunk_ukey,
});
}
}
}

true
}

/// Ensure that all entry chunks only export the exports used by other chunks,
/// this requires no other chunks depend on the entry chunk to get exports
///
Expand Down
14 changes: 13 additions & 1 deletion crates/rspack_plugin_esm_library/src/plugin.rs
Original file line number Diff line number Diff line change
Expand Up @@ -43,7 +43,7 @@ use crate::{
esm_lib_parser_plugin::EsmLibParserPlugin,
optimize_chunks::{
analyze_dyn_import_targets, assign_dyn_import_chunk_short_names, ensure_entry_exports,
optimize_runtime_chunks,
extract_tla_shared_modules, optimize_runtime_chunks,
},
preserve_modules::preserve_modules,
runtime::EsmRegisterModuleRuntimeModule,
Expand Down Expand Up @@ -697,6 +697,18 @@ async fn optimize_chunks(&self, compilation: &mut Compilation) -> Result<Option<
crate::split_chunks::split(cache_groups, compilation).await?;
}

let extracted_tla_shared = extract_tla_shared_modules(compilation);
if extracted_tla_shared {
compilation.push_diagnostic(rspack_error::Diagnostic::warn(
"EsmLibraryPlugin".into(),
"Top-level await with shared modules caused a circular dependency between async and \
parent chunks. The shared modules have been extracted into separate chunks to break \
the cycle. After bundling, the execution order of top-level await may differ from the \
original source, which could lead to incorrect runtime behavior."
.into(),
));
}

ensure_entry_exports(compilation);
let concate_modules_map = self.concatenated_modules_map.read().await;
let concatenated_modules = concate_modules_map
Expand Down
Original file line number Diff line number Diff line change
@@ -0,0 +1,32 @@
```mjs title=async.mjs

// ./async.js



export { value } from "./lib_js.mjs";

```

```mjs title=lib_js.mjs
// ./lib.js
const value = () => 42
export { value };

```

```mjs title=main.mjs
import { value } from "./lib_js.mjs";

// ./index.js


const {value: valueAsync} = await import("./async.mjs");

it('should have correct value', () => {
expect(value).toBe(valueAsync);
expect(value()).toBe(42);
})
export {};

```

This file was deleted.

Original file line number Diff line number Diff line change
@@ -0,0 +1 @@
module.exports = [/Top-level await with shared modules caused a circular dependency/];
Original file line number Diff line number Diff line change
@@ -0,0 +1,34 @@
```mjs title=async.mjs

// ./async.js



export { value } from "./lib_js.mjs";

```

```mjs title=lib_js.mjs
// ./lib.js
const value = () => 42
export { value };

```

```mjs title=main.mjs
import { value } from "./lib_js.mjs";

// ./index2.js


const {value: valueAsync} = await import("./async.mjs");

it('should have correct value', () => {
expect(value).toBe(valueAsync);
expect(value()).toBe(42);
})
// ./index.js

export {};

```

This file was deleted.

Original file line number Diff line number Diff line change
@@ -0,0 +1 @@
module.exports = [/Top-level await with shared modules caused a circular dependency/];
Loading
Loading