From f33f03e3572d11e6810f4ce110eb3af97cbd24a8 Mon Sep 17 00:00:00 2001 From: Dan Abramov Date: Sat, 15 Oct 2016 18:13:56 +0100 Subject: [PATCH] Support passthrough updates for error boundaries (#7949) MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit * Initial pass at the easy case of updates (updates that start at the root). * Don't expect an extra componentWillUnmount call It was fixed in #6613. * Remove duplicate expectations from the test * Fix style issues * Make naming consistent throughout the tests * receiveComponent() does not accept safely argument * Assert that lifecycle and refs fire for error message * Add more tests for mounting * Do not call componentWillMount twice on error boundary * Document more of existing behavior in tests * Do not call componentWillUnmount() when aborting mounting Previously, we would call componentWillUnmount() safely on the tree whenever we abort mounting it. However this is likely risky because the tree was never mounted in the first place. People shouldn't hold resources in componentWillMount() so it's safe to say that we can skip componentWillUnmount() if componentDidMount() was never called. Here, we introduce a new flag. If we abort during mounting, we will not call componentWillUnmount(). However if we abort during an update, it is safe to call componentWillUnmount() because the previous tree has been mounted by now. * Consistently display error messages in tests * Add more logging to tests and remove redundant one * Refactor tests * Split complicated tests into smaller ones * Assert clean unmounting * Add assertions about update hooks * Add more tests to document existing behavior and remove irrelevant details * Verify we can recover from error state * Fix lint * Error in boundary’s componentWillMount should propagate up This test is currently failing. * Move calling componentWillMount() into mountComponent() This removes the unnecessary non-recursive skipLifecycle check. It fixes the previously failing test that verifies that if a boundary throws in its own componentWillMount(), the error will propagate. * Remove extra whitespace --- src/renderers/dom/client/ReactMount.js | 11 +- src/renderers/dom/shared/ReactDOMComponent.js | 4 +- .../native/ReactNativeBaseComponent.js | 4 +- .../stack/reconciler/ReactChildReconciler.js | 16 +- .../reconciler/ReactCompositeComponent.js | 167 +- .../stack/reconciler/ReactMultiChild.js | 20 +- .../stack/reconciler/ReactReconciler.js | 4 +- .../reconciler/ReactSimpleEmptyComponent.js | 8 +- .../__tests__/ReactErrorBoundaries-test.js | 1675 ++++++++++++++--- src/renderers/testing/ReactTestMount.js | 3 +- src/test/ReactShallowRenderer.js | 6 +- 11 files changed, 1622 insertions(+), 296 deletions(-) diff --git a/src/renderers/dom/client/ReactMount.js b/src/renderers/dom/client/ReactMount.js index c0e3c9ec00..4c9a77f8c7 100644 --- a/src/renderers/dom/client/ReactMount.js +++ b/src/renderers/dom/client/ReactMount.js @@ -170,11 +170,15 @@ function batchedMountComponentIntoNode( * @internal * @see {ReactMount.unmountComponentAtNode} */ -function unmountComponentFromNode(instance, container, safely) { +function unmountComponentFromNode(instance, container) { if (__DEV__) { ReactInstrumentation.debugTool.onBeginFlush(); } - ReactReconciler.unmountComponent(instance, safely); + ReactReconciler.unmountComponent( + instance, + false /* safely */, + false /* skipLifecycle */ + ); if (__DEV__) { ReactInstrumentation.debugTool.onEndFlush(); } @@ -618,8 +622,7 @@ var ReactMount = { ReactUpdates.batchedUpdates( unmountComponentFromNode, prevComponent, - container, - false + container ); return true; }, diff --git a/src/renderers/dom/shared/ReactDOMComponent.js b/src/renderers/dom/shared/ReactDOMComponent.js index ef75df0c42..0da6954928 100644 --- a/src/renderers/dom/shared/ReactDOMComponent.js +++ b/src/renderers/dom/shared/ReactDOMComponent.js @@ -1155,7 +1155,7 @@ ReactDOMComponent.Mixin = { * * @internal */ - unmountComponent: function(safely) { + unmountComponent: function(safely, skipLifecycle) { switch (this._tag) { case 'audio': case 'form': @@ -1197,7 +1197,7 @@ ReactDOMComponent.Mixin = { break; } - this.unmountChildren(safely); + this.unmountChildren(safely, skipLifecycle); ReactDOMComponentTree.uncacheNode(this); EventPluginHub.deleteAllListeners(this); this._rootNodeID = 0; diff --git a/src/renderers/native/ReactNativeBaseComponent.js b/src/renderers/native/ReactNativeBaseComponent.js index 2192b019fc..1d47e9dbdf 100644 --- a/src/renderers/native/ReactNativeBaseComponent.js +++ b/src/renderers/native/ReactNativeBaseComponent.js @@ -55,10 +55,10 @@ ReactNativeBaseComponent.Mixin = { return this; }, - unmountComponent: function() { + unmountComponent: function(safely, skipLifecycle) { ReactNativeComponentTree.uncacheNode(this); deleteAllListeners(this); - this.unmountChildren(); + this.unmountChildren(safely, skipLifecycle); this._rootNodeID = 0; }, diff --git a/src/renderers/shared/stack/reconciler/ReactChildReconciler.js b/src/renderers/shared/stack/reconciler/ReactChildReconciler.js index d487e70999..dc58ff648e 100644 --- a/src/renderers/shared/stack/reconciler/ReactChildReconciler.js +++ b/src/renderers/shared/stack/reconciler/ReactChildReconciler.js @@ -146,7 +146,11 @@ var ReactChildReconciler = { } else { if (prevChild) { removedNodes[name] = ReactReconciler.getHostNode(prevChild); - ReactReconciler.unmountComponent(prevChild, false); + ReactReconciler.unmountComponent( + prevChild, + false, /* safely */ + false /* skipLifecycle */ + ); } // The child must be instantiated before it's mounted. var nextChildInstance = instantiateReactComponent(nextElement, true); @@ -170,7 +174,11 @@ var ReactChildReconciler = { !(nextChildren && nextChildren.hasOwnProperty(name))) { prevChild = prevChildren[name]; removedNodes[name] = ReactReconciler.getHostNode(prevChild); - ReactReconciler.unmountComponent(prevChild, false); + ReactReconciler.unmountComponent( + prevChild, + false, /* safely */ + false /* skipLifecycle */ + ); } } }, @@ -182,11 +190,11 @@ var ReactChildReconciler = { * @param {?object} renderedChildren Previously initialized set of children. * @internal */ - unmountChildren: function(renderedChildren, safely) { + unmountChildren: function(renderedChildren, safely, skipLifecycle) { for (var name in renderedChildren) { if (renderedChildren.hasOwnProperty(name)) { var renderedChild = renderedChildren[name]; - ReactReconciler.unmountComponent(renderedChild, safely); + ReactReconciler.unmountComponent(renderedChild, safely, skipLifecycle); } } }, diff --git a/src/renderers/shared/stack/reconciler/ReactCompositeComponent.js b/src/renderers/shared/stack/reconciler/ReactCompositeComponent.js index 56869d0ae6..3c49fc464d 100644 --- a/src/renderers/shared/stack/reconciler/ReactCompositeComponent.js +++ b/src/renderers/shared/stack/reconciler/ReactCompositeComponent.js @@ -333,6 +333,23 @@ var ReactCompositeComponent = { this._pendingReplaceState = false; this._pendingForceUpdate = false; + if (inst.componentWillMount) { + if (__DEV__) { + measureLifeCyclePerf( + () => inst.componentWillMount(), + this._debugID, + 'componentWillMount' + ); + } else { + inst.componentWillMount(); + } + // When mounting, calls to `setState` by `componentWillMount` will set + // `this._pendingStateQueue` without triggering a re-render. + if (this._pendingStateQueue) { + inst.state = this._processPendingState(inst.props, inst.context); + } + } + var markup; if (inst.unstable_handleError) { markup = this.performInitialMountWithErrorHandling( @@ -343,7 +360,13 @@ var ReactCompositeComponent = { context ); } else { - markup = this.performInitialMount(renderedElement, hostParent, hostContainerInfo, transaction, context); + markup = this.performInitialMount( + renderedElement, + hostParent, + hostContainerInfo, + transaction, + context + ); } if (inst.componentDidMount) { @@ -434,7 +457,13 @@ var ReactCompositeComponent = { var markup; var checkpoint = transaction.checkpoint(); try { - markup = this.performInitialMount(renderedElement, hostParent, hostContainerInfo, transaction, context); + markup = this.performInitialMount( + renderedElement, + hostParent, + hostContainerInfo, + transaction, + context + ); } catch (e) { // Roll back to checkpoint, handle error (which may add items to the transaction), and take a new checkpoint transaction.rollback(checkpoint); @@ -443,42 +472,33 @@ var ReactCompositeComponent = { this._instance.state = this._processPendingState(this._instance.props, this._instance.context); } checkpoint = transaction.checkpoint(); - - this._renderedComponent.unmountComponent(true); + this._renderedComponent.unmountComponent( + true, /* safely */ + // Don't call componentWillUnmount() because they never fully mounted: + true /* skipLifecyle */ + ); transaction.rollback(checkpoint); // Try again - we've informed the component about the error, so they can render an error message this time. // If this throws again, the error will bubble up (and can be caught by a higher error boundary). - markup = this.performInitialMount(renderedElement, hostParent, hostContainerInfo, transaction, context); + markup = this.performInitialMount( + renderedElement, + hostParent, + hostContainerInfo, + transaction, + context + ); } return markup; }, - performInitialMount: function(renderedElement, hostParent, hostContainerInfo, transaction, context) { - var inst = this._instance; - - var debugID = 0; - if (__DEV__) { - debugID = this._debugID; - } - - if (inst.componentWillMount) { - if (__DEV__) { - measureLifeCyclePerf( - () => inst.componentWillMount(), - debugID, - 'componentWillMount' - ); - } else { - inst.componentWillMount(); - } - // When mounting, calls to `setState` by `componentWillMount` will set - // `this._pendingStateQueue` without triggering a re-render. - if (this._pendingStateQueue) { - inst.state = this._processPendingState(inst.props, inst.context); - } - } - + performInitialMount: function( + renderedElement, + hostParent, + hostContainerInfo, + transaction, + context + ) { // If not a stateless component, we now render if (renderedElement === undefined) { renderedElement = this._renderValidatedComponent(); @@ -492,6 +512,11 @@ var ReactCompositeComponent = { ); this._renderedComponent = child; + var debugID = 0; + if (__DEV__) { + debugID = this._debugID; + } + var markup = ReactReconciler.mountComponent( child, transaction, @@ -521,7 +546,7 @@ var ReactCompositeComponent = { * @final * @internal */ - unmountComponent: function(safely) { + unmountComponent: function(safely, skipLifecycle) { if (!this._renderedComponent) { return; } @@ -532,8 +557,10 @@ var ReactCompositeComponent = { inst._calledComponentWillUnmount = true; if (safely) { - var name = this.getName() + '.componentWillUnmount()'; - ReactErrorUtils.invokeGuardedCallback(name, inst.componentWillUnmount.bind(inst)); + if (!skipLifecycle) { + var name = this.getName() + '.componentWillUnmount()'; + ReactErrorUtils.invokeGuardedCallback(name, inst.componentWillUnmount.bind(inst)); + } } else { if (__DEV__) { measureLifeCyclePerf( @@ -548,7 +575,11 @@ var ReactCompositeComponent = { } if (this._renderedComponent) { - ReactReconciler.unmountComponent(this._renderedComponent, safely); + ReactReconciler.unmountComponent( + this._renderedComponent, + safely, + skipLifecycle + ); this._renderedNodeType = null; this._renderedComponent = null; this._instance = null; @@ -941,7 +972,11 @@ var ReactCompositeComponent = { inst.state = nextState; inst.context = nextContext; - this._updateRenderedComponent(transaction, unmaskedContext); + if (inst.unstable_handleError) { + this._updateRenderedComponentWithErrorHandling(transaction, unmaskedContext); + } else { + this._updateRenderedComponent(transaction, unmaskedContext); + } if (hasComponentDidUpdate) { if (__DEV__) { @@ -961,6 +996,40 @@ var ReactCompositeComponent = { } }, + /** + * Call the component's `render` method and update the DOM accordingly. + * + * @param {ReactReconcileTransaction} transaction + * @internal + */ + _updateRenderedComponentWithErrorHandling: function(transaction, context) { + var checkpoint = transaction.checkpoint(); + try { + this._updateRenderedComponent(transaction, context); + } catch (e) { + // Roll back to checkpoint, handle error (which may add items to the transaction), + // and take a new checkpoint + transaction.rollback(checkpoint); + this._instance.unstable_handleError(e); + if (this._pendingStateQueue) { + this._instance.state = this._processPendingState(this._instance.props, this._instance.context); + } + checkpoint = transaction.checkpoint(); + + // Gracefully update to a clean state + this._updateRenderedComponentWithNextElement( + transaction, + context, + null, + true /* safely */ + ); + + // Try again - we've informed the component about the error, so they can render an error message this time. + // If this throws again, the error will bubble up (and can be caught by a higher error boundary). + this._updateRenderedComponent(transaction, context); + } + }, + /** * Call the component's `render` method and update the DOM accordingly. * @@ -968,9 +1037,29 @@ var ReactCompositeComponent = { * @internal */ _updateRenderedComponent: function(transaction, context) { + var nextRenderedElement = this._renderValidatedComponent(); + this._updateRenderedComponentWithNextElement( + transaction, + context, + nextRenderedElement, + false /* safely */ + ); + }, + + /** + * Call the component's `render` method and update the DOM accordingly. + * + * @param {ReactReconcileTransaction} transaction + * @internal + */ + _updateRenderedComponentWithNextElement: function( + transaction, + context, + nextRenderedElement, + safely + ) { var prevComponentInstance = this._renderedComponent; var prevRenderedElement = prevComponentInstance._currentElement; - var nextRenderedElement = this._renderValidatedComponent(); var debugID = 0; if (__DEV__) { @@ -986,7 +1075,11 @@ var ReactCompositeComponent = { ); } else { var oldHostNode = ReactReconciler.getHostNode(prevComponentInstance); - ReactReconciler.unmountComponent(prevComponentInstance, false); + ReactReconciler.unmountComponent( + prevComponentInstance, + safely, + false /* skipLifecycle */ + ); var nodeType = ReactNodeTypes.getType(nextRenderedElement); this._renderedNodeType = nodeType; diff --git a/src/renderers/shared/stack/reconciler/ReactMultiChild.js b/src/renderers/shared/stack/reconciler/ReactMultiChild.js index 220de49f3e..18fa1e8d0b 100644 --- a/src/renderers/shared/stack/reconciler/ReactMultiChild.js +++ b/src/renderers/shared/stack/reconciler/ReactMultiChild.js @@ -289,7 +289,11 @@ var ReactMultiChild = { updateTextContent: function(nextContent) { var prevChildren = this._renderedChildren; // Remove any rendered children. - ReactChildReconciler.unmountChildren(prevChildren, false); + ReactChildReconciler.unmountChildren( + prevChildren, + false, /* safely */ + false /* skipLifecycle */ + ); for (var name in prevChildren) { if (prevChildren.hasOwnProperty(name)) { invariant(false, 'updateTextContent called on non-empty component.'); @@ -309,7 +313,11 @@ var ReactMultiChild = { updateMarkup: function(nextMarkup) { var prevChildren = this._renderedChildren; // Remove any rendered children. - ReactChildReconciler.unmountChildren(prevChildren, false); + ReactChildReconciler.unmountChildren( + prevChildren, + false, /* safely */ + false /* skipLifecycle */ + ); for (var name in prevChildren) { if (prevChildren.hasOwnProperty(name)) { invariant(false, 'updateTextContent called on non-empty component.'); @@ -423,9 +431,13 @@ var ReactMultiChild = { * * @internal */ - unmountChildren: function(safely) { + unmountChildren: function(safely, skipLifecycle) { var renderedChildren = this._renderedChildren; - ReactChildReconciler.unmountChildren(renderedChildren, safely); + ReactChildReconciler.unmountChildren( + renderedChildren, + safely, + skipLifecycle + ); this._renderedChildren = null; }, diff --git a/src/renderers/shared/stack/reconciler/ReactReconciler.js b/src/renderers/shared/stack/reconciler/ReactReconciler.js index 3e5f1658ca..f55bf339ee 100644 --- a/src/renderers/shared/stack/reconciler/ReactReconciler.js +++ b/src/renderers/shared/stack/reconciler/ReactReconciler.js @@ -93,7 +93,7 @@ var ReactReconciler = { * @final * @internal */ - unmountComponent: function(internalInstance, safely) { + unmountComponent: function(internalInstance, safely, skipLifecycle) { if (__DEV__) { if (internalInstance._debugID !== 0) { ReactInstrumentation.debugTool.onBeforeUnmountComponent( @@ -102,7 +102,7 @@ var ReactReconciler = { } } ReactRef.detachRefs(internalInstance, internalInstance._currentElement); - internalInstance.unmountComponent(safely); + internalInstance.unmountComponent(safely, skipLifecycle); if (__DEV__) { if (internalInstance._debugID !== 0) { ReactInstrumentation.debugTool.onUnmountComponent( diff --git a/src/renderers/shared/stack/reconciler/ReactSimpleEmptyComponent.js b/src/renderers/shared/stack/reconciler/ReactSimpleEmptyComponent.js index a81793eab5..dc3c82077f 100644 --- a/src/renderers/shared/stack/reconciler/ReactSimpleEmptyComponent.js +++ b/src/renderers/shared/stack/reconciler/ReactSimpleEmptyComponent.js @@ -40,8 +40,12 @@ Object.assign(ReactSimpleEmptyComponent.prototype, { getHostNode: function() { return ReactReconciler.getHostNode(this._renderedComponent); }, - unmountComponent: function() { - ReactReconciler.unmountComponent(this._renderedComponent); + unmountComponent: function(safely, skipLifecycle) { + ReactReconciler.unmountComponent( + this._renderedComponent, + safely, + skipLifecycle + ); this._renderedComponent = null; }, }); diff --git a/src/renderers/shared/stack/reconciler/__tests__/ReactErrorBoundaries-test.js b/src/renderers/shared/stack/reconciler/__tests__/ReactErrorBoundaries-test.js index 75c8654e9b..ac5e410cad 100644 --- a/src/renderers/shared/stack/reconciler/__tests__/ReactErrorBoundaries-test.js +++ b/src/renderers/shared/stack/reconciler/__tests__/ReactErrorBoundaries-test.js @@ -15,291 +15,1492 @@ var React; var ReactDOM; describe('ReactErrorBoundaries', () => { + var log; + + var BrokenConstructor; + var BrokenComponentWillMount; + var BrokenComponentDidMount; + var BrokenComponentWillReceiveProps; + var BrokenComponentWillUpdate; + var BrokenComponentDidUpdate; + var BrokenComponentWillUnmount; + var BrokenRenderErrorBoundary; + var BrokenComponentWillMountErrorBoundary; + var BrokenRender; + var ErrorBoundary; + var ErrorMessage; + var NoopErrorBoundary; + var Normal; beforeEach(() => { ReactDOM = require('ReactDOM'); React = require('React'); - }); - it('does not register event handlers for unmounted children', () => { - class Angry extends React.Component { - render() { - throw new Error('Please, do not render me.'); - } - } + log = []; - class Boundary extends React.Component { + BrokenConstructor = class extends React.Component { constructor(props) { super(props); - this.state = {error: false}; + log.push('BrokenConstructor constructor [!]'); + throw new Error('Hello'); } render() { - if (!this.state.error) { - return ( -
- ); - } else { - return
Happy Birthday!
; - } - } - onClick() { - /* do nothing */ - } - unstable_handleError() { - this.setState({error: true}); - } - } - - var EventPluginHub = require('EventPluginHub'); - var container = document.createElement('div'); - EventPluginHub.putListener = jest.fn(); - ReactDOM.render(, container); - expect(EventPluginHub.putListener).not.toBeCalled(); - }); - - it('renders an error state', () => { - var log = []; - class Angry extends React.Component { - render() { - log.push('Angry render'); - throw new Error('Please, do not render me.'); - } - componentDidMount() { - log.push('Angry componentDidMount'); - } - componentWillUnmount() { - log.push('Angry componentWillUnmount'); - } - } - - class Boundary extends React.Component { - constructor(props) { - super(props); - this.state = {error: false}; - } - render() { - log.push('Boundary render'); - if (!this.state.error) { - return ( -
- ); - } else { - return
Happy Birthday!
; - } - } - componentDidMount() { - log.push('Boundary componentDidMount'); - } - componentWillUnmount() { - log.push('Boundary componentWillUnmount'); - } - onClick() { - /* do nothing */ - } - unstable_handleError() { - this.setState({error: true}); - } - } - - var container = document.createElement('div'); - ReactDOM.render(, container); - expect(container.firstChild.innerHTML).toBe('Happy Birthday!'); - expect(log).toEqual([ - 'Boundary render', - 'Angry render', - 'Boundary render', - 'Boundary componentDidMount', - ]); - }); - - it('will catch exceptions in componentWillUnmount', () => { - class ErrorBoundary extends React.Component { - constructor() { - super(); - this.state = {error: false}; - } - - render() { - if (!this.state.error) { - return
{this.props.children}
; - } - return
Error has been caught
; - } - - unstable_handleError() { - this.setState({error: true}); - } - } - - class BrokenRender extends React.Component { - render() { - throw new Error('Always broken.'); - } - } - - class BrokenUnmount extends React.Component { - render() { + log.push('BrokenConstructor render'); return
; } + componentWillMount() { + log.push('BrokenConstructor componentWillMount'); + } + componentDidMount() { + log.push('BrokenConstructor componentDidMount'); + } + componentWillReceiveProps() { + log.push('BrokenConstructor componentWillReceiveProps'); + } + componentWillUpdate() { + log.push('BrokenConstructor componentWillUpdate'); + } + componentDidUpdate() { + log.push('BrokenConstructor componentDidUpdate'); + } componentWillUnmount() { - throw new Error('Always broken.'); + log.push('BrokenConstructor componentWillUnmount'); + } + }; + + BrokenComponentWillMount = class extends React.Component { + constructor(props) { + super(props); + log.push('BrokenComponentWillMount constructor'); + } + render() { + log.push('BrokenComponentWillMount render'); + return
; + } + componentWillMount() { + log.push('BrokenComponentWillMount componentWillMount [!]'); + throw new Error('Hello'); + } + componentDidMount() { + log.push('BrokenComponentWillMount componentDidMount'); + } + componentWillReceiveProps() { + log.push('BrokenComponentWillMount componentWillReceiveProps'); + } + 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
; + } + componentWillMount() { + log.push('BrokenComponentDidMount componentWillMount'); + } + componentDidMount() { + log.push('BrokenComponentDidMount componentDidMount [!]'); + throw new Error('Hello'); + } + componentWillReceiveProps() { + log.push('BrokenComponentDidMount componentWillReceiveProps'); + } + 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
; + } + componentWillMount() { + log.push('BrokenComponentWillReceiveProps componentWillMount'); + } + componentDidMount() { + log.push('BrokenComponentWillReceiveProps componentDidMount'); + } + componentWillReceiveProps() { + log.push('BrokenComponentWillReceiveProps componentWillReceiveProps [!]'); + throw new Error('Hello'); + } + 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
; + } + componentWillMount() { + log.push('BrokenComponentWillUpdate componentWillMount'); + } + componentDidMount() { + log.push('BrokenComponentWillUpdate componentDidMount'); + } + componentWillReceiveProps() { + log.push('BrokenComponentWillUpdate componentWillReceiveProps'); + } + componentWillUpdate() { + log.push('BrokenComponentWillUpdate componentWillUpdate [!]'); + throw new Error('Hello'); + } + componentDidUpdate() { + log.push('BrokenComponentWillUpdate componentDidUpdate'); + } + componentWillUnmount() { + log.push('BrokenComponentWillUpdate componentWillUnmount'); + } + }; + + BrokenComponentDidUpdate = class extends React.Component { + constructor(props) { + super(props); + log.push('BrokenComponentDidUpdate constructor'); + } + render() { + log.push('BrokenComponentDidUpdate render'); + return
; + } + componentWillMount() { + log.push('BrokenComponentDidUpdate componentWillMount'); + } + componentDidMount() { + log.push('BrokenComponentDidUpdate componentDidMount'); + } + componentWillReceiveProps() { + log.push('BrokenComponentDidUpdate componentWillReceiveProps'); + } + componentWillUpdate() { + log.push('BrokenComponentDidUpdate componentWillUpdate'); + } + componentDidUpdate() { + log.push('BrokenComponentDidUpdate componentDidUpdate [!]'); + throw new Error('Hello'); + } + componentWillUnmount() { + log.push('BrokenComponentDidUpdate componentWillUnmount'); + } + }; + + BrokenComponentWillUnmount = class extends React.Component { + constructor(props) { + super(props); + log.push('BrokenComponentWillUnmount constructor'); + } + render() { + log.push('BrokenComponentWillUnmount render'); + return
; + } + componentWillMount() { + log.push('BrokenComponentWillUnmount componentWillMount'); + } + componentDidMount() { + log.push('BrokenComponentWillUnmount componentDidMount'); + } + componentWillReceiveProps() { + log.push('BrokenComponentWillUnmount componentWillReceiveProps'); + } + componentWillUpdate() { + log.push('BrokenComponentWillUnmount componentWillUpdate'); + } + componentDidUpdate() { + log.push('BrokenComponentWillUnmount componentDidUpdate'); + } + componentWillUnmount() { + log.push('BrokenComponentWillUnmount componentWillUnmount [!]'); + throw new Error('Hello'); + } + }; + + 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}
; + } + componentWillMount() { + log.push('BrokenComponentWillMountErrorBoundary componentWillMount [!]'); + throw new Error('Hello'); + } + componentDidMount() { + log.push('BrokenComponentWillMountErrorBoundary componentDidMount'); + } + componentWillUnmount() { + log.push('BrokenComponentWillMountErrorBoundary componentWillUnmount'); + } + unstable_handleError(error) { + log.push('BrokenComponentWillMountErrorBoundary unstable_handleError'); + 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}
; + } + componentWillMount() { + log.push('BrokenRenderErrorBoundary componentWillMount'); + } + componentDidMount() { + log.push('BrokenRenderErrorBoundary componentDidMount'); + } + componentWillUnmount() { + log.push('BrokenRenderErrorBoundary componentWillUnmount'); + } + unstable_handleError(error) { + log.push('BrokenRenderErrorBoundary unstable_handleError'); + 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'); + } + componentWillMount() { + log.push('BrokenRender componentWillMount'); + } + componentDidMount() { + log.push('BrokenRender componentDidMount'); + } + componentWillReceiveProps() { + log.push('BrokenRender componentWillReceiveProps'); + } + 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 ; + } + componentWillMount() { + log.push('NoopErrorBoundary componentWillMount'); + } + componentDidMount() { + log.push('NoopErrorBoundary componentDidMount'); + } + componentWillUnmount() { + log.push('NoopErrorBoundary componentWillUnmount'); + } + unstable_handleError() { + log.push('NoopErrorBoundary unstable_handleError'); + } + }; + + 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}
; + } + componentWillMount() { + log.push(`${this.props.logName} componentWillMount`); + } + componentDidMount() { + log.push(`${this.props.logName} componentDidMount`); + } + componentWillReceiveProps() { + log.push(`${this.props.logName} componentWillReceiveProps`); + } + 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() { + super(); + this.state = {error: null}; + log.push('ErrorBoundary constructor'); + } + render() { + if (this.state.error && !this.props.forceRetry) { + log.push('ErrorBoundary render error'); + return this.props.renderError(this.state.error, this.props); + } + log.push('ErrorBoundary render success'); + return
{this.props.children}
; + } + unstable_handleError(error) { + log.push('ErrorBoundary unstable_handleError'); + this.setState({error}); + } + componentWillMount() { + log.push('ErrorBoundary componentWillMount'); + } + componentDidMount() { + log.push('ErrorBoundary componentDidMount'); + } + componentWillReceiveProps() { + log.push('ErrorBoundary componentWillReceiveProps'); + } + componentWillUpdate() { + log.push('ErrorBoundary componentWillUpdate'); + } + componentDidUpdate() { + log.push('ErrorBoundary componentDidUpdate'); + } + componentWillUnmount() { + log.push('ErrorBoundary componentWillUnmount'); + } + }; + ErrorBoundary.defaultProps = { + renderError(error, props) { + return ( +
+ Caught an error: {error.message}. +
+ ); + }, + }; + + ErrorMessage = class extends React.Component { + constructor(props) { + super(props); + log.push('ErrorMessage constructor'); + } + 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}.
; + } + }; + }); + + // Known limitation: error boundary only "sees" errors caused by updates + // flowing through it. This might be easier to fix in Fiber. + it('currently does not catch errors originating downstream', () => { + var fail = false; + class Stateful extends React.Component { + state = {shouldThrow: false}; + + render() { + if (fail) { + log.push('Stateful render [!]'); + throw new Error('Hello'); + } + return
; } } + var statefulInst; var container = document.createElement('div'); ReactDOM.render( - - - + statefulInst = inst} /> , container ); - ReactDOM.unmountComponentAtNode(container); - }); - it('expect uneventful render to succeed', () => { - var log = []; - class Boundary extends React.Component { - constructor(props) { - super(props); - this.state = {error: false}; - } - render() { - log.push('Boundary render'); - return
; - } - onClick() { - /* do nothing */ - } - componentDidMount() { - log.push('Boundary componentDidMount'); - } - componentWillUnmount() { - log.push('Boundary componentWillUnmount'); - } - unstable_handleError() { - this.setState({error: true}); - } - } + log.length = 0; + expect(() => { + fail = true; + statefulInst.forceUpdate(); + }).toThrow(); - var container = document.createElement('div'); - ReactDOM.render(, container); expect(log).toEqual([ - 'Boundary render', - 'Boundary componentDidMount', + 'Stateful render [!]', + // FIXME: uncomment when downstream errors get caught. + // Catch and render an error message + // 'ErrorBoundary unstable_handleError', + // 'ErrorBoundary render error', + // 'ErrorBoundary componentDidUpdate', + ]); + + log.length = 0; + ReactDOM.unmountComponentAtNode(container); + expect(log).toEqual([ + 'ErrorBoundary componentWillUnmount', ]); }); - it('correctly handles composite siblings', () => { - class ErrorBoundary extends React.Component { - constructor() { - super(); - this.state = {error: false}; - } + it('renders an error state if child throws in render', () => { + var 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 [!]', + // Catch and render an error message + 'ErrorBoundary unstable_handleError', + 'ErrorBoundary render error', + 'ErrorBoundary componentDidMount', + ]); - render() { - if (!this.state.error) { - return
{this.props.children}
; - } - return
Error has been caught
; - } + log.length = 0; + ReactDOM.unmountComponentAtNode(container); + expect(log).toEqual([ + 'ErrorBoundary componentWillUnmount', + ]); + }); - unstable_handleError() { - this.setState({error: true}); - } - } + it('renders an error state if child throws in constructor', () => { + var 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 [!]', + // Catch and render an error message + 'ErrorBoundary unstable_handleError', + 'ErrorBoundary render error', + 'ErrorBoundary componentDidMount', + ]); - function Broken() { - throw new Error('Always broken.'); - } + log.length = 0; + ReactDOM.unmountComponentAtNode(container); + expect(log).toEqual([ + 'ErrorBoundary componentWillUnmount', + ]); + }); - function Composite() { - return
; + it('renders an error state if child throws in componentWillMount', () => { + var 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 [!]', + // Catch and render an error message + 'ErrorBoundary unstable_handleError', + 'ErrorBoundary render error', + 'ErrorBoundary componentDidMount', + ]); + + log.length = 0; + ReactDOM.unmountComponentAtNode(container); + expect(log).toEqual([ + 'ErrorBoundary componentWillUnmount', + ]); + }); + + it('mounts the error message if mounting fails', () => { + function renderError(error) { + return ( + + ); } var container = document.createElement('div'); ReactDOM.render( - , + + + , container ); + expect(log).toEqual([ + 'ErrorBoundary constructor', + 'ErrorBoundary componentWillMount', + 'ErrorBoundary render success', + 'BrokenRender constructor', + 'BrokenRender componentWillMount', + 'BrokenRender render [!]', + // Handle the error: + 'ErrorBoundary unstable_handleError', + 'ErrorBoundary render error', + // Mount the error message: + 'ErrorMessage constructor', + 'ErrorMessage componentWillMount', + 'ErrorMessage render', + 'ErrorMessage componentDidMount', + 'ErrorBoundary componentDidMount', + ]); + + log.length = 0; ReactDOM.unmountComponentAtNode(container); + expect(log).toEqual([ + 'ErrorBoundary componentWillUnmount', + 'ErrorMessage componentWillUnmount', + ]); }); - it('catches errors from children', () => { - var log = []; + // Known limitation because componentDidMount() does not occur on the stack. + // We could either hardcode searching for parent boundary, or wait for Fiber. + it('currently does not catch errors in componentDidMount', () => { + var container = document.createElement('div'); + expect(() => { + ReactDOM.render( + + + , + container + ); + }).toThrow(); + expect(log).toEqual([ + 'ErrorBoundary constructor', + 'ErrorBoundary componentWillMount', + 'ErrorBoundary render success', + 'BrokenComponentDidMount constructor', + 'BrokenComponentDidMount componentWillMount', + 'BrokenComponentDidMount render', + 'BrokenComponentDidMount componentDidMount [!]', + // FIXME: uncomment when componentDidMount() gets caught. + // Catch and render an error message + // 'ErrorBoundary unstable_handleError', + // 'ErrorBoundary render error', + // 'ErrorBoundary componentDidMount', + ]); - class Box extends React.Component { - constructor(props) { - super(props); - this.state = {errorMessage: null}; - } - render() { - if (this.state.errorMessage != null) { - log.push('Box renderError'); - return
Error: {this.state.errorMessage}
; - } - log.push('Box render'); - var ref = function(x) { - log.push('Inquisitive ref ' + x); - }; - return ( -
- - -
- ); - } - unstable_handleError(e) { - this.setState({errorMessage: e.message}); - } - componentDidMount() { - log.push('Box componentDidMount'); - } - componentWillUnmount() { - log.push('Box componentWillUnmount'); - } + log.length = 0; + ReactDOM.unmountComponentAtNode(container); + expect(log).toEqual([ + 'ErrorBoundary componentWillUnmount', + 'BrokenComponentDidMount componentWillUnmount', + ]); + }); + + it('propagates errors on retry on mounting', () => { + var 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', + 'NoopErrorBoundary constructor', + 'NoopErrorBoundary componentWillMount', + 'NoopErrorBoundary render', + 'BrokenRender constructor', + 'BrokenRender componentWillMount', + 'BrokenRender render [!]', + // The first error boundary catches the error + // However, it doesn't adjust its state so next render also fails + 'NoopErrorBoundary unstable_handleError', + 'NoopErrorBoundary render', + 'BrokenRender constructor', + 'BrokenRender componentWillMount', + 'BrokenRender render [!]', + // This time, the error propagates to the higher boundary + 'ErrorBoundary unstable_handleError', + // Render the error + 'ErrorBoundary render error', + 'ErrorBoundary componentDidMount', + ]); + + log.length = 0; + ReactDOM.unmountComponentAtNode(container); + expect(log).toEqual([ + 'ErrorBoundary componentWillUnmount', + ]); + }); + + it('propagates errors inside boundary during componentWillMount', () => { + var 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 unstable_handleError', + // Render the error + 'ErrorBoundary render error', + 'ErrorBoundary componentDidMount', + ]); + + log.length = 0; + ReactDOM.unmountComponentAtNode(container); + expect(log).toEqual([ + 'ErrorBoundary componentWillUnmount', + ]); + }); + + it('propagates errors inside boundary while rendering error state', () => { + var 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 + 'BrokenRenderErrorBoundary unstable_handleError', + 'BrokenRenderErrorBoundary render error [!]', + // The error propagates to the higher boundary + 'ErrorBoundary unstable_handleError', + // Render the error + 'ErrorBoundary render error', + 'ErrorBoundary componentDidMount', + ]); + + log.length = 0; + ReactDOM.unmountComponentAtNode(container); + expect(log).toEqual([ + 'ErrorBoundary componentWillUnmount', + ]); + }); + + it('does not register event handlers for unmounted children', () => { + var EventPluginHub = require('EventPluginHub'); + var container = document.createElement('div'); + EventPluginHub.putListener = jest.fn(); + ReactDOM.render( + + + + , + container + ); + expect(EventPluginHub.putListener).not.toBeCalled(); + + log.length = 0; + ReactDOM.unmountComponentAtNode(container); + expect(log).toEqual([ + 'ErrorBoundary componentWillUnmount', + ]); + }); + + it('does not call componentWillUnmount when aborting initial mount', () => { + var 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 [!]', + // Error boundary catches the error + 'ErrorBoundary unstable_handleError', + // Render the error message + 'ErrorBoundary render error', + 'ErrorBoundary componentDidMount', + ]); + + log.length = 0; + ReactDOM.unmountComponentAtNode(container); + expect(log).toEqual([ + 'ErrorBoundary componentWillUnmount', + ]); + }); + + it('resets refs if mounting aborts', () => { + function childRef(x) { + log.push('Child ref is set to ' + x); } - - class Inquisitive extends React.Component { - render() { - log.push('Inquisitive render'); - return
What is love?
; - } - componentDidMount() { - log.push('Inquisitive componentDidMount'); - } - componentWillUnmount() { - log.push('Inquisitive componentWillUnmount'); - } - } - - class Angry extends React.Component { - render() { - log.push('Angry render'); - throw new Error('Please, do not render me.'); - } - componentDidMount() { - log.push('Angry componentDidMount'); - } - componentWillUnmount() { - log.push('Angry componentWillUnmount'); - } + function errorMessageRef(x) { + log.push('Error message ref is set to ' + x); } var container = document.createElement('div'); - ReactDOM.render(, container); - expect(container.textContent).toBe('Error: Please, do not render me.'); + 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: + 'ErrorBoundary unstable_handleError', + // Child ref wasn't (and won't be) set but there's no harm in clearing: + 'Child ref is set to null', + 'ErrorBoundary render error', + // Ref to error message should get set: + 'Error message ref is set to [object HTMLDivElement]', + 'ErrorBoundary componentDidMount', + ]); + + log.length = 0; ReactDOM.unmountComponentAtNode(container); expect(log).toEqual([ - 'Box render', - 'Inquisitive render', - 'Angry render', - 'Inquisitive ref null', - 'Inquisitive componentWillUnmount', - 'Box renderError', - 'Box componentDidMount', - 'Box componentWillUnmount', + 'ErrorBoundary componentWillUnmount', + 'Error message ref is set to null', + ]); + }); + + it('successfully mounts if no error occurs', () => { + var 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', () => { + var 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 [!]', + 'ErrorBoundary unstable_handleError', + // Unmount the previously mounted components: + 'Normal componentWillUnmount', + // Normal2 does not get lifefycle because it was never mounted + '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', () => { + var 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 [!]', + 'ErrorBoundary unstable_handleError', + // Unmount the previously mounted components: + 'Normal componentWillUnmount', + // Normal2 does not get lifefycle because it was never mounted + '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', () => { + var 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 [!]', + 'ErrorBoundary unstable_handleError', + // Unmount the previously mounted components: + 'Normal componentWillUnmount', + 'BrokenComponentWillReceiveProps componentWillUnmount', + // Render error: + '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', () => { + var 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 [!]', + 'ErrorBoundary unstable_handleError', + // Unmount the previously mounted components: + 'Normal componentWillUnmount', + 'BrokenComponentWillUpdate componentWillUnmount', + // Render error: + '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', () => { + var 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 [!]', + 'ErrorBoundary unstable_handleError', + // Unmount the previously mounted components: + 'Normal componentWillUnmount', + // Normal2 does not get lifefycle because it was never mounted + '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); + } + + var 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 [!]', + 'ErrorBoundary unstable_handleError', + // Unmount the previously mounted components: + 'Child1 ref is set to null', + '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', + ]); + }); + + // Known limitation because componentDidUpdate() does not occur on the stack. + // We could either hardcode searching for parent boundary, or wait for Fiber. + it('currently does not catch errors in componentDidUpdate', () => { + var container = document.createElement('div'); + ReactDOM.render( + + + , + container + ); + + log.length = 0; + expect(() => { + ReactDOM.render( + + + , + container + ); + }).toThrow(); + expect(log).toEqual([ + 'ErrorBoundary componentWillReceiveProps', + 'ErrorBoundary componentWillUpdate', + 'ErrorBoundary render success', + 'BrokenComponentDidUpdate componentWillReceiveProps', + 'BrokenComponentDidUpdate componentWillUpdate', + 'BrokenComponentDidUpdate render', + 'BrokenComponentDidUpdate componentDidUpdate [!]', + // FIXME: uncomment when componentDidUpdate() gets caught. + // Catch and render an error message + // 'ErrorBoundary unstable_handleError', + // 'ErrorBoundary render error', + // 'ErrorBoundary componentDidUpdate', + ]); + + log.length = 0; + ReactDOM.unmountComponentAtNode(container); + expect(log).toEqual([ + 'ErrorBoundary componentWillUnmount', + 'BrokenComponentDidUpdate componentWillUnmount', + ]); + }); + + it('recovers from componentWillUnmount errors on update', () => { + var 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: + 'BrokenComponentWillUnmount componentWillReceiveProps', + 'BrokenComponentWillUnmount componentWillUpdate', + 'BrokenComponentWillUnmount render', + 'BrokenComponentWillUnmount componentWillReceiveProps', + 'BrokenComponentWillUnmount componentWillUpdate', + 'BrokenComponentWillUnmount render', + // Unmounting throws: + 'BrokenComponentWillUnmount componentWillUnmount [!]', + 'ErrorBoundary unstable_handleError', + // Attempt to unmount previous children: + 'BrokenComponentWillUnmount componentWillUnmount [!]', + 'BrokenComponentWillUnmount componentWillUnmount [!]', + // Render error: + 'ErrorBoundary render error', + 'ErrorBoundary componentDidUpdate', + // Children don't get componentDidUpdate() since update was aborted + ]); + + log.length = 0; + ReactDOM.unmountComponentAtNode(container); + expect(log).toEqual([ + 'ErrorBoundary componentWillUnmount', + ]); + }); + + it('recovers from nested componentWillUnmount errors on update', () => { + var 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 [!]', + 'ErrorBoundary unstable_handleError', + // Attempt to unmount previous children: + 'Normal componentWillUnmount', + 'BrokenComponentWillUnmount componentWillUnmount [!]', + // Render error: + 'ErrorBoundary render error', + 'ErrorBoundary componentDidUpdate', + ]); + + log.length = 0; + ReactDOM.unmountComponentAtNode(container); + expect(log).toEqual([ + 'ErrorBoundary componentWillUnmount', + ]); + }); + + it('can recover from error state', () => { + var 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', () => { + var 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', () => { + var 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', () => { + var 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() { + var elements = []; + for (var i = 0; i < 100; i++) { + elements.push(); + } + elements.push(); + + var currentIndex = elements.length; + while (0 !== currentIndex) { + var randomIndex = Math.floor(Math.random() * currentIndex); + currentIndex -= 1; + var 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
; + } + } + + var fail = false; + var 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', ]); }); }); diff --git a/src/renderers/testing/ReactTestMount.js b/src/renderers/testing/ReactTestMount.js index f13e5d20f6..bed525c431 100644 --- a/src/renderers/testing/ReactTestMount.js +++ b/src/renderers/testing/ReactTestMount.js @@ -128,7 +128,8 @@ ReactTestInstance.prototype.unmount = function(nextElement) { transaction.perform(function() { ReactReconciler.unmountComponent( component, - false + false, /* safely */ + false /* skipLifecycle */ ); }); ReactUpdates.ReactReconcileTransaction.release(transaction); diff --git a/src/test/ReactShallowRenderer.js b/src/test/ReactShallowRenderer.js index e16f847611..ca2e245b1f 100644 --- a/src/test/ReactShallowRenderer.js +++ b/src/test/ReactShallowRenderer.js @@ -117,7 +117,11 @@ class ReactShallowRenderer { } unmount() { if (this._instance) { - ReactReconciler.unmountComponent(this._instance, false); + ReactReconciler.unmountComponent( + this._instance, + false, /* safely */ + false /* skipLifecycle */ + ); } } _render(element, transaction, context) {