From f04c45f6cd9c754f596ec95ba3ea28f373617dda Mon Sep 17 00:00:00 2001 From: kazuya kawaguchi Date: Wed, 30 Sep 2026 17:21:11 +0900 Subject: [PATCH 1/7] chore(vite-dev-server): update optimizeDeps.include comment Port vitejs/vite#23489 (6a84c7210). --- packages/vite-dev-server/src/node/optimizer/index.ts | 4 ++-- 1 file changed, 2 insertions(+), 2 deletions(-) diff --git a/packages/vite-dev-server/src/node/optimizer/index.ts b/packages/vite-dev-server/src/node/optimizer/index.ts index 02b6e95..9bcf8b2 100644 --- a/packages/vite-dev-server/src/node/optimizer/index.ts +++ b/packages/vite-dev-server/src/node/optimizer/index.ts @@ -79,8 +79,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[] /** From 7efa2607bf95c2c7592a4b91745fb7cd465e7568 Mon Sep 17 00:00:00 2001 From: kazuya kawaguchi Date: Wed, 30 Sep 2026 17:21:45 +0900 Subject: [PATCH 2/7] fix(vite-dev-server): don't skip imports whose binding starts with type Port vitejs/vite#23540 (39330f489). --- .../src/node/optimizer/scan.test.ts | 55 ++++++++++++++++++- .../src/node/optimizer/scan.ts | 2 +- 2 files changed, 55 insertions(+), 2 deletions(-) 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 From 95a354a1c76372701f89dca975430adeac8b630e Mon Sep 17 00:00:00 2001 From: kazuya kawaguchi Date: Wed, 30 Sep 2026 17:22:46 +0900 Subject: [PATCH 3/7] chore(vite-dev-server): add debug log when waiting for dep before init Port vitejs/vite#23566 (63567c73a). --- .../src/node/optimizer/index.ts | 1 + .../src/node/optimizer/optimizer.ts | 19 ++++++++++--------- .../src/node/plugins/optimizedDeps.ts | 5 +++++ 3 files changed, 16 insertions(+), 9 deletions(-) diff --git a/packages/vite-dev-server/src/node/optimizer/index.ts b/packages/vite-dev-server/src/node/optimizer/index.ts index 9bcf8b2..d50b585 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 diff --git a/packages/vite-dev-server/src/node/optimizer/optimizer.ts b/packages/vite-dev-server/src/node/optimizer/optimizer.ts index 21f4238..78dbf9a 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), @@ -154,10 +155,9 @@ export function createDepsOptimizer( ]) } - 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 +283,7 @@ export function createDepsOptimizer( }) } } - initState = 'initialized' + depsOptimizer.initState = 'initialized' } function startNextDiscoveredBatch() { @@ -582,7 +582,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 +752,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 +774,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/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 From e360b42502956c77a29737fd244c16f306f74596 Mon Sep 17 00:00:00 2001 From: kazuya kawaguchi Date: Wed, 30 Sep 2026 17:23:59 +0900 Subject: [PATCH 4/7] fix(vite-dev-server): resolve pending discovered dep processing on close before init Port vitejs/vite#23567 (5f894339d). --- .../src/node/optimizer/optimizer.test.ts | 22 +++++++++++++++++++ .../src/node/optimizer/optimizer.ts | 12 ++++++---- 2 files changed, 30 insertions(+), 4 deletions(-) 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 78dbf9a..28d3d38 100644 --- a/packages/vite-dev-server/src/node/optimizer/optimizer.ts +++ b/packages/vite-dev-server/src/node/optimizer/optimizer.ts @@ -148,11 +148,19 @@ 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() } async function init() { @@ -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 } From 4c6a058d22cecfdcbeb7b54e62546e9665901f47 Mon Sep 17 00:00:00 2001 From: kazuya kawaguchi Date: Wed, 30 Sep 2026 17:25:05 +0900 Subject: [PATCH 5/7] fix(vite-dev-server): preserve excluded optional peer require fallbacks Port vitejs/vite#23600 (a2bd6fa89). --- .../src/node/optimizer/index.ts | 2 +- .../node/optimizer/rolldownDepPlugin.test.ts | 59 ++++++++++++++++++- .../src/node/optimizer/rolldownDepPlugin.ts | 21 ++++++- 3 files changed, 79 insertions(+), 3 deletions(-) diff --git a/packages/vite-dev-server/src/node/optimizer/index.ts b/packages/vite-dev-server/src/node/optimizer/index.ts index d50b585..d83e711 100644 --- a/packages/vite-dev-server/src/node/optimizer/index.ts +++ b/packages/vite-dev-server/src/node/optimizer/index.ts @@ -830,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/rolldownDepPlugin.test.ts b/packages/vite-dev-server/src/node/optimizer/rolldownDepPlugin.test.ts index 5754b41..6cab442 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,60 @@ describe('rolldownDepPlugin asset entrypoints', () => { }) }) +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..4cebabf 100644 --- a/packages/vite-dev-server/src/node/optimizer/rolldownDepPlugin.ts +++ b/packages/vite-dev-server/src/node/optimizer/rolldownDepPlugin.ts @@ -384,6 +384,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 +402,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 +420,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, } From d713c2ce340565342ef2cd0be32c38e14e64b7f3 Mon Sep 17 00:00:00 2001 From: kazuya kawaguchi Date: Wed, 30 Sep 2026 17:29:55 +0900 Subject: [PATCH 6/7] fix(vite-dev-server): pass queries to renderBuiltUrl Port vitejs/vite#23586 (744269e5b). The html.ts part is not ported because buildHtmlPlugin does not exist in this fork. Update the asset URL tests for the new placeholder format, where the postfix is carried in URL metadata instead of trailing the token. --- .../src/node/plugins/asset.test.ts | 86 +++++++++++++++---- .../vite-dev-server/src/node/plugins/asset.ts | 58 ++++++++++--- .../vite-dev-server/src/node/plugins/css.ts | 7 +- 3 files changed, 116 insertions(+), 35 deletions(-) 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( From 2e79d8a9edd8c525b6e8a8bfee397230318643b6 Mon Sep 17 00:00:00 2001 From: kazuya kawaguchi Date: Wed, 30 Sep 2026 17:30:57 +0900 Subject: [PATCH 7/7] fix(vite-dev-server): avoid unsupported warnings for browser:false mappings Port vitejs/vite#23590 (5e4b9ca3d). --- .../node/optimizer/rolldownDepPlugin.test.ts | 36 +++++++++++++++++++ .../src/node/optimizer/rolldownDepPlugin.ts | 11 +++++- 2 files changed, 46 insertions(+), 1 deletion(-) 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 6cab442..c91ff99 100644 --- a/packages/vite-dev-server/src/node/optimizer/rolldownDepPlugin.test.ts +++ b/packages/vite-dev-server/src/node/optimizer/rolldownDepPlugin.test.ts @@ -160,6 +160,42 @@ 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' diff --git a/packages/vite-dev-server/src/node/optimizer/rolldownDepPlugin.ts b/packages/vite-dev-server/src/node/optimizer/rolldownDepPlugin.ts index 4cebabf..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) {