diff --git a/packages/react/README.md b/packages/react/README.md
index 49c09247c9ea..1315d9760142 100644
--- a/packages/react/README.md
+++ b/packages/react/README.md
@@ -20,7 +20,7 @@ To use this SDK, call `Sentry.init(options)` before you mount your React compone
```javascript
import React from 'react';
-import ReactDOM from 'react-dom';
+import { createRoot } from 'react-dom/client';
import * as Sentry from '@sentry/react';
Sentry.init({
@@ -30,12 +30,60 @@ Sentry.init({
// ...
-ReactDOM.render(, rootNode);
+const container = document.getElementById(“app”);
+const root = createRoot(container);
+root.render();
-// Can also use with React Concurrent Mode
-// ReactDOM.createRoot(rootNode).render();
+// also works with hydrateRoot
+// const domNode = document.getElementById('root');
+// const root = hydrateRoot(domNode, reactNode);
+// root.render();
```
+### React 19
+
+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.
+
+```js
+const container = document.getElementById(“app”);
+const root = createRoot(container, {
+ // Callback called when an error is thrown and not caught by an Error Boundary.
+ onUncaughtError: (error, errorInfo) => {
+ Sentry.captureReactException(error, errorInfo);
+
+ console.error(
+ 'Uncaught error',
+ error,
+ errorInfo.componentStack
+ );
+ },
+ // Callback called when React catches an error in an Error Boundary.
+ onCaughtError: (error, errorInfo) => {
+ Sentry.captureReactException(error, errorInfo);
+
+ console.error(
+ 'Caught error',
+ error,
+ errorInfo.componentStack
+ );
+ },
+ // Callback called when React automatically recovers from errors.
+ onRecoverableError: (error, errorInfo) => {
+ Sentry.captureReactException(error, errorInfo);
+
+ console.error(
+ 'Recoverable error',
+ error,
+ error.cause,
+ errorInfo.componentStack,
+ );
+ }
+});
+root.render();
+```
+
+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.
+
### ErrorBoundary
`@sentry/react` exports an ErrorBoundary component that will automatically send Javascript errors from inside a
diff --git a/packages/react/src/error.ts b/packages/react/src/error.ts
new file mode 100644
index 000000000000..347793400d58
--- /dev/null
+++ b/packages/react/src/error.ts
@@ -0,0 +1,74 @@
+import { captureException } from '@sentry/browser';
+import type { EventHint } from '@sentry/types';
+import { isError } from '@sentry/utils';
+import { version } from 'react';
+import type { ErrorInfo } from 'react';
+
+/**
+ * See if React major version is 17+ by parsing version string.
+ */
+export function isAtLeastReact17(reactVersion: string): boolean {
+ const reactMajor = reactVersion.match(/^([^.]+)/);
+ return reactMajor !== null && parseInt(reactMajor[0]) >= 17;
+}
+
+/**
+ * Recurse through `error.cause` chain to set cause on an error.
+ */
+export function setCause(error: Error & { cause?: Error }, cause: Error): void {
+ const seenErrors = new WeakMap();
+
+ function recurse(error: Error & { cause?: Error }, cause: Error): void {
+ // If we've already seen the error, there is a recursive loop somewhere in the error's
+ // cause chain. Let's just bail out then to prevent a stack overflow.
+ if (seenErrors.has(error)) {
+ return;
+ }
+ if (error.cause) {
+ seenErrors.set(error, true);
+ return recurse(error.cause, cause);
+ }
+ error.cause = cause;
+ }
+
+ recurse(error, cause);
+}
+
+/**
+ * Captures an error that was thrown by a React ErrorBoundary or React root.
+ *
+ * @param error The error to capture.
+ * @param errorInfo The errorInfo provided by React.
+ * @param hint Optional additional data to attach to the Sentry event.
+ * @returns the id of the captured Sentry event.
+ */
+export function captureReactException(
+ // eslint-disable-next-line @typescript-eslint/no-explicit-any
+ error: any,
+ { componentStack }: ErrorInfo,
+ hint?: EventHint,
+): string {
+ // If on React version >= 17, create stack trace from componentStack param and links
+ // to to the original error using `error.cause` otherwise relies on error param for stacktrace.
+ // Linking errors requires the `LinkedErrors` integration be enabled.
+ // See: https://reactjs.org/blog/2020/08/10/react-v17-rc.html#native-component-stacks
+ //
+ // Although `componentDidCatch` is typed to accept an `Error` object, it can also be invoked
+ // with non-error objects. This is why we need to check if the error is an error-like object.
+ // See: https://github.com/getsentry/sentry-javascript/issues/6167
+ if (isAtLeastReact17(version) && isError(error)) {
+ const errorBoundaryError = new Error(error.message);
+ errorBoundaryError.name = `React ErrorBoundary ${error.name}`;
+ errorBoundaryError.stack = componentStack;
+
+ // Using the `LinkedErrors` integration to link the errors together.
+ setCause(error, errorBoundaryError);
+ }
+
+ return captureException(error, {
+ ...hint,
+ captureContext: {
+ contexts: { react: { componentStack } },
+ },
+ });
+}
diff --git a/packages/react/src/errorboundary.tsx b/packages/react/src/errorboundary.tsx
index 3ce1d0442b81..5cb706639b27 100644
--- a/packages/react/src/errorboundary.tsx
+++ b/packages/react/src/errorboundary.tsx
@@ -1,16 +1,12 @@
import type { ReportDialogOptions } from '@sentry/browser';
-import { captureException, getClient, showReportDialog, withScope } from '@sentry/browser';
+import { getClient, showReportDialog, withScope } from '@sentry/browser';
import type { Scope } from '@sentry/types';
-import { isError, logger } from '@sentry/utils';
+import { logger } from '@sentry/utils';
import hoistNonReactStatics from 'hoist-non-react-statics';
import * as React from 'react';
import { DEBUG_BUILD } from './debug-build';
-
-export function isAtLeastReact17(version: string): boolean {
- const major = version.match(/^([^.]+)/);
- return major !== null && parseInt(major[0]) >= 17;
-}
+import { captureReactException } from './error';
export const UNKNOWN_COMPONENT = 'unknown';
@@ -69,25 +65,6 @@ const INITIAL_STATE = {
eventId: null,
};
-function setCause(error: Error & { cause?: Error }, cause: Error): void {
- const seenErrors = new WeakMap();
-
- function recurse(error: Error & { cause?: Error }, cause: Error): void {
- // If we've already seen the error, there is a recursive loop somewhere in the error's
- // cause chain. Let's just bail out then to prevent a stack overflow.
- if (seenErrors.has(error)) {
- return;
- }
- if (error.cause) {
- seenErrors.set(error, true);
- return recurse(error.cause, cause);
- }
- error.cause = cause;
- }
-
- recurse(error, cause);
-}
-
/**
* A ErrorBoundary component that logs errors to Sentry.
* 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 {
- // If on React version >= 17, create stack trace from componentStack param and links
- // to to the original error using `error.cause` otherwise relies on error param for stacktrace.
- // Linking errors requires the `LinkedErrors` integration be enabled.
- // See: https://reactjs.org/blog/2020/08/10/react-v17-rc.html#native-component-stacks
- //
- // Although `componentDidCatch` is typed to accept an `Error` object, it can also be invoked
- // with non-error objects. This is why we need to check if the error is an error-like object.
- // See: https://github.com/getsentry/sentry-javascript/issues/6167
- if (isAtLeastReact17(React.version) && isError(error)) {
- const errorBoundaryError = new Error(error.message);
- errorBoundaryError.name = `React ErrorBoundary ${error.name}`;
- errorBoundaryError.stack = componentStack;
-
- // Using the `LinkedErrors` integration to link the errors together.
- setCause(error, errorBoundaryError);
- }
-
if (beforeCapture) {
beforeCapture(scope, error, componentStack);
}
- const eventId = captureException(error, {
- captureContext: {
- contexts: { react: { componentStack } },
- },
- // If users provide a fallback component we can assume they are handling the error.
- // Therefore, we set the mechanism depending on the presence of the fallback prop.
- mechanism: { handled: !!this.props.fallback },
- });
+ const eventId = captureReactException(error, errorInfo, { mechanism: { handled: !!this.props.fallback }})
if (onError) {
onError(error, componentStack, eventId);
diff --git a/packages/react/src/index.ts b/packages/react/src/index.ts
index ef6627cf0c5e..b3f20deed03d 100644
--- a/packages/react/src/index.ts
+++ b/packages/react/src/index.ts
@@ -1,6 +1,7 @@
export * from '@sentry/browser';
export { init } from './sdk';
+export { captureReactException } from './error';
export { Profiler, withProfiler, useProfiler } from './profiler';
export type { ErrorBoundaryProps, FallbackRender } from './errorboundary';
export { ErrorBoundary, withErrorBoundary } from './errorboundary';
diff --git a/packages/react/test/error.test.ts b/packages/react/test/error.test.ts
new file mode 100644
index 000000000000..780c6f9657fb
--- /dev/null
+++ b/packages/react/test/error.test.ts
@@ -0,0 +1,14 @@
+import { isAtLeastReact17 } from '../src/error';
+
+describe('isAtLeastReact17', () => {
+ test.each([
+ ['React 16', '16.0.4', false],
+ ['React 17', '17.0.0', true],
+ ['React 17 with no patch', '17.4', true],
+ ['React 17 with no patch and no minor', '17', true],
+ ['React 18', '18.1.0', true],
+ ['React 19', '19.0.0', true],
+ ])('%s', (_: string, input: string, output: ReturnType) => {
+ expect(isAtLeastReact17(input)).toBe(output);
+ });
+});
diff --git a/packages/react/test/errorboundary.test.tsx b/packages/react/test/errorboundary.test.tsx
index 10c5130f88d7..d032fe73d6d3 100644
--- a/packages/react/test/errorboundary.test.tsx
+++ b/packages/react/test/errorboundary.test.tsx
@@ -5,7 +5,7 @@ import * as React from 'react';
import { useState } from 'react';
import type { ErrorBoundaryProps } from '../src/errorboundary';
-import { ErrorBoundary, UNKNOWN_COMPONENT, isAtLeastReact17, withErrorBoundary } from '../src/errorboundary';
+import { ErrorBoundary, UNKNOWN_COMPONENT, withErrorBoundary } from '../src/errorboundary';
const mockCaptureException = jest.fn();
const mockShowReportDialog = jest.fn();
@@ -581,16 +581,3 @@ describe('ErrorBoundary', () => {
});
});
});
-
-describe('isAtLeastReact17', () => {
- test.each([
- ['React 16', '16.0.4', false],
- ['React 17', '17.0.0', true],
- ['React 17 with no patch', '17.4', true],
- ['React 17 with no patch and no minor', '17', true],
- ['React 18', '18.1.0', true],
- ['React 19', '19.0.0', true],
- ])('%s', (_: string, input: string, output: ReturnType) => {
- expect(isAtLeastReact17(input)).toBe(output);
- });
-});