diff --git a/packages/react-dom/src/__tests__/ReactErrorBoundaries-test.internal.js b/packages/react-dom/src/__tests__/ReactErrorBoundaries-test.internal.js index 316d5caa9acb..f85fc1fa13ea 100644 --- a/packages/react-dom/src/__tests__/ReactErrorBoundaries-test.internal.js +++ b/packages/react-dom/src/__tests__/ReactErrorBoundaries-test.internal.js @@ -288,9 +288,11 @@ describe('ReactErrorBoundaries', () => { componentWillUnmount() { log.push('BrokenComponentWillMountErrorBoundary componentWillUnmount'); } - componentDidCatch(error) { - log.push('BrokenComponentWillMountErrorBoundary componentDidCatch'); - this.setState({error}); + static getDerivedStateFromError(error) { + log.push( + 'BrokenComponentWillMountErrorBoundary static getDerivedStateFromError', + ); + return {error}; } }; @@ -318,9 +320,11 @@ describe('ReactErrorBoundaries', () => { componentWillUnmount() { log.push('BrokenComponentDidMountErrorBoundary componentWillUnmount'); } - componentDidCatch(error) { - log.push('BrokenComponentDidMountErrorBoundary componentDidCatch'); - this.setState({error}); + static getDerivedStateFromError(error) { + log.push( + 'BrokenComponentDidMountErrorBoundary static getDerivedStateFromError', + ); + return {error}; } }; @@ -347,9 +351,9 @@ describe('ReactErrorBoundaries', () => { componentWillUnmount() { log.push('BrokenRenderErrorBoundary componentWillUnmount'); } - componentDidCatch(error) { - log.push('BrokenRenderErrorBoundary componentDidCatch'); - this.setState({error}); + static getDerivedStateFromError(error) { + log.push('BrokenRenderErrorBoundary static getDerivedStateFromError'); + return {error}; } }; @@ -400,8 +404,8 @@ describe('ReactErrorBoundaries', () => { componentWillUnmount() { log.push('NoopErrorBoundary componentWillUnmount'); } - componentDidCatch() { - log.push('NoopErrorBoundary componentDidCatch'); + static getDerivedStateFromError() { + log.push('NoopErrorBoundary static getDerivedStateFromError'); } }; @@ -451,9 +455,9 @@ describe('ReactErrorBoundaries', () => { log.push(`${this.props.logName} render success`); return
{this.props.children}
; } - componentDidCatch(error) { - log.push(`${this.props.logName} componentDidCatch`); - this.setState({error}); + static getDerivedStateFromError(error) { + log.push('ErrorBoundary static getDerivedStateFromError'); + return {error}; } UNSAFE_componentWillMount() { log.push(`${this.props.logName} componentWillMount`); @@ -503,10 +507,10 @@ describe('ReactErrorBoundaries', () => { componentWillUnmount() { log.push('RetryErrorBoundary componentWillUnmount'); } - componentDidCatch(error) { - log.push('RetryErrorBoundary componentDidCatch [!]'); + static getDerivedStateFromError(error) { + log.push('RetryErrorBoundary static getDerivedStateFromError [!]'); // In Fiber, calling setState() (and failing) is treated as a rethrow. - this.setState({}); + return {}; } }; @@ -629,13 +633,11 @@ describe('ReactErrorBoundaries', () => { 'BrokenRender constructor', 'BrokenRender componentWillMount', 'BrokenRender render [!]', - // Fiber mounts with null children before capturing error - 'ErrorBoundary componentDidMount', // Catch and render an error message - 'ErrorBoundary componentDidCatch', - 'ErrorBoundary componentWillUpdate', + 'ErrorBoundary static getDerivedStateFromError', + 'ErrorBoundary componentWillMount', 'ErrorBoundary render error', - 'ErrorBoundary componentDidUpdate', + 'ErrorBoundary componentDidMount', ]); log.length = 0; @@ -657,13 +659,11 @@ describe('ReactErrorBoundaries', () => { 'ErrorBoundary componentWillMount', 'ErrorBoundary render success', 'BrokenConstructor constructor [!]', - // Fiber mounts with null children before capturing error - 'ErrorBoundary componentDidMount', // Catch and render an error message - 'ErrorBoundary componentDidCatch', - 'ErrorBoundary componentWillUpdate', + 'ErrorBoundary static getDerivedStateFromError', + 'ErrorBoundary componentWillMount', 'ErrorBoundary render error', - 'ErrorBoundary componentDidUpdate', + 'ErrorBoundary componentDidMount', ]); log.length = 0; @@ -686,11 +686,11 @@ describe('ReactErrorBoundaries', () => { 'ErrorBoundary render success', 'BrokenComponentWillMount constructor', 'BrokenComponentWillMount componentWillMount [!]', - 'ErrorBoundary componentDidMount', - 'ErrorBoundary componentDidCatch', - 'ErrorBoundary componentWillUpdate', + // Catch and render an error message + 'ErrorBoundary static getDerivedStateFromError', + 'ErrorBoundary componentWillMount', 'ErrorBoundary render error', - 'ErrorBoundary componentDidUpdate', + 'ErrorBoundary componentDidMount', ]); log.length = 0; @@ -769,15 +769,14 @@ describe('ReactErrorBoundaries', () => { 'BrokenRender constructor', 'BrokenRender componentWillMount', 'BrokenRender render [!]', - 'ErrorBoundary componentDidMount', - 'ErrorBoundary componentDidCatch', - 'ErrorBoundary componentWillUpdate', + 'ErrorBoundary static getDerivedStateFromError', + 'ErrorBoundary componentWillMount', 'ErrorBoundary render error', 'ErrorMessage constructor', 'ErrorMessage componentWillMount', 'ErrorMessage render', 'ErrorMessage componentDidMount', - 'ErrorBoundary componentDidUpdate', + 'ErrorBoundary componentDidMount', ]); log.length = 0; @@ -809,22 +808,18 @@ describe('ReactErrorBoundaries', () => { 'BrokenRender constructor', 'BrokenRender componentWillMount', 'BrokenRender render [!]', - // In Fiber, failed error boundaries render null before attempting to recover - 'RetryErrorBoundary componentDidMount', - 'RetryErrorBoundary componentDidCatch [!]', - 'ErrorBoundary componentDidMount', // Retry + 'RetryErrorBoundary static getDerivedStateFromError [!]', + 'RetryErrorBoundary componentWillMount', 'RetryErrorBoundary render', 'BrokenRender constructor', 'BrokenRender componentWillMount', 'BrokenRender render [!]', // This time, the error propagates to the higher boundary - 'RetryErrorBoundary componentWillUnmount', - 'ErrorBoundary componentDidCatch', - // Render the error - 'ErrorBoundary componentWillUpdate', + 'ErrorBoundary static getDerivedStateFromError', + 'ErrorBoundary componentWillMount', 'ErrorBoundary render error', - 'ErrorBoundary componentDidUpdate', + 'ErrorBoundary componentDidMount', ]); log.length = 0; @@ -848,11 +843,10 @@ describe('ReactErrorBoundaries', () => { 'BrokenComponentWillMountErrorBoundary constructor', 'BrokenComponentWillMountErrorBoundary componentWillMount [!]', // The error propagates to the higher boundary - 'ErrorBoundary componentDidMount', - 'ErrorBoundary componentDidCatch', - 'ErrorBoundary componentWillUpdate', + 'ErrorBoundary static getDerivedStateFromError', + 'ErrorBoundary componentWillMount', 'ErrorBoundary render error', - 'ErrorBoundary componentDidUpdate', + 'ErrorBoundary componentDidMount', ]); log.length = 0; @@ -881,21 +875,15 @@ describe('ReactErrorBoundaries', () => { 'BrokenRender constructor', 'BrokenRender componentWillMount', 'BrokenRender render [!]', - // The first error boundary catches the error - // It adjusts state but throws displaying the message - // Finish mounting with null children - 'BrokenRenderErrorBoundary componentDidMount', // Attempt to handle the error - 'BrokenRenderErrorBoundary componentDidCatch', - 'ErrorBoundary componentDidMount', + 'BrokenRenderErrorBoundary static getDerivedStateFromError', + 'BrokenRenderErrorBoundary componentWillMount', 'BrokenRenderErrorBoundary render error [!]', - // Boundary fails with new error, propagate to next boundary - 'BrokenRenderErrorBoundary componentWillUnmount', // Attempt to handle the error again - 'ErrorBoundary componentDidCatch', - 'ErrorBoundary componentWillUpdate', + 'ErrorBoundary static getDerivedStateFromError', + 'ErrorBoundary componentWillMount', 'ErrorBoundary render error', - 'ErrorBoundary componentDidUpdate', + 'ErrorBoundary componentDidMount', ]); log.length = 0; @@ -930,14 +918,11 @@ describe('ReactErrorBoundaries', () => { 'Normal constructor', 'Normal componentWillMount', 'Normal render', - // Finish mounting with null children - 'ErrorBoundary componentDidMount', // Handle the error - 'ErrorBoundary componentDidCatch', - // Render the error message - 'ErrorBoundary componentWillUpdate', + 'ErrorBoundary static getDerivedStateFromError', + 'ErrorBoundary componentWillMount', 'ErrorBoundary render error', - 'ErrorBoundary componentDidUpdate', + 'ErrorBoundary componentDidMount', ]); log.length = 0; @@ -969,16 +954,12 @@ describe('ReactErrorBoundaries', () => { 'BrokenRender constructor', 'BrokenRender componentWillMount', 'BrokenRender render [!]', - // Handle error: - // Finish mounting with null children - 'ErrorBoundary componentDidMount', // Handle the error - 'ErrorBoundary componentDidCatch', - // Render the error message - 'ErrorBoundary componentWillUpdate', + 'ErrorBoundary static getDerivedStateFromError', + 'ErrorBoundary componentWillMount', 'ErrorBoundary render error', 'Error message ref is set to [object HTMLDivElement]', - 'ErrorBoundary componentDidUpdate', + 'ErrorBoundary componentDidMount', ]); log.length = 0; @@ -1009,15 +990,11 @@ describe('ReactErrorBoundaries', () => { 'BrokenRender constructor', 'BrokenRender componentWillMount', 'BrokenRender render [!]', - // Handle error: - // Finish mounting with null children - 'ErrorBoundary componentDidMount', // Handle the error - 'ErrorBoundary componentDidCatch', - // Render the error message - 'ErrorBoundary componentWillUpdate', + 'ErrorBoundary static getDerivedStateFromError', + 'ErrorBoundary componentWillMount', 'ErrorBoundary render error', - 'ErrorBoundary componentDidUpdate', + 'ErrorBoundary componentDidMount', ]); expect(errorMessageRef.current.toString()).toEqual( '[object HTMLDivElement]', @@ -1058,7 +1035,6 @@ describe('ReactErrorBoundaries', () => { , container, ); - log.length = 0; ReactDOM.render( @@ -1082,14 +1058,12 @@ describe('ReactErrorBoundaries', () => { 'Normal2 render', // BrokenConstructor will abort rendering: 'BrokenConstructor constructor [!]', - // Finish updating with null children - 'Normal componentWillUnmount', - 'ErrorBoundary componentDidUpdate', // Handle the error - 'ErrorBoundary componentDidCatch', + 'ErrorBoundary static getDerivedStateFromError', // Render the error message 'ErrorBoundary componentWillUpdate', 'ErrorBoundary render error', + 'Normal componentWillUnmount', 'ErrorBoundary componentDidUpdate', ]); @@ -1131,14 +1105,12 @@ describe('ReactErrorBoundaries', () => { // BrokenComponentWillMount will abort rendering: 'BrokenComponentWillMount constructor', 'BrokenComponentWillMount componentWillMount [!]', - // Finish updating with null children - 'Normal componentWillUnmount', - 'ErrorBoundary componentDidUpdate', // Handle the error - 'ErrorBoundary componentDidCatch', + 'ErrorBoundary static getDerivedStateFromError', // Render the error message 'ErrorBoundary componentWillUpdate', 'ErrorBoundary render error', + 'Normal componentWillUnmount', 'ErrorBoundary componentDidUpdate', ]); @@ -1175,14 +1147,13 @@ describe('ReactErrorBoundaries', () => { 'Normal render', // BrokenComponentWillReceiveProps will abort rendering: 'BrokenComponentWillReceiveProps componentWillReceiveProps [!]', - // Finish updating with null children - 'Normal componentWillUnmount', - 'BrokenComponentWillReceiveProps componentWillUnmount', - 'ErrorBoundary componentDidUpdate', // Handle the error - 'ErrorBoundary componentDidCatch', + 'ErrorBoundary static getDerivedStateFromError', + // Render the error message 'ErrorBoundary componentWillUpdate', 'ErrorBoundary render error', + 'Normal componentWillUnmount', + 'BrokenComponentWillReceiveProps componentWillUnmount', 'ErrorBoundary componentDidUpdate', ]); @@ -1220,14 +1191,12 @@ describe('ReactErrorBoundaries', () => { // BrokenComponentWillUpdate will abort rendering: 'BrokenComponentWillUpdate componentWillReceiveProps', 'BrokenComponentWillUpdate componentWillUpdate [!]', - // Finish updating with null children - 'Normal componentWillUnmount', - 'BrokenComponentWillUpdate componentWillUnmount', - 'ErrorBoundary componentDidUpdate', // Handle the error - 'ErrorBoundary componentDidCatch', + 'ErrorBoundary static getDerivedStateFromError', 'ErrorBoundary componentWillUpdate', 'ErrorBoundary render error', + 'Normal componentWillUnmount', + 'BrokenComponentWillUpdate componentWillUnmount', 'ErrorBoundary componentDidUpdate', ]); @@ -1270,13 +1239,11 @@ describe('ReactErrorBoundaries', () => { 'BrokenRender constructor', 'BrokenRender componentWillMount', 'BrokenRender render [!]', - // Finish updating with null children - 'Normal componentWillUnmount', - 'ErrorBoundary componentDidUpdate', // Handle the error - 'ErrorBoundary componentDidCatch', + 'ErrorBoundary static getDerivedStateFromError', 'ErrorBoundary componentWillUpdate', 'ErrorBoundary render error', + 'Normal componentWillUnmount', 'ErrorBoundary componentDidUpdate', ]); @@ -1329,15 +1296,14 @@ describe('ReactErrorBoundaries', () => { 'BrokenRender constructor', 'BrokenRender componentWillMount', 'BrokenRender render [!]', - // Finish updating with null children - 'Child1 ref is set to null', - 'ErrorBoundary componentDidUpdate', // Handle the error - 'ErrorBoundary componentDidCatch', + 'ErrorBoundary static getDerivedStateFromError', 'ErrorBoundary componentWillUpdate', 'ErrorBoundary render error', - 'Error message ref is set to [object HTMLDivElement]', + // Update Child1 ref since Child1 has been unmounted // Child2 ref is never set because its mounting aborted + 'Child1 ref is set to null', + 'Error message ref is set to [object HTMLDivElement]', 'ErrorBoundary componentDidUpdate', ]); @@ -1383,15 +1349,15 @@ describe('ReactErrorBoundaries', () => { // The components have updated in this phase 'BrokenComponentWillUnmount componentDidUpdate', 'ErrorBoundary componentDidUpdate', - // Now that commit phase is done, Fiber unmounts the boundary's children - 'BrokenComponentWillUnmount componentWillUnmount [!]', - 'ErrorBoundary componentDidCatch', // The initial render was aborted, so // Fiber retries from the root. + 'ErrorBoundary static getDerivedStateFromError', 'ErrorBoundary componentWillUpdate', + 'ErrorBoundary render error', + 'BrokenComponentWillUnmount componentWillUnmount [!]', 'ErrorBoundary componentDidUpdate', // The second willUnmount error should be captured and logged, too. - 'ErrorBoundary componentDidCatch', + 'ErrorBoundary static getDerivedStateFromError', 'ErrorBoundary componentWillUpdate', // Render an error now (stack will do it later) 'ErrorBoundary render error', @@ -1444,16 +1410,15 @@ describe('ReactErrorBoundaries', () => { 'BrokenComponentWillUnmount componentDidUpdate', 'Normal componentDidUpdate', 'ErrorBoundary componentDidUpdate', - 'Normal componentWillUnmount', - 'BrokenComponentWillUnmount componentWillUnmount [!]', // Now that commit phase is done, Fiber handles errors - 'ErrorBoundary componentDidCatch', - // The initial render was aborted, so - // Fiber retries from the root. + 'ErrorBoundary static getDerivedStateFromError', 'ErrorBoundary componentWillUpdate', + 'ErrorBoundary render error', + 'Normal componentWillUnmount', + 'BrokenComponentWillUnmount componentWillUnmount [!]', 'ErrorBoundary componentDidUpdate', // The second willUnmount error should be captured and logged, too. - 'ErrorBoundary componentDidCatch', + 'ErrorBoundary static getDerivedStateFromError', 'ErrorBoundary componentWillUpdate', // Render an error now (stack will do it later) 'ErrorBoundary render error', @@ -1512,13 +1477,11 @@ describe('ReactErrorBoundaries', () => { 'InnerErrorBoundary render success', // Try unmounting child 'BrokenComponentWillUnmount componentWillUnmount [!]', - // Fiber proceeds with lifecycles despite errors - // Inner and outer boundaries have updated in this phase - 'InnerErrorBoundary componentDidUpdate', - 'OuterErrorBoundary componentDidUpdate', // Now that commit phase is done, Fiber handles errors // Only inner boundary receives the error: - 'InnerErrorBoundary componentDidCatch', + 'InnerErrorBoundary componentDidUpdate', + 'OuterErrorBoundary componentDidUpdate', + 'ErrorBoundary static getDerivedStateFromError', 'InnerErrorBoundary componentWillUpdate', // Render an error now 'InnerErrorBoundary render error', @@ -1723,7 +1686,7 @@ describe('ReactErrorBoundaries', () => { expect(log).toEqual([ 'Stateful render [!]', - 'ErrorBoundary componentDidCatch', + 'ErrorBoundary static getDerivedStateFromError', 'ErrorBoundary componentWillUpdate', 'ErrorBoundary render error', 'ErrorBoundary componentDidUpdate', @@ -1768,20 +1731,20 @@ describe('ReactErrorBoundaries', () => { 'BrokenComponentDidMount componentDidMount [!]', // Continue despite the error 'LastChild componentDidMount', - 'ErrorBoundary componentDidMount', // Now we are ready to handle the error + 'ErrorBoundary componentDidMount', + 'ErrorBoundary static getDerivedStateFromError', + 'ErrorBoundary componentWillUpdate', + 'ErrorBoundary render error', // Safely unmount every child 'BrokenComponentWillUnmount componentWillUnmount [!]', // Continue unmounting safely despite any errors 'Normal componentWillUnmount', 'BrokenComponentDidMount componentWillUnmount', 'LastChild componentWillUnmount', - // Handle the error - 'ErrorBoundary componentDidCatch', - 'ErrorBoundary componentWillUpdate', // The willUnmount error should be captured and logged, too. 'ErrorBoundary componentDidUpdate', - 'ErrorBoundary componentDidCatch', + 'ErrorBoundary static getDerivedStateFromError', 'ErrorBoundary componentWillUpdate', 'ErrorBoundary render error', // The update has finished @@ -1819,11 +1782,11 @@ describe('ReactErrorBoundaries', () => { // All lifecycles run 'BrokenComponentDidUpdate componentDidUpdate [!]', 'ErrorBoundary componentDidUpdate', - 'BrokenComponentDidUpdate componentWillUnmount', // Then, error is handled - 'ErrorBoundary componentDidCatch', + 'ErrorBoundary static getDerivedStateFromError', 'ErrorBoundary componentWillUpdate', 'ErrorBoundary render error', + 'BrokenComponentDidUpdate componentWillUnmount', 'ErrorBoundary componentDidUpdate', ]); @@ -1855,12 +1818,12 @@ describe('ReactErrorBoundaries', () => { 'BrokenComponentDidMountErrorBoundary componentDidMount [!]', // Fiber proceeds with the hooks 'ErrorBoundary componentDidMount', - 'BrokenComponentDidMountErrorBoundary componentWillUnmount', // The error propagates to the higher boundary - 'ErrorBoundary componentDidCatch', + 'ErrorBoundary static getDerivedStateFromError', // Fiber retries from the root 'ErrorBoundary componentWillUpdate', 'ErrorBoundary render error', + 'BrokenComponentDidMountErrorBoundary componentWillUnmount', 'ErrorBoundary componentDidUpdate', ]); @@ -1869,7 +1832,7 @@ describe('ReactErrorBoundaries', () => { expect(log).toEqual(['ErrorBoundary componentWillUnmount']); }); - it('calls componentDidCatch for each error that is captured', () => { + it('calls static getDerivedStateFromError for each error that is captured', () => { function renderUnmountError(error) { return
Caught an unmounting error: {error.message}.
; } @@ -1947,16 +1910,16 @@ describe('ReactErrorBoundaries', () => { 'OuterErrorBoundary componentDidUpdate', // After the commit phase, attempt to recover from any errors that // were captured - 'BrokenComponentDidUpdate componentWillUnmount', - 'BrokenComponentDidUpdate componentWillUnmount', - 'InnerUnmountBoundary componentDidCatch', - 'InnerUnmountBoundary componentDidCatch', - 'InnerUpdateBoundary componentDidCatch', - 'InnerUpdateBoundary componentDidCatch', + 'ErrorBoundary static getDerivedStateFromError', + 'ErrorBoundary static getDerivedStateFromError', 'InnerUnmountBoundary componentWillUpdate', 'InnerUnmountBoundary render error', + 'ErrorBoundary static getDerivedStateFromError', + 'ErrorBoundary static getDerivedStateFromError', 'InnerUpdateBoundary componentWillUpdate', 'InnerUpdateBoundary render error', + 'BrokenComponentDidUpdate componentWillUnmount', + 'BrokenComponentDidUpdate componentWillUnmount', 'InnerUnmountBoundary componentDidUpdate', 'InnerUpdateBoundary componentDidUpdate', ]); @@ -2003,16 +1966,18 @@ describe('ReactErrorBoundaries', () => { it('renders empty output if error boundary does not handle the error', () => { const container = document.createElement('div'); - ReactDOM.render( -
- Sibling - - - -
, - container, - ); - expect(container.firstChild.textContent).toBe('Sibling'); + expect(() => + ReactDOM.render( +
+ Sibling + + + +
, + container, + ), + ).toThrow('Hello'); + expect(container.innerHTML).toBe(''); expect(log).toEqual([ 'NoopErrorBoundary constructor', 'NoopErrorBoundary componentWillMount', @@ -2020,15 +1985,13 @@ describe('ReactErrorBoundaries', () => { 'BrokenRender constructor', 'BrokenRender componentWillMount', 'BrokenRender render [!]', - // In Fiber, noop error boundaries render null - 'NoopErrorBoundary componentDidMount', - 'NoopErrorBoundary componentDidCatch', - // Nothing happens. + // Noop error boundaries retry render (and fail again) + 'NoopErrorBoundary static getDerivedStateFromError', + 'NoopErrorBoundary render', + 'BrokenRender constructor', + 'BrokenRender componentWillMount', + 'BrokenRender render [!]', ]); - - log.length = 0; - ReactDOM.unmountComponentAtNode(container); - expect(log).toEqual(['NoopErrorBoundary componentWillUnmount']); }); it('passes first error when two errors happen in commit', () => { @@ -2121,4 +2084,69 @@ describe('ReactErrorBoundaries', () => { // Error should be the first thrown expect(caughtError.message).toBe('child sad'); }); + + it('should warn if an error boundary with only componentDidCatch does not update state', () => { + class InvalidErrorBoundary extends React.Component { + componentDidCatch(error, info) { + // This component does not define getDerivedStateFromError(). + // It also doesn't call setState(). + // So it would swallow errors (which is probably unintentional). + } + render() { + return this.props.children; + } + } + + const Throws = () => { + throw new Error('expected'); + }; + + const container = document.createElement('div'); + expect(() => { + ReactDOM.render( + + + , + container, + ); + }).toWarnDev( + 'InvalidErrorBoundary: Error boundaries should implement getDerivedStateFromError(). ' + + 'In that method, return a state update to display an error message or fallback UI.', + {withoutStack: true}, + ); + expect(container.textContent).toBe(''); + }); + + it('should call both componentDidCatch and getDerivedStateFromError if both exist on a component', () => { + let componentDidCatchError, getDerivedStateFromErrorError; + class ErrorBoundaryWithBothMethods extends React.Component { + state = {error: null}; + static getDerivedStateFromError(error) { + getDerivedStateFromErrorError = error; + return {error}; + } + componentDidCatch(error, info) { + componentDidCatchError = error; + } + render() { + return this.state.error ? 'ErrorBoundary' : this.props.children; + } + } + + const thrownError = new Error('expected'); + const Throws = () => { + throw thrownError; + }; + + const container = document.createElement('div'); + ReactDOM.render( + + + , + container, + ); + expect(container.textContent).toBe('ErrorBoundary'); + expect(componentDidCatchError).toBe(thrownError); + expect(getDerivedStateFromErrorError).toBe(thrownError); + }); }); diff --git a/packages/react-dom/src/__tests__/ReactLegacyErrorBoundaries-test.internal.js b/packages/react-dom/src/__tests__/ReactLegacyErrorBoundaries-test.internal.js new file mode 100644 index 000000000000..7cec1ac6e96f --- /dev/null +++ b/packages/react-dom/src/__tests__/ReactLegacyErrorBoundaries-test.internal.js @@ -0,0 +1,2130 @@ +/** + * Copyright (c) Facebook, Inc. and its affiliates. + * + * This source code is licensed under the MIT license found in the + * LICENSE file in the root directory of this source tree. + * + * @emails react-core + */ + +'use strict'; + +let PropTypes; +let React; +let ReactDOM; +let ReactFeatureFlags; + +// TODO: Refactor this test once componentDidCatch setState is deprecated. +describe('ReactLegacyErrorBoundaries', () => { + let log; + + let BrokenConstructor; + let BrokenComponentWillMount; + let BrokenComponentDidMount; + let BrokenComponentWillReceiveProps; + let BrokenComponentWillUpdate; + let BrokenComponentDidUpdate; + let BrokenComponentWillUnmount; + let BrokenRenderErrorBoundary; + let BrokenComponentWillMountErrorBoundary; + let BrokenComponentDidMountErrorBoundary; + let BrokenRender; + let ErrorBoundary; + let ErrorMessage; + let NoopErrorBoundary; + let RetryErrorBoundary; + let Normal; + + beforeEach(() => { + jest.resetModules(); + PropTypes = require('prop-types'); + ReactFeatureFlags = require('shared/ReactFeatureFlags'); + ReactFeatureFlags.replayFailedUnitOfWorkWithInvokeGuardedCallback = false; + ReactDOM = require('react-dom'); + React = require('react'); + + log = []; + + BrokenConstructor = class extends React.Component { + constructor(props) { + super(props); + log.push('BrokenConstructor constructor [!]'); + throw new Error('Hello'); + } + render() { + log.push('BrokenConstructor render'); + return
{this.props.children}
; + } + UNSAFE_componentWillMount() { + log.push('BrokenConstructor componentWillMount'); + } + componentDidMount() { + log.push('BrokenConstructor componentDidMount'); + } + UNSAFE_componentWillReceiveProps() { + log.push('BrokenConstructor componentWillReceiveProps'); + } + UNSAFE_componentWillUpdate() { + log.push('BrokenConstructor componentWillUpdate'); + } + componentDidUpdate() { + log.push('BrokenConstructor componentDidUpdate'); + } + componentWillUnmount() { + log.push('BrokenConstructor componentWillUnmount'); + } + }; + + BrokenComponentWillMount = class extends React.Component { + constructor(props) { + super(props); + log.push('BrokenComponentWillMount constructor'); + } + render() { + log.push('BrokenComponentWillMount render'); + return
{this.props.children}
; + } + UNSAFE_componentWillMount() { + log.push('BrokenComponentWillMount componentWillMount [!]'); + throw new Error('Hello'); + } + componentDidMount() { + log.push('BrokenComponentWillMount componentDidMount'); + } + UNSAFE_componentWillReceiveProps() { + log.push('BrokenComponentWillMount componentWillReceiveProps'); + } + UNSAFE_componentWillUpdate() { + log.push('BrokenComponentWillMount componentWillUpdate'); + } + componentDidUpdate() { + log.push('BrokenComponentWillMount componentDidUpdate'); + } + componentWillUnmount() { + log.push('BrokenComponentWillMount componentWillUnmount'); + } + }; + + BrokenComponentDidMount = class extends React.Component { + constructor(props) { + super(props); + log.push('BrokenComponentDidMount constructor'); + } + render() { + log.push('BrokenComponentDidMount render'); + return
{this.props.children}
; + } + UNSAFE_componentWillMount() { + log.push('BrokenComponentDidMount componentWillMount'); + } + componentDidMount() { + log.push('BrokenComponentDidMount componentDidMount [!]'); + throw new Error('Hello'); + } + UNSAFE_componentWillReceiveProps() { + log.push('BrokenComponentDidMount componentWillReceiveProps'); + } + UNSAFE_componentWillUpdate() { + log.push('BrokenComponentDidMount componentWillUpdate'); + } + componentDidUpdate() { + log.push('BrokenComponentDidMount componentDidUpdate'); + } + componentWillUnmount() { + log.push('BrokenComponentDidMount componentWillUnmount'); + } + }; + + BrokenComponentWillReceiveProps = class extends React.Component { + constructor(props) { + super(props); + log.push('BrokenComponentWillReceiveProps constructor'); + } + render() { + log.push('BrokenComponentWillReceiveProps render'); + return
{this.props.children}
; + } + UNSAFE_componentWillMount() { + log.push('BrokenComponentWillReceiveProps componentWillMount'); + } + componentDidMount() { + log.push('BrokenComponentWillReceiveProps componentDidMount'); + } + UNSAFE_componentWillReceiveProps() { + log.push( + 'BrokenComponentWillReceiveProps componentWillReceiveProps [!]', + ); + throw new Error('Hello'); + } + UNSAFE_componentWillUpdate() { + log.push('BrokenComponentWillReceiveProps componentWillUpdate'); + } + componentDidUpdate() { + log.push('BrokenComponentWillReceiveProps componentDidUpdate'); + } + componentWillUnmount() { + log.push('BrokenComponentWillReceiveProps componentWillUnmount'); + } + }; + + BrokenComponentWillUpdate = class extends React.Component { + constructor(props) { + super(props); + log.push('BrokenComponentWillUpdate constructor'); + } + render() { + log.push('BrokenComponentWillUpdate render'); + return
{this.props.children}
; + } + UNSAFE_componentWillMount() { + log.push('BrokenComponentWillUpdate componentWillMount'); + } + componentDidMount() { + log.push('BrokenComponentWillUpdate componentDidMount'); + } + UNSAFE_componentWillReceiveProps() { + log.push('BrokenComponentWillUpdate componentWillReceiveProps'); + } + UNSAFE_componentWillUpdate() { + log.push('BrokenComponentWillUpdate componentWillUpdate [!]'); + throw new Error('Hello'); + } + componentDidUpdate() { + log.push('BrokenComponentWillUpdate componentDidUpdate'); + } + componentWillUnmount() { + log.push('BrokenComponentWillUpdate componentWillUnmount'); + } + }; + + BrokenComponentDidUpdate = class extends React.Component { + static defaultProps = { + errorText: 'Hello', + }; + constructor(props) { + super(props); + log.push('BrokenComponentDidUpdate constructor'); + } + render() { + log.push('BrokenComponentDidUpdate render'); + return
{this.props.children}
; + } + UNSAFE_componentWillMount() { + log.push('BrokenComponentDidUpdate componentWillMount'); + } + componentDidMount() { + log.push('BrokenComponentDidUpdate componentDidMount'); + } + UNSAFE_componentWillReceiveProps() { + log.push('BrokenComponentDidUpdate componentWillReceiveProps'); + } + UNSAFE_componentWillUpdate() { + log.push('BrokenComponentDidUpdate componentWillUpdate'); + } + componentDidUpdate() { + log.push('BrokenComponentDidUpdate componentDidUpdate [!]'); + throw new Error(this.props.errorText); + } + componentWillUnmount() { + log.push('BrokenComponentDidUpdate componentWillUnmount'); + } + }; + + BrokenComponentWillUnmount = class extends React.Component { + static defaultProps = { + errorText: 'Hello', + }; + constructor(props) { + super(props); + log.push('BrokenComponentWillUnmount constructor'); + } + render() { + log.push('BrokenComponentWillUnmount render'); + return
{this.props.children}
; + } + UNSAFE_componentWillMount() { + log.push('BrokenComponentWillUnmount componentWillMount'); + } + componentDidMount() { + log.push('BrokenComponentWillUnmount componentDidMount'); + } + UNSAFE_componentWillReceiveProps() { + log.push('BrokenComponentWillUnmount componentWillReceiveProps'); + } + UNSAFE_componentWillUpdate() { + log.push('BrokenComponentWillUnmount componentWillUpdate'); + } + componentDidUpdate() { + log.push('BrokenComponentWillUnmount componentDidUpdate'); + } + componentWillUnmount() { + log.push('BrokenComponentWillUnmount componentWillUnmount [!]'); + throw new Error(this.props.errorText); + } + }; + + BrokenComponentWillMountErrorBoundary = class extends React.Component { + constructor(props) { + super(props); + this.state = {error: null}; + log.push('BrokenComponentWillMountErrorBoundary constructor'); + } + render() { + if (this.state.error) { + log.push('BrokenComponentWillMountErrorBoundary render error'); + return
Caught an error: {this.state.error.message}.
; + } + log.push('BrokenComponentWillMountErrorBoundary render success'); + return
{this.props.children}
; + } + UNSAFE_componentWillMount() { + log.push( + 'BrokenComponentWillMountErrorBoundary componentWillMount [!]', + ); + throw new Error('Hello'); + } + componentDidMount() { + log.push('BrokenComponentWillMountErrorBoundary componentDidMount'); + } + componentWillUnmount() { + log.push('BrokenComponentWillMountErrorBoundary componentWillUnmount'); + } + componentDidCatch(error) { + log.push('BrokenComponentWillMountErrorBoundary componentDidCatch'); + this.setState({error}); + } + }; + + BrokenComponentDidMountErrorBoundary = class extends React.Component { + constructor(props) { + super(props); + this.state = {error: null}; + log.push('BrokenComponentDidMountErrorBoundary constructor'); + } + render() { + if (this.state.error) { + log.push('BrokenComponentDidMountErrorBoundary render error'); + return
Caught an error: {this.state.error.message}.
; + } + log.push('BrokenComponentDidMountErrorBoundary render success'); + return
{this.props.children}
; + } + UNSAFE_componentWillMount() { + log.push('BrokenComponentDidMountErrorBoundary componentWillMount'); + } + componentDidMount() { + log.push('BrokenComponentDidMountErrorBoundary componentDidMount [!]'); + throw new Error('Hello'); + } + componentWillUnmount() { + log.push('BrokenComponentDidMountErrorBoundary componentWillUnmount'); + } + componentDidCatch(error) { + log.push('BrokenComponentDidMountErrorBoundary componentDidCatch'); + this.setState({error}); + } + }; + + BrokenRenderErrorBoundary = class extends React.Component { + constructor(props) { + super(props); + this.state = {error: null}; + log.push('BrokenRenderErrorBoundary constructor'); + } + render() { + if (this.state.error) { + log.push('BrokenRenderErrorBoundary render error [!]'); + throw new Error('Hello'); + } + log.push('BrokenRenderErrorBoundary render success'); + return
{this.props.children}
; + } + UNSAFE_componentWillMount() { + log.push('BrokenRenderErrorBoundary componentWillMount'); + } + componentDidMount() { + log.push('BrokenRenderErrorBoundary componentDidMount'); + } + componentWillUnmount() { + log.push('BrokenRenderErrorBoundary componentWillUnmount'); + } + componentDidCatch(error) { + log.push('BrokenRenderErrorBoundary componentDidCatch'); + this.setState({error}); + } + }; + + BrokenRender = class extends React.Component { + constructor(props) { + super(props); + log.push('BrokenRender constructor'); + } + render() { + log.push('BrokenRender render [!]'); + throw new Error('Hello'); + } + UNSAFE_componentWillMount() { + log.push('BrokenRender componentWillMount'); + } + componentDidMount() { + log.push('BrokenRender componentDidMount'); + } + UNSAFE_componentWillReceiveProps() { + log.push('BrokenRender componentWillReceiveProps'); + } + UNSAFE_componentWillUpdate() { + log.push('BrokenRender componentWillUpdate'); + } + componentDidUpdate() { + log.push('BrokenRender componentDidUpdate'); + } + componentWillUnmount() { + log.push('BrokenRender componentWillUnmount'); + } + }; + + NoopErrorBoundary = class extends React.Component { + constructor(props) { + super(props); + log.push('NoopErrorBoundary constructor'); + } + render() { + log.push('NoopErrorBoundary render'); + return ; + } + UNSAFE_componentWillMount() { + log.push('NoopErrorBoundary componentWillMount'); + } + componentDidMount() { + log.push('NoopErrorBoundary componentDidMount'); + } + componentWillUnmount() { + log.push('NoopErrorBoundary componentWillUnmount'); + } + componentDidCatch() { + log.push('NoopErrorBoundary componentDidCatch'); + } + }; + + Normal = class extends React.Component { + static defaultProps = { + logName: 'Normal', + }; + constructor(props) { + super(props); + log.push(`${this.props.logName} constructor`); + } + render() { + log.push(`${this.props.logName} render`); + return
{this.props.children}
; + } + UNSAFE_componentWillMount() { + log.push(`${this.props.logName} componentWillMount`); + } + componentDidMount() { + log.push(`${this.props.logName} componentDidMount`); + } + UNSAFE_componentWillReceiveProps() { + log.push(`${this.props.logName} componentWillReceiveProps`); + } + UNSAFE_componentWillUpdate() { + log.push(`${this.props.logName} componentWillUpdate`); + } + componentDidUpdate() { + log.push(`${this.props.logName} componentDidUpdate`); + } + componentWillUnmount() { + log.push(`${this.props.logName} componentWillUnmount`); + } + }; + + ErrorBoundary = class extends React.Component { + constructor(props) { + super(props); + this.state = {error: null}; + log.push(`${this.props.logName} constructor`); + } + render() { + if (this.state.error && !this.props.forceRetry) { + log.push(`${this.props.logName} render error`); + return this.props.renderError(this.state.error, this.props); + } + log.push(`${this.props.logName} render success`); + return
{this.props.children}
; + } + componentDidCatch(error) { + log.push(`${this.props.logName} componentDidCatch`); + this.setState({error}); + } + UNSAFE_componentWillMount() { + log.push(`${this.props.logName} componentWillMount`); + } + componentDidMount() { + log.push(`${this.props.logName} componentDidMount`); + } + UNSAFE_componentWillReceiveProps() { + log.push(`${this.props.logName} componentWillReceiveProps`); + } + UNSAFE_componentWillUpdate() { + log.push(`${this.props.logName} componentWillUpdate`); + } + componentDidUpdate() { + log.push(`${this.props.logName} componentDidUpdate`); + } + componentWillUnmount() { + log.push(`${this.props.logName} componentWillUnmount`); + } + }; + ErrorBoundary.defaultProps = { + logName: 'ErrorBoundary', + renderError(error, props) { + return ( +
+ Caught an error: {error.message}. +
+ ); + }, + }; + + RetryErrorBoundary = class extends React.Component { + constructor(props) { + super(props); + log.push('RetryErrorBoundary constructor'); + } + render() { + log.push('RetryErrorBoundary render'); + return ; + } + UNSAFE_componentWillMount() { + log.push('RetryErrorBoundary componentWillMount'); + } + componentDidMount() { + log.push('RetryErrorBoundary componentDidMount'); + } + componentWillUnmount() { + log.push('RetryErrorBoundary componentWillUnmount'); + } + componentDidCatch(error) { + log.push('RetryErrorBoundary componentDidCatch [!]'); + // In Fiber, calling setState() (and failing) is treated as a rethrow. + this.setState({}); + } + }; + + ErrorMessage = class extends React.Component { + constructor(props) { + super(props); + log.push('ErrorMessage constructor'); + } + UNSAFE_componentWillMount() { + log.push('ErrorMessage componentWillMount'); + } + componentDidMount() { + log.push('ErrorMessage componentDidMount'); + } + componentWillUnmount() { + log.push('ErrorMessage componentWillUnmount'); + } + render() { + log.push('ErrorMessage render'); + return
Caught an error: {this.props.message}.
; + } + }; + }); + + it('does not swallow exceptions on mounting without boundaries', () => { + let container = document.createElement('div'); + expect(() => { + ReactDOM.render(, container); + }).toThrow('Hello'); + + container = document.createElement('div'); + expect(() => { + ReactDOM.render(, container); + }).toThrow('Hello'); + + container = document.createElement('div'); + expect(() => { + ReactDOM.render(, container); + }).toThrow('Hello'); + }); + + it('does not swallow exceptions on updating without boundaries', () => { + let container = document.createElement('div'); + ReactDOM.render(, container); + expect(() => { + ReactDOM.render(, container); + }).toThrow('Hello'); + + container = document.createElement('div'); + ReactDOM.render(, container); + expect(() => { + ReactDOM.render(, container); + }).toThrow('Hello'); + + container = document.createElement('div'); + ReactDOM.render(, container); + expect(() => { + ReactDOM.render(, container); + }).toThrow('Hello'); + }); + + it('does not swallow exceptions on unmounting without boundaries', () => { + const container = document.createElement('div'); + ReactDOM.render(, container); + expect(() => { + ReactDOM.unmountComponentAtNode(container); + }).toThrow('Hello'); + }); + + it('prevents errors from leaking into other roots', () => { + const container1 = document.createElement('div'); + const container2 = document.createElement('div'); + const container3 = document.createElement('div'); + + ReactDOM.render(Before 1, container1); + expect(() => { + ReactDOM.render(, container2); + }).toThrow('Hello'); + ReactDOM.render( + + + , + container3, + ); + expect(container1.firstChild.textContent).toBe('Before 1'); + expect(container2.firstChild).toBe(null); + expect(container3.firstChild.textContent).toBe('Caught an error: Hello.'); + + ReactDOM.render(After 1, container1); + ReactDOM.render(After 2, container2); + ReactDOM.render( + After 3, + container3, + ); + expect(container1.firstChild.textContent).toBe('After 1'); + expect(container2.firstChild.textContent).toBe('After 2'); + expect(container3.firstChild.textContent).toBe('After 3'); + + ReactDOM.unmountComponentAtNode(container1); + ReactDOM.unmountComponentAtNode(container2); + ReactDOM.unmountComponentAtNode(container3); + expect(container1.firstChild).toBe(null); + expect(container2.firstChild).toBe(null); + expect(container3.firstChild).toBe(null); + }); + + it('renders an error state if child throws in render', () => { + const container = document.createElement('div'); + ReactDOM.render( + + + , + container, + ); + expect(container.firstChild.textContent).toBe('Caught an error: Hello.'); + expect(log).toEqual([ + 'ErrorBoundary constructor', + 'ErrorBoundary componentWillMount', + 'ErrorBoundary render success', + 'BrokenRender constructor', + 'BrokenRender componentWillMount', + 'BrokenRender render [!]', + // Fiber mounts with null children before capturing error + 'ErrorBoundary componentDidMount', + // Catch and render an error message + 'ErrorBoundary componentDidCatch', + 'ErrorBoundary componentWillUpdate', + 'ErrorBoundary render error', + 'ErrorBoundary componentDidUpdate', + ]); + + log.length = 0; + ReactDOM.unmountComponentAtNode(container); + expect(log).toEqual(['ErrorBoundary componentWillUnmount']); + }); + + it('renders an error state if child throws in constructor', () => { + const container = document.createElement('div'); + ReactDOM.render( + + + , + container, + ); + expect(container.firstChild.textContent).toBe('Caught an error: Hello.'); + expect(log).toEqual([ + 'ErrorBoundary constructor', + 'ErrorBoundary componentWillMount', + 'ErrorBoundary render success', + 'BrokenConstructor constructor [!]', + // Fiber mounts with null children before capturing error + 'ErrorBoundary componentDidMount', + // Catch and render an error message + 'ErrorBoundary componentDidCatch', + 'ErrorBoundary componentWillUpdate', + 'ErrorBoundary render error', + 'ErrorBoundary componentDidUpdate', + ]); + + log.length = 0; + ReactDOM.unmountComponentAtNode(container); + expect(log).toEqual(['ErrorBoundary componentWillUnmount']); + }); + + it('renders an error state if child throws in componentWillMount', () => { + const container = document.createElement('div'); + ReactDOM.render( + + + , + container, + ); + expect(container.firstChild.textContent).toBe('Caught an error: Hello.'); + expect(log).toEqual([ + 'ErrorBoundary constructor', + 'ErrorBoundary componentWillMount', + 'ErrorBoundary render success', + 'BrokenComponentWillMount constructor', + 'BrokenComponentWillMount componentWillMount [!]', + 'ErrorBoundary componentDidMount', + 'ErrorBoundary componentDidCatch', + 'ErrorBoundary componentWillUpdate', + 'ErrorBoundary render error', + 'ErrorBoundary componentDidUpdate', + ]); + + log.length = 0; + ReactDOM.unmountComponentAtNode(container); + expect(log).toEqual(['ErrorBoundary componentWillUnmount']); + }); + + it('renders an error state if context provider throws in componentWillMount', () => { + class BrokenComponentWillMountWithContext extends React.Component { + static childContextTypes = {foo: PropTypes.number}; + getChildContext() { + return {foo: 42}; + } + render() { + return
{this.props.children}
; + } + UNSAFE_componentWillMount() { + throw new Error('Hello'); + } + } + + const container = document.createElement('div'); + ReactDOM.render( + + + , + container, + ); + expect(container.firstChild.textContent).toBe('Caught an error: Hello.'); + }); + + it('renders an error state if module-style context provider throws in componentWillMount', () => { + function BrokenComponentWillMountWithContext() { + return { + getChildContext() { + return {foo: 42}; + }, + render() { + return
{this.props.children}
; + }, + UNSAFE_componentWillMount() { + throw new Error('Hello'); + }, + }; + } + BrokenComponentWillMountWithContext.childContextTypes = { + foo: PropTypes.number, + }; + + const container = document.createElement('div'); + ReactDOM.render( + + + , + container, + ); + expect(container.firstChild.textContent).toBe('Caught an error: Hello.'); + }); + + it('mounts the error message if mounting fails', () => { + function renderError(error) { + return ; + } + + const container = document.createElement('div'); + ReactDOM.render( + + + , + container, + ); + expect(log).toEqual([ + 'ErrorBoundary constructor', + 'ErrorBoundary componentWillMount', + 'ErrorBoundary render success', + 'BrokenRender constructor', + 'BrokenRender componentWillMount', + 'BrokenRender render [!]', + 'ErrorBoundary componentDidMount', + 'ErrorBoundary componentDidCatch', + 'ErrorBoundary componentWillUpdate', + 'ErrorBoundary render error', + 'ErrorMessage constructor', + 'ErrorMessage componentWillMount', + 'ErrorMessage render', + 'ErrorMessage componentDidMount', + 'ErrorBoundary componentDidUpdate', + ]); + + log.length = 0; + ReactDOM.unmountComponentAtNode(container); + expect(log).toEqual([ + 'ErrorBoundary componentWillUnmount', + 'ErrorMessage componentWillUnmount', + ]); + }); + + it('propagates errors on retry on mounting', () => { + const container = document.createElement('div'); + ReactDOM.render( + + + + + , + container, + ); + expect(container.firstChild.textContent).toBe('Caught an error: Hello.'); + expect(log).toEqual([ + 'ErrorBoundary constructor', + 'ErrorBoundary componentWillMount', + 'ErrorBoundary render success', + 'RetryErrorBoundary constructor', + 'RetryErrorBoundary componentWillMount', + 'RetryErrorBoundary render', + 'BrokenRender constructor', + 'BrokenRender componentWillMount', + 'BrokenRender render [!]', + // In Fiber, failed error boundaries render null before attempting to recover + 'RetryErrorBoundary componentDidMount', + 'RetryErrorBoundary componentDidCatch [!]', + 'ErrorBoundary componentDidMount', + // Retry + 'RetryErrorBoundary render', + 'BrokenRender constructor', + 'BrokenRender componentWillMount', + 'BrokenRender render [!]', + // This time, the error propagates to the higher boundary + 'RetryErrorBoundary componentWillUnmount', + 'ErrorBoundary componentDidCatch', + // Render the error + 'ErrorBoundary componentWillUpdate', + 'ErrorBoundary render error', + 'ErrorBoundary componentDidUpdate', + ]); + + log.length = 0; + ReactDOM.unmountComponentAtNode(container); + expect(log).toEqual(['ErrorBoundary componentWillUnmount']); + }); + + it('propagates errors inside boundary during componentWillMount', () => { + const container = document.createElement('div'); + ReactDOM.render( + + + , + container, + ); + expect(container.firstChild.textContent).toBe('Caught an error: Hello.'); + expect(log).toEqual([ + 'ErrorBoundary constructor', + 'ErrorBoundary componentWillMount', + 'ErrorBoundary render success', + 'BrokenComponentWillMountErrorBoundary constructor', + 'BrokenComponentWillMountErrorBoundary componentWillMount [!]', + // The error propagates to the higher boundary + 'ErrorBoundary componentDidMount', + 'ErrorBoundary componentDidCatch', + 'ErrorBoundary componentWillUpdate', + 'ErrorBoundary render error', + 'ErrorBoundary componentDidUpdate', + ]); + + log.length = 0; + ReactDOM.unmountComponentAtNode(container); + expect(log).toEqual(['ErrorBoundary componentWillUnmount']); + }); + + it('propagates errors inside boundary while rendering error state', () => { + const container = document.createElement('div'); + ReactDOM.render( + + + + + , + container, + ); + expect(container.firstChild.textContent).toBe('Caught an error: Hello.'); + expect(log).toEqual([ + 'ErrorBoundary constructor', + 'ErrorBoundary componentWillMount', + 'ErrorBoundary render success', + 'BrokenRenderErrorBoundary constructor', + 'BrokenRenderErrorBoundary componentWillMount', + 'BrokenRenderErrorBoundary render success', + 'BrokenRender constructor', + 'BrokenRender componentWillMount', + 'BrokenRender render [!]', + // The first error boundary catches the error + // It adjusts state but throws displaying the message + // Finish mounting with null children + 'BrokenRenderErrorBoundary componentDidMount', + // Attempt to handle the error + 'BrokenRenderErrorBoundary componentDidCatch', + 'ErrorBoundary componentDidMount', + 'BrokenRenderErrorBoundary render error [!]', + // Boundary fails with new error, propagate to next boundary + 'BrokenRenderErrorBoundary componentWillUnmount', + // Attempt to handle the error again + 'ErrorBoundary componentDidCatch', + 'ErrorBoundary componentWillUpdate', + 'ErrorBoundary render error', + 'ErrorBoundary componentDidUpdate', + ]); + + log.length = 0; + ReactDOM.unmountComponentAtNode(container); + expect(log).toEqual(['ErrorBoundary componentWillUnmount']); + }); + + it('does not call componentWillUnmount when aborting initial mount', () => { + const container = document.createElement('div'); + ReactDOM.render( + + + + + , + container, + ); + expect(container.firstChild.textContent).toBe('Caught an error: Hello.'); + expect(log).toEqual([ + 'ErrorBoundary constructor', + 'ErrorBoundary componentWillMount', + 'ErrorBoundary render success', + // Render first child + 'Normal constructor', + 'Normal componentWillMount', + 'Normal render', + // Render second child (it throws) + 'BrokenRender constructor', + 'BrokenRender componentWillMount', + 'BrokenRender render [!]', + // Render third child, even though an earlier sibling threw. + 'Normal constructor', + 'Normal componentWillMount', + 'Normal render', + // Finish mounting with null children + 'ErrorBoundary componentDidMount', + // Handle the error + 'ErrorBoundary componentDidCatch', + // Render the error message + 'ErrorBoundary componentWillUpdate', + 'ErrorBoundary render error', + 'ErrorBoundary componentDidUpdate', + ]); + + log.length = 0; + ReactDOM.unmountComponentAtNode(container); + expect(log).toEqual(['ErrorBoundary componentWillUnmount']); + }); + + it('resets callback refs if mounting aborts', () => { + function childRef(x) { + log.push('Child ref is set to ' + x); + } + function errorMessageRef(x) { + log.push('Error message ref is set to ' + x); + } + + const container = document.createElement('div'); + ReactDOM.render( + +
+ + , + container, + ); + expect(container.textContent).toBe('Caught an error: Hello.'); + expect(log).toEqual([ + 'ErrorBoundary constructor', + 'ErrorBoundary componentWillMount', + 'ErrorBoundary render success', + 'BrokenRender constructor', + 'BrokenRender componentWillMount', + 'BrokenRender render [!]', + // Handle error: + // Finish mounting with null children + 'ErrorBoundary componentDidMount', + // Handle the error + 'ErrorBoundary componentDidCatch', + // Render the error message + 'ErrorBoundary componentWillUpdate', + 'ErrorBoundary render error', + 'Error message ref is set to [object HTMLDivElement]', + 'ErrorBoundary componentDidUpdate', + ]); + + log.length = 0; + ReactDOM.unmountComponentAtNode(container); + expect(log).toEqual([ + 'ErrorBoundary componentWillUnmount', + 'Error message ref is set to null', + ]); + }); + + it('resets object refs if mounting aborts', () => { + let childRef = React.createRef(); + let errorMessageRef = React.createRef(); + + const container = document.createElement('div'); + ReactDOM.render( + +
+ + , + container, + ); + expect(container.textContent).toBe('Caught an error: Hello.'); + expect(log).toEqual([ + 'ErrorBoundary constructor', + 'ErrorBoundary componentWillMount', + 'ErrorBoundary render success', + 'BrokenRender constructor', + 'BrokenRender componentWillMount', + 'BrokenRender render [!]', + // Handle error: + // Finish mounting with null children + 'ErrorBoundary componentDidMount', + // Handle the error + 'ErrorBoundary componentDidCatch', + // Render the error message + 'ErrorBoundary componentWillUpdate', + 'ErrorBoundary render error', + 'ErrorBoundary componentDidUpdate', + ]); + expect(errorMessageRef.current.toString()).toEqual( + '[object HTMLDivElement]', + ); + + log.length = 0; + ReactDOM.unmountComponentAtNode(container); + expect(log).toEqual(['ErrorBoundary componentWillUnmount']); + expect(errorMessageRef.current).toEqual(null); + }); + + it('successfully mounts if no error occurs', () => { + const container = document.createElement('div'); + ReactDOM.render( + +
Mounted successfully.
+
, + container, + ); + expect(container.firstChild.textContent).toBe('Mounted successfully.'); + expect(log).toEqual([ + 'ErrorBoundary constructor', + 'ErrorBoundary componentWillMount', + 'ErrorBoundary render success', + 'ErrorBoundary componentDidMount', + ]); + + log.length = 0; + ReactDOM.unmountComponentAtNode(container); + expect(log).toEqual(['ErrorBoundary componentWillUnmount']); + }); + + it('catches if child throws in constructor during update', () => { + const container = document.createElement('div'); + ReactDOM.render( + + + , + container, + ); + + log.length = 0; + ReactDOM.render( + + + + + , + container, + ); + expect(container.textContent).toBe('Caught an error: Hello.'); + expect(log).toEqual([ + 'ErrorBoundary componentWillReceiveProps', + 'ErrorBoundary componentWillUpdate', + 'ErrorBoundary render success', + 'Normal componentWillReceiveProps', + 'Normal componentWillUpdate', + 'Normal render', + // Normal2 will attempt to mount: + 'Normal2 constructor', + 'Normal2 componentWillMount', + 'Normal2 render', + // BrokenConstructor will abort rendering: + 'BrokenConstructor constructor [!]', + // Finish updating with null children + 'Normal componentWillUnmount', + 'ErrorBoundary componentDidUpdate', + // Handle the error + 'ErrorBoundary componentDidCatch', + // Render the error message + 'ErrorBoundary componentWillUpdate', + 'ErrorBoundary render error', + 'ErrorBoundary componentDidUpdate', + ]); + + log.length = 0; + ReactDOM.unmountComponentAtNode(container); + expect(log).toEqual(['ErrorBoundary componentWillUnmount']); + }); + + it('catches if child throws in componentWillMount during update', () => { + const container = document.createElement('div'); + ReactDOM.render( + + + , + container, + ); + + log.length = 0; + ReactDOM.render( + + + + + , + container, + ); + expect(container.textContent).toBe('Caught an error: Hello.'); + expect(log).toEqual([ + 'ErrorBoundary componentWillReceiveProps', + 'ErrorBoundary componentWillUpdate', + 'ErrorBoundary render success', + 'Normal componentWillReceiveProps', + 'Normal componentWillUpdate', + 'Normal render', + // Normal2 will attempt to mount: + 'Normal2 constructor', + 'Normal2 componentWillMount', + 'Normal2 render', + // BrokenComponentWillMount will abort rendering: + 'BrokenComponentWillMount constructor', + 'BrokenComponentWillMount componentWillMount [!]', + // Finish updating with null children + 'Normal componentWillUnmount', + 'ErrorBoundary componentDidUpdate', + // Handle the error + 'ErrorBoundary componentDidCatch', + // Render the error message + 'ErrorBoundary componentWillUpdate', + 'ErrorBoundary render error', + 'ErrorBoundary componentDidUpdate', + ]); + + log.length = 0; + ReactDOM.unmountComponentAtNode(container); + expect(log).toEqual(['ErrorBoundary componentWillUnmount']); + }); + + it('catches if child throws in componentWillReceiveProps during update', () => { + const container = document.createElement('div'); + ReactDOM.render( + + + + , + container, + ); + + log.length = 0; + ReactDOM.render( + + + + , + container, + ); + expect(container.textContent).toBe('Caught an error: Hello.'); + expect(log).toEqual([ + 'ErrorBoundary componentWillReceiveProps', + 'ErrorBoundary componentWillUpdate', + 'ErrorBoundary render success', + 'Normal componentWillReceiveProps', + 'Normal componentWillUpdate', + 'Normal render', + // BrokenComponentWillReceiveProps will abort rendering: + 'BrokenComponentWillReceiveProps componentWillReceiveProps [!]', + // Finish updating with null children + 'Normal componentWillUnmount', + 'BrokenComponentWillReceiveProps componentWillUnmount', + 'ErrorBoundary componentDidUpdate', + // Handle the error + 'ErrorBoundary componentDidCatch', + 'ErrorBoundary componentWillUpdate', + 'ErrorBoundary render error', + 'ErrorBoundary componentDidUpdate', + ]); + + log.length = 0; + ReactDOM.unmountComponentAtNode(container); + expect(log).toEqual(['ErrorBoundary componentWillUnmount']); + }); + + it('catches if child throws in componentWillUpdate during update', () => { + const container = document.createElement('div'); + ReactDOM.render( + + + + , + container, + ); + + log.length = 0; + ReactDOM.render( + + + + , + container, + ); + expect(container.textContent).toBe('Caught an error: Hello.'); + expect(log).toEqual([ + 'ErrorBoundary componentWillReceiveProps', + 'ErrorBoundary componentWillUpdate', + 'ErrorBoundary render success', + 'Normal componentWillReceiveProps', + 'Normal componentWillUpdate', + 'Normal render', + // BrokenComponentWillUpdate will abort rendering: + 'BrokenComponentWillUpdate componentWillReceiveProps', + 'BrokenComponentWillUpdate componentWillUpdate [!]', + // Finish updating with null children + 'Normal componentWillUnmount', + 'BrokenComponentWillUpdate componentWillUnmount', + 'ErrorBoundary componentDidUpdate', + // Handle the error + 'ErrorBoundary componentDidCatch', + 'ErrorBoundary componentWillUpdate', + 'ErrorBoundary render error', + 'ErrorBoundary componentDidUpdate', + ]); + + log.length = 0; + ReactDOM.unmountComponentAtNode(container); + expect(log).toEqual(['ErrorBoundary componentWillUnmount']); + }); + + it('catches if child throws in render during update', () => { + const container = document.createElement('div'); + ReactDOM.render( + + + , + container, + ); + + log.length = 0; + ReactDOM.render( + + + + + , + container, + ); + expect(container.textContent).toBe('Caught an error: Hello.'); + expect(log).toEqual([ + 'ErrorBoundary componentWillReceiveProps', + 'ErrorBoundary componentWillUpdate', + 'ErrorBoundary render success', + 'Normal componentWillReceiveProps', + 'Normal componentWillUpdate', + 'Normal render', + // Normal2 will attempt to mount: + 'Normal2 constructor', + 'Normal2 componentWillMount', + 'Normal2 render', + // BrokenRender will abort rendering: + 'BrokenRender constructor', + 'BrokenRender componentWillMount', + 'BrokenRender render [!]', + // Finish updating with null children + 'Normal componentWillUnmount', + 'ErrorBoundary componentDidUpdate', + // Handle the error + 'ErrorBoundary componentDidCatch', + 'ErrorBoundary componentWillUpdate', + 'ErrorBoundary render error', + 'ErrorBoundary componentDidUpdate', + ]); + + log.length = 0; + ReactDOM.unmountComponentAtNode(container); + expect(log).toEqual(['ErrorBoundary componentWillUnmount']); + }); + + it('keeps refs up-to-date during updates', () => { + function child1Ref(x) { + log.push('Child1 ref is set to ' + x); + } + function child2Ref(x) { + log.push('Child2 ref is set to ' + x); + } + function errorMessageRef(x) { + log.push('Error message ref is set to ' + x); + } + + const container = document.createElement('div'); + ReactDOM.render( + +
+ , + container, + ); + expect(log).toEqual([ + 'ErrorBoundary constructor', + 'ErrorBoundary componentWillMount', + 'ErrorBoundary render success', + 'Child1 ref is set to [object HTMLDivElement]', + 'ErrorBoundary componentDidMount', + ]); + + log.length = 0; + ReactDOM.render( + +
+
+ + , + container, + ); + expect(container.textContent).toBe('Caught an error: Hello.'); + expect(log).toEqual([ + 'ErrorBoundary componentWillReceiveProps', + 'ErrorBoundary componentWillUpdate', + 'ErrorBoundary render success', + // BrokenRender will abort rendering: + 'BrokenRender constructor', + 'BrokenRender componentWillMount', + 'BrokenRender render [!]', + // Finish updating with null children + 'Child1 ref is set to null', + 'ErrorBoundary componentDidUpdate', + // Handle the error + 'ErrorBoundary componentDidCatch', + 'ErrorBoundary componentWillUpdate', + 'ErrorBoundary render error', + 'Error message ref is set to [object HTMLDivElement]', + // Child2 ref is never set because its mounting aborted + 'ErrorBoundary componentDidUpdate', + ]); + + log.length = 0; + ReactDOM.unmountComponentAtNode(container); + expect(log).toEqual([ + 'ErrorBoundary componentWillUnmount', + 'Error message ref is set to null', + ]); + }); + + it('recovers from componentWillUnmount errors on update', () => { + const container = document.createElement('div'); + ReactDOM.render( + + + + + , + container, + ); + + log.length = 0; + ReactDOM.render( + + + , + container, + ); + expect(container.textContent).toBe('Caught an error: Hello.'); + expect(log).toEqual([ + 'ErrorBoundary componentWillReceiveProps', + 'ErrorBoundary componentWillUpdate', + 'ErrorBoundary render success', + // Update existing child: + 'BrokenComponentWillUnmount componentWillReceiveProps', + 'BrokenComponentWillUnmount componentWillUpdate', + 'BrokenComponentWillUnmount render', + // Unmounting throws: + 'BrokenComponentWillUnmount componentWillUnmount [!]', + // Fiber proceeds with lifecycles despite errors + 'Normal componentWillUnmount', + // The components have updated in this phase + 'BrokenComponentWillUnmount componentDidUpdate', + 'ErrorBoundary componentDidUpdate', + // Now that commit phase is done, Fiber unmounts the boundary's children + 'BrokenComponentWillUnmount componentWillUnmount [!]', + 'ErrorBoundary componentDidCatch', + // The initial render was aborted, so + // Fiber retries from the root. + 'ErrorBoundary componentWillUpdate', + 'ErrorBoundary componentDidUpdate', + // The second willUnmount error should be captured and logged, too. + 'ErrorBoundary componentDidCatch', + 'ErrorBoundary componentWillUpdate', + // Render an error now (stack will do it later) + 'ErrorBoundary render error', + // Attempt to unmount previous child: + // Done + 'ErrorBoundary componentDidUpdate', + ]); + + log.length = 0; + ReactDOM.unmountComponentAtNode(container); + expect(log).toEqual(['ErrorBoundary componentWillUnmount']); + }); + + it('recovers from nested componentWillUnmount errors on update', () => { + const container = document.createElement('div'); + ReactDOM.render( + + + + + + , + container, + ); + + log.length = 0; + ReactDOM.render( + + + + + , + container, + ); + expect(container.textContent).toBe('Caught an error: Hello.'); + expect(log).toEqual([ + 'ErrorBoundary componentWillReceiveProps', + 'ErrorBoundary componentWillUpdate', + 'ErrorBoundary render success', + // Update existing children: + 'Normal componentWillReceiveProps', + 'Normal componentWillUpdate', + 'Normal render', + 'BrokenComponentWillUnmount componentWillReceiveProps', + 'BrokenComponentWillUnmount componentWillUpdate', + 'BrokenComponentWillUnmount render', + // Unmounting throws: + 'BrokenComponentWillUnmount componentWillUnmount [!]', + // Fiber proceeds with lifecycles despite errors + 'BrokenComponentWillUnmount componentDidUpdate', + 'Normal componentDidUpdate', + 'ErrorBoundary componentDidUpdate', + 'Normal componentWillUnmount', + 'BrokenComponentWillUnmount componentWillUnmount [!]', + // Now that commit phase is done, Fiber handles errors + 'ErrorBoundary componentDidCatch', + // The initial render was aborted, so + // Fiber retries from the root. + 'ErrorBoundary componentWillUpdate', + 'ErrorBoundary componentDidUpdate', + // The second willUnmount error should be captured and logged, too. + 'ErrorBoundary componentDidCatch', + 'ErrorBoundary componentWillUpdate', + // Render an error now (stack will do it later) + 'ErrorBoundary render error', + // Done + 'ErrorBoundary componentDidUpdate', + ]); + + log.length = 0; + ReactDOM.unmountComponentAtNode(container); + expect(log).toEqual(['ErrorBoundary componentWillUnmount']); + }); + + it('picks the right boundary when handling unmounting errors', () => { + function renderInnerError(error) { + return
Caught an inner error: {error.message}.
; + } + function renderOuterError(error) { + return
Caught an outer error: {error.message}.
; + } + + const container = document.createElement('div'); + ReactDOM.render( + + + + + , + container, + ); + + log.length = 0; + ReactDOM.render( + + + , + container, + ); + expect(container.textContent).toBe('Caught an inner error: Hello.'); + expect(log).toEqual([ + // Update outer boundary + 'OuterErrorBoundary componentWillReceiveProps', + 'OuterErrorBoundary componentWillUpdate', + 'OuterErrorBoundary render success', + // Update inner boundary + 'InnerErrorBoundary componentWillReceiveProps', + 'InnerErrorBoundary componentWillUpdate', + 'InnerErrorBoundary render success', + // Try unmounting child + 'BrokenComponentWillUnmount componentWillUnmount [!]', + // Fiber proceeds with lifecycles despite errors + // Inner and outer boundaries have updated in this phase + 'InnerErrorBoundary componentDidUpdate', + 'OuterErrorBoundary componentDidUpdate', + // Now that commit phase is done, Fiber handles errors + // Only inner boundary receives the error: + 'InnerErrorBoundary componentDidCatch', + 'InnerErrorBoundary componentWillUpdate', + // Render an error now + 'InnerErrorBoundary render error', + // In Fiber, this was a local update to the + // inner boundary so only its hook fires + 'InnerErrorBoundary componentDidUpdate', + ]); + + log.length = 0; + ReactDOM.unmountComponentAtNode(container); + expect(log).toEqual([ + 'OuterErrorBoundary componentWillUnmount', + 'InnerErrorBoundary componentWillUnmount', + ]); + }); + + it('can recover from error state', () => { + const container = document.createElement('div'); + ReactDOM.render( + + + , + container, + ); + + ReactDOM.render( + + + , + container, + ); + // Error boundary doesn't retry by itself: + expect(container.textContent).toBe('Caught an error: Hello.'); + + // Force the success path: + log.length = 0; + ReactDOM.render( + + + , + container, + ); + expect(container.textContent).not.toContain('Caught an error'); + expect(log).toEqual([ + 'ErrorBoundary componentWillReceiveProps', + 'ErrorBoundary componentWillUpdate', + 'ErrorBoundary render success', + // Mount children: + 'Normal constructor', + 'Normal componentWillMount', + 'Normal render', + // Finalize updates: + 'Normal componentDidMount', + 'ErrorBoundary componentDidUpdate', + ]); + + log.length = 0; + ReactDOM.unmountComponentAtNode(container); + expect(log).toEqual([ + 'ErrorBoundary componentWillUnmount', + 'Normal componentWillUnmount', + ]); + }); + + it('can update multiple times in error state', () => { + const container = document.createElement('div'); + ReactDOM.render( + + + , + container, + ); + expect(container.textContent).toBe('Caught an error: Hello.'); + + ReactDOM.render( + + + , + container, + ); + expect(container.textContent).toBe('Caught an error: Hello.'); + + ReactDOM.render(
Other screen
, container); + expect(container.textContent).toBe('Other screen'); + + ReactDOM.unmountComponentAtNode(container); + }); + + it("doesn't get into inconsistent state during removals", () => { + const container = document.createElement('div'); + ReactDOM.render( + + + + + , + container, + ); + + ReactDOM.render(, container); + expect(container.textContent).toBe('Caught an error: Hello.'); + + log.length = 0; + ReactDOM.unmountComponentAtNode(container); + expect(log).toEqual(['ErrorBoundary componentWillUnmount']); + }); + + it("doesn't get into inconsistent state during additions", () => { + const container = document.createElement('div'); + ReactDOM.render(, container); + ReactDOM.render( + + + + + , + container, + ); + expect(container.textContent).toBe('Caught an error: Hello.'); + + log.length = 0; + ReactDOM.unmountComponentAtNode(container); + expect(log).toEqual(['ErrorBoundary componentWillUnmount']); + }); + + it("doesn't get into inconsistent state during reorders", () => { + function getAMixOfNormalAndBrokenRenderElements() { + const elements = []; + for (let i = 0; i < 100; i++) { + elements.push(); + } + elements.push(); + + let currentIndex = elements.length; + while (0 !== currentIndex) { + const randomIndex = Math.floor(Math.random() * currentIndex); + currentIndex -= 1; + const temporaryValue = elements[currentIndex]; + elements[currentIndex] = elements[randomIndex]; + elements[randomIndex] = temporaryValue; + } + return elements; + } + + class MaybeBrokenRender extends React.Component { + render() { + if (fail) { + throw new Error('Hello'); + } + return
{this.props.children}
; + } + } + + let fail = false; + const container = document.createElement('div'); + ReactDOM.render( + {getAMixOfNormalAndBrokenRenderElements()}, + container, + ); + expect(container.textContent).not.toContain('Caught an error'); + + fail = true; + ReactDOM.render( + {getAMixOfNormalAndBrokenRenderElements()}, + container, + ); + expect(container.textContent).toBe('Caught an error: Hello.'); + + log.length = 0; + ReactDOM.unmountComponentAtNode(container); + expect(log).toEqual(['ErrorBoundary componentWillUnmount']); + }); + + it('catches errors originating downstream', () => { + let fail = false; + class Stateful extends React.Component { + state = {shouldThrow: false}; + + render() { + if (fail) { + log.push('Stateful render [!]'); + throw new Error('Hello'); + } + return
{this.props.children}
; + } + } + + let statefulInst; + const container = document.createElement('div'); + ReactDOM.render( + + (statefulInst = inst)} /> + , + container, + ); + + log.length = 0; + expect(() => { + fail = true; + statefulInst.forceUpdate(); + }).not.toThrow(); + + expect(log).toEqual([ + 'Stateful render [!]', + 'ErrorBoundary componentDidCatch', + 'ErrorBoundary componentWillUpdate', + 'ErrorBoundary render error', + 'ErrorBoundary componentDidUpdate', + ]); + + log.length = 0; + ReactDOM.unmountComponentAtNode(container); + expect(log).toEqual(['ErrorBoundary componentWillUnmount']); + }); + + it('catches errors in componentDidMount', () => { + const container = document.createElement('div'); + ReactDOM.render( + + + + + + + , + container, + ); + expect(log).toEqual([ + 'ErrorBoundary constructor', + 'ErrorBoundary componentWillMount', + 'ErrorBoundary render success', + 'BrokenComponentWillUnmount constructor', + 'BrokenComponentWillUnmount componentWillMount', + 'BrokenComponentWillUnmount render', + 'Normal constructor', + 'Normal componentWillMount', + 'Normal render', + 'BrokenComponentDidMount constructor', + 'BrokenComponentDidMount componentWillMount', + 'BrokenComponentDidMount render', + 'LastChild constructor', + 'LastChild componentWillMount', + 'LastChild render', + // Start flushing didMount queue + 'Normal componentDidMount', + 'BrokenComponentWillUnmount componentDidMount', + 'BrokenComponentDidMount componentDidMount [!]', + // Continue despite the error + 'LastChild componentDidMount', + 'ErrorBoundary componentDidMount', + // Now we are ready to handle the error + // Safely unmount every child + 'BrokenComponentWillUnmount componentWillUnmount [!]', + // Continue unmounting safely despite any errors + 'Normal componentWillUnmount', + 'BrokenComponentDidMount componentWillUnmount', + 'LastChild componentWillUnmount', + // Handle the error + 'ErrorBoundary componentDidCatch', + 'ErrorBoundary componentWillUpdate', + // The willUnmount error should be captured and logged, too. + 'ErrorBoundary componentDidUpdate', + 'ErrorBoundary componentDidCatch', + 'ErrorBoundary componentWillUpdate', + 'ErrorBoundary render error', + // The update has finished + 'ErrorBoundary componentDidUpdate', + ]); + + log.length = 0; + ReactDOM.unmountComponentAtNode(container); + expect(log).toEqual(['ErrorBoundary componentWillUnmount']); + }); + + it('catches errors in componentDidUpdate', () => { + const container = document.createElement('div'); + ReactDOM.render( + + + , + container, + ); + + log.length = 0; + ReactDOM.render( + + + , + container, + ); + expect(log).toEqual([ + 'ErrorBoundary componentWillReceiveProps', + 'ErrorBoundary componentWillUpdate', + 'ErrorBoundary render success', + 'BrokenComponentDidUpdate componentWillReceiveProps', + 'BrokenComponentDidUpdate componentWillUpdate', + 'BrokenComponentDidUpdate render', + // All lifecycles run + 'BrokenComponentDidUpdate componentDidUpdate [!]', + 'ErrorBoundary componentDidUpdate', + 'BrokenComponentDidUpdate componentWillUnmount', + // Then, error is handled + 'ErrorBoundary componentDidCatch', + 'ErrorBoundary componentWillUpdate', + 'ErrorBoundary render error', + 'ErrorBoundary componentDidUpdate', + ]); + + log.length = 0; + ReactDOM.unmountComponentAtNode(container); + expect(log).toEqual(['ErrorBoundary componentWillUnmount']); + }); + + it('propagates errors inside boundary during componentDidMount', () => { + const container = document.createElement('div'); + ReactDOM.render( + + ( +
We should never catch our own error: {error.message}.
+ )} + /> +
, + container, + ); + expect(container.firstChild.textContent).toBe('Caught an error: Hello.'); + expect(log).toEqual([ + 'ErrorBoundary constructor', + 'ErrorBoundary componentWillMount', + 'ErrorBoundary render success', + 'BrokenComponentDidMountErrorBoundary constructor', + 'BrokenComponentDidMountErrorBoundary componentWillMount', + 'BrokenComponentDidMountErrorBoundary render success', + 'BrokenComponentDidMountErrorBoundary componentDidMount [!]', + // Fiber proceeds with the hooks + 'ErrorBoundary componentDidMount', + 'BrokenComponentDidMountErrorBoundary componentWillUnmount', + // The error propagates to the higher boundary + 'ErrorBoundary componentDidCatch', + // Fiber retries from the root + 'ErrorBoundary componentWillUpdate', + 'ErrorBoundary render error', + 'ErrorBoundary componentDidUpdate', + ]); + + log.length = 0; + ReactDOM.unmountComponentAtNode(container); + expect(log).toEqual(['ErrorBoundary componentWillUnmount']); + }); + + it('calls componentDidCatch for each error that is captured', () => { + function renderUnmountError(error) { + return
Caught an unmounting error: {error.message}.
; + } + function renderUpdateError(error) { + return
Caught an updating error: {error.message}.
; + } + + const container = document.createElement('div'); + ReactDOM.render( + + + + + + + + + + , + container, + ); + + log.length = 0; + ReactDOM.render( + + + + + + + , + container, + ); + + expect(container.firstChild.textContent).toBe( + 'Caught an unmounting error: E2.' + 'Caught an updating error: E4.', + ); + expect(log).toEqual([ + // Begin update phase + 'OuterErrorBoundary componentWillReceiveProps', + 'OuterErrorBoundary componentWillUpdate', + 'OuterErrorBoundary render success', + 'InnerUnmountBoundary componentWillReceiveProps', + 'InnerUnmountBoundary componentWillUpdate', + 'InnerUnmountBoundary render success', + 'InnerUpdateBoundary componentWillReceiveProps', + 'InnerUpdateBoundary componentWillUpdate', + 'InnerUpdateBoundary render success', + // First come the updates + 'BrokenComponentDidUpdate componentWillReceiveProps', + 'BrokenComponentDidUpdate componentWillUpdate', + 'BrokenComponentDidUpdate render', + 'BrokenComponentDidUpdate componentWillReceiveProps', + 'BrokenComponentDidUpdate componentWillUpdate', + 'BrokenComponentDidUpdate render', + // We're in commit phase now, deleting + 'BrokenComponentWillUnmount componentWillUnmount [!]', + 'BrokenComponentWillUnmount componentWillUnmount [!]', + // Continue despite errors, handle them after commit is done + 'InnerUnmountBoundary componentDidUpdate', + // We're still in commit phase, now calling update lifecycles + 'BrokenComponentDidUpdate componentDidUpdate [!]', + // Again, continue despite errors, we'll handle them later + 'BrokenComponentDidUpdate componentDidUpdate [!]', + 'InnerUpdateBoundary componentDidUpdate', + 'OuterErrorBoundary componentDidUpdate', + // After the commit phase, attempt to recover from any errors that + // were captured + 'BrokenComponentDidUpdate componentWillUnmount', + 'BrokenComponentDidUpdate componentWillUnmount', + 'InnerUnmountBoundary componentDidCatch', + 'InnerUnmountBoundary componentDidCatch', + 'InnerUpdateBoundary componentDidCatch', + 'InnerUpdateBoundary componentDidCatch', + 'InnerUnmountBoundary componentWillUpdate', + 'InnerUnmountBoundary render error', + 'InnerUpdateBoundary componentWillUpdate', + 'InnerUpdateBoundary render error', + 'InnerUnmountBoundary componentDidUpdate', + 'InnerUpdateBoundary componentDidUpdate', + ]); + + log.length = 0; + ReactDOM.unmountComponentAtNode(container); + expect(log).toEqual([ + 'OuterErrorBoundary componentWillUnmount', + 'InnerUnmountBoundary componentWillUnmount', + 'InnerUpdateBoundary componentWillUnmount', + ]); + }); + + it('discards a bad root if the root component fails', () => { + const X = null; + const Y = undefined; + let err1; + let err2; + + try { + let container = document.createElement('div'); + expect(() => ReactDOM.render(, container)).toWarnDev( + 'React.createElement: type is invalid -- expected a string ' + + '(for built-in components) or a class/function ' + + '(for composite components) but got: null.', + ); + } catch (err) { + err1 = err; + } + try { + let container = document.createElement('div'); + expect(() => ReactDOM.render(, container)).toWarnDev( + 'React.createElement: type is invalid -- expected a string ' + + '(for built-in components) or a class/function ' + + '(for composite components) but got: undefined.', + ); + } catch (err) { + err2 = err; + } + + expect(err1.message).toMatch(/got: null/); + expect(err2.message).toMatch(/got: undefined/); + }); + + it('renders empty output if error boundary does not handle the error', () => { + const container = document.createElement('div'); + expect(() => { + ReactDOM.render( +
+ Sibling + + + +
, + container, + ); + }).toWarnDev( + 'ErrorBoundary: Error boundaries should implement getDerivedStateFromError()', + {withoutStack: true}, + ); + expect(container.firstChild.textContent).toBe('Sibling'); + expect(log).toEqual([ + 'NoopErrorBoundary constructor', + 'NoopErrorBoundary componentWillMount', + 'NoopErrorBoundary render', + 'BrokenRender constructor', + 'BrokenRender componentWillMount', + 'BrokenRender render [!]', + // In Fiber, noop error boundaries render null + 'NoopErrorBoundary componentDidMount', + 'NoopErrorBoundary componentDidCatch', + // Nothing happens. + ]); + + log.length = 0; + ReactDOM.unmountComponentAtNode(container); + expect(log).toEqual(['NoopErrorBoundary componentWillUnmount']); + }); + + it('passes first error when two errors happen in commit', () => { + const errors = []; + let caughtError; + class Parent extends React.Component { + render() { + return ; + } + componentDidMount() { + errors.push('parent sad'); + throw new Error('parent sad'); + } + } + class Child extends React.Component { + render() { + return
; + } + componentDidMount() { + errors.push('child sad'); + throw new Error('child sad'); + } + } + + const container = document.createElement('div'); + try { + // Here, we test the behavior where there is no error boundary and we + // delegate to the host root. + ReactDOM.render(, container); + } catch (e) { + if (e.message !== 'parent sad' && e.message !== 'child sad') { + throw e; + } + caughtError = e; + } + + expect(errors).toEqual(['child sad', 'parent sad']); + // Error should be the first thrown + expect(caughtError.message).toBe('child sad'); + }); + + it('propagates uncaught error inside unbatched initial mount', () => { + function Foo() { + throw new Error('foo error'); + } + const container = document.createElement('div'); + expect(() => { + ReactDOM.unstable_batchedUpdates(() => { + ReactDOM.render(, container); + }); + }).toThrow('foo error'); + }); + + it('handles errors that occur in before-mutation commit hook', () => { + const errors = []; + let caughtError; + class Parent extends React.Component { + getSnapshotBeforeUpdate() { + errors.push('parent sad'); + throw new Error('parent sad'); + } + componentDidUpdate() {} + render() { + return ; + } + } + class Child extends React.Component { + getSnapshotBeforeUpdate() { + errors.push('child sad'); + throw new Error('child sad'); + } + componentDidUpdate() {} + render() { + return
; + } + } + + const container = document.createElement('div'); + ReactDOM.render(, container); + try { + ReactDOM.render(, container); + } catch (e) { + if (e.message !== 'parent sad' && e.message !== 'child sad') { + throw e; + } + caughtError = e; + } + + expect(errors).toEqual(['child sad', 'parent sad']); + // Error should be the first thrown + expect(caughtError.message).toBe('child sad'); + }); +}); diff --git a/packages/react-dom/src/__tests__/ReactUpdates-test.js b/packages/react-dom/src/__tests__/ReactUpdates-test.js index 5f3dd684e3dd..a6b788902b1b 100644 --- a/packages/react-dom/src/__tests__/ReactUpdates-test.js +++ b/packages/react-dom/src/__tests__/ReactUpdates-test.js @@ -1343,6 +1343,9 @@ describe('ReactUpdates', () => { class ErrorBoundary extends React.Component { componentDidCatch() { + // Schedule a no-op state update to avoid triggering a DEV warning in the test. + this.setState({}); + this.props.parent.remount(); } render() { diff --git a/packages/react-reconciler/src/ReactFiberBeginWork.js b/packages/react-reconciler/src/ReactFiberBeginWork.js index d66132574649..0a6ae9df7bca 100644 --- a/packages/react-reconciler/src/ReactFiberBeginWork.js +++ b/packages/react-reconciler/src/ReactFiberBeginWork.js @@ -46,7 +46,6 @@ import { import {captureWillSyncRenderPlaceholder} from './ReactFiberScheduler'; import ReactSharedInternals from 'shared/ReactSharedInternals'; import { - enableGetDerivedStateFromCatch, enableSuspense, debugRenderPhaseSideEffects, debugRenderPhaseSideEffectsForStrictMode, @@ -156,6 +155,38 @@ export function reconcileChildren( } } +function forceUnmountCurrentAndReconcile( + current: Fiber, + workInProgress: Fiber, + nextChildren: any, + renderExpirationTime: ExpirationTime, +) { + // This function is fork of reconcileChildren. It's used in cases where we + // want to reconcile without matching against the existing set. This has the + // effect of all current children being unmounted; even if the type and key + // are the same, the old child is unmounted and a new child is created. + // + // To do this, we're going to go through the reconcile algorithm twice. In + // the first pass, we schedule a deletion for all the current children by + // passing null. + workInProgress.child = reconcileChildFibers( + workInProgress, + current.child, + null, + renderExpirationTime, + ); + // In the second pass, we mount the new children. The trick here is that we + // pass null in place of where we usually pass the current child set. This has + // the effect of remounting all children regardless of whether their their + // identity matches. + workInProgress.child = reconcileChildFibers( + workInProgress, + null, + nextChildren, + renderExpirationTime, + ); +} + function updateForwardRef( current: Fiber | null, workInProgress: Fiber, @@ -444,8 +475,7 @@ function finishClassComponent( let nextChildren; if ( didCaptureError && - (!enableGetDerivedStateFromCatch || - typeof Component.getDerivedStateFromCatch !== 'function') + typeof Component.getDerivedStateFromError !== 'function' ) { // If we captured an error, but getDerivedStateFrom catch is not defined, // unmount all the children. componentDidCatch will schedule an update to @@ -477,20 +507,25 @@ function finishClassComponent( // React DevTools reads this flag. workInProgress.effectTag |= PerformedWork; if (current !== null && didCaptureError) { - // If we're recovering from an error, reconcile twice: first to delete - // all the existing children. - reconcileChildren(current, workInProgress, null, renderExpirationTime); - workInProgress.child = null; - // Now we can continue reconciling like normal. This has the effect of - // remounting all children regardless of whether their their - // identity matches. + // If we're recovering from an error, reconcile without reusing any of + // the existing children. Conceptually, the normal children and the children + // that are shown on error are two different sets, so we shouldn't reuse + // normal children even if their identities match. + forceUnmountCurrentAndReconcile( + current, + workInProgress, + nextChildren, + renderExpirationTime, + ); + } else { + reconcileChildren( + current, + workInProgress, + nextChildren, + renderExpirationTime, + ); } - reconcileChildren( - current, - workInProgress, - nextChildren, - renderExpirationTime, - ); + // Memoize props and state using the values we just used to render. // TODO: Restructure so we never read values from the instance. memoizeState(workInProgress, instance.state); @@ -930,13 +965,6 @@ function updatePlaceholderComponent( // suspended during the last commit. Switch to the placholder. workInProgress.updateQueue = null; nextDidTimeout = true; - // If we're recovering from an error, reconcile twice: first to delete - // all the existing children. - reconcileChildren(current, workInProgress, null, renderExpirationTime); - current.child = null; - // Now we can continue reconciling like normal. This has the effect of - // remounting all children regardless of whether their their - // identity matches. } else { nextDidTimeout = !alreadyCaptured; } @@ -963,14 +991,28 @@ function updatePlaceholderComponent( nextChildren = nextDidTimeout ? nextProps.fallback : children; } + if (current !== null && nextDidTimeout !== workInProgress.memoizedState) { + // We're about to switch from the placeholder children to the normal + // children, or vice versa. These are two different conceptual sets that + // happen to be stored in the same set. Call this special function to + // force the new set not to match with the current set. + // TODO: The proper way to model this is by storing each set separately. + forceUnmountCurrentAndReconcile( + current, + workInProgress, + nextChildren, + renderExpirationTime, + ); + } else { + reconcileChildren( + current, + workInProgress, + nextChildren, + renderExpirationTime, + ); + } workInProgress.memoizedProps = nextProps; workInProgress.memoizedState = nextDidTimeout; - reconcileChildren( - current, - workInProgress, - nextChildren, - renderExpirationTime, - ); return workInProgress.child; } else { return null; diff --git a/packages/react-reconciler/src/ReactFiberClassComponent.js b/packages/react-reconciler/src/ReactFiberClassComponent.js index ee0da92c911f..fbacce2d2d9a 100644 --- a/packages/react-reconciler/src/ReactFiberClassComponent.js +++ b/packages/react-reconciler/src/ReactFiberClassComponent.js @@ -466,10 +466,10 @@ function checkClassInstance(workInProgress: Fiber, ctor: any, newProps: any) { name, ); const noInstanceGetDerivedStateFromCatch = - typeof instance.getDerivedStateFromCatch !== 'function'; + typeof instance.getDerivedStateFromError !== 'function'; warningWithoutStack( noInstanceGetDerivedStateFromCatch, - '%s: getDerivedStateFromCatch() is defined as an instance method ' + + '%s: getDerivedStateFromError() is defined as an instance method ' + 'and will be ignored. Instead, declare it as a static method.', name, ); diff --git a/packages/react-reconciler/src/ReactFiberScheduler.js b/packages/react-reconciler/src/ReactFiberScheduler.js index dcb0b8c105f7..5ca08cebc092 100644 --- a/packages/react-reconciler/src/ReactFiberScheduler.js +++ b/packages/react-reconciler/src/ReactFiberScheduler.js @@ -1464,7 +1464,7 @@ function dispatch( const ctor = fiber.type; const instance = fiber.stateNode; if ( - typeof ctor.getDerivedStateFromCatch === 'function' || + typeof ctor.getDerivedStateFromError === 'function' || (typeof instance.componentDidCatch === 'function' && !isAlreadyFailedLegacyErrorBoundary(instance)) ) { diff --git a/packages/react-reconciler/src/ReactFiberUnwindWork.js b/packages/react-reconciler/src/ReactFiberUnwindWork.js index bfd7714966e2..bba16ed0b641 100644 --- a/packages/react-reconciler/src/ReactFiberUnwindWork.js +++ b/packages/react-reconciler/src/ReactFiberUnwindWork.js @@ -14,6 +14,8 @@ import type {CapturedValue} from './ReactCapturedValue'; import type {Update} from './ReactUpdateQueue'; import type {Thenable} from './ReactFiberScheduler'; +import getComponentName from 'shared/getComponentName'; +import warningWithoutStack from 'shared/warningWithoutStack'; import { IndeterminateComponent, FunctionalComponent, @@ -33,11 +35,7 @@ import { Update as UpdateEffect, LifecycleEffectMask, } from 'shared/ReactSideEffectTags'; -import { - enableGetDerivedStateFromCatch, - enableSuspense, - enableSchedulerTracing, -} from 'shared/ReactFeatureFlags'; +import {enableSuspense, enableSchedulerTracing} from 'shared/ReactFeatureFlags'; import {StrictMode, ConcurrentMode} from './ReactTypeOfMode'; import {createCapturedValue} from './ReactCapturedValue'; @@ -104,28 +102,22 @@ function createClassErrorUpdate( ): Update { const update = createUpdate(expirationTime); update.tag = CaptureUpdate; - const getDerivedStateFromCatch = fiber.type.getDerivedStateFromCatch; - if ( - enableGetDerivedStateFromCatch && - typeof getDerivedStateFromCatch === 'function' - ) { + const getDerivedStateFromError = fiber.type.getDerivedStateFromError; + if (typeof getDerivedStateFromError === 'function') { const error = errorInfo.value; update.payload = () => { - return getDerivedStateFromCatch(error); + return getDerivedStateFromError(error); }; } const inst = fiber.stateNode; if (inst !== null && typeof inst.componentDidCatch === 'function') { update.callback = function callback() { - if ( - !enableGetDerivedStateFromCatch || - getDerivedStateFromCatch !== 'function' - ) { + if (typeof getDerivedStateFromError !== 'function') { // To preserve the preexisting retry behavior of error boundaries, // we keep track of which ones already failed during this batch. // This gets reset before we yield back to the browser. - // TODO: Warn in strict mode if getDerivedStateFromCatch is + // TODO: Warn in strict mode if getDerivedStateFromError is // not defined. markLegacyErrorBoundaryAsFailed(this); } @@ -135,6 +127,19 @@ function createClassErrorUpdate( this.componentDidCatch(error, { componentStack: stack !== null ? stack : '', }); + if (__DEV__) { + if (typeof getDerivedStateFromError !== 'function') { + // If componentDidCatch is the only error boundary method defined, + // then it needs to call setState to recover from errors. + // If no state update is scheduled then the boundary will swallow the error. + warningWithoutStack( + fiber.expirationTime === Sync, + '%s: Error boundaries should implement getDerivedStateFromError(). ' + + 'In that method, return a state update to display an error message or fallback UI.', + getComponentName(fiber.type) || 'Unknown', + ); + } + } }; } return update; @@ -364,8 +369,7 @@ function throwException( const instance = workInProgress.stateNode; if ( (workInProgress.effectTag & DidCapture) === NoEffect && - ((typeof ctor.getDerivedStateFromCatch === 'function' && - enableGetDerivedStateFromCatch) || + (typeof ctor.getDerivedStateFromError === 'function' || (instance !== null && typeof instance.componentDidCatch === 'function' && !isAlreadyFailedLegacyErrorBoundary(instance))) diff --git a/packages/react-reconciler/src/__tests__/ErrorBoundaryReconciliation-test.internal.js b/packages/react-reconciler/src/__tests__/ErrorBoundaryReconciliation-test.internal.js new file mode 100644 index 000000000000..8e7cbe067df5 --- /dev/null +++ b/packages/react-reconciler/src/__tests__/ErrorBoundaryReconciliation-test.internal.js @@ -0,0 +1,111 @@ +const jestDiff = require('jest-diff'); + +describe('ErrorBoundaryReconciliation', () => { + let BrokenRender; + let DidCatchErrorBoundary; + let GetDerivedErrorBoundary; + let React; + let ReactFeatureFlags; + let ReactTestRenderer; + let span; + + beforeEach(() => { + jest.resetModules(); + + ReactFeatureFlags = require('shared/ReactFeatureFlags'); + ReactFeatureFlags.replayFailedUnitOfWorkWithInvokeGuardedCallback = false; + ReactTestRenderer = require('react-test-renderer'); + React = require('react'); + + DidCatchErrorBoundary = class extends React.Component { + state = {error: null}; + componentDidCatch(error) { + this.setState({error}); + } + render() { + return this.state.error + ? React.createElement(this.props.fallbackTagName, { + prop: 'ErrorBoundary', + }) + : this.props.children; + } + }; + + GetDerivedErrorBoundary = class extends React.Component { + state = {error: null}; + static getDerivedStateFromError(error) { + return {error}; + } + render() { + return this.state.error + ? React.createElement(this.props.fallbackTagName, { + prop: 'ErrorBoundary', + }) + : this.props.children; + } + }; + + const InvalidType = undefined; + BrokenRender = ({fail}) => + fail ? : ; + + function toHaveRenderedChildren(renderer, children) { + let actual, expected; + try { + actual = renderer.toJSON(); + expected = ReactTestRenderer.create(children).toJSON(); + expect(actual).toEqual(expected); + } catch (error) { + return { + message: () => jestDiff(expected, actual), + pass: false, + }; + } + return {pass: true}; + } + expect.extend({toHaveRenderedChildren}); + }); + + [true, false].forEach(isConcurrent => { + function sharedTest(ErrorBoundary, fallbackTagName) { + const renderer = ReactTestRenderer.create( + + + , + {unstable_isConcurrent: isConcurrent}, + ); + if (isConcurrent) { + renderer.unstable_flushAll(); + } + expect(renderer).toHaveRenderedChildren(); + + expect(() => { + renderer.update( + + + , + ); + if (isConcurrent) { + renderer.unstable_flushAll(); + } + }).toWarnDev(isConcurrent ? ['invalid', 'invalid'] : ['invalid']); + expect(renderer).toHaveRenderedChildren( + React.createElement(fallbackTagName, {prop: 'ErrorBoundary'}), + ); + } + + describe(isConcurrent ? 'concurrent' : 'sync', () => { + it('componentDidCatch can recover by rendering an element of the same type', () => + sharedTest(DidCatchErrorBoundary, 'span')); + + it('componentDidCatch can recover by rendering an element of a different type', () => + sharedTest(DidCatchErrorBoundary, 'div')); + + it('getDerivedStateFromError can recover by rendering an element of the same type', () => + sharedTest(GetDerivedErrorBoundary, 'span')); + + it('getDerivedStateFromError can recover by rendering an element of a different type', () => + sharedTest(GetDerivedErrorBoundary, 'div')); + }); + }); +}); diff --git a/packages/react-reconciler/src/__tests__/ReactIncremental-test.internal.js b/packages/react-reconciler/src/__tests__/ReactIncremental-test.internal.js index 1e98f6245455..5260851a9e32 100644 --- a/packages/react-reconciler/src/__tests__/ReactIncremental-test.internal.js +++ b/packages/react-reconciler/src/__tests__/ReactIncremental-test.internal.js @@ -2398,7 +2398,10 @@ describe('ReactIncremental', () => { instance.setState({ throwError: true, }); - ReactNoop.flush(); + expect(ReactNoop.flush).toWarnDev( + 'Error boundaries should implement getDerivedStateFromError()', + {withoutStack: true}, + ); }); it('should not recreate masked context unless inputs have changed', () => { diff --git a/packages/react-reconciler/src/__tests__/ReactIncrementalErrorHandling-test.internal.js b/packages/react-reconciler/src/__tests__/ReactIncrementalErrorHandling-test.internal.js index 982d0b641bcb..eab804cedb39 100644 --- a/packages/react-reconciler/src/__tests__/ReactIncrementalErrorHandling-test.internal.js +++ b/packages/react-reconciler/src/__tests__/ReactIncrementalErrorHandling-test.internal.js @@ -19,7 +19,6 @@ describe('ReactIncrementalErrorHandling', () => { beforeEach(() => { jest.resetModules(); ReactFeatureFlags = require('shared/ReactFeatureFlags'); - ReactFeatureFlags.enableGetDerivedStateFromCatch = true; ReactFeatureFlags.debugRenderPhaseSideEffectsForStrictMode = false; ReactFeatureFlags.replayFailedUnitOfWorkWithInvokeGuardedCallback = false; PropTypes = require('prop-types'); @@ -41,6 +40,99 @@ describe('ReactIncrementalErrorHandling', () => { } it('recovers from errors asynchronously', () => { + class ErrorBoundary extends React.Component { + state = {error: null}; + static getDerivedStateFromError(error) { + ReactNoop.yield('getDerivedStateFromError'); + return {error}; + } + render() { + if (this.state.error) { + ReactNoop.yield('ErrorBoundary (catch)'); + return ; + } + ReactNoop.yield('ErrorBoundary (try)'); + return this.props.children; + } + } + + function ErrorMessage(props) { + ReactNoop.yield('ErrorMessage'); + return ; + } + + function Indirection(props) { + ReactNoop.yield('Indirection'); + return props.children || null; + } + + function BadRender() { + ReactNoop.yield('throw'); + throw new Error('oops!'); + } + + ReactNoop.render( + + + + + + + + + + + , + ); + + // Start rendering asynchronsouly + ReactNoop.flushThrough([ + 'ErrorBoundary (try)', + 'Indirection', + 'Indirection', + 'Indirection', + // An error is thrown. React keeps rendering asynchronously. + 'throw', + ]); + + // Still rendering async... + ReactNoop.flushThrough(['Indirection']); + + ReactNoop.flushThrough([ + 'Indirection', + + // Call getDerivedStateFromError and re-render the error boundary, this + // time rendering an error message. + 'getDerivedStateFromError', + 'ErrorBoundary (catch)', + 'ErrorMessage', + ]); + + // Since the error was thrown during an async render, React won't commit + // the result yet. + expect(ReactNoop.getChildren()).toEqual([]); + + // Instead, it will try rendering one more time, synchronously, in case that + // happens to fix the error. + expect(ReactNoop.flushNextYield()).toEqual([ + 'ErrorBoundary (try)', + 'Indirection', + 'Indirection', + 'Indirection', + + // The error was thrown again. This time, React will actually commit + // the result. + 'throw', + 'Indirection', + 'Indirection', + 'getDerivedStateFromError', + 'ErrorBoundary (catch)', + 'ErrorMessage', + ]); + expect(ReactNoop.getChildren()).toEqual([span('Caught an error: oops!')]); + }); + + it('recovers from errors asynchronously (legacy, no getDerivedStateFromError)', () => { class ErrorBoundary extends React.Component { state = {error: null}; componentDidCatch(error) { @@ -1442,10 +1534,10 @@ describe('ReactIncrementalErrorHandling', () => { ]); }); - it('does not provide component stack to the error boundary with getDerivedStateFromCatch', () => { + it('does not provide component stack to the error boundary with getDerivedStateFromError', () => { class ErrorBoundary extends React.Component { state = {error: null}; - static getDerivedStateFromCatch(error, errorInfo) { + static getDerivedStateFromError(error, errorInfo) { expect(errorInfo).toBeUndefined(); return {error}; } diff --git a/packages/react-reconciler/src/__tests__/ReactSuspense-test.internal.js b/packages/react-reconciler/src/__tests__/ReactSuspense-test.internal.js index 2033b4e14538..40f74132dd8b 100644 --- a/packages/react-reconciler/src/__tests__/ReactSuspense-test.internal.js +++ b/packages/react-reconciler/src/__tests__/ReactSuspense-test.internal.js @@ -1316,11 +1316,7 @@ describe('ReactSuspense', () => { 'A', 'B', 'C', - // 'A' matched with the placeholder. It's ok to reuse children when - // switching back. Though in a real app you probably don't want to. - // TODO: This is wrong. The timed out children and the placeholder - // should be siblings in async mode. Revisit in follow-up PR. - 'Update [A]', + 'Mount [A]', 'Mount [B]', 'Mount [C]', ]); diff --git a/packages/react/src/__tests__/ReactCoffeeScriptClass-test.coffee b/packages/react/src/__tests__/ReactCoffeeScriptClass-test.coffee index ec0921293d53..813082c9a552 100644 --- a/packages/react/src/__tests__/ReactCoffeeScriptClass-test.coffee +++ b/packages/react/src/__tests__/ReactCoffeeScriptClass-test.coffee @@ -129,15 +129,15 @@ describe 'ReactCoffeeScriptClass', -> ).toWarnDev 'Foo: getDerivedStateFromProps() is defined as an instance method and will be ignored. Instead, declare it as a static method.', {withoutStack: true} undefined - it 'warns if getDerivedStateFromCatch is not static', -> + it 'warns if getDerivedStateFromError is not static', -> class Foo extends React.Component render: -> div() - getDerivedStateFromCatch: -> + getDerivedStateFromError: -> {} expect(-> ReactDOM.render(React.createElement(Foo, foo: 'foo'), container) - ).toWarnDev 'Foo: getDerivedStateFromCatch() is defined as an instance method and will be ignored. Instead, declare it as a static method.', {withoutStack: true} + ).toWarnDev 'Foo: getDerivedStateFromError() is defined as an instance method and will be ignored. Instead, declare it as a static method.', {withoutStack: true} undefined it 'warns if getSnapshotBeforeUpdate is static', -> diff --git a/packages/react/src/__tests__/ReactES6Class-test.js b/packages/react/src/__tests__/ReactES6Class-test.js index 3e4dbc040dea..bc4d33d24417 100644 --- a/packages/react/src/__tests__/ReactES6Class-test.js +++ b/packages/react/src/__tests__/ReactES6Class-test.js @@ -147,9 +147,9 @@ describe('ReactES6Class', () => { ); }); - it('warns if getDerivedStateFromCatch is not static', () => { + it('warns if getDerivedStateFromError is not static', () => { class Foo extends React.Component { - getDerivedStateFromCatch() { + getDerivedStateFromError() { return {}; } render() { @@ -157,7 +157,7 @@ describe('ReactES6Class', () => { } } expect(() => ReactDOM.render(, container)).toWarnDev( - 'Foo: getDerivedStateFromCatch() is defined as an instance method ' + + 'Foo: getDerivedStateFromError() is defined as an instance method ' + 'and will be ignored. Instead, declare it as a static method.', {withoutStack: true}, ); diff --git a/packages/react/src/__tests__/ReactProfiler-test.internal.js b/packages/react/src/__tests__/ReactProfiler-test.internal.js index ca6120b51ce4..f7386f0d75db 100644 --- a/packages/react/src/__tests__/ReactProfiler-test.internal.js +++ b/packages/react/src/__tests__/ReactProfiler-test.internal.js @@ -36,7 +36,6 @@ function loadModules({ ReactFeatureFlags.debugRenderPhaseSideEffects = false; ReactFeatureFlags.debugRenderPhaseSideEffectsForStrictMode = false; ReactFeatureFlags.enableProfilerTimer = enableProfilerTimer; - ReactFeatureFlags.enableGetDerivedStateFromCatch = true; ReactFeatureFlags.enableSchedulerTracing = enableSchedulerTracing; ReactFeatureFlags.enableSuspense = enableSuspense; ReactFeatureFlags.replayFailedUnitOfWorkWithInvokeGuardedCallback = replayFailedUnitOfWorkWithInvokeGuardedCallback; @@ -985,7 +984,7 @@ describe('Profiler', () => { ); }); - it('should accumulate actual time after an error handled by getDerivedStateFromCatch()', () => { + it('should accumulate actual time after an error handled by getDerivedStateFromError()', () => { const callback = jest.fn(); const ThrowsError = () => { @@ -995,7 +994,7 @@ describe('Profiler', () => { class ErrorBoundary extends React.Component { state = {error: null}; - static getDerivedStateFromCatch(error) { + static getDerivedStateFromError(error) { return {error}; } render() { diff --git a/packages/react/src/__tests__/ReactTypeScriptClass-test.ts b/packages/react/src/__tests__/ReactTypeScriptClass-test.ts index c738c1742bd5..e79ab7f49675 100644 --- a/packages/react/src/__tests__/ReactTypeScriptClass-test.ts +++ b/packages/react/src/__tests__/ReactTypeScriptClass-test.ts @@ -397,9 +397,9 @@ describe('ReactTypeScriptClass', function() { ); }); - it('warns if getDerivedStateFromCatch is not static', function() { + it('warns if getDerivedStateFromError is not static', function() { class Foo extends React.Component { - getDerivedStateFromCatch() { + getDerivedStateFromError() { return {}; } render() { @@ -409,7 +409,7 @@ describe('ReactTypeScriptClass', function() { expect(function() { ReactDOM.render(React.createElement(Foo, {foo: 'foo'}), container); }).toWarnDev( - 'Foo: getDerivedStateFromCatch() is defined as an instance method ' + + 'Foo: getDerivedStateFromError() is defined as an instance method ' + 'and will be ignored. Instead, declare it as a static method.', {withoutStack: true} ); diff --git a/packages/react/src/__tests__/createReactClassIntegration-test.js b/packages/react/src/__tests__/createReactClassIntegration-test.js index d1e82b11f39c..efc2bf3ba49d 100644 --- a/packages/react/src/__tests__/createReactClassIntegration-test.js +++ b/packages/react/src/__tests__/createReactClassIntegration-test.js @@ -459,9 +459,9 @@ describe('create-react-class-integration', () => { ); }); - it('warns if getDerivedStateFromCatch is not static', () => { + it('warns if getDerivedStateFromError is not static', () => { const Foo = createReactClass({ - getDerivedStateFromCatch() { + getDerivedStateFromError() { return {}; }, render() { @@ -471,7 +471,7 @@ describe('create-react-class-integration', () => { expect(() => ReactDOM.render(, document.createElement('div')), ).toWarnDev( - 'Component: getDerivedStateFromCatch() is defined as an instance method ' + + 'Component: getDerivedStateFromError() is defined as an instance method ' + 'and will be ignored. Instead, declare it as a static method.', {withoutStack: true}, ); diff --git a/packages/shared/ReactFeatureFlags.js b/packages/shared/ReactFeatureFlags.js index aae29ada1838..abd23679539d 100644 --- a/packages/shared/ReactFeatureFlags.js +++ b/packages/shared/ReactFeatureFlags.js @@ -10,9 +10,6 @@ // Exports ReactDOM.createRoot export const enableUserTimingAPI = __DEV__; -// Experimental error-boundary API that can recover from errors within a single -// render phase -export const enableGetDerivedStateFromCatch = false; // Suspense export const enableSuspense = false; // Helps identify side effects in begin-phase lifecycle hooks and setState reducers: diff --git a/packages/shared/forks/ReactFeatureFlags.native-fabric-fb.js b/packages/shared/forks/ReactFeatureFlags.native-fabric-fb.js index 69bb61eca4d4..566d27122270 100644 --- a/packages/shared/forks/ReactFeatureFlags.native-fabric-fb.js +++ b/packages/shared/forks/ReactFeatureFlags.native-fabric-fb.js @@ -15,7 +15,6 @@ import typeof * as FabricFeatureFlagsType from './ReactFeatureFlags.native-fabri export const debugRenderPhaseSideEffects = false; export const debugRenderPhaseSideEffectsForStrictMode = false; export const enableUserTimingAPI = __DEV__; -export const enableGetDerivedStateFromCatch = false; export const enableSuspense = false; export const warnAboutDeprecatedLifecycles = false; export const warnAboutLegacyContextAPI = __DEV__; diff --git a/packages/shared/forks/ReactFeatureFlags.native-fabric-oss.js b/packages/shared/forks/ReactFeatureFlags.native-fabric-oss.js index 66f1b4714ad4..f9b6576d4f46 100644 --- a/packages/shared/forks/ReactFeatureFlags.native-fabric-oss.js +++ b/packages/shared/forks/ReactFeatureFlags.native-fabric-oss.js @@ -15,7 +15,6 @@ import typeof * as FabricFeatureFlagsType from './ReactFeatureFlags.native-fabri export const debugRenderPhaseSideEffects = false; export const debugRenderPhaseSideEffectsForStrictMode = false; export const enableUserTimingAPI = __DEV__; -export const enableGetDerivedStateFromCatch = false; export const enableSuspense = false; export const warnAboutDeprecatedLifecycles = false; export const warnAboutLegacyContextAPI = false; diff --git a/packages/shared/forks/ReactFeatureFlags.native-fb.js b/packages/shared/forks/ReactFeatureFlags.native-fb.js index be63c527a8ee..4b12e739183d 100644 --- a/packages/shared/forks/ReactFeatureFlags.native-fb.js +++ b/packages/shared/forks/ReactFeatureFlags.native-fb.js @@ -14,7 +14,6 @@ import typeof * as FeatureFlagsShimType from './ReactFeatureFlags.native-fb'; // Re-export dynamic flags from the fbsource version. export const { - enableGetDerivedStateFromCatch, enableSuspense, debugRenderPhaseSideEffects, debugRenderPhaseSideEffectsForStrictMode, diff --git a/packages/shared/forks/ReactFeatureFlags.native-oss.js b/packages/shared/forks/ReactFeatureFlags.native-oss.js index 7a2e325723a7..a92e3c0f953f 100644 --- a/packages/shared/forks/ReactFeatureFlags.native-oss.js +++ b/packages/shared/forks/ReactFeatureFlags.native-oss.js @@ -14,7 +14,6 @@ import typeof * as FeatureFlagsShimType from './ReactFeatureFlags.native-oss'; export const debugRenderPhaseSideEffects = false; export const debugRenderPhaseSideEffectsForStrictMode = false; -export const enableGetDerivedStateFromCatch = false; export const enableSuspense = false; export const enableUserTimingAPI = __DEV__; export const replayFailedUnitOfWorkWithInvokeGuardedCallback = __DEV__; diff --git a/packages/shared/forks/ReactFeatureFlags.persistent.js b/packages/shared/forks/ReactFeatureFlags.persistent.js index 4b1b1c4c34f2..1fb299db333f 100644 --- a/packages/shared/forks/ReactFeatureFlags.persistent.js +++ b/packages/shared/forks/ReactFeatureFlags.persistent.js @@ -15,7 +15,6 @@ import typeof * as PersistentFeatureFlagsType from './ReactFeatureFlags.persiste export const debugRenderPhaseSideEffects = false; export const debugRenderPhaseSideEffectsForStrictMode = false; export const enableUserTimingAPI = __DEV__; -export const enableGetDerivedStateFromCatch = false; export const enableSuspense = false; export const warnAboutDeprecatedLifecycles = false; export const warnAboutLegacyContextAPI = false; diff --git a/packages/shared/forks/ReactFeatureFlags.test-renderer.js b/packages/shared/forks/ReactFeatureFlags.test-renderer.js index fb828d661dfb..07fde5086acc 100644 --- a/packages/shared/forks/ReactFeatureFlags.test-renderer.js +++ b/packages/shared/forks/ReactFeatureFlags.test-renderer.js @@ -15,7 +15,6 @@ import typeof * as PersistentFeatureFlagsType from './ReactFeatureFlags.persiste export const debugRenderPhaseSideEffects = false; export const debugRenderPhaseSideEffectsForStrictMode = false; export const enableUserTimingAPI = __DEV__; -export const enableGetDerivedStateFromCatch = false; export const enableSuspense = false; export const warnAboutDeprecatedLifecycles = false; export const warnAboutLegacyContextAPI = false; diff --git a/packages/shared/forks/ReactFeatureFlags.test-renderer.www.js b/packages/shared/forks/ReactFeatureFlags.test-renderer.www.js index 213d9ef75c4e..20e510afa1b9 100644 --- a/packages/shared/forks/ReactFeatureFlags.test-renderer.www.js +++ b/packages/shared/forks/ReactFeatureFlags.test-renderer.www.js @@ -15,7 +15,6 @@ import typeof * as PersistentFeatureFlagsType from './ReactFeatureFlags.persiste export const debugRenderPhaseSideEffects = false; export const debugRenderPhaseSideEffectsForStrictMode = false; export const enableUserTimingAPI = __DEV__; -export const enableGetDerivedStateFromCatch = false; export const enableSuspense = true; export const warnAboutDeprecatedLifecycles = false; export const warnAboutLegacyContextAPI = false; diff --git a/packages/shared/forks/ReactFeatureFlags.www.js b/packages/shared/forks/ReactFeatureFlags.www.js index 3f9d960d69f1..414bc4052b2a 100644 --- a/packages/shared/forks/ReactFeatureFlags.www.js +++ b/packages/shared/forks/ReactFeatureFlags.www.js @@ -15,7 +15,6 @@ export const { enableSuspense, debugRenderPhaseSideEffects, debugRenderPhaseSideEffectsForStrictMode, - enableGetDerivedStateFromCatch, enableSuspenseServerRenderer, replayFailedUnitOfWorkWithInvokeGuardedCallback, warnAboutDeprecatedLifecycles,