From 47071ce53f21726cf39e999c4407c4828ecbe957 Mon Sep 17 00:00:00 2001 From: yyh <92089059+lyzno1@users.noreply.github.com> Date: Thu, 14 May 2026 16:09:47 +0800 Subject: [PATCH 1/9] feat(optimizer): improve the esbuild plugin converter to pass some properties of build result to `onEnd` (#22357) Co-authored-by: sapphi-red <49056869+sapphi-red@users.noreply.github.com> --- .../optimizer/pluginConverter.spec.ts | 34 +++++++++++++++++++ .../src/node/optimizer/pluginConverter.ts | 17 +++++++--- 2 files changed, 46 insertions(+), 5 deletions(-) create mode 100644 packages/vite/src/node/__tests__/optimizer/pluginConverter.spec.ts diff --git a/packages/vite/src/node/__tests__/optimizer/pluginConverter.spec.ts b/packages/vite/src/node/__tests__/optimizer/pluginConverter.spec.ts new file mode 100644 index 00000000000000..e25e17bb64aee6 --- /dev/null +++ b/packages/vite/src/node/__tests__/optimizer/pluginConverter.spec.ts @@ -0,0 +1,34 @@ +import type * as esbuild from 'esbuild' +import { describe, expect, test } from 'vitest' +import { convertEsbuildPluginToRolldownPlugin } from '../../optimizer/pluginConverter' +import type { Plugin } from '../../plugin' + +type ConvertedPluginHooks = { + options: Extract + generateBundle: Extract +} + +describe('convertEsbuildPluginToRolldownPlugin', () => { + test('passes a BuildResult to onEnd callbacks', async () => { + let buildResult: esbuild.BuildResult | undefined + + const plugin = convertEsbuildPluginToRolldownPlugin({ + name: 'read-metafile', + setup(build) { + build.onEnd((result) => { + buildResult = result + expect(result.metafile).toBeUndefined() + }) + }, + }) as ConvertedPluginHooks + + await plugin.options.call({} as any, { plugins: [], platform: 'browser' }) + await plugin.generateBundle.call({} as any, {} as any, {} as any, true) + + expect(buildResult).toEqual({ + outputFiles: undefined, + metafile: undefined, + mangleCache: undefined, + }) + }) +}) diff --git a/packages/vite/src/node/optimizer/pluginConverter.ts b/packages/vite/src/node/optimizer/pluginConverter.ts index 0fc1e3f7e32281..3e2d49b551fb6d 100644 --- a/packages/vite/src/node/optimizer/pluginConverter.ts +++ b/packages/vite/src/node/optimizer/pluginConverter.ts @@ -134,15 +134,22 @@ export function convertEsbuildPluginToRolldownPlugin( cb() } }, - generateBundle() { + generateBundle(_outputOpts, _bundle, isWrite) { const buildResult = new Proxy( - {}, { - get(_target, _prop) { - throw new Error('Not implemented') + metafile: undefined, + mangleCache: undefined, + ...(isWrite ? { outputFiles: undefined } : {}), + } as esbuild.BuildResult, + { + get(_target, prop) { + if (prop in _target || typeof prop === 'symbol') { + return (_target as any)[prop] + } + throw new Error('Not implemented property: ' + prop) }, }, - ) as esbuild.BuildResult + ) for (const cb of onEndCallbacks) { cb(buildResult) } From 158e8ae8efdf7075ab295727e36b5ff68da3243e Mon Sep 17 00:00:00 2001 From: Artyom Konoplyov <46654802+artemxknpv@users.noreply.github.com> Date: Thu, 14 May 2026 12:05:12 +0200 Subject: [PATCH 2/9] fix(build): copy public directory after building same environment with `write=false` (#22328) Co-authored-by: sapphi-red <49056869+sapphi-red@users.noreply.github.com> --- .../vite/src/node/__tests__/build.spec.ts | 23 +++++++++++++++++++ .../public-dir-write-false/index.html | 0 .../public-dir-write-false/public/favicon.svg | 1 + .../vite/src/node/plugins/prepareOutDir.ts | 2 +- 4 files changed, 25 insertions(+), 1 deletion(-) create mode 100644 packages/vite/src/node/__tests__/fixtures/public-dir-write-false/index.html create mode 100644 packages/vite/src/node/__tests__/fixtures/public-dir-write-false/public/favicon.svg diff --git a/packages/vite/src/node/__tests__/build.spec.ts b/packages/vite/src/node/__tests__/build.spec.ts index d978dcfe2e2613..141fc682f9edbd 100644 --- a/packages/vite/src/node/__tests__/build.spec.ts +++ b/packages/vite/src/node/__tests__/build.spec.ts @@ -1187,6 +1187,29 @@ test('watch rebuild manifest', async (ctx) => { `) }) +test('copies public directory after building same environment with write false first', async (ctx) => { + const root = resolve(dirname, 'fixtures/public-dir-write-false') + ctx.onTestFinished(() => + fsp.rm(resolve(root, 'dist'), { recursive: true, force: true }), + ) + + const builder = await createBuilder({ + root, + configFile: false, + logLevel: 'silent', + }) + + builder.environments.client.config.build.write = false + await builder.build(builder.environments.client) + + builder.environments.client.config.build.write = true + await builder.build(builder.environments.client) + + await expect( + fsp.readFile(resolve(root, 'dist/favicon.svg'), 'utf-8'), + ).resolves.toBe('') +}) + /** * for each chunks in output1, if there's a chunk in output2 with the same fileName, * ensure that the chunk code is the same. if not, the chunk hash should have changed. diff --git a/packages/vite/src/node/__tests__/fixtures/public-dir-write-false/index.html b/packages/vite/src/node/__tests__/fixtures/public-dir-write-false/index.html new file mode 100644 index 00000000000000..e69de29bb2d1d6 diff --git a/packages/vite/src/node/__tests__/fixtures/public-dir-write-false/public/favicon.svg b/packages/vite/src/node/__tests__/fixtures/public-dir-write-false/public/favicon.svg new file mode 100644 index 00000000000000..dc1ced5b6b300c --- /dev/null +++ b/packages/vite/src/node/__tests__/fixtures/public-dir-write-false/public/favicon.svg @@ -0,0 +1 @@ + \ No newline at end of file diff --git a/packages/vite/src/node/plugins/prepareOutDir.ts b/packages/vite/src/node/plugins/prepareOutDir.ts index eb8fd6554c6d21..6f5f16544cabdc 100644 --- a/packages/vite/src/node/plugins/prepareOutDir.ts +++ b/packages/vite/src/node/plugins/prepareOutDir.ts @@ -20,10 +20,10 @@ export function prepareOutDirPlugin(): Plugin { if (rendered.has(this.environment)) { return } - rendered.add(this.environment) const { config } = this.environment if (config.build.write) { + rendered.add(this.environment) const { root, build: options } = config const resolvedOutDirs = getResolvedOutDirs( root, From 4f0949f3f13e4b2b34d32bf7b2b4de5f26bea192 Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?=E7=BF=A0?= Date: Thu, 14 May 2026 19:05:23 +0900 Subject: [PATCH 3/9] feat(bundled-dev): add lazy bundling support (#21406) --- packages/vite/src/client/client.ts | 12 ++++- .../environments/fullBundleEnvironment.ts | 44 ++++++++++++++----- packages/vite/src/node/server/index.ts | 2 + .../server/middlewares/triggerLazyBundling.ts | 41 +++++++++++++++++ packages/vite/types/customEvent.d.ts | 2 +- .../__tests__/hmr-full-bundle-mode.spec.ts | 5 +++ playground/hmr-full-bundle-mode/dynamic.js | 5 +++ playground/hmr-full-bundle-mode/index.html | 4 ++ playground/hmr-full-bundle-mode/main.js | 4 ++ 9 files changed, 105 insertions(+), 14 deletions(-) create mode 100644 packages/vite/src/node/server/middlewares/triggerLazyBundling.ts create mode 100644 playground/hmr-full-bundle-mode/dynamic.js diff --git a/packages/vite/src/client/client.ts b/packages/vite/src/client/client.ts index b6bfb63534b2a7..b8f7eb76cbe121 100644 --- a/packages/vite/src/client/client.ts +++ b/packages/vite/src/client/client.ts @@ -644,6 +644,15 @@ if (isBundleMode && typeof DevRuntime !== 'undefined') { } } + const clientId = nanoid() + + // notify client id + transport.send({ + type: 'custom', + event: 'vite:module-loaded', + data: { modules: [], clientId }, + }) + const wrappedSocket: Messenger = { send(message) { switch (message.type) { @@ -652,7 +661,7 @@ if (isBundleMode && typeof DevRuntime !== 'undefined') { type: 'custom', event: 'vite:module-loaded', // clone array as the runtime reuses the array instance - data: { modules: message.modules.slice() }, + data: { modules: message.modules.slice(), clientId }, }) break } @@ -661,7 +670,6 @@ if (isBundleMode && typeof DevRuntime !== 'undefined') { } }, } - const clientId = nanoid() ;(globalThis as any).__rolldown_runtime__ ??= new ViteDevRuntime( wrappedSocket, clientId, diff --git a/packages/vite/src/node/server/environments/fullBundleEnvironment.ts b/packages/vite/src/node/server/environments/fullBundleEnvironment.ts index 4d5d86b0fc12bc..f57f5f230eb1b9 100644 --- a/packages/vite/src/node/server/environments/fullBundleEnvironment.ts +++ b/packages/vite/src/node/server/environments/fullBundleEnvironment.ts @@ -1,4 +1,3 @@ -import { randomUUID } from 'node:crypto' import { setTimeout } from 'node:timers/promises' import { type BindingClientHmrUpdate, @@ -61,6 +60,7 @@ export class MemoryFiles { export class FullBundleDevEnvironment extends DevEnvironment { private devEngine!: DevEngine + private initialBuildCompleted = false private clients = new Clients() private invalidateCalledModules = new Map< NormalizedHotChannelClient, @@ -106,8 +106,8 @@ export class FullBundleDevEnvironment extends DevEnvironment { )! this.hot.on('vite:module-loaded', (payload, client) => { - const clientId = this.clients.setupIfNeeded(client) - this.devEngine.registerModules(clientId, payload.modules) + this.clients.setupIfNeeded(client, payload.clientId) + this.devEngine.registerModules(payload.clientId, payload.modules) }) this.hot.on('vite:client:disconnect', (_payload, client) => { const clientId = this.clients.delete(client) @@ -184,6 +184,7 @@ export class FullBundleDevEnvironment extends DevEnvironment { this.waitForInitialBuildFinish().then(() => { debug?.('INITIAL: build done') this.hot.send({ type: 'full-reload', path: '*' }) + this.initialBuildCompleted = true }) } @@ -260,7 +261,9 @@ export class FullBundleDevEnvironment extends DevEnvironment { async triggerBundleRegenerationIfStale(): Promise { const bundleState = await this.devEngine.getBundleState() const shouldTrigger = - bundleState.hasStaleOutput && !bundleState.lastFullBuildFailed + bundleState.hasStaleOutput && + !bundleState.lastFullBuildFailed && + this.initialBuildCompleted if (shouldTrigger) { this.devEngine.ensureLatestBuildOutput().then(() => { this.debouncedFullReload() @@ -270,9 +273,23 @@ export class FullBundleDevEnvironment extends DevEnvironment { return shouldTrigger } + async triggerLazyBundling( + moduleId: string | null, + clientId: string | null, + ): Promise { + if (!moduleId || !clientId) { + return + } + debug?.( + `TRIGGER-LAZY: trigger lazy bundling for module ${moduleId} for client ${clientId}`, + ) + return await this.devEngine.compileEntry(moduleId, clientId) + } + override async close(): Promise { this.memoryFiles.clear() await Promise.all([super.close(), this.devEngine.close()]) + this.initialBuildCompleted = false } private async getRolldownOptions() { @@ -280,6 +297,10 @@ export class FullBundleDevEnvironment extends DevEnvironment { const rolldownOptions = resolveRolldownOptions(this, chunkMetadataMap) rolldownOptions.experimental ??= {} rolldownOptions.experimental.devMode = { + lazy: true, + ...(typeof rolldownOptions.experimental.devMode === 'object' + ? rolldownOptions.experimental.devMode + : {}), implement: await getHmrImplementation(this.getTopLevelConfig()), } @@ -382,14 +403,15 @@ class Clients { private clientToId = new Map() private idToClient = new Map() - setupIfNeeded(client: NormalizedHotChannelClient): string { + setupIfNeeded(client: NormalizedHotChannelClient, clientId: string) { const id = this.clientToId.get(client) - if (id) return id - - const newId = randomUUID() - this.clientToId.set(client, newId) - this.idToClient.set(newId, client) - return newId + if (id && id !== clientId) { + throw new Error( + 'client ID conflict detected. Please restart the dev server.', + ) + } + this.clientToId.set(client, clientId) + this.idToClient.set(clientId, client) } get(id: string): NormalizedHotChannelClient | undefined { diff --git a/packages/vite/src/node/server/index.ts b/packages/vite/src/node/server/index.ts index a7318e3fd04d7e..7ed06d8c1c3d29 100644 --- a/packages/vite/src/node/server/index.ts +++ b/packages/vite/src/node/server/index.ts @@ -107,6 +107,7 @@ import type { DevEnvironment } from './environment' import { hostValidationMiddleware } from './middlewares/hostCheck' import { rejectInvalidRequestMiddleware } from './middlewares/rejectInvalidRequest' import { memoryFilesMiddleware } from './middlewares/memoryFiles' +import { triggerLazyBundlingMiddleware } from './middlewares/triggerLazyBundling' const usedConfigs = new WeakSet() @@ -993,6 +994,7 @@ export async function _createServer( } if (config.experimental.bundledDev) { + middlewares.use(triggerLazyBundlingMiddleware(server)) middlewares.use(memoryFilesMiddleware(server)) } else { // main transform middleware diff --git a/packages/vite/src/node/server/middlewares/triggerLazyBundling.ts b/packages/vite/src/node/server/middlewares/triggerLazyBundling.ts new file mode 100644 index 00000000000000..c1dc7bc7732e0f --- /dev/null +++ b/packages/vite/src/node/server/middlewares/triggerLazyBundling.ts @@ -0,0 +1,41 @@ +import type { Connect } from '#dep-types/connect' +import type { ViteDevServer } from '..' +import { FullBundleDevEnvironment } from '../environments/fullBundleEnvironment' + +export function triggerLazyBundlingMiddleware( + server: ViteDevServer, +): Connect.NextHandleFunction { + const environment = + server.environments.client instanceof FullBundleDevEnvironment + ? server.environments.client + : undefined + if (!environment) { + throw new Error( + 'triggerLazyBundlingMiddleware can only be used for fullBundleMode', + ) + } + + return async function viteTriggerLazyBundlingMiddleware(req, res, next) { + if (!req.url?.startsWith('/@vite/lazy?')) { + return next() + } + + let params: URLSearchParams + try { + params = new URL(`http://localhost${req.url}`).searchParams + } catch { + // Malformed URL + return next() + } + + const moduleId = params.get('id') + const clientId = params.get('clientId') + const code = await environment.triggerLazyBundling(moduleId, clientId) + if (code == null) { + return next() + } + + res!.setHeader('Content-Type', 'application/javascript') + return res!.end(code) + } +} diff --git a/packages/vite/types/customEvent.d.ts b/packages/vite/types/customEvent.d.ts index c971a9609bdcd0..7ca838895fba26 100644 --- a/packages/vite/types/customEvent.d.ts +++ b/packages/vite/types/customEvent.d.ts @@ -18,7 +18,7 @@ export interface CustomEventMap { /** @internal */ 'vite:forward-console': ForwardConsolePayload /** @internal */ - 'vite:module-loaded': { modules: string[] } + 'vite:module-loaded': { modules: string[]; clientId: string } // server events 'vite:client:connect': undefined diff --git a/playground/hmr-full-bundle-mode/__tests__/hmr-full-bundle-mode.spec.ts b/playground/hmr-full-bundle-mode/__tests__/hmr-full-bundle-mode.spec.ts index 9edac35690fdea..0b37df9d612125 100644 --- a/playground/hmr-full-bundle-mode/__tests__/hmr-full-bundle-mode.spec.ts +++ b/playground/hmr-full-bundle-mode/__tests__/hmr-full-bundle-mode.spec.ts @@ -183,4 +183,9 @@ if (isBuild) { ) await expect.poll(() => page.textContent('.worker-url')).toBe('worker-url') }) + + test('lazy bundling', async () => { + await page.click('#load-dynamic') + await expect.poll(() => page.textContent('.dynamic')).toBe('loaded') + }) } diff --git a/playground/hmr-full-bundle-mode/dynamic.js b/playground/hmr-full-bundle-mode/dynamic.js new file mode 100644 index 00000000000000..c19beab71ac1eb --- /dev/null +++ b/playground/hmr-full-bundle-mode/dynamic.js @@ -0,0 +1,5 @@ +text('.dynamic', 'loaded') + +function text(el, text) { + document.querySelector(el).textContent = text +} diff --git a/playground/hmr-full-bundle-mode/index.html b/playground/hmr-full-bundle-mode/index.html index c5259e11e5d184..54694110256bec 100644 --- a/playground/hmr-full-bundle-mode/index.html +++ b/playground/hmr-full-bundle-mode/index.html @@ -5,5 +5,9 @@

HMR Full Bundle Mode

+
+ +
+
diff --git a/playground/hmr-full-bundle-mode/main.js b/playground/hmr-full-bundle-mode/main.js index 9438bf643f1960..752e33219a2537 100644 --- a/playground/hmr-full-bundle-mode/main.js +++ b/playground/hmr-full-bundle-mode/main.js @@ -19,6 +19,10 @@ workerUrl.addEventListener('message', (e) => { text('.worker-url', e.data) }) +document.querySelector('#load-dynamic').addEventListener('click', () => { + import('./dynamic.js') +}) + function text(el, text) { document.querySelector(el).textContent = text } From d9b18e0387a253628d3d834288e79c5f7e85d566 Mon Sep 17 00:00:00 2001 From: Cameron Date: Thu, 14 May 2026 11:07:01 +0100 Subject: [PATCH 4/9] fix(ssr): avoid rewriting labels that collide with imports (#22451) --- .../src/node/ssr/__tests__/ssrTransform.spec.ts | 13 +++++++++++++ packages/vite/src/node/ssr/ssrTransform.ts | 10 ++++++++++ 2 files changed, 23 insertions(+) diff --git a/packages/vite/src/node/ssr/__tests__/ssrTransform.spec.ts b/packages/vite/src/node/ssr/__tests__/ssrTransform.spec.ts index ac47c732fdaa60..47b94f8179b7e5 100644 --- a/packages/vite/src/node/ssr/__tests__/ssrTransform.spec.ts +++ b/packages/vite/src/node/ssr/__tests__/ssrTransform.spec.ts @@ -49,6 +49,19 @@ test('named import: arbitrary module namespace specifier', async () => { ) }) +test('named import colliding with label', async () => { + expect( + await ssrTransformSimpleCode( + `import { query } from 'vue';function foo() { query: while (true) { continue query; break query } }`, + ), + ).toMatchInlineSnapshot( + ` + "const __vite_ssr_import_0__ = await __vite_ssr_import__("vue", {"importedNames":["query"]}); + function foo() { query: while (true) { continue query; break query } }" + `, + ) +}) + test('namespace import', async () => { expect( await ssrTransformSimpleCode( diff --git a/packages/vite/src/node/ssr/ssrTransform.ts b/packages/vite/src/node/ssr/ssrTransform.ts index 927661424a0c28..bdb0f42802ca45 100644 --- a/packages/vite/src/node/ssr/ssrTransform.ts +++ b/packages/vite/src/node/ssr/ssrTransform.ts @@ -712,6 +712,16 @@ function isRefIdentifier( return false } + // label declaration or break/continue target + if ( + (parent.type === 'LabeledStatement' || + parent.type === 'BreakStatement' || + parent.type === 'ContinueStatement') && + parent.label === id + ) { + return false + } + // meta property (e.g. import.meta) if (parent.type === 'MetaProperty') { return false From f3a0bc90bcc529a12a520469b9d0fb6fa751107c Mon Sep 17 00:00:00 2001 From: Rayan Salhab Date: Thu, 14 May 2026 13:29:42 +0300 Subject: [PATCH 5/9] fix(plugin-legacy): remove modulepreload links for legacy-only builds (#22332) Co-authored-by: sapphi-red <49056869+sapphi-red@users.noreply.github.com> --- .../plugin-legacy/src/__tests__/index.spec.ts | 45 +++++++++++++++++++ packages/plugin-legacy/src/index.ts | 6 ++- .../no-polyfills/legacy-no-polyfills.spec.ts | 8 ++++ 3 files changed, 58 insertions(+), 1 deletion(-) create mode 100644 packages/plugin-legacy/src/__tests__/index.spec.ts diff --git a/packages/plugin-legacy/src/__tests__/index.spec.ts b/packages/plugin-legacy/src/__tests__/index.spec.ts new file mode 100644 index 00000000000000..2cbbc93d0b8028 --- /dev/null +++ b/packages/plugin-legacy/src/__tests__/index.spec.ts @@ -0,0 +1,45 @@ +import { describe, expect, test } from 'vitest' +import { modulePreloadLinkRE } from '../index' + +describe('modulePreloadLinkRE', () => { + const matches: Array<[string, string]> = [ + ['rel first', ''], + [ + 'rel after other attributes', + '', + ], + ['rel only', ''], + ['self-closing', ''], + ['self-closing with space', ''], + ['single quotes', ""], + [ + 'attributes across multiple lines', + '', + ], + ] + + for (const [name, html] of matches) { + test(`matches: ${name}`, () => { + expect(html.replace(modulePreloadLinkRE, '')).toBe('') + }) + } + + const nonMatches: Array<[string, string]> = [ + ['tag name with suffix', ''], + ['custom element with hyphen', ''], + ['bare link tag', ''], + ['stylesheet link', ''], + ['preload (not modulepreload)', ''], + [ + 'attribute name ending in rel', + '', + ], + ['mismatched quotes', `]*>/g // browsers supporting dynamic import + import.meta.resolve + async generator const modernTargetsEsbuild = [ @@ -641,7 +643,9 @@ function viteLegacyPlugin(options: Options = {}): Plugin[] { } } if (!genModern) { - html = html.replace(/