diff --git a/src/core/__tests__/ReactErrorBoundaries-test.js b/src/core/__tests__/ReactErrorBoundaries-test.js new file mode 100644 index 00000000000000..4cea533e3e9393 --- /dev/null +++ b/src/core/__tests__/ReactErrorBoundaries-test.js @@ -0,0 +1,98 @@ +/** + * 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('catches errors from children', function() { + var log = []; + + class Box extends React.Component { + constructor(props) { + super(props); + this._errorMessage = undefined; + } + render() { + if (this._errorMessage) { + log.push('Box renderError'); + return
Error: {this._errorMessage}
; + } + log.push('Box render'); + var ref = function(x) { + log.push('Inquisitive ref ' + x); + }; + return ( +
+ + +
+ ); + } + handleError(e) { + this._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', + ]); + }); +}); diff --git a/src/renderers/dom/client/ReactReconcileTransaction.js b/src/renderers/dom/client/ReactReconcileTransaction.js index cc71061bb2bd66..17776aa99c1dc6 100644 --- a/src/renderers/dom/client/ReactReconcileTransaction.js +++ b/src/renderers/dom/client/ReactReconcileTransaction.js @@ -137,6 +137,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 b341eec2f8d37f..585ef6a7876c28 100644 --- a/src/renderers/shared/reconciler/ReactCompositeComponent.js +++ b/src/renderers/shared/reconciler/ReactCompositeComponent.js @@ -28,6 +28,12 @@ var invariant = require('invariant'); var shouldUpdateReactComponent = require('shouldUpdateReactComponent'); var warning = require('warning'); +/** + * Used to indicate that no error has been thrown (since you can actually throw + * null, undefined, and seemingly anything else). + */ +var NO_ERROR = {}; + function getDeclarationErrorAddendum(component) { var owner = component._currentElement._owner || null; if (owner) { @@ -99,6 +105,7 @@ var ReactCompositeComponentMixin = { this._instance = null; this._nativeParent = null; this._nativeContainerInfo = null; + this._renderedNodeType = null; // See ReactUpdateQueue this._pendingElement = null; @@ -106,9 +113,8 @@ var ReactCompositeComponentMixin = { this._pendingReplaceState = false; this._pendingForceUpdate = false; - this._renderedNodeType = null; + this._caughtError = NO_ERROR; this._renderedComponent = null; - this._context = null; this._mountOrder = 0; this._topLevelWrapper = null; @@ -278,6 +284,36 @@ var ReactCompositeComponentMixin = { this._pendingReplaceState = false; this._pendingForceUpdate = false; + var markup; + if (inst.handleError) { + var checkpoint = transaction.checkpoint(); + try { + markup = this.performInitialMount(renderedElement, nativeParent, nativeContainerInfo, transaction, context); + } catch (e) { + this._caughtError = e; + inst.handleError(e); + 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); + // Don't call componentDidMount (TODO: wait, what? why not?) + return markup; + } + } else { + markup = this.performInitialMount(renderedElement, nativeParent, nativeContainerInfo, transaction, context); + } + + if (inst.componentDidMount) { + transaction.getReactMountReady().enqueue(inst.componentDidMount, inst); + } + + 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 +340,6 @@ var ReactCompositeComponentMixin = { nativeContainerInfo, this._processChildContext(context) ); - if (inst.componentDidMount) { - transaction.getReactMountReady().enqueue(inst.componentDidMount, inst); - } return markup; }, @@ -328,10 +361,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, @@ -786,7 +821,8 @@ var ReactCompositeComponentMixin = { */ _renderValidatedComponentWithoutOwnerOrContext: function() { var inst = this._instance; - var renderedComponent = inst.render(); + var renderedComponent; + renderedComponent = inst.render(); if (__DEV__) { // We allow auto-mocks to proceed as if they're returning null. if (typeof renderedComponent === 'undefined' && diff --git a/src/shared/utils/CallbackQueue.js b/src/shared/utils/CallbackQueue.js index 089d6802920c00..2fca444c49b2f3 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. *