fix(css): avoid generating empty JS files when JS files becomes empty but has CSS files imported (#16078)

This commit is contained in:
翠 / green 2024-05-18 16:22:00 +09:00 committed by GitHub
parent c9aa06a0f1
commit 95fe5a79c4
No known key found for this signature in database
GPG Key ID: B5690EEEBB952194
16 changed files with 117 additions and 41 deletions

View File

@ -549,6 +549,8 @@ export function cssPostPlugin(config: ResolvedConfig): Plugin {
async renderChunk(code, chunk, opts) {
let chunkCSS = ''
// the chunk is empty if it's a dynamic entry chunk that only contains a CSS import
const isJsChunkEmpty = code === '' && !chunk.isEntry
let isPureCssChunk = true
const ids = Object.keys(chunk.modules)
for (const id of ids) {
@ -561,7 +563,7 @@ export function cssPostPlugin(config: ResolvedConfig): Plugin {
isPureCssChunk = false
}
}
} else {
} else if (!isJsChunkEmpty) {
// if the module does not have a style, then it's not a pure css chunk.
// this is true because in the `transform` hook above, only modules
// that are css gets added to the `styles` map.
@ -723,13 +725,13 @@ export function cssPostPlugin(config: ResolvedConfig): Plugin {
}
if (chunkCSS) {
if (config.build.cssCodeSplit) {
if (opts.format === 'es' || opts.format === 'cjs') {
if (isPureCssChunk) {
if (isPureCssChunk && (opts.format === 'es' || opts.format === 'cjs')) {
// this is a shared CSS-only chunk that is empty.
pureCssChunks.add(chunk)
}
if (config.build.cssCodeSplit) {
if (opts.format === 'es' || opts.format === 'cjs') {
const isEntry = chunk.isEntry && isPureCssChunk
const cssFullAssetName = ensureFileExt(chunk.name, '.css')
// if facadeModuleId doesn't exist or doesn't have a CSS extension,
@ -837,6 +839,40 @@ export function cssPostPlugin(config: ResolvedConfig): Plugin {
return
}
function extractCss() {
let css = ''
const collected = new Set<OutputChunk>()
const prelimaryNameToChunkMap = new Map(
Object.values(bundle)
.filter((chunk): chunk is OutputChunk => chunk.type === 'chunk')
.map((chunk) => [chunk.preliminaryFileName, chunk]),
)
function collect(fileName: string) {
const chunk = bundle[fileName]
if (!chunk || chunk.type !== 'chunk' || collected.has(chunk)) return
collected.add(chunk)
chunk.imports.forEach(collect)
css += chunkCSSMap.get(chunk.preliminaryFileName) ?? ''
}
for (const chunkName of chunkCSSMap.keys())
collect(prelimaryNameToChunkMap.get(chunkName)?.fileName ?? '')
return css
}
let extractedCss = !hasEmitted && extractCss()
if (extractedCss) {
hasEmitted = true
extractedCss = await finalizeCss(extractedCss, true, config)
this.emitFile({
name: cssBundleName,
type: 'asset',
source: extractedCss,
})
}
// remove empty css chunks and their imports
if (pureCssChunks.size) {
// map each pure css chunk (rendered chunk) to it's corresponding bundle
@ -893,40 +929,6 @@ export function cssPostPlugin(config: ResolvedConfig): Plugin {
delete bundle[`${fileName}.map`]
})
}
function extractCss() {
let css = ''
const collected = new Set<OutputChunk>()
const prelimaryNameToChunkMap = new Map(
Object.values(bundle)
.filter((chunk): chunk is OutputChunk => chunk.type === 'chunk')
.map((chunk) => [chunk.preliminaryFileName, chunk]),
)
function collect(fileName: string) {
const chunk = bundle[fileName]
if (!chunk || chunk.type !== 'chunk' || collected.has(chunk)) return
collected.add(chunk)
chunk.imports.forEach(collect)
css += chunkCSSMap.get(chunk.preliminaryFileName) ?? ''
}
for (const chunkName of chunkCSSMap.keys())
collect(prelimaryNameToChunkMap.get(chunkName)?.fileName ?? '')
return css
}
let extractedCss = !hasEmitted && extractCss()
if (extractedCss) {
hasEmitted = true
extractedCss = await finalizeCss(extractedCss, true, config)
this.emitFile({
name: cssBundleName,
type: 'asset',
source: extractedCss,
})
}
},
}
}

View File

@ -3,6 +3,7 @@ import {
findAssetFile,
getColor,
isBuild,
listAssets,
page,
readManifest,
untilUpdated,
@ -12,6 +13,7 @@ test('should load all stylesheets', async () => {
expect(await getColor('h1')).toBe('red')
expect(await getColor('h2')).toBe('blue')
expect(await getColor('.dynamic')).toBe('green')
expect(await getColor('.async-js')).toBe('blue')
expect(await getColor('.chunk')).toBe('magenta')
})
@ -40,7 +42,12 @@ describe.runIf(isBuild)('build', () => {
expect(findAssetFile(/style-.*\.js$/)).toBe('')
expect(findAssetFile('main.*.js$')).toMatch(`/* empty css`)
expect(findAssetFile('other.*.js$')).toMatch(`/* empty css`)
expect(findAssetFile(/async.*\.js$/)).toBe('')
expect(findAssetFile(/async-[-\w]{8}\.js$/)).toBe('')
const assets = listAssets()
expect(assets).not.toContainEqual(
expect.stringMatching(/async-js-[-\w]{8}\.js$/),
)
})
test('should remove empty chunk, HTML without JS', async () => {

View File

@ -0,0 +1,3 @@
.async-js {
color: blue;
}

View File

@ -0,0 +1,2 @@
// a JS file that becomes an empty file but imports CSS files
import './async-js.css'

View File

@ -2,6 +2,7 @@
<h2>This should be blue</h2>
<p class="dynamic">This should be green</p>
<p class="async-js">This should be blue</p>
<p class="inline">This should not be yellow</p>
<p class="dynamic-inline"></p>
<p class="mod">This should be yellow</p>

View File

@ -9,6 +9,7 @@ import chunkCssUrl from './chunk.css?url'
globalThis.__test_chunkCssUrl = chunkCssUrl
import('./async.css')
import('./async-js')
import('./inline.css?inline').then((css) => {
document.querySelector('.dynamic-inline').textContent = css.default

View File

@ -0,0 +1,17 @@
import { describe, expect, test } from 'vitest'
import { expectWithRetry, getColor, isBuild, listAssets } from '~utils'
test('should load all stylesheets', async () => {
expect(await getColor('.shared-linked')).toBe('blue')
await expectWithRetry(() => getColor('.async-js')).toBe('blue')
})
describe.runIf(isBuild)('build', () => {
test('should remove empty chunk', async () => {
const assets = listAssets()
expect(assets).not.toContainEqual(
expect.stringMatching(/shared-linked-.*\.js$/),
)
expect(assets).not.toContainEqual(expect.stringMatching(/async-js-.*\.js$/))
})
})

View File

@ -0,0 +1,3 @@
.async-js {
color: blue;
}

View File

@ -0,0 +1,2 @@
// a JS file that becomes an empty file but imports CSS files
import './async-js.css'

View File

@ -0,0 +1,5 @@
<link rel="stylesheet" href="./shared-linked.css" />
<script type="module" src="./index.js"></script>
<p class="shared-linked">shared linked: this should be blue</p>
<p class="async-js">async JS importing CSS: this should be blue</p>

View File

@ -0,0 +1 @@
import('./async-js')

View File

@ -0,0 +1,12 @@
{
"name": "@vitejs/test-css-no-codesplit",
"private": true,
"version": "0.0.0",
"type": "module",
"scripts": {
"dev": "vite",
"build": "vite build",
"debug": "node --inspect-brk ../../packages/vite/bin/vite",
"preview": "vite preview"
}
}

View File

@ -0,0 +1,3 @@
.shared-linked {
color: blue;
}

View File

@ -0,0 +1 @@
<link rel="stylesheet" href="./shared-linked.css" />

View File

@ -0,0 +1,14 @@
import { resolve } from 'node:path'
import { defineConfig } from 'vite'
export default defineConfig({
build: {
cssCodeSplit: false,
rollupOptions: {
input: {
index: resolve(__dirname, './index.html'),
sub: resolve(__dirname, './sub.html'),
},
},
},
})

View File

@ -613,6 +613,8 @@ importers:
specifier: ^1.24.1
version: 1.24.1
playground/css-no-codesplit: {}
playground/css-sourcemap:
devDependencies:
less: