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

+ + +