Skip to content

Commit

Permalink
feat(react): Add Error Boundary component (#2647)
Browse files Browse the repository at this point in the history
* feat(react): Add Error Boundary component
* test(react): Use @testing-library/react for tests
* ref(react): Change how name is calculated for Profiler
  • Loading branch information
AbhiPrasad committed Jun 8, 2020
1 parent 7a40f36 commit 47b654c
Show file tree
Hide file tree
Showing 11 changed files with 535 additions and 37 deletions.
2 changes: 1 addition & 1 deletion CHANGELOG.md
Expand Up @@ -4,7 +4,7 @@

- "You miss 100 percent of the chances you don't take. — Wayne Gretzky" — Michael Scott
- [react] feat: Add @sentry/react package (#2631)

- [react] feat: Add Error Boundary component (#2647)

## 5.17.0

Expand Down
2 changes: 2 additions & 0 deletions packages/react/README.md
Expand Up @@ -7,6 +7,8 @@

# Official Sentry SDK for ReactJS

Note this library is in active development and not ready for production usage.

## Links

- [Official SDK Docs](https://docs.sentry.io/quickstart/)
Expand Down
3 changes: 1 addition & 2 deletions packages/react/package.json
Expand Up @@ -27,16 +27,15 @@
"react-dom": "^16.0.0"
},
"devDependencies": {
"@testing-library/react": "^10.0.6",
"@types/hoist-non-react-statics": "^3.3.1",
"@types/react": "^16.9.35",
"@types/react-test-renderer": "^16.9.2",
"jest": "^24.7.1",
"npm-run-all": "^4.1.2",
"prettier": "^1.17.0",
"prettier-check": "^2.0.0",
"react": "^16.0.0",
"react-dom": "^16.0.0",
"react-test-renderer": "^16.13.1",
"rimraf": "^2.6.3",
"tslint": "^5.16.0",
"tslint-react": "^5.0.0",
Expand Down
115 changes: 115 additions & 0 deletions packages/react/src/errorboundary.tsx
@@ -0,0 +1,115 @@
import * as Sentry from '@sentry/browser';
import * as hoistNonReactStatic from 'hoist-non-react-statics';
import * as React from 'react';

export const UNKNOWN_COMPONENT = 'unknown';

export type FallbackRender = (fallback: {
error: Error | null;
componentStack: string | null;
resetError(): void;
}) => React.ReactNode;

export type ErrorBoundaryProps = {
showDialog?: boolean;
dialogOptions?: Sentry.ReportDialogOptions;
// tslint:disable-next-line: no-null-undefined-union
fallback?: React.ReactNode | FallbackRender;
onError?(error: Error, componentStack: string): void;
onMount?(): void;
onReset?(error: Error | null, componentStack: string | null): void;
onUnmount?(error: Error | null, componentStack: string | null): void;
};

type ErrorBoundaryState = {
componentStack: string | null;
error: Error | null;
};

const INITIAL_STATE = {
componentStack: null,
error: null,
};

class ErrorBoundary extends React.Component<ErrorBoundaryProps, ErrorBoundaryState> {
public state: ErrorBoundaryState = INITIAL_STATE;

public componentDidCatch(error: Error, { componentStack }: React.ErrorInfo): void {
Sentry.captureException(error, { contexts: { react: { componentStack } } });
const { onError, showDialog, dialogOptions } = this.props;
if (onError) {
onError(error, componentStack);
}
if (showDialog) {
Sentry.showReportDialog(dialogOptions);
}

// componentDidCatch is used over getDerivedStateFromError
// so that componentStack is accessible through state.
this.setState({ error, componentStack });
}

public componentDidMount(): void {
const { onMount } = this.props;
if (onMount) {
onMount();
}
}

public componentWillUnmount(): void {
const { error, componentStack } = this.state;
const { onUnmount } = this.props;
if (onUnmount) {
onUnmount(error, componentStack);
}
}

public resetErrorBoundary = () => {
const { onReset } = this.props;
if (onReset) {
onReset(this.state.error, this.state.componentStack);
}
this.setState(INITIAL_STATE);
};

public render(): React.ReactNode {
const { fallback } = this.props;
const { error, componentStack } = this.state;

if (error) {
if (React.isValidElement(fallback)) {
return fallback;
}
if (typeof fallback === 'function') {
return fallback({ error, componentStack, resetError: this.resetErrorBoundary }) as FallbackRender;
}

// Fail gracefully if no fallback provided
return null;
}

return this.props.children;
}
}

function withErrorBoundary<P extends object>(
WrappedComponent: React.ComponentType<P>,
errorBoundaryOptions: ErrorBoundaryProps,
): React.FC<P> {
const componentDisplayName = WrappedComponent.displayName || WrappedComponent.name || UNKNOWN_COMPONENT;

const Wrapped: React.FC<P> = (props: P) => (
<ErrorBoundary {...errorBoundaryOptions}>
<WrappedComponent {...props} />
</ErrorBoundary>
);

Wrapped.displayName = `errorBoundary(${componentDisplayName})`;

// Copy over static methods from Wrapped component to Profiler HOC
// See: https://reactjs.org/docs/higher-order-components.html#static-methods-must-be-copied-over
hoistNonReactStatic(Wrapped, WrappedComponent);
return Wrapped;
}

export { ErrorBoundary, withErrorBoundary };
1 change: 1 addition & 0 deletions packages/react/src/index.ts
@@ -1,3 +1,4 @@
export * from '@sentry/browser';

export { Profiler, withProfiler } from './profiler';
export { ErrorBoundary, withErrorBoundary } from './errorboundary';
23 changes: 9 additions & 14 deletions packages/react/src/profiler.tsx
Expand Up @@ -39,38 +39,33 @@ function afterNextFrame(callback: Function): void {
timeout = window.setTimeout(done, 100);
}

const getInitActivity = (componentDisplayName: string): number | null => {
const getInitActivity = (name: string): number | null => {
const tracingIntegration = getCurrentHub().getIntegration(TRACING_GETTER);

if (tracingIntegration !== null) {
// tslint:disable-next-line:no-unsafe-any
const activity = (tracingIntegration as any).constructor.pushActivity(componentDisplayName, {
description: `<${componentDisplayName}>`,
return (tracingIntegration as any).constructor.pushActivity(name, {
description: `<${name}>`,
op: 'react',
});

// tslint:disable-next-line: no-unsafe-any
return activity;
}

logger.warn(
`Unable to profile component ${componentDisplayName} due to invalid Tracing Integration. Please make sure to setup the Tracing integration.`,
`Unable to profile component ${name} due to invalid Tracing Integration. Please make sure to setup the Tracing integration.`,
);
return null;
};

interface ProfilerProps {
componentDisplayName?: string;
}
export type ProfilerProps = {
name: string;
};

class Profiler extends React.Component<ProfilerProps> {
public activity: number | null;
public constructor(props: ProfilerProps) {
super(props);

const { componentDisplayName = UNKNOWN_COMPONENT } = this.props;

this.activity = getInitActivity(componentDisplayName);
this.activity = getInitActivity(this.props.name);
}

public componentDidMount(): void {
Expand Down Expand Up @@ -103,7 +98,7 @@ function withProfiler<P extends object>(WrappedComponent: React.ComponentType<P>
const componentDisplayName = WrappedComponent.displayName || WrappedComponent.name || UNKNOWN_COMPONENT;

const Wrapped: React.FC<P> = (props: P) => (
<Profiler componentDisplayName={componentDisplayName}>
<Profiler name={componentDisplayName}>
<WrappedComponent {...props} />
</Profiler>
);
Expand Down

0 comments on commit 47b654c

Please sign in to comment.