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) {