Skip to content

charlsantony/vite-css-preload-bug

Folders and files

NameName
Last commit message
Last commit date

Latest commit

 

History

1 Commit
 
 
 
 
 
 
 
 
 
 
 
 

Repository files navigation

Vite 8 — nested dynamic import drops CSS preload deps to void 0

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)

Reproduce

npm install
npm run build

Expected

import('./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.

Actual

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 lost

Where 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 it

npm 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.

Trigger

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.

Root cause

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:

  1. ./a (1st import). findPreloadMarker(code, end_a) returns the position of marker A. Writes ./a's deps to A. rewroteMarkerStartPos = { posA }.
  2. ./b (2nd import). findPreloadMarker(code, end_b) returns the position of marker A again (still the next marker after end_b). Overwrites A with ./b's deps. rewroteMarkerStartPos = { posA } (unchanged).
  3. 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.

Suggested fix

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.

Why this only surfaces on Vite 8

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.

System info

- vite: 8.0.16
- rolldown: 1.1.1 (transitive)
- node: 22.x
- pnpm/npm: any
- OS: macOS / Linux

About

No description, website, or topics provided.

Resources

Stars

Watchers

Forks

Releases

Packages

Contributors

Languages