diff --git a/packages/react/package.json b/packages/react/package.json index 215865a5f638..bf126527ff4f 100644 --- a/packages/react/package.json +++ b/packages/react/package.json @@ -32,6 +32,7 @@ "@types/hoist-non-react-statics": "^3.3.1", "@types/react": "^16.9.35", "jest": "^24.7.1", + "jsdom": "^16.2.2", "npm-run-all": "^4.1.2", "prettier": "^1.17.0", "prettier-check": "^2.0.0", diff --git a/packages/react/src/errorboundary.tsx b/packages/react/src/errorboundary.tsx index 0c0fda21e258..4d50395025d4 100644 --- a/packages/react/src/errorboundary.tsx +++ b/packages/react/src/errorboundary.tsx @@ -11,13 +11,31 @@ export type FallbackRender = (fallback: { }) => React.ReactNode; export type ErrorBoundaryProps = { + /** If a Sentry report dialog should be rendered on error */ showDialog?: boolean; + /** + * Options to be passed into the Sentry report dialog. + * No-op if {@link showDialog} is false. + */ dialogOptions?: Sentry.ReportDialogOptions; - // tslint:disable-next-line: no-null-undefined-union + // tslint:disable no-null-undefined-union + /** + * A fallback component that gets rendered when the error boundary encounters an error. + * + * Can either provide a React Component, or a function that returns React Component as + * a valid fallback prop. If a function is provided, the function will be called with + * the error, the component stack, and an function that resets the error boundary on error. + * + */ fallback?: React.ReactNode | FallbackRender; + // tslint:enable no-null-undefined-union + /** Called with the error boundary encounters an error */ onError?(error: Error, componentStack: string): void; + /** Called on componentDidMount() */ onMount?(): void; + /** Called if resetError() is called from the fallback render props function */ onReset?(error: Error | null, componentStack: string | null): void; + /** Called on componentWillUnmount() */ onUnmount?(error: Error | null, componentStack: string | null): void; }; @@ -31,17 +49,21 @@ const INITIAL_STATE = { error: null, }; +/** + * A ErrorBoundary component that logs errors to Sentry. + * Requires React >= 16 + */ class ErrorBoundary extends React.Component { public state: ErrorBoundaryState = INITIAL_STATE; public componentDidCatch(error: Error, { componentStack }: React.ErrorInfo): void { - Sentry.captureException(error, { contexts: { react: { componentStack } } }); + const eventId = Sentry.captureException(error, { contexts: { react: { componentStack } } }); const { onError, showDialog, dialogOptions } = this.props; if (onError) { onError(error, componentStack); } if (showDialog) { - Sentry.showReportDialog(dialogOptions); + Sentry.showReportDialog({ ...dialogOptions, eventId }); } // componentDidCatch is used over getDerivedStateFromError diff --git a/packages/react/src/index.ts b/packages/react/src/index.ts index d6779b078372..fc67e410912c 100644 --- a/packages/react/src/index.ts +++ b/packages/react/src/index.ts @@ -1,4 +1,27 @@ +import { addGlobalEventProcessor, SDK_VERSION } from '@sentry/browser'; + +function createReactEventProcessor(): void { + addGlobalEventProcessor(event => { + event.sdk = { + ...event.sdk, + name: 'sentry.javascript.react', + packages: [ + ...((event.sdk && event.sdk.packages) || []), + { + name: 'npm:@sentry/react', + version: SDK_VERSION, + }, + ], + version: SDK_VERSION, + }; + + return event; + }); +} + export * from '@sentry/browser'; export { Profiler, withProfiler, useProfiler } from './profiler'; export { ErrorBoundary, withErrorBoundary } from './errorboundary'; + +createReactEventProcessor(); diff --git a/packages/react/src/profiler.tsx b/packages/react/src/profiler.tsx index fec61437abbb..8cf86ed89009 100644 --- a/packages/react/src/profiler.tsx +++ b/packages/react/src/profiler.tsx @@ -39,6 +39,10 @@ function afterNextFrame(callback: Function): void { timeout = window.setTimeout(done, 100); } +/** + * getInitActivity pushes activity based on React component mount + * @param name displayName of component that started activity + */ const getInitActivity = (name: string): number | null => { const tracingIntegration = getCurrentHub().getIntegration(TRACING_GETTER); @@ -68,10 +72,13 @@ class Profiler extends React.Component { this.activity = getInitActivity(this.props.name); } + // If a component mounted, we can finish the mount activity. public componentDidMount(): void { afterNextFrame(this.finishProfile); } + // Sometimes a component will unmount first, so we make + // sure to also finish the mount activity here. public componentWillUnmount(): void { afterNextFrame(this.finishProfile); } @@ -94,8 +101,15 @@ class Profiler extends React.Component { } } -function withProfiler

(WrappedComponent: React.ComponentType

): React.FC

{ - const componentDisplayName = WrappedComponent.displayName || WrappedComponent.name || UNKNOWN_COMPONENT; +/** + * withProfiler is a higher order component that wraps a + * component in a {@link Profiler} component. + * + * @param WrappedComponent component that is wrapped by Profiler + * @param name displayName of component being profiled + */ +function withProfiler

(WrappedComponent: React.ComponentType

, name?: string): React.FC

{ + const componentDisplayName = name || WrappedComponent.displayName || WrappedComponent.name || UNKNOWN_COMPONENT; const Wrapped: React.FC

= (props: P) => ( @@ -119,7 +133,7 @@ function withProfiler

(WrappedComponent: React.ComponentType

* @param name displayName of component being profiled */ function useProfiler(name: string): void { - const activity = getInitActivity(name); + const [activity] = React.useState(() => getInitActivity(name)); React.useEffect(() => { afterNextFrame(() => { diff --git a/packages/react/test/errorboundary.test.tsx b/packages/react/test/errorboundary.test.tsx index 8847c803aa25..9e4e5de11344 100644 --- a/packages/react/test/errorboundary.test.tsx +++ b/packages/react/test/errorboundary.test.tsx @@ -1,14 +1,16 @@ import { fireEvent, render, screen } from '@testing-library/react'; import * as React from 'react'; -import { ErrorBoundary, ErrorBoundaryProps } from '../src/errorboundary'; +import { ErrorBoundary, ErrorBoundaryProps, UNKNOWN_COMPONENT, withErrorBoundary } from '../src/errorboundary'; const mockCaptureException = jest.fn(); const mockShowReportDialog = jest.fn(); +const EVENT_ID = 'test-id-123'; jest.mock('@sentry/browser', () => ({ captureException: (err: any, ctx: any) => { mockCaptureException(err, ctx); + return EVENT_ID; }, showReportDialog: (options: any) => { mockShowReportDialog(options); @@ -18,7 +20,15 @@ jest.mock('@sentry/browser', () => ({ const TestApp: React.FC = ({ children, ...props }) => { const [isError, setError] = React.useState(false); return ( - + { + setError(false); + if (props.onReset) { + props.onReset(err, stack); + } + }} + > {isError ? : children}