Skip to content

Commit ea54198

Browse files
authored
fix: handling of falsey values in error boundaries (#93134)
our error boundaries had a bunch of logic that set `state.error = error` and then checked `if (state.error)`, which only works correctly if thrown value is truthy. it breaks if something does e.g. `throw undefined`. in this case, we would incorrectly think that no error occurred and render children again (instead of a fallback), which can then lead to an infinite loop if the children throw again. the fix is to wrap the thrown value, so `state.error` is either `null` (initial/reset) or `{ thrownValue: ... }` if something errored. i initially considered using a separate `state.hasError` boolean, but that's a bit annoying to type, and really we want to model this as a discriminated union, so using a pseudo-Optional thing is nicer.
1 parent 70d9876 commit ea54198

30 files changed

Lines changed: 463 additions & 68 deletions

File tree

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

Lines changed: 13 additions & 9 deletions
Original file line numberDiff line numberDiff line change
@@ -30,15 +30,15 @@ type CatchErrorProps<P extends UserProps> = {
3030
}
3131

3232
type CatchErrorState = {
33-
error: Error | null
33+
error: null | { thrownValue: unknown }
3434
previousPathname: string | null
3535
}
3636

3737
// This is forked from error-boundary.
3838
// TODO: Extend it instead of forking to easily sync the behavior?
3939
class CatchError<P extends UserProps> extends React.Component<
4040
CatchErrorProps<P>,
41-
{ error: Error | null; previousPathname: string | null }
41+
CatchErrorState
4242
> {
4343
declare context: AppRouterInstance | null
4444
static contextType = AppRouterContext
@@ -54,14 +54,16 @@ class CatchError<P extends UserProps> extends React.Component<
5454
}
5555
}
5656

57-
static getDerivedStateFromError(error: Error) {
58-
if (isNextRouterError(error)) {
57+
static getDerivedStateFromError(
58+
thrownValue: unknown
59+
): Partial<CatchErrorState> {
60+
if (isNextRouterError(thrownValue)) {
5961
// Re-throw if an expected internal Next.js router error occurs
6062
// this means it should be handled by a different boundary (such as a NotFound boundary in a parent segment)
61-
throw error
63+
throw thrownValue
6264
}
6365

64-
return { error }
66+
return { error: { thrownValue } }
6567
}
6668

6769
static getDerivedStateFromProps(
@@ -75,7 +77,7 @@ class CatchError<P extends UserProps> extends React.Component<
7577
// the error boundary and instead should fallback
7678
// to a hard navigation to attempt recovering
7779
if (process.env.__NEXT_APP_NAV_FAIL_HANDLING) {
78-
if (error && handleHardNavError(error)) {
80+
if (error && handleHardNavError(error.thrownValue)) {
7981
// clear error so we don't render anything
8082
return {
8183
error: null,
@@ -124,13 +126,15 @@ class CatchError<P extends UserProps> extends React.Component<
124126
//When it's bot request, segment level error boundary will keep rendering the children,
125127
// the final error will be caught by the root error boundary and determine wether need to apply graceful degrade.
126128
if (this.state.error && !isBotUserAgent) {
127-
handleISRError({ error: this.state.error })
129+
const thrownValue = this.state.error.thrownValue
130+
handleISRError({ error: thrownValue })
128131

129132
return (
130133
<this.props.fallback
131134
props={this.props.props}
132135
errorInfo={{
133-
error: this.state.error,
136+
// TODO(NAR-804): Docs say this is an Error object, but we don't guarantee that
137+
error: thrownValue,
134138
reset: this.reset,
135139
unstable_retry: this.unstable_retry,
136140
}}

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

Lines changed: 13 additions & 9 deletions
Original file line numberDiff line numberDiff line change
@@ -15,7 +15,7 @@ const isBotUserAgent =
1515
typeof window !== 'undefined' && isBot(window.navigator.userAgent)
1616

1717
export type ErrorInfo = {
18-
error: Error
18+
error: unknown
1919
reset: () => void
2020
unstable_retry: () => void
2121
}
@@ -35,7 +35,7 @@ interface ErrorBoundaryHandlerProps extends ErrorBoundaryProps {
3535
}
3636

3737
interface ErrorBoundaryHandlerState {
38-
error: Error | null
38+
error: null | { thrownValue: unknown }
3939
previousPathname: string | null
4040
}
4141

@@ -54,14 +54,16 @@ export class ErrorBoundaryHandler extends React.Component<
5454
}
5555
}
5656

57-
static getDerivedStateFromError(error: Error) {
58-
if (isNextRouterError(error)) {
57+
static getDerivedStateFromError(
58+
thrownValue: unknown
59+
): Partial<ErrorBoundaryHandlerState> {
60+
if (isNextRouterError(thrownValue)) {
5961
// Re-throw if an expected internal Next.js router error occurs
6062
// this means it should be handled by a different boundary (such as a NotFound boundary in a parent segment)
61-
throw error
63+
throw thrownValue
6264
}
6365

64-
return { error }
66+
return { error: { thrownValue } }
6567
}
6668

6769
static getDerivedStateFromProps(
@@ -75,7 +77,7 @@ export class ErrorBoundaryHandler extends React.Component<
7577
// the error boundary and instead should fallback
7678
// to a hard navigation to attempt recovering
7779
if (process.env.__NEXT_APP_NAV_FAIL_HANDLING) {
78-
if (error && handleHardNavError(error)) {
80+
if (error && handleHardNavError(error.thrownValue)) {
7981
// clear error so we don't render anything
8082
return {
8183
error: null,
@@ -118,14 +120,16 @@ export class ErrorBoundaryHandler extends React.Component<
118120
//When it's bot request, segment level error boundary will keep rendering the children,
119121
// the final error will be caught by the root error boundary and determine wether need to apply graceful degrade.
120122
if (this.state.error && !isBotUserAgent) {
121-
handleISRError({ error: this.state.error })
123+
const thrownValue = this.state.error.thrownValue
124+
handleISRError({ error: thrownValue })
122125

123126
return (
124127
<>
125128
{this.props.errorStyles}
126129
{this.props.errorScripts}
127130
<this.props.errorComponent
128-
error={this.state.error}
131+
// TODO(NAR-804): Docs say this is an Error object, but we don't guarantee that
132+
error={thrownValue}
129133
reset={this.reset}
130134
unstable_retry={this.unstable_retry}
131135
/>

packages/next/src/client/components/http-access-fallback/error-boundary.tsx

Lines changed: 1 addition & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -77,7 +77,7 @@ class HTTPAccessFallbackErrorBoundary extends React.Component<
7777
}
7878
}
7979

80-
static getDerivedStateFromError(error: any) {
80+
static getDerivedStateFromError(error: unknown) {
8181
if (isHTTPAccessFallbackError(error)) {
8282
const httpStatus = getAccessFallbackHTTPStatus(error)
8383
return {

packages/next/src/client/components/nav-failure-handler.ts

Lines changed: 0 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -3,7 +3,6 @@ import { createHrefFromUrl } from './router-reducer/create-href-from-url'
33

44
export function handleHardNavError(error: unknown): boolean {
55
if (
6-
error &&
76
typeof window !== 'undefined' &&
87
window.next.__pendingUrl &&
98
createHrefFromUrl(new URL(window.location.href)) !==

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

Lines changed: 1 addition & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -44,7 +44,7 @@ export class RedirectErrorBoundary extends React.Component<
4444
this.state = { redirect: null, redirectType: null }
4545
}
4646

47-
static getDerivedStateFromError(error: any) {
47+
static getDerivedStateFromError(error: unknown) {
4848
if (isRedirectError(error)) {
4949
const url = getURLFromRedirectError(error)
5050
const redirectType = getRedirectTypeFromError(error)

packages/next/src/next-devtools/userspace/app/app-dev-overlay-error-boundary.tsx

Lines changed: 26 additions & 27 deletions
Original file line numberDiff line numberDiff line change
@@ -9,40 +9,33 @@ import {
99
AppRouterContext,
1010
type AppRouterInstance,
1111
} from '../../../shared/lib/app-router-context.shared-runtime'
12+
import isError from '../../../lib/is-error'
1213

1314
type AppDevOverlayErrorBoundaryProps = {
1415
children: React.ReactNode
1516
globalError: GlobalErrorState
1617
}
1718

1819
type AppDevOverlayErrorBoundaryState = {
19-
reactError: unknown
20+
error: null | { thrownValue: unknown }
2021
}
2122

2223
function ErroredHtml({
2324
globalError: [GlobalError, globalErrorStyles],
24-
error,
25+
thrownValue,
2526
reset,
2627
unstable_retry,
2728
}: {
2829
globalError: GlobalErrorState
29-
error: unknown
30+
thrownValue: unknown
3031
reset: () => void
3132
unstable_retry: () => void
3233
}) {
33-
if (!error) {
34-
return (
35-
<html>
36-
<head />
37-
<body />
38-
</html>
39-
)
40-
}
4134
return (
4235
<ErrorBoundary errorComponent={DefaultGlobalError}>
4336
{globalErrorStyles}
4437
<GlobalError
45-
error={error}
38+
error={thrownValue}
4639
reset={reset}
4740
unstable_retry={unstable_retry}
4841
/>
@@ -58,20 +51,23 @@ export class AppDevOverlayErrorBoundary extends PureComponent<
5851
declare context: AppRouterInstance | null
5952

6053
state: AppDevOverlayErrorBoundaryState = {
61-
reactError: null,
54+
error: null,
6255
}
6356

64-
static getDerivedStateFromError(error: Error) {
57+
static getDerivedStateFromError(
58+
thrownValue: Error
59+
): Partial<AppDevOverlayErrorBoundaryState> {
6560
RuntimeErrorHandler.hadRuntimeError = true
6661

6762
return {
68-
reactError: error,
63+
error: { thrownValue },
6964
}
7065
}
7166

72-
componentDidCatch(err: Error) {
67+
componentDidCatch(err: unknown) {
7368
if (
7469
process.env.NODE_ENV === 'development' &&
70+
isError(err) &&
7571
err.message === SEGMENT_EXPLORER_SIMULATED_ERROR_MESSAGE
7672
) {
7773
return
@@ -87,22 +83,25 @@ export class AppDevOverlayErrorBoundary extends PureComponent<
8783
}
8884

8985
reset = () => {
90-
this.setState({ reactError: null })
86+
this.setState({ error: null })
9187
}
9288

9389
render() {
9490
const { children, globalError } = this.props
95-
const { reactError } = this.state
91+
const { error } = this.state
9692

97-
const fallback = (
98-
<ErroredHtml
99-
globalError={globalError}
100-
error={reactError}
101-
reset={this.reset}
102-
unstable_retry={this.unstable_retry}
103-
/>
104-
)
93+
if (error !== null) {
94+
const thrownValue = error.thrownValue
95+
return (
96+
<ErroredHtml
97+
globalError={globalError}
98+
thrownValue={thrownValue}
99+
reset={this.reset}
100+
unstable_retry={this.unstable_retry}
101+
/>
102+
)
103+
}
105104

106-
return reactError !== null ? fallback : children
105+
return children
107106
}
108107
}

packages/next/src/next-devtools/userspace/pages/pages-dev-overlay-error-boundary.tsx

Lines changed: 9 additions & 5 deletions
Original file line numberDiff line numberDiff line change
@@ -3,21 +3,25 @@ import React from 'react'
33
type PagesDevOverlayErrorBoundaryProps = {
44
children?: React.ReactNode
55
}
6-
type PagesDevOverlayErrorBoundaryState = { error: Error | null }
6+
type PagesDevOverlayErrorBoundaryState = {
7+
hasError: boolean
8+
}
79

810
export class PagesDevOverlayErrorBoundary extends React.PureComponent<
911
PagesDevOverlayErrorBoundaryProps,
1012
PagesDevOverlayErrorBoundaryState
1113
> {
12-
state = { error: null }
14+
state = { hasError: false }
1315

14-
static getDerivedStateFromError(error: Error) {
15-
return { error }
16+
static getDerivedStateFromError(
17+
_: unknown
18+
): Partial<PagesDevOverlayErrorBoundaryState> {
19+
return { hasError: true }
1620
}
1721

1822
// Explicit type is needed to avoid the generated `.d.ts` having a wide return type that could be specific to the `@types/react` version.
1923
render(): React.ReactNode {
2024
// The component has to be unmounted or else it would continue to error
21-
return this.state.error ? null : this.props.children
25+
return this.state.hasError ? null : this.props.children
2226
}
2327
}

test/e2e/app-dir/catch-error/app/client-component/catch-error-wrapper.tsx

Lines changed: 1 addition & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -9,7 +9,7 @@ export function ErrorFallback(
99
) {
1010
return (
1111
<>
12-
<p id="error-boundary-message">{error.message}</p>
12+
<p id="error-boundary-message">{(error as Error).message}</p>
1313
<p id="error-boundary-title">{props.title}</p>
1414
<button id="reset" onClick={() => reset()}>
1515
Reset
Lines changed: 37 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,37 @@
1+
'use client'
2+
3+
import { useState } from 'react'
4+
import type { ErrorInfo } from 'next/error'
5+
import { unstable_catchError } from 'next/error'
6+
7+
function Inner() {
8+
const [clicked, setClicked] = useState(false)
9+
if (clicked) {
10+
// eslint-disable-next-line no-throw-literal -- testing bad values on purpose
11+
throw null
12+
}
13+
return (
14+
<button
15+
id="error-trigger-button"
16+
onClick={() => {
17+
setClicked(true)
18+
}}
19+
>
20+
Trigger Error!
21+
</button>
22+
)
23+
}
24+
25+
function ErrorFallback(_props: {}, { error }: ErrorInfo) {
26+
return <p id="error-boundary-message">{`An error occurred: ${error}`}</p>
27+
}
28+
29+
const Wrapped = unstable_catchError(ErrorFallback)
30+
31+
export default function Page() {
32+
return (
33+
<Wrapped>
34+
<Inner />
35+
</Wrapped>
36+
)
37+
}
Lines changed: 37 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,37 @@
1+
'use client'
2+
3+
import { useState } from 'react'
4+
import type { ErrorInfo } from 'next/error'
5+
import { unstable_catchError } from 'next/error'
6+
7+
function Inner() {
8+
const [clicked, setClicked] = useState(false)
9+
if (clicked) {
10+
// eslint-disable-next-line no-throw-literal -- testing bad values on purpose
11+
throw undefined
12+
}
13+
return (
14+
<button
15+
id="error-trigger-button"
16+
onClick={() => {
17+
setClicked(true)
18+
}}
19+
>
20+
Trigger Error!
21+
</button>
22+
)
23+
}
24+
25+
function ErrorFallback(_props: {}, { error }: ErrorInfo) {
26+
return <p id="error-boundary-message">{`An error occurred: ${error}`}</p>
27+
}
28+
29+
const Wrapped = unstable_catchError(ErrorFallback)
30+
31+
export default function Page() {
32+
return (
33+
<Wrapped>
34+
<Inner />
35+
</Wrapped>
36+
)
37+
}

0 commit comments

Comments
 (0)