Skip to content

Commit 47b654c

Browse files
authored
feat(react): Add Error Boundary component (getsentry#2647)
* feat(react): Add Error Boundary component * test(react): Use @testing-library/react for tests * ref(react): Change how name is calculated for Profiler
1 parent 7a40f36 commit 47b654c

11 files changed

+535
-37
lines changed

CHANGELOG.md

+1-1
Original file line numberDiff line numberDiff line change
@@ -4,7 +4,7 @@
44

55
- "You miss 100 percent of the chances you don't take. — Wayne Gretzky" — Michael Scott
66
- [react] feat: Add @sentry/react package (#2631)
7-
7+
- [react] feat: Add Error Boundary component (#2647)
88

99
## 5.17.0
1010

packages/react/README.md

+2
Original file line numberDiff line numberDiff line change
@@ -7,6 +7,8 @@
77

88
# Official Sentry SDK for ReactJS
99

10+
Note this library is in active development and not ready for production usage.
11+
1012
## Links
1113

1214
- [Official SDK Docs](https://docs.sentry.io/quickstart/)

packages/react/package.json

+1-2
Original file line numberDiff line numberDiff line change
@@ -27,16 +27,15 @@
2727
"react-dom": "^16.0.0"
2828
},
2929
"devDependencies": {
30+
"@testing-library/react": "^10.0.6",
3031
"@types/hoist-non-react-statics": "^3.3.1",
3132
"@types/react": "^16.9.35",
32-
"@types/react-test-renderer": "^16.9.2",
3333
"jest": "^24.7.1",
3434
"npm-run-all": "^4.1.2",
3535
"prettier": "^1.17.0",
3636
"prettier-check": "^2.0.0",
3737
"react": "^16.0.0",
3838
"react-dom": "^16.0.0",
39-
"react-test-renderer": "^16.13.1",
4039
"rimraf": "^2.6.3",
4140
"tslint": "^5.16.0",
4241
"tslint-react": "^5.0.0",

packages/react/src/errorboundary.tsx

+115
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,115 @@
1+
import * as Sentry from '@sentry/browser';
2+
import * as hoistNonReactStatic from 'hoist-non-react-statics';
3+
import * as React from 'react';
4+
5+
export const UNKNOWN_COMPONENT = 'unknown';
6+
7+
export type FallbackRender = (fallback: {
8+
error: Error | null;
9+
componentStack: string | null;
10+
resetError(): void;
11+
}) => React.ReactNode;
12+
13+
export type ErrorBoundaryProps = {
14+
showDialog?: boolean;
15+
dialogOptions?: Sentry.ReportDialogOptions;
16+
// tslint:disable-next-line: no-null-undefined-union
17+
fallback?: React.ReactNode | FallbackRender;
18+
onError?(error: Error, componentStack: string): void;
19+
onMount?(): void;
20+
onReset?(error: Error | null, componentStack: string | null): void;
21+
onUnmount?(error: Error | null, componentStack: string | null): void;
22+
};
23+
24+
type ErrorBoundaryState = {
25+
componentStack: string | null;
26+
error: Error | null;
27+
};
28+
29+
const INITIAL_STATE = {
30+
componentStack: null,
31+
error: null,
32+
};
33+
34+
class ErrorBoundary extends React.Component<ErrorBoundaryProps, ErrorBoundaryState> {
35+
public state: ErrorBoundaryState = INITIAL_STATE;
36+
37+
public componentDidCatch(error: Error, { componentStack }: React.ErrorInfo): void {
38+
Sentry.captureException(error, { contexts: { react: { componentStack } } });
39+
const { onError, showDialog, dialogOptions } = this.props;
40+
if (onError) {
41+
onError(error, componentStack);
42+
}
43+
if (showDialog) {
44+
Sentry.showReportDialog(dialogOptions);
45+
}
46+
47+
// componentDidCatch is used over getDerivedStateFromError
48+
// so that componentStack is accessible through state.
49+
this.setState({ error, componentStack });
50+
}
51+
52+
public componentDidMount(): void {
53+
const { onMount } = this.props;
54+
if (onMount) {
55+
onMount();
56+
}
57+
}
58+
59+
public componentWillUnmount(): void {
60+
const { error, componentStack } = this.state;
61+
const { onUnmount } = this.props;
62+
if (onUnmount) {
63+
onUnmount(error, componentStack);
64+
}
65+
}
66+
67+
public resetErrorBoundary = () => {
68+
const { onReset } = this.props;
69+
if (onReset) {
70+
onReset(this.state.error, this.state.componentStack);
71+
}
72+
this.setState(INITIAL_STATE);
73+
};
74+
75+
public render(): React.ReactNode {
76+
const { fallback } = this.props;
77+
const { error, componentStack } = this.state;
78+
79+
if (error) {
80+
if (React.isValidElement(fallback)) {
81+
return fallback;
82+
}
83+
if (typeof fallback === 'function') {
84+
return fallback({ error, componentStack, resetError: this.resetErrorBoundary }) as FallbackRender;
85+
}
86+
87+
// Fail gracefully if no fallback provided
88+
return null;
89+
}
90+
91+
return this.props.children;
92+
}
93+
}
94+
95+
function withErrorBoundary<P extends object>(
96+
WrappedComponent: React.ComponentType<P>,
97+
errorBoundaryOptions: ErrorBoundaryProps,
98+
): React.FC<P> {
99+
const componentDisplayName = WrappedComponent.displayName || WrappedComponent.name || UNKNOWN_COMPONENT;
100+
101+
const Wrapped: React.FC<P> = (props: P) => (
102+
<ErrorBoundary {...errorBoundaryOptions}>
103+
<WrappedComponent {...props} />
104+
</ErrorBoundary>
105+
);
106+
107+
Wrapped.displayName = `errorBoundary(${componentDisplayName})`;
108+
109+
// Copy over static methods from Wrapped component to Profiler HOC
110+
// See: https://reactjs.org/docs/higher-order-components.html#static-methods-must-be-copied-over
111+
hoistNonReactStatic(Wrapped, WrappedComponent);
112+
return Wrapped;
113+
}
114+
115+
export { ErrorBoundary, withErrorBoundary };

packages/react/src/index.ts

+1
Original file line numberDiff line numberDiff line change
@@ -1,3 +1,4 @@
11
export * from '@sentry/browser';
22

33
export { Profiler, withProfiler } from './profiler';
4+
export { ErrorBoundary, withErrorBoundary } from './errorboundary';

packages/react/src/profiler.tsx

+9-14
Original file line numberDiff line numberDiff line change
@@ -39,38 +39,33 @@ function afterNextFrame(callback: Function): void {
3939
timeout = window.setTimeout(done, 100);
4040
}
4141

42-
const getInitActivity = (componentDisplayName: string): number | null => {
42+
const getInitActivity = (name: string): number | null => {
4343
const tracingIntegration = getCurrentHub().getIntegration(TRACING_GETTER);
4444

4545
if (tracingIntegration !== null) {
4646
// tslint:disable-next-line:no-unsafe-any
47-
const activity = (tracingIntegration as any).constructor.pushActivity(componentDisplayName, {
48-
description: `<${componentDisplayName}>`,
47+
return (tracingIntegration as any).constructor.pushActivity(name, {
48+
description: `<${name}>`,
4949
op: 'react',
5050
});
51-
52-
// tslint:disable-next-line: no-unsafe-any
53-
return activity;
5451
}
5552

5653
logger.warn(
57-
`Unable to profile component ${componentDisplayName} due to invalid Tracing Integration. Please make sure to setup the Tracing integration.`,
54+
`Unable to profile component ${name} due to invalid Tracing Integration. Please make sure to setup the Tracing integration.`,
5855
);
5956
return null;
6057
};
6158

62-
interface ProfilerProps {
63-
componentDisplayName?: string;
64-
}
59+
export type ProfilerProps = {
60+
name: string;
61+
};
6562

6663
class Profiler extends React.Component<ProfilerProps> {
6764
public activity: number | null;
6865
public constructor(props: ProfilerProps) {
6966
super(props);
7067

71-
const { componentDisplayName = UNKNOWN_COMPONENT } = this.props;
72-
73-
this.activity = getInitActivity(componentDisplayName);
68+
this.activity = getInitActivity(this.props.name);
7469
}
7570

7671
public componentDidMount(): void {
@@ -103,7 +98,7 @@ function withProfiler<P extends object>(WrappedComponent: React.ComponentType<P>
10398
const componentDisplayName = WrappedComponent.displayName || WrappedComponent.name || UNKNOWN_COMPONENT;
10499

105100
const Wrapped: React.FC<P> = (props: P) => (
106-
<Profiler componentDisplayName={componentDisplayName}>
101+
<Profiler name={componentDisplayName}>
107102
<WrappedComponent {...props} />
108103
</Profiler>
109104
);

0 commit comments

Comments
 (0)