Skip to content

Commit d358bbb

Browse files
authored
feat: hook up sentry boundary (#6)
BREAKING CHANGE: `reporter` prop is no longer accepted on `ErrorBoundaryProvider` BREAKING CHANGE: `reportErrors` prop has been removed from `ErrorBoundaryProps` BREAKING CHANGE: `withErrorBoundary` HOC does not forward boundary ref
1 parent 677a6f8 commit d358bbb

14 files changed

+141
-343
lines changed

README.md

Lines changed: 2 additions & 19 deletions
Original file line numberDiff line numberDiff line change
@@ -12,7 +12,7 @@ The React error boundary tailored to Stoplight needs, inspired by [react-error-b
1212
### Features
1313

1414
- all the great features provided by [react-error-boundary](https://github.com/bvaughn/react-error-boundary),
15-
- built-in error reporting,
15+
- built-in error reporting powered by Sentry,
1616
- supports recovering,
1717
- fallback component can try to recover error boundary.
1818

@@ -27,24 +27,7 @@ yarn add @stoplight/react-error-boundary
2727

2828
### Usage
2929

30-
Before you start, you need to place the `ErrorBoundaryProvider` component, preferably at the root component
31-
32-
```tsx
33-
import { ErrorBoundaryProvider } from '@stoplight/react-error-boundary';
34-
35-
// a standard instance of Stoplight reporter
36-
const reporter: IReporter = {
37-
reportError() {},
38-
};
39-
40-
const App = () => {
41-
<ErrorBoundaryProvider reporter={reporter}>
42-
<Content />
43-
</ErrorBoundaryProvider>
44-
};
45-
```
46-
47-
then, you can either make use of:
30+
You can either make use of:
4831

4932
- withErrorBoundary HOC
5033

package.json

Lines changed: 1 addition & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -41,7 +41,7 @@
4141
"react-dom": ">=16.8"
4242
},
4343
"dependencies": {
44-
"@stoplight/types": "^11.9.0"
44+
"@sentry/react": "^6.13.2"
4545
},
4646
"devDependencies": {
4747
"@stoplight/reporter": "^1.7.6",

src/ErrorBoundary.tsx

Lines changed: 37 additions & 77 deletions
Original file line numberDiff line numberDiff line change
@@ -1,89 +1,49 @@
1+
import { ErrorBoundary as SentryErrorBoundary } from '@sentry/react';
2+
import { FallbackRender } from '@sentry/react/dist/errorboundary';
13
import * as React from 'react';
24
import { ErrorBoundaryContext } from './ErrorBoundaryProvider';
35
import { FallbackComponent } from './FallbackComponent';
4-
import { ErrorBoundaryProps, ErrorBoundaryState } from './types';
6+
import { ErrorBoundaryProps, FallbackProps } from './types';
57

6-
export class ErrorBoundary<P extends object = {}> extends React.PureComponent<
7-
P & ErrorBoundaryProps<P>,
8-
ErrorBoundaryState
9-
> {
10-
public static contextType = ErrorBoundaryContext;
11-
public context!: React.ContextType<typeof ErrorBoundaryContext>;
8+
const wrapFallback = (Component: React.ElementType<FallbackProps>): FallbackRender => {
9+
return props => (
10+
<Component error={props.error} componentStack={props.componentStack} tryRecovering={props.resetError} />
11+
);
12+
};
1213

13-
public state = {
14-
error: null,
15-
componentStack: null,
16-
};
14+
type GenericProps = Record<string, unknown>;
1715

18-
public componentDidUpdate(prevProps: Readonly<P & ErrorBoundaryProps<P>>) {
19-
if (
20-
this.state.error !== null &&
21-
this.props.recoverableProps !== void 0 &&
22-
Array.isArray(this.props.recoverableProps)
23-
) {
24-
for (const recoverableProp of this.props.recoverableProps) {
25-
if (prevProps[recoverableProp] !== this.props[recoverableProp]) {
26-
this.setError(null);
27-
break;
28-
}
29-
}
30-
}
31-
}
32-
33-
public componentDidCatch(error: Error, errorInfo: React.ErrorInfo) {
34-
this.setError(error, errorInfo.componentStack);
35-
this.handleError(error, errorInfo);
36-
}
37-
38-
protected handleError(error: Error, errorInfo: React.ErrorInfo | null) {
39-
if (this.props.reportErrors !== false) {
40-
try {
41-
if (errorInfo !== null) {
42-
this.context.reporter.error(error.message, { errorInfo });
43-
} else {
44-
this.context.reporter.error(error);
45-
}
46-
} catch (ex) {
47-
console.error('Error could not be reported', ex);
48-
}
49-
}
50-
51-
if (this.props.onError !== void 0) {
52-
try {
53-
this.props.onError(error, errorInfo && errorInfo.componentStack);
54-
} catch {
55-
// happens
56-
}
57-
}
58-
}
16+
function usePrev(value: GenericProps) {
17+
const ref = React.useRef<GenericProps>();
18+
React.useEffect(() => {
19+
ref.current = value;
20+
}, [value]);
5921

60-
public throwError = (error: Error) => {
61-
this.setError(error);
62-
this.handleError(error, null);
63-
};
22+
return ref.current;
23+
}
6424

65-
protected setError(error: Error | null, componentStack: string | null = null) {
66-
this.setState({ error, componentStack });
67-
}
25+
export const ErrorBoundary: React.FC<ErrorBoundaryProps<GenericProps> & GenericProps> = props => {
26+
const context = React.useContext(ErrorBoundaryContext);
27+
const fallback = props.FallbackComponent || context.FallbackComponent || FallbackComponent;
28+
const ActualFallback = React.useMemo<FallbackRender>(() => wrapFallback(fallback), [fallback]);
6829

69-
protected recover = () => {
70-
if (this.state.error !== null) {
71-
this.setError(null);
72-
} else if (process.env.NODE_ENV !== 'production') {
73-
console.warn('Component has not crashed. Recovering is a no-op in such case');
74-
}
75-
};
30+
const boundaryRef = React.useRef<SentryErrorBoundary | null>(null);
31+
const prevProps = usePrev(props);
7632

77-
public render() {
78-
const {
79-
props: { FallbackComponent: Fallback = this.context.FallbackComponent || FallbackComponent, children },
80-
state: { error, componentStack },
81-
} = this;
33+
React.useEffect(() => {
34+
const boundary = boundaryRef.current;
35+
if (!boundary || !prevProps || !boundary.state.error || !props.recoverableProps) return;
8236

83-
if (error !== null) {
84-
return <Fallback error={error} componentStack={componentStack} tryRecovering={this.recover} />;
37+
for (const recoverableProp of props.recoverableProps) {
38+
if (prevProps[recoverableProp] !== props[recoverableProp]) {
39+
boundary.resetErrorBoundary();
40+
}
8541
}
86-
87-
return children;
88-
}
89-
}
42+
}, [props]);
43+
44+
return (
45+
<SentryErrorBoundary ref={boundaryRef} showDialog={false} fallback={ActualFallback} onError={props.onError}>
46+
{props.children}
47+
</SentryErrorBoundary>
48+
);
49+
};

src/ErrorBoundaryProvider.tsx

Lines changed: 0 additions & 3 deletions
Original file line numberDiff line numberDiff line change
@@ -1,15 +1,12 @@
1-
import { ICoreReportingAPI } from '@stoplight/reporter';
21
import * as React from 'react';
32
import { FallbackComponent } from './FallbackComponent';
43
import { FallbackProps } from './types';
54

65
export type ErrorBoundaryContext = {
7-
reporter: ICoreReportingAPI;
86
FallbackComponent?: React.ElementType<FallbackProps>;
97
};
108

119
export const ErrorBoundaryContext = React.createContext<ErrorBoundaryContext>({
12-
reporter: console,
1310
FallbackComponent,
1411
});
1512

src/__tests__/ErrorBoundary.test.tsx

Lines changed: 27 additions & 80 deletions
Original file line numberDiff line numberDiff line change
@@ -1,5 +1,5 @@
11
/* tslint:disable:jsx-wrap-multiline */
2-
import { ICoreReportingAPI } from '@stoplight/reporter';
2+
import * as Sentry from '@sentry/react';
33
import { mount } from 'enzyme';
44
import * as React from 'react';
55

@@ -20,23 +20,12 @@ describe('ErrorBoundary component', () => {
2020
return <span>{String(value)}</span>;
2121
};
2222

23-
let reporter: ICoreReportingAPI;
24-
25-
beforeEach(() => {
26-
reporter = {
27-
...console,
28-
error: jest.fn(),
29-
};
30-
});
31-
3223
describe('when exception is not thrown', () => {
3324
it('renders children', () => {
3425
const wrapper = mount(
35-
<ErrorBoundaryProvider reporter={reporter}>
36-
<ErrorBoundary>
37-
<TestComponent value="test" />
38-
</ErrorBoundary>
39-
</ErrorBoundaryProvider>,
26+
<ErrorBoundary>
27+
<TestComponent value="test" />
28+
</ErrorBoundary>,
4029
);
4130

4231
expect(wrapper.find(TestComponent)).toHaveHTML('<span>test</span>');
@@ -48,70 +37,32 @@ describe('ErrorBoundary component', () => {
4837
describe('when exception is thrown', () => {
4938
it('renders fallback component and passes error-related props', () => {
5039
const wrapper = mount(
51-
<ErrorBoundaryProvider reporter={reporter}>
52-
<ErrorBoundary>
53-
<TestComponent value={0} />
54-
</ErrorBoundary>
55-
</ErrorBoundaryProvider>,
40+
<ErrorBoundary>
41+
<TestComponent value={0} />
42+
</ErrorBoundary>,
5643
);
5744

5845
expect(wrapper.find(FallbackComponent)).toExist();
5946
expect(wrapper.find(FallbackComponent)).toHaveProp({
6047
error: ex,
6148
componentStack: expect.stringContaining('in TestComponent'),
62-
tryRecovering: (wrapper.find(ErrorBoundary).instance() as any).recover,
49+
tryRecovering: (wrapper.find(Sentry.ErrorBoundary).instance() as any).resetErrorBoundary,
6350
});
6451

6552
wrapper.unmount();
6653
});
6754

68-
describe('error reporting', () => {
69-
it('calls onError prop', () => {
70-
const onError = jest.fn();
71-
const wrapper = mount(
72-
<ErrorBoundaryProvider reporter={reporter}>
73-
<ErrorBoundary onError={onError}>
74-
<TestComponent value={0} />
75-
</ErrorBoundary>
76-
</ErrorBoundaryProvider>,
77-
);
78-
79-
expect(onError).toBeCalledWith(ex, expect.stringContaining('in TestComponent'));
80-
81-
wrapper.unmount();
82-
});
83-
84-
it('reports errors by default', () => {
85-
const wrapper = mount(
86-
<ErrorBoundaryProvider reporter={reporter}>
87-
<ErrorBoundary>
88-
<TestComponent value={0} />
89-
</ErrorBoundary>
90-
</ErrorBoundaryProvider>,
91-
);
92-
93-
expect(reporter.error).toBeCalledWith(ex.message, {
94-
errorInfo: {
95-
componentStack: expect.any(String),
96-
},
97-
});
98-
99-
wrapper.unmount();
100-
});
101-
102-
it('does reports error if reporting is disabled', () => {
103-
const wrapper = mount(
104-
<ErrorBoundaryProvider reporter={reporter}>
105-
<ErrorBoundary reportErrors={false}>
106-
<TestComponent value={0} />
107-
</ErrorBoundary>
108-
</ErrorBoundaryProvider>,
109-
);
55+
it('calls onError prop', () => {
56+
const onError = jest.fn();
57+
const wrapper = mount(
58+
<ErrorBoundary onError={onError}>
59+
<TestComponent value={0} />
60+
</ErrorBoundary>,
61+
);
11062

111-
expect(reporter.error).not.toBeCalled();
63+
expect(onError).toBeCalledWith(ex, expect.stringContaining('in TestComponent'), expect.any(String));
11264

113-
wrapper.unmount();
114-
});
65+
wrapper.unmount();
11566
});
11667

11768
describe('and a custom fallback component is provided', () => {
@@ -120,19 +71,17 @@ describe('ErrorBoundary component', () => {
12071
const CustomFallbackComponent = () => <div>foo</div>;
12172

12273
const wrapper = mount(
123-
<ErrorBoundaryProvider reporter={reporter}>
124-
<ErrorBoundary FallbackComponent={CustomFallbackComponent}>
125-
<TestComponent value={0} />
126-
</ErrorBoundary>
127-
</ErrorBoundaryProvider>,
74+
<ErrorBoundary FallbackComponent={CustomFallbackComponent}>
75+
<TestComponent value={0} />
76+
</ErrorBoundary>,
12877
);
12978

13079
expect(wrapper.find(FallbackComponent)).not.toExist(); // makes sure we don't render the original one
13180
expect(wrapper.find(CustomFallbackComponent)).toExist();
13281
expect(wrapper.find(CustomFallbackComponent)).toHaveProp({
13382
error: ex,
13483
componentStack: expect.stringContaining('in TestComponent'),
135-
tryRecovering: (wrapper.find(ErrorBoundary).instance() as any).recover,
84+
tryRecovering: (wrapper.find(Sentry.ErrorBoundary).instance() as any).resetErrorBoundary,
13685
});
13786

13887
wrapper.unmount();
@@ -144,7 +93,7 @@ describe('ErrorBoundary component', () => {
14493
const CustomFallbackComponent = () => <div>foo</div>;
14594

14695
const wrapper = mount(
147-
<ErrorBoundaryProvider reporter={reporter} FallbackComponent={CustomFallbackComponent}>
96+
<ErrorBoundaryProvider FallbackComponent={CustomFallbackComponent}>
14897
>
14998
<ErrorBoundary>
15099
<TestComponent value={0} />
@@ -157,7 +106,7 @@ describe('ErrorBoundary component', () => {
157106
expect(wrapper.find(CustomFallbackComponent)).toHaveProp({
158107
error: ex,
159108
componentStack: expect.stringContaining('in TestComponent'),
160-
tryRecovering: (wrapper.find(ErrorBoundary).instance() as any).recover,
109+
tryRecovering: (wrapper.find(Sentry.ErrorBoundary).instance() as any).resetErrorBoundary,
161110
});
162111

163112
wrapper.unmount();
@@ -170,7 +119,7 @@ describe('ErrorBoundary component', () => {
170119
const CustomFallbackPropsComponent = () => <div>level!</div>;
171120

172121
const wrapper = mount(
173-
<ErrorBoundaryProvider reporter={reporter} FallbackComponent={CustomFallbackContextComponent}>
122+
<ErrorBoundaryProvider FallbackComponent={CustomFallbackContextComponent}>
174123
<ErrorBoundary FallbackComponent={CustomFallbackPropsComponent}>
175124
<TestComponent value={0} />
176125
</ErrorBoundary>
@@ -190,11 +139,9 @@ describe('ErrorBoundary component', () => {
190139
const getValue = jest.fn().mockReturnValue(0);
191140

192141
const wrapper = mount(
193-
<ErrorBoundaryProvider reporter={reporter}>
194-
<ErrorBoundary FallbackComponent={CustomFallbackComponent}>
195-
<TestComponent getValue={getValue} />
196-
</ErrorBoundary>
197-
</ErrorBoundaryProvider>,
142+
<ErrorBoundary FallbackComponent={CustomFallbackComponent}>
143+
<TestComponent getValue={getValue} />
144+
</ErrorBoundary>,
198145
);
199146

200147
expect(wrapper.find(CustomFallbackComponent)).toExist();

0 commit comments

Comments
 (0)