diff --git a/packages/vite/src/client/client.ts b/packages/vite/src/client/client.ts index 08ab3ae6db56ca..1db4a49aa2556a 100644 --- a/packages/vite/src/client/client.ts +++ b/packages/vite/src/client/client.ts @@ -1,11 +1,11 @@ import type { ErrorPayload, HotPayload } from 'types/hmrPayload' import type { ViteHotContext } from 'types/hot' -import type { InferCustomEventPayload } from 'types/customEvent' import { HMRClient, HMRContext } from '../shared/hmr' import { createWebSocketModuleRunnerTransport, normalizeModuleRunnerTransport, } from '../shared/moduleRunnerTransport' +import { createHMRHandler } from '../shared/hmrHandler' import { ErrorOverlay, overlayId } from './overlay' import '@vite/env' @@ -166,7 +166,7 @@ const hmrClient = new HMRClient( return await importPromise }, ) -transport.connect!(handleMessage) +transport.connect!(createHMRHandler(handleMessage)) async function handleMessage(payload: HotPayload) { switch (payload.type) { @@ -174,7 +174,7 @@ async function handleMessage(payload: HotPayload) { console.debug(`[vite] connected.`) break case 'update': - notifyListeners('vite:beforeUpdate', payload) + 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 @@ -238,10 +238,10 @@ async function handleMessage(payload: HotPayload) { }) }), ) - notifyListeners('vite:afterUpdate', payload) + await hmrClient.notifyListeners('vite:afterUpdate', payload) break case 'custom': { - notifyListeners(payload.event, payload.data) + await hmrClient.notifyListeners(payload.event, payload.data) if (payload.event === 'vite:ws:disconnect') { if (hasDocument && !willUnload) { console.log(`[vite] server connection lost. Polling for restart...`) @@ -255,7 +255,7 @@ async function handleMessage(payload: HotPayload) { break } case 'full-reload': - notifyListeners('vite:beforeFullReload', payload) + await hmrClient.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 @@ -276,11 +276,11 @@ async function handleMessage(payload: HotPayload) { } break case 'prune': - notifyListeners('vite:beforePrune', payload) + await hmrClient.notifyListeners('vite:beforePrune', payload) await hmrClient.prunePaths(payload.paths) break case 'error': { - notifyListeners('vite:error', payload) + await hmrClient.notifyListeners('vite:error', payload) if (hasDocument) { const err = payload.err if (enableOverlay) { @@ -302,14 +302,6 @@ async function handleMessage(payload: HotPayload) { } } -function notifyListeners( - event: T, - data: InferCustomEventPayload, -): void -function notifyListeners(event: string, data: any): void { - hmrClient.notifyListeners(event, data) -} - const enableOverlay = __HMR_ENABLE_OVERLAY__ const hasDocument = 'document' in globalThis diff --git a/packages/vite/src/module-runner/hmrHandler.ts b/packages/vite/src/module-runner/hmrHandler.ts index 9615f4485eb57b..ab0ed6c3b58cae 100644 --- a/packages/vite/src/module-runner/hmrHandler.ts +++ b/packages/vite/src/module-runner/hmrHandler.ts @@ -1,133 +1,87 @@ import type { HotPayload } from 'types/hmrPayload' import { slash, unwrapId } from '../shared/utils' import { ERR_OUTDATED_OPTIMIZED_DEP } from '../shared/constants' +import { createHMRHandler } from '../shared/hmrHandler' import type { ModuleRunner } from './runner' -// updates to HMR should go one after another. It is possible to trigger another update during the invalidation for example. -export function createHMRHandler( +export function createHMRHandlerForRunner( runner: ModuleRunner, ): (payload: HotPayload) => Promise { - const queue = new Queue() - return (payload) => queue.enqueue(() => handleHotPayload(runner, payload)) -} - -export async function handleHotPayload( - runner: ModuleRunner, - payload: HotPayload, -): Promise { - const hmrClient = runner.hmrClient - if (!hmrClient || runner.isClosed()) return - switch (payload.type) { - case 'connected': - hmrClient.logger.debug(`connected.`) - break - case 'update': - await hmrClient.notifyListeners('vite:beforeUpdate', payload) - await Promise.all( - payload.updates.map(async (update): Promise => { - if (update.type === 'js-update') { - // runner always caches modules by their full path without /@id/ prefix - update.acceptedPath = unwrapId(update.acceptedPath) - update.path = unwrapId(update.path) - return hmrClient.queueUpdate(update) - } + return createHMRHandler(async (payload) => { + const hmrClient = runner.hmrClient + if (!hmrClient || runner.isClosed()) return + switch (payload.type) { + case 'connected': + hmrClient.logger.debug(`connected.`) + break + case 'update': + await hmrClient.notifyListeners('vite:beforeUpdate', payload) + await Promise.all( + payload.updates.map(async (update): Promise => { + if (update.type === 'js-update') { + // runner always caches modules by their full path without /@id/ prefix + update.acceptedPath = unwrapId(update.acceptedPath) + update.path = unwrapId(update.path) + return hmrClient.queueUpdate(update) + } - hmrClient.logger.error('css hmr is not supported in runner mode.') - }), - ) - await hmrClient.notifyListeners('vite:afterUpdate', payload) - break - case 'custom': { - await hmrClient.notifyListeners(payload.event, payload.data) - break - } - case 'full-reload': { - const { triggeredBy } = payload - const clearEntrypointUrls = triggeredBy - ? getModulesEntrypoints( - runner, - getModulesByFile(runner, slash(triggeredBy)), - ) - : findAllEntrypoints(runner) + hmrClient.logger.error('css hmr is not supported in runner mode.') + }), + ) + await hmrClient.notifyListeners('vite:afterUpdate', payload) + break + case 'custom': { + await hmrClient.notifyListeners(payload.event, payload.data) + break + } + case 'full-reload': { + const { triggeredBy } = payload + const clearEntrypointUrls = triggeredBy + ? getModulesEntrypoints( + runner, + getModulesByFile(runner, slash(triggeredBy)), + ) + : findAllEntrypoints(runner) - if (!clearEntrypointUrls.size) break + if (!clearEntrypointUrls.size) break - hmrClient.logger.debug(`program reload`) - await hmrClient.notifyListeners('vite:beforeFullReload', payload) - runner.evaluatedModules.clear() + hmrClient.logger.debug(`program reload`) + await hmrClient.notifyListeners('vite:beforeFullReload', payload) + runner.evaluatedModules.clear() - for (const url of clearEntrypointUrls) { - try { - await runner.import(url) - } catch (err) { - if (err.code !== ERR_OUTDATED_OPTIMIZED_DEP) { - hmrClient.logger.error( - `An error happened during full reload\n${err.message}\n${err.stack}`, - ) + for (const url of clearEntrypointUrls) { + try { + await runner.import(url) + } catch (err) { + if (err.code !== ERR_OUTDATED_OPTIMIZED_DEP) { + hmrClient.logger.error( + `An error happened during full reload\n${err.message}\n${err.stack}`, + ) + } } } + break + } + case 'prune': + await hmrClient.notifyListeners('vite:beforePrune', payload) + await hmrClient.prunePaths(payload.paths) + break + case 'error': { + await hmrClient.notifyListeners('vite:error', payload) + const err = payload.err + hmrClient.logger.error( + `Internal Server Error\n${err.message}\n${err.stack}`, + ) + break + } + case 'ping': // noop + break + default: { + const check: never = payload + return check } - break - } - case 'prune': - await hmrClient.notifyListeners('vite:beforePrune', payload) - await hmrClient.prunePaths(payload.paths) - break - case 'error': { - await hmrClient.notifyListeners('vite:error', payload) - const err = payload.err - hmrClient.logger.error( - `Internal Server Error\n${err.message}\n${err.stack}`, - ) - break - } - case 'ping': // noop - break - default: { - const check: never = payload - return check - } - } -} - -class Queue { - private queue: { - promise: () => Promise - resolve: (value?: unknown) => void - reject: (err?: unknown) => void - }[] = [] - private pending = false - - enqueue(promise: () => Promise) { - return new Promise((resolve, reject) => { - this.queue.push({ - promise, - resolve, - reject, - }) - this.dequeue() - }) - } - - dequeue() { - if (this.pending) { - return false - } - const item = this.queue.shift() - if (!item) { - return false } - this.pending = true - item - .promise() - .then(item.resolve) - .catch(item.reject) - .finally(() => { - this.pending = false - this.dequeue() - }) - return true - } + }) } function getModulesByFile(runner: ModuleRunner, file: string): string[] { diff --git a/packages/vite/src/module-runner/runner.ts b/packages/vite/src/module-runner/runner.ts index 902aa70bcbd02b..339b6a6a8908e6 100644 --- a/packages/vite/src/module-runner/runner.ts +++ b/packages/vite/src/module-runner/runner.ts @@ -30,7 +30,7 @@ import { ssrModuleExportsKey, } from './constants' import { hmrLogger, silentConsole } from './hmrLogger' -import { createHMRHandler } from './hmrHandler' +import { createHMRHandlerForRunner } from './hmrHandler' import { enableSourceMapSupport } from './sourcemap/index' import { ESModulesEvaluator } from './esmEvaluator' @@ -83,7 +83,7 @@ export class ModuleRunner { 'HMR is not supported by this runner transport, but `hmr` option was set to true', ) } - this.transport.connect(createHMRHandler(this)) + this.transport.connect(createHMRHandlerForRunner(this)) } else { this.transport.connect?.() } diff --git a/packages/vite/src/shared/hmrHandler.ts b/packages/vite/src/shared/hmrHandler.ts new file mode 100644 index 00000000000000..59cde8671e3e85 --- /dev/null +++ b/packages/vite/src/shared/hmrHandler.ts @@ -0,0 +1,49 @@ +import type { HotPayload } from 'types/hmrPayload' + +// updates to HMR should go one after another. It is possible to trigger another update during the invalidation for example. +export function createHMRHandler( + handler: (payload: HotPayload) => Promise, +): (payload: HotPayload) => Promise { + const queue = new Queue() + return (payload) => queue.enqueue(() => handler(payload)) +} + +class Queue { + private queue: { + promise: () => Promise + resolve: (value?: unknown) => void + reject: (err?: unknown) => void + }[] = [] + private pending = false + + enqueue(promise: () => Promise): Promise { + return new Promise((resolve, reject) => { + this.queue.push({ + promise, + resolve, + reject, + }) + this.dequeue() + }) + } + + dequeue(): boolean { + if (this.pending) { + return false + } + const item = this.queue.shift() + if (!item) { + return false + } + this.pending = true + item + .promise() + .then(item.resolve) + .catch(item.reject) + .finally(() => { + this.pending = false + this.dequeue() + }) + return true + } +} diff --git a/playground/hmr/__tests__/hmr.spec.ts b/playground/hmr/__tests__/hmr.spec.ts index 29acfee7aa7df6..7c4a54e2b2e3e9 100644 --- a/playground/hmr/__tests__/hmr.spec.ts +++ b/playground/hmr/__tests__/hmr.spec.ts @@ -935,27 +935,33 @@ if (!isBuild) { }) test('deleted file should trigger dispose and prune callbacks', async () => { - browserLogs.length = 0 await page.goto(viteTestUrl) const parentFile = 'file-delete-restore/parent.js' const childFile = 'file-delete-restore/child.js' + const originalChildFileCode = readFile(childFile) - // delete the file - editFile(parentFile, (code) => - code.replace( - "export { value as childValue } from './child'", - "export const childValue = 'not-child'", - ), + await untilBrowserLogAfter( + () => { + // delete the file + editFile(parentFile, (code) => + code.replace( + "export { value as childValue } from './child'", + "export const childValue = 'not-child'", + ), + ) + removeFile(childFile) + }, + [ + 'file-delete-restore/child.js is disposed', + 'file-delete-restore/child.js is pruned', + ], + false, ) - const originalChildFileCode = readFile(childFile) - removeFile(childFile) await untilUpdated( () => page.textContent('.file-delete-restore'), 'parent:not-child', ) - expect(browserLogs).to.include('file-delete-restore/child.js is disposed') - expect(browserLogs).to.include('file-delete-restore/child.js is pruned') // restore the file addFile(childFile, originalChildFileCode)