Skip to content

Commit 7741886

Browse files
committed
feat(react): Add Sentry.captureReactException
1 parent abe31ba commit 7741886

File tree

6 files changed

+148
-70
lines changed

6 files changed

+148
-70
lines changed

packages/react/README.md

+52-4
Original file line numberDiff line numberDiff line change
@@ -20,7 +20,7 @@ To use this SDK, call `Sentry.init(options)` before you mount your React compone
2020

2121
```javascript
2222
import React from 'react';
23-
import ReactDOM from 'react-dom';
23+
import { createRoot } from 'react-dom/client';
2424
import * as Sentry from '@sentry/react';
2525

2626
Sentry.init({
@@ -30,12 +30,60 @@ Sentry.init({
3030

3131
// ...
3232

33-
ReactDOM.render(<App />, rootNode);
33+
const container = document.getElementById(“app”);
34+
const root = createRoot(container);
35+
root.render(<App />);
3436

35-
// Can also use with React Concurrent Mode
36-
// ReactDOM.createRoot(rootNode).render(<App />);
37+
// also works with hydrateRoot
38+
// const domNode = document.getElementById('root');
39+
// const root = hydrateRoot(domNode, reactNode);
40+
// root.render(<App />);
3741
```
3842

43+
### React 19
44+
45+
Starting with React 19, the `createRoot` and `hydrateRoot` methods expose error hooks that can be used to capture errors automatically. Use the `Sentry.captureReactException` method to capture errors in the error hooks you are interested in.
46+
47+
```js
48+
const container = document.getElementById(“app”);
49+
const root = createRoot(container, {
50+
// Callback called when an error is thrown and not caught by an Error Boundary.
51+
onUncaughtError: (error, errorInfo) => {
52+
Sentry.captureReactException(error, errorInfo);
53+
54+
console.error(
55+
'Uncaught error',
56+
error,
57+
errorInfo.componentStack
58+
);
59+
},
60+
// Callback called when React catches an error in an Error Boundary.
61+
onCaughtError: (error, errorInfo) => {
62+
Sentry.captureReactException(error, errorInfo);
63+
64+
console.error(
65+
'Caught error',
66+
error,
67+
errorInfo.componentStack
68+
);
69+
},
70+
// Callback called when React automatically recovers from errors.
71+
onRecoverableError: (error, errorInfo) => {
72+
Sentry.captureReactException(error, errorInfo);
73+
74+
console.error(
75+
'Recoverable error',
76+
error,
77+
error.cause,
78+
errorInfo.componentStack,
79+
);
80+
}
81+
});
82+
root.render(<App />);
83+
```
84+
85+
If you want more finely grained control over error handling, we recommend only adding the `onUncaughtError` and `onRecoverableError` hooks and using an `ErrorBoundary` component instead of the `onCaughtError` hook.
86+
3987
### ErrorBoundary
4088

4189
`@sentry/react` exports an ErrorBoundary component that will automatically send Javascript errors from inside a

packages/react/src/error.ts

+74
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,74 @@
1+
import { captureException } from '@sentry/browser';
2+
import type { EventHint } from '@sentry/types';
3+
import { isError } from '@sentry/utils';
4+
import { version } from 'react';
5+
import type { ErrorInfo } from 'react';
6+
7+
/**
8+
* See if React major version is 17+ by parsing version string.
9+
*/
10+
export function isAtLeastReact17(reactVersion: string): boolean {
11+
const reactMajor = reactVersion.match(/^([^.]+)/);
12+
return reactMajor !== null && parseInt(reactMajor[0]) >= 17;
13+
}
14+
15+
/**
16+
* Recurse through `error.cause` chain to set cause on an error.
17+
*/
18+
export function setCause(error: Error & { cause?: Error }, cause: Error): void {
19+
const seenErrors = new WeakMap<Error, boolean>();
20+
21+
function recurse(error: Error & { cause?: Error }, cause: Error): void {
22+
// If we've already seen the error, there is a recursive loop somewhere in the error's
23+
// cause chain. Let's just bail out then to prevent a stack overflow.
24+
if (seenErrors.has(error)) {
25+
return;
26+
}
27+
if (error.cause) {
28+
seenErrors.set(error, true);
29+
return recurse(error.cause, cause);
30+
}
31+
error.cause = cause;
32+
}
33+
34+
recurse(error, cause);
35+
}
36+
37+
/**
38+
* Captures an error that was thrown by a React ErrorBoundary or React root.
39+
*
40+
* @param error The error to capture.
41+
* @param errorInfo The errorInfo provided by React.
42+
* @param hint Optional additional data to attach to the Sentry event.
43+
* @returns the id of the captured Sentry event.
44+
*/
45+
export function captureReactException(
46+
// eslint-disable-next-line @typescript-eslint/no-explicit-any
47+
error: any,
48+
{ componentStack }: ErrorInfo,
49+
hint?: EventHint,
50+
): string {
51+
// If on React version >= 17, create stack trace from componentStack param and links
52+
// to to the original error using `error.cause` otherwise relies on error param for stacktrace.
53+
// Linking errors requires the `LinkedErrors` integration be enabled.
54+
// See: https://reactjs.org/blog/2020/08/10/react-v17-rc.html#native-component-stacks
55+
//
56+
// Although `componentDidCatch` is typed to accept an `Error` object, it can also be invoked
57+
// with non-error objects. This is why we need to check if the error is an error-like object.
58+
// See: https://github.com/getsentry/sentry-javascript/issues/6167
59+
if (isAtLeastReact17(version) && isError(error)) {
60+
const errorBoundaryError = new Error(error.message);
61+
errorBoundaryError.name = `React ErrorBoundary ${error.name}`;
62+
errorBoundaryError.stack = componentStack;
63+
64+
// Using the `LinkedErrors` integration to link the errors together.
65+
setCause(error, errorBoundaryError);
66+
}
67+
68+
return captureException(error, {
69+
...hint,
70+
captureContext: {
71+
contexts: { react: { componentStack } },
72+
},
73+
});
74+
}

packages/react/src/errorboundary.tsx

+6-52
Original file line numberDiff line numberDiff line change
@@ -1,16 +1,12 @@
11
import type { ReportDialogOptions } from '@sentry/browser';
2-
import { captureException, getClient, showReportDialog, withScope } from '@sentry/browser';
2+
import { getClient, showReportDialog, withScope } from '@sentry/browser';
33
import type { Scope } from '@sentry/types';
4-
import { isError, logger } from '@sentry/utils';
4+
import { logger } from '@sentry/utils';
55
import hoistNonReactStatics from 'hoist-non-react-statics';
66
import * as React from 'react';
77

88
import { DEBUG_BUILD } from './debug-build';
9-
10-
export function isAtLeastReact17(version: string): boolean {
11-
const major = version.match(/^([^.]+)/);
12-
return major !== null && parseInt(major[0]) >= 17;
13-
}
9+
import { captureReactException } from './error';
1410

1511
export const UNKNOWN_COMPONENT = 'unknown';
1612

@@ -69,25 +65,6 @@ const INITIAL_STATE = {
6965
eventId: null,
7066
};
7167

72-
function setCause(error: Error & { cause?: Error }, cause: Error): void {
73-
const seenErrors = new WeakMap<Error, boolean>();
74-
75-
function recurse(error: Error & { cause?: Error }, cause: Error): void {
76-
// If we've already seen the error, there is a recursive loop somewhere in the error's
77-
// cause chain. Let's just bail out then to prevent a stack overflow.
78-
if (seenErrors.has(error)) {
79-
return;
80-
}
81-
if (error.cause) {
82-
seenErrors.set(error, true);
83-
return recurse(error.cause, cause);
84-
}
85-
error.cause = cause;
86-
}
87-
88-
recurse(error, cause);
89-
}
90-
9168
/**
9269
* A ErrorBoundary component that logs errors to Sentry.
9370
* NOTE: If you are a Sentry user, and you are seeing this stack frame, it means the
@@ -118,38 +95,15 @@ class ErrorBoundary extends React.Component<ErrorBoundaryProps, ErrorBoundarySta
11895
}
11996
}
12097

121-
public componentDidCatch(error: unknown, { componentStack }: React.ErrorInfo): void {
98+
public componentDidCatch(error: unknown, errorInfo: React.ErrorInfo): void {
99+
const { componentStack } = errorInfo;
122100
const { beforeCapture, onError, showDialog, dialogOptions } = this.props;
123101
withScope(scope => {
124-
// If on React version >= 17, create stack trace from componentStack param and links
125-
// to to the original error using `error.cause` otherwise relies on error param for stacktrace.
126-
// Linking errors requires the `LinkedErrors` integration be enabled.
127-
// See: https://reactjs.org/blog/2020/08/10/react-v17-rc.html#native-component-stacks
128-
//
129-
// Although `componentDidCatch` is typed to accept an `Error` object, it can also be invoked
130-
// with non-error objects. This is why we need to check if the error is an error-like object.
131-
// See: https://github.com/getsentry/sentry-javascript/issues/6167
132-
if (isAtLeastReact17(React.version) && isError(error)) {
133-
const errorBoundaryError = new Error(error.message);
134-
errorBoundaryError.name = `React ErrorBoundary ${error.name}`;
135-
errorBoundaryError.stack = componentStack;
136-
137-
// Using the `LinkedErrors` integration to link the errors together.
138-
setCause(error, errorBoundaryError);
139-
}
140-
141102
if (beforeCapture) {
142103
beforeCapture(scope, error, componentStack);
143104
}
144105

145-
const eventId = captureException(error, {
146-
captureContext: {
147-
contexts: { react: { componentStack } },
148-
},
149-
// If users provide a fallback component we can assume they are handling the error.
150-
// Therefore, we set the mechanism depending on the presence of the fallback prop.
151-
mechanism: { handled: !!this.props.fallback },
152-
});
106+
const eventId = captureReactException(error, errorInfo, { mechanism: { handled: !!this.props.fallback }})
153107

154108
if (onError) {
155109
onError(error, componentStack, eventId);

packages/react/src/index.ts

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

33
export { init } from './sdk';
4+
export { captureReactException } from './error';
45
export { Profiler, withProfiler, useProfiler } from './profiler';
56
export type { ErrorBoundaryProps, FallbackRender } from './errorboundary';
67
export { ErrorBoundary, withErrorBoundary } from './errorboundary';

packages/react/test/error.test.ts

+14
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,14 @@
1+
import { isAtLeastReact17 } from '../src/error';
2+
3+
describe('isAtLeastReact17', () => {
4+
test.each([
5+
['React 16', '16.0.4', false],
6+
['React 17', '17.0.0', true],
7+
['React 17 with no patch', '17.4', true],
8+
['React 17 with no patch and no minor', '17', true],
9+
['React 18', '18.1.0', true],
10+
['React 19', '19.0.0', true],
11+
])('%s', (_: string, input: string, output: ReturnType<typeof isAtLeastReact17>) => {
12+
expect(isAtLeastReact17(input)).toBe(output);
13+
});
14+
});

packages/react/test/errorboundary.test.tsx

+1-14
Original file line numberDiff line numberDiff line change
@@ -5,7 +5,7 @@ import * as React from 'react';
55
import { useState } from 'react';
66

77
import type { ErrorBoundaryProps } from '../src/errorboundary';
8-
import { ErrorBoundary, UNKNOWN_COMPONENT, isAtLeastReact17, withErrorBoundary } from '../src/errorboundary';
8+
import { ErrorBoundary, UNKNOWN_COMPONENT, withErrorBoundary } from '../src/errorboundary';
99

1010
const mockCaptureException = jest.fn();
1111
const mockShowReportDialog = jest.fn();
@@ -581,16 +581,3 @@ describe('ErrorBoundary', () => {
581581
});
582582
});
583583
});
584-
585-
describe('isAtLeastReact17', () => {
586-
test.each([
587-
['React 16', '16.0.4', false],
588-
['React 17', '17.0.0', true],
589-
['React 17 with no patch', '17.4', true],
590-
['React 17 with no patch and no minor', '17', true],
591-
['React 18', '18.1.0', true],
592-
['React 19', '19.0.0', true],
593-
])('%s', (_: string, input: string, output: ReturnType<typeof isAtLeastReact17>) => {
594-
expect(isAtLeastReact17(input)).toBe(output);
595-
});
596-
});

0 commit comments

Comments
 (0)