diff --git a/packages/vite/src/node/plugins/asset.ts b/packages/vite/src/node/plugins/asset.ts index b9447d9cf17f68..cd85f0bf7728d7 100644 --- a/packages/vite/src/node/plugins/asset.ts +++ b/packages/vite/src/node/plugins/asset.ts @@ -184,6 +184,11 @@ export function assetPlugin(config: ResolvedConfig): Plugin { id = removeUrlQuery(id) let url = await fileToUrl(this, id) + if (id.endsWith('.css')) { + // .css?url depends on .css?direct + this.addWatchFile(id + '?direct') + } + // Inherit HMR timestamp if this asset was invalidated if (!url.startsWith('data:') && this.environment.mode === 'dev') { const mod = this.environment.moduleGraph.getModuleById(id) diff --git a/packages/vite/src/node/plugins/css.ts b/packages/vite/src/node/plugins/css.ts index 04c0f87982fa2e..d2800e846880d6 100644 --- a/packages/vite/src/node/plugins/css.ts +++ b/packages/vite/src/node/plugins/css.ts @@ -373,20 +373,20 @@ export function cssPlugin(config: ResolvedConfig): Plugin { const resolveUrl = (url: string, importer?: string) => idResolver(environment, url, importer) - const urlReplacer: CssUrlReplacer = async (url, importer) => { + const urlResolver: CssUrlResolver = async (url, importer) => { const decodedUrl = decodeURI(url) if (checkPublicFile(decodedUrl, config)) { if (encodePublicUrlsInCSS(config)) { - return publicFileToBuiltUrl(decodedUrl, config) + return [publicFileToBuiltUrl(decodedUrl, config), undefined] } else { - return joinUrlSegments(config.base, decodedUrl) + return [joinUrlSegments(config.base, decodedUrl), undefined] } } const [id, fragment] = decodedUrl.split('#') let resolved = await resolveUrl(id, importer) if (resolved) { if (fragment) resolved += '#' + fragment - return fileToUrl(this, resolved) + return [await fileToUrl(this, resolved), resolved] } if (config.command === 'build') { const isExternal = config.build.rollupOptions.external @@ -405,7 +405,7 @@ export function cssPlugin(config: ResolvedConfig): Plugin { ) } } - return url + return [url, undefined] } const { @@ -418,7 +418,7 @@ export function cssPlugin(config: ResolvedConfig): Plugin { id, raw, preprocessorWorkerController!, - urlReplacer, + urlResolver, ) if (modules) { moduleCache.set(id, modules) @@ -1058,17 +1058,20 @@ export function cssAnalysisPlugin(config: ResolvedConfig): Plugin { // main import to hot update const depModules = new Set() for (const file of pluginImports) { - depModules.add( - isCSSRequest(file) - ? moduleGraph.createFileOnlyEntry(file) - : await moduleGraph.ensureEntryFromUrl( - await fileToDevUrl( - this.environment, - file, - /* skipBase */ true, - ), - ), - ) + if (isCSSRequest(file)) { + depModules.add(moduleGraph.createFileOnlyEntry(file)) + } else { + const url = await fileToDevUrl( + this.environment, + file, + /* skipBase */ true, + ) + if (url.startsWith('data:')) { + depModules.add(moduleGraph.createFileOnlyEntry(file)) + } else { + depModules.add(await moduleGraph.ensureEntryFromUrl(url)) + } + } } moduleGraph.updateModuleInfo( thisModule, @@ -1261,7 +1264,7 @@ async function compileCSS( id: string, code: string, workerController: PreprocessorWorkerController, - urlReplacer?: CssUrlReplacer, + urlResolver?: CssUrlResolver, ): Promise<{ code: string map?: SourceMapInput @@ -1271,7 +1274,7 @@ async function compileCSS( }> { const { config } = environment if (config.css.transformer === 'lightningcss') { - return compileLightningCSS(id, code, environment, urlReplacer) + return compileLightningCSS(id, code, environment, urlResolver) } const { modules: modulesOptions, devSourcemap } = config.css @@ -1380,10 +1383,11 @@ async function compileCSS( ) } - if (urlReplacer) { + if (urlResolver) { postcssPlugins.push( UrlRewritePostcssPlugin({ - replacer: urlReplacer, + resolver: urlResolver, + deps, logger: environment.logger, }), ) @@ -1717,6 +1721,12 @@ async function resolvePostcssConfig( return result } +type CssUrlResolver = ( + url: string, + importer?: string, +) => + | [url: string, id: string | undefined] + | Promise<[url: string, id: string | undefined]> type CssUrlReplacer = ( url: string, importer?: string, @@ -1733,7 +1743,8 @@ export const importCssRE = const cssImageSetRE = /(?<=image-set\()((?:[\w-]{1,256}\([^)]*\)|[^)])*)(?=\))/ const UrlRewritePostcssPlugin: PostCSS.PluginCreator<{ - replacer: CssUrlReplacer + resolver: CssUrlResolver + deps: Set logger: Logger }> = (opts) => { if (!opts) { @@ -1757,8 +1768,12 @@ const UrlRewritePostcssPlugin: PostCSS.PluginCreator<{ const isCssUrl = cssUrlRE.test(declaration.value) const isCssImageSet = cssImageSetRE.test(declaration.value) if (isCssUrl || isCssImageSet) { - const replacerForDeclaration = (rawUrl: string) => { - return opts.replacer(rawUrl, importer) + const replacerForDeclaration = async (rawUrl: string) => { + const [newUrl, resolvedId] = await opts.resolver(rawUrl, importer) + if (resolvedId) { + opts.deps.add(resolvedId) + } + return newUrl } if (isCssUrl && isCssImageSet) { promises.push( @@ -3129,7 +3144,7 @@ async function compileLightningCSS( id: string, src: string, environment: PartialEnvironment, - urlReplacer?: CssUrlReplacer, + urlResolver?: CssUrlResolver, ): ReturnType { const { config } = environment const deps = new Set() @@ -3215,11 +3230,14 @@ async function compileLightningCSS( css = css.replace(dep.placeholder, () => dep.url) break } - if (urlReplacer) { - const replaceUrl = await urlReplacer( + if (urlResolver) { + const [replaceUrl, resolvedId] = await urlResolver( dep.url, toAbsolute(dep.loc.filePath), ) + if (resolvedId) { + deps.add(resolvedId) + } css = css.replace(dep.placeholder, () => replaceUrl) } else { css = css.replace(dep.placeholder, () => dep.url) diff --git a/packages/vite/src/node/server/hmr.ts b/packages/vite/src/node/server/hmr.ts index b4993bd31c6b7e..80855756d2ad99 100644 --- a/packages/vite/src/node/server/hmr.ts +++ b/packages/vite/src/node/server/hmr.ts @@ -793,6 +793,7 @@ function propagateUpdate( // #3716, #3913 // For a non-CSS file, if all of its importers are CSS files (registered via // PostCSS plugins) it should be considered a dead end and force full reload. + // TODO if ( !isCSSRequest(node.url) && [...node.importers].every((i) => isCSSRequest(i.url)) diff --git a/playground/assets/__tests__/assets.spec.ts b/playground/assets/__tests__/assets.spec.ts index 0f33ca7d4b55fb..b71ff8b9c2e66e 100644 --- a/playground/assets/__tests__/assets.spec.ts +++ b/playground/assets/__tests__/assets.spec.ts @@ -287,12 +287,36 @@ describe('css url() references', () => { }) test('url() with svg', async () => { - expect(await getBg('.css-url-svg')).toMatch(/data:image\/svg\+xml,.+/) + const bg = await getBg('.css-url-svg') + expect(bg).toMatch(/data:image\/svg\+xml,.+/) + expect(bg).toContain('blue') + expect(bg).not.toContain('red') + + if (isServe) { + editFile('nested/fragment-bg-hmr.svg', (code) => + code.replace('fill="blue"', 'fill="red"'), + ) + await untilUpdated(() => getBg('.css-url-svg'), 'red') + } }) test('image-set() with svg', async () => { expect(await getBg('.css-image-set-svg')).toMatch(/data:image\/svg\+xml,.+/) }) + + test('url() with svg in .css?url', async () => { + const bg = await getBg('.css-url-svg-in-url') + expect(bg).toMatch(/data:image\/svg\+xml,.+/) + expect(bg).toContain('blue') + expect(bg).not.toContain('red') + + if (isServe) { + editFile('nested/fragment-bg-hmr2.svg', (code) => + code.replace('fill="blue"', 'fill="red"'), + ) + await untilUpdated(() => getBg('.css-url-svg'), 'red') + } + }) }) describe('image', () => { diff --git a/playground/assets/css/css-url-url.css b/playground/assets/css/css-url-url.css new file mode 100644 index 00000000000000..f810e2300a96dd --- /dev/null +++ b/playground/assets/css/css-url-url.css @@ -0,0 +1,4 @@ +.css-url-svg-in-url { + background: url(../nested/fragment-bg-hmr2.svg); + background-size: 10px; +} diff --git a/playground/assets/css/css-url.css b/playground/assets/css/css-url.css index 61282fb20fa3b7..54d8ab6c36929b 100644 --- a/playground/assets/css/css-url.css +++ b/playground/assets/css/css-url.css @@ -114,7 +114,7 @@ urls inside comments should be ignored */ .css-url-svg { - background: url(../nested/fragment-bg.svg); + background: url(../nested/fragment-bg-hmr.svg); background-size: 10px; } diff --git a/playground/assets/index.html b/playground/assets/index.html index e41ae78cff0227..aec7fa46b447a2 100644 --- a/playground/assets/index.html +++ b/playground/assets/index.html @@ -148,6 +148,9 @@

CSS url references

CSS SVG background
+
+ CSS (?url) SVG background +
CSS SVG background with image-set @@ -528,6 +531,12 @@

assets in template

import cssUrl from './css/icons.css?url' text('.url-css', cssUrl) + import cssUrlUrl from './css/css-url-url.css?url' + const linkTag = document.createElement('link') + linkTag.href = cssUrlUrl + linkTag.rel = 'stylesheet' + document.body.appendChild(linkTag) + // const url = new URL('non_existent_file.png', import.meta.url) const metaUrl = new URL('./nested/asset.png', import.meta.url) text('.import-meta-url', metaUrl) diff --git a/playground/assets/nested/fragment-bg-hmr.svg b/playground/assets/nested/fragment-bg-hmr.svg new file mode 100644 index 00000000000000..44e4248f924d70 --- /dev/null +++ b/playground/assets/nested/fragment-bg-hmr.svg @@ -0,0 +1,21 @@ + + + + + + + + + + + + + + + + + + + diff --git a/playground/assets/nested/fragment-bg-hmr2.svg b/playground/assets/nested/fragment-bg-hmr2.svg new file mode 100644 index 00000000000000..44e4248f924d70 --- /dev/null +++ b/playground/assets/nested/fragment-bg-hmr2.svg @@ -0,0 +1,21 @@ + + + + + + + + + + + + + + + + + + +