From ed56d96cec8fd3b9b6dae88d5511e386177e6d5c Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?=E7=BF=A0=20/=20green?= Date: Fri, 12 Jan 2024 18:07:22 +0900 Subject: [PATCH] fix(css): `.css?url` support (#15259) --- packages/vite/src/node/plugins/asset.ts | 10 +- packages/vite/src/node/plugins/css.ts | 200 ++++++++++++++---- .../vite/src/node/plugins/importAnalysis.ts | 2 +- .../src/node/server/middlewares/transform.ts | 2 +- packages/vite/src/node/utils.ts | 34 +++ playground/assets/__tests__/assets.spec.ts | 9 +- playground/css/__tests__/css.spec.ts | 4 + playground/css/index.html | 2 + playground/css/main.js | 10 + playground/css/raw-imported.css | 7 +- playground/css/url-imported.css | 6 + 11 files changed, 232 insertions(+), 54 deletions(-) create mode 100644 playground/css/url-imported.css diff --git a/packages/vite/src/node/plugins/asset.ts b/packages/vite/src/node/plugins/asset.ts index df5fdc1eb1aa21..1b3a0cd752136a 100644 --- a/packages/vite/src/node/plugins/asset.ts +++ b/packages/vite/src/node/plugins/asset.ts @@ -23,7 +23,10 @@ import { injectQuery, joinUrlSegments, normalizePath, + rawRE, removeLeadingSlash, + removeUrlQuery, + urlRE, withTrailingSlash, } from '../utils' import { DEFAULT_ASSETS_INLINE_LIMIT, FS_PREFIX } from '../constants' @@ -32,10 +35,7 @@ import type { ModuleGraph } from '../server/moduleGraph' // referenceId is base64url but replaces - with $ export const assetUrlRE = /__VITE_ASSET__([\w$]+)__(?:\$_(.*?)__)?/g -const rawRE = /(?:\?|&)raw(?:&|$)/ -export const urlRE = /(\?|&)url(?:&|$)/ const jsSourceMapRE = /\.[cm]?js\.map$/ -const unnededFinalQueryCharRE = /[?&]$/ const assetCache = new WeakMap>() @@ -191,11 +191,11 @@ export function assetPlugin(config: ResolvedConfig): Plugin { )}` } - if (!config.assetsInclude(cleanUrl(id)) && !urlRE.test(id)) { + if (!urlRE.test(id) && !config.assetsInclude(cleanUrl(id))) { return } - id = id.replace(urlRE, '$1').replace(unnededFinalQueryCharRE, '') + id = removeUrlQuery(id) let url = await fileToUrl(id, config, this) // Inherit HMR timestamp if this asset was invalidated diff --git a/packages/vite/src/node/plugins/css.ts b/packages/vite/src/node/plugins/css.ts index 67711df7cae2c0..cb0d811bada606 100644 --- a/packages/vite/src/node/plugins/css.ts +++ b/packages/vite/src/node/plugins/css.ts @@ -27,7 +27,12 @@ import type { RawSourceMap } from '@ampproject/remapping' import { getCodeWithSourcemap, injectSourcesContent } from '../server/sourcemap' import type { ModuleNode } from '../server/moduleGraph' import type { ResolveFn, ViteDevServer } from '../' -import { resolveUserExternal, toOutputFilePathInCss } from '../build' +import { + createToImportMetaURLBasedRelativeRuntime, + resolveUserExternal, + toOutputFilePathInCss, + toOutputFilePathInJS, +} from '../build' import { CLIENT_PUBLIC_PATH, CSS_LANGS_RE, @@ -42,10 +47,12 @@ import { asyncReplace, cleanUrl, combineSourcemaps, + createSerialPromiseQueue, emptyCssComments, generateCodeFrame, getHash, getPackageManagerCommand, + injectQuery, isDataUrl, isExternalUrl, isObject, @@ -54,10 +61,12 @@ import { parseRequest, processSrcSet, removeDirectQuery, + removeUrlQuery, requireResolveFromRootWithFallback, slash, stripBase, stripBomTag, + urlRE, } from '../utils' import type { Logger } from '../logger' import { addToHTMLProxyTransformResult } from './html' @@ -167,6 +176,7 @@ const inlineRE = /[?&]inline\b/ const inlineCSSRE = /[?&]inline-css\b/ const styleAttrRE = /[?&]style-attr\b/ const functionCallRE = /^[A-Z_][\w-]*\(/i +const transformOnlyRE = /[?&]transform-only\b/ const nonEscapedDoubleQuoteRe = /(?> @@ -253,6 +266,32 @@ export function cssPlugin(config: ResolvedConfig): Plugin { removedPureCssFilesCache.set(config, new Map()) }, + async load(id) { + if (!isCSSRequest(id)) return + + if (urlRE.test(id)) { + if (isModuleCSSRequest(id)) { + throw new Error( + `?url is not supported with CSS modules. (tried to import ${JSON.stringify( + id, + )})`, + ) + } + + // *.css?url + // in dev, it's handled by assets plugin. + if (isBuild) { + id = injectQuery(removeUrlQuery(id), 'transform-only') + return ( + `import ${JSON.stringify(id)};` + + `export default "__VITE_CSS_URL__${Buffer.from(id).toString( + 'hex', + )}__"` + ) + } + } + }, + async transform(raw, id, options) { if ( !isCSSRequest(id) || @@ -374,8 +413,9 @@ export function cssPlugin(config: ResolvedConfig): Plugin { export function cssPostPlugin(config: ResolvedConfig): Plugin { // styles initialization in buildStart causes a styling loss in watch const styles: Map = new Map() - // list of css emit tasks to guarantee the files are emitted in a deterministic order - let emitTasks: Promise[] = [] + // queue to emit css serially to guarantee the files are emitted in a deterministic order + let codeSplitEmitQueue = createSerialPromiseQueue() + const urlEmitQueue = createSerialPromiseQueue() let pureCssChunks: Set // when there are multiple rollup outputs and extracting CSS, only emit once, @@ -414,7 +454,7 @@ export function cssPostPlugin(config: ResolvedConfig): Plugin { pureCssChunks = new Set() hasEmitted = false chunkCSSMap = new Map() - emitTasks = [] + codeSplitEmitQueue = createSerialPromiseQueue() }, async transform(css, id, options) { @@ -530,10 +570,13 @@ export function cssPostPlugin(config: ResolvedConfig): Plugin { const ids = Object.keys(chunk.modules) for (const id of ids) { if (styles.has(id)) { - chunkCSS += styles.get(id) - // a css module contains JS, so it makes this not a pure css chunk - if (cssModuleRE.test(id)) { - isPureCssChunk = false + // ?transform-only is used for ?url and shouldn't be included in normal CSS chunks + if (!transformOnlyRE.test(id)) { + chunkCSS += styles.get(id) + // a css module contains JS, so it makes this not a pure css chunk + if (cssModuleRE.test(id)) { + isPureCssChunk = false + } } } else { // if the module does not have a style, then it's not a pure css chunk. @@ -543,10 +586,6 @@ export function cssPostPlugin(config: ResolvedConfig): Plugin { } } - if (!chunkCSS) { - return null - } - const publicAssetUrlMap = publicAssetUrlCache.get(config)! // resolve asset URL placeholders to their built file URLs @@ -608,6 +647,98 @@ export function cssPostPlugin(config: ResolvedConfig): Plugin { ) } + let s: MagicString | undefined + const urlEmitTasks: Array<{ + cssAssetName: string + originalFilename: string + content: string + start: number + end: number + }> = [] + + if (code.includes('__VITE_CSS_URL__')) { + let match: RegExpExecArray | null + cssUrlAssetRE.lastIndex = 0 + while ((match = cssUrlAssetRE.exec(code))) { + const [full, idHex] = match + const id = Buffer.from(idHex, 'hex').toString() + const originalFilename = cleanUrl(id) + const cssAssetName = ensureFileExt( + path.basename(originalFilename), + '.css', + ) + if (!styles.has(id)) { + throw new Error( + `css content for ${JSON.stringify(id)} was not found`, + ) + } + + let cssContent = styles.get(id)! + + cssContent = resolveAssetUrlsInCss(cssContent, cssAssetName) + + urlEmitTasks.push({ + cssAssetName, + originalFilename, + content: cssContent, + start: match.index, + end: match.index + full.length, + }) + } + } + + // should await even if this chunk does not include __VITE_CSS_URL__ + // so that code after this line runs in the same order + await urlEmitQueue.run(async () => + Promise.all( + urlEmitTasks.map(async (info) => { + info.content = await finalizeCss(info.content, true, config) + }), + ), + ) + if (urlEmitTasks.length > 0) { + const toRelativeRuntime = createToImportMetaURLBasedRelativeRuntime( + opts.format, + config.isWorker, + ) + s ||= new MagicString(code) + + for (const { + cssAssetName, + originalFilename, + content, + start, + end, + } of urlEmitTasks) { + const referenceId = this.emitFile({ + name: cssAssetName, + type: 'asset', + source: content, + }) + generatedAssets + .get(config)! + .set(referenceId, { originalName: originalFilename }) + + const replacement = toOutputFilePathInJS( + this.getFileName(referenceId), + 'asset', + chunk.fileName, + 'js', + config, + toRelativeRuntime, + ) + const replacementString = + typeof replacement === 'string' + ? JSON.stringify(replacement).slice(1, -1) + : `"+${replacement.runtime}+"` + s.update(start, end, replacementString) + } + } + + if (!chunkCSS && !s) { + return null + } + if (config.build.cssCodeSplit) { if (opts.format === 'es' || opts.format === 'cjs') { if (isPureCssChunk) { @@ -633,22 +764,11 @@ export function cssPostPlugin(config: ResolvedConfig): Plugin { chunkCSS = resolveAssetUrlsInCss(chunkCSS, cssAssetName) - const previousTask = emitTasks[emitTasks.length - 1] - // finalizeCss is async which makes `emitFile` non-deterministic, so - // we use a `.then` to wait for previous tasks before finishing this - const thisTask = finalizeCss(chunkCSS, true, config).then((css) => { - chunkCSS = css - // make sure the previous task is also finished, this works recursively - return previousTask + // wait for previous tasks as well + chunkCSS = await codeSplitEmitQueue.run(async () => { + return finalizeCss(chunkCSS, true, config) }) - // push this task so the next task can wait for this one - emitTasks.push(thisTask) - const emitTasksLength = emitTasks.length - - // wait for this and previous tasks to finish - await thisTask - // emit corresponding css file const referenceId = this.emitFile({ name: cssAssetName, @@ -659,11 +779,6 @@ export function cssPostPlugin(config: ResolvedConfig): Plugin { .get(config)! .set(referenceId, { originalName: originalFilename, isEntry }) chunk.viteMetadata!.importedCss.add(this.getFileName(referenceId)) - - if (emitTasksLength === emitTasks.length) { - // this is the last task, clear `emitTasks` to free up memory - emitTasks = [] - } } else if (!config.build.ssr) { // legacy build and inline css @@ -697,24 +812,27 @@ export function cssPostPlugin(config: ResolvedConfig): Plugin { const insertMark = "'use strict';" injectionPoint = code.indexOf(insertMark) + insertMark.length } - const s = new MagicString(code) + s ||= new MagicString(code) s.appendRight(injectionPoint, injectCode) - if (config.build.sourcemap) { - // resolve public URL from CSS paths, we need to use absolute paths - return { - code: s.toString(), - map: s.generateMap({ hires: 'boundary' }), - } - } else { - return { code: s.toString() } - } } } else { + // resolve public URL from CSS paths, we need to use absolute paths chunkCSS = resolveAssetUrlsInCss(chunkCSS, cssBundleName) // finalizeCss is called for the aggregated chunk in generateBundle chunkCSSMap.set(chunk.fileName, chunkCSS) } + + if (s) { + if (config.build.sourcemap) { + return { + code: s.toString(), + map: s.generateMap({ hires: 'boundary' }), + } + } else { + return { code: s.toString() } + } + } return null }, diff --git a/packages/vite/src/node/plugins/importAnalysis.ts b/packages/vite/src/node/plugins/importAnalysis.ts index 29e2819cf48ac8..c89d5af76de2e5 100644 --- a/packages/vite/src/node/plugins/importAnalysis.ts +++ b/packages/vite/src/node/plugins/importAnalysis.ts @@ -48,6 +48,7 @@ import { timeFrom, transformStableResult, unwrapId, + urlRE, withTrailingSlash, wrapId, } from '../utils' @@ -58,7 +59,6 @@ import type { ResolvedConfig } from '../config' import type { Plugin } from '../plugin' import { shouldExternalizeForSSR } from '../ssr/ssrExternal' import { getDepsOptimizer, optimizedDepNeedsInterop } from '../optimizer' -import { urlRE } from './asset' import { throwOutdatedRequest } from './optimizedDeps' import { isCSSRequest, isDirectCSSRequest } from './css' import { browserExternalId } from './resolve' diff --git a/packages/vite/src/node/server/middlewares/transform.ts b/packages/vite/src/node/server/middlewares/transform.ts index 12e2fcef9739ec..753a025f98b491 100644 --- a/packages/vite/src/node/server/middlewares/transform.ts +++ b/packages/vite/src/node/server/middlewares/transform.ts @@ -16,6 +16,7 @@ import { removeImportQuery, removeTimestampQuery, unwrapId, + urlRE, withTrailingSlash, } from '../../utils' import { send } from '../send' @@ -38,7 +39,6 @@ import { } from '../../plugins/optimizedDeps' import { ERR_CLOSED_SERVER } from '../pluginContainer' import { getDepsOptimizer } from '../../optimizer' -import { urlRE } from '../../plugins/asset' const debugCache = createDebugger('vite:cache') diff --git a/packages/vite/src/node/utils.ts b/packages/vite/src/node/utils.ts index c9f00ca65bf6c2..9b8fd2192adb61 100644 --- a/packages/vite/src/node/utils.ts +++ b/packages/vite/src/node/utils.ts @@ -336,6 +336,15 @@ export function removeDirectQuery(url: string): string { return url.replace(directRequestRE, '$1').replace(trailingSeparatorRE, '') } +export const urlRE = /(\?|&)url(?:&|$)/ +export const rawRE = /(\?|&)raw(?:&|$)/ +export function removeUrlQuery(url: string): string { + return url.replace(urlRE, '$1').replace(trailingSeparatorRE, '') +} +export function removeRawQuery(url: string): string { + return url.replace(rawRE, '$1').replace(trailingSeparatorRE, '') +} + const replacePercentageRE = /%/g export function injectQuery(url: string, queryToInject: string): string { // encode percents for consistent behavior with pathToFileURL @@ -1380,6 +1389,31 @@ export function promiseWithResolvers(): PromiseWithResolvers { return { promise, resolve, reject } } +export function createSerialPromiseQueue(): { + run(f: () => Promise): Promise +} { + let previousTask: Promise<[unknown, Awaited]> | undefined + + return { + async run(f) { + const thisTask = f() + // wait for both the previous task and this task + // so that this function resolves in the order this function is called + const depTasks = Promise.all([previousTask, thisTask]) + previousTask = depTasks + + const [, result] = await depTasks + + // this task was the last one, clear `previousTask` to free up memory + if (previousTask === depTasks) { + previousTask = undefined + } + + return result + }, + } +} + export function sortObjectKeys>(obj: T): T { const sorted: Record = {} for (const key of Object.keys(obj).sort()) { diff --git a/playground/assets/__tests__/assets.spec.ts b/playground/assets/__tests__/assets.spec.ts index 51b8c336ef96d0..5933db4d76c08d 100644 --- a/playground/assets/__tests__/assets.spec.ts +++ b/playground/assets/__tests__/assets.spec.ts @@ -259,7 +259,7 @@ describe('css url() references', () => { }) test.runIf(isBuild)('generated paths in CSS', () => { - const css = findAssetFile(/\.css$/, 'foo') + const css = findAssetFile(/index-[-\w]{8}\.css$/, 'foo') // preserve postfix query/hash expect(css).toMatch(`woff2?#iefix`) @@ -359,11 +359,10 @@ test('?url import', async () => { }) test('?url import on css', async () => { - const src = readFile('css/icons.css') const txt = await page.textContent('.url-css') - expect(txt).toEqual( + expect(txt).toMatch( isBuild - ? `data:text/css;base64,${Buffer.from(src).toString('base64')}` + ? /\/foo\/bar\/assets\/icons-[-\w]{8}\.css/ : '/foo/bar/css/icons.css', ) }) @@ -462,6 +461,8 @@ test.runIf(isBuild)('manifest', async () => { for (const file of listAssets('foo')) { if (file.endsWith('.css')) { + // ignore icons-*.css as it's imported with ?url + if (file.includes('icons-')) continue expect(entry.css).toContain(`assets/${file}`) } else if (!file.endsWith('.js')) { expect(entry.assets).toContain(`assets/${file}`) diff --git a/playground/css/__tests__/css.spec.ts b/playground/css/__tests__/css.spec.ts index 5391450166f288..2e7e78aefaf976 100644 --- a/playground/css/__tests__/css.spec.ts +++ b/playground/css/__tests__/css.spec.ts @@ -438,6 +438,10 @@ test('minify css', async () => { expect(cssFile).not.toMatch('#ffff00b3') }) +test('?url', async () => { + expect(await getColor('.url-imported-css')).toBe('yellow') +}) + test('?raw', async () => { const rawImportCss = await page.$('.raw-imported-css') diff --git a/playground/css/index.html b/playground/css/index.html index 66a2ce75c22121..520e1e20b4e2aa 100644 --- a/playground/css/index.html +++ b/playground/css/index.html @@ -185,6 +185,8 @@

CSS


 
+  

URL Support

+

Raw Support


 
diff --git a/playground/css/main.js b/playground/css/main.js
index 9e98425248da88..204d95768e88d4 100644
--- a/playground/css/main.js
+++ b/playground/css/main.js
@@ -6,6 +6,9 @@ import './less.less'
 import './stylus.styl'
 import './manual-chunk.css'
 
+import urlCss from './url-imported.css?url'
+appendLinkStylesheet(urlCss)
+
 import rawCss from './raw-imported.css?raw'
 text('.raw-imported-css', rawCss)
 
@@ -53,6 +56,13 @@ function text(el, text) {
   document.querySelector(el).textContent = text
 }
 
+function appendLinkStylesheet(href) {
+  const link = document.createElement('link')
+  link.rel = 'stylesheet'
+  link.href = href
+  document.head.appendChild(link)
+}
+
 if (import.meta.hot) {
   import.meta.hot.accept('./mod.module.css', (newMod) => {
     const list = document.querySelector('.modules').classList
diff --git a/playground/css/raw-imported.css b/playground/css/raw-imported.css
index ac0aee96390c33..ee681e650b0b47 100644
--- a/playground/css/raw-imported.css
+++ b/playground/css/raw-imported.css
@@ -1,3 +1,6 @@
-.raw-imported {
-  color: yellow;
+.raw {
+  /* should not be transformed by postcss */
+  &-imported {
+    color: yellow;
+  }
 }
diff --git a/playground/css/url-imported.css b/playground/css/url-imported.css
new file mode 100644
index 00000000000000..95fec50ab2c554
--- /dev/null
+++ b/playground/css/url-imported.css
@@ -0,0 +1,6 @@
+.url {
+  /* should be transformed by postcss */
+  &-imported-css {
+    color: yellow;
+  }
+}