Skip to content

Commit

Permalink
App Router: Send errors not handled by explicit error boundaries thro…
Browse files Browse the repository at this point in the history
…ugh `reportError`
  • Loading branch information
eps1lon committed Feb 17, 2025
1 parent a989f3f commit f40503b
Show file tree
Hide file tree
Showing 9 changed files with 125 additions and 179 deletions.
20 changes: 17 additions & 3 deletions packages/next/src/client/app-index.tsx
Original file line number Diff line number Diff line change
Expand Up @@ -27,6 +27,9 @@ import { MissingSlotContext } from '../shared/lib/app-router-context.shared-runt
import { setAppBuildId } from './app-build-id'
import { shouldRenderRootLevelErrorOverlay } from './lib/is-error-thrown-while-rendering-rsc'
import { handleClientError } from './components/errors/use-error-handler'
import OldAppDevErrorBoundary from './components/react-dev-overlay/app/old-react-dev-overlay'
import { DevOverlayErrorBoundary as AppDevErrorBoundary } from './components/react-dev-overlay/_experimental/app/error-boundary'
import { ErrorBoundaryHandler } from './components/error-boundary'

/// <reference types="react-dom/experimental" />

Expand Down Expand Up @@ -243,11 +246,22 @@ function Root({ children }: React.PropsWithChildren<{}>) {
return children
}

const reactRootOptions = {
const reactRootOptions: ReactDOMClient.RootOptions = {
onRecoverableError,
onCaughtError,
onCaughtError: (error, errorInfo) => {
const errorBoundaryComponent = errorInfo.errorBoundary?.constructor
const isImplicitErrorBoundary =
(process.env.NODE_ENV !== 'production' &&
(errorBoundaryComponent === OldAppDevErrorBoundary ||
errorBoundaryComponent === AppDevErrorBoundary)) ||
errorBoundaryComponent === ErrorBoundaryHandler
// Built-in error boundaries decide whether an error is caught or not.
if (!isImplicitErrorBoundary) {
onCaughtError(error, errorInfo)
}
},
onUncaughtError,
} satisfies ReactDOMClient.RootOptions
}

export function hydrate() {
const reactEl = (
Expand Down
14 changes: 14 additions & 0 deletions packages/next/src/client/components/error-boundary.tsx
Original file line number Diff line number Diff line change
Expand Up @@ -5,6 +5,10 @@ import { useUntrackedPathname } from './navigation-untracked'
import { isNextRouterError } from './is-next-router-error'
import { handleHardNavError } from './nav-failure-handler'
import { workAsyncStorage } from '../../server/app-render/work-async-storage.external'
import {
onCaughtError,
onUncaughtError,
} from '../react-client-callbacks/error-boundary-callbacks'

const styles = {
error: {
Expand Down Expand Up @@ -118,6 +122,16 @@ export class ErrorBoundaryHandler extends React.Component<
}
}

componentDidCatch(error: Error, errorInfo: React.ErrorInfo): void {
// We don't consider errors caught unless they're caught by an explicit error
// boundary. The built-in ones are considered implicit.
if (this.props.errorComponent === GlobalError) {
onUncaughtError(error, errorInfo)
} else {
onCaughtError(error, { ...errorInfo, errorBoundary: this })
}
}

reset = () => {
this.setState({ error: null })
}
Expand Down
Original file line number Diff line number Diff line change
Expand Up @@ -3,7 +3,7 @@ import { isNextRouterError } from '../is-next-router-error'
import { handleClientError } from '../errors/use-error-handler'
import { parseConsoleArgs } from '../../lib/console'

export const originConsoleError = window.console.error
export const originConsoleError = globalThis.console.error

// Patch console.error to collect information about hydration errors
export function patchConsoleError() {
Expand Down
Original file line number Diff line number Diff line change
Expand Up @@ -2,6 +2,7 @@ import type { GlobalErrorComponent } from '../../../error-boundary'

import { PureComponent } from 'react'
import { RuntimeErrorHandler } from '../../../errors/runtime-error-handler'
import { onUncaughtError } from '../../../../react-client-callbacks/error-boundary-callbacks'

type DevOverlayErrorBoundaryProps = {
children: React.ReactNode
Expand Down Expand Up @@ -57,8 +58,12 @@ export class DevOverlayErrorBoundary extends PureComponent<
}
}

componentDidCatch() {
componentDidCatch(error: Error, errorInfo: React.ErrorInfo): void {
this.props.onError(this.state.isReactError)

// We don't consider errors caught unless they're caught by an explicit error
// boundary. The built-in ones are considered implicit.
onUncaughtError(error, errorInfo)
}

render() {
Expand Down
Original file line number Diff line number Diff line change
Expand Up @@ -11,6 +11,7 @@ import { RootLayoutMissingTagsError } from '../internal/container/RootLayoutMiss
import type { Dispatcher } from './hot-reloader-client'
import { RuntimeErrorHandler } from '../../errors/runtime-error-handler'
import type { GlobalErrorComponent } from '../../error-boundary'
import { onUncaughtError } from '../../../react-client-callbacks/error-boundary-callbacks'

function ErroredHtml({
globalError: [GlobalError, globalErrorStyles],
Expand Down Expand Up @@ -63,6 +64,12 @@ export default class ReactDevOverlay extends React.PureComponent<
}
}

componentDidCatch(error: Error, errorInfo: React.ErrorInfo): void {
// We don't consider errors caught unless they're caught by an explicit error
// boundary. The built-in ones are considered implicit.
onUncaughtError(error, errorInfo)
}

render() {
const { state, children, dispatcher, globalError } = this.props
const { isReactError, reactError } = this.state
Expand Down
Original file line number Diff line number Diff line change
@@ -1,22 +1,22 @@
// This file is only used in app router due to the specific error state handling.

import type { HydrationOptions } from 'react-dom/client'
import type { ErrorInfo } from 'react'
import { getReactStitchedError } from '../components/errors/stitched-error'
import { handleClientError } from '../components/errors/use-error-handler'
import { isNextRouterError } from '../components/is-next-router-error'
import { isBailoutToCSRError } from '../../shared/lib/lazy-dynamic/bailout-to-csr'
import { reportGlobalError } from './report-global-error'
import { originConsoleError } from '../components/globals/intercept-console-error'

export const onCaughtError: HydrationOptions['onCaughtError'] = (
err,
errorInfo
) => {
export function onCaughtError(
err: unknown,
errorInfo: ErrorInfo & { errorBoundary?: React.Component }
) {
// Skip certain custom errors which are not expected to be reported on client
if (isBailoutToCSRError(err) || isNextRouterError(err)) return

if (process.env.NODE_ENV !== 'production') {
const errorBoundaryComponent = errorInfo?.errorBoundary?.constructor
const errorBoundaryComponent = errorInfo.errorBoundary?.constructor
const errorBoundaryName =
// read react component displayName
(errorBoundaryComponent as any)?.displayName ||
Expand Down Expand Up @@ -51,41 +51,28 @@ export const onCaughtError: HydrationOptions['onCaughtError'] = (
// Log and report the error with location but without modifying the error stack
originConsoleError('%o\n\n%s', err, errorLocation)

handleClientError(stitchedError, [])
if (typeof window !== 'undefined') {
handleClientError(stitchedError, [])
}
} else {
originConsoleError(err)
}
}

export const onUncaughtError: HydrationOptions['onUncaughtError'] = (
err,
errorInfo
) => {
export function onUncaughtError(err: unknown, errorInfo: React.ErrorInfo) {
// Skip certain custom errors which are not expected to be reported on client
if (isBailoutToCSRError(err) || isNextRouterError(err)) return

if (process.env.NODE_ENV !== 'production') {
const componentThatErroredFrame = errorInfo?.componentStack?.split('\n')[1]

// Match chrome or safari stack trace
const matches =
componentThatErroredFrame?.match(/\s+at (\w+)\s+|(\w+)@/) ?? []
const componentThatErroredName = matches[1] || matches[2] || 'Unknown'

// Create error location with errored component and error boundary, to match the behavior of default React onCaughtError handler.
const errorLocation = componentThatErroredName
? `The above error occurred in the <${componentThatErroredName}> component.`
: `The above error occurred in one of your components.`

const stitchedError = getReactStitchedError(err)
// TODO: change to passing down errorInfo later
// In development mode, pass along the component stack to the error
if (errorInfo.componentStack) {
;(stitchedError as any)._componentStack = errorInfo.componentStack
}

// Log and report the error with location but without modifying the error stack
originConsoleError('%o\n\n%s', err, errorLocation)
// TODO: Add an adendum to the overlay telling people about custom error boundaries.
// FIXME: Why double error overlay?
reportGlobalError(stitchedError)
} else {
reportGlobalError(err)
Expand Down
Original file line number Diff line number Diff line change
Expand Up @@ -4,5 +4,6 @@ export const reportGlobalError =
// emulating an uncaught JavaScript error.
reportError
: (error: unknown) => {
window.console.error(error)
// TODO: Dispatch error event
globalThis.console.error(error)
}
Loading

0 comments on commit f40503b

Please sign in to comment.