-
Notifications
You must be signed in to change notification settings - Fork 710
Description
Found while investigating oxc-project/oxc#20033 — prettier-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
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 othersshared.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.