Skip to content

Commit f40503b

Browse files
committed
App Router: Send errors not handled by explicit error boundaries through reportError
1 parent a989f3f commit f40503b

File tree

9 files changed

+125
-179
lines changed

9 files changed

+125
-179
lines changed

packages/next/src/client/app-index.tsx

Lines changed: 17 additions & 3 deletions
Original file line numberDiff line numberDiff line change
@@ -27,6 +27,9 @@ import { MissingSlotContext } from '../shared/lib/app-router-context.shared-runt
2727
import { setAppBuildId } from './app-build-id'
2828
import { shouldRenderRootLevelErrorOverlay } from './lib/is-error-thrown-while-rendering-rsc'
2929
import { handleClientError } from './components/errors/use-error-handler'
30+
import OldAppDevErrorBoundary from './components/react-dev-overlay/app/old-react-dev-overlay'
31+
import { DevOverlayErrorBoundary as AppDevErrorBoundary } from './components/react-dev-overlay/_experimental/app/error-boundary'
32+
import { ErrorBoundaryHandler } from './components/error-boundary'
3033

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

@@ -243,11 +246,22 @@ function Root({ children }: React.PropsWithChildren<{}>) {
243246
return children
244247
}
245248

246-
const reactRootOptions = {
249+
const reactRootOptions: ReactDOMClient.RootOptions = {
247250
onRecoverableError,
248-
onCaughtError,
251+
onCaughtError: (error, errorInfo) => {
252+
const errorBoundaryComponent = errorInfo.errorBoundary?.constructor
253+
const isImplicitErrorBoundary =
254+
(process.env.NODE_ENV !== 'production' &&
255+
(errorBoundaryComponent === OldAppDevErrorBoundary ||
256+
errorBoundaryComponent === AppDevErrorBoundary)) ||
257+
errorBoundaryComponent === ErrorBoundaryHandler
258+
// Built-in error boundaries decide whether an error is caught or not.
259+
if (!isImplicitErrorBoundary) {
260+
onCaughtError(error, errorInfo)
261+
}
262+
},
249263
onUncaughtError,
250-
} satisfies ReactDOMClient.RootOptions
264+
}
251265

252266
export function hydrate() {
253267
const reactEl = (

packages/next/src/client/components/error-boundary.tsx

Lines changed: 14 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -5,6 +5,10 @@ import { useUntrackedPathname } from './navigation-untracked'
55
import { isNextRouterError } from './is-next-router-error'
66
import { handleHardNavError } from './nav-failure-handler'
77
import { workAsyncStorage } from '../../server/app-render/work-async-storage.external'
8+
import {
9+
onCaughtError,
10+
onUncaughtError,
11+
} from '../react-client-callbacks/error-boundary-callbacks'
812

913
const styles = {
1014
error: {
@@ -118,6 +122,16 @@ export class ErrorBoundaryHandler extends React.Component<
118122
}
119123
}
120124

125+
componentDidCatch(error: Error, errorInfo: React.ErrorInfo): void {
126+
// We don't consider errors caught unless they're caught by an explicit error
127+
// boundary. The built-in ones are considered implicit.
128+
if (this.props.errorComponent === GlobalError) {
129+
onUncaughtError(error, errorInfo)
130+
} else {
131+
onCaughtError(error, { ...errorInfo, errorBoundary: this })
132+
}
133+
}
134+
121135
reset = () => {
122136
this.setState({ error: null })
123137
}

packages/next/src/client/components/globals/intercept-console-error.ts

Lines changed: 1 addition & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -3,7 +3,7 @@ import { isNextRouterError } from '../is-next-router-error'
33
import { handleClientError } from '../errors/use-error-handler'
44
import { parseConsoleArgs } from '../../lib/console'
55

6-
export const originConsoleError = window.console.error
6+
export const originConsoleError = globalThis.console.error
77

88
// Patch console.error to collect information about hydration errors
99
export function patchConsoleError() {

packages/next/src/client/components/react-dev-overlay/_experimental/app/error-boundary.tsx

Lines changed: 6 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -2,6 +2,7 @@ import type { GlobalErrorComponent } from '../../../error-boundary'
22

33
import { PureComponent } from 'react'
44
import { RuntimeErrorHandler } from '../../../errors/runtime-error-handler'
5+
import { onUncaughtError } from '../../../../react-client-callbacks/error-boundary-callbacks'
56

67
type DevOverlayErrorBoundaryProps = {
78
children: React.ReactNode
@@ -57,8 +58,12 @@ export class DevOverlayErrorBoundary extends PureComponent<
5758
}
5859
}
5960

60-
componentDidCatch() {
61+
componentDidCatch(error: Error, errorInfo: React.ErrorInfo): void {
6162
this.props.onError(this.state.isReactError)
63+
64+
// We don't consider errors caught unless they're caught by an explicit error
65+
// boundary. The built-in ones are considered implicit.
66+
onUncaughtError(error, errorInfo)
6267
}
6368

6469
render() {

packages/next/src/client/components/react-dev-overlay/app/old-react-dev-overlay.tsx

Lines changed: 7 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -11,6 +11,7 @@ import { RootLayoutMissingTagsError } from '../internal/container/RootLayoutMiss
1111
import type { Dispatcher } from './hot-reloader-client'
1212
import { RuntimeErrorHandler } from '../../errors/runtime-error-handler'
1313
import type { GlobalErrorComponent } from '../../error-boundary'
14+
import { onUncaughtError } from '../../../react-client-callbacks/error-boundary-callbacks'
1415

1516
function ErroredHtml({
1617
globalError: [GlobalError, globalErrorStyles],
@@ -63,6 +64,12 @@ export default class ReactDevOverlay extends React.PureComponent<
6364
}
6465
}
6566

67+
componentDidCatch(error: Error, errorInfo: React.ErrorInfo): void {
68+
// We don't consider errors caught unless they're caught by an explicit error
69+
// boundary. The built-in ones are considered implicit.
70+
onUncaughtError(error, errorInfo)
71+
}
72+
6673
render() {
6774
const { state, children, dispatcher, globalError } = this.props
6875
const { isReactError, reactError } = this.state

packages/next/src/client/react-client-callbacks/error-boundary-callbacks.ts

Lines changed: 12 additions & 25 deletions
Original file line numberDiff line numberDiff line change
@@ -1,22 +1,22 @@
11
// This file is only used in app router due to the specific error state handling.
22

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

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

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

54-
handleClientError(stitchedError, [])
54+
if (typeof window !== 'undefined') {
55+
handleClientError(stitchedError, [])
56+
}
5557
} else {
5658
originConsoleError(err)
5759
}
5860
}
5961

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

6766
if (process.env.NODE_ENV !== 'production') {
68-
const componentThatErroredFrame = errorInfo?.componentStack?.split('\n')[1]
69-
70-
// Match chrome or safari stack trace
71-
const matches =
72-
componentThatErroredFrame?.match(/\s+at (\w+)\s+|(\w+)@/) ?? []
73-
const componentThatErroredName = matches[1] || matches[2] || 'Unknown'
74-
75-
// Create error location with errored component and error boundary, to match the behavior of default React onCaughtError handler.
76-
const errorLocation = componentThatErroredName
77-
? `The above error occurred in the <${componentThatErroredName}> component.`
78-
: `The above error occurred in one of your components.`
79-
8067
const stitchedError = getReactStitchedError(err)
8168
// TODO: change to passing down errorInfo later
8269
// In development mode, pass along the component stack to the error
8370
if (errorInfo.componentStack) {
8471
;(stitchedError as any)._componentStack = errorInfo.componentStack
8572
}
8673

87-
// Log and report the error with location but without modifying the error stack
88-
originConsoleError('%o\n\n%s', err, errorLocation)
74+
// TODO: Add an adendum to the overlay telling people about custom error boundaries.
75+
// FIXME: Why double error overlay?
8976
reportGlobalError(stitchedError)
9077
} else {
9178
reportGlobalError(err)

packages/next/src/client/react-client-callbacks/report-global-error.ts

Lines changed: 2 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -4,5 +4,6 @@ export const reportGlobalError =
44
// emulating an uncaught JavaScript error.
55
reportError
66
: (error: unknown) => {
7-
window.console.error(error)
7+
// TODO: Dispatch error event
8+
globalThis.console.error(error)
89
}

0 commit comments

Comments
 (0)