Skip to content
New issue

Have a question about this project? Sign up for a free GitHub account to open an issue and contact its maintainers and the community.

By clicking “Sign up for GitHub”, you agree to our terms of service and privacy statement. We’ll occasionally send you account related emails.

Already on GitHub? Sign in to your account

Handle error overlay for new router #41325

Merged
merged 8 commits into from Oct 11, 2022
Merged
Show file tree
Hide file tree
Changes from 7 commits
Commits
File filter

Filter by extension

Filter by extension


Conversations
Failed to load comments.
Jump to
Jump to file
Failed to load files.
Diff view
Diff view
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
}