fix(css): track dependencies from addWatchFile for HMR (#15608)

This commit is contained in:
Bjorn Lu 2024-01-17 03:46:32 +08:00 committed by GitHub
parent 3b7e0c3dd2
commit dfcb83d41a
No known key found for this signature in database
GPG Key ID: B5690EEEBB952194
8 changed files with 128 additions and 60 deletions

View File

@ -237,7 +237,6 @@ const cssUrlAssetRE = /__VITE_CSS_URL__([\da-f]+)__/g
*/
export function cssPlugin(config: ResolvedConfig): Plugin {
const isBuild = config.command === 'build'
let server: ViteDevServer
let moduleCache: Map<string, Record<string, string>>
const resolveUrl = config.createResolver({
@ -254,10 +253,6 @@ export function cssPlugin(config: ResolvedConfig): Plugin {
return {
name: 'vite:css',
configureServer(_server) {
server = _server
},
buildStart() {
// Ensure a new cache for every build (i.e. rebuilding in watch mode)
moduleCache = new Map<string, Record<string, string>>()
@ -292,7 +287,7 @@ export function cssPlugin(config: ResolvedConfig): Plugin {
}
},
async transform(raw, id, options) {
async transform(raw, id) {
if (
!isCSSRequest(id) ||
commonjsProxyRE.test(id) ||
@ -300,8 +295,6 @@ export function cssPlugin(config: ResolvedConfig): Plugin {
) {
return
}
const ssr = options?.ssr === true
const urlReplacer: CssUrlReplacer = async (url, importer) => {
const decodedUrl = decodeURI(url)
if (checkPublicFile(decodedUrl, config)) {
@ -345,60 +338,12 @@ export function cssPlugin(config: ResolvedConfig): Plugin {
moduleCache.set(id, modules)
}
// track deps for build watch mode
if (config.command === 'build' && config.build.watch && deps) {
if (deps) {
for (const file of deps) {
this.addWatchFile(file)
}
}
// dev
if (server) {
// server only logic for handling CSS @import dependency hmr
const { moduleGraph } = server
const thisModule = moduleGraph.getModuleById(id)
if (thisModule) {
// CSS modules cannot self-accept since it exports values
const isSelfAccepting =
!modules && !inlineRE.test(id) && !htmlProxyRE.test(id)
if (deps) {
// record deps in the module graph so edits to @import css can trigger
// main import to hot update
const depModules = new Set<string | ModuleNode>()
const devBase = config.base
for (const file of deps) {
depModules.add(
isCSSRequest(file)
? moduleGraph.createFileOnlyEntry(file)
: await moduleGraph.ensureEntryFromUrl(
stripBase(
await fileToUrl(file, config, this),
(config.server?.origin ?? '') + devBase,
),
ssr,
),
)
}
moduleGraph.updateModuleInfo(
thisModule,
depModules,
null,
// The root CSS proxy module is self-accepting and should not
// have an explicit accept list
new Set(),
null,
isSelfAccepting,
ssr,
)
for (const file of deps) {
this.addWatchFile(file)
}
} else {
thisModule.isSelfAccepting = isSelfAccepting
}
}
}
return {
code: css,
map,
@ -945,6 +890,78 @@ export function cssPostPlugin(config: ResolvedConfig): Plugin {
}
}
export function cssAnalysisPlugin(config: ResolvedConfig): Plugin {
let server: ViteDevServer
return {
name: 'vite:css-analysis',
configureServer(_server) {
server = _server
},
async transform(_, id, options) {
if (
!isCSSRequest(id) ||
commonjsProxyRE.test(id) ||
SPECIAL_QUERY_RE.test(id)
) {
return
}
const ssr = options?.ssr === true
const { moduleGraph } = server
const thisModule = moduleGraph.getModuleById(id)
// Handle CSS @import dependency HMR and other added modules via this.addWatchFile.
// JS-related HMR is handled in the import-analysis plugin.
if (thisModule) {
// CSS modules cannot self-accept since it exports values
const isSelfAccepting =
!cssModulesCache.get(config)?.get(id) &&
!inlineRE.test(id) &&
!htmlProxyRE.test(id)
// attached by pluginContainer.addWatchFile
const pluginImports = (this as any)._addedImports as
| Set<string>
| undefined
if (pluginImports) {
// record deps in the module graph so edits to @import css can trigger
// main import to hot update
const depModules = new Set<string | ModuleNode>()
const devBase = config.base
for (const file of pluginImports) {
depModules.add(
isCSSRequest(file)
? moduleGraph.createFileOnlyEntry(file)
: await moduleGraph.ensureEntryFromUrl(
stripBase(
await fileToUrl(file, config, this),
(config.server?.origin ?? '') + devBase,
),
ssr,
),
)
}
moduleGraph.updateModuleInfo(
thisModule,
depModules,
null,
// The root CSS proxy module is self-accepting and should not
// have an explicit accept list
new Set(),
null,
isSelfAccepting,
ssr,
)
} else {
thisModule.isSelfAccepting = isSelfAccepting
}
}
},
}
}
/**
* Create a replacer function that takes code and replaces given pure CSS chunk imports
* @param pureCssChunkNames The chunks that only contain pure CSS and should be replaced

View File

@ -744,7 +744,7 @@ export function importAnalysisPlugin(config: ResolvedConfig): Plugin {
}
// update the module graph for HMR analysis.
// node CSS imports does its own graph update in the css plugin so we
// node CSS imports does its own graph update in the css-analysis plugin so we
// only handle js graph updates here.
if (!isCSSRequest(importer)) {
// attached by pluginContainer.addWatchFile

View File

@ -12,7 +12,7 @@ import { resolvePlugin } from './resolve'
import { optimizedDepsPlugin } from './optimizedDeps'
import { esbuildPlugin } from './esbuild'
import { importAnalysisPlugin } from './importAnalysis'
import { cssPlugin, cssPostPlugin } from './css'
import { cssAnalysisPlugin, cssPlugin, cssPostPlugin } from './css'
import { assetPlugin } from './asset'
import { clientInjectionsPlugin } from './clientInjections'
import { buildHtmlPlugin, htmlInlineProxyPlugin } from './html'
@ -101,7 +101,11 @@ export async function resolvePlugins(
// internal server-only plugins are always applied after everything else
...(isBuild
? []
: [clientInjectionsPlugin(config), importAnalysisPlugin(config)]),
: [
clientInjectionsPlugin(config),
cssAnalysisPlugin(config),
importAnalysisPlugin(config),
]),
].filter(Boolean) as Plugin[]
}

View File

@ -5,6 +5,7 @@ import {
browserLogs,
editFile,
getBg,
getColor,
isBuild,
page,
removeFile,
@ -919,4 +920,11 @@ if (import.meta.hot) {
)
await untilUpdated(() => el.evaluate((it) => `${it.clientHeight}`), '40')
})
test('CSS HMR with this.addWatchFile', async () => {
await page.goto(viteTestUrl + '/css-deps/index.html')
expect(await getColor('.css-deps')).toMatch('red')
editFile('css-deps/dep.js', (code) => code.replace(`red`, `green`))
await untilUpdated(() => getColor('.css-deps'), 'green')
})
}

View File

@ -0,0 +1,8 @@
// This file is depended by main.css via this.addWatchFile
export const color = 'red'
// Self-accept so that updating this file would not trigger a page reload.
// We only want to observe main.css updating itself.
if (import.meta.hot) {
import.meta.hot.accept()
}

View File

@ -0,0 +1,8 @@
<div class="css-deps">should be red</div>
<script type="module">
import './main.css'
// Import dep.js so that not only the CSS depends on dep.js, as Vite will do
// a full page reload if the only importers are CSS files.
import './dep.js'
</script>

View File

@ -0,0 +1,3 @@
.css-deps {
color: replaced;
}

View File

@ -1,3 +1,5 @@
import fs from 'node:fs/promises'
import path from 'node:path'
import { defineConfig } from 'vite'
import type { Plugin } from 'vite'
@ -24,6 +26,7 @@ export default defineConfig({
},
virtualPlugin(),
transformCountPlugin(),
watchCssDepsPlugin(),
],
})
@ -66,3 +69,20 @@ function transformCountPlugin(): Plugin {
},
}
}
function watchCssDepsPlugin(): Plugin {
return {
name: 'watch-css-deps',
async transform(code, id) {
// replace the `replaced` identifier in the CSS file with the adjacent
// `dep.js` file's `color` variable.
if (id.includes('css-deps/main.css')) {
const depPath = path.resolve(__dirname, './css-deps/dep.js')
const dep = await fs.readFile(depPath, 'utf-8')
const color = dep.match(/color = '(.+?)'/)[1]
this.addWatchFile(depPath)
return code.replace('replaced', color)
}
},
}
}