Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
36 changes: 36 additions & 0 deletions packages/vite/src/node/plugins/css.ts
Original file line number Diff line number Diff line change
Expand Up @@ -480,6 +480,7 @@ export function cssPostPlugin(config: ResolvedConfig): Plugin {
let codeSplitEmitQueue = createSerialPromiseQueue<string>()
const urlEmitQueue = createSerialPromiseQueue<unknown>()
let pureCssChunks: Set<RenderedChunk>
let chunkCssReferences: Map<string, string>

// when there are multiple rollup outputs and extracting CSS, only emit once,
// since output formats have no effect on the generated CSS.
Expand Down Expand Up @@ -536,6 +537,7 @@ export function cssPostPlugin(config: ResolvedConfig): Plugin {
renderStart() {
// Ensure new caches for every build (i.e. rebuilding in watch mode)
pureCssChunks = new Set<RenderedChunk>()
chunkCssReferences = new Map<string, string>()
hasEmitted = false
chunkCSSMap = new Map()
codeSplitEmitQueue = createSerialPromiseQueue()
Expand Down Expand Up @@ -930,6 +932,7 @@ export function cssPostPlugin(config: ResolvedConfig): Plugin {
originalFileName,
source: chunkCSS,
})
chunkCssReferences.set(chunk.fileName, referenceId)
if (isEntry) {
cssEntriesMap
.get(this.environment)!
Expand Down Expand Up @@ -1067,6 +1070,39 @@ export function cssPostPlugin(config: ResolvedConfig): Plugin {
}
}

// With `cssCodeSplit: false`, CSS is emitted as a single stylesheet in the HTML,
// so this per-chunk import map handling is irrelevant.
if (config.build.chunkImportMap && chunkCssReferences.size) {
// The import map hash identifies the JS chunk independently of its content.
// Since each chunk has at most one extracted CSS sidecar, we can reuse that
// stable identity with a `.css` extension while mapping it to the CSS content hash.
const importMap = getImportMap(bundle, config)!
const importMapReverseMapping = Object.fromEntries(
Object.entries(importMap.mapping).map(([k, v]) => [v, k]),
)
const chunksByPreliminaryFileName = new Map(
Object.values(bundle)
.filter((output): output is OutputChunk => output.type === 'chunk')
.map((chunk) => [chunk.preliminaryFileName, chunk]),
)

for (const [chunkFileName, referenceId] of chunkCssReferences) {
const chunk = chunksByPreliminaryFileName.get(chunkFileName)
if (!chunk) continue

const stableChunkFileName =
importMapReverseMapping[chunk.fileName] ?? chunk.fileName
const extension = path.posix.extname(stableChunkFileName)
const stableCssFileName = `${stableChunkFileName.slice(
0,
extension ? -extension.length : undefined,
)}.css`
importMap.content.imports[config.base + stableCssFileName] =
config.base + this.getFileName(referenceId)
}
importMap.asset.source = JSON.stringify(importMap.content)
}

// remove empty css chunks and their imports
if (pureCssChunks.size) {
// map each pure css chunk (rendered chunk) to it's corresponding bundle
Expand Down
3 changes: 2 additions & 1 deletion packages/vite/src/node/plugins/html.ts
Original file line number Diff line number Diff line change
Expand Up @@ -1680,6 +1680,7 @@ export function getImportMap(
):
| {
asset: OutputAsset
content: { imports: Record<string, string> }
/** import map entries with the base stripped (placeholder name -> real name) */
mapping: Record<string, string>
}
Expand All @@ -1698,5 +1699,5 @@ export function getImportMap(
v.slice(config.base.length),
]),
)
return { asset, mapping }
return { asset, content, mapping }
}
80 changes: 79 additions & 1 deletion playground/chunk-importmap/__tests__/chunk-importmap.spec.ts
Original file line number Diff line number Diff line change
@@ -1,5 +1,7 @@
import { expect, test } from 'vitest'
import { browserLogs, getColor, page } from '~utils'
import type { OutputAsset, OutputChunk, RolldownOutput } from 'rolldown'
import { build } from 'vite'
import { browserLogs, getColor, isBuild, page, testDir } from '~utils'

test('should have no 404s', () => {
browserLogs.forEach((msg) => {
Expand Down Expand Up @@ -35,6 +37,10 @@ test('dynamic css', async () => {
await expect.poll(() => getColor('.dynamic')).toBe('red')
})

test('direct dynamic css', async () => {
await expect.poll(() => getColor('.direct-dynamic')).toBe('red')
})

// a CSS-only module shared by multiple chunks becomes a pure CSS chunk that is
// removed from the output. The import map must not keep referencing its removed
// JS file, otherwise chunks importing it 404 and fail to execute
Expand All @@ -47,3 +53,75 @@ test('shared pure css chunk', async () => {
test('worker', async () => {
await expect.poll(() => page.textContent('.worker')).toBe('worker: pong')
})

for (const cssFileName of ['dynamic.css', 'direct-dynamic.css']) {
// This is a correctness requirement, not only a cache optimization: hashed
// chunk filenames are cached as immutable. If the filename stayed the same
// while its code changed to reference a new CSS filename, cached JS could
// load stale CSS alongside newly mapped JS, breaking CSS module selectors.
test.runIf(isBuild)(
`${cssFileName} uses a stable import map specifier`,
async () => {
const buildWithColor = async (color: string) =>
(await build({
root: testDir,
logLevel: 'silent',
build: { write: false },
plugins: [
{
name: 'change-dynamic-css',
enforce: 'pre',
transform(code, id) {
if (id.endsWith(`/${cssFileName}`)) {
return code.replace('red', color)
}
},
},
],
})) as RolldownOutput

const getIndexChunk = (output: RolldownOutput) =>
output.output.find(
(file): file is OutputChunk => file.type === 'chunk' && file.isEntry,
)!
const getImportMap = (output: RolldownOutput) =>
JSON.parse(
output.output
.find(
(file): file is OutputAsset => file.fileName === 'importmap.json',
)!
.source.toString(),
).imports as Record<string, string>
const getCssFileName = (output: RolldownOutput) =>
output.output.find(
(file): file is OutputAsset =>
file.type === 'asset' && file.names.includes(cssFileName),
)!.fileName

const redBuild = await buildWithColor('red')
const blueBuild = await buildWithColor('blue')
const redIndex = getIndexChunk(redBuild)
const blueIndex = getIndexChunk(blueBuild)

// The importer chunk does not change
// because the CSS reference uses the hash in the import map
expect(redIndex.fileName).toBe(blueIndex.fileName)
expect(redIndex.code).toBe(blueIndex.code)

const redImportMap = getImportMap(redBuild)
const blueImportMap = getImportMap(blueBuild)
const cssName = cssFileName.slice(0, -'.css'.length)
const cssSpecifier = Object.keys(redImportMap).find(
(specifier) =>
specifier.includes(`/${cssName}-`) && specifier.endsWith('.css'),
)!

// The hash in the import map specifier is stable across builds,
// but the mapped filename changes properly
expect(cssSpecifier).toBeDefined()
expect(redImportMap[cssSpecifier]).toBe(`/${getCssFileName(redBuild)}`)
expect(blueImportMap[cssSpecifier]).toBe(`/${getCssFileName(blueBuild)}`)
expect(redImportMap[cssSpecifier]).not.toBe(blueImportMap[cssSpecifier])
},
)
}
3 changes: 3 additions & 0 deletions playground/chunk-importmap/direct-dynamic.css
Original file line number Diff line number Diff line change
@@ -0,0 +1,3 @@
.direct-dynamic {
color: red;
}
1 change: 1 addition & 0 deletions playground/chunk-importmap/index.html
Original file line number Diff line number Diff line change
Expand Up @@ -12,6 +12,7 @@

<p class="static">static</p>
<p class="dynamic">dynamic</p>
<p class="direct-dynamic">direct dynamic</p>
<p class="shared">shared</p>

<p class="static-js">static-js: error</p>
Expand Down
1 change: 1 addition & 0 deletions playground/chunk-importmap/index.js
Original file line number Diff line number Diff line change
@@ -1,5 +1,6 @@
import './static.js'
import('./dynamic.js')
import('./direct-dynamic.css')
import('./dynamic2.js')

import myWorker from './worker.js?worker'
Expand Down
Loading