Skip to content

fix: preload css for nested dynamic imports#22759

Open
shulaoda wants to merge 1 commit into
mainfrom
06-24-fix_preload_css_for_nested_dynamic_imports
Open

fix: preload css for nested dynamic imports#22759
shulaoda wants to merge 1 commit into
mainfrom
06-24-fix_preload_css_for_nested_dynamic_imports

Conversation

@shulaoda

@shulaoda shulaoda commented Jun 24, 2026

Copy link
Copy Markdown
Member

Description

Fixes #22700 and closes #22721

In a nested dynamic import like import('a').then(() => import('b')) where a has a CSS dependency, the production build set the outer import's preload deps to void 0. The stylesheet for a was emitted to disk and listed in the __vite__mapDeps table, but nothing referenced its index, so no <link> was created and the CSS never loaded in production. Dev is unaffected because CSS is injected by JS there.

Root cause

generateBundle in importAnalysisBuild.ts matched each dynamic import() with the next __VITE_PRELOAD__ marker after it. Rolldown wraps the whole import().then() expression, so the wrappers nest and the inner marker comes before the outer one in the text:

__vitePreload(() => import('a').then(() => __vitePreload(() => import('b'), ⟨inner⟩)), ⟨outer⟩)

Matching by the next marker gave the inner marker to both imports, where the second write overwrote the first, and left the outer marker unclaimed, so the fallback set it to void 0 and a's deps, including its CSS, were lost.

This only surfaces with Rolldown because the dynamic-import wrapping shape changed. Rollup-era Vite wrapped each import() on its own and left the .then() outside, like __vitePreload(() => import('a'), Ma).then(() => __vitePreload(() => import('b'), Mb)), so the markers stayed interleaved (import a < Ma < import b < Mb) and the next-marker pairing landed correctly. Rolldown's native plugin wraps the whole import().then() for a non-destructuring .then, which nests the inner marker before the outer one. That whole-.then wrapping landed in rolldown#8328 to tree-shake import().then((m) => m.prop) (refs #21121), and a side effect is that .then(() => import()) nests the markers too, which is what trips the proximity-based pairing.

Fix

The imports and their markers nest like balanced brackets, so they are matched with a stack in a single pass, where each marker closes the innermost import still open. Every import keeps its own deps for any nesting depth, for ternary arms like cond ? import(a) : import(b), and for sibling imports inside a .then. It also removes a quadratic repeated scan that the old approach did once per import on deeply nested chunks.

Tests

A build regression was added under playground/dynamic-import/nested/then-css/. It uses a nested import().then(() => import()) where the outer module has a CSS side effect, and asserts that the outer module's stylesheet actually applies. It fails on main and passes with this change.

pnpm --filter vite build and pnpm test-build dynamic-import both pass, serve mode passes, and eslint and oxfmt are clean.


Thanks to @mturac for digging into this in #22721.

Co-authored-by: mehmet turac 345446+mturac@users.noreply.github.com

@shulaoda shulaoda force-pushed the 06-24-fix_preload_css_for_nested_dynamic_imports branch from f7f90fa to 4b943fb Compare June 24, 2026 01:32
@shulaoda shulaoda requested a review from sapphi-red June 24, 2026 01:40
@shulaoda shulaoda marked this pull request as draft June 24, 2026 10:44
@shulaoda shulaoda marked this pull request as ready for review June 24, 2026 23:55

@sapphi-red sapphi-red left a comment

Copy link
Copy Markdown
Member

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

This was already latent in Vite 7 on Rollup. Rollup hoisted the CSS of a nested chunk up to its parent, so the affected import was never trapped inside the nested .then. Rolldown keeps the CSS on the original chunk, which is what exposes the problem.

Why does this happen?

@shulaoda

Copy link
Copy Markdown
Member Author

Why does this happen?

Sorry, that paragraph was wrong. It was AI-written and I didn't catch it. I've fixed the description.

The real reason it only shows up on Rolldown is the wrapping shape, not CSS hoisting.

Rollup-era Vite wrapped each import() on its own and left the .then() outside, so each import's marker sits right after it:

__vitePreload(() => import("a"), Ma).then(() => __vitePreload(() => import("b"), Mb))

The markers stay interleaved (import a, Ma, import b, Mb), so pairing each import with the next marker is correct: a → Ma, b → Mb.

Rolldown's native plugin wraps the whole import().then() for a non-destructuring .then, which nests the calls:

__vitePreload(() => import("a").then(() => __vitePreload(() => import("b"), Mb)), Ma)

Now both imports come first and Mb precedes Ma (import a, import b, Mb, Ma). So "next marker after each import" gives Mb to both imports, and Ma is left unclaimed and becomes void 0. That is the lost CSS.

This wrapping change came in with rolldown/rolldown#8328 (tree-shaking for import().then((m) => m.prop), refs #21121). A side effect is that .then(() => import()) now nests the markers too, which is what trips Vite's proximity-based pairing.

@sapphi-red

Copy link
Copy Markdown
Member

ok. Is it possible to change the Rolldown plugin to output

__vitePreload(() => import("a"), Ma).then(() => __vitePreload(() => import("b"), Mb))

? That feels less fragile.

Also, have you considered other ways to solve this?

@shulaoda

Copy link
Copy Markdown
Member Author

ok. Is it possible to change the Rolldown plugin to output

__vitePreload(() => import("a"), Ma).then(() => __vitePreload(() => import("b"), Mb))

? That feels less fragile.

Also, have you considered other ways to solve this?

If this wasn't an intentional change in rolldown/rolldown#8328, I'll probably need to adjust it on the Rolldown side.

@sapphi-red

Copy link
Copy Markdown
Member

On second thought,

__vitePreload(() => import("a"), Ma).then(() => __vitePreload(() => import("b"), Mb))

isn't possible, because Rolldown requires import("a").then((m) => m.foo) for treeshaking.

@shulaoda

Copy link
Copy Markdown
Member Author

On second thought,

__vitePreload(() => import("a"), Ma).then(() => __vitePreload(() => import("b"), Mb))

isn't possible, because Rolldown requires import("a").then((m) => m.foo) for treeshaking.

Yes, that's what I thought

Sign up for free to join this conversation on GitHub. Already have an account? Sign in to comment

Labels

None yet

Projects

None yet

Development

Successfully merging this pull request may close these issues.

Build: nested import().then(() => import()) drops outer __vitePreload deps to void 0, orphaning CSS

2 participants