diff --git a/src/core/__tests__/ReactErrorBoundaries-test.js b/src/core/__tests__/ReactErrorBoundaries-test.js new file mode 100644 index 0000000000000..6c7333c8293c2 --- /dev/null +++ b/src/core/__tests__/ReactErrorBoundaries-test.js @@ -0,0 +1,158 @@ +/** + * Copyright 2013-2015, Facebook, Inc. + * All rights reserved. + * + * This source code is licensed under the BSD-style license found in the + * LICENSE file in the root directory of this source tree. An additional grant + * of patent rights can be found in the PATENTS file in the same directory. + * + * @emails react-core + */ + +'use strict'; + +var React; +var ReactDOM; + +describe('ReactErrorBoundaries', function() { + + beforeEach(function() { + ReactDOM = require('ReactDOM'); + React = require('React'); + }); + + it('does not register event handlers for unmounted children', function() { + class Angry extends React.Component { + render() { + throw new Error('Please, do not render me.'); + } + } + + class Boundary extends React.Component { + constructor(props) { + super(props); + this.state = {error: false}; + } + render() { + if (!this.state.error) { + return (
); + } else { + return (
Happy Birthday!
); + } + } + onClick() { + /* do nothing */ + } + unstable_handleError() { + this.setState({error: true}); + } + } + + var EventPluginHub = require('EventPluginHub'); + var container = document.createElement('div'); + EventPluginHub.putListener = jest.genMockFn(); + ReactDOM.render(, container); + expect(EventPluginHub.putListener).not.toBeCalled(); + }); + + it('expect uneventful render to succeed', function() { + class Boundary extends React.Component { + constructor(props) { + super(props); + this.state = {error: false}; + } + render() { + return (
); + } + onClick() { + /* do nothing */ + } + unstable_handleError() { + this.setState({error: true}); + } + } + + var EventPluginHub = require('EventPluginHub'); + var container = document.createElement('div'); + EventPluginHub.putListener = jest.genMockFn(); + ReactDOM.render(, container); + expect(EventPluginHub.putListener).toBeCalled(); + }); + + + it('catches errors from children', 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 ( +
+ + +
+ ); + } + unstable_handleError(e) { + 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); + expect(container.textContent).toBe('Error: Please, do not render me.'); + expect(log).toEqual([ + 'Box render', + 'Inquisitive render', + 'Angry render', + 'Inquisitive ref null', + 'Inquisitive componentWillUnmount', + 'Angry componentWillUnmount', + 'Box renderError', + 'Box componentDidMount', + ]); + }); +}); diff --git a/src/renderers/dom/client/ReactReconcileTransaction.js b/src/renderers/dom/client/ReactReconcileTransaction.js index 3cdebad2d77bd..6cf2c22631bd1 100644 --- a/src/renderers/dom/client/ReactReconcileTransaction.js +++ b/src/renderers/dom/client/ReactReconcileTransaction.js @@ -136,6 +136,19 @@ var Mixin = { return this.reactMountReady; }, + /** + * Save current transaction state -- if the return value from this method is + * passed to `rollback`, the transaction will be reset to that state. + */ + checkpoint: function() { + // reactMountReady is the our only stateful wrapper + return this.reactMountReady.checkpoint(); + }, + + rollback: function(checkpoint) { + this.reactMountReady.rollback(checkpoint); + }, + /** * `PooledClass` looks for this, and will invoke this before allowing this * instance to be reused. diff --git a/src/renderers/shared/reconciler/ReactCompositeComponent.js b/src/renderers/shared/reconciler/ReactCompositeComponent.js index b341eec2f8d37..13657ad204a61 100644 --- a/src/renderers/shared/reconciler/ReactCompositeComponent.js +++ b/src/renderers/shared/reconciler/ReactCompositeComponent.js @@ -108,7 +108,6 @@ var ReactCompositeComponentMixin = { this._renderedNodeType = null; this._renderedComponent = null; - this._context = null; this._mountOrder = 0; this._topLevelWrapper = null; @@ -278,6 +277,58 @@ var ReactCompositeComponentMixin = { this._pendingReplaceState = false; this._pendingForceUpdate = false; + var markup; + if (inst.unstable_handleError) { + markup = this.performInitialMountWithErrorHandling( + renderedElement, + nativeParent, + nativeContainerInfo, + transaction, + context + ); + } else { + markup = this.performInitialMount(renderedElement, nativeParent, nativeContainerInfo, transaction, context); + } + + if (inst.componentDidMount) { + transaction.getReactMountReady().enqueue(inst.componentDidMount, inst); + } + + return markup; + }, + + performInitialMountWithErrorHandling: function( + renderedElement, + nativeParent, + nativeContainerInfo, + transaction, + context + ) { + var markup; + var checkpoint = transaction.checkpoint(); + try { + markup = this.performInitialMount(renderedElement, nativeParent, nativeContainerInfo, 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(); + + this._renderedComponent.unmountComponent(); + 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, nativeParent, nativeContainerInfo, transaction, context); + } + return markup; + }, + + performInitialMount: function(renderedElement, nativeParent, nativeContainerInfo, transaction, context) { + var inst = this._instance; if (inst.componentWillMount) { inst.componentWillMount(); // When mounting, calls to `setState` by `componentWillMount` will set @@ -304,9 +355,6 @@ var ReactCompositeComponentMixin = { nativeContainerInfo, this._processChildContext(context) ); - if (inst.componentDidMount) { - transaction.getReactMountReady().enqueue(inst.componentDidMount, inst); - } return markup; }, @@ -328,10 +376,12 @@ var ReactCompositeComponentMixin = { inst.componentWillUnmount(); } - ReactReconciler.unmountComponent(this._renderedComponent); - this._renderedNodeType = null; - this._renderedComponent = null; - this._instance = null; + if (this._renderedComponent) { + ReactReconciler.unmountComponent(this._renderedComponent); + this._renderedNodeType = null; + this._renderedComponent = null; + this._instance = null; + } // Reset pending fields // Even if this component is scheduled for another update in ReactUpdates, diff --git a/src/shared/utils/CallbackQueue.js b/src/shared/utils/CallbackQueue.js index 089d6802920c0..2fca444c49b2f 100644 --- a/src/shared/utils/CallbackQueue.js +++ b/src/shared/utils/CallbackQueue.js @@ -72,6 +72,17 @@ assign(CallbackQueue.prototype, { } }, + checkpoint: function() { + return this._callbacks ? this._callbacks.length : 0; + }, + + rollback: function(len) { + if (this._callbacks) { + this._callbacks.length = len; + this._contexts.length = len; + } + }, + /** * Resets the internal queue. *