From 6f1e8a105b00b4b65ff7fbd7e2c7018cc312cdf5 Mon Sep 17 00:00:00 2001 From: Hana Date: Tue, 7 Jul 2026 10:22:52 +0800 Subject: [PATCH 01/13] feat(bundled-dev): client-side HMR --- packages/vite/rolldown.config.ts | 15 + packages/vite/src/client/client.ts | 191 ++++------- packages/vite/src/client/fbmClient.ts | 67 ++++ packages/vite/src/client/fbmHmrClient.ts | 319 ++++++++++++++++++ packages/vite/src/module-runner/hmrHandler.ts | 8 +- packages/vite/src/node/config.ts | 13 + packages/vite/src/node/constants.ts | 4 + .../vite/src/node/plugins/clientInjections.ts | 9 +- packages/vite/src/node/server/bundledDev.ts | 139 +++----- packages/vite/src/node/server/environment.ts | 2 +- .../src/node/server/middlewares/indexHtml.ts | 20 +- .../node/server/middlewares/memoryFiles.ts | 6 +- .../server/middlewares/triggerLazyBundling.ts | 7 +- packages/vite/types/customEvent.d.ts | 2 +- packages/vite/types/hmrPayload.d.ts | 20 +- 15 files changed, 572 insertions(+), 250 deletions(-) create mode 100644 packages/vite/src/client/fbmClient.ts create mode 100644 packages/vite/src/client/fbmHmrClient.ts diff --git a/packages/vite/rolldown.config.ts b/packages/vite/rolldown.config.ts index 2def47719a79fc..7336621b299d39 100644 --- a/packages/vite/rolldown.config.ts +++ b/packages/vite/rolldown.config.ts @@ -38,6 +38,20 @@ const clientConfig = defineConfig({ }, }) +// separate entry so the full-bundle-mode HMR code is never bundled into `client.mjs` +const fbmClientConfig = defineConfig({ + input: path.resolve(dirname, 'src/client/fbmClient.ts'), + platform: 'browser', + transform: { + target: 'es2020', + }, + external: ['@vite/env'], + output: { + dir: path.resolve(dirname, 'dist'), + entryFileNames: 'client/fbmClient.mjs', + }, +}) + const sharedNodeOptions = defineConfig({ platform: 'node', treeshake: { @@ -155,6 +169,7 @@ const moduleRunnerConfig = defineConfig({ export default defineConfig([ envConfig, clientConfig, + fbmClientConfig, nodeConfig, moduleRunnerConfig, ]) diff --git a/packages/vite/src/client/client.ts b/packages/vite/src/client/client.ts index 9f8b8deea14e1b..f14466c367e7bd 100644 --- a/packages/vite/src/client/client.ts +++ b/packages/vite/src/client/client.ts @@ -1,8 +1,3 @@ -import { nanoid } from 'nanoid/non-secure' -import type { - DevRuntime as DevRuntimeType, - Messenger, -} from 'rolldown/experimental/runtime-types' import type { ErrorPayload, HotPayload } from '#types/hmrPayload' import type { ViteHotContext } from '#types/hot' import { HMRClient, HMRContext } from '../shared/hmr' @@ -12,6 +7,7 @@ import { } from '../shared/moduleRunnerTransport' import { createHMRHandler } from '../shared/hmrHandler' import { setupForwardConsoleHandler } from '../shared/forwardConsole' +import type { FbmHMRClient } from './fbmHmrClient' import { ErrorOverlay, cspNonce, overlayId } from './overlay' // @ts-expect-error internal virtual module import '@vite/env' @@ -28,7 +24,6 @@ declare const __HMR_TIMEOUT__: number declare const __HMR_ENABLE_OVERLAY__: boolean declare const __WS_TOKEN__: string declare const __SERVER_FORWARD_CONSOLE__: any -declare const __BUNDLED_DEV__: boolean console.debug('[vite] connecting...') @@ -43,13 +38,12 @@ const socketHost = `${__HMR_HOSTNAME__ || importMetaUrl.hostname}:${ hmrPort || importMetaUrl.port }${__HMR_BASE__}` const directSocketHost = __HMR_DIRECT_TARGET__ -const base = __BASE__ || '/' +export const base = __BASE__ || '/' const hmrTimeout = __HMR_TIMEOUT__ const wsToken = __WS_TOKEN__ -const isBundleMode = __BUNDLED_DEV__ const forwardConsole = __SERVER_FORWARD_CONSOLE__ -const transport = normalizeModuleRunnerTransport( +export const transport = normalizeModuleRunnerTransport( (() => { let wsTransport = createWebSocketModuleRunnerTransport({ createConnection: () => @@ -143,7 +137,7 @@ const debounceReload = (time: number) => { }, time) } } -const pageReload = debounceReload(20) +export const pageReload = debounceReload(20) const hmrClient = new HMRClient( { @@ -151,79 +145,74 @@ const hmrClient = new HMRClient( debug: (...msg) => console.debug('[vite]', ...msg), }, transport, - isBundleMode - ? async function importUpdatedModule({ - url, - acceptedPath, - isWithinCircularImport, - }) { - const importPromise = import(base + url!).then(() => - // @ts-expect-error globalThis.__rolldown_runtime__ - globalThis.__rolldown_runtime__.loadExports(acceptedPath), - ) - if (isWithinCircularImport) { - importPromise.catch(() => { - console.info( - `[hmr] ${acceptedPath} failed to apply HMR as it's within a circular import. Reloading page to reset the execution order. ` + - `To debug and break the circular import, you can run \`vite --debug hmr\` to log the circular dependency path if a file change triggered it.`, - ) - pageReload() - }) - } - return await importPromise - } - : async function importUpdatedModule({ - acceptedPath, - timestamp, - explicitImportRequired, - isWithinCircularImport, - }) { - const [acceptedPathWithoutQuery, query] = acceptedPath.split(`?`) - const importPromise = import( - /* @vite-ignore */ - base + - acceptedPathWithoutQuery.slice(1) + - `?${explicitImportRequired ? 'import&' : ''}t=${timestamp}${ - query ? `&${query}` : '' - }` + async function importUpdatedModule({ + acceptedPath, + timestamp, + explicitImportRequired, + isWithinCircularImport, + }) { + const [acceptedPathWithoutQuery, query] = acceptedPath.split(`?`) + const importPromise = import( + /* @vite-ignore */ + base + + acceptedPathWithoutQuery.slice(1) + + `?${explicitImportRequired ? 'import&' : ''}t=${timestamp}${ + query ? `&${query}` : '' + }` + ) + if (isWithinCircularImport) { + importPromise.catch(() => { + console.info( + `[hmr] ${acceptedPath} failed to apply HMR as it's within a circular import. Reloading page to reset the execution order. ` + + `To debug and break the circular import, you can run \`vite --debug hmr\` to log the circular dependency path if a file change triggered it.`, ) - if (isWithinCircularImport) { - importPromise.catch(() => { - console.info( - `[hmr] ${acceptedPath} failed to apply HMR as it's within a circular import. Reloading page to reset the execution order. ` + - `To debug and break the circular import, you can run \`vite --debug hmr\` to log the circular dependency path if a file change triggered it.`, - ) - pageReload() - }) - } - return await importPromise - }, + pageReload() + }) + } + return await importPromise + }, ) +// set by the full-bundle-mode entry (`fbmClient.ts`); the `import type` above keeps +// `FbmHMRClient` compile-time only, so `client.mjs` bundles no FBM code +let fbmClient: FbmHMRClient | undefined +export function registerFbmClient(client: FbmHMRClient): void { + fbmClient = client +} transport.connect!(createHMRHandler(handleMessage)) setupForwardConsoleHandler(transport, forwardConsole) +// if this is the first update and there's already an error overlay, it means the +// page opened with existing server compile error and the whole module script failed +// to load (since one of the nested imports is 500). in this case a normal update +// won't work and a full reload is needed. +export function clearOverlayOrReloadOnFirstUpdate(): 'reload' | 'continue' { + if (hasDocument) { + if (isFirstUpdate && hasErrorOverlay()) { + location.reload() + return 'reload' + } + if (enableOverlay) { + clearErrorOverlay() + } + isFirstUpdate = false + } + return 'continue' +} + async function handleMessage(payload: HotPayload) { + const activeHmrClient = fbmClient ?? hmrClient switch (payload.type) { case 'connected': console.debug(`[vite] connected.`) break + case 'fbm-update': + fbmClient!.handlePush(payload) + break case 'update': - await hmrClient.notifyListeners('vite:beforeUpdate', payload) - if (hasDocument) { - // if this is the first update and there's already an error overlay, it - // means the page opened with existing server compile error and the whole - // module script failed to load (since one of the nested imports is 500). - // in this case a normal update won't work and a full reload is needed. - if (isFirstUpdate && hasErrorOverlay()) { - location.reload() - return - } else { - if (enableOverlay) { - clearErrorOverlay() - } - isFirstUpdate = false - } + await activeHmrClient.notifyListeners('vite:beforeUpdate', payload) + if (clearOverlayOrReloadOnFirstUpdate() === 'reload') { + return } await Promise.all( payload.updates.map(async (update): Promise => { @@ -273,10 +262,10 @@ async function handleMessage(payload: HotPayload) { }) }), ) - await hmrClient.notifyListeners('vite:afterUpdate', payload) + await activeHmrClient.notifyListeners('vite:afterUpdate', payload) break case 'custom': { - await hmrClient.notifyListeners(payload.event, payload.data) + await activeHmrClient.notifyListeners(payload.event, payload.data) if (payload.event === 'vite:ws:disconnect') { if (hasDocument && !willUnload) { console.log(`[vite] server connection lost. Polling for restart...`) @@ -290,7 +279,7 @@ async function handleMessage(payload: HotPayload) { break } case 'full-reload': - await hmrClient.notifyListeners('vite:beforeFullReload', payload) + await activeHmrClient.notifyListeners('vite:beforeFullReload', payload) if (hasDocument) { if (payload.path && payload.path.endsWith('.html')) { // if html file is edited, only reload the page if the browser is @@ -311,11 +300,11 @@ async function handleMessage(payload: HotPayload) { } break case 'prune': - await hmrClient.notifyListeners('vite:beforePrune', payload) - await hmrClient.prunePaths(payload.paths) + await activeHmrClient.notifyListeners('vite:beforePrune', payload) + await activeHmrClient.prunePaths(payload.paths) break case 'error': { - await hmrClient.notifyListeners('vite:error', payload) + await activeHmrClient.notifyListeners('vite:error', payload) if (hasDocument) { const err = payload.err if (enableOverlay) { @@ -623,51 +612,3 @@ export function injectQuery(url: string, queryToInject: string): string { } export { ErrorOverlay } - -declare const DevRuntime: typeof DevRuntimeType - -if (isBundleMode && typeof DevRuntime !== 'undefined') { - class ViteDevRuntime extends DevRuntime { - override createModuleHotContext(moduleId: string) { - const ctx = createHotContext(moduleId) - // @ts-expect-error TODO: support CSS properly - ctx._internal = { updateStyle, removeStyle } - return ctx - } - - override applyUpdates(_boundaries: [string, string][]): void { - // noop, handled in the HMR client - } - } - - 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) { - case 'hmr:module-registered': { - transport.send({ - type: 'custom', - event: 'vite:module-loaded', - // clone array as the runtime reuses the array instance - data: { modules: message.modules.slice(), clientId }, - }) - break - } - default: - throw new Error(`Unknown message type: ${JSON.stringify(message)}`) - } - }, - } - ;(globalThis as any).__rolldown_runtime__ ??= new ViteDevRuntime( - wrappedSocket, - clientId, - ) -} diff --git a/packages/vite/src/client/fbmClient.ts b/packages/vite/src/client/fbmClient.ts new file mode 100644 index 00000000000000..bd4f0341e0756f --- /dev/null +++ b/packages/vite/src/client/fbmClient.ts @@ -0,0 +1,67 @@ +import { nanoid } from 'nanoid/non-secure' +import type { DevRuntime as DevRuntimeType } from 'rolldown/experimental/runtime-types' +import { FbmHMRClient, FbmHMRContext } from './fbmHmrClient' +import { + base, + clearOverlayOrReloadOnFirstUpdate, + pageReload, + registerFbmClient, + removeStyle, + transport, + updateStyle, +} from './client' + +// keep the same public exports as `client.ts`, which this entry replaces when inlined +export { + createHotContext, + injectQuery, + removeStyle, + updateStyle, + ErrorOverlay, +} from './client' + +// injected by rolldown's hmr plugin into the bundle prelude, ahead of this client +declare const DevRuntime: typeof DevRuntimeType + +if (typeof DevRuntime !== 'undefined') { + class ViteDevRuntime extends DevRuntime { + override createModuleHotContext(moduleId: string) { + const ctx = new FbmHMRContext(fbmHmrClient, moduleId) + // @ts-expect-error TODO: support CSS properly + ctx._internal = { updateStyle, removeStyle } + return ctx + } + } + + const clientId = nanoid() + + transport.send({ + type: 'custom', + event: 'vite:client-connected', + data: { clientId }, + }) + + const runtime = ((globalThis as any).__rolldown_runtime__ ??= + new ViteDevRuntime(clientId)) + + const fbmHmrClient = new FbmHMRClient( + { + error: (err) => console.error('[vite]', err), + debug: (...msg) => console.debug('[vite]', ...msg), + }, + transport, + runtime, + { + base, + beforeApply: clearOverlayOrReloadOnFirstUpdate, + pageReload, + }, + ) + registerFbmClient(fbmHmrClient) + + runtime.hooks = { + createModuleHotContext: (id: string) => runtime.createModuleHotContext(id), + onModuleCacheRemoval: (id: string) => + fbmHmrClient.handleModuleCacheRemoval(id), + } +} diff --git a/packages/vite/src/client/fbmHmrClient.ts b/packages/vite/src/client/fbmHmrClient.ts new file mode 100644 index 00000000000000..77189b139693c4 --- /dev/null +++ b/packages/vite/src/client/fbmHmrClient.ts @@ -0,0 +1,319 @@ +import type { FbmUpdatePayload, Update, UpdatePayload } from '#types/hmrPayload' +import { HMRClient, HMRContext, type HMRLogger } from '../shared/hmr' +import type { NormalizedModuleRunnerTransport } from '../shared/moduleRunnerTransport' + +/** the subset of `__rolldown_runtime__` the HMR client uses */ +export interface RolldownRuntimeLike { + getImporters(id: string): string[] + isExecuted(id: string): boolean + hasFactory(id: string): boolean + removeModuleCache(id: string): void + initModule(id: string): unknown + loadExports(id: string): unknown +} + +type HmrUpdate = + | { type: 'noop' } + | { type: 'full-reload'; reason: string } + | { + type: 'boundaries' + /** `[boundary, acceptedVia]` pairs */ + boundaries: [string, string][] + updateSet: string[] + } + +export interface FbmHMRClientOptions { + base: string + /** returning `'reload'` aborts the apply — the hook reloads the page itself */ + beforeApply: () => 'reload' | 'continue' + pageReload: () => void +} + +export class FbmHMRClient extends HMRClient { + private applyQueue = Promise.resolve() + private lastSeq = 0 + + constructor( + logger: HMRLogger, + transport: NormalizedModuleRunnerTransport, + private runtime: RolldownRuntimeLike, + private options: FbmHMRClientOptions, + ) { + super(logger, transport, async () => { + throw new Error( + 'unreachable: full-bundle mode applies patches through its own queue', + ) + }) + } + + isSelfAccepted(id: string): boolean { + return ( + this.hotModulesMap.get(id)?.callbacks.some((c) => c.deps.includes(id)) ?? + false + ) + } + + acceptsDep(parent: string, id: string): boolean { + return ( + this.hotModulesMap + .get(parent) + ?.callbacks.some((c) => c.deps.includes(id)) ?? false + ) + } + + computeHmrUpdate( + changedIds: string[], + opts?: { firstInvalidatedBy?: string }, + ): HmrUpdate { + const boundaries: [string, string][] = [] + const updateSet = new Set() + const traversedModules = new Set() + for (const changed of changedIds) { + if (!this.runtime.isExecuted(changed)) { + continue + } + const fullReload = this.bubble( + changed, + [changed], + updateSet, + boundaries, + opts?.firstInvalidatedBy, + traversedModules, + ) + if (fullReload) return fullReload + } + return boundaries.length + ? { type: 'boundaries', boundaries, updateSet: [...updateSet] } + : { type: 'noop' } + } + + private bubble( + id: string, + stack: string[], + updateSet: Set, + boundaries: [string, string][], + firstInvalidatedBy: string | undefined, + traversedModules: Set, + ): HmrUpdate | undefined { + if (traversedModules.has(id)) return + traversedModules.add(id) + updateSet.add(id) + if (firstInvalidatedBy !== undefined && id === firstInvalidatedBy) { + return { + type: 'full-reload', + reason: `update propagated back to ${firstInvalidatedBy}, which already called \`import.meta.hot.invalidate()\``, + } + } + if (this.isSelfAccepted(id)) { + boundaries.push([id, id]) + return + } + const parents = this.runtime + .getImporters(id) + .filter((p) => this.runtime.isExecuted(p)) + if (!parents.length) { + return { + type: 'full-reload', + reason: `no hmr boundary found for module \`${id}\``, + } + } + for (const parent of parents) { + if (this.acceptsDep(parent, id)) { + boundaries.push([parent, id]) + continue + } + if (stack.includes(parent)) { + return { + type: 'full-reload', + reason: `circular import chain between \`${id}\` and \`${parent}\``, + } + } + const fullReload = this.bubble( + parent, + [...stack, parent], + updateSet, + boundaries, + firstInvalidatedBy, + traversedModules, + ) + if (fullReload) return fullReload + } + } + + handlePush(payload: FbmUpdatePayload): void { + this.applyQueue = this.applyQueue + .then(() => this.applyPush(payload)) + .catch((err) => { + this.warnFailedUpdate(err, payload.changedIds) + }) + } + + invalidateLocally(id: string, message?: string): void { + this.logger.debug(`invalidate ${id}${message ? `: ${message}` : ''}`) + this.applyQueue = this.applyQueue + .then(() => this.applyInvalidate(id)) + .catch((err) => { + this.warnFailedUpdate(err, id) + }) + } + + handleModuleCacheRemoval(id: string): void { + const data = {} + const disposer = this.disposeMap.get(id) + if (disposer) { + disposer(data) + } + this.dataMap.set(id, data) + } + + private async applyPush({ + changedIds, + url, + seq, + }: FbmUpdatePayload): Promise { + if (seq !== this.lastSeq + 1) { + this.requestFullReload( + `hmr update sequence gap (expected ${this.lastSeq + 1}, got ${seq})`, + ) + return + } + this.lastSeq = seq + + const update = this.computeHmrUpdate(changedIds) + if (update.type === 'noop') return + if (update.type === 'full-reload') { + this.requestFullReload(update.reason) + return + } + + const listenerPayload = this.toUpdatePayload(update.boundaries, undefined) + await this.notifyListeners('vite:beforeUpdate', listenerPayload) + if (this.options.beforeApply() === 'reload') return + + try { + await import(/* @vite-ignore */ this.options.base + url) + } catch { + this.requestFullReload(`failed to import hmr patch ${url}`) + return + } + + await this.applyUpdate(update) + await this.notifyListeners('vite:afterUpdate', listenerPayload) + } + + private async applyInvalidate(id: string): Promise { + const firstInvalidatedBy = this.currentFirstInvalidatedBy ?? id + const importers = this.runtime + .getImporters(id) + .filter((p) => this.runtime.isExecuted(p)) + if (!importers.length) { + this.requestFullReload( + `no importers to handle \`import.meta.hot.invalidate()\` called by \`${id}\``, + ) + return + } + + // no rebuild happened, so there is no patch to fetch + const update = this.computeHmrUpdate(importers, { firstInvalidatedBy }) + if (update.type === 'noop') return + if (update.type === 'full-reload') { + this.requestFullReload(update.reason) + return + } + + const listenerPayload = this.toUpdatePayload( + update.boundaries, + firstInvalidatedBy, + ) + await this.notifyListeners('vite:beforeUpdate', listenerPayload) + if (this.options.beforeApply() === 'reload') return + await this.applyUpdate(update, firstInvalidatedBy) + await this.notifyListeners('vite:afterUpdate', listenerPayload) + } + + private async applyUpdate( + update: Extract, + firstInvalidatedBy?: string, + ): Promise { + for (const id of update.updateSet) { + if (!this.runtime.hasFactory(id)) { + this.requestFullReload(`no factory for module \`${id}\``) + return + } + } + + // collect callbacks before the caches are removed + const applies = update.boundaries.map(([boundary, acceptedVia]) => ({ + boundary, + acceptedVia, + callbacks: + this.hotModulesMap + .get(boundary) + ?.callbacks.filter((c) => c.deps.includes(acceptedVia)) ?? [], + })) + + for (const id of update.updateSet) { + this.runtime.removeModuleCache(id) + } + + for (const { boundary, acceptedVia, callbacks } of applies) { + this.runtime.initModule(acceptedVia) + const fresh = this.runtime.loadExports(acceptedVia) + try { + this.currentFirstInvalidatedBy = firstInvalidatedBy + for (const { deps, fn } of callbacks) { + fn( + deps.map((dep) => + dep === acceptedVia ? (fresh as any) : undefined, + ), + ) + } + } finally { + this.currentFirstInvalidatedBy = undefined + } + this.logger.debug( + `hot updated: ${ + boundary === acceptedVia ? boundary : `${acceptedVia} via ${boundary}` + }`, + ) + } + } + + private toUpdatePayload( + boundaries: [string, string][], + firstInvalidatedBy: string | undefined, + ): UpdatePayload { + const updates: Update[] = boundaries.map(([boundary, acceptedVia]) => ({ + type: 'js-update', + path: boundary, + acceptedPath: acceptedVia, + timestamp: Date.now(), + firstInvalidatedBy, + })) + return { type: 'update', updates } + } + + private requestFullReload(reason: string): void { + this.logger.debug(`full reload: ${reason}`) + this.options.pageReload() + } +} + +export class FbmHMRContext extends HMRContext { + constructor( + private fbmClient: FbmHMRClient, + private owner: string, + ) { + super(fbmClient, owner) + } + + override invalidate(message: string): void { + this.fbmClient.notifyListeners('vite:invalidate', { + path: this.owner, + message, + firstInvalidatedBy: + this.fbmClient.currentFirstInvalidatedBy ?? this.owner, + }) + this.fbmClient.invalidateLocally(this.owner, message) + } +} diff --git a/packages/vite/src/module-runner/hmrHandler.ts b/packages/vite/src/module-runner/hmrHandler.ts index edeb1d24b71130..1dd280d58ca849 100644 --- a/packages/vite/src/module-runner/hmrHandler.ts +++ b/packages/vite/src/module-runner/hmrHandler.ts @@ -38,9 +38,9 @@ export function createHMRHandlerForRunner( const { triggeredBy } = payload const clearEntrypointUrls = triggeredBy ? getModulesEntrypoints( - runner, - getModulesByFile(runner, slash(triggeredBy)), - ) + runner, + getModulesByFile(runner, slash(triggeredBy)), + ) : findAllEntrypoints(runner) if (!clearEntrypointUrls.size) break @@ -78,6 +78,8 @@ export function createHMRHandlerForRunner( } case 'ping': // noop break + case 'fbm-update': // todo + break default: { const check: never = payload return check diff --git a/packages/vite/src/node/config.ts b/packages/vite/src/node/config.ts index c43a91baa3b29d..061a907696c2b5 100644 --- a/packages/vite/src/node/config.ts +++ b/packages/vite/src/node/config.ts @@ -619,6 +619,19 @@ export interface ExperimentalOptions { * * This is highly experimental. * + * HMR semantics under full bundle mode differ from the middleware-based dev server + * in three ways (boundaries are computed in the browser from runtime state, not + * statically on the server): + * + * - Acceptance counts only when it executed: an `import.meta.hot.accept()` that is + * visible in the source but never ran (e.g. inside a dead branch) does not suppress + * the update — it triggers a full reload instead. + * - `hot.dispose` runs for every module the update re-executes, each receiving a + * fresh `hot.data` object — not only for the accepted module with a shared + * persistent object. + * - `hot.invalidate()` is handled fully client-side (a re-walk from the invalidator's + * importers); no request is sent to the server. + * * @experimental * @default false */ diff --git a/packages/vite/src/node/constants.ts b/packages/vite/src/node/constants.ts index 7e08db15513777..1234ac4cf186bf 100644 --- a/packages/vite/src/node/constants.ts +++ b/packages/vite/src/node/constants.ts @@ -129,6 +129,10 @@ export const CLIENT_ENTRY: string = resolve( VITE_PACKAGE_DIR, 'dist/client/client.mjs', ) +export const FBM_CLIENT_ENTRY: string = resolve( + VITE_PACKAGE_DIR, + 'dist/client/fbmClient.mjs', +) export const ENV_ENTRY: string = resolve( VITE_PACKAGE_DIR, 'dist/client/env.mjs', diff --git a/packages/vite/src/node/plugins/clientInjections.ts b/packages/vite/src/node/plugins/clientInjections.ts index ce2e10c250d485..871125b5e6ef82 100644 --- a/packages/vite/src/node/plugins/clientInjections.ts +++ b/packages/vite/src/node/plugins/clientInjections.ts @@ -2,7 +2,7 @@ import path from 'node:path' import fs from 'node:fs' import type { Plugin } from '../plugin' import type { ResolvedConfig } from '../config' -import { CLIENT_ENTRY, ENV_ENTRY } from '../constants' +import { CLIENT_ENTRY, ENV_ENTRY, FBM_CLIENT_ENTRY } from '../constants' import { isObject, normalizePath, resolveHostname } from '../utils' import { cleanUrl } from '../../shared/utils' import { perEnvironmentState } from '../environment' @@ -10,6 +10,7 @@ import { replaceDefine, serializeDefine } from './define' // ids in transform are normalized to unix style const normalizedClientEntry = normalizePath(CLIENT_ENTRY) +const normalizedFbmClientEntry = normalizePath(FBM_CLIENT_ENTRY) const normalizedEnvEntry = normalizePath(ENV_ENTRY) /** @@ -119,9 +120,6 @@ async function createClientConfigValueReplacer( const serverForwardConsoleReplacement = escapeReplacement( config.server.forwardConsole as any, ) - const bundleDevReplacement = escapeReplacement( - config.experimental.bundledDev || false, - ) return (code) => code @@ -138,13 +136,12 @@ async function createClientConfigValueReplacer( .replace(`__HMR_CONFIG_NAME__`, hmrConfigNameReplacement) .replace(`__WS_TOKEN__`, wsTokenReplacement) .replace(`__SERVER_FORWARD_CONSOLE__`, serverForwardConsoleReplacement) - .replaceAll(`__BUNDLED_DEV__`, bundleDevReplacement) } export async function getHmrImplementation( config: ResolvedConfig, ): Promise { - const content = fs.readFileSync(normalizedClientEntry, 'utf-8') + const content = fs.readFileSync(normalizedFbmClientEntry, 'utf-8') const replacer = await createClientConfigValueReplacer(config) return ( replacer(content) diff --git a/packages/vite/src/node/server/bundledDev.ts b/packages/vite/src/node/server/bundledDev.ts index e7413dd75a5c2f..3943ed0fc467d7 100644 --- a/packages/vite/src/node/server/bundledDev.ts +++ b/packages/vite/src/node/server/bundledDev.ts @@ -7,7 +7,6 @@ import { import type { RolldownOutput } from 'rolldown' import colors from 'picocolors' import getEtag from 'etag' -import type { Update } from '#types/hmrPayload' import { ChunkMetadataMap, resolveRolldownOptions } from '../build' import { getHmrImplementation } from '../plugins/clientInjections' import { @@ -66,10 +65,6 @@ export class BundledDev { private initialBuildCompleted = false private _closed = false private clients = new Clients() - private invalidateCalledModules = new Map< - NormalizedHotChannelClient, - Set - >() private debouncedFullReload = debounce(20, () => { this.environment.hot.send({ type: 'full-reload', path: '*' }) this.environment.logger.info(colors.green(`page reload`), { @@ -83,6 +78,8 @@ export class BundledDev { memoryFiles: MemoryFiles = new MemoryFiles() + servedFallbackDuringInitialBuild = false + constructor(private environment: DevEnvironment) { if (environment.name !== 'client') { throw new Error( @@ -98,6 +95,8 @@ export class BundledDev { return this._devEngine } + private pendingPayloadFilenames = new Set() + async listen(): Promise { this._closed = false debug?.('INITIAL: setup bundle options') @@ -115,10 +114,13 @@ export class BundledDev { : rolldownOptions.output )! - this.environment.hot.on('vite:module-loaded', (payload, client) => { - this.clients.setupIfNeeded(client, payload.clientId) - this.devEngine.registerModules(payload.clientId, payload.modules) - }) + this.environment.hot.on( + 'vite:client-connected', + async (payload, client) => { + this.clients.setupIfNeeded(client, payload.clientId) + this.devEngine.registerClient(payload.clientId) + }, + ) this.environment.hot.on('vite:client:connect', (_payload, client) => { // Replay the cached build error to freshly connected clients. if (this.lastBuildError) { @@ -160,7 +162,6 @@ export class BundledDev { for (const { clientId, update } of updates) { const client = this.clients.get(clientId) if (client) { - this.invalidateCalledModules.get(client)?.clear() this.handleHmrOutput(client, changedFiles, update) } } @@ -210,7 +211,9 @@ export class BundledDev { this.waitForInitialBuildFinish().then(() => { if (this._closed) return debug?.('INITIAL: build done') - this.environment.hot.send({ type: 'full-reload', path: '*' }) + if (this.servedFallbackDuringInitialBuild) { + this.environment.hot.send({ type: 'full-reload', path: '*' }) + } this.initialBuildCompleted = true }) } @@ -230,60 +233,6 @@ export class BundledDev { } } - async invalidateModule( - m: { - path: string - message?: string - firstInvalidatedBy: string - }, - client: NormalizedHotChannelClient, - ): Promise { - const invalidateCalledModules = this.invalidateCalledModules.get(client) - if (invalidateCalledModules?.has(m.path)) { - debug?.( - `INVALIDATE: invalidate received from ${m.path}, but ignored because it was already invalidated`, - ) - return - } - - debug?.(`INVALIDATE: invalidate received from ${m.path}, re-triggering HMR`) - if (!invalidateCalledModules) { - this.invalidateCalledModules.set(client, new Set([])) - } - this.invalidateCalledModules.get(client)!.add(m.path) - - let update: BindingClientHmrUpdate['update'] | undefined - try { - const _update = await this.devEngine.invalidate( - m.path, - m.firstInvalidatedBy, - ) - update = _update.find( - (u) => this.clients.get(u.clientId) === client, - )?.update - } catch (e) { - client.send({ - type: 'error', - err: prepareError(e as Error), - }) - return - } - if (!update) return - - if (update.type === 'Patch') { - this.environment.logger.info( - colors.yellow(`hmr invalidate `) + - colors.dim(m.path) + - (m.message ? ` ${m.message}` : ''), - { timestamp: true }, - ) - } - - this.handleHmrOutput(client, [m.path], update, { - firstInvalidatedBy: m.firstInvalidatedBy, - }) - } - async triggerBundleRegenerationIfStale(): Promise { const bundleState = await this.devEngine.getBundleState() @@ -319,14 +268,27 @@ export class BundledDev { async triggerLazyBundling( moduleId: string | null, clientId: string | null, - ): Promise { + ): Promise<{ code: string; filename: string } | undefined> { if (!moduleId || !clientId) { return } debug?.( `TRIGGER-LAZY: trigger lazy bundling for module ${moduleId} for client ${clientId}`, ) - return await this.devEngine.compileEntry(moduleId, clientId) + const result = await this.devEngine.compileEntry(moduleId, clientId) + this.pendingPayloadFilenames.add(result.filename) + return result + } + + /** + * Called by the serving middlewares when the response for a payload completed. + * Only delivered payloads are recorded on the server's per-client ship map, so + * later chunks may omit a module only if the payload carrying it was delivered. + */ + markPayloadDelivered(filename: string): void { + if (this.pendingPayloadFilenames.delete(filename)) { + void this.devEngine.notifyPayloadDelivered(filename) + } } async close(): Promise { @@ -420,7 +382,6 @@ export class BundledDev { client: NormalizedHotChannelClient, files: string[], hmrOutput: HmrOutput, - invalidateInformation?: { firstInvalidatedBy: string }, ) { if (hmrOutput.type === 'Noop') return @@ -433,18 +394,12 @@ export class BundledDev { : '' this.environment.logger.info( colors.green(`trigger page reload `) + colors.dim(shortFile) + reason, - { clear: !invalidateInformation, timestamp: true }, + { clear: true, timestamp: true }, ) - if (invalidateInformation) { - // Invalidate does not get upgraded to `rebuild`, - // so `onOutput` will not be triggered and thus the reload needs to be triggered here. - this.devEngine.ensureLatestBuildOutput().then(async () => { - this.debouncedFullReload() - }) - } else { - // Use a flag to defer the reload until the `onOutput` callback to avoid error lay flashes. - this.fullReloadPending = true - } + // `import.meta.hot.invalidate()` is fully client-side now, so every server-sent + // reload comes from a file change: defer it until the `onOutput` callback to + // avoid error overlay flashes. + this.fullReloadPending = true return } @@ -453,6 +408,7 @@ export class BundledDev { code: typeof hmrOutput.code === 'string' ? '[code]' : hmrOutput.code, }) + this.pendingPayloadFilenames.add(hmrOutput.filename) this.memoryFiles.set(hmrOutput.filename, { // ensure that the generated hmr patch contains ESM syntax // this is to avoid attacks like GHSA-4v9v-hfq4-rm2v @@ -468,27 +424,20 @@ export class BundledDev { source: hmrOutput.sourcemap, }) } - const updates: Update[] = hmrOutput.hmrBoundaries.map((boundary: any) => { - return { - type: 'js-update', - url: hmrOutput.filename, - path: boundary.boundary, - acceptedPath: boundary.acceptedVia, - firstInvalidatedBy: invalidateInformation?.firstInvalidatedBy, - timestamp: Date.now(), - } - }) client.send({ - type: 'update', - updates, + type: 'fbm-update', + changedIds: hmrOutput.changedIds, + url: hmrOutput.filename, + seq: hmrOutput.seq, }) - const filePaths = [...new Set(updates.map((u) => u.path))] - const { formatted, truncated } = formatAndTruncateFileList(filePaths) - if (truncated) debugHmr?.(`hmr update ${filePaths.join(', ')}`) + const { formatted, truncated } = formatAndTruncateFileList( + hmrOutput.changedIds, + ) + if (truncated) debugHmr?.(`hmr update ${hmrOutput.changedIds.join(', ')}`) this.environment.logger.info( colors.green(`hmr update `) + colors.dim(formatted), { - clear: !invalidateInformation, + clear: true, timestamp: true, }, ) diff --git a/packages/vite/src/node/server/environment.ts b/packages/vite/src/node/server/environment.ts index d0cbe94c91c920..d7465710987b92 100644 --- a/packages/vite/src/node/server/environment.ts +++ b/packages/vite/src/node/server/environment.ts @@ -295,7 +295,7 @@ export class DevEnvironment extends BaseEnvironment { _client: NormalizedHotChannelClient, ): void { if (this.bundledDev) { - this.bundledDev.invalidateModule(m, _client) + // full-bundle mode handles `import.meta.hot.invalidate()` fully client-side return } diff --git a/packages/vite/src/node/server/middlewares/indexHtml.ts b/packages/vite/src/node/server/middlewares/indexHtml.ts index 657059c0c06838..7bd59846bb5f9a 100644 --- a/packages/vite/src/node/server/middlewares/indexHtml.ts +++ b/packages/vite/src/node/server/middlewares/indexHtml.ts @@ -479,15 +479,19 @@ export function indexHtmlMiddleware( return next() } const secFetchDest = req.headers['sec-fetch-dest'] + const isDocumentRequest = [ + 'document', + 'iframe', + 'frame', + 'fencedframe', + '', + undefined, + ].includes(secFetchDest) + if (isDocumentRequest && file === undefined) { + fullBundle.servedFallbackDuringInitialBuild = true + } if ( - [ - 'document', - 'iframe', - 'frame', - 'fencedframe', - '', - undefined, - ].includes(secFetchDest) && + isDocumentRequest && ((await fullBundle.triggerBundleRegenerationIfStale()) || file === undefined) ) { diff --git a/packages/vite/src/node/server/middlewares/memoryFiles.ts b/packages/vite/src/node/server/middlewares/memoryFiles.ts index 65316fe0425e08..308d7b4a5d06ca 100644 --- a/packages/vite/src/node/server/middlewares/memoryFiles.ts +++ b/packages/vite/src/node/server/middlewares/memoryFiles.ts @@ -6,8 +6,9 @@ import type { ViteDevServer } from '..' export function memoryFilesMiddleware( server: ViteDevServer, ): Connect.NextHandleFunction { - const memoryFiles = server.environments.client.bundledDev?.memoryFiles - if (!memoryFiles) { + const bundledDev = server.environments.client.bundledDev + const memoryFiles = bundledDev?.memoryFiles + if (!bundledDev || !memoryFiles) { throw new Error('memoryFilesMiddleware can only be used for fullBundleMode') } const headers = server.config.server.headers @@ -47,6 +48,7 @@ export function memoryFilesMiddleware( res.setHeader(name, headers[name]!) } + res.on('finish', () => bundledDev.markPayloadDelivered(filePath)) return res.end(file.source) } next() diff --git a/packages/vite/src/node/server/middlewares/triggerLazyBundling.ts b/packages/vite/src/node/server/middlewares/triggerLazyBundling.ts index 18235b0129d2d9..dbb91ac2121f5d 100644 --- a/packages/vite/src/node/server/middlewares/triggerLazyBundling.ts +++ b/packages/vite/src/node/server/middlewares/triggerLazyBundling.ts @@ -26,12 +26,13 @@ export function triggerLazyBundlingMiddleware( const moduleId = params.get('id') const clientId = params.get('clientId') - const code = await bundledDev.triggerLazyBundling(moduleId, clientId) - if (code == null) { + const result = await bundledDev.triggerLazyBundling(moduleId, clientId) + if (result == null) { return next() } res!.setHeader('Content-Type', 'application/javascript') - return res!.end(code) + res!.on('finish', () => bundledDev.markPayloadDelivered(result.filename)) + return res!.end(result.code) } } diff --git a/packages/vite/types/customEvent.d.ts b/packages/vite/types/customEvent.d.ts index 7ca838895fba26..acab38d9c627b4 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[]; clientId: string } + 'vite:client-connected': { clientId: string } // server events 'vite:client:connect': undefined diff --git a/packages/vite/types/hmrPayload.d.ts b/packages/vite/types/hmrPayload.d.ts index 8796a6b05cfd79..0a0dfc1a03eb26 100644 --- a/packages/vite/types/hmrPayload.d.ts +++ b/packages/vite/types/hmrPayload.d.ts @@ -4,6 +4,7 @@ export type HotPayload = | ConnectedPayload | PingPayload | UpdatePayload + | FbmUpdatePayload | FullReloadPayload | CustomPayload | ErrorPayload @@ -22,14 +23,21 @@ export interface UpdatePayload { updates: Update[] } +/** + * Full-bundle-mode update notification. The client computes the HMR boundaries + * itself from `changedIds`. + */ +export interface FbmUpdatePayload { + type: 'fbm-update' + changedIds: string[] + /** URL of the per-client HMR patch chunk */ + url: string + /** Per-client sequence number */ + seq: number +} + export interface Update { type: 'js-update' | 'css-update' - /** - * URL of HMR patch chunk - * - * This only exists when full-bundle mode is enabled. - */ - url?: string path: string acceptedPath: string timestamp: number From ffffe73c815858368311da287f472ee50727c15a Mon Sep 17 00:00:00 2001 From: Hana Date: Fri, 17 Jul 2026 21:16:09 +0800 Subject: [PATCH 02/13] chore: fix formatting --- packages/vite/src/module-runner/hmrHandler.ts | 6 +++--- 1 file changed, 3 insertions(+), 3 deletions(-) diff --git a/packages/vite/src/module-runner/hmrHandler.ts b/packages/vite/src/module-runner/hmrHandler.ts index 1dd280d58ca849..73c9405c6b7007 100644 --- a/packages/vite/src/module-runner/hmrHandler.ts +++ b/packages/vite/src/module-runner/hmrHandler.ts @@ -38,9 +38,9 @@ export function createHMRHandlerForRunner( const { triggeredBy } = payload const clearEntrypointUrls = triggeredBy ? getModulesEntrypoints( - runner, - getModulesByFile(runner, slash(triggeredBy)), - ) + runner, + getModulesByFile(runner, slash(triggeredBy)), + ) : findAllEntrypoints(runner) if (!clearEntrypointUrls.size) break From 471a33479afc87aa8c35b8c96c051c6dda72b0df Mon Sep 17 00:00:00 2001 From: Hana Date: Fri, 17 Jul 2026 21:16:11 +0800 Subject: [PATCH 03/13] test(bundled-dev): update invalidate test for client-side HMR, add reload and worker cases --- .../__tests__/hmr-full-bundle-mode.spec.ts | 86 ++++++++++++++++++- playground/hmr-full-bundle-mode/cycle-a.js | 4 + playground/hmr-full-bundle-mode/cycle-b.js | 5 ++ .../hmr-full-bundle-mode/dead-accept.js | 8 ++ playground/hmr-full-bundle-mode/index.html | 3 + playground/hmr-full-bundle-mode/main.js | 12 +++ .../hmr-full-bundle-mode/worker-plain-dep.js | 1 + .../hmr-full-bundle-mode/worker-plain.js | 7 ++ 8 files changed, 122 insertions(+), 4 deletions(-) create mode 100644 playground/hmr-full-bundle-mode/cycle-a.js create mode 100644 playground/hmr-full-bundle-mode/cycle-b.js create mode 100644 playground/hmr-full-bundle-mode/dead-accept.js create mode 100644 playground/hmr-full-bundle-mode/worker-plain-dep.js create mode 100644 playground/hmr-full-bundle-mode/worker-plain.js 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 6c643de119070b..eb533d7a8c49d1 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 @@ -1,6 +1,6 @@ import { setTimeout } from 'node:timers/promises' import { expect, test, onTestFinished } from 'vitest' -import { addFile, editFile, isBuild, page, readFile } from '~utils' +import { addFile, browserLogs, editFile, isBuild, page, readFile } from '~utils' const assetUrl = /asset-[\w-]+\.png/ @@ -231,14 +231,92 @@ if (isBuild) { await expect .poll(() => page.textContent('.invalidation-parent')) .toBe('child') + const logIndex = browserLogs.length editFile('invalidation-child.js', (code) => code.replace("'child'", "'child updated'"), ) - // With client-side HMR, `import.meta.hot.invalidate()` is handled inside - // the client and never reaches the server, so there is no "hmr invalidate" - // server log. Assert the user-visible result instead. + // `hot.invalidate()` is handled fully client-side; the update propagates + // to the parent (re-run in place, or a full reload when the parent's + // factory was never shipped) await expect .poll(() => page.textContent('.invalidation-parent')) .toBe('child updated') + expect( + browserLogs + .slice(logIndex) + .some( + (l) => l.includes('invalidate') && l.includes('invalidation-child'), + ), + ).toBe(true) + }) + + test('never-executed accept falls back to a full reload', async () => { + await expect + .poll(() => page.textContent('.invalidation-parent')) + .toBe('child') + + const original = readFile('dead-accept.js') + onTestFinished(async () => { + addFile('dead-accept.js', original) + await expect + .poll(() => page.textContent('.dead-accept')) + .toBe('dead-accept') + }) + + await expect + .poll(() => page.textContent('.dead-accept')) + .toBe('dead-accept') + editFile('dead-accept.js', (code) => + code.replace("'dead-accept'", "'dead-accept-updated'"), + ) + await expect + .poll(() => page.textContent('.dead-accept')) + .toBe('dead-accept-updated') + }) + + test('editing a worker-only module without accept reloads the page', async () => { + const original = readFile('worker-plain-dep.js') + onTestFinished(async () => { + addFile('worker-plain-dep.js', original) + await page.reload() + await expect + .poll(() => page.textContent('.worker-plain')) + .toBe('worker-plain') + }) + + await expect + .poll(() => page.textContent('.worker-plain')) + .toBe('worker-plain') + editFile('worker-plain-dep.js', (code) => + code.replace("'worker-plain'", "'worker-plain-updated'"), + ) + await expect + .poll(() => page.textContent('.worker-plain')) + .toBe('worker-plain-updated') + }) + + test.skip('chained invalidate in an import cycle settles', async () => { + const original = readFile('cycle-a.js') + onTestFinished(async () => { + addFile('cycle-a.js', original) + await page.reload() + await expect.poll(() => page.textContent('.cycle')).toBe('cycle') + }) + + const invalidateCount = () => + browserLogs.filter( + (l) => l.includes('invalidate') && l.includes('cycle-b'), + ).length + + await expect.poll(() => page.textContent('.cycle')).toBe('cycle') + editFile('cycle-a.js', (code) => code.replace("'cycle'", "'cycle-updated'")) + await expect.poll(() => page.textContent('.cycle')).toBe('cycle-updated') + + // the invalidate count must stop growing once the update settles + await setTimeout(1000) + const settled = invalidateCount() + await setTimeout(1000) + expect(invalidateCount()).toBe(settled) + expect(settled).toBeLessThanOrEqual(2) }) } diff --git a/playground/hmr-full-bundle-mode/cycle-a.js b/playground/hmr-full-bundle-mode/cycle-a.js new file mode 100644 index 00000000000000..c9e3cf32ad7e86 --- /dev/null +++ b/playground/hmr-full-bundle-mode/cycle-a.js @@ -0,0 +1,4 @@ +import './cycle-b.js' + +export const value = 'cycle' +document.querySelector('.cycle').textContent = value diff --git a/playground/hmr-full-bundle-mode/cycle-b.js b/playground/hmr-full-bundle-mode/cycle-b.js new file mode 100644 index 00000000000000..acca4ea9d5d30b --- /dev/null +++ b/playground/hmr-full-bundle-mode/cycle-b.js @@ -0,0 +1,5 @@ +import './cycle-a.js' + +import.meta.hot?.accept('./cycle-a.js', () => { + import.meta.hot.invalidate() +}) diff --git a/playground/hmr-full-bundle-mode/dead-accept.js b/playground/hmr-full-bundle-mode/dead-accept.js new file mode 100644 index 00000000000000..62bfb4703d2a1f --- /dev/null +++ b/playground/hmr-full-bundle-mode/dead-accept.js @@ -0,0 +1,8 @@ +export const value = 'dead-accept' +document.querySelector('.dead-accept').textContent = value + +// visible to static analysis so the server ships a patch instead of +// broadcasting a full reload itself, but never executed at runtime +if (globalThis.__NEVER_TRUE__) { + import.meta.hot?.accept(() => {}) +} diff --git a/playground/hmr-full-bundle-mode/index.html b/playground/hmr-full-bundle-mode/index.html index 751b87eb2e99cb..34beb643bb59b0 100644 --- a/playground/hmr-full-bundle-mode/index.html +++ b/playground/hmr-full-bundle-mode/index.html @@ -7,6 +7,9 @@

HMR Full Bundle Mode

+
+
+
diff --git a/playground/hmr-full-bundle-mode/main.js b/playground/hmr-full-bundle-mode/main.js index 55b95caa69c8ff..1d6f84e247fca4 100644 --- a/playground/hmr-full-bundle-mode/main.js +++ b/playground/hmr-full-bundle-mode/main.js @@ -1,6 +1,8 @@ import './hmr.js' import './hmr-asset.js' import './invalidation-parent.js' +import './dead-accept.js' +import './cycle-a.js' import assetUrl from './asset.png' import WorkerQuery from './worker-query.js?worker' @@ -21,10 +23,20 @@ workerUrl.addEventListener('message', (e) => { text('.worker-url', e.data) }) +const workerPlain = new Worker(new URL('./worker-plain.js', import.meta.url), { + type: 'module', +}) +workerPlain.postMessage('ping') +workerPlain.addEventListener('message', (e) => { + text('.worker-plain', e.data) +}) + document.querySelector('#load-dynamic').addEventListener('click', () => { import('./dynamic.js') }) +import.meta.hot?.accept('./cycle-a.js', () => {}) + function text(el, text) { document.querySelector(el).textContent = text } diff --git a/playground/hmr-full-bundle-mode/worker-plain-dep.js b/playground/hmr-full-bundle-mode/worker-plain-dep.js new file mode 100644 index 00000000000000..59bb2a9501fa7c --- /dev/null +++ b/playground/hmr-full-bundle-mode/worker-plain-dep.js @@ -0,0 +1 @@ +export const msg = 'worker-plain' diff --git a/playground/hmr-full-bundle-mode/worker-plain.js b/playground/hmr-full-bundle-mode/worker-plain.js new file mode 100644 index 00000000000000..f1151f1274e8d5 --- /dev/null +++ b/playground/hmr-full-bundle-mode/worker-plain.js @@ -0,0 +1,7 @@ +import { msg } from './worker-plain-dep.js' + +self.onmessage = (e) => { + if (e.data === 'ping') { + self.postMessage(msg) + } +} From 5fd146379a7975ed8f54dd5f14099ce998d8334e Mon Sep 17 00:00:00 2001 From: Hana Date: Fri, 17 Jul 2026 21:34:49 +0800 Subject: [PATCH 04/13] test: fix worker test cleanup race --- .../__tests__/hmr-full-bundle-mode.spec.ts | 3 ++- 1 file changed, 2 insertions(+), 1 deletion(-) 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 eb533d7a8c49d1..3b737ea0dd9bfa 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 @@ -277,8 +277,9 @@ if (isBuild) { test('editing a worker-only module without accept reloads the page', async () => { const original = readFile('worker-plain-dep.js') onTestFinished(async () => { + // the restore edit itself makes the page client reload; a manual + // `page.reload()` here would race the rebuild and lose the update addFile('worker-plain-dep.js', original) - await page.reload() await expect .poll(() => page.textContent('.worker-plain')) .toBe('worker-plain') From d4ba516baf4a14fccd51bff67e9590b9980393b6 Mon Sep 17 00:00:00 2001 From: Hana Date: Fri, 17 Jul 2026 22:13:37 +0800 Subject: [PATCH 05/13] refactor: expose only the public client API from client.mjs --- packages/vite/rolldown.config.ts | 2 +- packages/vite/src/client/clientEntry.ts | 11 +++++++++++ 2 files changed, 12 insertions(+), 1 deletion(-) create mode 100644 packages/vite/src/client/clientEntry.ts diff --git a/packages/vite/rolldown.config.ts b/packages/vite/rolldown.config.ts index 7336621b299d39..04e1d88c5b4c34 100644 --- a/packages/vite/rolldown.config.ts +++ b/packages/vite/rolldown.config.ts @@ -26,7 +26,7 @@ const envConfig = defineConfig({ }) const clientConfig = defineConfig({ - input: path.resolve(dirname, 'src/client/client.ts'), + input: path.resolve(dirname, 'src/client/clientEntry.ts'), platform: 'browser', transform: { target: 'es2020', diff --git a/packages/vite/src/client/clientEntry.ts b/packages/vite/src/client/clientEntry.ts new file mode 100644 index 00000000000000..71860e8156d1cc --- /dev/null +++ b/packages/vite/src/client/clientEntry.ts @@ -0,0 +1,11 @@ +// entry for `client.mjs` (middleware mode). `client.ts` exports extra internal +// bindings (`transport`, `registerFbmClient`, ...) so the full-bundle-mode +// entry can reuse them; this wrapper keeps them out of `/@vite/client`'s +// public API. +export { + createHotContext, + injectQuery, + removeStyle, + updateStyle, + ErrorOverlay, +} from './client' From 4fe72b9d7ba31c77e44410afb22bd5056503a694 Mon Sep 17 00:00:00 2001 From: Hana Date: Tue, 21 Jul 2026 15:34:21 +0800 Subject: [PATCH 06/13] refactor(bundled-dev): decide fallback-page reload on the client (ifFallback) --- packages/vite/src/client/client.ts | 8 +++++++ packages/vite/src/node/server/bundledDev.ts | 21 ++++++++++++++----- .../src/node/server/middlewares/indexHtml.ts | 4 +--- packages/vite/types/hmrPayload.d.ts | 6 ++++++ 4 files changed, 31 insertions(+), 8 deletions(-) diff --git a/packages/vite/src/client/client.ts b/packages/vite/src/client/client.ts index f14466c367e7bd..61a86c5c619580 100644 --- a/packages/vite/src/client/client.ts +++ b/packages/vite/src/client/client.ts @@ -279,6 +279,14 @@ async function handleMessage(payload: HotPayload) { break } case 'full-reload': + // `ifFallback` reloads are addressed only to the bundling-fallback page, + // which marks itself with this global (see `generateFallbackHtml`) + if ( + payload.ifFallback && + !(globalThis as any).__vite_is_fallback_page__ + ) { + break + } await activeHmrClient.notifyListeners('vite:beforeFullReload', payload) if (hasDocument) { if (payload.path && payload.path.endsWith('.html')) { diff --git a/packages/vite/src/node/server/bundledDev.ts b/packages/vite/src/node/server/bundledDev.ts index 3943ed0fc467d7..f1db05ddc6d141 100644 --- a/packages/vite/src/node/server/bundledDev.ts +++ b/packages/vite/src/node/server/bundledDev.ts @@ -78,8 +78,6 @@ export class BundledDev { memoryFiles: MemoryFiles = new MemoryFiles() - servedFallbackDuringInitialBuild = false - constructor(private environment: DevEnvironment) { if (environment.name !== 'client') { throw new Error( @@ -130,6 +128,13 @@ export class BundledDev { type: 'error', err: prepareError(this.lastBuildError), }) + } else if (this.initialBuildCompleted) { + // A fallback page whose socket connects after the initial-build + // completion broadcast would otherwise stay on the spinner. Over-sending + // is safe: only the fallback page acts on `ifFallback` reloads. Not sent + // while the initial build is still running (or failed — no output to + // serve yet), as reloading would only lead back to the fallback page. + client.send({ type: 'full-reload', path: '*', ifFallback: true }) } }) this.environment.hot.on('vite:client:disconnect', (_payload, client) => { @@ -211,10 +216,16 @@ export class BundledDev { this.waitForInitialBuildFinish().then(() => { if (this._closed) return debug?.('INITIAL: build done') - if (this.servedFallbackDuringInitialBuild) { - this.environment.hot.send({ type: 'full-reload', path: '*' }) - } + // Set the flag before broadcasting so a client that connects in between + // is caught by the `vite:client:connect` replay above. this.initialBuildCompleted = true + if (!this.lastBuildError) { + this.environment.hot.send({ + type: 'full-reload', + path: '*', + ifFallback: true, + }) + } }) } diff --git a/packages/vite/src/node/server/middlewares/indexHtml.ts b/packages/vite/src/node/server/middlewares/indexHtml.ts index 7bd59846bb5f9a..e7c055c2b7364c 100644 --- a/packages/vite/src/node/server/middlewares/indexHtml.ts +++ b/packages/vite/src/node/server/middlewares/indexHtml.ts @@ -487,9 +487,6 @@ export function indexHtmlMiddleware( '', undefined, ].includes(secFetchDest) - if (isDocumentRequest && file === undefined) { - fullBundle.servedFallbackDuringInitialBuild = true - } if ( isDocumentRequest && ((await fullBundle.triggerBundleRegenerationIfStale()) || @@ -575,6 +572,7 @@ async function generateFallbackHtml(server: ViteDevServer) { + ', '<\\/script>')} diff --git a/packages/vite/types/hmrPayload.d.ts b/packages/vite/types/hmrPayload.d.ts index 0a0dfc1a03eb26..ec2f32c706b137 100644 --- a/packages/vite/types/hmrPayload.d.ts +++ b/packages/vite/types/hmrPayload.d.ts @@ -61,6 +61,12 @@ export interface FullReloadPayload { path?: string /** @internal */ triggeredBy?: string + /** + * When set, only the bundling-fallback page acts on the reload; + * other pages ignore the message. + * @internal + */ + ifFallback?: boolean } export interface CustomPayload { From 7ea245776963e68b13e7d52f512d257a95646954 Mon Sep 17 00:00:00 2001 From: Hana Date: Tue, 21 Jul 2026 15:55:21 +0800 Subject: [PATCH 07/13] fix(bundled-dev): don't replay the fallback reload on client connect --- packages/vite/src/node/server/bundledDev.ts | 9 --------- 1 file changed, 9 deletions(-) diff --git a/packages/vite/src/node/server/bundledDev.ts b/packages/vite/src/node/server/bundledDev.ts index f1db05ddc6d141..4a9ddcc95a74c6 100644 --- a/packages/vite/src/node/server/bundledDev.ts +++ b/packages/vite/src/node/server/bundledDev.ts @@ -128,13 +128,6 @@ export class BundledDev { type: 'error', err: prepareError(this.lastBuildError), }) - } else if (this.initialBuildCompleted) { - // A fallback page whose socket connects after the initial-build - // completion broadcast would otherwise stay on the spinner. Over-sending - // is safe: only the fallback page acts on `ifFallback` reloads. Not sent - // while the initial build is still running (or failed — no output to - // serve yet), as reloading would only lead back to the fallback page. - client.send({ type: 'full-reload', path: '*', ifFallback: true }) } }) this.environment.hot.on('vite:client:disconnect', (_payload, client) => { @@ -216,8 +209,6 @@ export class BundledDev { this.waitForInitialBuildFinish().then(() => { if (this._closed) return debug?.('INITIAL: build done') - // Set the flag before broadcasting so a client that connects in between - // is caught by the `vite:client:connect` replay above. this.initialBuildCompleted = true if (!this.lastBuildError) { this.environment.hot.send({ From aa8e6ef0867ad06bc30c4c2e927f0b019bcf70ca Mon Sep 17 00:00:00 2001 From: andrew Date: Tue, 21 Jul 2026 19:48:15 +0800 Subject: [PATCH 08/13] Update packages/vite/src/node/config.ts MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Co-authored-by: 翠 --- packages/vite/src/node/config.ts | 10 ++++------ 1 file changed, 4 insertions(+), 6 deletions(-) diff --git a/packages/vite/src/node/config.ts b/packages/vite/src/node/config.ts index 061a907696c2b5..d09ed0c8249456 100644 --- a/packages/vite/src/node/config.ts +++ b/packages/vite/src/node/config.ts @@ -623,14 +623,12 @@ export interface ExperimentalOptions { * in three ways (boundaries are computed in the browser from runtime state, not * statically on the server): * - * - Acceptance counts only when it executed: an `import.meta.hot.accept()` that is - * visible in the source but never ran (e.g. inside a dead branch) does not suppress - * the update — it triggers a full reload instead. + * - Acceptance counts only when it executed: an `import.meta.hot.accept()` that + * is in a dead branch does not suppress the update and falls back to a full reload. * - `hot.dispose` runs for every module the update re-executes, each receiving a - * fresh `hot.data` object — not only for the accepted module with a shared + * fresh `hot.data` object and not only for the accepted module with a shared * persistent object. - * - `hot.invalidate()` is handled fully client-side (a re-walk from the invalidator's - * importers); no request is sent to the server. + * - `hot.invalidate()` is handled fully client-side. * * @experimental * @default false From d96d351804557f5c5d7f7afbefb57e4cbec30321 Mon Sep 17 00:00:00 2001 From: Hana Date: Tue, 21 Jul 2026 20:10:39 +0800 Subject: [PATCH 09/13] refactor(bundled-dev): rename fbm names to bundled-dev --- packages/vite/rolldown.config.ts | 8 +++--- .../{fbmClient.ts => bundledDevClient.ts} | 15 ++++++---- ...fbmHmrClient.ts => bundledDevHmrClient.ts} | 28 +++++++++++-------- packages/vite/src/client/client.ts | 18 ++++++------ packages/vite/src/client/clientEntry.ts | 2 +- packages/vite/src/module-runner/hmrHandler.ts | 2 +- packages/vite/src/node/constants.ts | 4 +-- .../vite/src/node/plugins/clientInjections.ts | 6 ++-- packages/vite/src/node/server/bundledDev.ts | 2 +- packages/vite/types/hmrPayload.d.ts | 6 ++-- 10 files changed, 49 insertions(+), 42 deletions(-) rename packages/vite/src/client/{fbmClient.ts => bundledDevClient.ts} (81%) rename packages/vite/src/client/{fbmHmrClient.ts => bundledDevHmrClient.ts} (92%) diff --git a/packages/vite/rolldown.config.ts b/packages/vite/rolldown.config.ts index 04e1d88c5b4c34..7e4f0be4a7104c 100644 --- a/packages/vite/rolldown.config.ts +++ b/packages/vite/rolldown.config.ts @@ -39,8 +39,8 @@ const clientConfig = defineConfig({ }) // separate entry so the full-bundle-mode HMR code is never bundled into `client.mjs` -const fbmClientConfig = defineConfig({ - input: path.resolve(dirname, 'src/client/fbmClient.ts'), +const bundledDevClientConfig = defineConfig({ + input: path.resolve(dirname, 'src/client/bundledDevClient.ts'), platform: 'browser', transform: { target: 'es2020', @@ -48,7 +48,7 @@ const fbmClientConfig = defineConfig({ external: ['@vite/env'], output: { dir: path.resolve(dirname, 'dist'), - entryFileNames: 'client/fbmClient.mjs', + entryFileNames: 'client/bundledDevClient.mjs', }, }) @@ -169,7 +169,7 @@ const moduleRunnerConfig = defineConfig({ export default defineConfig([ envConfig, clientConfig, - fbmClientConfig, + bundledDevClientConfig, nodeConfig, moduleRunnerConfig, ]) diff --git a/packages/vite/src/client/fbmClient.ts b/packages/vite/src/client/bundledDevClient.ts similarity index 81% rename from packages/vite/src/client/fbmClient.ts rename to packages/vite/src/client/bundledDevClient.ts index bd4f0341e0756f..332547fdb1f0c2 100644 --- a/packages/vite/src/client/fbmClient.ts +++ b/packages/vite/src/client/bundledDevClient.ts @@ -1,11 +1,14 @@ import { nanoid } from 'nanoid/non-secure' import type { DevRuntime as DevRuntimeType } from 'rolldown/experimental/runtime-types' -import { FbmHMRClient, FbmHMRContext } from './fbmHmrClient' +import { + BundledDevHMRClient, + BundledDevHMRContext, +} from './bundledDevHmrClient' import { base, clearOverlayOrReloadOnFirstUpdate, pageReload, - registerFbmClient, + registerBundledDevClient, removeStyle, transport, updateStyle, @@ -26,7 +29,7 @@ declare const DevRuntime: typeof DevRuntimeType if (typeof DevRuntime !== 'undefined') { class ViteDevRuntime extends DevRuntime { override createModuleHotContext(moduleId: string) { - const ctx = new FbmHMRContext(fbmHmrClient, moduleId) + const ctx = new BundledDevHMRContext(bundledDevHmrClient, moduleId) // @ts-expect-error TODO: support CSS properly ctx._internal = { updateStyle, removeStyle } return ctx @@ -44,7 +47,7 @@ if (typeof DevRuntime !== 'undefined') { const runtime = ((globalThis as any).__rolldown_runtime__ ??= new ViteDevRuntime(clientId)) - const fbmHmrClient = new FbmHMRClient( + const bundledDevHmrClient = new BundledDevHMRClient( { error: (err) => console.error('[vite]', err), debug: (...msg) => console.debug('[vite]', ...msg), @@ -57,11 +60,11 @@ if (typeof DevRuntime !== 'undefined') { pageReload, }, ) - registerFbmClient(fbmHmrClient) + registerBundledDevClient(bundledDevHmrClient) runtime.hooks = { createModuleHotContext: (id: string) => runtime.createModuleHotContext(id), onModuleCacheRemoval: (id: string) => - fbmHmrClient.handleModuleCacheRemoval(id), + bundledDevHmrClient.handleModuleCacheRemoval(id), } } diff --git a/packages/vite/src/client/fbmHmrClient.ts b/packages/vite/src/client/bundledDevHmrClient.ts similarity index 92% rename from packages/vite/src/client/fbmHmrClient.ts rename to packages/vite/src/client/bundledDevHmrClient.ts index 77189b139693c4..d32d625ef375cb 100644 --- a/packages/vite/src/client/fbmHmrClient.ts +++ b/packages/vite/src/client/bundledDevHmrClient.ts @@ -1,4 +1,8 @@ -import type { FbmUpdatePayload, Update, UpdatePayload } from '#types/hmrPayload' +import type { + BundledDevUpdatePayload, + Update, + UpdatePayload, +} from '#types/hmrPayload' import { HMRClient, HMRContext, type HMRLogger } from '../shared/hmr' import type { NormalizedModuleRunnerTransport } from '../shared/moduleRunnerTransport' @@ -22,14 +26,14 @@ type HmrUpdate = updateSet: string[] } -export interface FbmHMRClientOptions { +export interface BundledDevHMRClientOptions { base: string /** returning `'reload'` aborts the apply — the hook reloads the page itself */ beforeApply: () => 'reload' | 'continue' pageReload: () => void } -export class FbmHMRClient extends HMRClient { +export class BundledDevHMRClient extends HMRClient { private applyQueue = Promise.resolve() private lastSeq = 0 @@ -37,7 +41,7 @@ export class FbmHMRClient extends HMRClient { logger: HMRLogger, transport: NormalizedModuleRunnerTransport, private runtime: RolldownRuntimeLike, - private options: FbmHMRClientOptions, + private options: BundledDevHMRClientOptions, ) { super(logger, transport, async () => { throw new Error( @@ -140,7 +144,7 @@ export class FbmHMRClient extends HMRClient { } } - handlePush(payload: FbmUpdatePayload): void { + handlePush(payload: BundledDevUpdatePayload): void { this.applyQueue = this.applyQueue .then(() => this.applyPush(payload)) .catch((err) => { @@ -170,7 +174,7 @@ export class FbmHMRClient extends HMRClient { changedIds, url, seq, - }: FbmUpdatePayload): Promise { + }: BundledDevUpdatePayload): Promise { if (seq !== this.lastSeq + 1) { this.requestFullReload( `hmr update sequence gap (expected ${this.lastSeq + 1}, got ${seq})`, @@ -299,21 +303,21 @@ export class FbmHMRClient extends HMRClient { } } -export class FbmHMRContext extends HMRContext { +export class BundledDevHMRContext extends HMRContext { constructor( - private fbmClient: FbmHMRClient, + private bundledDevClient: BundledDevHMRClient, private owner: string, ) { - super(fbmClient, owner) + super(bundledDevClient, owner) } override invalidate(message: string): void { - this.fbmClient.notifyListeners('vite:invalidate', { + this.bundledDevClient.notifyListeners('vite:invalidate', { path: this.owner, message, firstInvalidatedBy: - this.fbmClient.currentFirstInvalidatedBy ?? this.owner, + this.bundledDevClient.currentFirstInvalidatedBy ?? this.owner, }) - this.fbmClient.invalidateLocally(this.owner, message) + this.bundledDevClient.invalidateLocally(this.owner, message) } } diff --git a/packages/vite/src/client/client.ts b/packages/vite/src/client/client.ts index 61a86c5c619580..e75c439dd1bfa7 100644 --- a/packages/vite/src/client/client.ts +++ b/packages/vite/src/client/client.ts @@ -7,7 +7,7 @@ import { } from '../shared/moduleRunnerTransport' import { createHMRHandler } from '../shared/hmrHandler' import { setupForwardConsoleHandler } from '../shared/forwardConsole' -import type { FbmHMRClient } from './fbmHmrClient' +import type { BundledDevHMRClient } from './bundledDevHmrClient' import { ErrorOverlay, cspNonce, overlayId } from './overlay' // @ts-expect-error internal virtual module import '@vite/env' @@ -172,11 +172,11 @@ const hmrClient = new HMRClient( return await importPromise }, ) -// set by the full-bundle-mode entry (`fbmClient.ts`); the `import type` above keeps -// `FbmHMRClient` compile-time only, so `client.mjs` bundles no FBM code -let fbmClient: FbmHMRClient | undefined -export function registerFbmClient(client: FbmHMRClient): void { - fbmClient = client +// set by the full-bundle-mode entry (`bundledDevClient.ts`); the `import type` above keeps +// `BundledDevHMRClient` compile-time only, so `client.mjs` bundles no bundled-dev code +let bundledDevClient: BundledDevHMRClient | undefined +export function registerBundledDevClient(client: BundledDevHMRClient): void { + bundledDevClient = client } transport.connect!(createHMRHandler(handleMessage)) @@ -201,13 +201,13 @@ export function clearOverlayOrReloadOnFirstUpdate(): 'reload' | 'continue' { } async function handleMessage(payload: HotPayload) { - const activeHmrClient = fbmClient ?? hmrClient + const activeHmrClient = bundledDevClient ?? hmrClient switch (payload.type) { case 'connected': console.debug(`[vite] connected.`) break - case 'fbm-update': - fbmClient!.handlePush(payload) + case 'bundled-dev-update': + bundledDevClient!.handlePush(payload) break case 'update': await activeHmrClient.notifyListeners('vite:beforeUpdate', payload) diff --git a/packages/vite/src/client/clientEntry.ts b/packages/vite/src/client/clientEntry.ts index 71860e8156d1cc..0f364d80e846e0 100644 --- a/packages/vite/src/client/clientEntry.ts +++ b/packages/vite/src/client/clientEntry.ts @@ -1,5 +1,5 @@ // entry for `client.mjs` (middleware mode). `client.ts` exports extra internal -// bindings (`transport`, `registerFbmClient`, ...) so the full-bundle-mode +// bindings (`transport`, `registerBundledDevClient`, ...) so the full-bundle-mode // entry can reuse them; this wrapper keeps them out of `/@vite/client`'s // public API. export { diff --git a/packages/vite/src/module-runner/hmrHandler.ts b/packages/vite/src/module-runner/hmrHandler.ts index 73c9405c6b7007..ec4fb5aec4b4c9 100644 --- a/packages/vite/src/module-runner/hmrHandler.ts +++ b/packages/vite/src/module-runner/hmrHandler.ts @@ -78,7 +78,7 @@ export function createHMRHandlerForRunner( } case 'ping': // noop break - case 'fbm-update': // todo + case 'bundled-dev-update': // todo break default: { const check: never = payload diff --git a/packages/vite/src/node/constants.ts b/packages/vite/src/node/constants.ts index 1234ac4cf186bf..e5ff90bca483c8 100644 --- a/packages/vite/src/node/constants.ts +++ b/packages/vite/src/node/constants.ts @@ -129,9 +129,9 @@ export const CLIENT_ENTRY: string = resolve( VITE_PACKAGE_DIR, 'dist/client/client.mjs', ) -export const FBM_CLIENT_ENTRY: string = resolve( +export const BUNDLED_DEV_CLIENT_ENTRY: string = resolve( VITE_PACKAGE_DIR, - 'dist/client/fbmClient.mjs', + 'dist/client/bundledDevClient.mjs', ) export const ENV_ENTRY: string = resolve( VITE_PACKAGE_DIR, diff --git a/packages/vite/src/node/plugins/clientInjections.ts b/packages/vite/src/node/plugins/clientInjections.ts index 871125b5e6ef82..8ad6f399db2a8a 100644 --- a/packages/vite/src/node/plugins/clientInjections.ts +++ b/packages/vite/src/node/plugins/clientInjections.ts @@ -2,7 +2,7 @@ import path from 'node:path' import fs from 'node:fs' import type { Plugin } from '../plugin' import type { ResolvedConfig } from '../config' -import { CLIENT_ENTRY, ENV_ENTRY, FBM_CLIENT_ENTRY } from '../constants' +import { CLIENT_ENTRY, ENV_ENTRY, BUNDLED_DEV_CLIENT_ENTRY } from '../constants' import { isObject, normalizePath, resolveHostname } from '../utils' import { cleanUrl } from '../../shared/utils' import { perEnvironmentState } from '../environment' @@ -10,7 +10,7 @@ import { replaceDefine, serializeDefine } from './define' // ids in transform are normalized to unix style const normalizedClientEntry = normalizePath(CLIENT_ENTRY) -const normalizedFbmClientEntry = normalizePath(FBM_CLIENT_ENTRY) +const normalizedBundledDevClientEntry = normalizePath(BUNDLED_DEV_CLIENT_ENTRY) const normalizedEnvEntry = normalizePath(ENV_ENTRY) /** @@ -141,7 +141,7 @@ async function createClientConfigValueReplacer( export async function getHmrImplementation( config: ResolvedConfig, ): Promise { - const content = fs.readFileSync(normalizedFbmClientEntry, 'utf-8') + const content = fs.readFileSync(normalizedBundledDevClientEntry, 'utf-8') const replacer = await createClientConfigValueReplacer(config) return ( replacer(content) diff --git a/packages/vite/src/node/server/bundledDev.ts b/packages/vite/src/node/server/bundledDev.ts index 4a9ddcc95a74c6..72c794f14fe7db 100644 --- a/packages/vite/src/node/server/bundledDev.ts +++ b/packages/vite/src/node/server/bundledDev.ts @@ -427,7 +427,7 @@ export class BundledDev { }) } client.send({ - type: 'fbm-update', + type: 'bundled-dev-update', changedIds: hmrOutput.changedIds, url: hmrOutput.filename, seq: hmrOutput.seq, diff --git a/packages/vite/types/hmrPayload.d.ts b/packages/vite/types/hmrPayload.d.ts index ec2f32c706b137..85daa03647ba1f 100644 --- a/packages/vite/types/hmrPayload.d.ts +++ b/packages/vite/types/hmrPayload.d.ts @@ -4,7 +4,7 @@ export type HotPayload = | ConnectedPayload | PingPayload | UpdatePayload - | FbmUpdatePayload + | BundledDevUpdatePayload | FullReloadPayload | CustomPayload | ErrorPayload @@ -27,8 +27,8 @@ export interface UpdatePayload { * Full-bundle-mode update notification. The client computes the HMR boundaries * itself from `changedIds`. */ -export interface FbmUpdatePayload { - type: 'fbm-update' +export interface BundledDevUpdatePayload { + type: 'bundled-dev-update' changedIds: string[] /** URL of the per-client HMR patch chunk */ url: string From 84fee119cf1d2aa7575049b09869ffd396a25dfe Mon Sep 17 00:00:00 2001 From: Hana Date: Tue, 21 Jul 2026 20:18:08 +0800 Subject: [PATCH 10/13] docs(bundled-dev): drop hot.data difference note, document payload filename uniqueness --- packages/vite/src/node/config.ts | 5 +---- packages/vite/src/node/server/bundledDev.ts | 4 +++- 2 files changed, 4 insertions(+), 5 deletions(-) diff --git a/packages/vite/src/node/config.ts b/packages/vite/src/node/config.ts index d09ed0c8249456..89eac6cf1732ec 100644 --- a/packages/vite/src/node/config.ts +++ b/packages/vite/src/node/config.ts @@ -620,14 +620,11 @@ export interface ExperimentalOptions { * This is highly experimental. * * HMR semantics under full bundle mode differ from the middleware-based dev server - * in three ways (boundaries are computed in the browser from runtime state, not + * in two ways (boundaries are computed in the browser from runtime state, not * statically on the server): * * - Acceptance counts only when it executed: an `import.meta.hot.accept()` that * is in a dead branch does not suppress the update and falls back to a full reload. - * - `hot.dispose` runs for every module the update re-executes, each receiving a - * fresh `hot.data` object and not only for the accepted module with a shared - * persistent object. * - `hot.invalidate()` is handled fully client-side. * * @experimental diff --git a/packages/vite/src/node/server/bundledDev.ts b/packages/vite/src/node/server/bundledDev.ts index 72c794f14fe7db..8c7bdbf3c7024e 100644 --- a/packages/vite/src/node/server/bundledDev.ts +++ b/packages/vite/src/node/server/bundledDev.ts @@ -286,10 +286,12 @@ export class BundledDev { * Called by the serving middlewares when the response for a payload completed. * Only delivered payloads are recorded on the server's per-client ship map, so * later chunks may omit a module only if the payload carrying it was delivered. + * + * Note: the payload filename is unique across all clients. */ markPayloadDelivered(filename: string): void { if (this.pendingPayloadFilenames.delete(filename)) { - void this.devEngine.notifyPayloadDelivered(filename) + this.devEngine.notifyPayloadDelivered(filename) } } From bdad1fe430e25de35896ee7351ea308a71acf2c6 Mon Sep 17 00:00:00 2001 From: Hana Date: Tue, 21 Jul 2026 20:19:24 +0800 Subject: [PATCH 11/13] docs(bundled-dev): remove the HMR semantics comparison from the option comment --- packages/vite/src/node/config.ts | 8 -------- 1 file changed, 8 deletions(-) diff --git a/packages/vite/src/node/config.ts b/packages/vite/src/node/config.ts index 89eac6cf1732ec..c43a91baa3b29d 100644 --- a/packages/vite/src/node/config.ts +++ b/packages/vite/src/node/config.ts @@ -619,14 +619,6 @@ export interface ExperimentalOptions { * * This is highly experimental. * - * HMR semantics under full bundle mode differ from the middleware-based dev server - * in two ways (boundaries are computed in the browser from runtime state, not - * statically on the server): - * - * - Acceptance counts only when it executed: an `import.meta.hot.accept()` that - * is in a dead branch does not suppress the update and falls back to a full reload. - * - `hot.invalidate()` is handled fully client-side. - * * @experimental * @default false */ From 495a39203d36b409977e93c5a7f0f0a886c8fb1b Mon Sep 17 00:00:00 2001 From: Hana Date: Tue, 21 Jul 2026 21:28:18 +0800 Subject: [PATCH 12/13] Revert "docs(bundled-dev): remove the HMR semantics comparison from the option comment" --- packages/vite/src/node/config.ts | 8 ++++++++ 1 file changed, 8 insertions(+) diff --git a/packages/vite/src/node/config.ts b/packages/vite/src/node/config.ts index c43a91baa3b29d..89eac6cf1732ec 100644 --- a/packages/vite/src/node/config.ts +++ b/packages/vite/src/node/config.ts @@ -619,6 +619,14 @@ export interface ExperimentalOptions { * * This is highly experimental. * + * HMR semantics under full bundle mode differ from the middleware-based dev server + * in two ways (boundaries are computed in the browser from runtime state, not + * statically on the server): + * + * - Acceptance counts only when it executed: an `import.meta.hot.accept()` that + * is in a dead branch does not suppress the update and falls back to a full reload. + * - `hot.invalidate()` is handled fully client-side. + * * @experimental * @default false */ From acaae990a31ce25dc74f42180e572bd62612268e Mon Sep 17 00:00:00 2001 From: Hana Date: Tue, 21 Jul 2026 21:28:21 +0800 Subject: [PATCH 13/13] test(bundled-dev): note the issue blocking the skipped cycle test --- .../hmr-full-bundle-mode/__tests__/hmr-full-bundle-mode.spec.ts | 1 + 1 file changed, 1 insertion(+) 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 3b737ea0dd9bfa..57e71b564560f6 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 @@ -296,6 +296,7 @@ if (isBuild) { .toBe('worker-plain-updated') }) + // Blocked by https://github.com/rolldown/rolldown/issues/10340 test.skip('chained invalidate in an import cycle settles', async () => { const original = readFile('cycle-a.js') onTestFinished(async () => {