Skip to content

Commit 463e8da

Browse files
authored
feat(react)!: Update ErrorBoundary componentStack type (#14742)
The principle thing that drove this change was this todo: https://github.com/getsentry/sentry-javascript/blob/5b773779693cb52c9173c67c42cf2a9e48e927cb/packages/react/src/errorboundary.tsx#L101-L102 Specifically we wanted to remove `null` as a valid state from `componentStack`, making sure that all exposed public API see it as `string | undefined`. React always returns a `string` `componentStack` from the error boundary, so this matches our typings more closely to react. By making this change, we can also clean up the `render` logic a little. Specifically we can check for `state.componentStack === null` to determine if a fallback is rendered, and then I went ahead and removed some unnecessary nesting.
1 parent 64e9fb6 commit 463e8da

File tree

3 files changed

+65
-40
lines changed

3 files changed

+65
-40
lines changed

CHANGELOG.md

+1-1
Original file line numberDiff line numberDiff line change
@@ -10,7 +10,7 @@
1010

1111
- "You miss 100 percent of the chances you don't take. — Wayne Gretzky" — Michael Scott
1212

13-
Work in this release was contributed by @nwalters512, @aloisklink, @arturovt, @benjick, @maximepvrt, and @mstrokin. Thank you for your contributions!
13+
Work in this release was contributed by @nwalters512, @aloisklink, @arturovt, @benjick, @maximepvrt, @mstrokin, and @kunal-511. Thank you for your contributions!
1414

1515
- **feat(solidstart)!: Default to `--import` setup and add `autoInjectServerSentry` ([#14862](https://github.com/getsentry/sentry-javascript/pull/14862))**
1616

docs/migration/v8-to-v9.md

+6
Original file line numberDiff line numberDiff line change
@@ -126,6 +126,12 @@ Older Typescript versions _may_ still work, but we will not test them anymore an
126126

127127
To customize which files are deleted after upload, define the `filesToDeleteAfterUpload` array with globs.
128128

129+
### `@sentry/react`
130+
131+
The `componentStack` field in the `ErrorBoundary` component is now typed as `string` instead of `string | null | undefined` for the `onError` and `onReset` lifecycle methods. This more closely matches the actual behavior of React, which always returns a `string` whenever a component stack is available.
132+
133+
In the `onUnmount` lifecycle method, the `componentStack` field is now typed as `string | null`. The `componentStack` is `null` when no error has been thrown at time of unmount.
134+
129135
### Uncategorized (TODO)
130136

131137
TODO

packages/react/src/errorboundary.tsx

+58-39
Original file line numberDiff line numberDiff line change
@@ -17,6 +17,11 @@ export type FallbackRender = (errorData: {
1717
resetError(): void;
1818
}) => React.ReactElement;
1919

20+
type OnUnmountType = {
21+
(error: null, componentStack: null, eventId: null): void;
22+
(error: unknown, componentStack: string, eventId: string): void;
23+
};
24+
2025
export type ErrorBoundaryProps = {
2126
children?: React.ReactNode | (() => React.ReactNode);
2227
/** If a Sentry report dialog should be rendered on error */
@@ -42,15 +47,23 @@ export type ErrorBoundaryProps = {
4247
*/
4348
handled?: boolean | undefined;
4449
/** Called when the error boundary encounters an error */
45-
onError?: ((error: unknown, componentStack: string | undefined, eventId: string) => void) | undefined;
50+
onError?: ((error: unknown, componentStack: string, eventId: string) => void) | undefined;
4651
/** Called on componentDidMount() */
4752
onMount?: (() => void) | undefined;
48-
/** Called if resetError() is called from the fallback render props function */
49-
onReset?: ((error: unknown, componentStack: string | null | undefined, eventId: string | null) => void) | undefined;
50-
/** Called on componentWillUnmount() */
51-
onUnmount?: ((error: unknown, componentStack: string | null | undefined, eventId: string | null) => void) | undefined;
53+
/**
54+
* Called when the error boundary resets due to a reset call from the
55+
* fallback render props function.
56+
*/
57+
onReset?: ((error: unknown, componentStack: string, eventId: string) => void) | undefined;
58+
/**
59+
* Called on componentWillUnmount() with the error, componentStack, and eventId.
60+
*
61+
* If the error boundary never encountered an error, the error
62+
* componentStack, and eventId will be null.
63+
*/
64+
onUnmount?: OnUnmountType | undefined;
5265
/** Called before the error is captured by Sentry, allows for you to add tags or context using the scope */
53-
beforeCapture?: ((scope: Scope, error: unknown, componentStack: string | undefined) => void) | undefined;
66+
beforeCapture?: ((scope: Scope, error: unknown, componentStack: string) => void) | undefined;
5467
};
5568

5669
type ErrorBoundaryState =
@@ -65,7 +78,7 @@ type ErrorBoundaryState =
6578
eventId: string;
6679
};
6780

68-
const INITIAL_STATE = {
81+
const INITIAL_STATE: ErrorBoundaryState = {
6982
componentStack: null,
7083
error: null,
7184
eventId: null,
@@ -104,20 +117,17 @@ class ErrorBoundary extends React.Component<ErrorBoundaryProps, ErrorBoundarySta
104117

105118
public componentDidCatch(error: unknown, errorInfo: React.ErrorInfo): void {
106119
const { componentStack } = errorInfo;
107-
// TODO(v9): Remove this check and type `componentStack` to be React.ErrorInfo['componentStack'].
108-
const passedInComponentStack: string | undefined = componentStack == null ? undefined : componentStack;
109-
110120
const { beforeCapture, onError, showDialog, dialogOptions } = this.props;
111121
withScope(scope => {
112122
if (beforeCapture) {
113-
beforeCapture(scope, error, passedInComponentStack);
123+
beforeCapture(scope, error, componentStack);
114124
}
115125

116126
const handled = this.props.handled != null ? this.props.handled : !!this.props.fallback;
117127
const eventId = captureReactException(error, errorInfo, { mechanism: { handled } });
118128

119129
if (onError) {
120-
onError(error, passedInComponentStack, eventId);
130+
onError(error, componentStack, eventId);
121131
}
122132
if (showDialog) {
123133
this._lastEventId = eventId;
@@ -143,7 +153,15 @@ class ErrorBoundary extends React.Component<ErrorBoundaryProps, ErrorBoundarySta
143153
const { error, componentStack, eventId } = this.state;
144154
const { onUnmount } = this.props;
145155
if (onUnmount) {
146-
onUnmount(error, componentStack, eventId);
156+
if (this.state === INITIAL_STATE) {
157+
// If the error boundary never encountered an error, call onUnmount with null values
158+
onUnmount(null, null, null);
159+
} else {
160+
// `componentStack` and `eventId` are guaranteed to be non-null here because `onUnmount` is only called
161+
// when the error boundary has already encountered an error.
162+
// eslint-disable-next-line @typescript-eslint/no-non-null-assertion
163+
onUnmount(error, componentStack!, eventId!);
164+
}
147165
}
148166

149167
if (this._cleanupHook) {
@@ -156,7 +174,10 @@ class ErrorBoundary extends React.Component<ErrorBoundaryProps, ErrorBoundarySta
156174
const { onReset } = this.props;
157175
const { error, componentStack, eventId } = this.state;
158176
if (onReset) {
159-
onReset(error, componentStack, eventId);
177+
// `componentStack` and `eventId` are guaranteed to be non-null here because `onReset` is only called
178+
// when the error boundary has already encountered an error.
179+
// eslint-disable-next-line @typescript-eslint/no-non-null-assertion
180+
onReset(error, componentStack!, eventId!);
160181
}
161182
this.setState(INITIAL_STATE);
162183
}
@@ -165,35 +186,33 @@ class ErrorBoundary extends React.Component<ErrorBoundaryProps, ErrorBoundarySta
165186
const { fallback, children } = this.props;
166187
const state = this.state;
167188

168-
if (state.error) {
169-
let element: React.ReactElement | undefined = undefined;
170-
if (typeof fallback === 'function') {
171-
element = React.createElement(fallback, {
172-
error: state.error,
173-
componentStack: state.componentStack as string,
174-
resetError: this.resetErrorBoundary.bind(this),
175-
eventId: state.eventId as string,
176-
});
177-
} else {
178-
element = fallback;
179-
}
180-
181-
if (React.isValidElement(element)) {
182-
return element;
183-
}
184-
185-
if (fallback) {
186-
DEBUG_BUILD && logger.warn('fallback did not produce a valid ReactElement');
187-
}
189+
// `componentStack` is only null in the initial state, when no error has been captured.
190+
// If an error has been captured, `componentStack` will be a string.
191+
// We cannot check `state.error` because null can be thrown as an error.
192+
if (state.componentStack === null) {
193+
return typeof children === 'function' ? children() : children;
194+
}
188195

189-
// Fail gracefully if no fallback provided or is not valid
190-
return null;
196+
const element =
197+
typeof fallback === 'function'
198+
? React.createElement(fallback, {
199+
error: state.error,
200+
componentStack: state.componentStack,
201+
resetError: () => this.resetErrorBoundary(),
202+
eventId: state.eventId,
203+
})
204+
: fallback;
205+
206+
if (React.isValidElement(element)) {
207+
return element;
191208
}
192209

193-
if (typeof children === 'function') {
194-
return (children as () => React.ReactNode)();
210+
if (fallback) {
211+
DEBUG_BUILD && logger.warn('fallback did not produce a valid ReactElement');
195212
}
196-
return children;
213+
214+
// Fail gracefully if no fallback provided or is not valid
215+
return null;
197216
}
198217
}
199218

0 commit comments

Comments
 (0)