Skip to content

fix(css): preserve source-import order in CSS cascade (#4890)#22541

Open
karlvr wants to merge 3 commits into
vitejs:mainfrom
karlvr:fix/css-import-order-4890
Open

fix(css): preserve source-import order in CSS cascade (#4890)#22541
karlvr wants to merge 3 commits into
vitejs:mainfrom
karlvr:fix/css-import-order-4890

Conversation

@karlvr
Copy link
Copy Markdown
Contributor

@karlvr karlvr commented May 28, 2026

This PR fixes #4890: CSS <link> tags being emitted in the wrong order in production builds, which reverses the CSS cascade so the wrong rules win.

Two things could scramble the CSS order:

  1. Within a chunk. When multiple CSS modules landed in the same output chunk, their contents were concatenated in Object.keys(chunk.modules) order (roughly Rollup's internal order), rather than in source-import order.
  2. Across chunks / in the HTML. getCssFilesForChunk collected <link> tags by walking the chunk import graph. When two CSS modules with a definite source-import order ended up in different emitted CSS files (e.g. one pulled into its own chunk and absorbed as a pure-CSS chunk into a shared chunk's importedCss), the chunk-level walk could emit their link tags in the wrong relative order.

The fix in this PR is:

  • css.ts: In renderChunk we walk the chunk's modules via the module graph (importedIds) in DFS source-import order. (post-order, so a module's dependencies' CSS is concatenated before its own) and concatenate CSS in that order. We also build a new cssModuleFileMapCache mapping each CSS module id to its emitted CSS filename, which the html.ts change below consumes.
  • html.ts: We rewrite getCssFilesForChunk to DFS the module graph from the entry's facade module (same post-order), looking up each module's emitted file in the map built above. A chunk-import post-order walk remains as a fallback for CSS not reachable via the module graph.

There's also a new playground to demonstrate the fix and test for regressions, and unit tests in html.spec.ts were updated and extended.

karlvr added 3 commits May 28, 2026 15:51
Walk the chunk's modules via `getModuleInfo(id).importedIds` in DFS
source-import order when building a chunk's concatenated CSS, instead
of iterating `Object.keys(chunk.modules)`. The chunk-record order is
determined by Rollup's chunking and does not reliably match the order
in which the source code imports the CSS modules — so two CSS modules
in the same chunk could end up concatenated in the wrong cascade order.

Falls back to chunk-record order for modules not reached from the
facade (side-effect-only auto-injected modules, manualChunks output
without a facade).

Refs vitejs#4890.
`getCssFilesForChunk` previously walked `chunk.imports` post-order and
appended each chunk's `viteMetadata.importedCss` in iteration order.
Both orderings are derived from Rollup's chunking and do not match the
order in which the source code imports the CSS — so when two pure-CSS
chunks were absorbed into a shared importer, the resulting `<link>`
tags could load in the wrong cascade order. With two HTML entries that
share their imports (the classic shape from vitejs#4890), the absorption
ordering reversed and the app's override stylesheet ended up loading
*before* the vendor stylesheet it was meant to override.

Instead, walk the module graph from the entry chunk's facade module in
DFS source-import order. Each CSS module's emitted file (looked up in
`cssModuleFileMapCache`, populated as chunks are rendered) is
registered at the position in the walk where its importing module
references it. Post-order DFS preserves the existing "dependencies'
CSS first" cascade behavior.

The old chunk-import post-order walk is kept as a fallback for CSS
files that aren't reached via the module graph (e.g., CSS-only chunks
with no facade-reachable module, or implicit Rollup edges), and for
callers that don't pass module-graph info.

Adds unit tests in `html.spec.ts` that demonstrate the bug (without
module info, the fallback still produces the inverted order) and the
fix (with module info, source-import order wins).

Fixes vitejs#4890.
Adds `playground/css-import-order/` covering the OP shape from vitejs#4890:
two HTML entries that both import a vendor stylesheet (via a shared
helper module) and then an override stylesheet. The vendor.css is
forced into its own chunk via `manualChunks` so it lands in the
absorbed-pure-CSS-chunk path — historically the place where the
override link ended up before the vendor link.

The build-mode tests assert that `vendor.css` precedes `override.css`
in both `dist/index.html` and `dist/entry2/index.html`, and the
runtime test asserts the cascade outcome (`.box` is the override's
blue, not the vendor's red).
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.

The order of <style> and <link rel="stylesheet"> changes after build

1 participant