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 | 1579 +++++++++++++++-- src/renderers/testing/ReactTestMount.js | 3 +- src/test/ReactShallowRenderer.js | 6 +- 11 files changed, 1574 insertions(+), 248 deletions(-) diff --git a/src/renderers/dom/client/ReactMount.js b/src/renderers/dom/client/ReactMount.js index c0e3c9ec00e2e..4c9a77f8c7dbe 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 ef75df0c42b8b..0da6954928477 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 2192b019fc4ab..1d47e9dbdfde5 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 d487e70999f04..dc58ff648e06d 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 56869d0ae6987..3c49fc464df14 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 220de49f3e6b5..18fa1e8d0bd94 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 3e5f1658ca8cf..f55bf339ee7aa 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 a81793eab56c9..dc3c82077f4ff 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 75c8654e9b5ec..ac5e410cad911 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 { + log = []; + + BrokenConstructor = class extends React.Component { + constructor(props) { + super(props); + log.push('BrokenConstructor constructor [!]'); + throw new Error('Hello'); + } render() { - throw new Error('Please, do not render me.'); + 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() { + log.push('BrokenConstructor componentWillUnmount'); + } + }; - class Boundary extends React.Component { + BrokenComponentWillMount = class extends React.Component { constructor(props) { super(props); - this.state = {error: false}; + log.push('BrokenComponentWillMount constructor'); } render() { - if (!this.state.error) { - return ( -
- ); - } else { - return
Happy Birthday!
; - } + log.push('BrokenComponentWillMount render'); + return
; } - onClick() { - /* do nothing */ + componentWillMount() { + log.push('BrokenComponentWillMount componentWillMount [!]'); + throw new Error('Hello'); } - unstable_handleError() { - this.setState({error: true}); + componentDidMount() { + log.push('BrokenComponentWillMount componentDidMount'); } - } - - var EventPluginHub = require('EventPluginHub'); - var container = document.createElement('div'); - EventPluginHub.putListener = jest.fn(); - ReactDOM.render(, container); - expect(EventPluginHub.putListener).not.toBeCalled(); - }); + componentWillReceiveProps() { + log.push('BrokenComponentWillMount componentWillReceiveProps'); + } + componentWillUpdate() { + log.push('BrokenComponentWillMount componentWillUpdate'); + } + componentDidUpdate() { + log.push('BrokenComponentWillMount componentDidUpdate'); + } + componentWillUnmount() { + log.push('BrokenComponentWillMount componentWillUnmount'); + } + }; - it('renders an error state', () => { - var log = []; - class Angry extends React.Component { + BrokenComponentDidMount = class extends React.Component { + constructor(props) { + super(props); + log.push('BrokenComponentDidMount constructor'); + } render() { - log.push('Angry render'); - throw new Error('Please, do not render me.'); + log.push('BrokenComponentDidMount render'); + return
; + } + componentWillMount() { + log.push('BrokenComponentDidMount componentWillMount'); } componentDidMount() { - log.push('Angry 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('Angry componentWillUnmount'); + log.push('BrokenComponentDidMount componentWillUnmount'); } - } + }; - class Boundary extends React.Component { + BrokenComponentWillReceiveProps = class extends React.Component { constructor(props) { super(props); - this.state = {error: false}; + log.push('BrokenComponentWillReceiveProps constructor'); } render() { - log.push('Boundary render'); - if (!this.state.error) { - return ( -
- ); - } else { - return
Happy Birthday!
; - } + log.push('BrokenComponentWillReceiveProps render'); + return
; + } + componentWillMount() { + log.push('BrokenComponentWillReceiveProps componentWillMount'); } componentDidMount() { - log.push('Boundary componentDidMount'); + log.push('BrokenComponentWillReceiveProps componentDidMount'); } - componentWillUnmount() { - log.push('Boundary componentWillUnmount'); + componentWillReceiveProps() { + log.push('BrokenComponentWillReceiveProps componentWillReceiveProps [!]'); + throw new Error('Hello'); } - onClick() { - /* do nothing */ + componentWillUpdate() { + log.push('BrokenComponentWillReceiveProps componentWillUpdate'); } - unstable_handleError() { - this.setState({error: true}); + componentDidUpdate() { + log.push('BrokenComponentWillReceiveProps componentDidUpdate'); } - } - - 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}; + componentWillUnmount() { + log.push('BrokenComponentWillReceiveProps componentWillUnmount'); } + }; + BrokenComponentWillUpdate = class extends React.Component { + constructor(props) { + super(props); + log.push('BrokenComponentWillUpdate constructor'); + } render() { - if (!this.state.error) { - return
{this.props.children}
; - } - return
Error has been caught
; + log.push('BrokenComponentWillUpdate render'); + return
; } - - unstable_handleError() { - this.setState({error: true}); + 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'); + } + }; - class BrokenRender extends React.Component { + BrokenComponentDidUpdate = class extends React.Component { + constructor(props) { + super(props); + log.push('BrokenComponentDidUpdate constructor'); + } render() { - throw new Error('Always broken.'); + 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'); + } + }; - class BrokenUnmount extends React.Component { + 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() { - throw new Error('Always broken.'); + log.push('BrokenComponentWillUnmount componentWillUnmount [!]'); + throw new Error('Hello'); } - } - - var container = document.createElement('div'); - ReactDOM.render( - - - - - , - container - ); - ReactDOM.unmountComponentAtNode(container); - }); + }; - it('expect uneventful render to succeed', () => { - var log = []; - class Boundary extends React.Component { + BrokenComponentWillMountErrorBoundary = class extends React.Component { constructor(props) { super(props); - this.state = {error: false}; + this.state = {error: null}; + log.push('BrokenComponentWillMountErrorBoundary constructor'); } render() { - log.push('Boundary render'); - return
; + 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}
; } - onClick() { - /* do nothing */ + componentWillMount() { + log.push('BrokenComponentWillMountErrorBoundary componentWillMount [!]'); + throw new Error('Hello'); } componentDidMount() { - log.push('Boundary componentDidMount'); + log.push('BrokenComponentWillMountErrorBoundary componentDidMount'); } componentWillUnmount() { - log.push('Boundary componentWillUnmount'); + log.push('BrokenComponentWillMountErrorBoundary componentWillUnmount'); } - unstable_handleError() { - this.setState({error: true}); + unstable_handleError(error) { + log.push('BrokenComponentWillMountErrorBoundary unstable_handleError'); + this.setState({error}); } - } - - var container = document.createElement('div'); - ReactDOM.render(, container); - expect(log).toEqual([ - 'Boundary render', - 'Boundary componentDidMount', - ]); - }); + }; - it('correctly handles composite siblings', () => { - class ErrorBoundary extends React.Component { - constructor() { - super(); - this.state = {error: false}; + BrokenRenderErrorBoundary = class extends React.Component { + constructor(props) { + super(props); + this.state = {error: null}; + log.push('BrokenRenderErrorBoundary constructor'); } - render() { - if (!this.state.error) { - return
{this.props.children}
; + if (this.state.error) { + log.push('BrokenRenderErrorBoundary render error [!]'); + throw new Error('Hello'); } - return
Error has been caught
; + log.push('BrokenRenderErrorBoundary render success'); + return
{this.props.children}
; } - - unstable_handleError() { - this.setState({error: true}); + componentWillMount() { + log.push('BrokenRenderErrorBoundary componentWillMount'); } - } - - function Broken() { - throw new Error('Always broken.'); - } - - function Composite() { - return
; - } - - var container = document.createElement('div'); - ReactDOM.render( - , - container - ); - ReactDOM.unmountComponentAtNode(container); - }); + componentDidMount() { + log.push('BrokenRenderErrorBoundary componentDidMount'); + } + componentWillUnmount() { + log.push('BrokenRenderErrorBoundary componentWillUnmount'); + } + unstable_handleError(error) { + log.push('BrokenRenderErrorBoundary unstable_handleError'); + this.setState({error}); + } + }; - it('catches errors from children', () => { - var log = []; + 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'); + } + }; - class Box extends React.Component { + NoopErrorBoundary = class extends React.Component { constructor(props) { super(props); - this.state = {errorMessage: null}; + log.push('NoopErrorBoundary constructor'); } 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 ( -
- - -
- ); + log.push('NoopErrorBoundary render'); + return ; } - unstable_handleError(e) { - this.setState({errorMessage: e.message}); + componentWillMount() { + log.push('NoopErrorBoundary componentWillMount'); } componentDidMount() { - log.push('Box componentDidMount'); + log.push('NoopErrorBoundary componentDidMount'); } componentWillUnmount() { - log.push('Box componentWillUnmount'); + log.push('NoopErrorBoundary componentWillUnmount'); } - } + unstable_handleError() { + log.push('NoopErrorBoundary unstable_handleError'); + } + }; - class Inquisitive extends React.Component { + Normal = class extends React.Component { + static defaultProps = { + logName: 'Normal', + }; + constructor(props) { + super(props); + log.push(`${this.props.logName} constructor`); + } render() { - log.push('Inquisitive render'); - return
What is love?
; + log.push(`${this.props.logName} render`); + return
{this.props.children}
; + } + componentWillMount() { + log.push(`${this.props.logName} componentWillMount`); } componentDidMount() { - log.push('Inquisitive 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('Inquisitive componentWillUnmount'); + log.push(`${this.props.logName} componentWillUnmount`); } - } + }; - class Angry extends React.Component { + ErrorBoundary = class extends React.Component { + constructor() { + super(); + this.state = {error: null}; + log.push('ErrorBoundary constructor'); + } render() { - log.push('Angry render'); - throw new Error('Please, do not render me.'); + 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('Angry componentDidMount'); + log.push('ErrorMessage componentDidMount'); } componentWillUnmount() { - log.push('Angry 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 + ); + + log.length = 0; + expect(() => { + fail = true; + statefulInst.forceUpdate(); + }).toThrow(); + + expect(log).toEqual([ + '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('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', + ]); + + log.length = 0; + ReactDOM.unmountComponentAtNode(container); + expect(log).toEqual([ + 'ErrorBoundary componentWillUnmount', + ]); + }); + + 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', + ]); + + log.length = 0; + ReactDOM.unmountComponentAtNode(container); + expect(log).toEqual([ + 'ErrorBoundary componentWillUnmount', + ]); + }); + + it('renders an error state if child throws in componentWillMount', () => { var container = document.createElement('div'); - ReactDOM.render(, container); - expect(container.textContent).toBe('Error: Please, do not render me.'); + 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', + ]); + }); + + // 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', + ]); + + 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); + } + function errorMessageRef(x) { + log.push('Error message ref is set to ' + x); + } + + var 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: + '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([ + '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([ - 'Box render', - 'Inquisitive render', - 'Angry render', - 'Inquisitive ref null', - 'Inquisitive componentWillUnmount', - 'Box renderError', - 'Box componentDidMount', - 'Box componentWillUnmount', + 'ErrorBoundary componentWillUnmount', ]); }); }); diff --git a/src/renderers/testing/ReactTestMount.js b/src/renderers/testing/ReactTestMount.js index f13e5d20f6d7f..bed525c4315ed 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 e16f8476117e5..ca2e245b1f992 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) {