Skip to content

Circular inter-chunk imports from acyclic modules when external dynamic imports exist #8595

@Dunqing

Description

@Dunqing

Found while investigating oxc-project/oxc#20033prettier-plugin-tailwindcss produces circular chunks when bundled with tsdown/rolldown.

Bug Description

When a module graph contains external dynamic imports (e.g., await import("@optional/ext")), rolldown can produce circular inter-chunk imports from a completely acyclic source module graph.

The root cause is in init_entry_point / chunk_optimizer: entry bit positions are assigned via .enumerate() over all entries including external modules, but external entries are skipped during chunk creation. This causes bit positions to not map 1:1 to chunk indices. The chunk optimizer then uses ChunkIdx::from_raw(bit_position) which produces wrong chunk indices, causing shared modules to be placed in wrong chunks.

Reproduction

Rolldown Repl

Minimal fixture (9 source files):

entry-a.js

import { format } from "./apis.js";
export const a = () => format("a");

entry-b.js

import { format } from "./apis.js";
export const b = () => format("b");

apis.js

export async function format(code) {
  let pl = await import("./plugin.js");
  let core = await import("./core.js");
  return core.run(pl.transform(code));
}

core.js

export async function run(code) {
  let pa = await import("./parser-a.js");
  let pb = await import("./parser-b.js");
  let pc = await import("./parser-c.js");
  return pa.parse(code) + pb.parse(code) + pc.parse(code);
}

plugin.js

import { parse as pa } from "./parser-a.js";
import { parse as pb } from "./parser-b.js";
import { parse as pc } from "./parser-c.js";
async function opt() { await import("@optional/ext"); }
export function transform(code) { opt(); return pa(code) + pb(code) + pc(code); }

parser-a.js / parser-b.js / parser-c.js

import { helper } from "./shared.js";
export function parse(x) { return helper("a" + x); }  // "b" / "c" for the others

shared.js

export function helper(x) { return x.toUpperCase(); }

Config:

{
  "input": { "entry-a": "entry-a.js", "entry-b": "entry-b.js" },
  "external": ["@optional/ext"]
}

Actual Output (buggy)

Modules are placed in wrong chunks. Notice parser-a.js contains parser-b.js's code and imports from plugin.js, while plugin.js contains parser-a.js's code and imports back from parser-a.js:

parser-a.js — contains parser-b's code, imports from plugin.js:

import { t as helper } from "./shared.js";
import { t as parse } from "./plugin.js";    // ⚠️ imports from plugin
//#region parser-b.js                         // ⚠️ wrong module!
function parse$1(x) {
	return helper("b" + x);                    // ⚠️ parser-b code in parser-a chunk
}
//#endregion
export { parse, parse$1 as t };

plugin.js — contains parser-a's code, imports from parser-a.js:

import { t as helper } from "./shared.js";
import { t as parse$1 } from "./parser-a.js";  // ⚠️ imports from parser-a
import { t as parse$2 } from "./parser-b.js";
//#region parser-a.js                           // ⚠️ wrong module!
function parse(x) {
	return helper("a" + x);                      // ⚠️ parser-a code in plugin chunk
}
//#endregion
//#region plugin.js
async function opt() {
	await import("@optional/ext");
}
function transform(code) {
	opt();
	return parse(code) + parse$1(code) + parse$2(code);
}
//#endregion
export { parse as t, transform };

Result: parser-a.js <-> plugin.js circular dependency

Expected Output

Each chunk contains only its own module's code. No circular dependencies:

parser-a.js — only parser-a code, imports only from shared:

import { t as helper } from "./shared.js";
//#region parser-a.js
function parse(x) {
	return helper("a" + x);
}
//#endregion
export { parse };

plugin.js — only plugin code, imports from parsers:

import { parse } from "./parser-a.js";
import { parse as parse$1 } from "./parser-b.js";
import { parse as parse$2 } from "./parser-c.js";
//#region plugin.js
async function opt() {
	await import("@optional/ext");
}
function transform(code) {
	opt();
	return parse(code) + parse$1(code) + parse$2(code);
}
//#endregion
export { transform };

Root Cause

In code_splitting.rs init_entry_point, bit positions are assigned by enumerate() over all entries (including externals), but chunks are only created for non-external entries. When the chunk optimizer in chunk_optimizer.rs converts bit positions to ChunkIdx via ChunkIdx::from_raw(bit_position), the off-by-one from skipped externals maps to wrong chunks.

Fix

Add a bit_to_chunk_idx: Vec<Option<ChunkIdx>> mapping to ChunkGraph, populated during init_entry_point, and use it in the chunk optimizer instead of ChunkIdx::from_raw(). Also add a bounds check to BitSet::has_bit for safety.

Metadata

Metadata

Assignees

No one assigned

    Labels

    No labels
    No labels

    Type

    No type

    Projects

    No projects

    Milestone

    No milestone

    Relationships

    None yet

    Development

    No branches or pull requests

    Issue actions