Skip to content

Commit

Permalink
fix(css): avoid generating empty JS files when JS files becomes empty…
Browse files Browse the repository at this point in the history
… but has CSS files imported (#16078)
  • Loading branch information
sapphi-red committed May 18, 2024
1 parent c9aa06a commit 95fe5a7
Show file tree
Hide file tree
Showing 16 changed files with 117 additions and 41 deletions.
82 changes: 42 additions & 40 deletions packages/vite/src/node/plugins/css.ts
Original file line number Diff line number Diff line change
Expand Up @@ -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) {
Expand All @@ -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.
Expand Down Expand Up @@ -723,13 +725,13 @@ export function cssPostPlugin(config: ResolvedConfig): Plugin {
}

if (chunkCSS) {
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') {
if (isPureCssChunk) {
// this is a shared CSS-only chunk that is empty.
pureCssChunks.add(chunk)
}

const isEntry = chunk.isEntry && isPureCssChunk
const cssFullAssetName = ensureFileExt(chunk.name, '.css')
// if facadeModuleId doesn't exist or doesn't have a CSS extension,
Expand Down Expand Up @@ -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
Expand Down Expand Up @@ -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,
})
}
},
}
}
Expand Down
9 changes: 8 additions & 1 deletion playground/css-codesplit/__tests__/css-codesplit.spec.ts
Original file line number Diff line number Diff line change
Expand Up @@ -3,6 +3,7 @@ import {
findAssetFile,
getColor,
isBuild,
listAssets,
page,
readManifest,
untilUpdated,
Expand All @@ -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')
})

Expand Down Expand Up @@ -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 () => {
Expand Down
3 changes: 3 additions & 0 deletions playground/css-codesplit/async-js.css
Original file line number Diff line number Diff line change
@@ -0,0 +1,3 @@
.async-js {
color: blue;
}
2 changes: 2 additions & 0 deletions playground/css-codesplit/async-js.js
Original file line number Diff line number Diff line change
@@ -0,0 +1,2 @@
// a JS file that becomes an empty file but imports CSS files
import './async-js.css'
1 change: 1 addition & 0 deletions playground/css-codesplit/index.html
Original file line number Diff line number Diff line change
Expand Up @@ -2,6 +2,7 @@ <h1>This should be red</h1>
<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>
Expand Down
1 change: 1 addition & 0 deletions playground/css-codesplit/main.js
Original file line number Diff line number Diff line change
Expand Up @@ -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
Expand Down
17 changes: 17 additions & 0 deletions playground/css-no-codesplit/__tests__/css-no-codesplit.spec.ts
Original file line number Diff line number Diff line change
@@ -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$/))
})
})
3 changes: 3 additions & 0 deletions playground/css-no-codesplit/async-js.css
Original file line number Diff line number Diff line change
@@ -0,0 +1,3 @@
.async-js {
color: blue;
}
2 changes: 2 additions & 0 deletions playground/css-no-codesplit/async-js.js
Original file line number Diff line number Diff line change
@@ -0,0 +1,2 @@
// a JS file that becomes an empty file but imports CSS files
import './async-js.css'
5 changes: 5 additions & 0 deletions playground/css-no-codesplit/index.html
Original file line number Diff line number Diff line change
@@ -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>
1 change: 1 addition & 0 deletions playground/css-no-codesplit/index.js
Original file line number Diff line number Diff line change
@@ -0,0 +1 @@
import('./async-js')
12 changes: 12 additions & 0 deletions playground/css-no-codesplit/package.json
Original file line number Diff line number Diff line change
@@ -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"
}
}
3 changes: 3 additions & 0 deletions playground/css-no-codesplit/shared-linked.css
Original file line number Diff line number Diff line change
@@ -0,0 +1,3 @@
.shared-linked {
color: blue;
}
1 change: 1 addition & 0 deletions playground/css-no-codesplit/sub.html
Original file line number Diff line number Diff line change
@@ -0,0 +1 @@
<link rel="stylesheet" href="./shared-linked.css" />
14 changes: 14 additions & 0 deletions playground/css-no-codesplit/vite.config.js
Original file line number Diff line number Diff line change
@@ -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'),
},
},
},
})
2 changes: 2 additions & 0 deletions pnpm-lock.yaml

Some generated files are not rendered by default. Learn more about how customized files appear on GitHub.

0 comments on commit 95fe5a7

Please sign in to comment.