diff --git a/packages/vite-dev-server/src/node/optimizer/index.ts b/packages/vite-dev-server/src/node/optimizer/index.ts index 02b6e95..d83e711 100644 --- a/packages/vite-dev-server/src/node/optimizer/index.ts +++ b/packages/vite-dev-server/src/node/optimizer/index.ts @@ -62,6 +62,7 @@ export type ExportsData = { export interface DepsOptimizer { init: () => Promise + initState: 'idle' | 'initializing' | 'initialized' metadata: DepOptimizationMetadata scanProcessing?: Promise @@ -79,8 +80,8 @@ export interface DepsOptimizer { export interface DepOptimizationConfig { /** - * Force optimize listed dependencies (must be resolvable import paths, - * cannot be globs). + * Force optimize listed dependencies (must be resolvable import paths). + * Supports experimental glob patterns for deep imports. */ include?: string[] /** @@ -829,7 +830,7 @@ async function prepareRolldownOptimizerRun( const plugins = await asyncFlatten(arraify(pluginsFromConfig)) if (external.length) { - plugins.push(rolldownCjsExternalPlugin(external, platform)) + plugins.push(rolldownCjsExternalPlugin(external, platform, environment)) } plugins.push(...rolldownDepPlugin(environment, flatIdDeps, external)) diff --git a/packages/vite-dev-server/src/node/optimizer/optimizer.test.ts b/packages/vite-dev-server/src/node/optimizer/optimizer.test.ts index 3de4373..6120c19 100644 --- a/packages/vite-dev-server/src/node/optimizer/optimizer.test.ts +++ b/packages/vite-dev-server/src/node/optimizer/optimizer.test.ts @@ -143,6 +143,28 @@ describe('createDepsOptimizer initialization', () => { await optimizer.close() }) + // regression test for https://github.com/vitejs/vite/issues/23143 + it('resolves discovered dep processing promise on close before init', async () => { + const optimizer = createDepsOptimizer(createEnvironment()) + const info = optimizer.registerMissingImport( + 'example', + '/node_modules/example/index.js', + ) + let resolved = false + void info.processing?.then(() => { + resolved = true + }) + + // The processing promise stays pending because init() has not run. + await vi.advanceTimersByTimeAsync(500) + expect(resolved).toBe(false) + + // close() resolves it so requests waiting on the dep can unblock. + await optimizer.close() + await vi.advanceTimersByTimeAsync(0) + expect(resolved).toBe(true) + }) + it('does not schedule an optimizer run while initializing', async () => { let resolveCachedMetadata: | ((metadata: DepOptimizationMetadata) => void) diff --git a/packages/vite-dev-server/src/node/optimizer/optimizer.ts b/packages/vite-dev-server/src/node/optimizer/optimizer.ts index 21f4238..28d3d38 100644 --- a/packages/vite-dev-server/src/node/optimizer/optimizer.ts +++ b/packages/vite-dev-server/src/node/optimizer/optimizer.ts @@ -58,6 +58,7 @@ export function createDepsOptimizer( const depsOptimizer: DepsOptimizer = { init, + initState: 'idle', metadata, registerMissingImport, run: () => debouncedProcessing(0), @@ -147,17 +148,24 @@ export function createDepsOptimizer( async function close() { closed = true + + // Ensure that a rerun will not be issued + if (debounceProcessingHandle) { clearTimeout(debounceProcessingHandle) } + debounceProcessingHandle = undefined + await Promise.allSettled([ discover?.cancel(), depsOptimizer.scanProcessing, optimizationResult?.cancel(), ]) + + depOptimizationProcessing.resolve() + resolveEnqueuedProcessingPromises() } - let initState: 'idle' | 'initializing' | 'initialized' = 'idle' async function init() { - if (initState !== 'idle') { return } - initState = 'initializing' + if (depsOptimizer.initState !== 'idle') { return } + depsOptimizer.initState = 'initializing' const cachedMetadata = await loadCachedDepOptimizationMetadata(environment) @@ -283,7 +291,7 @@ export function createDepsOptimizer( }) } } - initState = 'initialized' + depsOptimizer.initState = 'initialized' } function startNextDiscoveredBatch() { @@ -337,11 +345,8 @@ export function createDepsOptimizer( if (closed) { currentlyProcessing = false - depOptimizationProcessing.resolve() - resolveEnqueuedProcessingPromises() return } - currentlyProcessing = true try { @@ -360,7 +365,6 @@ export function createDepsOptimizer( if (closed) { currentlyProcessing = false processingResult.cancel() - resolveEnqueuedProcessingPromises() return } @@ -582,7 +586,7 @@ export function createDepsOptimizer( // A module can be transformed between `createServer()` and `server.listen()`, // which discovers a dep before `init()` runs. Starting a run here would race // with `init()` resetting the metadata and crash in `commitProcessing`. - if (initState === 'initialized' && !waitingForCrawlEnd) { + if (depsOptimizer.initState === 'initialized' && !waitingForCrawlEnd) { // Debounced rerun, let other missing dependencies be discovered before // the running next optimizeDeps debouncedProcessing() @@ -752,8 +756,9 @@ export function createDepsOptimizer( export function createExplicitDepsOptimizer( environment: DevEnvironment, ): DepsOptimizer { - const depsOptimizer = { + const depsOptimizer: DepsOptimizer = { metadata: initDepsOptimizerMetadata(environment), + initState: 'idle', isOptimizedDepFile: createIsOptimizedDepFile(environment), isOptimizedDepUrl: createIsOptimizedDepUrl(environment), getOptimizedDepId: (depInfo: OptimizedDepInfo) => @@ -773,12 +778,12 @@ export function createExplicitDepsOptimizer( options: environment.config.optimizeDeps, } - let inited = false async function init() { - if (inited) { return } - inited = true + if (depsOptimizer.initState !== 'idle') { return } + depsOptimizer.initState = 'initializing' depsOptimizer.metadata = await optimizeExplicitEnvironmentDeps(environment) + depsOptimizer.initState = 'initialized' } return depsOptimizer diff --git a/packages/vite-dev-server/src/node/optimizer/rolldownDepPlugin.test.ts b/packages/vite-dev-server/src/node/optimizer/rolldownDepPlugin.test.ts index 5754b41..c91ff99 100644 --- a/packages/vite-dev-server/src/node/optimizer/rolldownDepPlugin.test.ts +++ b/packages/vite-dev-server/src/node/optimizer/rolldownDepPlugin.test.ts @@ -22,7 +22,10 @@ vi.mock('../plugins/resolve', () => ({ })) import type { Environment } from '../environment' -import { rolldownDepPlugin } from './rolldownDepPlugin' +import { + rolldownCjsExternalPlugin, + rolldownDepPlugin, +} from './rolldownDepPlugin' type ResolveIdHandler = ( this: PluginContext, @@ -157,6 +160,96 @@ describe('rolldownDepPlugin asset entrypoints', () => { }) }) +describe('rolldownDepPlugin browser externals', () => { + const importer = '/project/node_modules/dep/index.js' + + test('treats an exact browser-external id as an explicit browser:false mapping', async () => { + const { depPlugin } = createPlugins('__vite-browser-external') + + expect( + await getResolveIdHandler(depPlugin).call(context, 'shimmed', importer, { + isEntry: false, + kind: 'import-statement', + }), + ).toEqual({ id: '__vite-browser-external' }) + expect( + await getLoadHandler(depPlugin).call(context, '__vite-browser-external'), + ).toEqual({ code: 'module.exports = {}' }) + }) + + test('keeps the warning proxy for unsupported Node builtins', async () => { + const { depPlugin } = createPlugins('__vite-browser-external:fs') + + expect( + await getResolveIdHandler(depPlugin).call(context, 'fs', importer, { + isEntry: false, + kind: 'import-statement', + }), + ).toEqual({ id: 'browser-external:fs' }) + expect( + await getLoadHandler(depPlugin).call(context, 'browser-external:fs'), + ).toMatchObject({ + code: expect.stringContaining( + 'has been externalized for browser compatibility', + ), + }) + }) +}) + +describe('rolldownCjsExternalPlugin', () => { + const importer = '/project/node_modules/dep/index.js' + + function createCjsExternalPlugin(resolve: () => Promise) { + const config = { + createResolver: () => vi.fn<() => Promise>(resolve), + } + const environment = { + name: 'client', + config, + getTopLevelConfig: () => config, + } as unknown as Environment + const plugin = rolldownCjsExternalPlugin( + ['optional-peer', 'excluded'], + 'browser', + environment, + ) + if (!plugin) { + throw new Error('Could not create the cjs-external plugin') + } + return plugin + } + + test('keeps a missing optional peer require on the pre-bundler CJS stub', async () => { + const resolved = '__vite-optional-peer-dep:optional-peer:dep' + const plugin = createCjsExternalPlugin(async () => resolved) + + expect( + await getResolveIdHandler(plugin).call(context, 'optional-peer', importer, { + isEntry: false, + kind: 'require-call', + }), + ).toEqual({ id: 'optional-peer-dep:' + resolved }) + }) + + test('converts other excluded requires to ESM facades', async () => { + const resolvedPlugin = createCjsExternalPlugin( + async () => '/project/node_modules/excluded/index.js', + ) + const unresolvablePlugin = createCjsExternalPlugin(() => + Promise.reject(new Error('not resolvable until served')), + ) + + for (const plugin of [resolvedPlugin, unresolvablePlugin]) { + expect( + await getResolveIdHandler(plugin).call(context, 'excluded', importer, { + isEntry: false, + kind: 'require-call', + }), + ).toEqual({ id: 'vite:cjs-external-facadeexcluded' }) + } + }) +}) + describe('rolldownDepPlugin asset URL transform filter', () => { const resolved = '/project/node_modules/pkg/index.js' const id = '/project/node_modules/pkg/index.js' diff --git a/packages/vite-dev-server/src/node/optimizer/rolldownDepPlugin.ts b/packages/vite-dev-server/src/node/optimizer/rolldownDepPlugin.ts index 93aacdf..0791bbf 100644 --- a/packages/vite-dev-server/src/node/optimizer/rolldownDepPlugin.ts +++ b/packages/vite-dev-server/src/node/optimizer/rolldownDepPlugin.ts @@ -1,6 +1,6 @@ import path from 'node:path' import type { ImportKind, Plugin, RolldownPlugin } from 'rolldown' -import { prefixRegex } from 'rolldown/filter' +import { exactRegex, prefixRegex } from 'rolldown/filter' import MagicString from 'magic-string' import { stripLiteral } from 'strip-literal' import { JS_TYPES_RE, KNOWN_ASSET_TYPES } from '../constants' @@ -125,6 +125,11 @@ export function rolldownDepPlugin( } const resolveResult = (id: string, resolved: string, kind: ImportKind) => { + // An exact browser-external id is an explicit browser:false mapping. + // Suffixed ids are unsupported Node builtins and still need the warning. + if (resolved === browserExternalId) { + return { id: browserExternalId } + } if (resolved.startsWith(browserExternalId)) { return { id: browserExternalNamespace + id, @@ -260,11 +265,15 @@ export function rolldownDepPlugin( load: { filter: { id: [ + exactRegex(browserExternalId), prefixRegex(browserExternalNamespace), prefixRegex(optionalPeerDepNamespace), ], }, handler(id) { + if (id === browserExternalId) { + return { code: 'module.exports = {}' } + } if (id.startsWith(browserExternalNamespace)) { const path = id.slice(browserExternalNamespace.length) if (isProduction) { @@ -384,6 +393,7 @@ const matchesEntireLine = (text: string) => `^${escapeRegex(text)}$` export function rolldownCjsExternalPlugin( externals: string[], platform: 'node' | 'browser' | 'neutral', + environment: Environment, ): Plugin | undefined { // Skip this plugin for `platform: 'node'` as `require` is available in Node // and that is more accurate than converting to `import` @@ -401,11 +411,17 @@ export function rolldownCjsExternalPlugin( const filter = new RegExp(externals.map(matchesEntireLine).join('|')) + const packageCache: PackageCache = new Map() + const resolveRequire = createBackCompatIdResolver( + environment.getTopLevelConfig(), + { asSrc: false, isRequire: true, scan: true, packageCache }, + ) + return { name: 'cjs-external', resolveId: { filter: { id: [prefixRegex(nonFacadePrefix), filter] }, - handler(id, _importer, options) { + async handler(id, importer, options) { if (id.startsWith(nonFacadePrefix)) { return { id: id.slice(nonFacadePrefix.length), @@ -413,6 +429,18 @@ export function rolldownCjsExternalPlugin( } } if (options.kind === 'require-call') { + // A missing optional peer must throw when require() runs, not when + // an eager ESM facade is evaluated. Reuse the pre-bundler's CJS stub + // so the dependency's try/catch can still select its fallback. + // Other excluded deps may intentionally be unresolvable until served. + const resolved = await resolveRequire( + environment, + id, + importer, + ).catch(() => undefined) + if (resolved?.startsWith(optionalPeerDepId)) { + return { id: optionalPeerDepNamespace + resolved } + } return { id: cjsExternalFacadeNamespace + id, } diff --git a/packages/vite-dev-server/src/node/optimizer/scan.test.ts b/packages/vite-dev-server/src/node/optimizer/scan.test.ts index dad261f..8c8f830 100644 --- a/packages/vite-dev-server/src/node/optimizer/scan.test.ts +++ b/packages/vite-dev-server/src/node/optimizer/scan.test.ts @@ -22,7 +22,7 @@ vi.mock('../plugins/oxc', () => ({ })) import { createEnvironmentPluginContainer } from '../server/pluginContainer' -import { type ScanEnvironment, scanImports } from './scan' +import { importsRE, type ScanEnvironment, scanImports } from './scan' let root: string @@ -199,3 +199,56 @@ describe('scanImports', () => { } }) }) + +describe('importsRE', () => { + it('imports regex should work', () => { + const shouldMatchArray: [code: string, expected: string][] = [ + [`import 'vue'`, `'vue'`], + [`import { foo } from 'vue'`, `'vue'`], + [`import foo from 'vue'`, `'vue'`], + [`;import foo from 'vue'`, `'vue'`], + [` import foo from 'vue'`, `'vue'`], + [ + `import { foo + } from 'vue'`, + `'vue'`, + ], + [`import bar, { foo } from 'vue'`, `'vue'`], + [`import foo from 'vue';`, `'vue'`], + [`*/ import foo from 'vue';`, `'vue'`], + [`import foo from 'vue';//comment`, `'vue'`], + [ + `import foo from 'vue';/*comment + */`, + `'vue'`, + ], + // https://github.com/vitejs/vite/issues/23471 + // bindings starting with "type" should not be treated as type-only imports + [`import typescript from 'typescript'`, `'typescript'`], + [`import typeorm from 'typeorm'`, `'typeorm'`], + [`import types from 'types'`, `'types'`], + // still a known false negative: a default binding literally named `type` + // (`import type, {foo} from 'vue'`, `import type from 'vue'`) is a valid + // value import, but the word boundary cannot tell it apart from the + // `import type` modifier. Missed deps are discovered again at runtime. + ] + + shouldMatchArray.forEach(([str, expected]) => { + importsRE.lastIndex = 0 + expect(importsRE.exec(str)![1]).toEqual(expected) + }) + + const shouldFailArray = [ + `testMultiline("import", { + body: "ok" });`, + `//;import foo from 'vue'`, + `import type { Bar } from 'foo'`, + `import type{ Bar } from 'foo'`, + `import type Bar from 'foo'`, + ] + shouldFailArray.forEach((str) => { + importsRE.lastIndex = 0 + expect(importsRE.test(str)).toBe(false) + }) + }) +}) diff --git a/packages/vite-dev-server/src/node/optimizer/scan.ts b/packages/vite-dev-server/src/node/optimizer/scan.ts index f1023b4..c785747 100644 --- a/packages/vite-dev-server/src/node/optimizer/scan.ts +++ b/packages/vite-dev-server/src/node/optimizer/scan.ts @@ -118,7 +118,7 @@ const htmlTypesRE = /\.(?:html|vue|svelte|astro|imba)$/ // since even missed imports can be caught at runtime, and false positives will // simply be ignored. export const importsRE: RegExp = - /(? Promise diff --git a/packages/vite-dev-server/src/node/plugins/asset.test.ts b/packages/vite-dev-server/src/node/plugins/asset.test.ts index b8bea7f..a1afa51 100644 --- a/packages/vite-dev-server/src/node/plugins/asset.test.ts +++ b/packages/vite-dev-server/src/node/plugins/asset.test.ts @@ -24,8 +24,9 @@ import type { InlineConfig } from '../config' import { ROLLUP_HOOKS } from '../constants' import { DevEnvironment } from '../server/environment' import { EnvironmentModuleNode } from '../server/moduleGraph' +import { injectQuery } from '../utils' import { getHookHandler } from './index' -import { assetPlugin, fileToUrl, renderAssetUrlInJS, urlToBuiltUrl } from './asset' +import { assetPlugin, fileToUrl, getAssetUrlPostfix, renderAssetUrlInJS, urlToBuiltUrl } from './asset' import { cssPlugin, cssPostPlugin } from './css' let root: string @@ -33,6 +34,11 @@ let assetId: string const assetContent = 'asset contents' const outputName = 'assets/asset file.png' const postfix = '?v=1#icon' +// Rolldown reference IDs are 22-character base64url strings with `-` replaced by `$` +const referenceId = 'asset$ref'.padEnd(22, '0') +const escapedReferenceId = referenceId.replace('$', '\\$') +const assetTokenRE = new RegExp(`^__VITE_ASSET__${escapedReferenceId}_([a-f\\d]{24})__$`) +const fileUrlExpressionRE = new RegExp(`import\\.meta\\.ROLLDOWN_FILE_URL_${escapedReferenceId}_([a-f\\d]{24})$`) beforeAll(async () => { vi.stubGlobal('__VROWZER_SERVICE_WORKER__', false) @@ -64,7 +70,7 @@ async function setup(base = '/preview/', command: 'build' | 'serve' = 'build', o const plugin = assetPlugin(config) const context = { environment, - emitFile: vi.fn(() => 'asset$ref'), + emitFile: vi.fn(() => referenceId), getFileName: vi.fn(() => outputName), getModuleInfo: vi.fn(() => null), addWatchFile: vi.fn(), @@ -86,19 +92,26 @@ function createChunk(modules: RenderedChunk['modules'] = {}): RenderedChunk { } describe('asset URL formats', () => { - test('shares emitted assets across JS and text consumers and keeps the postfix outside the token', async () => { - const { plugin, context, ctx } = await setup() + test('shares emitted assets across JS and text consumers and carries the postfix in URL metadata', async () => { + const { plugin, environment, context, ctx } = await setup() const id = `${assetId}?no-inline&v=1#icon` - expect(await fileToUrl(ctx, id, 'string')).toBe(`__VITE_ASSET__asset$ref__${postfix}`) - expect(await fileToUrl(ctx, id, 'js')).toBe(`import.meta.ROLLDOWN_FILE_URL_asset$ref + "${postfix}"`) - expect(await urlToBuiltUrl(ctx, './asset file.png?no-inline&v=1#icon', path.join(root, 'main.js'))) - .toBe(`__VITE_ASSET__asset$ref__${postfix}`) + const tokenUrlId = (await fileToUrl(ctx, id, 'string')).match(assetTokenRE)?.[1] + expect(tokenUrlId).toBeDefined() + expect(getAssetUrlPostfix(environment, tokenUrlId)).toBe(postfix) + const expressionUrlId = (await fileToUrl(ctx, id, 'js')).match(fileUrlExpressionRE)?.[1] + expect(expressionUrlId).toBeDefined() + expect(getAssetUrlPostfix(environment, expressionUrlId)).toBe(postfix) + const builtUrlId = (await urlToBuiltUrl(ctx, './asset file.png?no-inline&v=1#icon', path.join(root, 'main.js'))) + .match(assetTokenRE)?.[1] + expect(getAssetUrlPostfix(environment, builtUrlId)).toBe(postfix) expect(context.emitFile).toHaveBeenCalledOnce() expect(context.emitFile).toHaveBeenCalledWith(expect.objectContaining({ type: 'asset', name: 'asset file.png', originalFileName: 'asset file.png', })) const loaded = await getHookHandler(plugin.load!).call(ctx, `${assetId}?url&no-inline&v=1#icon`) - expect(loaded).toMatchObject({ code: `export default import.meta.ROLLDOWN_FILE_URL_asset$ref + "${postfix}"` }) + expect(loaded).toMatchObject({ + code: expect.stringMatching(new RegExp(`^export default ${fileUrlExpressionRE.source}`)), + }) await getHookHandler(plugin.watchChange!).call(ctx, id, { event: 'update' }) await fileToUrl(ctx, id, 'string') expect(context.emitFile).toHaveBeenCalledTimes(2) @@ -157,17 +170,19 @@ describe('asset URL formats', () => { expect(context.emitFile).not.toHaveBeenCalled() }) - test('uses per-reference metadata for file URLs without changing ordinary URLs', async () => { + test('uses per-reference metadata for file URLs and postfixes without changing ordinary URLs', async () => { const { plugin, ctx } = await setup() - const id = `${assetId}?no-inline&v=1#icon` - const expression = await fileToUrl(ctx, id, 'js', true) - const urlId = expression.match(/ROLLDOWN_FILE_URL_asset\$ref_([a-f\d]{24})/)?.[1] - expect(urlId).toBeDefined() + // File URLs are requested for cleaned ids (SSR wasm upstream), so they carry no postfix. + const fileUrlId = (await fileToUrl(ctx, `${assetId}?no-inline`, 'js', true)).match(fileUrlExpressionRE)?.[1] + const postfixUrlId = (await fileToUrl(ctx, `${assetId}?no-inline&v=1#icon`, 'js')).match(fileUrlExpressionRE)?.[1] + expect(fileUrlId).toBeDefined() + expect(postfixUrlId).toBeDefined() + expect(postfixUrlId).not.toBe(fileUrlId) const resolve = getHookHandler(plugin.resolveFileUrl!) - const args = { fileName: outputName, chunkId: 'assets/main.js', format: 'es' as const, referenceId: 'asset$ref', relativePath: 'asset file.png', moduleId: assetId } - expect(resolve.call(ctx, { ...args, urlId })).toBe("new URL('asset file.png', import.meta.url).href") + const args = { fileName: outputName, chunkId: 'assets/main.js', format: 'es' as const, referenceId, relativePath: 'asset file.png', moduleId: assetId } + expect(resolve.call(ctx, { ...args, urlId: fileUrlId })).toBe("new URL('asset file.png', import.meta.url).href") + expect(resolve.call(ctx, { ...args, urlId: postfixUrlId })).toBe(`"/preview/assets/asset%20file.png${postfix}"`) expect(resolve.call(ctx, args)).toBe('"/preview/assets/asset%20file.png"') - expect(expression).toContain(` + "${postfix}"`) expect(ROLLUP_HOOKS).toContain('resolveFileUrl') }) }) @@ -183,8 +198,8 @@ describe('asset text consumers', () => { expect(result).not.toContain('__VITE_') expect(result).toContain(postfix) expect(result).toContain(base === './' - ? "new URL('asset file.png', import.meta.url).href" - : '/preview/assets/asset%20file.png') + ? `new URL('asset file.png${postfix}', import.meta.url).href` + : `/preview/assets/asset%20file.png${postfix}`) expect(result).toContain(base === './' ? "new URL('../public.png?v=1#icon', import.meta.url).href" : `/preview/public.png${postfix}`) @@ -218,6 +233,39 @@ describe('asset text consumers', () => { }) }) +describe('asset renderBuiltUrl', () => { + test('receives asset postfixes in JS and CSS', async () => { + const renderBuiltUrl = vi.fn<(filename: string) => string>(filename => injectQuery(filename, 'dpl=id')) + const { config, ctx, context } = await setup('/preview/', 'build', { experimental: { renderBuiltUrl } }) + const valueToken = await fileToUrl(ctx, `${assetId}?no-inline&marker=value`, 'string') + const otherToken = await fileToUrl(ctx, `${assetId}?no-inline&marker=other`, 'string') + + const js = renderAssetUrlInJS(ctx, createChunk(), { format: 'es' } as NormalizedOutputOptions, + `console.log(${JSON.stringify(valueToken)}, ${JSON.stringify(otherToken)})`)!.toString() + expect(js).toContain('?dpl=id&marker=value') + expect(js).toContain('?dpl=id&marker=other') + + const css = cssPlugin(config) + const post = cssPostPlugin(config) + await getHookHandler(css.buildStart!).call(ctx, {} as never) + try { + await getHookHandler(post.renderStart!).call(ctx, {} as never, {} as never) + const id = path.join(root, 'style.css') + await getHookHandler(post.transform!).call(ctx as never, + `.asset-a{background:url(${valueToken})}.asset-b{background:url(${otherToken})}`, id) + const chunk = createChunk({ [id]: {} as never }) + await getHookHandler(post.renderChunk!).call(ctx, 'export default 1', chunk, + { format: 'es' } as NormalizedOutputOptions, { chunks: { [chunk.fileName]: chunk } }) + const emitted = context.emitFile.mock.calls.map(([file]) => file) + .find(file => file.type === 'asset' && file.name === 'main.css') + expect(emitted?.source).toContain('?dpl=id&marker=value') + expect(emitted?.source).toContain('?dpl=id&marker=other') + } finally { + await getHookHandler(css.buildEnd!).call(ctx) + } + }) +}) + describe('asset plugin with native Rolldown', () => { test.each(['/preview/', '/encoded%20base/', './'])('resolves emitted asset and chunk hashes with base %s', async base => { const { plugin, environment } = await setup(base) diff --git a/packages/vite-dev-server/src/node/plugins/asset.ts b/packages/vite-dev-server/src/node/plugins/asset.ts index 73c33fd..4d85972 100644 --- a/packages/vite-dev-server/src/node/plugins/asset.ts +++ b/packages/vite-dev-server/src/node/plugins/asset.ts @@ -43,8 +43,9 @@ import { import type { Environment } from '../environment' import type { PartialEnvironment } from '../baseEnvironment' -// referenceId is base64url but replaces - with $ -export const assetUrlRE: RegExp = /__VITE_ASSET__([\w$]+)__/g +// referenceId is a 22-character base64url string but replaces - with $ +export const assetUrlRE: RegExp = + /__VITE_ASSET__([\w$]{22})(?:_([a-f\d]{24}))?__/g const encodedHashPlaceholderRE = /!~%7B([\w$]{1,17})%7D~/g function unescapeHashPlaceholders(uri: string): string { @@ -53,6 +54,7 @@ function unescapeHashPlaceholders(uri: string): string { interface FileUrlMetadata { asFileUrl: boolean + postfix: string } const fileUrlMetadata = new WeakMap>() @@ -138,12 +140,12 @@ export function renderAssetUrlInJS( assetUrlRE.lastIndex = 0 while ((match = assetUrlRE.exec(code))) { s ||= new MagicString(code) - const [full, referenceId] = match + const [full, referenceId, urlId] = match const file = pluginContext.getFileName(referenceId) chunk.viteMetadata!.importedAssets.add(cleanUrl(file)) const replacement = toOutputFilePathInJS( environment, - file, + file + getAssetUrlPostfix(environment, urlId), 'asset', chunk.fileName, 'js', @@ -283,7 +285,11 @@ export function assetPlugin(config: ResolvedConfig): Plugin { } return { - code: `export default ${formatBuiltAsset(resolved, 'js')}`, + code: `export default ${formatBuiltAsset( + resolved, + 'js', + addFileUrlMetadataForAsset(this.environment, resolved, 'js'), + )}`, // Force rollup to keep this module from being shared between other entry points if it's an entrypoint. // If the resulting chunk is empty, it will be removed in generateBundle. moduleSideEffects: @@ -325,7 +331,7 @@ export function assetPlugin(config: ResolvedConfig): Plugin { } const replacement = toOutputFilePathInJS( environment, - fileName, + fileName + (metadata?.postfix ?? ''), 'asset', chunkId, 'js', @@ -547,13 +553,30 @@ async function fileToBuiltUrl( skipPublicCheck, forceInline, ) - const urlId = - resolved.type === 'reference' && format === 'js' && asFileUrl - ? addFileUrlMetadata(pluginContext.environment, { asFileUrl }) - : undefined + const urlId = addFileUrlMetadataForAsset( + pluginContext.environment, + resolved, + format, + asFileUrl, + ) return formatBuiltAsset(resolved, format, urlId) } +function addFileUrlMetadataForAsset( + environment: Environment, + resolved: FileToBuiltUrlResult, + format: AssetUrlFormat, + useFileUrl = false, +): string | undefined { + if (resolved.type !== 'reference') { return } + + const asFileUrl = format === 'js' && useFileUrl + const { postfix } = resolved + if (!asFileUrl && !postfix) { return } + + return addFileUrlMetadata(environment, { asFileUrl, postfix }) +} + function addFileUrlMetadata( environment: Environment, metadata: FileUrlMetadata, @@ -573,6 +596,15 @@ function addFileUrlMetadata( return urlId } +export function getAssetUrlPostfix( + environment: Environment, + urlId: string | undefined, +): string { + return urlId + ? (fileUrlMetadata.get(environment)?.get(urlId)?.postfix ?? '') + : '' +} + /** Format a resolved asset as either a JS expression or a plain-text string. */ function formatBuiltAsset( resolved: FileToBuiltUrlResult, @@ -584,11 +616,9 @@ function formatBuiltAsset( const base = urlId ? `import.meta.ROLLDOWN_FILE_URL_${resolved.referenceId}_${urlId}` : `import.meta.ROLLDOWN_FILE_URL_${resolved.referenceId}` - return resolved.postfix - ? `${base} + ${JSON.stringify(resolved.postfix)}` - : base + return base } - return `__VITE_ASSET__${resolved.referenceId}__${resolved.postfix}` + return `__VITE_ASSET__${resolved.referenceId}${urlId ? `_${urlId}` : ''}__` } return format === 'js' ? JSON.stringify(encodeURIPath(resolved.value)) diff --git a/packages/vite-dev-server/src/node/plugins/css.ts b/packages/vite-dev-server/src/node/plugins/css.ts index 85abdcc..f1a8575 100644 --- a/packages/vite-dev-server/src/node/plugins/css.ts +++ b/packages/vite-dev-server/src/node/plugins/css.ts @@ -93,6 +93,7 @@ import { assetUrlRE, cssEntriesMap, fileToUrl, + getAssetUrlPostfix, publicAssetUrlCache, publicAssetUrlRE, publicFileToBuiltUrl, @@ -724,8 +725,10 @@ export function cssPostPlugin(config: ResolvedConfig): Plugin { } // replace asset url references with resolved url. - chunkCSS = chunkCSS.replace(assetUrlRE, (_, fileHash) => { - const filename = this.getFileName(fileHash) + chunkCSS = chunkCSS.replace(assetUrlRE, (_, fileHash, urlId) => { + const filename = + this.getFileName(fileHash) + + getAssetUrlPostfix(this.environment, urlId) chunk.viteMetadata!.importedAssets.add(cleanUrl(filename)) return encodeURIPath( toOutputFilePathInCss( diff --git a/packages/vite-dev-server/src/node/plugins/optimizedDeps.ts b/packages/vite-dev-server/src/node/plugins/optimizedDeps.ts index 972e93f..8317b92 100644 --- a/packages/vite-dev-server/src/node/plugins/optimizedDeps.ts +++ b/packages/vite-dev-server/src/node/plugins/optimizedDeps.ts @@ -62,6 +62,11 @@ export function optimizedDepsPlugin(): Plugin { } try { // This is an entry point, it may still not be bundled + if (info.processing && depsOptimizer.initState === 'idle') { + debug?.( + `transforming ${colors.cyan(file)} is waiting for the dependency optimizer to initialize`, + ) + } await info.processing } catch { // If the refresh has not happened after timeout, Vite considers