import * as React from 'react' import { ACTION_UNHANDLED_ERROR, ACTION_UNHANDLED_REJECTION, UnhandledErrorAction, UnhandledRejectionAction, } from '../error-overlay-reducer' import { Dialog, DialogBody, DialogContent, DialogHeader, } from '../components/Dialog' import { LeftRightDialogHeader } from '../components/LeftRightDialogHeader' import { Overlay } from '../components/Overlay' import { Toast } from '../components/Toast' import { getErrorByType, ReadyRuntimeError } from '../helpers/getErrorByType' import { getErrorSource } from '../helpers/nodeStackFrames' import { noop as css } from '../helpers/noop-template' import { CloseIcon } from '../icons/CloseIcon' import { RuntimeError } from './RuntimeError' export type SupportedErrorEvent = { id: number event: UnhandledErrorAction | UnhandledRejectionAction } export type ErrorsProps = { errors: SupportedErrorEvent[] } type ReadyErrorEvent = ReadyRuntimeError function getErrorSignature(ev: SupportedErrorEvent): string { const { event } = ev switch (event.type) { case ACTION_UNHANDLED_ERROR: case ACTION_UNHANDLED_REJECTION: { return `${event.reason.name}::${event.reason.message}::${event.reason.stack}` } default: { } } // eslint-disable-next-line @typescript-eslint/no-unused-vars const _: never = event return '' } const HotlinkedText: React.FC<{ text: string }> = function HotlinkedText(props) { const { text } = props const linkRegex = /https?:\/\/[^\s/$.?#].[^\s"]*/i return ( <> {linkRegex.test(text) ? text.split(' ').map((word, index, array) => { if (linkRegex.test(word)) { return ( {word} {index === array.length - 1 ? '' : ' '} ) } return index === array.length - 1 ? ( {word} ) : ( {word} ) }) : text} ) } export const Errors: React.FC = function Errors({ errors }) { const [lookups, setLookups] = React.useState( {} as { [eventId: string]: ReadyErrorEvent } ) const [readyErrors, nextError] = React.useMemo< [ReadyErrorEvent[], SupportedErrorEvent | null] >(() => { let ready: ReadyErrorEvent[] = [] let next: SupportedErrorEvent | null = null // Ensure errors are displayed in the order they occurred in: for (let idx = 0; idx < errors.length; ++idx) { const e = errors[idx] const { id } = e if (id in lookups) { ready.push(lookups[id]) continue } // Check for duplicate errors if (idx > 0) { const prev = errors[idx - 1] if (getErrorSignature(prev) === getErrorSignature(e)) { continue } } next = e break } return [ready, next] }, [errors, lookups]) const isLoading = React.useMemo(() => { return readyErrors.length < 1 && Boolean(errors.length) }, [errors.length, readyErrors.length]) React.useEffect(() => { if (nextError == null) { return } let mounted = true getErrorByType(nextError).then( (resolved) => { // We don't care if the desired error changed while we were resolving, // thus we're not tracking it using a ref. Once the work has been done, // we'll store it. if (mounted) { setLookups((m) => ({ ...m, [resolved.id]: resolved })) } }, () => { // TODO: handle this, though an edge case } ) return () => { mounted = false } }, [nextError]) const [displayState, setDisplayState] = React.useState< 'minimized' | 'fullscreen' | 'hidden' >('fullscreen') const [activeIdx, setActiveIndex] = React.useState(0) const previous = React.useCallback((e?: MouseEvent | TouchEvent) => { e?.preventDefault() setActiveIndex((v) => Math.max(0, v - 1)) }, []) const next = React.useCallback( (e?: MouseEvent | TouchEvent) => { e?.preventDefault() setActiveIndex((v) => Math.max(0, Math.min(readyErrors.length - 1, v + 1)) ) }, [readyErrors.length] ) const activeError = React.useMemo( () => readyErrors[activeIdx] ?? null, [activeIdx, readyErrors] ) // Reset component state when there are no errors to be displayed. // This should never happen, but lets handle it. React.useEffect(() => { if (errors.length < 1) { setLookups({}) setDisplayState('hidden') setActiveIndex(0) } }, [errors.length]) const minimize = React.useCallback((e?: MouseEvent | TouchEvent) => { e?.preventDefault() setDisplayState('minimized') }, []) const hide = React.useCallback((e?: MouseEvent | TouchEvent) => { e?.preventDefault() setDisplayState('hidden') }, []) const fullscreen = React.useCallback( (e?: React.MouseEvent) => { e?.preventDefault() setDisplayState('fullscreen') }, [] ) // This component shouldn't be rendered with no errors, but if it is, let's // handle it gracefully by rendering nothing. if (errors.length < 1 || activeError == null) { return null } if (isLoading) { // TODO: better loading state return } if (displayState === 'hidden') { return null } if (displayState === 'minimized') { return (
{readyErrors.length} error{readyErrors.length > 1 ? 's' : ''}
) } const isServerError = ['server', 'edge-server'].includes( getErrorSource(activeError.error) || '' ) return ( 0 ? previous : null} next={activeIdx < readyErrors.length - 1 ? next : null} close={isServerError ? undefined : minimize} > {activeIdx + 1} of{' '} {readyErrors.length} unhandled error {readyErrors.length < 2 ? '' : 's'}

{isServerError ? 'Server Error' : 'Unhandled Runtime Error'}

{activeError.error.name}:{' '}

{isServerError ? (
This error happened while generating the page. Any console logs will be displayed in the terminal window.
) : undefined}
) } export const styles = css` .nextjs-container-errors-header > h1 { font-size: var(--size-font-big); line-height: var(--size-font-bigger); font-weight: bold; margin: 0; margin-top: calc(var(--size-gap-double) + var(--size-gap-half)); } .nextjs-container-errors-header small { font-size: var(--size-font-small); color: var(--color-accents-1); margin-left: var(--size-gap-double); } .nextjs-container-errors-header small > span { font-family: var(--font-stack-monospace); } .nextjs-container-errors-header > p { font-family: var(--font-stack-monospace); font-size: var(--size-font-small); line-height: var(--size-font-big); font-weight: bold; margin: 0; margin-top: var(--size-gap-half); color: var(--color-ansi-red); white-space: pre-wrap; } .nextjs-container-errors-header > div > small { margin: 0; margin-top: var(--size-gap-half); } .nextjs-container-errors-header > p > a { color: var(--color-ansi-red); } .nextjs-container-errors-body > h5:not(:first-child) { margin-top: calc(var(--size-gap-double) + var(--size-gap)); } .nextjs-container-errors-body > h5 { margin-bottom: var(--size-gap); } .nextjs-toast-errors-parent { cursor: pointer; transition: transform 0.2s ease; } .nextjs-toast-errors-parent:hover { transform: scale(1.1); } .nextjs-toast-errors { display: flex; align-items: center; justify-content: flex-start; } .nextjs-toast-errors > svg { margin-right: var(--size-gap); } .nextjs-toast-errors-hide-button { margin-left: var(--size-gap-triple); border: none; background: none; color: var(--color-ansi-bright-white); padding: 0; transition: opacity 0.25s ease; opacity: 0.7; } .nextjs-toast-errors-hide-button:hover { opacity: 1; } `