diff --git a/packages/vite/src/node/plugins/css.ts b/packages/vite/src/node/plugins/css.ts index effeec120..75e43f137 100644 --- a/packages/vite/src/node/plugins/css.ts +++ b/packages/vite/src/node/plugins/css.ts @@ -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> 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>() @@ -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() - 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 + | 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() + 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 diff --git a/packages/vite/src/node/plugins/importAnalysis.ts b/packages/vite/src/node/plugins/importAnalysis.ts index c89d5af76..195cbaf60 100644 --- a/packages/vite/src/node/plugins/importAnalysis.ts +++ b/packages/vite/src/node/plugins/importAnalysis.ts @@ -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 diff --git a/packages/vite/src/node/plugins/index.ts b/packages/vite/src/node/plugins/index.ts index 82fa46964..08bb7c3a9 100644 --- a/packages/vite/src/node/plugins/index.ts +++ b/packages/vite/src/node/plugins/index.ts @@ -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[] } diff --git a/playground/hmr/__tests__/hmr.spec.ts b/playground/hmr/__tests__/hmr.spec.ts index 194ddb48d..cd4fb773c 100644 --- a/playground/hmr/__tests__/hmr.spec.ts +++ b/playground/hmr/__tests__/hmr.spec.ts @@ -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') + }) } diff --git a/playground/hmr/css-deps/dep.js b/playground/hmr/css-deps/dep.js new file mode 100644 index 000000000..07f62c424 --- /dev/null +++ b/playground/hmr/css-deps/dep.js @@ -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() +} diff --git a/playground/hmr/css-deps/index.html b/playground/hmr/css-deps/index.html new file mode 100644 index 000000000..6ff27f565 --- /dev/null +++ b/playground/hmr/css-deps/index.html @@ -0,0 +1,8 @@ +
should be red
+ + diff --git a/playground/hmr/css-deps/main.css b/playground/hmr/css-deps/main.css new file mode 100644 index 000000000..65cea880b --- /dev/null +++ b/playground/hmr/css-deps/main.css @@ -0,0 +1,3 @@ +.css-deps { + color: replaced; +} diff --git a/playground/hmr/vite.config.ts b/playground/hmr/vite.config.ts index 9ae2186d1..364373873 100644 --- a/playground/hmr/vite.config.ts +++ b/playground/hmr/vite.config.ts @@ -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) + } + }, + } +}