From 973207e03b749d7cf9b553691ab38853bcdf1553 Mon Sep 17 00:00:00 2001 From: DukeDeSouth Date: Sat, 7 Feb 2026 13:52:28 -0500 Subject: [PATCH] fix(css): preserve css order when merging pure css chunks When a pure CSS chunk (created via `manualChunks` or code splitting) is removed from `chunk.imports`, its CSS files were appended to the importing chunk's `importedCss` Set via `.add()`. Since Sets maintain insertion order and the chunk's own CSS was already in the Set, the pure CSS chunk's styles ended up AFTER the importing chunk's styles in the cascade. This broke the expected CSS order: dependency styles should appear before the importing module's styles so the importing module can override them with same-specificity selectors. The fix saves the chunk's own CSS before merging, collects CSS from removed pure CSS chunks in their original import order, then rebuilds `importedCss` with the correct ordering: pure CSS chunk styles first, then the chunk's own styles. Fixes #3924 Fixes #6375 Co-authored-by: Cursor --- packages/vite/src/node/plugins/css.ts | 21 ++++++- .../__tests__/css-dynamic-import.spec.ts | 55 +++++++++++++++++-- .../css-dynamic-import/async-order/base.css | 3 + .../css-dynamic-import/async-order/base.js | 5 ++ .../css-dynamic-import/async-order/page.css | 4 ++ .../css-dynamic-import/async-order/page.js | 13 +++++ playground/css-dynamic-import/index.html | 5 ++ 7 files changed, 99 insertions(+), 7 deletions(-) create mode 100644 playground/css-dynamic-import/async-order/base.css create mode 100644 playground/css-dynamic-import/async-order/base.js create mode 100644 playground/css-dynamic-import/async-order/page.css create mode 100644 playground/css-dynamic-import/async-order/page.js diff --git a/packages/vite/src/node/plugins/css.ts b/packages/vite/src/node/plugins/css.ts index 30021593c79a50..9ed9f5994d4f1f 100644 --- a/packages/vite/src/node/plugins/css.ts +++ b/packages/vite/src/node/plugins/css.ts @@ -1100,14 +1100,23 @@ export function cssPostPlugin(config: ResolvedConfig): Plugin { // remove pure css chunk from other chunk's imports, // and also register the emitted CSS files under the importer // chunks instead. + // + // We need to preserve the original import order: CSS from pure + // CSS chunks should appear at the same position they had in the + // imports list, not after the chunk's own CSS. + // Save the chunk's own CSS before merging. + const ownImportedCss = [...chunk.viteMetadata!.importedCss] + + // Collect CSS from pure CSS chunks in their original import + // order position. + const pureCssFromImports: string[] = [] + chunk.imports = chunk.imports.filter((file) => { if (pureCssChunkNames.includes(file)) { const { importedCss, importedAssets } = ( bundle[file] as OutputChunk ).viteMetadata! - importedCss.forEach((file) => - chunk.viteMetadata!.importedCss.add(file), - ) + importedCss.forEach((file) => pureCssFromImports.push(file)) importedAssets.forEach((file) => chunk.viteMetadata!.importedAssets.add(file), ) @@ -1117,6 +1126,12 @@ export function cssPostPlugin(config: ResolvedConfig): Plugin { return true }) if (chunkImportsPureCssChunk) { + // Rebuild importedCss: pure CSS chunk CSS first (preserving + // import order), then the chunk's own CSS. + chunk.viteMetadata!.importedCss = new Set([ + ...pureCssFromImports, + ...ownImportedCss, + ]) chunk.code = replaceEmptyChunk(chunk.code) } } diff --git a/playground/css-dynamic-import/__tests__/css-dynamic-import.spec.ts b/playground/css-dynamic-import/__tests__/css-dynamic-import.spec.ts index 4b473d985f136a..0c7c6b7f63ab16 100644 --- a/playground/css-dynamic-import/__tests__/css-dynamic-import.spec.ts +++ b/playground/css-dynamic-import/__tests__/css-dynamic-import.spec.ts @@ -8,17 +8,25 @@ const baseOptions = [ { base: '/', label: 'absolute' }, ] -const getConfig = (base: string): InlineConfig => ({ +const getConfig = ( + base: string, + extra?: Partial, +): InlineConfig => ({ base, root: rootDir, logLevel: 'silent', server: { port: ports['css/dynamic-import'] }, preview: { port: ports['css/dynamic-import'] }, - build: { assetsInlineLimit: 0 }, + build: { assetsInlineLimit: 0, ...extra?.build }, + ...extra, }) -async function withBuild(base: string, fn: () => Promise) { - const config = getConfig(base) +async function withBuild( + base: string, + fn: () => Promise, + extra?: Partial, +) { + const config = getConfig(base, extra) await build(config) const server = await preview(config) @@ -91,3 +99,42 @@ baseOptions.forEach(({ base, label }) => { }, ) }) + +// Test: CSS from pure CSS chunks (via manualChunks) should preserve +// the original import order. When base.css is in a separate pure CSS +// chunk that was imported BEFORE page.css, base.css should appear +// before page.css in the cascade. This means page.css (green) should +// override base.css (red). (#3924, #6375) +test.runIf(isBuild)( + 'css order is preserved when pure css chunks are created via manualChunks', + async () => { + const path = await import('node:path') + await withBuild( + '/', + async () => { + await page.waitForSelector('.async-order-el', { state: 'attached' }) + // page.css (green) should override base.css (red) because + // page.js imports base.js first (which brings in base.css), + // then imports page.css — so page.css should come later in cascade + expect(await getColor('.async-order-el')).toBe('green') + }, + { + build: { + assetsInlineLimit: 0, + rollupOptions: { + output: { + // Split base.css into its own chunk, creating a pure CSS chunk. + // This reproduces the bug where CSS from pure CSS chunks ends up + // after the importing chunk's own CSS in the cascade. + manualChunks(id) { + if (id.includes('async-order/base.css')) { + return 'async-base' + } + }, + }, + }, + }, + }, + ) + }, +) diff --git a/playground/css-dynamic-import/async-order/base.css b/playground/css-dynamic-import/async-order/base.css new file mode 100644 index 00000000000000..be66de3bd29a51 --- /dev/null +++ b/playground/css-dynamic-import/async-order/base.css @@ -0,0 +1,3 @@ +.async-order-el { + color: red; +} diff --git a/playground/css-dynamic-import/async-order/base.js b/playground/css-dynamic-import/async-order/base.js new file mode 100644 index 00000000000000..5938fdec245b2d --- /dev/null +++ b/playground/css-dynamic-import/async-order/base.js @@ -0,0 +1,5 @@ +import './base.css' + +export function base() { + return 'base' +} diff --git a/playground/css-dynamic-import/async-order/page.css b/playground/css-dynamic-import/async-order/page.css new file mode 100644 index 00000000000000..20f3e45dd584f7 --- /dev/null +++ b/playground/css-dynamic-import/async-order/page.css @@ -0,0 +1,4 @@ +/* Same specificity as base.css - later in cascade should win */ +.async-order-el { + color: green; +} diff --git a/playground/css-dynamic-import/async-order/page.js b/playground/css-dynamic-import/async-order/page.js new file mode 100644 index 00000000000000..3bddde28efc1dd --- /dev/null +++ b/playground/css-dynamic-import/async-order/page.js @@ -0,0 +1,13 @@ +// base.css is imported as a standalone CSS import. +// When manualChunks splits it into a pure CSS chunk, +// the CSS ordering must still be preserved: +// base.css should come BEFORE page.css in the cascade. +import './base.css' +import './page.css' + +export function render() { + const el = document.createElement('div') + el.className = 'async-order-el' + el.textContent = 'async order test' + document.body.appendChild(el) +} diff --git a/playground/css-dynamic-import/index.html b/playground/css-dynamic-import/index.html index d9f9fedbbda752..50e2a0f64a12c0 100644 --- a/playground/css-dynamic-import/index.html +++ b/playground/css-dynamic-import/index.html @@ -1,3 +1,8 @@

This should be green

+ + +