Minimal reproduction of a build-time bug in Vite 8 (with Rolldown) where a
nested import().then(() => import()) pattern causes the outer dynamic
import's preload deps array to be replaced with void 0, orphaning any CSS
the outer chunk imports.
- Vite version:
8.0.16 - Node: tested on
v22 - OS: macOS (also reproducible on Linux)
npm install
npm run buildimport('./a.js') should be wrapped as
__vitePreload(() => import('./a-…js'), __vite__mapDeps([…, 'a-….css']))
so the CSS dep is preloaded as a <link rel="stylesheet"> when the chunk
loads. With CSS code splitting, this is the only mechanism that loads CSS
attached to a non-entry chunk.
The built entry chunk (dist/assets/index-*.js) ends with:
r(()=>import(`./a-BP-_rVx-.js`).then(e=>{
globalThis.__loadedA=e.a,
r(()=>import(`./b-Doj2PM4D.js`),[])
}), void 0); // ← outer call: deps lostWhere r is __vitePreload. The __vite__mapDeps lookup table at the top
contains:
const __vite__mapDeps = ... ["assets/a-BP-_rVx-.js","assets/a-CHaxkE_K.css"] ...a-*.css is emitted to dist/assets/, but the only place its filename
appears in the bundled JS is as a string in the __vite__mapDeps table.
No code path references either index — the stylesheet never loads.
$ grep -rn "a-CHaxkE_K" dist/
dist/assets/index-De4fS43_.js:1:const __vite__mapDeps=(...,"assets/a-CHaxkE_K.css"]...
# no hits in index.html, no <link rel="stylesheet">, no preload call referencing itnpm run preview and open the page: the body background should be
hotpink but renders white. The dev server (vite) injects CSS via
JS and is unaffected — this is a production-build-only bug.
Any nested dynamic import of the shape
import('./outer').then(() => { import('./inner'); });…where ./outer is split into its own chunk and that chunk has any CSS
side-effect dep. Default Vite 8 chunking is sufficient — no manual
build.rolldownOptions.output.codeSplitting or manualChunks is needed.
packages/vite/src/node/plugins/importAnalysisBuild.ts walks dynamic
import() calls in source-text order during generateBundle and pairs
each with the next __VITE_PRELOAD__ marker after its end position:
for (let index = 0; index < imports.length; index++) {
const { e: end } = imports[index];
// … compute deps for this import …
let markerStartPos = findPreloadMarker(code, end);
if (markerStartPos > 0) {
s.update(markerStartPos, markerStartPos + preloadMarker.length, depsExpr);
rewroteMarkerStartPos.add(markerStartPos);
}
}
// fallback pass — any unclaimed marker becomes "void 0"
let markerStartPos = findPreloadMarker(code);
while (markerStartPos >= 0) {
if (!rewroteMarkerStartPos.has(markerStartPos))
s.update(markerStartPos, markerStartPos + preloadMarker.length, "void 0");
markerStartPos = findPreloadMarker(code, markerStartPos + preloadMarker.length);
}After the transform pass injects wrappers, the code looks like:
__vitePreload(
() => import('./a'),
__VITE_PRELOAD__ // marker B (intended for ./a — outer call)
).then(() => {
__vitePreload(
() => import('./b'),
__VITE_PRELOAD__ // marker A (intended for ./b — inner call)
);
});The inner marker A textually precedes the outer marker B because it sits
inside the .then(...) callback. So the import-walking loop does this:
./a(1st import).findPreloadMarker(code, end_a)returns the position of marker A. Writes./a's deps to A.rewroteMarkerStartPos = { posA }../b(2nd import).findPreloadMarker(code, end_b)returns the position of marker A again (still the next marker afterend_b). Overwrites A with./b's deps.rewroteMarkerStartPos = { posA }(unchanged).- Fallback pass. Marker B is not in
rewroteMarkerStartPos→ replaced with"void 0".
./a's deps array (which would have included a.css) is silently lost.
Skip past markers already claimed by a previous import in this iteration:
let markerStartPos = findPreloadMarker(code, end);
while (markerStartPos !== -1 && rewroteMarkerStartPos.has(markerStartPos)) {
markerStartPos = findPreloadMarker(
code,
markerStartPos + preloadMarker.length,
);
}A more robust fix would walk the AST and pair each __vitePreload call
with its own marker by AST relationship rather than source-text proximity,
which is fragile for any pattern where a __vitePreload body contains
another __vitePreload.
Vite 7 uses Rollup, which hoisted CSS attached to nested chunks up to the nearest entry or parent chunk during chunk graph construction. CSS deps ended up on chunks whose dynamic imports were not trapped in nested-then positions, so the marker-pairing happened to work despite the latent bug.
Vite 8 uses Rolldown, which keeps CSS on the chunk where it was originally
imported. The CSS dep now lives on the inner-most chunk involved in the
nested-then, exposing the bug. See laravel/framework#59662 for further
documentation of the underlying chunk-shape change between Rollup and
Rolldown.
- vite: 8.0.16
- rolldown: 1.1.1 (transitive)
- node: 22.x
- pnpm/npm: any
- OS: macOS / Linux