From d959599acf7784b8b257cf3a63992af9050faf00 Mon Sep 17 00:00:00 2001 From: jim Date: Thu, 11 Feb 2016 09:58:19 -0800 Subject: [PATCH] Initial pass at the easy case of updates (updates that start at the root). --- .../__tests__/ReactErrorBoundaries-test.js | 205 +++++++++++++++++- .../reconciler/ReactCompositeComponent.js | 55 ++++- 2 files changed, 253 insertions(+), 7 deletions(-) diff --git a/src/core/__tests__/ReactErrorBoundaries-test.js b/src/core/__tests__/ReactErrorBoundaries-test.js index 0ba37323d5522..6eb4486230674 100644 --- a/src/core/__tests__/ReactErrorBoundaries-test.js +++ b/src/core/__tests__/ReactErrorBoundaries-test.js @@ -55,7 +55,7 @@ describe('ReactErrorBoundaries', function() { expect(EventPluginHub.putListener).not.toBeCalled(); }); - it('will catch exceptions in componentWillUnmount', function() { + it('will catch exceptions in componentWillUnmount initial render', function() { class ErrorBoundary extends React.Component { constructor() { super(); @@ -236,4 +236,207 @@ describe('ReactErrorBoundaries', function() { 'Box componentWillUnmount', ]); }); + + it('catches errors on update', function() { + var log = []; + + class Box extends React.Component { + constructor(props) { + super(props); + this.state = {errorMessage: null}; + } + render() { + if (this.state.errorMessage != null) { + log.push('Box renderError'); + return
Error: {this.state.errorMessage}
; + } + log.push('Box render'); + var ref = function(x) { + log.push('Inquisitive ref ' + x); + }; + return ( +
+ + {this.props.angry ? :
} +
+ ); + } + unstable_handleError(e) { + log.push('error handled'); + this.setState({errorMessage: e.message}); + } + componentDidMount() { + log.push('Box componentDidMount'); + } + componentWillUnmount() { + log.push('Box componentWillUnmount'); + } + } + + class Inquisitive extends React.Component { + render() { + log.push('Inquisitive render'); + return
What is love?
; + } + componentDidMount() { + log.push('Inquisitive componentDidMount'); + } + componentWillUnmount() { + log.push('Inquisitive componentWillUnmount'); + } + } + + class Angry extends React.Component { + render() { + log.push('Angry render'); + throw new Error('Please, do not render me.'); + } + componentDidMount() { + log.push('Angry componentDidMount'); + } + componentWillUnmount() { + log.push('Angry componentWillUnmount'); + } + } + + var container = document.createElement('div'); + ReactDOM.render(, container); + ReactDOM.render(, container); + expect(container.textContent).toBe('Error: Please, do not render me.'); + expect(log).toEqual([ + 'Box render', + 'Inquisitive render', + 'Inquisitive componentDidMount', + 'Inquisitive ref [object Object]', + 'Box componentDidMount', + 'Box render', + 'Inquisitive ref null', + 'Inquisitive render', + 'Angry render', + 'error handled', + 'Inquisitive ref null', + 'Inquisitive componentWillUnmount', + 'Box renderError', + ]); + }); + + it('catches componentWillUnmount errors on update', function() { + var log = []; + + class ErrorBoundary extends React.Component { + constructor(props) { + super(props); + this.state = {errorMessage: null}; + } + render() { + if (this.state.errorMessage != null) { + log.push('Box renderError'); + return
Error: I am now a sad component :(
; + } + log.push('Box render'); + + return ( +
+ + + {this.props.angry ? null : } +
+ ); + } + unstable_handleError(e) { + log.push('error handled'); + this.setState({errorMessage: e.message}); + } + componentDidMount() { + log.push('Box componentDidMount'); + } + componentWillUnmount() { + log.push('Box componentWillUnmount'); + } + } + + class BrokenUnmount extends React.Component { + render() { + return
; + } + componentWillUnmount() { + log.push('BrokenUnmount is attempting to unmount'); + throw new Error('Always broken.'); + } + } + + var container = document.createElement('div'); + ReactDOM.render(, container); + ReactDOM.render(, container); + expect(container.textContent).toBe('Error: I am now a sad component :('); + expect(log).toEqual([ + 'Box render', + 'Box componentDidMount', + 'Box render', + 'BrokenUnmount is attempting to unmount', + 'error handled', + 'BrokenUnmount is attempting to unmount', + 'BrokenUnmount is attempting to unmount', + 'BrokenUnmount is attempting to unmount', + 'Box renderError', + ]); + ReactDOM.unmountComponentAtNode(container); + expect(log).toEqual([ + 'Box render', + 'Box componentDidMount', + 'Box render', + 'BrokenUnmount is attempting to unmount', + 'error handled', + 'BrokenUnmount is attempting to unmount', + 'BrokenUnmount is attempting to unmount', + 'BrokenUnmount is attempting to unmount', + 'Box renderError', + 'Box componentWillUnmount', + ]); + }); + + it('catches componentWillUnmount errors nested children', function() { + class ErrorBoundary extends React.Component { + constructor(props) { + super(props); + this.state = {errorMessage: null}; + } + render() { + if (this.state.errorMessage != null) { + return
Error: I am now a sad component :(
; + } + + return ( +
+ + {this.props.angry ? null : } +
+ ); + } + unstable_handleError(e) { + this.setState({errorMessage: e.message}); + } + } + + class InnocentParent extends React.Component { + render() { + return ; + } + } + + class BrokenUnmount extends React.Component { + render() { + return
; + } + componentWillUnmount() { + throw new Error('Always broken.'); + } + } + + var container = document.createElement('div'); + ReactDOM.render(, container); + ReactDOM.render(, container); + expect(container.textContent).toBe('Error: I am now a sad component :('); + ReactDOM.unmountComponentAtNode(container); + }); }); diff --git a/src/renderers/shared/reconciler/ReactCompositeComponent.js b/src/renderers/shared/reconciler/ReactCompositeComponent.js index 65b28c6c773cf..e54eedd75dc96 100644 --- a/src/renderers/shared/reconciler/ReactCompositeComponent.js +++ b/src/renderers/shared/reconciler/ReactCompositeComponent.js @@ -342,7 +342,6 @@ var ReactCompositeComponentMixin = { this._instance.state = this._processPendingState(this._instance.props, this._instance.context); } checkpoint = transaction.checkpoint(); - this._renderedComponent.unmountComponent(true); transaction.rollback(checkpoint); @@ -406,7 +405,7 @@ var ReactCompositeComponentMixin = { var name = this.getName() + '.componentWillUnmount()'; ReactErrorUtils.invokeGuardedCallback(name, inst.componentWillUnmount.bind(inst)); } else { - inst.componentWillUnmount(); + inst.componentWillUnmount(safely); } } @@ -808,7 +807,11 @@ var ReactCompositeComponentMixin = { 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) { transaction.getReactMountReady().enqueue( @@ -818,6 +821,35 @@ var ReactCompositeComponentMixin = { } }, + /** + * 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); + + // 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. * @@ -825,19 +857,30 @@ var ReactCompositeComponentMixin = { * @internal */ _updateRenderedComponent: function(transaction, context) { + var nextRenderedElement = this._renderValidatedComponent(); + this._updateRenderedComponentWithNextElement(transaction, context, nextRenderedElement, false); + }, + + /** + * 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(); if (shouldUpdateReactComponent(prevRenderedElement, nextRenderedElement)) { ReactReconciler.receiveComponent( prevComponentInstance, nextRenderedElement, transaction, - this._processChildContext(context) + this._processChildContext(context), + safely ); } else { var oldNativeNode = ReactReconciler.getNativeNode(prevComponentInstance); - ReactReconciler.unmountComponent(prevComponentInstance, false); + ReactReconciler.unmountComponent(prevComponentInstance, safely); this._renderedNodeType = ReactNodeTypes.getType(nextRenderedElement); this._renderedComponent = this._instantiateReactComponent(