Skip to content

Commit 95fe5a7

Browse files
authored
fix(css): avoid generating empty JS files when JS files becomes empty but has CSS files imported (#16078)
1 parent c9aa06a commit 95fe5a7

File tree

16 files changed

+117
-41
lines changed

16 files changed

+117
-41
lines changed

packages/vite/src/node/plugins/css.ts

Lines changed: 42 additions & 40 deletions
Original file line numberDiff line numberDiff line change
@@ -549,6 +549,8 @@ export function cssPostPlugin(config: ResolvedConfig): Plugin {
549549

550550
async renderChunk(code, chunk, opts) {
551551
let chunkCSS = ''
552+
// the chunk is empty if it's a dynamic entry chunk that only contains a CSS import
553+
const isJsChunkEmpty = code === '' && !chunk.isEntry
552554
let isPureCssChunk = true
553555
const ids = Object.keys(chunk.modules)
554556
for (const id of ids) {
@@ -561,7 +563,7 @@ export function cssPostPlugin(config: ResolvedConfig): Plugin {
561563
isPureCssChunk = false
562564
}
563565
}
564-
} else {
566+
} else if (!isJsChunkEmpty) {
565567
// if the module does not have a style, then it's not a pure css chunk.
566568
// this is true because in the `transform` hook above, only modules
567569
// that are css gets added to the `styles` map.
@@ -723,13 +725,13 @@ export function cssPostPlugin(config: ResolvedConfig): Plugin {
723725
}
724726

725727
if (chunkCSS) {
728+
if (isPureCssChunk && (opts.format === 'es' || opts.format === 'cjs')) {
729+
// this is a shared CSS-only chunk that is empty.
730+
pureCssChunks.add(chunk)
731+
}
732+
726733
if (config.build.cssCodeSplit) {
727734
if (opts.format === 'es' || opts.format === 'cjs') {
728-
if (isPureCssChunk) {
729-
// this is a shared CSS-only chunk that is empty.
730-
pureCssChunks.add(chunk)
731-
}
732-
733735
const isEntry = chunk.isEntry && isPureCssChunk
734736
const cssFullAssetName = ensureFileExt(chunk.name, '.css')
735737
// if facadeModuleId doesn't exist or doesn't have a CSS extension,
@@ -837,6 +839,40 @@ export function cssPostPlugin(config: ResolvedConfig): Plugin {
837839
return
838840
}
839841

842+
function extractCss() {
843+
let css = ''
844+
const collected = new Set<OutputChunk>()
845+
const prelimaryNameToChunkMap = new Map(
846+
Object.values(bundle)
847+
.filter((chunk): chunk is OutputChunk => chunk.type === 'chunk')
848+
.map((chunk) => [chunk.preliminaryFileName, chunk]),
849+
)
850+
851+
function collect(fileName: string) {
852+
const chunk = bundle[fileName]
853+
if (!chunk || chunk.type !== 'chunk' || collected.has(chunk)) return
854+
collected.add(chunk)
855+
856+
chunk.imports.forEach(collect)
857+
css += chunkCSSMap.get(chunk.preliminaryFileName) ?? ''
858+
}
859+
860+
for (const chunkName of chunkCSSMap.keys())
861+
collect(prelimaryNameToChunkMap.get(chunkName)?.fileName ?? '')
862+
863+
return css
864+
}
865+
let extractedCss = !hasEmitted && extractCss()
866+
if (extractedCss) {
867+
hasEmitted = true
868+
extractedCss = await finalizeCss(extractedCss, true, config)
869+
this.emitFile({
870+
name: cssBundleName,
871+
type: 'asset',
872+
source: extractedCss,
873+
})
874+
}
875+
840876
// remove empty css chunks and their imports
841877
if (pureCssChunks.size) {
842878
// map each pure css chunk (rendered chunk) to it's corresponding bundle
@@ -893,40 +929,6 @@ export function cssPostPlugin(config: ResolvedConfig): Plugin {
893929
delete bundle[`${fileName}.map`]
894930
})
895931
}
896-
897-
function extractCss() {
898-
let css = ''
899-
const collected = new Set<OutputChunk>()
900-
const prelimaryNameToChunkMap = new Map(
901-
Object.values(bundle)
902-
.filter((chunk): chunk is OutputChunk => chunk.type === 'chunk')
903-
.map((chunk) => [chunk.preliminaryFileName, chunk]),
904-
)
905-
906-
function collect(fileName: string) {
907-
const chunk = bundle[fileName]
908-
if (!chunk || chunk.type !== 'chunk' || collected.has(chunk)) return
909-
collected.add(chunk)
910-
911-
chunk.imports.forEach(collect)
912-
css += chunkCSSMap.get(chunk.preliminaryFileName) ?? ''
913-
}
914-
915-
for (const chunkName of chunkCSSMap.keys())
916-
collect(prelimaryNameToChunkMap.get(chunkName)?.fileName ?? '')
917-
918-
return css
919-
}
920-
let extractedCss = !hasEmitted && extractCss()
921-
if (extractedCss) {
922-
hasEmitted = true
923-
extractedCss = await finalizeCss(extractedCss, true, config)
924-
this.emitFile({
925-
name: cssBundleName,
926-
type: 'asset',
927-
source: extractedCss,
928-
})
929-
}
930932
},
931933
}
932934
}

playground/css-codesplit/__tests__/css-codesplit.spec.ts

Lines changed: 8 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -3,6 +3,7 @@ import {
33
findAssetFile,
44
getColor,
55
isBuild,
6+
listAssets,
67
page,
78
readManifest,
89
untilUpdated,
@@ -12,6 +13,7 @@ test('should load all stylesheets', async () => {
1213
expect(await getColor('h1')).toBe('red')
1314
expect(await getColor('h2')).toBe('blue')
1415
expect(await getColor('.dynamic')).toBe('green')
16+
expect(await getColor('.async-js')).toBe('blue')
1517
expect(await getColor('.chunk')).toBe('magenta')
1618
})
1719

@@ -40,7 +42,12 @@ describe.runIf(isBuild)('build', () => {
4042
expect(findAssetFile(/style-.*\.js$/)).toBe('')
4143
expect(findAssetFile('main.*.js$')).toMatch(`/* empty css`)
4244
expect(findAssetFile('other.*.js$')).toMatch(`/* empty css`)
43-
expect(findAssetFile(/async.*\.js$/)).toBe('')
45+
expect(findAssetFile(/async-[-\w]{8}\.js$/)).toBe('')
46+
47+
const assets = listAssets()
48+
expect(assets).not.toContainEqual(
49+
expect.stringMatching(/async-js-[-\w]{8}\.js$/),
50+
)
4451
})
4552

4653
test('should remove empty chunk, HTML without JS', async () => {

playground/css-codesplit/async-js.css

Lines changed: 3 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,3 @@
1+
.async-js {
2+
color: blue;
3+
}

playground/css-codesplit/async-js.js

Lines changed: 2 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,2 @@
1+
// a JS file that becomes an empty file but imports CSS files
2+
import './async-js.css'

playground/css-codesplit/index.html

Lines changed: 1 addition & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -2,6 +2,7 @@ <h1>This should be red</h1>
22
<h2>This should be blue</h2>
33

44
<p class="dynamic">This should be green</p>
5+
<p class="async-js">This should be blue</p>
56
<p class="inline">This should not be yellow</p>
67
<p class="dynamic-inline"></p>
78
<p class="mod">This should be yellow</p>

playground/css-codesplit/main.js

Lines changed: 1 addition & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -9,6 +9,7 @@ import chunkCssUrl from './chunk.css?url'
99
globalThis.__test_chunkCssUrl = chunkCssUrl
1010

1111
import('./async.css')
12+
import('./async-js')
1213

1314
import('./inline.css?inline').then((css) => {
1415
document.querySelector('.dynamic-inline').textContent = css.default
Lines changed: 17 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,17 @@
1+
import { describe, expect, test } from 'vitest'
2+
import { expectWithRetry, getColor, isBuild, listAssets } from '~utils'
3+
4+
test('should load all stylesheets', async () => {
5+
expect(await getColor('.shared-linked')).toBe('blue')
6+
await expectWithRetry(() => getColor('.async-js')).toBe('blue')
7+
})
8+
9+
describe.runIf(isBuild)('build', () => {
10+
test('should remove empty chunk', async () => {
11+
const assets = listAssets()
12+
expect(assets).not.toContainEqual(
13+
expect.stringMatching(/shared-linked-.*\.js$/),
14+
)
15+
expect(assets).not.toContainEqual(expect.stringMatching(/async-js-.*\.js$/))
16+
})
17+
})
Lines changed: 3 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,3 @@
1+
.async-js {
2+
color: blue;
3+
}
Lines changed: 2 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,2 @@
1+
// a JS file that becomes an empty file but imports CSS files
2+
import './async-js.css'
Lines changed: 5 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,5 @@
1+
<link rel="stylesheet" href="./shared-linked.css" />
2+
<script type="module" src="./index.js"></script>
3+
4+
<p class="shared-linked">shared linked: this should be blue</p>
5+
<p class="async-js">async JS importing CSS: this should be blue</p>

playground/css-no-codesplit/index.js

Lines changed: 1 addition & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1 @@
1+
import('./async-js')
Lines changed: 12 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,12 @@
1+
{
2+
"name": "@vitejs/test-css-no-codesplit",
3+
"private": true,
4+
"version": "0.0.0",
5+
"type": "module",
6+
"scripts": {
7+
"dev": "vite",
8+
"build": "vite build",
9+
"debug": "node --inspect-brk ../../packages/vite/bin/vite",
10+
"preview": "vite preview"
11+
}
12+
}
Lines changed: 3 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,3 @@
1+
.shared-linked {
2+
color: blue;
3+
}

playground/css-no-codesplit/sub.html

Lines changed: 1 addition & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1 @@
1+
<link rel="stylesheet" href="./shared-linked.css" />
Lines changed: 14 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,14 @@
1+
import { resolve } from 'node:path'
2+
import { defineConfig } from 'vite'
3+
4+
export default defineConfig({
5+
build: {
6+
cssCodeSplit: false,
7+
rollupOptions: {
8+
input: {
9+
index: resolve(__dirname, './index.html'),
10+
sub: resolve(__dirname, './sub.html'),
11+
},
12+
},
13+
},
14+
})

pnpm-lock.yaml

Lines changed: 2 additions & 0 deletions
Some generated files are not rendered by default. Learn more about customizing how changed files appear on GitHub.

0 commit comments

Comments
 (0)