diff --git a/dev-packages/e2e-tests/test-applications/hydrogen-react-router-7/app/entry.client.tsx b/dev-packages/e2e-tests/test-applications/hydrogen-react-router-7/app/entry.client.tsx index 9c48e56befe8..bd606bbe7c08 100644 --- a/dev-packages/e2e-tests/test-applications/hydrogen-react-router-7/app/entry.client.tsx +++ b/dev-packages/e2e-tests/test-applications/hydrogen-react-router-7/app/entry.client.tsx @@ -16,7 +16,7 @@ startTransition(() => { hydrateRoot( document, - + , ); }); diff --git a/dev-packages/e2e-tests/test-applications/hydrogen-react-router-7/app/root.tsx b/dev-packages/e2e-tests/test-applications/hydrogen-react-router-7/app/root.tsx index afa85270e045..e45feb5dd576 100644 --- a/dev-packages/e2e-tests/test-applications/hydrogen-react-router-7/app/root.tsx +++ b/dev-packages/e2e-tests/test-applications/hydrogen-react-router-7/app/root.tsx @@ -1,4 +1,3 @@ -import * as Sentry from '@sentry/react-router/cloudflare'; import { type LoaderFunctionArgs } from '@shopify/remix-oxygen'; import { Outlet, @@ -160,8 +159,6 @@ export function ErrorBoundary({ error }: { error: unknown }) { let errorMessage = 'Unknown error'; let errorStatus = 500; - const eventId = Sentry.captureException(error); - if (isRouteErrorResponse(error)) { errorMessage = error?.data?.message ?? error.data; errorStatus = error.status; @@ -178,11 +175,6 @@ export function ErrorBoundary({ error }: { error: unknown }) {
{errorMessage}
)} - {eventId && ( -

- Sentry Event ID: {eventId} -

- )} ); } diff --git a/dev-packages/e2e-tests/test-applications/react-router-7-framework-custom/app/entry.client.tsx b/dev-packages/e2e-tests/test-applications/react-router-7-framework-custom/app/entry.client.tsx index 925c1e6ab143..005268b40ad0 100644 --- a/dev-packages/e2e-tests/test-applications/react-router-7-framework-custom/app/entry.client.tsx +++ b/dev-packages/e2e-tests/test-applications/react-router-7-framework-custom/app/entry.client.tsx @@ -17,7 +17,7 @@ startTransition(() => { hydrateRoot( document, - + , ); }); diff --git a/dev-packages/e2e-tests/test-applications/react-router-7-framework-custom/app/root.tsx b/dev-packages/e2e-tests/test-applications/react-router-7-framework-custom/app/root.tsx index 227c08f7730c..bc1b8f1236c0 100644 --- a/dev-packages/e2e-tests/test-applications/react-router-7-framework-custom/app/root.tsx +++ b/dev-packages/e2e-tests/test-applications/react-router-7-framework-custom/app/root.tsx @@ -1,4 +1,3 @@ -import * as Sentry from '@sentry/react-router'; import { Links, Meta, Outlet, Scripts, ScrollRestoration, isRouteErrorResponse } from 'react-router'; import type { Route } from './+types/root'; import stylesheet from './app.css?url'; @@ -48,7 +47,6 @@ export function ErrorBoundary({ error }: Route.ErrorBoundaryProps) { message = error.status === 404 ? '404' : 'Error'; details = error.status === 404 ? 'The requested page could not be found.' : error.statusText || details; } else if (error && error instanceof Error) { - Sentry.captureException(error); if (import.meta.env.DEV) { details = error.message; stack = error.stack; diff --git a/dev-packages/e2e-tests/test-applications/react-router-7-framework-custom/tests/errors/errors.client.test.ts b/dev-packages/e2e-tests/test-applications/react-router-7-framework-custom/tests/errors/errors.client.test.ts index d6c80924c121..c1a7de46f1b6 100644 --- a/dev-packages/e2e-tests/test-applications/react-router-7-framework-custom/tests/errors/errors.client.test.ts +++ b/dev-packages/e2e-tests/test-applications/react-router-7-framework-custom/tests/errors/errors.client.test.ts @@ -100,7 +100,8 @@ test.describe('client-side errors', () => { type: 'Error', value: errorMessage, mechanism: { - handled: true, + handled: false, + type: 'auto.function.react_router.on_error', }, }, ], @@ -127,7 +128,8 @@ test.describe('client-side errors', () => { type: 'Error', value: errorMessage, mechanism: { - handled: true, + handled: false, + type: 'auto.function.react_router.on_error', }, }, ], diff --git a/dev-packages/e2e-tests/test-applications/react-router-7-framework-instrumentation/app/entry.client.tsx b/dev-packages/e2e-tests/test-applications/react-router-7-framework-instrumentation/app/entry.client.tsx index c8bd9df2ba99..9c9ccd812edd 100644 --- a/dev-packages/e2e-tests/test-applications/react-router-7-framework-instrumentation/app/entry.client.tsx +++ b/dev-packages/e2e-tests/test-applications/react-router-7-framework-instrumentation/app/entry.client.tsx @@ -27,7 +27,7 @@ startTransition(() => { document, {/* unstable_instrumentations is React Router 7.x's prop name (will become `instrumentations` in v8) */} - + , ); }); diff --git a/dev-packages/e2e-tests/test-applications/react-router-7-framework-instrumentation/app/root.tsx b/dev-packages/e2e-tests/test-applications/react-router-7-framework-instrumentation/app/root.tsx index 227c08f7730c..bc1b8f1236c0 100644 --- a/dev-packages/e2e-tests/test-applications/react-router-7-framework-instrumentation/app/root.tsx +++ b/dev-packages/e2e-tests/test-applications/react-router-7-framework-instrumentation/app/root.tsx @@ -1,4 +1,3 @@ -import * as Sentry from '@sentry/react-router'; import { Links, Meta, Outlet, Scripts, ScrollRestoration, isRouteErrorResponse } from 'react-router'; import type { Route } from './+types/root'; import stylesheet from './app.css?url'; @@ -48,7 +47,6 @@ export function ErrorBoundary({ error }: Route.ErrorBoundaryProps) { message = error.status === 404 ? '404' : 'Error'; details = error.status === 404 ? 'The requested page could not be found.' : error.statusText || details; } else if (error && error instanceof Error) { - Sentry.captureException(error); if (import.meta.env.DEV) { details = error.message; stack = error.stack; diff --git a/dev-packages/e2e-tests/test-applications/react-router-7-framework-node-20-18/app/entry.client.tsx b/dev-packages/e2e-tests/test-applications/react-router-7-framework-node-20-18/app/entry.client.tsx index 925c1e6ab143..005268b40ad0 100644 --- a/dev-packages/e2e-tests/test-applications/react-router-7-framework-node-20-18/app/entry.client.tsx +++ b/dev-packages/e2e-tests/test-applications/react-router-7-framework-node-20-18/app/entry.client.tsx @@ -17,7 +17,7 @@ startTransition(() => { hydrateRoot( document, - + , ); }); diff --git a/dev-packages/e2e-tests/test-applications/react-router-7-framework-node-20-18/app/root.tsx b/dev-packages/e2e-tests/test-applications/react-router-7-framework-node-20-18/app/root.tsx index 227c08f7730c..bc1b8f1236c0 100644 --- a/dev-packages/e2e-tests/test-applications/react-router-7-framework-node-20-18/app/root.tsx +++ b/dev-packages/e2e-tests/test-applications/react-router-7-framework-node-20-18/app/root.tsx @@ -1,4 +1,3 @@ -import * as Sentry from '@sentry/react-router'; import { Links, Meta, Outlet, Scripts, ScrollRestoration, isRouteErrorResponse } from 'react-router'; import type { Route } from './+types/root'; import stylesheet from './app.css?url'; @@ -48,7 +47,6 @@ export function ErrorBoundary({ error }: Route.ErrorBoundaryProps) { message = error.status === 404 ? '404' : 'Error'; details = error.status === 404 ? 'The requested page could not be found.' : error.statusText || details; } else if (error && error instanceof Error) { - Sentry.captureException(error); if (import.meta.env.DEV) { details = error.message; stack = error.stack; diff --git a/dev-packages/e2e-tests/test-applications/react-router-7-framework-node-20-18/tests/errors/errors.client.test.ts b/dev-packages/e2e-tests/test-applications/react-router-7-framework-node-20-18/tests/errors/errors.client.test.ts index d6c80924c121..c1a7de46f1b6 100644 --- a/dev-packages/e2e-tests/test-applications/react-router-7-framework-node-20-18/tests/errors/errors.client.test.ts +++ b/dev-packages/e2e-tests/test-applications/react-router-7-framework-node-20-18/tests/errors/errors.client.test.ts @@ -100,7 +100,8 @@ test.describe('client-side errors', () => { type: 'Error', value: errorMessage, mechanism: { - handled: true, + handled: false, + type: 'auto.function.react_router.on_error', }, }, ], @@ -127,7 +128,8 @@ test.describe('client-side errors', () => { type: 'Error', value: errorMessage, mechanism: { - handled: true, + handled: false, + type: 'auto.function.react_router.on_error', }, }, ], diff --git a/dev-packages/e2e-tests/test-applications/react-router-7-framework-spa-node-20-18/app/entry.client.tsx b/dev-packages/e2e-tests/test-applications/react-router-7-framework-spa-node-20-18/app/entry.client.tsx index 223c8e6129dd..f9e8c5139d22 100644 --- a/dev-packages/e2e-tests/test-applications/react-router-7-framework-spa-node-20-18/app/entry.client.tsx +++ b/dev-packages/e2e-tests/test-applications/react-router-7-framework-spa-node-20-18/app/entry.client.tsx @@ -17,7 +17,7 @@ startTransition(() => { hydrateRoot( document, - + , ); }); diff --git a/dev-packages/e2e-tests/test-applications/react-router-7-framework-spa-node-20-18/app/root.tsx b/dev-packages/e2e-tests/test-applications/react-router-7-framework-spa-node-20-18/app/root.tsx index 194d91eea422..2a3279aed365 100644 --- a/dev-packages/e2e-tests/test-applications/react-router-7-framework-spa-node-20-18/app/root.tsx +++ b/dev-packages/e2e-tests/test-applications/react-router-7-framework-spa-node-20-18/app/root.tsx @@ -2,7 +2,6 @@ import { isRouteErrorResponse, Links, Meta, Outlet, Scripts, ScrollRestoration } import type { Route } from './+types/root'; import './app.css'; -import * as Sentry from '@sentry/react-router'; export function Layout({ children }: { children: React.ReactNode }) { return ( @@ -35,8 +34,6 @@ export function ErrorBoundary({ error }: Route.ErrorBoundaryProps) { message = error.status === 404 ? '404' : 'Error'; details = error.status === 404 ? 'The requested page could not be found.' : error.statusText || details; } else if (error && error instanceof Error) { - Sentry.captureException(error); - details = error.message; stack = error.stack; } diff --git a/dev-packages/e2e-tests/test-applications/react-router-7-framework-spa-node-20-18/tests/errors/errors.client.test.ts b/dev-packages/e2e-tests/test-applications/react-router-7-framework-spa-node-20-18/tests/errors/errors.client.test.ts index d6c80924c121..c1a7de46f1b6 100644 --- a/dev-packages/e2e-tests/test-applications/react-router-7-framework-spa-node-20-18/tests/errors/errors.client.test.ts +++ b/dev-packages/e2e-tests/test-applications/react-router-7-framework-spa-node-20-18/tests/errors/errors.client.test.ts @@ -100,7 +100,8 @@ test.describe('client-side errors', () => { type: 'Error', value: errorMessage, mechanism: { - handled: true, + handled: false, + type: 'auto.function.react_router.on_error', }, }, ], @@ -127,7 +128,8 @@ test.describe('client-side errors', () => { type: 'Error', value: errorMessage, mechanism: { - handled: true, + handled: false, + type: 'auto.function.react_router.on_error', }, }, ], diff --git a/dev-packages/e2e-tests/test-applications/react-router-7-framework-spa/app/entry.client.tsx b/dev-packages/e2e-tests/test-applications/react-router-7-framework-spa/app/entry.client.tsx index 7448ebe7bfe2..249e18d27c08 100644 --- a/dev-packages/e2e-tests/test-applications/react-router-7-framework-spa/app/entry.client.tsx +++ b/dev-packages/e2e-tests/test-applications/react-router-7-framework-spa/app/entry.client.tsx @@ -17,7 +17,7 @@ startTransition(() => { hydrateRoot( document, - + , ); }); diff --git a/dev-packages/e2e-tests/test-applications/react-router-7-framework-spa/app/root.tsx b/dev-packages/e2e-tests/test-applications/react-router-7-framework-spa/app/root.tsx index 194d91eea422..2a3279aed365 100644 --- a/dev-packages/e2e-tests/test-applications/react-router-7-framework-spa/app/root.tsx +++ b/dev-packages/e2e-tests/test-applications/react-router-7-framework-spa/app/root.tsx @@ -2,7 +2,6 @@ import { isRouteErrorResponse, Links, Meta, Outlet, Scripts, ScrollRestoration } import type { Route } from './+types/root'; import './app.css'; -import * as Sentry from '@sentry/react-router'; export function Layout({ children }: { children: React.ReactNode }) { return ( @@ -35,8 +34,6 @@ export function ErrorBoundary({ error }: Route.ErrorBoundaryProps) { message = error.status === 404 ? '404' : 'Error'; details = error.status === 404 ? 'The requested page could not be found.' : error.statusText || details; } else if (error && error instanceof Error) { - Sentry.captureException(error); - details = error.message; stack = error.stack; } diff --git a/dev-packages/e2e-tests/test-applications/react-router-7-framework-spa/tests/errors/errors.client.test.ts b/dev-packages/e2e-tests/test-applications/react-router-7-framework-spa/tests/errors/errors.client.test.ts index d6c80924c121..c1a7de46f1b6 100644 --- a/dev-packages/e2e-tests/test-applications/react-router-7-framework-spa/tests/errors/errors.client.test.ts +++ b/dev-packages/e2e-tests/test-applications/react-router-7-framework-spa/tests/errors/errors.client.test.ts @@ -100,7 +100,8 @@ test.describe('client-side errors', () => { type: 'Error', value: errorMessage, mechanism: { - handled: true, + handled: false, + type: 'auto.function.react_router.on_error', }, }, ], @@ -127,7 +128,8 @@ test.describe('client-side errors', () => { type: 'Error', value: errorMessage, mechanism: { - handled: true, + handled: false, + type: 'auto.function.react_router.on_error', }, }, ], diff --git a/dev-packages/e2e-tests/test-applications/react-router-7-framework/app/entry.client.tsx b/dev-packages/e2e-tests/test-applications/react-router-7-framework/app/entry.client.tsx index 925c1e6ab143..005268b40ad0 100644 --- a/dev-packages/e2e-tests/test-applications/react-router-7-framework/app/entry.client.tsx +++ b/dev-packages/e2e-tests/test-applications/react-router-7-framework/app/entry.client.tsx @@ -17,7 +17,7 @@ startTransition(() => { hydrateRoot( document, - + , ); }); diff --git a/dev-packages/e2e-tests/test-applications/react-router-7-framework/app/root.tsx b/dev-packages/e2e-tests/test-applications/react-router-7-framework/app/root.tsx index 227c08f7730c..bc1b8f1236c0 100644 --- a/dev-packages/e2e-tests/test-applications/react-router-7-framework/app/root.tsx +++ b/dev-packages/e2e-tests/test-applications/react-router-7-framework/app/root.tsx @@ -1,4 +1,3 @@ -import * as Sentry from '@sentry/react-router'; import { Links, Meta, Outlet, Scripts, ScrollRestoration, isRouteErrorResponse } from 'react-router'; import type { Route } from './+types/root'; import stylesheet from './app.css?url'; @@ -48,7 +47,6 @@ export function ErrorBoundary({ error }: Route.ErrorBoundaryProps) { message = error.status === 404 ? '404' : 'Error'; details = error.status === 404 ? 'The requested page could not be found.' : error.statusText || details; } else if (error && error instanceof Error) { - Sentry.captureException(error); if (import.meta.env.DEV) { details = error.message; stack = error.stack; diff --git a/dev-packages/e2e-tests/test-applications/react-router-7-framework/tests/errors/errors.client.test.ts b/dev-packages/e2e-tests/test-applications/react-router-7-framework/tests/errors/errors.client.test.ts index d6c80924c121..c1a7de46f1b6 100644 --- a/dev-packages/e2e-tests/test-applications/react-router-7-framework/tests/errors/errors.client.test.ts +++ b/dev-packages/e2e-tests/test-applications/react-router-7-framework/tests/errors/errors.client.test.ts @@ -100,7 +100,8 @@ test.describe('client-side errors', () => { type: 'Error', value: errorMessage, mechanism: { - handled: true, + handled: false, + type: 'auto.function.react_router.on_error', }, }, ], @@ -127,7 +128,8 @@ test.describe('client-side errors', () => { type: 'Error', value: errorMessage, mechanism: { - handled: true, + handled: false, + type: 'auto.function.react_router.on_error', }, }, ], diff --git a/packages/react-router/src/client/index.ts b/packages/react-router/src/client/index.ts index 6734b21c8583..24431843710c 100644 --- a/packages/react-router/src/client/index.ts +++ b/packages/react-router/src/client/index.ts @@ -31,3 +31,5 @@ export { isNavigateHookInvoked, type CreateSentryClientInstrumentationOptions, } from './createClientInstrumentation'; + +export { sentryOnError } from './sentryOnError'; diff --git a/packages/react-router/src/client/sentryOnError.ts b/packages/react-router/src/client/sentryOnError.ts new file mode 100644 index 000000000000..bf6d8108565d --- /dev/null +++ b/packages/react-router/src/client/sentryOnError.ts @@ -0,0 +1,36 @@ +import { captureException } from '@sentry/core'; +import { captureReactException } from '@sentry/react'; + +/** + * A handler function for React Router's `onError` prop on `HydratedRouter`. + * + * Reports errors to Sentry. + * + * @example (entry.client.tsx) + * ```tsx + * import { sentryOnError } from '@sentry/react-router'; + * + * startTransition(() => { + * hydrateRoot( + * document, + * + * ); + * }); + * ``` + */ +export function sentryOnError( + error: unknown, + { + errorInfo, + }: { + errorInfo?: React.ErrorInfo; + }, +): void { + const mechanism = { handled: false, type: 'auto.function.react_router.on_error' }; + + if (errorInfo) { + captureReactException(error, errorInfo, { mechanism }); + } else { + captureException(error, { mechanism }); + } +} diff --git a/packages/react-router/test/client/sentryOnError.test.ts b/packages/react-router/test/client/sentryOnError.test.ts new file mode 100644 index 000000000000..33df01858288 --- /dev/null +++ b/packages/react-router/test/client/sentryOnError.test.ts @@ -0,0 +1,38 @@ +import * as SentryCore from '@sentry/core'; +import * as SentryReact from '@sentry/react'; +import { afterEach, describe, expect, it, vi } from 'vitest'; +import { sentryOnError } from '../../src/client/sentryOnError'; + +const captureReactExceptionSpy = vi.spyOn(SentryReact, 'captureReactException').mockReturnValue('mock-event-id'); +const captureExceptionSpy = vi.spyOn(SentryCore, 'captureException').mockReturnValue('mock-event-id'); + +describe('sentryOnError', () => { + afterEach(() => { + vi.clearAllMocks(); + }); + + it('calls captureReactException when errorInfo is provided', () => { + const error = new Error('test error'); + const errorInfo = { componentStack: '\n' }; + + sentryOnError(error, { + errorInfo, + }); + + expect(captureReactExceptionSpy).toHaveBeenCalledWith(error, errorInfo, { + mechanism: { handled: false, type: 'auto.function.react_router.on_error' }, + }); + expect(captureExceptionSpy).not.toHaveBeenCalled(); + }); + + it('calls captureException when errorInfo is undefined', () => { + const error = new Error('loader error'); + + sentryOnError(error, {}); + + expect(captureExceptionSpy).toHaveBeenCalledWith(error, { + mechanism: { handled: false, type: 'auto.function.react_router.on_error' }, + }); + expect(captureReactExceptionSpy).not.toHaveBeenCalled(); + }); +});