From 0571b7c6c3acc8ec46357a9ac1f20b60030cdd2f Mon Sep 17 00:00:00 2001 From: Vladimir Date: Fri, 1 Dec 2023 11:25:54 +0100 Subject: [PATCH] refactor: make HMR agnostic to environment (#15179) --- packages/vite/src/client/client.ts | 244 +++--------------------- packages/vite/src/shared/hmr.ts | 251 +++++++++++++++++++++++++ packages/vite/src/shared/tsconfig.json | 9 + 3 files changed, 287 insertions(+), 217 deletions(-) create mode 100644 packages/vite/src/shared/hmr.ts create mode 100644 packages/vite/src/shared/tsconfig.json diff --git a/packages/vite/src/client/client.ts b/packages/vite/src/client/client.ts index 86c1bd0a837a47..41dcebc5e27a3c 100644 --- a/packages/vite/src/client/client.ts +++ b/packages/vite/src/client/client.ts @@ -1,6 +1,7 @@ -import type { ErrorPayload, HMRPayload, Update } from 'types/hmrPayload' -import type { ModuleNamespace, ViteHotContext } from 'types/hot' +import type { ErrorPayload, HMRPayload } from 'types/hmrPayload' +import type { ViteHotContext } from 'types/hot' import type { InferCustomEventPayload } from 'types/customEvent' +import { HMRClient, HMRContext } from '../shared/hmr' import { ErrorOverlay, overlayId } from './overlay' import '@vite/env' @@ -110,17 +111,6 @@ function setupWebSocket( return socket } -function warnFailedFetch(err: Error, path: string | string[]) { - if (!err.message.match('fetch')) { - console.error(err) - } - console.error( - `[hmr] Failed to reload ${path}. ` + - `This could be due to syntax errors or importing non-existent ` + - `modules. (see errors above)`, - ) -} - function cleanUrl(pathname: string): string { const url = new URL(pathname, location.toString()) url.searchParams.delete('direct') @@ -144,6 +134,22 @@ const debounceReload = (time: number) => { } const pageReload = debounceReload(50) +const hmrClient = new HMRClient(console, async function importUpdatedModule({ + acceptedPath, + timestamp, + explicitImportRequired, +}) { + const [acceptedPathWithoutQuery, query] = acceptedPath.split(`?`) + return await import( + /* @vite-ignore */ + base + + acceptedPathWithoutQuery.slice(1) + + `?${explicitImportRequired ? 'import&' : ''}t=${timestamp}${ + query ? `&${query}` : '' + }` + ) +}) + async function handleMessage(payload: HMRPayload) { switch (payload.type) { case 'connected': @@ -173,7 +179,7 @@ async function handleMessage(payload: HMRPayload) { await Promise.all( payload.updates.map(async (update): Promise => { if (update.type === 'js-update') { - return queueUpdate(fetchUpdate(update)) + return queueUpdate(hmrClient.fetchUpdate(update)) } // css-update @@ -245,16 +251,7 @@ async function handleMessage(payload: HMRPayload) { break case 'prune': notifyListeners('vite:beforePrune', payload) - // After an HMR update, some modules are no longer imported on the page - // but they may have left behind side effects that need to be cleaned up - // (.e.g style injections) - // TODO Trigger their dispose callbacks. - payload.paths.forEach((path) => { - const fn = pruneMap.get(path) - if (fn) { - fn(dataMap.get(path)) - } - }) + hmrClient.prunePaths(payload.paths) break case 'error': { notifyListeners('vite:error', payload) @@ -280,10 +277,7 @@ function notifyListeners( data: InferCustomEventPayload, ): void function notifyListeners(event: string, data: any): void { - const cbs = customListenersMap.get(event) - if (cbs) { - cbs.forEach((cb) => cb(data)) - } + hmrClient.notifyListeners(event, data) } const enableOverlay = __HMR_ENABLE_OVERLAY__ @@ -430,55 +424,6 @@ export function removeStyle(id: string): void { } } -async function fetchUpdate({ - path, - acceptedPath, - timestamp, - explicitImportRequired, -}: Update) { - const mod = hotModulesMap.get(path) - if (!mod) { - // In a code-splitting project, - // it is common that the hot-updating module is not loaded yet. - // https://github.com/vitejs/vite/issues/721 - return - } - - let fetchedModule: ModuleNamespace | undefined - const isSelfUpdate = path === acceptedPath - - // determine the qualified callbacks before we re-import the modules - const qualifiedCallbacks = mod.callbacks.filter(({ deps }) => - deps.includes(acceptedPath), - ) - - if (isSelfUpdate || qualifiedCallbacks.length > 0) { - const disposer = disposeMap.get(acceptedPath) - if (disposer) await disposer(dataMap.get(acceptedPath)) - const [acceptedPathWithoutQuery, query] = acceptedPath.split(`?`) - try { - fetchedModule = await import( - /* @vite-ignore */ - base + - acceptedPathWithoutQuery.slice(1) + - `?${explicitImportRequired ? 'import&' : ''}t=${timestamp}${ - query ? `&${query}` : '' - }` - ) - } catch (e) { - warnFailedFetch(e, acceptedPath) - } - } - - return () => { - for (const { deps, fn } of qualifiedCallbacks) { - fn(deps.map((dep) => (dep === acceptedPath ? fetchedModule : undefined))) - } - const loggedPath = isSelfUpdate ? path : `${acceptedPath} via ${path}` - console.debug(`[vite] hot updated: ${loggedPath}`) - } -} - function sendMessageBuffer() { if (socket.readyState === 1) { messageBuffer.forEach((msg) => socket.send(msg)) @@ -486,150 +431,15 @@ function sendMessageBuffer() { } } -interface HotModule { - id: string - callbacks: HotCallback[] -} - -interface HotCallback { - // the dependencies must be fetchable paths - deps: string[] - fn: (modules: Array) => void -} - -type CustomListenersMap = Map void)[]> - -const hotModulesMap = new Map() -const disposeMap = new Map void | Promise>() -const pruneMap = new Map void | Promise>() -const dataMap = new Map() -const customListenersMap: CustomListenersMap = new Map() -const ctxToListenersMap = new Map() - export function createHotContext(ownerPath: string): ViteHotContext { - if (!dataMap.has(ownerPath)) { - dataMap.set(ownerPath, {}) - } - - // when a file is hot updated, a new context is created - // clear its stale callbacks - const mod = hotModulesMap.get(ownerPath) - if (mod) { - mod.callbacks = [] - } - - // clear stale custom event listeners - const staleListeners = ctxToListenersMap.get(ownerPath) - if (staleListeners) { - for (const [event, staleFns] of staleListeners) { - const listeners = customListenersMap.get(event) - if (listeners) { - customListenersMap.set( - event, - listeners.filter((l) => !staleFns.includes(l)), - ) - } - } - } - - const newListeners: CustomListenersMap = new Map() - ctxToListenersMap.set(ownerPath, newListeners) - - function acceptDeps(deps: string[], callback: HotCallback['fn'] = () => {}) { - const mod: HotModule = hotModulesMap.get(ownerPath) || { - id: ownerPath, - callbacks: [], - } - mod.callbacks.push({ - deps, - fn: callback, - }) - hotModulesMap.set(ownerPath, mod) - } - - const hot: ViteHotContext = { - get data() { - return dataMap.get(ownerPath) - }, - - accept(deps?: any, callback?: any) { - if (typeof deps === 'function' || !deps) { - // self-accept: hot.accept(() => {}) - acceptDeps([ownerPath], ([mod]) => deps?.(mod)) - } else if (typeof deps === 'string') { - // explicit deps - acceptDeps([deps], ([mod]) => callback?.(mod)) - } else if (Array.isArray(deps)) { - acceptDeps(deps, callback) - } else { - throw new Error(`invalid hot.accept() usage.`) - } - }, - - // export names (first arg) are irrelevant on the client side, they're - // extracted in the server for propagation - acceptExports(_, callback) { - acceptDeps([ownerPath], ([mod]) => callback?.(mod)) + return new HMRContext(ownerPath, hmrClient, { + addBuffer(message) { + messageBuffer.push(message) }, - - dispose(cb) { - disposeMap.set(ownerPath, cb) - }, - - prune(cb) { - pruneMap.set(ownerPath, cb) - }, - - // Kept for backward compatibility (#11036) - // @ts-expect-error untyped - // eslint-disable-next-line @typescript-eslint/no-empty-function - decline() {}, - - // tell the server to re-perform hmr propagation from this module as root - invalidate(message) { - notifyListeners('vite:invalidate', { path: ownerPath, message }) - this.send('vite:invalidate', { path: ownerPath, message }) - console.debug( - `[vite] invalidate ${ownerPath}${message ? `: ${message}` : ''}`, - ) - }, - - // custom events - on(event, cb) { - const addToMap = (map: Map) => { - const existing = map.get(event) || [] - existing.push(cb) - map.set(event, existing) - } - addToMap(customListenersMap) - addToMap(newListeners) - }, - - // remove a custom event - off(event, cb) { - const removeFromMap = (map: Map) => { - const existing = map.get(event) - if (existing === undefined) { - return - } - const pruned = existing.filter((l) => l !== cb) - if (pruned.length === 0) { - map.delete(event) - return - } - map.set(event, pruned) - } - removeFromMap(customListenersMap) - removeFromMap(newListeners) - }, - - send(event, data) { - messageBuffer.push(JSON.stringify({ type: 'custom', event, data })) + send() { sendMessageBuffer() }, - } - - return hot + }) } /** diff --git a/packages/vite/src/shared/hmr.ts b/packages/vite/src/shared/hmr.ts new file mode 100644 index 00000000000000..60c26b50e49da5 --- /dev/null +++ b/packages/vite/src/shared/hmr.ts @@ -0,0 +1,251 @@ +import type { Update } from 'types/hmrPayload' +import type { ModuleNamespace, ViteHotContext } from 'types/hot' +import type { InferCustomEventPayload } from 'types/customEvent' + +type CustomListenersMap = Map void)[]> + +interface HotModule { + id: string + callbacks: HotCallback[] +} + +interface HotCallback { + // the dependencies must be fetchable paths + deps: string[] + fn: (modules: Array) => void +} + +interface Connection { + addBuffer(message: string): void + send(): unknown +} + +export class HMRContext implements ViteHotContext { + private newListeners: CustomListenersMap + + constructor( + private ownerPath: string, + private hmrClient: HMRClient, + private connection: Connection, + ) { + if (!hmrClient.dataMap.has(ownerPath)) { + hmrClient.dataMap.set(ownerPath, {}) + } + + // when a file is hot updated, a new context is created + // clear its stale callbacks + const mod = hmrClient.hotModulesMap.get(ownerPath) + if (mod) { + mod.callbacks = [] + } + + // clear stale custom event listeners + const staleListeners = hmrClient.ctxToListenersMap.get(ownerPath) + if (staleListeners) { + for (const [event, staleFns] of staleListeners) { + const listeners = hmrClient.customListenersMap.get(event) + if (listeners) { + hmrClient.customListenersMap.set( + event, + listeners.filter((l) => !staleFns.includes(l)), + ) + } + } + } + + this.newListeners = new Map() + hmrClient.ctxToListenersMap.set(ownerPath, this.newListeners) + } + + get data(): any { + return this.hmrClient.dataMap.get(this.ownerPath) + } + + accept(deps?: any, callback?: any): void { + if (typeof deps === 'function' || !deps) { + // self-accept: hot.accept(() => {}) + this.acceptDeps([this.ownerPath], ([mod]) => deps?.(mod)) + } else if (typeof deps === 'string') { + // explicit deps + this.acceptDeps([deps], ([mod]) => callback?.(mod)) + } else if (Array.isArray(deps)) { + this.acceptDeps(deps, callback) + } else { + throw new Error(`invalid hot.accept() usage.`) + } + } + + // export names (first arg) are irrelevant on the client side, they're + // extracted in the server for propagation + acceptExports( + _: string | readonly string[], + callback: (data: any) => void, + ): void { + this.acceptDeps([this.ownerPath], ([mod]) => callback?.(mod)) + } + + dispose(cb: (data: any) => void): void { + this.hmrClient.disposeMap.set(this.ownerPath, cb) + } + + prune(cb: (data: any) => void): void { + this.hmrClient.pruneMap.set(this.ownerPath, cb) + } + + // Kept for backward compatibility (#11036) + // eslint-disable-next-line @typescript-eslint/no-empty-function + decline(): void {} + + invalidate(message: string): void { + this.hmrClient.notifyListeners('vite:invalidate', { + path: this.ownerPath, + message, + }) + this.send('vite:invalidate', { path: this.ownerPath, message }) + this.hmrClient.logger.debug( + `[vite] invalidate ${this.ownerPath}${message ? `: ${message}` : ''}`, + ) + } + + on( + event: T, + cb: (payload: InferCustomEventPayload) => void, + ): void { + const addToMap = (map: Map) => { + const existing = map.get(event) || [] + existing.push(cb) + map.set(event, existing) + } + addToMap(this.hmrClient.customListenersMap) + addToMap(this.newListeners) + } + + off( + event: T, + cb: (payload: InferCustomEventPayload) => void, + ): void { + const removeFromMap = (map: Map) => { + const existing = map.get(event) + if (existing === undefined) { + return + } + const pruned = existing.filter((l) => l !== cb) + if (pruned.length === 0) { + map.delete(event) + return + } + map.set(event, pruned) + } + removeFromMap(this.hmrClient.customListenersMap) + removeFromMap(this.newListeners) + } + + send(event: T, data?: InferCustomEventPayload): void { + this.connection.addBuffer(JSON.stringify({ type: 'custom', event, data })) + this.connection.send() + } + + private acceptDeps( + deps: string[], + callback: HotCallback['fn'] = () => {}, + ): void { + const mod: HotModule = this.hmrClient.hotModulesMap.get(this.ownerPath) || { + id: this.ownerPath, + callbacks: [], + } + mod.callbacks.push({ + deps, + fn: callback, + }) + this.hmrClient.hotModulesMap.set(this.ownerPath, mod) + } +} + +export class HMRClient { + public hotModulesMap = new Map() + public disposeMap = new Map void | Promise>() + public pruneMap = new Map void | Promise>() + public dataMap = new Map() + public customListenersMap: CustomListenersMap = new Map() + public ctxToListenersMap = new Map() + + constructor( + public logger: Console, + // this allows up to implement reloading via different methods depending on the environment + private importUpdatedModule: (update: Update) => Promise, + ) {} + + public async notifyListeners( + event: T, + data: InferCustomEventPayload, + ): Promise + public async notifyListeners(event: string, data: any): Promise { + const cbs = this.customListenersMap.get(event) + if (cbs) { + await Promise.allSettled(cbs.map((cb) => cb(data))) + } + } + + // After an HMR update, some modules are no longer imported on the page + // but they may have left behind side effects that need to be cleaned up + // (.e.g style injections) + // TODO Trigger their dispose callbacks. + public prunePaths(paths: string[]): void { + paths.forEach((path) => { + const fn = this.pruneMap.get(path) + if (fn) { + fn(this.dataMap.get(path)) + } + }) + } + + protected warnFailedUpdate(err: Error, path: string | string[]): void { + if (!err.message.match('fetch')) { + this.logger.error(err) + } + this.logger.error( + `[hmr] Failed to reload ${path}. ` + + `This could be due to syntax errors or importing non-existent ` + + `modules. (see errors above)`, + ) + } + + public async fetchUpdate(update: Update): Promise<(() => void) | undefined> { + const { path, acceptedPath } = update + const mod = this.hotModulesMap.get(path) + if (!mod) { + // In a code-splitting project, + // it is common that the hot-updating module is not loaded yet. + // https://github.com/vitejs/vite/issues/721 + return + } + + let fetchedModule: ModuleNamespace | undefined + const isSelfUpdate = path === acceptedPath + + // determine the qualified callbacks before we re-import the modules + const qualifiedCallbacks = mod.callbacks.filter(({ deps }) => + deps.includes(acceptedPath), + ) + + if (isSelfUpdate || qualifiedCallbacks.length > 0) { + const disposer = this.disposeMap.get(acceptedPath) + if (disposer) await disposer(this.dataMap.get(acceptedPath)) + try { + fetchedModule = await this.importUpdatedModule(update) + } catch (e) { + this.warnFailedUpdate(e, acceptedPath) + } + } + + return () => { + for (const { deps, fn } of qualifiedCallbacks) { + fn( + deps.map((dep) => (dep === acceptedPath ? fetchedModule : undefined)), + ) + } + const loggedPath = isSelfUpdate ? path : `${acceptedPath} via ${path}` + this.logger.debug(`[vite] hot updated: ${loggedPath}`) + } + } +} diff --git a/packages/vite/src/shared/tsconfig.json b/packages/vite/src/shared/tsconfig.json new file mode 100644 index 00000000000000..a7f7890f1d0e7b --- /dev/null +++ b/packages/vite/src/shared/tsconfig.json @@ -0,0 +1,9 @@ +{ + "extends": "../../tsconfig.base.json", + "include": ["./", "../dep-types", "../types"], + "exclude": ["**/__tests__"], + "compilerOptions": { + "lib": ["ESNext", "DOM"], + "stripInternal": true + } +}