diff --git a/packages/react/src/__tests__/index-test.js b/packages/react/src/__tests__/index-test.js index ffa0083ae740..d2f1062ce83a 100644 --- a/packages/react/src/__tests__/index-test.js +++ b/packages/react/src/__tests__/index-test.js @@ -42,6 +42,8 @@ describe('Carbon Components React', () => { "DatePickerSkeleton", "Dropdown", "DropdownSkeleton", + "ErrorBoundary", + "ErrorBoundaryContext", "ExpandableTile", "FileUploader", "FileUploaderButton", diff --git a/packages/react/src/components/ErrorBoundary/ErrorBoundary-story.js b/packages/react/src/components/ErrorBoundary/ErrorBoundary-story.js new file mode 100644 index 000000000000..01361833d2d9 --- /dev/null +++ b/packages/react/src/components/ErrorBoundary/ErrorBoundary-story.js @@ -0,0 +1,82 @@ +/** + * Copyright IBM Corp. 2016, 2018 + * + * This source code is licensed under the Apache-2.0 license found in the + * LICENSE file in the root directory of this source tree. + */ + +import React, { useState } from 'react'; +import { storiesOf } from '@storybook/react'; +import { action } from '@storybook/addon-actions'; +import { ErrorBoundary, ErrorBoundaryContext } from './'; +import Button from '../Button'; + +storiesOf('ErrorBoundary', module) + .add('default', () => { + function DemoComponent() { + const [shouldThrowError, setShouldThrowError] = useState(false); + + function onClick() { + setShouldThrowError(!shouldThrowError); + } + + return ( + <> + +
+ }> + + +
+ + ); + } + + function Fallback() { + return 'Whoops'; + } + + function ThrowError({ shouldThrowError }) { + if (shouldThrowError) { + throw new Error('Component threw error'); + } + + return 'Successfully rendered'; + } + + return ; + }) + .add('with custom context', () => { + function DemoComponent() { + const [shouldThrowError, setShouldThrowError] = useState(false); + + function onClick() { + setShouldThrowError(!shouldThrowError); + } + + return ( + + +
+ }> + + +
+
+ ); + } + + function Fallback() { + return 'Whoops'; + } + + function ThrowError({ shouldThrowError }) { + if (shouldThrowError) { + throw new Error('Component threw error'); + } + + return 'Successfully rendered'; + } + + return ; + }); diff --git a/packages/react/src/components/ErrorBoundary/ErrorBoundary.js b/packages/react/src/components/ErrorBoundary/ErrorBoundary.js new file mode 100644 index 000000000000..339402a05470 --- /dev/null +++ b/packages/react/src/components/ErrorBoundary/ErrorBoundary.js @@ -0,0 +1,63 @@ +/** + * Copyright IBM Corp. 2016, 2018 + * + * This source code is licensed under the Apache-2.0 license found in the + * LICENSE file in the root directory of this source tree. + */ + +import React from 'react'; +import PropTypes from 'prop-types'; +import { ErrorBoundaryContext } from './ErrorBoundaryContext'; + +/** + * React introduced additional lifecycle methods in v16 for capturing errors + * that occur in a specific sub-tree of components. This component helps to + * consolidate some of the duplication that occurs when using these lifecycle + * methods across a codebase. In addition, it allows you to specify the fallback + * UI to display when an error occurs in the sub-tree through the `fallback` + * prop. + * + * This component roughly follows the React.js docs example code for these + * methods. In addition, it takes advantage of an `ErrorBoundaryContext` so that + * consumers can specify their own logic for logging errors. For example, + * reporting an error in the UI to an external service for every `ErrorBoundary` + * used. + * + * Reference: + * https://reactjs.org/docs/error-boundaries.html#introducing-error-boundaries + */ +export default class ErrorBoundary extends React.Component { + static propTypes = { + children: PropTypes.node, + fallback: PropTypes.node, + }; + + static contextType = ErrorBoundaryContext; + + static getDerivedStateFromError() { + return { + hasError: true, + }; + } + + state = { + hasError: false, + }; + + componentDidCatch(error, info) { + this.context.log(error, info); + } + + componentDidUpdate(prevProps) { + if (prevProps.children !== this.props.children) { + this.setState({ hasError: false }); + } + } + + render() { + if (this.state.hasError) { + return this.props.fallback; + } + return this.props.children; + } +} diff --git a/packages/react/src/components/ErrorBoundary/ErrorBoundaryContext.js b/packages/react/src/components/ErrorBoundary/ErrorBoundaryContext.js new file mode 100644 index 000000000000..04977f157cac --- /dev/null +++ b/packages/react/src/components/ErrorBoundary/ErrorBoundaryContext.js @@ -0,0 +1,14 @@ +/** + * Copyright IBM Corp. 2016, 2018 + * + * This source code is licensed under the Apache-2.0 license found in the + * LICENSE file in the root directory of this source tree. + */ + +import { createContext } from 'react'; + +export const ErrorBoundaryContext = createContext({ + log(error, info) { + console.log(info.componentStack); + }, +}); diff --git a/packages/react/src/components/ErrorBoundary/__tests__/ErrorBoundary-test.js b/packages/react/src/components/ErrorBoundary/__tests__/ErrorBoundary-test.js new file mode 100644 index 000000000000..cddd95a3247f --- /dev/null +++ b/packages/react/src/components/ErrorBoundary/__tests__/ErrorBoundary-test.js @@ -0,0 +1,123 @@ +/** + * Copyright IBM Corp. 2016, 2018 + * + * This source code is licensed under the Apache-2.0 license found in the + * LICENSE file in the root directory of this source tree. + */ + +import { render, cleanup } from '@carbon/test-utils/react'; +import React, { useState } from 'react'; +import { ErrorBoundary, ErrorBoundaryContext } from '../'; + +describe('ErrorBoundary', () => { + afterEach(cleanup); + + it('should render children and not a fallback if no error is caught', () => { + function MockComponent() { + return mock; + } + + function MockFallback() { + return mock; + } + + const { container } = render( + }> + + + ); + + const component = container.querySelector('[data-test-id="mock"]'); + const fallback = container.querySelector('[data-test-id="fallback"]'); + + expect(component).toBeDefined(); + expect(fallback).toBe(null); + }); + + it('should render a fallback if an error is caught', () => { + console.error = jest.spyOn(console, 'error').mockImplementation(() => {}); + + function MockComponent() { + throw new Error('test error'); + } + + function MockFallback() { + return mock; + } + + const log = jest.fn(); + + const { container } = render( + + }> + + + + ); + + const component = container.querySelector('[data-test-id="mock"]'); + const fallback = container.querySelector('[data-test-id="fallback"]'); + + expect(component).toBe(null); + expect(fallback).toBeDefined(); + expect(console.error).toHaveBeenCalled(); + expect(log).toHaveBeenCalled(); + + console.error.mockRestore(); + }); + + it('should reset from fallback if children have changed', () => { + console.error = jest.spyOn(console, 'error').mockImplementation(() => {}); + + let content = null; + let fallback = null; + let button = null; + + function ThrowError({ shouldThrowError }) { + if (shouldThrowError) { + throw new Error('test error'); + } + return (content = element)}>mock; + } + + function MockFallback() { + return (fallback = element)}>mock; + } + + function MockTest() { + const [shouldThrow, setShouldThrow] = useState(false); + + function onClick() { + setShouldThrow(!shouldThrow); + } + + return ( + + + }> + + + + ); + } + + render(); + + expect(content).toBeDefined(); + expect(fallback).toBe(null); + + button.click(); + + expect(content).toBe(null); + expect(fallback).toBeDefined(); + + button.click(); + + expect(content).toBeDefined(); + expect(fallback).toBe(null); + + console.error.mockRestore(); + }); +}); diff --git a/packages/react/src/components/ErrorBoundary/index.js b/packages/react/src/components/ErrorBoundary/index.js new file mode 100644 index 000000000000..cbba37bd349e --- /dev/null +++ b/packages/react/src/components/ErrorBoundary/index.js @@ -0,0 +1,9 @@ +/** + * Copyright IBM Corp. 2016, 2018 + * + * This source code is licensed under the Apache-2.0 license found in the + * LICENSE file in the root directory of this source tree. + */ + +export { default as ErrorBoundary } from './ErrorBoundary'; +export { ErrorBoundaryContext } from './ErrorBoundaryContext'; diff --git a/packages/react/src/index.js b/packages/react/src/index.js index cc2d274d0d74..c040649f2d61 100644 --- a/packages/react/src/index.js +++ b/packages/react/src/index.js @@ -47,6 +47,10 @@ export { export DatePicker from './components/DatePicker'; export DatePickerInput from './components/DatePickerInput'; export Dropdown from './components/Dropdown'; +export { + ErrorBoundary, + ErrorBoundaryContext, +} from './components/ErrorBoundary'; export FileUploader, { Filename, FileUploaderButton,