[Bug]: dynamic entries sorted by path give an import cycle an execution order that cannot occur at runtime
Reproduction link or steps
Four files, no plugins, input + format: esm only.
src/entry.js
export default { async fetch() { return (await import("./server.js")).server_exports; } };src/server.js
import { __exportAll, getRouter } from "./router.js";
export function getStartContext() { return "ctx"; }
async function loadRoutes() {
const a = await import("./routeA.js");
return [a.A(), getRouter()];
}
// Module-evaluation-time use of a binding from the other half of the cycle.
export const server_exports = /* @__PURE__ */ __exportAll({ default: () => loadRoutes });src/router.js
import { getStartContext } from "./server.js";
// `var`, so a too-late declaration yields `undefined` rather than a TDZ error.
var __exportAll = (all) => {
const target = {};
for (const name in all) Object.defineProperty(target, name, { get: all[name], enumerable: true });
return target;
};
// Deferred: the other half of the cycle is only read when this is called.
export const getRouter = () => `router(${getStartContext()})`;
export { __exportAll };src/routeA.js
import { getRouter } from "./router.js";
export const A = () => "A:" + getRouter();rolldown.config.mjs
export default { input: "src/entry.js", output: { dir: "out", format: "esm", minifyInternalExports: false } };Run the source as plain ESM, then the bundle:
node -e "import('./src/entry.js').then(m=>m.default.fetch()).then(r=>console.log('native : OK',Object.keys(r))).catch(e=>console.log('native : FAIL',e.message))"
npx rolldown -c rolldown.config.mjs
node -e "import('./out/entry.js').then(m=>m.default.fetch()).then(r=>console.log('bundled : OK',Object.keys(r))).catch(e=>console.log('bundled : FAIL',e.message))"What is expected?
Both print OK. The source runs correctly as plain ESM, so the bundle should too.
native : OK [ 'default' ]
bundled : OK [ 'default' ]What is actually happening?
native : OK [ 'default' ]
bundled : FAIL __exportAll is not a functionThe two modules of the cycle are concatenated in the reverse of the order they can be evaluated in:
//#region src/server.js
const server_exports = __exportAll({ default: () => loadRoutes }); // call
//#endregion
//#region src/router.js
var __exportAll = (all) => { /* ... */ }; // declarationserver.js and router.js are a cycle, and it only works if router.js is evaluated first. At runtime that is what happens: server.js is entered first, so its dependency router.js runs before server.js's own body.
LinkStage::new sorts non-user-defined entries by (kind, module id), and the module id is the path:
// crates/rolldown/src/stages/link_stage/mod.rs
rest.sort_by_cached_key(|item| {
(item.kind, scan_stage_output.module_table.modules[item.idx].id().as_str())
});routeA.js sorts before server.js alphabetically, so sort_modules starts its walk at routeA.js:
routeA -> router -> server (cycle, already on the stack) -> emit server -> emit routerserver.js gets the lower exec order and is emitted first, so the var it calls is hoisted but unassigned.
The chosen order is not one legal option among several — it cannot occur. routeA.js is dynamically imported from inside server.js, so nothing reaches it without evaluating server.js first. Forcing that order by hand fails natively too:
node -e "import('./src/routeA.js').then(() => import('./src/server.js'))"
# TypeError: __exportAll is not a functionRemove either of these and the output is correct:
entry.jsimportingserver.jsstatically instead of dynamicallyrouteA.jsnot importing anything fromrouter.js
System Info
System:
OS: macOS 26.6.2
CPU: (11) arm64 Apple M3 Pro
Shell: 5.9 - /bin/zsh
Binaries:
Node: 26.8.1
npm: 11.19.0
pnpm: 12.3.4
bun: 1.4.0
npmPackages:
rolldown: 1.2.8 => 1.2.8Any additional comments?
Not a recent regression: reproduces identically on 1.1.5, 1.2.0, 1.2.3, 1.2.4, 1.2.5, 1.2.6, 1.2.7 and 1.2.8.
How I ran into it. A TanStack Start app built through Nitro, which bundles in two stages: Vite writes chunks into node_modules/.nitro/vite/services/ssr/assets/, then rolldown re-bundles those already-chunked files. Stage 1's output contains exactly this cycle (server-*.js imports __exportAll from router-*.js, router-*.js imports getStartContext back), which is fine under its own entry order. Stage 2 has different entries, ~30 route chunks all sort before server-*.js by filename, and the cycle flips. Downstream reports: nitrojs/nitro#4605 and nitrojs/nitro#4113.
Relation to existing issues, as far as I can tell:
- #10747 — same failure shape and same stack (TanStack Start + Nitro), but that one is a 1.2.4 → 1.2.5 regression across two chunks that import each other. This one is within a single chunk and predates 1.2.5 by a long way, so I do not think they are the same defect, though a fix for ordering may well touch both.
- #10673 — cross-chunk evaluation order where either entry load order is legitimate and one of them crashes. Here there is only one possible order and rolldown picks the other one.
- #10294 —
strictExecutionOrder/onDemandWrappingledger. This reproduces with no experimental options set. - #9993 / #10101 — same helper family (
__commonJSMin), fixed; this input is unaffected by that fix.
I have a fix and a regression fixture; PR to follow.
Source: rolldown/rolldown