Skip to content

Commit 648bd04

Browse files
authored
fix: strip pure CSS chunk imports when chunkImportMap is enabled (#22841)
1 parent b5868c0 commit 648bd04

10 files changed

Lines changed: 74 additions & 19 deletions

File tree

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

Lines changed: 15 additions & 2 deletions
Original file line numberDiff line numberDiff line change
@@ -94,7 +94,7 @@ import { type DevEnvironment } from '..'
9494
import type { PackageCache } from '../packages'
9595
import { findNearestMainPackageData } from '../packages'
9696
import { nodeResolveWithVite } from '../nodeResolve'
97-
import { addToHTMLProxyTransformResult } from './html'
97+
import { addToHTMLProxyTransformResult, getImportMap } from './html'
9898
import {
9999
assetUrlRE,
100100
cssEntriesMap,
@@ -1072,8 +1072,21 @@ export function cssPostPlugin(config: ResolvedConfig): Plugin {
10721072
.map((pureCssChunk) => prelimaryNameToChunkMap[pureCssChunk.fileName])
10731073
.filter(Boolean)
10741074

1075+
let importMapReverseMapping: Record<string, string> | undefined
1076+
if (config.build.chunkImportMap) {
1077+
const importMap = getImportMap(bundle, config)!
1078+
importMapReverseMapping = Object.fromEntries(
1079+
Object.entries(importMap.mapping).map(([k, v]) => [v, k]),
1080+
)
1081+
}
1082+
const pureCssChunkNamesInCode = importMapReverseMapping
1083+
? pureCssChunkNames.map(
1084+
(name) => importMapReverseMapping![name] ?? name,
1085+
)
1086+
: pureCssChunkNames
1087+
10751088
const replaceEmptyChunk = getEmptyChunkReplacer(
1076-
pureCssChunkNames,
1089+
pureCssChunkNamesInCode,
10771090
opts.format,
10781091
)
10791092

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

Lines changed: 31 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -1669,3 +1669,34 @@ export function getImportMapFilename(config: ResolvedConfig): string {
16691669
}
16701670
return 'importmap.json'
16711671
}
1672+
1673+
/**
1674+
* Read and parse the chunk import map asset from the bundle.
1675+
* Returns `undefined` when the import map is not present in the bundle.
1676+
*/
1677+
export function getImportMap(
1678+
bundle: OutputBundle,
1679+
config: ResolvedConfig,
1680+
):
1681+
| {
1682+
asset: OutputAsset
1683+
/** import map entries with the base stripped (placeholder name -> real name) */
1684+
mapping: Record<string, string>
1685+
}
1686+
| undefined {
1687+
const asset = bundle[getImportMapFilename(config)] as OutputAsset | undefined
1688+
if (!asset) return undefined
1689+
1690+
const content: { imports: Record<string, string> } = JSON.parse(
1691+
typeof asset.source === 'string'
1692+
? asset.source
1693+
: new TextDecoder().decode(asset.source),
1694+
)
1695+
const mapping = Object.fromEntries(
1696+
Object.entries(content.imports).map(([k, v]) => [
1697+
k.slice(config.base.length),
1698+
v.slice(config.base.length),
1699+
]),
1700+
)
1701+
return { asset, mapping }
1702+
}

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

Lines changed: 5 additions & 17 deletions
Original file line numberDiff line numberDiff line change
@@ -2,7 +2,7 @@ import path from 'node:path'
22
import MagicString from 'magic-string'
33
import type { ImportSpecifier } from 'es-module-lexer'
44
import { init, parse as parseImports } from 'es-module-lexer'
5-
import type { OutputAsset, SourceMap } from 'rolldown'
5+
import type { SourceMap } from 'rolldown'
66
import { viteBuildImportAnalysisPlugin as nativeBuildImportAnalysisPlugin } from 'rolldown/experimental'
77
import type { RawSourceMap } from '@jridgewell/remapping'
88
import convertSourceMap from 'convert-source-map'
@@ -13,7 +13,7 @@ import { toOutputFilePathInJS } from '../build'
1313
import { genSourceMapUrl } from '../server/sourcemap'
1414
import type { PartialEnvironment } from '../baseEnvironment'
1515
import { removedPureCssFilesCache } from './css'
16-
import { getImportMapFilename } from './html'
16+
import { getImportMap, getImportMapFilename } from './html'
1717

1818
type FileDep = {
1919
url: string
@@ -351,20 +351,8 @@ export function buildImportAnalysisPlugin(config: ResolvedConfig): Plugin[] {
351351
let importMapMapping: Record<string, string> | undefined
352352
let importMapReverseMapping: Record<string, string> | undefined
353353
if (config.build.chunkImportMap) {
354-
const decoder = new TextDecoder()
355-
const importMap = bundle[getImportMapFilename(config)]! as OutputAsset
356-
const importMapContent: { imports: Record<string, string> } =
357-
JSON.parse(
358-
typeof importMap.source === 'string'
359-
? importMap.source
360-
: decoder.decode(importMap.source),
361-
)
362-
importMapMapping = Object.fromEntries(
363-
Object.entries(importMapContent.imports).map(([k, v]) => [
364-
k.slice(config.base.length),
365-
v.slice(config.base.length),
366-
]),
367-
)
354+
const importMap = getImportMap(bundle, config)!
355+
importMapMapping = importMap.mapping
368356
importMapReverseMapping = Object.fromEntries(
369357
Object.entries(importMapMapping).map(([k, v]) => [v, k]),
370358
)
@@ -373,7 +361,7 @@ export function buildImportAnalysisPlugin(config: ResolvedConfig): Plugin[] {
373361
this.emitFile({
374362
type: 'asset',
375363
fileName: 'importmap.legacy.json',
376-
source: importMap.source,
364+
source: importMap.asset.source,
377365
})
378366
delete bundle[getImportMapFilename(config)]
379367
}

playground/chunk-importmap/__tests__/chunk-importmap.spec.ts

Lines changed: 9 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -35,6 +35,15 @@ test('dynamic css', async () => {
3535
await expect.poll(() => getColor('.dynamic')).toBe('red')
3636
})
3737

38+
// a CSS-only module shared by multiple chunks becomes a pure CSS chunk that is
39+
// removed from the output. The import map must not keep referencing its removed
40+
// JS file, otherwise chunks importing it 404 and fail to execute
41+
// (https://github.com/vitejs/vite/issues/22740)
42+
test('shared pure css chunk', async () => {
43+
await expect.poll(() => page.textContent('.shared-js')).toBe('shared-js: ok')
44+
await expect.poll(() => getColor('.shared')).toBe('green')
45+
})
46+
3847
test('worker', async () => {
3948
await expect.poll(() => page.textContent('.worker')).toBe('worker: pong')
4049
})
Lines changed: 1 addition & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -1,3 +1,4 @@
11
import './dynamic.css'
2+
import './shared-dep.js'
23

34
document.querySelector('.dynamic-js').textContent = 'dynamic-js: ok'
Lines changed: 3 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,3 @@
1+
import './shared-dep.js'
2+
3+
document.querySelector('.shared-js').textContent = 'shared-js: ok'

playground/chunk-importmap/index.html

Lines changed: 2 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -12,9 +12,11 @@
1212

1313
<p class="static">static</p>
1414
<p class="dynamic">dynamic</p>
15+
<p class="shared">shared</p>
1516

1617
<p class="static-js">static-js: error</p>
1718
<p class="dynamic-js">dynamic-js: error</p>
19+
<p class="shared-js">shared-js: error</p>
1820

1921
<p class="worker">worker: ping</p>
2022
</body>

playground/chunk-importmap/index.js

Lines changed: 1 addition & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -1,5 +1,6 @@
11
import './static.js'
22
import('./dynamic.js')
3+
import('./dynamic2.js')
34

45
import myWorker from './worker.js?worker'
56

Lines changed: 4 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,4 @@
1+
// CSS-only module (no JS exports) shared by multiple dynamic chunks, so it is
2+
// hoisted into a shared pure CSS chunk that those chunks import statically
3+
// (https://github.com/vitejs/vite/issues/22740)
4+
import './shared-styles.css'
Lines changed: 3 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,3 @@
1+
.shared {
2+
color: green;
3+
}

0 commit comments

Comments
 (0)