Skip to content

fix: prevent circular chunk dependencies in manualCodeSplitting#8227

Open
mldangelo wants to merge 10 commits intorolldown:mainfrom
mldangelo:fix/cross-chunk-tdz-const-to-var
Open

fix: prevent circular chunk dependencies in manualCodeSplitting#8227
mldangelo wants to merge 10 commits intorolldown:mainfrom
mldangelo:fix/cross-chunk-tdz-const-to-var

Conversation

@mldangelo
Copy link

@mldangelo mldangelo commented Feb 7, 2026

Summary

When includeDependenciesRecursively: false splits a non-wrapped module into a separate chunk, it can create bidirectional (circular) imports between chunks, causing TDZ ReferenceError for const/let bindings 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_SKIPPED warning with actionable suggestions (strictExecutionOrder: true or includeDependenciesRecursively: 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/let to var in 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:

  1. Check if it has dependencies outside the group
  2. Check if something outside the group depends on it AND shares a chunk (same BitSet bits) with one of its outside deps
  3. If both → splitting would create a circular chunk import → remove from group

Changes

File Change
manual_code_splitting.rs Cycle prevention with WrapKind guard, PreventedManualSplit tracking, warning emission
chunk_graph.rs chunks_with_circular_deps + detect_circular_chunk_deps() (safety net)
module_finalizers/mod.rs const/let→var for ESM non-wrapped chunks with circular deps (safety net)
generate_stage/mod.rs Call detect_circular_chunk_deps()
event_kind.rs New ManualCodeSplittingSkipped event kind (separate from CircularDependency)
manual_code_splitting_circular_chunk_dependency.rs Warning diagnostic with actionable suggestions
cross_chunk_tdz_esm/ Validates cycle prevention: single chunk, const preserved
cross_chunk_tdz_esm_strict_execution_order/ Validates WrapKind guard: split proceeds with lazy init
cross_chunk_tdz_esm_safety_net/ Validates const→var safety net fires independently
manual_splitting_no_circular/ Validates leaf modules still split correctly

Test results

  • cross_chunk_tdz_esm: Single chunk, const preserved, MANUAL_CODE_SPLITTING_SKIPPED warning ✓
  • cross_chunk_tdz_esm_strict_execution_order: Vendor chunk created, wrapped modules split correctly ✓
  • cross_chunk_tdz_esm_safety_net: Two chunks, var conversion in affected chunks ✓
  • manual_splitting_no_circular: Vendor chunk created for leaf module ✓
  • issues/3650: Snapshot unchanged ✓
  • Full integration suite: 733 passed, 0 failed ✓

Fixes #7449

Copilot AI review requested due to automatic review settings February 7, 2026 07:55
Comment on lines 1555 to 1558
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 {
Copy link
Contributor

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

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));
    }
  }
}
Suggested change
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

Fix in Graphite


Is this helpful? React 👍 or 👎 to let us know.

Copy link
Contributor

Copilot AI left a comment

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

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_links pass to detect circular chunk import relationships and record affected chunks.
  • Convert top-level const/let declarations (and top-level class decls) to var in chunks participating in circular chunk deps to avoid TDZ ReferenceError.
  • 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/letvar (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 class declarations via get_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 {

Comment on lines +83 to +98
/// 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);
}
}
}
Copy link

Copilot AI Feb 7, 2026

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

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.

Suggested change
/// 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();

Copilot uses AI. Check for mistakes.
Comment on lines +88 to +89
for imported_chunk_idx in chunk.imports_from_other_chunks.keys().copied().collect::<Vec<_>>()
{
Copy link

Copilot AI Feb 7, 2026

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

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.

Suggested change
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() {

Copilot uses AI. Check for mistakes.
Comment on lines +1537 to +1548
} 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));
}
}
Copy link

Copilot AI Feb 7, 2026

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

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.

Copilot uses AI. Check for mistakes.
@IWANABETHATGUY IWANABETHATGUY marked this pull request as draft February 7, 2026 08:24
@IWANABETHATGUY
Copy link
Member

Let's discuss it at #7449 before making a pull request.

@mldangelo mldangelo force-pushed the fix/cross-chunk-tdz-const-to-var branch from d53ad9b to e0116af Compare February 7, 2026 22:23
@mldangelo mldangelo changed the title fix: convert const/let to var in chunks with circular dependencies fix: prevent circular chunk dependencies in manualCodeSplitting Feb 7, 2026
@mldangelo mldangelo force-pushed the fix/cross-chunk-tdz-const-to-var branch from e0116af to fdd0234 Compare February 8, 2026 07:32
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
@mldangelo mldangelo force-pushed the fix/cross-chunk-tdz-const-to-var branch from 7d947d5 to 0811d86 Compare February 8, 2026 18:27
@mldangelo mldangelo marked this pull request as ready for review February 8, 2026 21:44
Copilot AI review requested due to automatic review settings February 8, 2026 21:44
Copy link
Contributor

Copilot AI left a comment

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Pull request overview

Copilot reviewed 39 out of 44 changed files in this pull request and generated 1 comment.

Comment on lines +83 to +99
/// 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);
Copy link

Copilot AI Feb 8, 2026

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

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).

Suggested change
/// 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);

Copilot uses AI. Check for mistakes.
mldangelo and others added 2 commits March 3, 2026 10:45
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>
Copilot AI review requested due to automatic review settings March 3, 2026 16:51
Copy link
Contributor

Copilot AI left a comment

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Pull request overview

Copilot reviewed 199 out of 4371 changed files in this pull request and generated 1 comment.

Comment on lines +87 to +90
/// 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.
Copy link

Copilot AI Mar 3, 2026

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

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.

Suggested change
/// 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.

Copilot uses AI. Check for mistakes.
mldangelo and others added 2 commits March 3, 2026 13:35
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>
Copilot AI review requested due to automatic review settings March 3, 2026 18:36
@mldangelo mldangelo review requested due to automatic review settings March 3, 2026 18:36
@codspeed-hq
Copy link

codspeed-hq bot commented Mar 3, 2026

Merging this PR will not alter performance

✅ 8 untouched benchmarks


Comparing mldangelo:fix/cross-chunk-tdz-const-to-var (d2be521) with main (a4f0798)

Open in CodSpeed

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));
Copy link
Member

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

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)

Copy link
Member

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

@hyf0 hyf0 self-assigned this Mar 4, 2026
@hyf0
Copy link
Member

hyf0 commented Mar 4, 2026

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>
Copilot AI review requested due to automatic review settings March 4, 2026 09:27
Copy link
Contributor

Copilot AI left a comment

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Pull request overview

Copilot reviewed 48 out of 57 changed files in this pull request and generated 3 comments.

Comment on lines +127 to 207
/// 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
}
Copy link

Copilot AI Mar 4, 2026

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

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.

Copilot uses AI. Check for mistakes.
Comment on lines +175 to +182
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
})
});
Copy link

Copilot AI Mar 4, 2026

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

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.

Copilot uses AI. Check for mistakes.
Comment on lines +104 to 108
// 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);

Copy link

Copilot AI Mar 4, 2026

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

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.

Suggested change
// 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);

Copilot uses AI. Check for mistakes.
Sign up for free to join this conversation on GitHub. Already have an account? Sign in to comment

Labels

None yet

Projects

None yet

Development

Successfully merging this pull request may close these issues.

[Bug]: incorrect execution order with CJS + advancedChunks

4 participants