When using tsdown (powered by rolldown) with codeSplitting: false and DTS generation enabled via rolldown-plugin-dts, rolldown produces unexpected chunk files containing runtime helpers (__defProp, __exportAll, etc.), violating the codeSplitting: false setting.
| Package | Version |
|---|---|
tsdown |
0.20.3 |
rolldown (bundled in tsdown) |
1.0.0-rc.3 |
rolldown (direct dep) |
1.0.0-rc.6 |
rolldown-plugin-dts |
0.22.2 |
typescript |
5.9.3 |
rolldown-plugin-dtsregisters atransformhook that detects entry modules- For each entry module, it calls
this.emitFile({ type: "chunk", id: dtsVirtualId })to emit a virtual DTS entry - Rolldown now sees 2 entries (the real entry + the DTS virtual entry) instead of 1
- With multiple entries, rolldown extracts shared runtime helpers (
__defProp,__exportAll) into a separate chunk file — even thoughcodeSplitting: falseis set - This only affects formats where DTS is enabled (e.g., ESM with
dts: true). IIFE withdts: falseproduces a single file as expected.
bun install
bun run builddist/
├── index.mjs # Single ESM bundle (no chunks)
├── index.d.mts # DTS file
└── index.iife.js # IIFE bundle
Only 3 files. The ESM bundle should be self-contained.
dist/
├── chunk-DQk6qfdC.mjs # ← BUG: unexpected runtime helper chunk
├── index.d.mts
├── index.iife.js
└── index.mjs # imports from chunk-DQk6qfdC.mjs
4 files. index.mjs contains:
import { t as __exportAll } from "./chunk-DQk6qfdC.mjs";
// ... rest of bundleAnd chunk-DQk6qfdC.mjs contains:
var __defProp = Object.defineProperty;
var __exportAll = (all, no_symbols) => {
let target = {};
for (var name in all) {
__defProp(target, name, { get: all[name], enumerable: true });
}
if (!no_symbols) {
__defProp(target, Symbol.toStringTag, { value: "Module" });
}
return target;
};
export { __exportAll as t };- IIFE format (with
dts: false): ✅ Single file, no chunks — correct behavior - ESM format (with
dts: true): ❌ Chunk file generated — bug - The only difference is DTS being enabled, which causes
rolldown-plugin-dtsto callemitFile({ type: "chunk" }) - The
export * as lib from "./lib"pattern triggers the__exportAllruntime helper, which rolldown then splits into a separate chunk when it sees multiple entries
A generateBundle plugin that inlines the runtime chunk back into the entry and deletes it:
const inlineRuntimeChunkPlugin = {
name: "inline-runtime-chunk",
generateBundle(_, bundle) {
// Collect non-entry chunks (runtime helpers)
const chunks = new Map();
for (const [fileName, output] of Object.entries(bundle)) {
if (output.type === "chunk" && !output.isEntry && !output.isDynamicEntry) {
chunks.set(fileName, output);
}
}
if (chunks.size === 0) return;
// Inline chunk imports into entry chunks
for (const output of Object.values(bundle)) {
if (output.type !== "chunk" || !output.isEntry) continue;
if (output.fileName.endsWith(".d.ts")) continue;
let { code } = output;
for (const [chunkFileName, chunk] of chunks) {
const escapedName = chunkFileName.split("/").pop().replace(/\./g, "\\.");
const importRegex = new RegExp(
`import\\s*\\{[^}]+\\}\\s*from\\s*["'][^"']*${escapedName}["'];?\\n?`
);
if (!importRegex.test(code)) continue;
const body = chunk.code
.replace(/^\/\*[\s\S]*?\*\/\s*\n?/, "")
.replace(/\/\/#region[^\n]*\n?/g, "")
.replace(/\/\/#endregion[^\n]*\n?/g, "")
.replace(/\n?export\s*\{[^}]*\};?\s*$/m, "")
.trim();
code = code.replace(importRegex, `${body}\n`);
}
output.code = code;
}
// Delete inlined chunk files
for (const fileName of chunks.keys()) {
delete bundle[fileName];
}
},
};