-
Notifications
You must be signed in to change notification settings - Fork 98
Expand file tree
/
Copy pathErrorBoundary.tsx
More file actions
330 lines (297 loc) · 10.9 KB
/
Copy pathErrorBoundary.tsx
File metadata and controls
330 lines (297 loc) · 10.9 KB
1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
24
25
26
27
28
29
30
31
32
33
34
35
36
37
38
39
40
41
42
43
44
45
46
47
48
49
50
51
52
53
54
55
56
57
58
59
60
61
62
63
64
65
66
67
68
69
70
71
72
73
74
75
76
77
78
79
80
81
82
83
84
85
86
87
88
89
90
91
92
93
94
95
96
97
98
99
100
101
102
103
104
105
106
107
108
109
110
111
112
113
114
115
116
117
118
119
120
121
122
123
124
125
126
127
128
129
130
131
132
133
134
135
136
137
138
139
140
141
142
143
144
145
146
147
148
149
150
151
152
153
154
155
156
157
158
159
160
161
162
163
164
165
166
167
168
169
170
171
172
173
174
175
176
177
178
179
180
181
182
183
184
185
186
187
188
189
190
191
192
193
194
195
196
197
198
199
200
201
202
203
204
205
206
207
208
209
210
211
212
213
214
215
216
217
218
219
220
221
222
223
224
225
226
227
228
229
230
231
232
233
234
235
236
237
238
239
240
241
242
243
244
245
246
247
248
249
250
251
252
253
254
255
256
257
258
259
260
261
262
263
264
265
266
267
268
269
270
271
272
273
274
275
276
277
278
279
280
281
282
283
284
285
286
287
288
289
290
291
292
293
294
295
296
297
298
299
300
301
302
303
304
305
306
307
308
309
310
311
312
313
314
315
316
317
318
319
320
321
322
323
324
325
326
327
328
329
330
import {
Component,
type ComponentProps,
type ComponentType,
type ErrorInfo,
type ForwardRefExoticComponent,
type ForwardedRef,
type FunctionComponent,
type PropsWithChildren,
type ReactNode,
createContext,
forwardRef,
useContext,
useImperativeHandle,
useMemo,
useRef,
useState,
} from 'react'
import { ErrorBoundaryGroupContext } from './ErrorBoundaryGroup'
import {
Message_useErrorBoundaryFallbackProps_this_hook_should_be_called_in_ErrorBoundary_props_fallback,
Message_useErrorBoundary_this_hook_should_be_called_in_ErrorBoundary_props_children,
SuspensiveError,
} from './models/SuspensiveError'
import type { ConstructorType } from './utility-types/ConstructorType'
import type { PropsWithoutChildren } from './utility-types/PropsWithoutChildren'
import { hasResetKeysChanged } from './utils/hasResetKeysChanged'
interface ErrorBoundaryHandle {
/**
* when you want to reset caught error, you can use this reset
*/
reset: () => void
}
export interface ErrorBoundaryFallbackProps<TError extends Error = Error> extends ErrorBoundaryHandle {
/**
* when ErrorBoundary catch error, you can use this error
*/
error: TError
}
type ErrorTypeGuard<TError extends Error> = (error: Error) => error is TError
type ErrorValidator = (error: Error) => boolean
type ErrorMatcher = boolean | ConstructorType<Error> | ErrorTypeGuard<Error> | ErrorValidator
type InferErrorByErrorMatcher<TErrorMatcher extends ErrorMatcher> =
TErrorMatcher extends ConstructorType<infer TErrorOfConstructorType extends Error>
? TErrorOfConstructorType
: TErrorMatcher extends ErrorTypeGuard<infer TErrorOfTypeGuard extends Error>
? TErrorOfTypeGuard
: Error
type ShouldCatch = ErrorMatcher | [ErrorMatcher, ...ErrorMatcher[]]
/**
* Main type inference from shouldCatch
*/
type InferError<TShouldCatch extends ShouldCatch> = TShouldCatch extends readonly ErrorMatcher[]
? InferErrorByErrorMatcher<TShouldCatch[number]> extends never
? Error
: InferErrorByErrorMatcher<TShouldCatch[number]>
: TShouldCatch extends ErrorMatcher
? InferErrorByErrorMatcher<TShouldCatch>
: Error
const matchError = (errorMatcher: ErrorMatcher, error: Error): error is InferError<typeof errorMatcher> => {
if (typeof errorMatcher === 'boolean') {
return errorMatcher
}
if (typeof errorMatcher === 'function') {
try {
if (errorMatcher === Error || errorMatcher.prototype instanceof Error) {
return error instanceof errorMatcher
}
} catch {
// If accessing prototype throws, it's not a constructor. This can happen with proxy objects or in restricted environments.
}
return (errorMatcher as ErrorValidator | ErrorTypeGuard<InferError<typeof errorMatcher>>)(error)
}
return false
}
const shouldCatchError = <TShouldCatch extends ShouldCatch>(
shouldCatch: TShouldCatch | true,
error: Error
): error is InferError<TShouldCatch> =>
Array.isArray(shouldCatch)
? shouldCatch.some((errorMatcher) => matchError(errorMatcher, error))
: matchError(shouldCatch, error)
export type ErrorBoundaryProps<TShouldCatch extends ShouldCatch = true> = PropsWithChildren<{
/**
* an array of elements for the ErrorBoundary to check each render. If any of those elements change between renders, then the ErrorBoundary will reset the state which will re-render the children
*/
resetKeys?: unknown[]
/**
* when ErrorBoundary is reset by resetKeys or fallback's props.reset, onReset will be triggered
*/
onReset?: () => void
/**
* when ErrorBoundary catch error, onError will be triggered
*/
onError?: (error: InferError<TShouldCatch>, info: ErrorInfo) => void
/**
* when ErrorBoundary catch error, fallback will be render instead of children
*/
fallback: ReactNode | FunctionComponent<ErrorBoundaryFallbackProps<InferError<TShouldCatch>>>
/**
* determines whether the ErrorBoundary should catch errors based on conditions
* @default true
*/
shouldCatch?: TShouldCatch
}>
type ErrorBoundaryState =
| {
isError: true
error: Error
}
| {
isError: false
error: null
}
const initialErrorBoundaryState: ErrorBoundaryState = {
isError: false,
error: null,
}
// Although `componentDidCatch` and `getDerivedStateFromError` are typed to accept an `Error` object,
// they can also be invoked with non-error objects. This is why we need to convert them to Error instances.
// See: https://github.com/getsentry/sentry-javascript/issues/6167
const convertToError = (error: unknown): Error => {
return error instanceof Error ? error : new Error(String(error))
}
class BaseErrorBoundary<TShouldCatch extends ShouldCatch> extends Component<
ErrorBoundaryProps<TShouldCatch>,
ErrorBoundaryState
> {
// eslint-disable-next-line @typescript-eslint/no-explicit-any
static getDerivedStateFromError(error: any): ErrorBoundaryState {
return { isError: true, error: convertToError(error) }
}
state = initialErrorBoundaryState
componentDidUpdate(prevProps: ErrorBoundaryProps<TShouldCatch>, prevState: ErrorBoundaryState) {
const { isError } = this.state
const { resetKeys } = this.props
if (isError && prevState.isError && hasResetKeysChanged(prevProps.resetKeys, resetKeys)) {
this.reset()
}
}
// eslint-disable-next-line @typescript-eslint/no-explicit-any
componentDidCatch(error: any, info: ErrorInfo) {
this.props.onError?.(convertToError(error) as InferError<TShouldCatch>, info)
}
reset = () => {
this.props.onReset?.()
this.setState(initialErrorBoundaryState)
}
render() {
const { children, fallback, shouldCatch = true } = this.props
const { isError, error } = this.state
let childrenOrFallback = children
if (isError) {
if (error instanceof SuspensiveError) {
throw error
}
if (error instanceof ErrorInFallback) {
throw error.originalError
}
if (!shouldCatchError(shouldCatch, error)) {
throw error
}
if (typeof fallback === 'undefined') {
if (process.env.NODE_ENV === 'development') {
console.error('ErrorBoundary of @suspensive/react requires a defined fallback')
}
throw error
}
const Fallback = fallback
childrenOrFallback = (
<FallbackBoundary>
{typeof Fallback === 'function' ? <Fallback error={error} reset={this.reset} /> : Fallback}
</FallbackBoundary>
)
}
return (
<ErrorBoundaryContext.Provider value={{ ...this.state, reset: this.reset }}>
{childrenOrFallback}
</ErrorBoundaryContext.Provider>
)
}
}
class ErrorInFallback extends Error {
originalError: Error
constructor(originalError: Error) {
super()
this.originalError = originalError
}
}
class FallbackBoundary extends Component<{ children: ReactNode }> {
componentDidCatch(originalError: Error) {
throw originalError instanceof SuspensiveError ? originalError : new ErrorInFallback(originalError)
}
render() {
return this.props.children
}
}
/**
* This component provides a simple and reusable wrapper that you can use to wrap around your components. Any rendering errors in your components hierarchy can then be gracefully handled.
* @see {@link https://suspensive.org/docs/react/ErrorBoundary Suspensive Docs}
*/
export const ErrorBoundary = Object.assign(
forwardRef(function ErrorBoundary<TShouldCatch extends ShouldCatch>(
props: ErrorBoundaryProps<TShouldCatch>,
ref: ForwardedRef<ErrorBoundaryHandle>
) {
const { fallback, children, onError, onReset, resetKeys, shouldCatch } = props
const group = useContext(ErrorBoundaryGroupContext) ?? { resetKey: 0 }
const baseErrorBoundaryRef = useRef<BaseErrorBoundary<TShouldCatch>>(null)
useImperativeHandle(ref, () => ({
reset: () => baseErrorBoundaryRef.current?.reset(),
}))
return (
<BaseErrorBoundary<TShouldCatch>
shouldCatch={shouldCatch}
fallback={fallback}
onError={onError}
onReset={onReset}
resetKeys={[group.resetKey, ...(resetKeys || [])]}
ref={baseErrorBoundaryRef}
>
{children}
</BaseErrorBoundary>
)
}) as {
<TShouldCatch extends ShouldCatch>(
props: ErrorBoundaryProps<TShouldCatch> & React.RefAttributes<ErrorBoundaryHandle>
): ReturnType<ForwardRefExoticComponent<ErrorBoundaryProps<TShouldCatch>>>
},
{
displayName: 'ErrorBoundary',
with: <
TProps extends ComponentProps<ComponentType> = Record<string, never>,
TShouldCatch extends ShouldCatch = ShouldCatch,
>(
errorBoundaryProps: PropsWithoutChildren<ErrorBoundaryProps<TShouldCatch>> = { fallback: undefined },
Component: ComponentType<TProps>
) =>
Object.assign(
(props: TProps) => (
<ErrorBoundary<TShouldCatch> {...errorBoundaryProps}>
<Component {...props} />
</ErrorBoundary>
),
{ displayName: `ErrorBoundary.with(${Component.displayName || Component.name || 'Component'})` }
),
Consumer: ({ children }: { children: (errorBoundary: ReturnType<typeof useErrorBoundary>) => ReactNode }) => (
<>{children(useErrorBoundary())}</>
),
}
)
const ErrorBoundaryContext = Object.assign(createContext<(ErrorBoundaryHandle & ErrorBoundaryState) | null>(null), {
displayName: 'ErrorBoundaryContext',
})
/**
* This hook provides a simple and reusable wrapper that you can use to wrap around your components. Any rendering errors in your components hierarchy can then be gracefully handled.
* @see {@link https://suspensive.org/docs/react/ErrorBoundary#useerrorboundary Suspensive Docs}
*/
// eslint-disable-next-line @typescript-eslint/no-unnecessary-type-parameters
export const useErrorBoundary = <TError extends Error = Error>() => {
const [state, setState] = useState<ErrorBoundaryState>({
isError: false,
error: null,
})
if (state.isError) {
throw state.error
}
const errorBoundary = useContext(ErrorBoundaryContext)
SuspensiveError.assert(
errorBoundary != null && !errorBoundary.isError,
Message_useErrorBoundary_this_hook_should_be_called_in_ErrorBoundary_props_children
)
return useMemo(
() => ({
setError: (error: TError) => setState({ isError: true, error }),
}),
[]
)
}
/**
* This hook allows you to access the reset method and error objects without prop drilling.
* @see {@link https://suspensive.org/docs/react/ErrorBoundary#useerrorboundaryfallbackprops Suspensive Docs}
*/
export const useErrorBoundaryFallbackProps = <TError extends Error = Error>(): ErrorBoundaryFallbackProps<TError> => {
const errorBoundary = useContext(ErrorBoundaryContext)
SuspensiveError.assert(
errorBoundary != null && errorBoundary.isError,
Message_useErrorBoundaryFallbackProps_this_hook_should_be_called_in_ErrorBoundary_props_fallback
)
return useMemo(
() => ({
error: errorBoundary.error as TError,
reset: errorBoundary.reset,
}),
[errorBoundary.error, errorBoundary.reset]
)
}