fix: prevent circular chunk dependencies in manualCodeSplitting#8227
fix: prevent circular chunk dependencies in manualCodeSplitting#8227mldangelo wants to merge 10 commits intorolldown:mainfrom
Conversation
| if self.ctx.chunk_graph.chunks_with_circular_deps.contains(&self.ctx.chunk_idx) { | ||
| if let Statement::VariableDeclaration(var_decl) = &mut top_stmt { | ||
| var_decl.kind = ast::VariableDeclarationKind::Var; | ||
| for decl in &mut var_decl.declarations { |
There was a problem hiding this comment.
Class declarations in circular chunks are not converted, which will cause TDZ errors similar to const/let. The top_level_var block above (lines 1544-1548) handles class declarations by transforming them, but this circular deps handler only converts variable declarations.
If a module uses export class MyClass {} in a circular chunk, it will still throw TDZ errors. The fix should include:
if self.ctx.chunk_graph.chunks_with_circular_deps.contains(&self.ctx.chunk_idx) {
if let Statement::VariableDeclaration(var_decl) = &mut top_stmt {
var_decl.kind = ast::VariableDeclarationKind::Var;
for decl in &mut var_decl.declarations {
decl.kind = VariableDeclarationKind::Var;
}
}
// Add class handling:
if let Statement::ClassDeclaration(class_decl) = &mut top_stmt {
if let Some(mut decl) = self.get_transformed_class_decl(class_decl) {
top_stmt = Statement::from(decl.take_in(self.alloc));
}
}
}| if self.ctx.chunk_graph.chunks_with_circular_deps.contains(&self.ctx.chunk_idx) { | |
| if let Statement::VariableDeclaration(var_decl) = &mut top_stmt { | |
| var_decl.kind = ast::VariableDeclarationKind::Var; | |
| for decl in &mut var_decl.declarations { | |
| if self.ctx.chunk_graph.chunks_with_circular_deps.contains(&self.ctx.chunk_idx) { | |
| if let Statement::VariableDeclaration(var_decl) = &mut top_stmt { | |
| var_decl.kind = ast::VariableDeclarationKind::Var; | |
| for decl in &mut var_decl.declarations { | |
| decl.kind = ast::VariableDeclarationKind::Var; | |
| } | |
| } | |
| // Add class handling: | |
| if let Statement::ClassDeclaration(class_decl) = &mut top_stmt { | |
| if let Some(mut decl) = self.get_transformed_class_decl(class_decl) { | |
| top_stmt = Statement::from(decl.take_in(self.alloc)); | |
| } | |
| } | |
| } |
Spotted by Graphite Agent
Is this helpful? React 👍 or 👎 to let us know.
There was a problem hiding this comment.
Pull request overview
Fixes TDZ-related runtime crashes caused by circular chunk import graphs produced by manualCodeSplitting / advancedChunks when includeDependenciesRecursively: false, by switching affected top-level bindings from const/let to var.
Changes:
- Add a post-
compute_cross_chunk_linkspass to detect circular chunk import relationships and record affected chunks. - Convert top-level
const/letdeclarations (and top-level class decls) tovarin chunks participating in circular chunk deps to avoid TDZReferenceError. - Add a new integration test case (
cross_chunk_tdz_esm) and update snapshots.
Reviewed changes
Copilot reviewed 10 out of 10 changed files in this pull request and generated 3 comments.
Show a summary per file
| File | Description |
|---|---|
| crates/rolldown/src/stages/generate_stage/mod.rs | Calls circular-chunk detection after cross-chunk links are computed. |
| crates/rolldown/src/chunk_graph.rs | Adds chunks_with_circular_deps tracking + circular import detection logic. |
| crates/rolldown/src/module_finalizers/mod.rs | Applies const/let→var (and class rewrite) in chunks marked as circular. |
| crates/rolldown/tests/rolldown/function/advanced_chunks/cross_chunk_tdz_esm/* | New repro test + expected output snapshot. |
| crates/rolldown/tests/snapshots/integration_rolldown__filename_with_hash.snap | Snapshot updates for the new test and regenerated hashes. |
Comments suppressed due to low confidence (1)
crates/rolldown/src/module_finalizers/mod.rs:1569
- The comment says “Convert const/let to var”, but this block also rewrites top-level
classdeclarations viaget_transformed_class_decl. Either update the comment to mention class TDZ as well, or split the concerns so the comment accurately reflects what’s happening.
// Convert const/let to var in chunks with circular chunk dependencies.
// This runs AFTER export stripping (above), so it catches declarations
// that were originally `export const` and are now plain `const`.
// Without this, circular chunk imports cause TDZ ReferenceErrors because
// const/let bindings cannot be accessed before initialization.
if self.ctx.chunk_graph.chunks_with_circular_deps.contains(&self.ctx.chunk_idx) {
if let Statement::VariableDeclaration(var_decl) = &mut top_stmt {
var_decl.kind = ast::VariableDeclarationKind::Var;
for decl in &mut var_decl.declarations {
decl.kind = VariableDeclarationKind::Var;
}
}
if let Statement::ClassDeclaration(class_decl) = &mut top_stmt {
if let Some(mut decl) = self.get_transformed_class_decl(class_decl) {
top_stmt = Statement::from(decl.take_in(self.alloc));
}
}
}
program.body.push(top_stmt);
if is_module_decl {
crates/rolldown/src/chunk_graph.rs
Outdated
| /// When chunk A imports from chunk B and chunk B also imports from chunk A, both chunks | ||
| /// are part of a circular dependency. Modules in these chunks need `const`/`let` converted | ||
| /// to `var` to prevent TDZ errors during ESM circular evaluation. | ||
| pub fn detect_circular_chunk_deps(&mut self) { | ||
| for (chunk_idx, chunk) in self.chunk_table.iter_enumerated() { | ||
| for imported_chunk_idx in chunk.imports_from_other_chunks.keys().copied().collect::<Vec<_>>() | ||
| { | ||
| if imported_chunk_idx == chunk_idx { | ||
| continue; | ||
| } | ||
| if self.chunk_table[imported_chunk_idx].imports_from_other_chunks.contains_key(&chunk_idx) { | ||
| self.chunks_with_circular_deps.insert(chunk_idx); | ||
| self.chunks_with_circular_deps.insert(imported_chunk_idx); | ||
| } | ||
| } | ||
| } |
There was a problem hiding this comment.
detect_circular_chunk_deps only detects mutual imports (A↔B). A longer cycle (A→B→C→A) won’t mark any chunk here, but it’s still a circular chunk dependency and can still trigger TDZ issues. Consider detecting all cycles (e.g., SCC via Tarjan/Kosaraju, marking SCCs with size>1, or a DFS back-edge check) so every chunk participating in any cycle is covered.
| /// When chunk A imports from chunk B and chunk B also imports from chunk A, both chunks | |
| /// are part of a circular dependency. Modules in these chunks need `const`/`let` converted | |
| /// to `var` to prevent TDZ errors during ESM circular evaluation. | |
| pub fn detect_circular_chunk_deps(&mut self) { | |
| for (chunk_idx, chunk) in self.chunk_table.iter_enumerated() { | |
| for imported_chunk_idx in chunk.imports_from_other_chunks.keys().copied().collect::<Vec<_>>() | |
| { | |
| if imported_chunk_idx == chunk_idx { | |
| continue; | |
| } | |
| if self.chunk_table[imported_chunk_idx].imports_from_other_chunks.contains_key(&chunk_idx) { | |
| self.chunks_with_circular_deps.insert(chunk_idx); | |
| self.chunks_with_circular_deps.insert(imported_chunk_idx); | |
| } | |
| } | |
| } | |
| /// Any chunk that participates in a directed cycle in the chunk graph is marked as having | |
| /// circular dependencies. Modules in these chunks need `const`/`let` converted to `var` to | |
| /// prevent TDZ errors during ESM circular evaluation. | |
| pub fn detect_circular_chunk_deps(&mut self) { | |
| // Clear previous results so repeated calls re-compute from scratch. | |
| self.chunks_with_circular_deps.clear(); | |
| let chunk_count = self.chunk_table.chunks.len(); | |
| // 0 = unvisited, 1 = visiting, 2 = done | |
| let mut state = vec![0u8; chunk_count]; | |
| let mut stack: Vec<ChunkIdx> = Vec::new(); | |
| for (chunk_idx, _) in self.chunk_table.iter_enumerated() { | |
| let idx = chunk_idx.index(); | |
| if state[idx] == 0 { | |
| self.dfs_mark_circular_chunks(chunk_idx, &mut state, &mut stack); | |
| } | |
| } | |
| } | |
| /// Depth-first search helper to find and mark all chunks participating in any directed cycle. | |
| /// | |
| /// `state`: 0 = unvisited, 1 = visiting (on current DFS stack), 2 = done. | |
| /// `stack`: current DFS path of chunk indices. | |
| fn dfs_mark_circular_chunks( | |
| &mut self, | |
| current: ChunkIdx, | |
| state: &mut [u8], | |
| stack: &mut Vec<ChunkIdx>, | |
| ) { | |
| let current_raw = current.index(); | |
| state[current_raw] = 1; | |
| stack.push(current); | |
| // Collect neighbors first to avoid borrowing issues with recursive calls. | |
| let neighbors: Vec<ChunkIdx> = self.chunk_table[current] | |
| .imports_from_other_chunks | |
| .keys() | |
| .copied() | |
| .collect(); | |
| for next in neighbors { | |
| // Ignore self-imports, preserving previous behavior that focused on cross-chunk cycles. | |
| if next == current { | |
| continue; | |
| } | |
| let next_raw = next.index(); | |
| match state[next_raw] { | |
| 0 => { | |
| // Tree edge: continue DFS. | |
| self.dfs_mark_circular_chunks(next, state, stack); | |
| } | |
| 1 => { | |
| // Back edge: current -> next where `next` is on the stack. | |
| // All nodes from `next` to the top of the stack are part of a cycle. | |
| if let Some(pos) = stack.iter().position(|&idx| idx == next) { | |
| for &cyclic_chunk in &stack[pos..] { | |
| self.chunks_with_circular_deps.insert(cyclic_chunk); | |
| } | |
| } | |
| } | |
| _ => { | |
| // state == 2: already fully processed; nothing to do. | |
| } | |
| } | |
| } | |
| state[current_raw] = 2; | |
| stack.pop(); |
crates/rolldown/src/chunk_graph.rs
Outdated
| for imported_chunk_idx in chunk.imports_from_other_chunks.keys().copied().collect::<Vec<_>>() | ||
| { |
There was a problem hiding this comment.
This loop allocates a Vec of keys (keys().copied().collect::<Vec<_>>()) just to iterate. If you keep the current approach, you can iterate keys directly (or restructure the algorithm) to avoid per-chunk allocations during generation.
| for imported_chunk_idx in chunk.imports_from_other_chunks.keys().copied().collect::<Vec<_>>() | |
| { | |
| for &imported_chunk_idx in chunk.imports_from_other_chunks.keys() { |
| } else if self.ctx.options.top_level_var { | ||
| // Here we should find if it's a "VariableDeclaration" and switch it to "Var." | ||
| if let Statement::VariableDeclaration(var_decl) = &mut top_stmt { | ||
| var_decl.kind = ast::VariableDeclarationKind::Var; | ||
| for decl in &mut var_decl.declarations { | ||
| decl.kind = VariableDeclarationKind::Var; | ||
| } | ||
| } | ||
| if let Statement::ClassDeclaration(class_decl) = &mut top_stmt { | ||
| if let Some(mut decl) = self.get_transformed_class_decl(class_decl) { | ||
| top_stmt = Statement::from(decl.take_in(self.alloc)); | ||
| } | ||
| } |
There was a problem hiding this comment.
The top_level_var block and the circular-deps block duplicate the same “convert VariableDeclaration to var + transform ClassDeclaration” logic. Consider extracting a small helper (or combining the conditions) to avoid divergence if this transformation needs to change later.
|
Let's discuss it at #7449 before making a pull request. |
d53ad9b to
e0116af
Compare
e0116af to
fdd0234
Compare
When `includeDependenciesRecursively: false` splits a non-wrapped module into a separate chunk, detect if it would create a bidirectional import between the new chunk and an existing chunk. If so, skip splitting that module to prevent TDZ errors at runtime and emit a MANUAL_CODE_SPLITTING_SKIPPED warning with actionable suggestions. Wrapped modules (WrapKind::Esm/Cjs) are excluded from this check since their lazy initialization already prevents TDZ. A const/let-to-var conversion safety net is kept as defense-in-depth for circular chunk deps that the prevention heuristic cannot catch (e.g. transitive chains, cycles emerging after chunk optimization). The safety net is scoped to ESM format and non-wrapped modules only. Fixes rolldown#7449
7d947d5 to
0811d86
Compare
Co-Authored-By: Claude Opus 4.6 <noreply@anthropic.com>
crates/rolldown/src/chunk_graph.rs
Outdated
| /// When chunk A imports from chunk B and chunk B also imports from chunk A, both chunks | ||
| /// are part of a circular dependency. Modules in these chunks need `const`/`let` converted | ||
| /// to `var` to prevent TDZ errors during ESM circular evaluation. | ||
| /// | ||
| /// Note: This currently detects direct mutual imports (A↔B) only. Longer cycles (A→B→C→A) | ||
| /// would require SCC detection (e.g. Tarjan's algorithm) but are uncommon in practice since | ||
| /// chunk-level circular deps are typically caused by `manualCodeSplitting` splitting a module | ||
| /// away from its dependents. | ||
| pub fn detect_circular_chunk_deps(&mut self) { | ||
| for (chunk_idx, chunk) in self.chunk_table.iter_enumerated() { | ||
| for &imported_chunk_idx in chunk.imports_from_other_chunks.keys() { | ||
| if imported_chunk_idx == chunk_idx { | ||
| continue; | ||
| } | ||
| if self.chunk_table[imported_chunk_idx].imports_from_other_chunks.contains_key(&chunk_idx) { | ||
| self.chunks_with_circular_deps.insert(chunk_idx); | ||
| self.chunks_with_circular_deps.insert(imported_chunk_idx); |
There was a problem hiding this comment.
detect_circular_chunk_deps() currently only detects direct mutual imports (A↔B). Any longer chunk cycle (e.g. A→B→C→A) will not be marked in chunks_with_circular_deps, so the const/let→var safety net in module_finalizers won’t run for those chunks and TDZ ReferenceErrors can still happen. Consider switching this to a proper cycle/SCC detection over the chunk import graph (and clear chunks_with_circular_deps at the start of the pass to avoid stale data if the method is ever called more than once).
| /// When chunk A imports from chunk B and chunk B also imports from chunk A, both chunks | |
| /// are part of a circular dependency. Modules in these chunks need `const`/`let` converted | |
| /// to `var` to prevent TDZ errors during ESM circular evaluation. | |
| /// | |
| /// Note: This currently detects direct mutual imports (A↔B) only. Longer cycles (A→B→C→A) | |
| /// would require SCC detection (e.g. Tarjan's algorithm) but are uncommon in practice since | |
| /// chunk-level circular deps are typically caused by `manualCodeSplitting` splitting a module | |
| /// away from its dependents. | |
| pub fn detect_circular_chunk_deps(&mut self) { | |
| for (chunk_idx, chunk) in self.chunk_table.iter_enumerated() { | |
| for &imported_chunk_idx in chunk.imports_from_other_chunks.keys() { | |
| if imported_chunk_idx == chunk_idx { | |
| continue; | |
| } | |
| if self.chunk_table[imported_chunk_idx].imports_from_other_chunks.contains_key(&chunk_idx) { | |
| self.chunks_with_circular_deps.insert(chunk_idx); | |
| self.chunks_with_circular_deps.insert(imported_chunk_idx); | |
| /// Any strongly connected component (SCC) in the chunk graph of size > 1 is considered a | |
| /// circular dependency (e.g. A↔B, or A→B→C→A). Additionally, a single chunk that imports | |
| /// itself (self-loop) is treated as a circular dependency. | |
| /// | |
| /// Modules in these chunks need `const`/`let` converted to `var` to prevent TDZ errors during | |
| /// ESM circular evaluation. | |
| fn tarjan_scc( | |
| &self, | |
| v: ChunkIdx, | |
| index: &mut u32, | |
| indices: &mut FxHashMap<ChunkIdx, u32>, | |
| lowlinks: &mut FxHashMap<ChunkIdx, u32>, | |
| stack: &mut Vec<ChunkIdx>, | |
| on_stack: &mut FxHashSet<ChunkIdx>, | |
| sccs: &mut Vec<Vec<ChunkIdx>>, | |
| ) { | |
| indices.insert(v, *index); | |
| lowlinks.insert(v, *index); | |
| *index += 1; | |
| stack.push(v); | |
| on_stack.insert(v); | |
| for &w in self.chunk_table[v].imports_from_other_chunks.keys() { | |
| if !indices.contains_key(&w) { | |
| self.tarjan_scc(w, index, indices, lowlinks, stack, on_stack, sccs); | |
| let v_lowlink = lowlinks[&v]; | |
| let w_lowlink = lowlinks[&w]; | |
| if w_lowlink < v_lowlink { | |
| lowlinks.insert(v, w_lowlink); | |
| } | |
| } else if on_stack.contains(&w) { | |
| let v_lowlink = lowlinks[&v]; | |
| let w_index = indices[&w]; | |
| if w_index < v_lowlink { | |
| lowlinks.insert(v, w_index); | |
| } | |
| } | |
| } | |
| if let (Some(&v_index), Some(&v_lowlink)) = (indices.get(&v), lowlinks.get(&v)) { | |
| if v_lowlink == v_index { | |
| let mut scc = Vec::new(); | |
| loop { | |
| if let Some(w) = stack.pop() { | |
| on_stack.remove(&w); | |
| scc.push(w); | |
| if w == v { | |
| break; | |
| } | |
| } else { | |
| break; | |
| } | |
| } | |
| if !scc.is_empty() { | |
| sccs.push(scc); | |
| } | |
| } | |
| } | |
| } | |
| pub fn detect_circular_chunk_deps(&mut self) { | |
| // Ensure we don't retain stale data if this method is ever called more than once. | |
| self.chunks_with_circular_deps.clear(); | |
| let mut index: u32 = 0; | |
| let mut indices: FxHashMap<ChunkIdx, u32> = FxHashMap::default(); | |
| let mut lowlinks: FxHashMap<ChunkIdx, u32> = FxHashMap::default(); | |
| let mut stack: Vec<ChunkIdx> = Vec::new(); | |
| let mut on_stack: FxHashSet<ChunkIdx> = FxHashSet::default(); | |
| let mut sccs: Vec<Vec<ChunkIdx>> = Vec::new(); | |
| for (chunk_idx, _chunk) in self.chunk_table.iter_enumerated() { | |
| if !indices.contains_key(&chunk_idx) { | |
| self.tarjan_scc( | |
| chunk_idx, | |
| &mut index, | |
| &mut indices, | |
| &mut lowlinks, | |
| &mut stack, | |
| &mut on_stack, | |
| &mut sccs, | |
| ); | |
| } | |
| } | |
| for scc in sccs { | |
| if scc.len() > 1 { | |
| // Any SCC with more than one chunk is a circular dependency. | |
| for chunk_idx in scc { | |
| self.chunks_with_circular_deps.insert(chunk_idx); | |
| } | |
| } else if let Some(&chunk_idx) = scc.first() { | |
| // Single-node SCC: treat as circular only if there is a self-loop. | |
| let chunk = &self.chunk_table[chunk_idx]; | |
| if chunk.imports_from_other_chunks.contains_key(&chunk_idx) { | |
| self.chunks_with_circular_deps.insert(chunk_idx); |
Resolve merge conflicts and address code review feedback: - Fix wrap_kind().is_some() -> !wrap_kind().is_none() (WrapKind is not Option) - Fix borrow checker conflict by dropping splitter before emitting warnings - Add checks guard for ManualCodeSplittingSkipped warning emission - Deduplicate const/let-to-var conversion logic in module_finalizers Co-Authored-By: Claude Opus 4.6 <noreply@anthropic.com>
- Replace !matches!(x, Some(false)) with != Some(false) for clarity - Eliminate intermediate Vec allocation in cycle detection by moving the "outside group" filter into the inner any() closure - Shorten constructor name to manual_code_splitting_circular_chunk_dep - Simplify module_id derivation to use id().to_string() matching the convention used by IneffectiveDynamicImport Co-Authored-By: Claude Opus 4.6 <noreply@anthropic.com>
Verify that CJS modules with circular chunk dependencies from manualCodeSplitting work correctly. CJS modules are always wrapped with __commonJSMin (lazy initialization), so the split proceeds and execution order is correct despite circular chunk imports. This covers the original CJS scenario from rolldown#7449. Co-Authored-By: Claude Opus 4.6 <noreply@anthropic.com>
crates/rolldown/src/chunk_graph.rs
Outdated
| /// Note: This currently detects direct mutual imports (A↔B) only. Longer cycles (A→B→C→A) | ||
| /// would require SCC detection (e.g. Tarjan's algorithm) but are uncommon in practice since | ||
| /// chunk-level circular deps are typically caused by `manualCodeSplitting` splitting a module | ||
| /// away from its dependents. |
There was a problem hiding this comment.
The docstring says longer cycles "would require SCC detection" and are "uncommon in practice," but the PR description states "Transitive chains (A→B→C→A) and cycles emerging after chunk optimization still rely on the safety net." This discrepancy should be resolved — the doc comment should explicitly note that transitive cycles are handled by the const/let→var safety net layer rather than implying they are simply out of scope.
| /// Note: This currently detects direct mutual imports (A↔B) only. Longer cycles (A→B→C→A) | |
| /// would require SCC detection (e.g. Tarjan's algorithm) but are uncommon in practice since | |
| /// chunk-level circular deps are typically caused by `manualCodeSplitting` splitting a module | |
| /// away from its dependents. | |
| /// Note: This currently detects direct mutual imports (A↔B) only. Longer/transitive cycles | |
| /// (A→B→C→A) or cycles that emerge after later optimizations are not detected here and instead | |
| /// rely on the general const/let→var safety net layer. Direct detection is mainly an | |
| /// optimization, since chunk-level circular deps are typically caused by `manualCodeSplitting` | |
| /// splitting a module away from its dependents. |
Resolve conflicts with latest main. Update doc comment on detect_circular_chunk_deps to clarify that transitive cycles rely on the const/let-to-var safety net layer. Co-Authored-By: Claude Opus 4.6 <noreply@anthropic.com>
| let needs_top_level_var = self.ctx.options.top_level_var | ||
| || (self.ctx.options.format.is_esm() | ||
| && self.ctx.linking_info.wrap_kind().is_none() | ||
| && self.ctx.chunk_graph.chunks_with_circular_deps.contains(&self.ctx.chunk_idx)); |
There was a problem hiding this comment.
I prefer to warn the user and let them decide whether to enable topLevelVar. (Because there is not only one way to solve circular references)
There was a problem hiding this comment.
This is also how rollup handle circularChunks
https://github.com/rollup/rollup/blob/ae846957f109690a866cc3e4c073613c338d3476/src/Bundle.ts#L234-L273
|
Just took a rough look to manual code splitting part, I have to say I'm not convinced about what this PR tries to solve. Feel free to reach me in email or discord for deeper discussions. I will also check this PR again when I got time. |
Remove the automatic const→var safety net (Layer 2) and the BundleHandle.close() refactor, keeping only the circular chunk dependency prevention heuristic with warning emission. - Remove chunks_with_circular_deps and detect_circular_chunk_deps - Remove automatic const/let→var conversion in module finalizers - Remove cross_chunk_tdz_esm_safety_net test fixture - Revert all BundleHandle.close() refactor changes - Remove design docs (rust-bundler.md, rust-classic-bundler.md) - Remove unused glob dev-dependency - Simplify double negative wrap_kind check - Simplify PreventedManualSplit ordering with tuple key - Simplify diagnostic message construction Co-Authored-By: Claude Opus 4.6 <noreply@anthropic.com>
| /// Prevent circular chunk dependencies for non-wrapped modules. | ||
| /// | ||
| /// When `includeDependenciesRecursively: false`, a module can be split into a | ||
| /// separate chunk while its dependencies stay in the entry chunk. If the split | ||
| /// module is also depended on by something in the entry chunk, this creates a | ||
| /// bidirectional (circular) import between chunks, causing TDZ errors for | ||
| /// const/let bindings at runtime. | ||
| /// | ||
| /// Wrapped modules (WrapKind::Esm/Cjs) use lazy initialization (__esmMin/__cjsMin) | ||
| /// and are immune to TDZ, so we skip them. | ||
| fn prevent_circular_chunk_deps( | ||
| &self, | ||
| module_groups: &mut FxHashMap<u32, ModuleGroup>, | ||
| ) -> FxHashMap<ModuleIdx, PreventedManualSplit> { | ||
| let mut prevented_manual_splits: FxHashMap<ModuleIdx, PreventedManualSplit> = | ||
| FxHashMap::default(); | ||
|
|
||
| if self.chunking_options.include_dependencies_recursively != Some(false) { | ||
| return prevented_manual_splits; | ||
| } | ||
|
|
||
| // Build reverse dependency map: for each module, which modules depend on it? | ||
| let mut reverse_deps: IndexVec<ModuleIdx, Vec<ModuleIdx>> = | ||
| index_vec![Vec::new(); self.link_output.module_table.modules.len()]; | ||
| for normal_module in self.link_output.module_table.modules.iter().filter_map(Module::as_normal) | ||
| { | ||
| if !self.link_output.metas[normal_module.idx].is_included { | ||
| continue; | ||
| } | ||
| for dep in &self.link_output.metas[normal_module.idx].dependencies { | ||
| reverse_deps[*dep].push(normal_module.idx); | ||
| } | ||
| } | ||
|
|
||
| for group in module_groups.values_mut() { | ||
| let mut to_remove = Vec::new(); | ||
| for &module_idx in &group.modules { | ||
| // Wrapped modules (CJS/ESM) use lazy init -- immune to TDZ | ||
| if self.link_output.metas[module_idx].wrap_kind() != WrapKind::None { | ||
| continue; | ||
| } | ||
|
|
||
| // Check if splitting this module would create a circular chunk dependency: | ||
| // module_idx is in this group, has deps outside the group, and something | ||
| // outside the group depends on module_idx AND shares a chunk (same bits) | ||
| // with one of module_idx's outside deps. | ||
| let deps = &self.link_output.metas[module_idx].dependencies; | ||
| let rdeps = &reverse_deps[module_idx]; | ||
| let would_create_cycle = rdeps.iter().any(|&rdep| { | ||
| !group.modules.contains(&rdep) | ||
| && self.link_output.metas[rdep].is_included | ||
| && deps.iter().any(|&dep| { | ||
| !group.modules.contains(&dep) | ||
| && self.index_splitting_info[dep].bits == self.index_splitting_info[rdep].bits | ||
| }) | ||
| }); | ||
|
|
||
| if would_create_cycle { | ||
| let new_prevented = PreventedManualSplit { | ||
| priority: group.priority, | ||
| match_group_index: group.match_group_index, | ||
| group_name: group.name.clone(), | ||
| }; | ||
| // Keep only the best (lowest ordering key) prevented split per module. | ||
| match prevented_manual_splits.get(&module_idx) { | ||
| Some(existing) if existing.ordering_key() <= new_prevented.ordering_key() => {} | ||
| _ => { | ||
| prevented_manual_splits.insert(module_idx, new_prevented); | ||
| } | ||
| } | ||
| to_remove.push(module_idx); | ||
| } | ||
| } | ||
|
|
||
| for module_idx in to_remove { | ||
| group.remove_module(module_idx, &self.link_output.module_table); | ||
| } | ||
| } | ||
|
|
||
| prevented_manual_splits | ||
| } |
There was a problem hiding this comment.
The PR description describes a "Layer 2: Safety net" with three components: chunks_with_circular_deps + detect_circular_chunk_deps() in chunk_graph.rs, const/let→var conversion in module_finalizers/mod.rs, a call to detect_circular_chunk_deps() in generate_stage/mod.rs, and a cross_chunk_tdz_esm_safety_net test case. None of these are present in the actual diff or the current codebase. The table in the PR description lists these as changes in the PR, but they are absent. If the safety net is intentionally omitted (i.e., only Layer 1 is included), the PR description should be updated to reflect this. If these changes were supposed to be included, they are missing from the PR.
| let would_create_cycle = rdeps.iter().any(|&rdep| { | ||
| !group.modules.contains(&rdep) | ||
| && self.link_output.metas[rdep].is_included | ||
| && deps.iter().any(|&dep| { | ||
| !group.modules.contains(&dep) | ||
| && self.index_splitting_info[dep].bits == self.index_splitting_info[rdep].bits | ||
| }) | ||
| }); |
There was a problem hiding this comment.
The cycle detection check at line 180 uses self.index_splitting_info[dep].bits == self.index_splitting_info[rdep].bits to determine if dep and rdep are in the same chunk. However, this comparison is based on the pre-manual-splitting bit state — it does not account for the fact that dep might also be assigned to a different manual splitting group and thus moved to a different chunk. This can produce false positives: a split may be prevented even when splitting would not actually create a circular dependency (because dep would itself end up in a separate manual chunk, not in the same chunk as rdep). This is a known limitation (mentioned in the PR description for transitive cycles), but this specific over-approximation applies to direct cycles too. The practical impact is that some valid splits may be unnecessarily blocked when multiple manual groups overlap in their target modules.
| // Prevent circular chunk dependencies before any further processing. | ||
| let prevented_manual_splits = self.prevent_circular_chunk_deps(&mut module_groups); | ||
|
|
||
| self.extract_runtime_chunk(&mut module_groups); | ||
|
|
There was a problem hiding this comment.
The prevent_circular_chunk_deps function is called before extract_runtime_chunk, which means the runtime module could still be present in group.modules during cycle analysis. For the runtime module specifically, if it is included in a group (unlikely in practice, but possible if a regex matches it), cycle prevention would run on it. The runtime module typically has WrapKind::None but represents a special module. Since extract_runtime_chunk removes it from groups right after, this ordering issue is benign in practice but could produce misleading PreventedManualSplit entries for the runtime module that are never emitted (because after removal by extract_runtime_chunk, the runtime module would be in module_to_assigned, suppressing the warning). This is a minor ordering concern but not a critical correctness bug.
| // Prevent circular chunk dependencies before any further processing. | |
| let prevented_manual_splits = self.prevent_circular_chunk_deps(&mut module_groups); | |
| self.extract_runtime_chunk(&mut module_groups); | |
| // Extract the runtime chunk before further processing so it does not participate in | |
| // circular dependency prevention or other group transformations. | |
| self.extract_runtime_chunk(&mut module_groups); | |
| // Prevent circular chunk dependencies on the finalized module groups. | |
| let prevented_manual_splits = self.prevent_circular_chunk_deps(&mut module_groups); |
Summary
When
includeDependenciesRecursively: falsesplits a non-wrapped module into a separate chunk, it can create bidirectional (circular) imports between chunks, causing TDZReferenceErrorforconst/letbindings at runtime.This PR adds cycle prevention during manual group formation and keeps a const/let→var safety net as defense-in-depth:
Layer 1: Prevention (new)
After populating module groups, detect if splitting a module would create a circular chunk dependency. If so, keep it in the entry chunk and emit a
MANUAL_CODE_SPLITTING_SKIPPEDwarning with actionable suggestions (strictExecutionOrder: trueorincludeDependenciesRecursively: true).Wrapped modules (
WrapKind::Esm/Cjs) are excluded since their lazy initialization (__esmMin/__cjsMin) already prevents TDZ.Scope: The prevention heuristic catches direct A↔B cycles at group formation time. Transitive chains (A→B→C→A) and cycles emerging after chunk optimization still rely on the safety net.
Layer 2: Safety net (existing)
For any circular chunk deps that slip through, convert
const/lettovarin affected chunks. Scoped to ESM format and non-wrapped modules only.How cycle detection works
For each non-wrapped module in a manual splitting group:
BitSetbits) with one of its outside depsChanges
manual_code_splitting.rsPreventedManualSplittracking, warning emissionchunk_graph.rschunks_with_circular_deps+detect_circular_chunk_deps()(safety net)module_finalizers/mod.rsgenerate_stage/mod.rsdetect_circular_chunk_deps()event_kind.rsManualCodeSplittingSkippedevent kind (separate fromCircularDependency)manual_code_splitting_circular_chunk_dependency.rscross_chunk_tdz_esm/constpreservedcross_chunk_tdz_esm_strict_execution_order/cross_chunk_tdz_esm_safety_net/manual_splitting_no_circular/Test results
cross_chunk_tdz_esm: Single chunk,constpreserved,MANUAL_CODE_SPLITTING_SKIPPEDwarning ✓cross_chunk_tdz_esm_strict_execution_order: Vendor chunk created, wrapped modules split correctly ✓cross_chunk_tdz_esm_safety_net: Two chunks,varconversion in affected chunks ✓manual_splitting_no_circular: Vendor chunk created for leaf module ✓issues/3650: Snapshot unchanged ✓Fixes #7449