Skip to content

Commit ed56d96

Browse files
authored
fix(css): .css?url support (#15259)
1 parent 67a50f1 commit ed56d96

File tree

11 files changed

+232
-54
lines changed

11 files changed

+232
-54
lines changed

packages/vite/src/node/plugins/asset.ts

Lines changed: 5 additions & 5 deletions
Original file line numberDiff line numberDiff line change
@@ -23,7 +23,10 @@ import {
2323
injectQuery,
2424
joinUrlSegments,
2525
normalizePath,
26+
rawRE,
2627
removeLeadingSlash,
28+
removeUrlQuery,
29+
urlRE,
2730
withTrailingSlash,
2831
} from '../utils'
2932
import { DEFAULT_ASSETS_INLINE_LIMIT, FS_PREFIX } from '../constants'
@@ -32,10 +35,7 @@ import type { ModuleGraph } from '../server/moduleGraph'
3235
// referenceId is base64url but replaces - with $
3336
export const assetUrlRE = /__VITE_ASSET__([\w$]+)__(?:\$_(.*?)__)?/g
3437

35-
const rawRE = /(?:\?|&)raw(?:&|$)/
36-
export const urlRE = /(\?|&)url(?:&|$)/
3738
const jsSourceMapRE = /\.[cm]?js\.map$/
38-
const unnededFinalQueryCharRE = /[?&]$/
3939

4040
const assetCache = new WeakMap<ResolvedConfig, Map<string, string>>()
4141

@@ -191,11 +191,11 @@ export function assetPlugin(config: ResolvedConfig): Plugin {
191191
)}`
192192
}
193193

194-
if (!config.assetsInclude(cleanUrl(id)) && !urlRE.test(id)) {
194+
if (!urlRE.test(id) && !config.assetsInclude(cleanUrl(id))) {
195195
return
196196
}
197197

198-
id = id.replace(urlRE, '$1').replace(unnededFinalQueryCharRE, '')
198+
id = removeUrlQuery(id)
199199
let url = await fileToUrl(id, config, this)
200200

201201
// Inherit HMR timestamp if this asset was invalidated

packages/vite/src/node/plugins/css.ts

Lines changed: 159 additions & 41 deletions
Original file line numberDiff line numberDiff line change
@@ -27,7 +27,12 @@ import type { RawSourceMap } from '@ampproject/remapping'
2727
import { getCodeWithSourcemap, injectSourcesContent } from '../server/sourcemap'
2828
import type { ModuleNode } from '../server/moduleGraph'
2929
import type { ResolveFn, ViteDevServer } from '../'
30-
import { resolveUserExternal, toOutputFilePathInCss } from '../build'
30+
import {
31+
createToImportMetaURLBasedRelativeRuntime,
32+
resolveUserExternal,
33+
toOutputFilePathInCss,
34+
toOutputFilePathInJS,
35+
} from '../build'
3136
import {
3237
CLIENT_PUBLIC_PATH,
3338
CSS_LANGS_RE,
@@ -42,10 +47,12 @@ import {
4247
asyncReplace,
4348
cleanUrl,
4449
combineSourcemaps,
50+
createSerialPromiseQueue,
4551
emptyCssComments,
4652
generateCodeFrame,
4753
getHash,
4854
getPackageManagerCommand,
55+
injectQuery,
4956
isDataUrl,
5057
isExternalUrl,
5158
isObject,
@@ -54,10 +61,12 @@ import {
5461
parseRequest,
5562
processSrcSet,
5663
removeDirectQuery,
64+
removeUrlQuery,
5765
requireResolveFromRootWithFallback,
5866
slash,
5967
stripBase,
6068
stripBomTag,
69+
urlRE,
6170
} from '../utils'
6271
import type { Logger } from '../logger'
6372
import { addToHTMLProxyTransformResult } from './html'
@@ -167,6 +176,7 @@ const inlineRE = /[?&]inline\b/
167176
const inlineCSSRE = /[?&]inline-css\b/
168177
const styleAttrRE = /[?&]style-attr\b/
169178
const functionCallRE = /^[A-Z_][\w-]*\(/i
179+
const transformOnlyRE = /[?&]transform-only\b/
170180
const nonEscapedDoubleQuoteRe = /(?<!\\)(")/g
171181

172182
const cssBundleName = 'style.css'
@@ -220,10 +230,13 @@ function encodePublicUrlsInCSS(config: ResolvedConfig) {
220230
return config.command === 'build'
221231
}
222232

233+
const cssUrlAssetRE = /__VITE_CSS_URL__([\da-f]+)__/g
234+
223235
/**
224236
* Plugin applied before user plugins
225237
*/
226238
export function cssPlugin(config: ResolvedConfig): Plugin {
239+
const isBuild = config.command === 'build'
227240
let server: ViteDevServer
228241
let moduleCache: Map<string, Record<string, string>>
229242

@@ -253,6 +266,32 @@ export function cssPlugin(config: ResolvedConfig): Plugin {
253266
removedPureCssFilesCache.set(config, new Map<string, RenderedChunk>())
254267
},
255268

269+
async load(id) {
270+
if (!isCSSRequest(id)) return
271+
272+
if (urlRE.test(id)) {
273+
if (isModuleCSSRequest(id)) {
274+
throw new Error(
275+
`?url is not supported with CSS modules. (tried to import ${JSON.stringify(
276+
id,
277+
)})`,
278+
)
279+
}
280+
281+
// *.css?url
282+
// in dev, it's handled by assets plugin.
283+
if (isBuild) {
284+
id = injectQuery(removeUrlQuery(id), 'transform-only')
285+
return (
286+
`import ${JSON.stringify(id)};` +
287+
`export default "__VITE_CSS_URL__${Buffer.from(id).toString(
288+
'hex',
289+
)}__"`
290+
)
291+
}
292+
}
293+
},
294+
256295
async transform(raw, id, options) {
257296
if (
258297
!isCSSRequest(id) ||
@@ -374,8 +413,9 @@ export function cssPlugin(config: ResolvedConfig): Plugin {
374413
export function cssPostPlugin(config: ResolvedConfig): Plugin {
375414
// styles initialization in buildStart causes a styling loss in watch
376415
const styles: Map<string, string> = new Map<string, string>()
377-
// list of css emit tasks to guarantee the files are emitted in a deterministic order
378-
let emitTasks: Promise<void>[] = []
416+
// queue to emit css serially to guarantee the files are emitted in a deterministic order
417+
let codeSplitEmitQueue = createSerialPromiseQueue<string>()
418+
const urlEmitQueue = createSerialPromiseQueue<unknown>()
379419
let pureCssChunks: Set<RenderedChunk>
380420

381421
// when there are multiple rollup outputs and extracting CSS, only emit once,
@@ -414,7 +454,7 @@ export function cssPostPlugin(config: ResolvedConfig): Plugin {
414454
pureCssChunks = new Set<RenderedChunk>()
415455
hasEmitted = false
416456
chunkCSSMap = new Map()
417-
emitTasks = []
457+
codeSplitEmitQueue = createSerialPromiseQueue()
418458
},
419459

420460
async transform(css, id, options) {
@@ -530,10 +570,13 @@ export function cssPostPlugin(config: ResolvedConfig): Plugin {
530570
const ids = Object.keys(chunk.modules)
531571
for (const id of ids) {
532572
if (styles.has(id)) {
533-
chunkCSS += styles.get(id)
534-
// a css module contains JS, so it makes this not a pure css chunk
535-
if (cssModuleRE.test(id)) {
536-
isPureCssChunk = false
573+
// ?transform-only is used for ?url and shouldn't be included in normal CSS chunks
574+
if (!transformOnlyRE.test(id)) {
575+
chunkCSS += styles.get(id)
576+
// a css module contains JS, so it makes this not a pure css chunk
577+
if (cssModuleRE.test(id)) {
578+
isPureCssChunk = false
579+
}
537580
}
538581
} else {
539582
// 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 {
543586
}
544587
}
545588

546-
if (!chunkCSS) {
547-
return null
548-
}
549-
550589
const publicAssetUrlMap = publicAssetUrlCache.get(config)!
551590

552591
// resolve asset URL placeholders to their built file URLs
@@ -608,6 +647,98 @@ export function cssPostPlugin(config: ResolvedConfig): Plugin {
608647
)
609648
}
610649

650+
let s: MagicString | undefined
651+
const urlEmitTasks: Array<{
652+
cssAssetName: string
653+
originalFilename: string
654+
content: string
655+
start: number
656+
end: number
657+
}> = []
658+
659+
if (code.includes('__VITE_CSS_URL__')) {
660+
let match: RegExpExecArray | null
661+
cssUrlAssetRE.lastIndex = 0
662+
while ((match = cssUrlAssetRE.exec(code))) {
663+
const [full, idHex] = match
664+
const id = Buffer.from(idHex, 'hex').toString()
665+
const originalFilename = cleanUrl(id)
666+
const cssAssetName = ensureFileExt(
667+
path.basename(originalFilename),
668+
'.css',
669+
)
670+
if (!styles.has(id)) {
671+
throw new Error(
672+
`css content for ${JSON.stringify(id)} was not found`,
673+
)
674+
}
675+
676+
let cssContent = styles.get(id)!
677+
678+
cssContent = resolveAssetUrlsInCss(cssContent, cssAssetName)
679+
680+
urlEmitTasks.push({
681+
cssAssetName,
682+
originalFilename,
683+
content: cssContent,
684+
start: match.index,
685+
end: match.index + full.length,
686+
})
687+
}
688+
}
689+
690+
// should await even if this chunk does not include __VITE_CSS_URL__
691+
// so that code after this line runs in the same order
692+
await urlEmitQueue.run(async () =>
693+
Promise.all(
694+
urlEmitTasks.map(async (info) => {
695+
info.content = await finalizeCss(info.content, true, config)
696+
}),
697+
),
698+
)
699+
if (urlEmitTasks.length > 0) {
700+
const toRelativeRuntime = createToImportMetaURLBasedRelativeRuntime(
701+
opts.format,
702+
config.isWorker,
703+
)
704+
s ||= new MagicString(code)
705+
706+
for (const {
707+
cssAssetName,
708+
originalFilename,
709+
content,
710+
start,
711+
end,
712+
} of urlEmitTasks) {
713+
const referenceId = this.emitFile({
714+
name: cssAssetName,
715+
type: 'asset',
716+
source: content,
717+
})
718+
generatedAssets
719+
.get(config)!
720+
.set(referenceId, { originalName: originalFilename })
721+
722+
const replacement = toOutputFilePathInJS(
723+
this.getFileName(referenceId),
724+
'asset',
725+
chunk.fileName,
726+
'js',
727+
config,
728+
toRelativeRuntime,
729+
)
730+
const replacementString =
731+
typeof replacement === 'string'
732+
? JSON.stringify(replacement).slice(1, -1)
733+
: `"+${replacement.runtime}+"`
734+
s.update(start, end, replacementString)
735+
}
736+
}
737+
738+
if (!chunkCSS && !s) {
739+
return null
740+
}
741+
611742
if (config.build.cssCodeSplit) {
612743
if (opts.format === 'es' || opts.format === 'cjs') {
613744
if (isPureCssChunk) {
@@ -633,22 +764,11 @@ export function cssPostPlugin(config: ResolvedConfig): Plugin {
633764

634765
chunkCSS = resolveAssetUrlsInCss(chunkCSS, cssAssetName)
635766

636-
const previousTask = emitTasks[emitTasks.length - 1]
637-
// finalizeCss is async which makes `emitFile` non-deterministic, so
638-
// we use a `.then` to wait for previous tasks before finishing this
639-
const thisTask = finalizeCss(chunkCSS, true, config).then((css) => {
640-
chunkCSS = css
641-
// make sure the previous task is also finished, this works recursively
642-
return previousTask
767+
// wait for previous tasks as well
768+
chunkCSS = await codeSplitEmitQueue.run(async () => {
769+
return finalizeCss(chunkCSS, true, config)
643770
})
644771

645-
// push this task so the next task can wait for this one
646-
emitTasks.push(thisTask)
647-
const emitTasksLength = emitTasks.length
648-
649-
// wait for this and previous tasks to finish
650-
await thisTask
651-
652772
// emit corresponding css file
653773
const referenceId = this.emitFile({
654774
name: cssAssetName,
@@ -659,11 +779,6 @@ export function cssPostPlugin(config: ResolvedConfig): Plugin {
659779
.get(config)!
660780
.set(referenceId, { originalName: originalFilename, isEntry })
661781
chunk.viteMetadata!.importedCss.add(this.getFileName(referenceId))
662-
663-
if (emitTasksLength === emitTasks.length) {
664-
// this is the last task, clear `emitTasks` to free up memory
665-
emitTasks = []
666-
}
667782
} else if (!config.build.ssr) {
668783
// legacy build and inline css
669784

@@ -697,24 +812,27 @@ export function cssPostPlugin(config: ResolvedConfig): Plugin {
697812
const insertMark = "'use strict';"
698813
injectionPoint = code.indexOf(insertMark) + insertMark.length
699814
}
700-
const s = new MagicString(code)
815+
s ||= new MagicString(code)
701816
s.appendRight(injectionPoint, injectCode)
702-
if (config.build.sourcemap) {
703-
// resolve public URL from CSS paths, we need to use absolute paths
704-
return {
705-
code: s.toString(),
706-
map: s.generateMap({ hires: 'boundary' }),
707-
}
708-
} else {
709-
return { code: s.toString() }
710-
}
711817
}
712818
} else {
819+
// resolve public URL from CSS paths, we need to use absolute paths
713820
chunkCSS = resolveAssetUrlsInCss(chunkCSS, cssBundleName)
714821
// finalizeCss is called for the aggregated chunk in generateBundle
715822

716823
chunkCSSMap.set(chunk.fileName, chunkCSS)
717824
}
825+
826+
if (s) {
827+
if (config.build.sourcemap) {
828+
return {
829+
code: s.toString(),
830+
map: s.generateMap({ hires: 'boundary' }),
831+
}
832+
} else {
833+
return { code: s.toString() }
834+
}
835+
}
718836
return null
719837
},
720838

packages/vite/src/node/plugins/importAnalysis.ts

Lines changed: 1 addition & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -48,6 +48,7 @@ import {
4848
timeFrom,
4949
transformStableResult,
5050
unwrapId,
51+
urlRE,
5152
withTrailingSlash,
5253
wrapId,
5354
} from '../utils'
@@ -58,7 +59,6 @@ import type { ResolvedConfig } from '../config'
5859
import type { Plugin } from '../plugin'
5960
import { shouldExternalizeForSSR } from '../ssr/ssrExternal'
6061
import { getDepsOptimizer, optimizedDepNeedsInterop } from '../optimizer'
61-
import { urlRE } from './asset'
6262
import { throwOutdatedRequest } from './optimizedDeps'
6363
import { isCSSRequest, isDirectCSSRequest } from './css'
6464
import { browserExternalId } from './resolve'

packages/vite/src/node/server/middlewares/transform.ts

Lines changed: 1 addition & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -16,6 +16,7 @@ import {
1616
removeImportQuery,
1717
removeTimestampQuery,
1818
unwrapId,
19+
urlRE,
1920
withTrailingSlash,
2021
} from '../../utils'
2122
import { send } from '../send'
@@ -38,7 +39,6 @@ import {
3839
} from '../../plugins/optimizedDeps'
3940
import { ERR_CLOSED_SERVER } from '../pluginContainer'
4041
import { getDepsOptimizer } from '../../optimizer'
41-
import { urlRE } from '../../plugins/asset'
4242

4343
const debugCache = createDebugger('vite:cache')
4444

0 commit comments

Comments
 (0)