diff --git a/packages/vite/src/client/client.ts b/packages/vite/src/client/client.ts index 1fc8c2b522fdb3..99ad3d09f56740 100644 --- a/packages/vite/src/client/client.ts +++ b/packages/vite/src/client/client.ts @@ -6,7 +6,13 @@ import { normalizeModuleRunnerTransport, } from '../shared/moduleRunnerTransport' import { createHMRHandler } from '../shared/hmrHandler' -import { ErrorOverlay, cspNonce, overlayId } from './overlay' +import type { RuntimeErrorsToast } from './overlay' +import { + ErrorOverlay, + cspNonce, + overlayId, + runtimeErrorsToastId, +} from './overlay' import '@vite/env' // injected by the hmr plugin when served @@ -19,6 +25,7 @@ declare const __HMR_DIRECT_TARGET__: string declare const __HMR_BASE__: string declare const __HMR_TIMEOUT__: number declare const __HMR_ENABLE_OVERLAY__: boolean +declare const __HMR_RUNTIME_ERRORS__: boolean | ((err: ErrorEvent) => unknown) declare const __WS_TOKEN__: string console.debug('[vite] connecting...') @@ -37,7 +44,10 @@ const directSocketHost = __HMR_DIRECT_TARGET__ const base = __BASE__ || '/' const hmrTimeout = __HMR_TIMEOUT__ const wsToken = __WS_TOKEN__ +const runtimeErrors = __HMR_RUNTIME_ERRORS__ +const enableOverlay = __HMR_ENABLE_OVERLAY__ +const runtimeErrorList: Error[] = [] const transport = normalizeModuleRunnerTransport( (() => { let wsTransport = createWebSocketModuleRunnerTransport({ @@ -109,6 +119,14 @@ if (typeof window !== 'undefined') { window.addEventListener?.('beforeunload', () => { willUnload = true }) + + if (enableOverlay && runtimeErrors) { + if (typeof runtimeErrors === 'function') { + window.addEventListener?.('error', runtimeErrors) + } else { + window.addEventListener?.('error', handlerRuntimeError) + } + } } function cleanUrl(pathname: string): string { @@ -303,15 +321,19 @@ async function handleMessage(payload: HotPayload) { } } -const enableOverlay = __HMR_ENABLE_OVERLAY__ const hasDocument = 'document' in globalThis -function createErrorOverlay(err: ErrorPayload['err']) { +function createErrorOverlay( + err: ErrorPayload['err'] | Error, + runtimeErrors: boolean = false, +) { clearErrorOverlay() const { customElements } = globalThis if (customElements) { const ErrorOverlayConstructor = customElements.get(overlayId)! - document.body.appendChild(new ErrorOverlayConstructor(err)) + document.body.appendChild( + new ErrorOverlayConstructor(err, true, runtimeErrors), + ) } } @@ -323,6 +345,56 @@ function hasErrorOverlay() { return document.querySelectorAll(overlayId).length } +function createRuntimeToast(errs: Error[], toggleDetail: () => void) { + clearRuntimeToast() + const { customElements } = globalThis + if (customElements) { + const RuntimeErrorsToastConstructor = + customElements.get(runtimeErrorsToastId)! + document.body.appendChild( + new RuntimeErrorsToastConstructor(errs, toggleDetail), + ) + } +} + +function clearRuntimeToast() { + document + .querySelectorAll(runtimeErrorsToastId) + .forEach((n) => n.close()) +} + +function hasRuntimeToast() { + return document.querySelectorAll(runtimeErrorsToastId).length +} + +function updateRuntimeToast() { + const toast = document.querySelector(runtimeErrorsToastId) + if (toast) { + if (runtimeErrorList.length === 0) { + toast.close() + } else { + toast.updateErrorList(runtimeErrorList) + } + } +} + +function handlerRuntimeError(err: ErrorEvent) { + const { error, message } = err + const errorObject = + error instanceof Error ? error : new Error(error || message, { cause: err }) + runtimeErrorList.push(errorObject) + if (hasRuntimeToast()) { + const toast = + document.querySelector(runtimeErrorsToastId)! + toast.updateErrorList(runtimeErrorList) + return + } + createRuntimeToast(runtimeErrorList, () => { + createErrorOverlay(runtimeErrorList.shift()!, true) + updateRuntimeToast() + }) +} + function waitForSuccessfulPing(socketUrl: string) { if (typeof SharedWorker === 'undefined') { const visibilityManager: VisibilityManager = { diff --git a/packages/vite/src/client/overlay.ts b/packages/vite/src/client/overlay.ts index 67f22594cf8f1d..a978596c15f719 100644 --- a/packages/vite/src/client/overlay.ts +++ b/packages/vite/src/client/overlay.ts @@ -170,9 +170,53 @@ kbd { border-image: initial; } ` +const ToastTemplateStyle = ` +:host { + position: fixed; + right: 20px; + bottom: 20px; + z-index: 99999; + --monospace: 'SFMono-Regular', Consolas, + 'Liberation Mono', Menlo, Courier, monospace; + --toast-red: #fc0606; + --toast-yellow: #e2aa53; + --toast-purple: #cfa4ff; + --toast-cyan: #2dd9da; + --toast-dim: #c9c9c9; + + --window-background: #181818; + --window-color: #d8d8d8; +} +.runtime-toast { + padding: 10px 20px; + background: var(--toast-red); + color: #fff; + border-radius: 20px; + cursor: pointer; +} +.error-info { + font-family: var(--monospace); + font-size: 14px; + font-weight: 600; + display: flex; + align-items: center; +} +.close-icon { + margin-left: 10px; + display: inline-flex; + width: 20px; + height: 20px; + justify-content: center; + align-items: center; + border-radius: 50%; +} +.close-icon:hover { + background: rgba(255, 255, 255, 0.4); +} +` // Error Template -const createTemplate = () => +const createTemplate = (runtimeErrors: boolean) => h( 'div', { class: 'backdrop', part: 'backdrop' }, @@ -204,7 +248,30 @@ const createTemplate = () => '.', ), ), - h('style', { nonce: cspNonce }, templateStyle), + h( + 'style', + { nonce: cspNonce }, + runtimeErrors + ? templateStyle.replace( + 'border-top: 8px solid var(--red);', + 'border-top: 8px solid var(--yellow);', + ) + : templateStyle, + ), + ) + +// Runtime Errors Template +export const createRuntimeToastTemplate = (): HTMLElement => + h( + 'div', + { class: 'runtime-toast', part: 'runtime-toast' }, + h( + 'div', + { class: 'error-info', part: 'error-info' }, + h('span', { class: 'issue-text', part: 'issue-text' }), + h('span', { class: 'close-icon', part: 'close-icon' }, '✕'), + ), + h('style', { nonce: cspNonce }, ToastTemplateStyle), ) const fileRE = /(?:file:\/\/)?(?:[a-zA-Z]:\\|\/).*?:\d+:\d+/g @@ -217,10 +284,10 @@ export class ErrorOverlay extends HTMLElement { root: ShadowRoot closeOnEsc: (e: KeyboardEvent) => void - constructor(err: ErrorPayload['err'], links = true) { + constructor(err: ErrorPayload['err'], links = true, runtimeError = false) { super() this.root = this.attachShadow({ mode: 'open' }) - this.root.appendChild(createTemplate()) + this.root.appendChild(createTemplate(runtimeError)) codeframeRE.lastIndex = 0 const hasFrame = err.frame && codeframeRE.test(err.frame) @@ -299,7 +366,41 @@ export class ErrorOverlay extends HTMLElement { } export const overlayId = 'vite-error-overlay' + +export class RuntimeErrorsToast extends HTMLElement { + root: ShadowRoot + + constructor(errs: Error[], toggleDetail: () => void) { + super() + this.root = this.attachShadow({ mode: 'open' }) + this.root.appendChild(createRuntimeToastTemplate()) + const toast = this.root.querySelector('.runtime-toast')! + toast.addEventListener('click', toggleDetail) + + const issueText = this.root.querySelector('.issue-text')! + issueText.textContent = `${errs.length} Issue${errs.length > 1 ? 's' : ''}` + + const closeIcon = this.root.querySelector('.close-icon')! + closeIcon.addEventListener('click', (e) => { + e.stopPropagation() + this.close() + }) + } + updateErrorList(errs: Error[]): void { + const issueText = this.root.querySelector('.issue-text')! + issueText.textContent = `${errs.length} Issue${errs.length > 1 ? 's' : ''}` + } + close(): void { + this.parentNode?.removeChild(this) + } +} + +export const runtimeErrorsToastId = 'vite-runtime-errors-toast' + const { customElements } = globalThis // Ensure `customElements` is defined before the next line. if (customElements && !customElements.get(overlayId)) { customElements.define(overlayId, ErrorOverlay) } +if (customElements && !customElements.get(runtimeErrorsToastId)) { + customElements.define(runtimeErrorsToastId, RuntimeErrorsToast) +} diff --git a/packages/vite/src/node/plugins/clientInjections.ts b/packages/vite/src/node/plugins/clientInjections.ts index 6790b4582fbac0..b7993fd04052bf 100644 --- a/packages/vite/src/node/plugins/clientInjections.ts +++ b/packages/vite/src/node/plugins/clientInjections.ts @@ -49,6 +49,7 @@ export function clientInjectionsPlugin(config: ResolvedConfig): Plugin { const overlay = hmrConfig?.overlay !== false const isHmrServerSpecified = !!hmrConfig?.server const hmrConfigName = path.basename(config.configFile || 'vite.config.js') + const runtimeErrors = hmrConfig?.runtimeErrors ?? false // hmr.clientPort -> hmr.port // -> (24678 if middleware mode and HMR server is not specified) -> new URL(import.meta.url).port @@ -78,6 +79,10 @@ export function clientInjectionsPlugin(config: ResolvedConfig): Plugin { const hmrEnableOverlayReplacement = escapeReplacement(overlay) const hmrConfigNameReplacement = escapeReplacement(hmrConfigName) const wsTokenReplacement = escapeReplacement(config.webSocketToken) + const hmrRuntimeErrorsReplacement = + typeof runtimeErrors === 'function' + ? () => runtimeErrors.toString() + : escapeReplacement(runtimeErrors) injectConfigValues = (code: string) => { return code @@ -93,6 +98,7 @@ export function clientInjectionsPlugin(config: ResolvedConfig): Plugin { .replace(`__HMR_ENABLE_OVERLAY__`, hmrEnableOverlayReplacement) .replace(`__HMR_CONFIG_NAME__`, hmrConfigNameReplacement) .replace(`__WS_TOKEN__`, wsTokenReplacement) + .replace(`__HMR_RUNTIME_ERRORS__`, hmrRuntimeErrorsReplacement) } }, async transform(code, id) { diff --git a/packages/vite/src/node/server/hmr.ts b/packages/vite/src/node/server/hmr.ts index b7e29e929795f0..9184e8a5c7104b 100644 --- a/packages/vite/src/node/server/hmr.ts +++ b/packages/vite/src/node/server/hmr.ts @@ -40,6 +40,13 @@ const whitespaceRE = /\s/ const normalizedClientDir = normalizePath(CLIENT_DIR) +interface ErrorEvent extends Event { + readonly error: any + readonly message: string + readonly filename?: string + readonly lineno?: number + readonly colno?: number +} export interface HmrOptions { protocol?: string host?: string @@ -49,6 +56,7 @@ export interface HmrOptions { timeout?: number overlay?: boolean server?: HttpServer + runtimeErrors?: boolean | ((err: ErrorEvent) => unknown) } export interface HotUpdateOptions {