Skip to content
Open
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
80 changes: 76 additions & 4 deletions packages/vite/src/client/client.ts
Original file line number Diff line number Diff line change
Expand Up @@ -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
Expand All @@ -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...')
Expand All @@ -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({
Expand Down Expand Up @@ -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 {
Expand Down Expand Up @@ -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),
)
}
}

Expand All @@ -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<RuntimeErrorsToast>(runtimeErrorsToastId)
.forEach((n) => n.close())
}

function hasRuntimeToast() {
return document.querySelectorAll(runtimeErrorsToastId).length
}

function updateRuntimeToast() {
const toast = document.querySelector<RuntimeErrorsToast>(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<RuntimeErrorsToast>(runtimeErrorsToastId)!
toast.updateErrorList(runtimeErrorList)
return
}
createRuntimeToast(runtimeErrorList, () => {
createErrorOverlay(runtimeErrorList.shift()!, true)
updateRuntimeToast()
})
}

function waitForSuccessfulPing(socketUrl: string) {
if (typeof SharedWorker === 'undefined') {
const visibilityManager: VisibilityManager = {
Expand Down
109 changes: 105 additions & 4 deletions packages/vite/src/client/overlay.ts
Original file line number Diff line number Diff line change
Expand Up @@ -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' },
Expand Down Expand Up @@ -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
Expand All @@ -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)
Expand Down Expand Up @@ -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)
}
6 changes: 6 additions & 0 deletions packages/vite/src/node/plugins/clientInjections.ts
Original file line number Diff line number Diff line change
Expand Up @@ -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
Expand Down Expand Up @@ -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
Expand All @@ -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) {
Expand Down
8 changes: 8 additions & 0 deletions packages/vite/src/node/server/hmr.ts
Original file line number Diff line number Diff line change
Expand Up @@ -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
Expand All @@ -49,6 +56,7 @@ export interface HmrOptions {
timeout?: number
overlay?: boolean
server?: HttpServer
runtimeErrors?: boolean | ((err: ErrorEvent) => unknown)
}

export interface HotUpdateOptions {
Expand Down