Skip to content

Commit

Permalink
Handle error overlay for new router (vercel#41325)
Browse files Browse the repository at this point in the history
  • Loading branch information
timneutkens authored and Kikobeats committed Oct 24, 2022
1 parent 8fcb10d commit 1735719
Show file tree
Hide file tree
Showing 65 changed files with 7,013 additions and 7 deletions.
2 changes: 1 addition & 1 deletion packages/next/client/components/hot-reloader.tsx
Expand Up @@ -15,7 +15,7 @@ import {
onBuildOk,
onRefresh,
ReactDevOverlay,
} from 'next/dist/compiled/@next/react-dev-overlay/dist/client'
} from './react-dev-overlay/src/client'
import stripAnsi from 'next/dist/compiled/strip-ansi'
import formatWebpackMessages from '../dev/error-overlay/format-webpack-messages'
import { useRouter } from './hooks-client'
Expand Down
95 changes: 95 additions & 0 deletions packages/next/client/components/react-dev-overlay/src/client.ts
@@ -0,0 +1,95 @@
import * as Bus from './internal/bus'
import { parseStack } from './internal/helpers/parseStack'

let isRegistered = false
let stackTraceLimit: number | undefined = undefined

function onUnhandledError(ev: ErrorEvent) {
const error = ev?.error
if (!error || !(error instanceof Error) || typeof error.stack !== 'string') {
// A non-error was thrown, we don't have anything to show. :-(
return
}

if (
error.message.match(/(hydration|content does not match|did not match)/i)
) {
error.message += `\n\nSee more info here: https://nextjs.org/docs/messages/react-hydration-error`
}

const e = error
Bus.emit({
type: Bus.TYPE_UNHANDLED_ERROR,
reason: error,
frames: parseStack(e.stack!),
})
}

function onUnhandledRejection(ev: PromiseRejectionEvent) {
const reason = ev?.reason
if (
!reason ||
!(reason instanceof Error) ||
typeof reason.stack !== 'string'
) {
// A non-error was thrown, we don't have anything to show. :-(
return
}

const e = reason
Bus.emit({
type: Bus.TYPE_UNHANDLED_REJECTION,
reason: reason,
frames: parseStack(e.stack!),
})
}

function register() {
if (isRegistered) {
return
}
isRegistered = true

try {
const limit = Error.stackTraceLimit
Error.stackTraceLimit = 50
stackTraceLimit = limit
} catch {}

window.addEventListener('error', onUnhandledError)
window.addEventListener('unhandledrejection', onUnhandledRejection)
}

function unregister() {
if (!isRegistered) {
return
}
isRegistered = false

if (stackTraceLimit !== undefined) {
try {
Error.stackTraceLimit = stackTraceLimit
} catch {}
stackTraceLimit = undefined
}

window.removeEventListener('error', onUnhandledError)
window.removeEventListener('unhandledrejection', onUnhandledRejection)
}

function onBuildOk() {
Bus.emit({ type: Bus.TYPE_BUILD_OK })
}

function onBuildError(message: string) {
Bus.emit({ type: Bus.TYPE_BUILD_ERROR, message })
}

function onRefresh() {
Bus.emit({ type: Bus.TYPE_REFRESH })
}

export { getErrorByType } from './internal/helpers/getErrorByType'
export { getServerError } from './internal/helpers/nodeStackFrames'
export { default as ReactDevOverlay } from './internal/ReactDevOverlay'
export { onBuildOk, onBuildError, register, unregister, onRefresh }
@@ -0,0 +1,50 @@
import React from 'react'

type ErrorBoundaryProps = {
onError: (error: Error, componentStack: string | null) => void
globalOverlay?: boolean
isMounted?: boolean
}
type ErrorBoundaryState = { error: Error | null }

class ErrorBoundary extends React.PureComponent<
ErrorBoundaryProps,
ErrorBoundaryState
> {
state = { error: null }

static getDerivedStateFromError(error: Error) {
return { error }
}

componentDidCatch(
error: Error,
// Loosely typed because it depends on the React version and was
// accidentally excluded in some versions.
errorInfo?: { componentStack?: string | null }
) {
this.props.onError(error, errorInfo?.componentStack || null)
if (!this.props.globalOverlay) {
this.setState({ error })
}
}

render() {
// The component has to be unmounted or else it would continue to error
return this.state.error ||
(this.props.globalOverlay && this.props.isMounted) ? (
// When the overlay is global for the application and it wraps a component rendering `<html>`
// we have to render the html shell otherwise the shadow root will not be able to attach
this.props.globalOverlay ? (
<html>
<head></head>
<body></body>
</html>
) : null
) : (
this.props.children
)
}
}

export { ErrorBoundary }
@@ -0,0 +1,127 @@
import * as React from 'react'

import * as Bus from './bus'
import { ShadowPortal } from './components/ShadowPortal'
import { BuildError } from './container/BuildError'
import { Errors, SupportedErrorEvent } from './container/Errors'
import { ErrorBoundary } from './ErrorBoundary'
import { Base } from './styles/Base'
import { ComponentStyles } from './styles/ComponentStyles'
import { CssReset } from './styles/CssReset'

type OverlayState = {
nextId: number
buildError: string | null
errors: SupportedErrorEvent[]
}

function reducer(state: OverlayState, ev: Bus.BusEvent): OverlayState {
switch (ev.type) {
case Bus.TYPE_BUILD_OK: {
return { ...state, buildError: null }
}
case Bus.TYPE_BUILD_ERROR: {
return { ...state, buildError: ev.message }
}
case Bus.TYPE_REFRESH: {
return { ...state, buildError: null, errors: [] }
}
case Bus.TYPE_UNHANDLED_ERROR:
case Bus.TYPE_UNHANDLED_REJECTION: {
return {
...state,
nextId: state.nextId + 1,
errors: [
...state.errors.filter((err) => {
// Filter out duplicate errors
return err.event.reason !== ev.reason
}),
{ id: state.nextId, event: ev },
],
}
}
default: {
// eslint-disable-next-line @typescript-eslint/no-unused-vars
const _: never = ev
return state
}
}
}

type ErrorType = 'runtime' | 'build'

const shouldPreventDisplay = (
errorType?: ErrorType | null,
preventType?: ErrorType[] | null
) => {
if (!preventType || !errorType) {
return false
}
return preventType.includes(errorType)
}

type ReactDevOverlayProps = {
children?: React.ReactNode
preventDisplay?: ErrorType[]
globalOverlay?: boolean
}

const ReactDevOverlay: React.FunctionComponent<ReactDevOverlayProps> =
function ReactDevOverlay({ children, preventDisplay, globalOverlay }) {
const [state, dispatch] = React.useReducer<
React.Reducer<OverlayState, Bus.BusEvent>
>(reducer, {
nextId: 1,
buildError: null,
errors: [],
})

React.useEffect(() => {
Bus.on(dispatch)
return function () {
Bus.off(dispatch)
}
}, [dispatch])

const onComponentError = React.useCallback(
(_error: Error, _componentStack: string | null) => {
// TODO: special handling
},
[]
)

const hasBuildError = state.buildError != null
const hasRuntimeErrors = Boolean(state.errors.length)

const isMounted = hasBuildError || hasRuntimeErrors

return (
<React.Fragment>
<ErrorBoundary
globalOverlay={globalOverlay}
isMounted={isMounted}
onError={onComponentError}
>
{children ?? null}
</ErrorBoundary>
{isMounted ? (
<ShadowPortal globalOverlay={globalOverlay}>
<CssReset />
<Base />
<ComponentStyles />

{shouldPreventDisplay(
hasBuildError ? 'build' : hasRuntimeErrors ? 'runtime' : null,
preventDisplay
) ? null : hasBuildError ? (
<BuildError message={state.buildError!} />
) : hasRuntimeErrors ? (
<Errors errors={state.errors} />
) : undefined}
</ShadowPortal>
) : undefined}
</React.Fragment>
)
}

export default ReactDevOverlay
@@ -0,0 +1,76 @@
import type { StackFrame } from 'next/dist/compiled/stacktrace-parser'

export const TYPE_BUILD_OK = 'build-ok'
export const TYPE_BUILD_ERROR = 'build-error'
export const TYPE_REFRESH = 'fast-refresh'
export const TYPE_UNHANDLED_ERROR = 'unhandled-error'
export const TYPE_UNHANDLED_REJECTION = 'unhandled-rejection'

export type BuildOk = { type: typeof TYPE_BUILD_OK }
export type BuildError = {
type: typeof TYPE_BUILD_ERROR
message: string
}
export type FastRefresh = { type: typeof TYPE_REFRESH }
export type UnhandledError = {
type: typeof TYPE_UNHANDLED_ERROR
reason: Error
frames: StackFrame[]
}
export type UnhandledRejection = {
type: typeof TYPE_UNHANDLED_REJECTION
reason: Error
frames: StackFrame[]
}
export type BusEvent =
| BuildOk
| BuildError
| FastRefresh
| UnhandledError
| UnhandledRejection

export type BusEventHandler = (ev: BusEvent) => void

let handlers: Set<BusEventHandler> = new Set()
let queue: BusEvent[] = []

function drain() {
// Draining should never happen synchronously in case multiple handlers are
// registered.
setTimeout(function () {
while (
// Until we are out of events:
Boolean(queue.length) &&
// Or, if all handlers removed themselves as a result of handling the
// event(s)
Boolean(handlers.size)
) {
const ev = queue.shift()!
handlers.forEach((handler) => handler(ev))
}
}, 1)
}

export function emit(ev: BusEvent): void {
queue.push(Object.freeze({ ...ev }))
drain()
}

export function on(fn: BusEventHandler): boolean {
if (handlers.has(fn)) {
return false
}

handlers.add(fn)
drain()
return true
}

export function off(fn: BusEventHandler): boolean {
if (handlers.has(fn)) {
handlers.delete(fn)
return true
}

return false
}

0 comments on commit 1735719

Please sign in to comment.