diff --git a/src/renderers/shared/fiber/ReactFiberCommitWork.js b/src/renderers/shared/fiber/ReactFiberCommitWork.js index 2503e0d02b59e..db844453c8ef8 100644 --- a/src/renderers/shared/fiber/ReactFiberCommitWork.js +++ b/src/renderers/shared/fiber/ReactFiberCommitWork.js @@ -12,6 +12,7 @@ 'use strict'; +import type { TrappedError } from 'ReactFiberErrorBoundary'; import type { Fiber } from 'ReactFiber'; import type { FiberRoot } from 'ReactFiberRoot'; import type { HostConfig } from 'ReactFiberReconciler'; @@ -23,6 +24,7 @@ var { HostComponent, HostText, } = ReactTypeOfWork; +var { trapError } = require('ReactFiberErrorBoundary'); var { callCallbacks } = require('ReactFiberUpdateQueue'); var { @@ -155,7 +157,10 @@ module.exports = function(config : HostConfig) { } } - function commitNestedUnmounts(root : Fiber) { + function commitNestedUnmounts(root : Fiber): Array | null { + // Since errors are rare, we allocate this array on demand. + let trappedErrors = null; + // While we're inside a removed host node we don't want to call // removeChild on the inner nodes because they're removed by the top // call anyway. We also want to call componentWillUnmount on all @@ -163,39 +168,58 @@ module.exports = function(config : HostConfig) { // we do an inner loop while we're still inside the host node. let node : Fiber = root; while (true) { - commitUnmount(node); + const error = commitUnmount(node); + if (error) { + trappedErrors = trappedErrors || []; + trappedErrors.push(error); + } if (node.child) { // TODO: Coroutines need to visit the stateNode. node = node.child; continue; } if (node === root) { - return; + return trappedErrors; } while (!node.sibling) { if (!node.return || node.return === root) { - return; + return trappedErrors; } node = node.return; } node = node.sibling; } + return trappedErrors; } - function unmountHostComponents(parent, current) { + function unmountHostComponents(parent, current): Array | null { + // Since errors are rare, we allocate this array on demand. + let trappedErrors = null; + // We only have the top Fiber that was inserted but we need recurse down its // children to find all the terminal nodes. let node : Fiber = current; while (true) { if (node.tag === HostComponent || node.tag === HostText) { - commitNestedUnmounts(node); + const errors = commitNestedUnmounts(node); + if (errors) { + if (!trappedErrors) { + trappedErrors = errors; + } else { + trappedErrors.push.apply(trappedErrors, errors); + } + } // After all the children have unmounted, it is now safe to remove the // node from the tree. if (parent) { removeChild(parent, node.stateNode); } } else { - commitUnmount(node); + const error = commitUnmount(node); + if (error) { + trappedErrors = trappedErrors || []; + trappedErrors.push(error); + } if (node.child) { // TODO: Coroutines need to visit the stateNode. node = node.child; @@ -203,24 +227,24 @@ module.exports = function(config : HostConfig) { } } if (node === current) { - return; + return trappedErrors; } while (!node.sibling) { if (!node.return || node.return === current) { - return; + return trappedErrors; } node = node.return; } node = node.sibling; } + return trappedErrors; } - function commitDeletion(current : Fiber) : void { + function commitDeletion(current : Fiber) : Array | null { // Recursively delete all host nodes from the parent. - // TODO: Error handling. const parent = getHostParent(current); - - unmountHostComponents(parent, current); + // Detach refs and call componentWillUnmount() on the whole subtree. + const trappedErrors = unmountHostComponents(parent, current); // Cut off the return pointers to disconnect it from the tree. Ideally, we // should clear the child pointer of the parent alternate to let this @@ -233,21 +257,29 @@ module.exports = function(config : HostConfig) { current.alternate.child = null; current.alternate.return = null; } + + return trappedErrors; } - function commitUnmount(current : Fiber) : void { + function commitUnmount(current : Fiber) : TrappedError | null { switch (current.tag) { case ClassComponent: { detachRef(current); const instance = current.stateNode; if (typeof instance.componentWillUnmount === 'function') { - instance.componentWillUnmount(); + const error = tryCallComponentWillUnmount(instance); + if (error) { + return trapError(current, error); + } } - return; + return null; } case HostComponent: { detachRef(current); - return; + return null; + } + default: { + return null; } } } @@ -292,19 +324,20 @@ module.exports = function(config : HostConfig) { } } - function commitLifeCycles(current : ?Fiber, finishedWork : Fiber) : void { + function commitLifeCycles(current : ?Fiber, finishedWork : Fiber) : TrappedError | null { switch (finishedWork.tag) { case ClassComponent: { const instance = finishedWork.stateNode; + let error = null; if (!current) { if (typeof instance.componentDidMount === 'function') { - instance.componentDidMount(); + error = tryCallComponentDidMount(instance); } } else { if (typeof instance.componentDidUpdate === 'function') { const prevProps = current.memoizedProps; const prevState = current.memoizedState; - instance.componentDidUpdate(prevProps, prevState); + error = tryCallComponentDidUpdate(instance, prevProps, prevState); } } // Clear updates from current fiber. This must go before the callbacks @@ -320,7 +353,10 @@ module.exports = function(config : HostConfig) { callCallbacks(callbackList, instance); } attachRef(current, finishedWork, instance); - return; + if (error) { + return trapError(finishedWork, error); + } + return null; } case HostContainer: { const instance = finishedWork.stateNode; @@ -333,17 +369,44 @@ module.exports = function(config : HostConfig) { case HostComponent: { const instance : I = finishedWork.stateNode; attachRef(current, finishedWork, instance); - return; + return null; } case HostText: { // We have no life-cycles associated with text. - return; + return null; } default: throw new Error('This unit of work tag should not have side-effects.'); } } + function tryCallComponentDidMount(instance) { + try { + instance.componentDidMount(); + return null; + } catch (error) { + return error; + } + } + + function tryCallComponentDidUpdate(instance, prevProps, prevState) { + try { + instance.componentDidUpdate(prevProps, prevState); + return null; + } catch (error) { + return error; + } + } + + function tryCallComponentWillUnmount(instance) { + try { + instance.componentWillUnmount(); + return null; + } catch (error) { + return error; + } + } + return { commitInsertion, commitDeletion, diff --git a/src/renderers/shared/fiber/ReactFiberErrorBoundary.js b/src/renderers/shared/fiber/ReactFiberErrorBoundary.js new file mode 100644 index 0000000000000..7268882155963 --- /dev/null +++ b/src/renderers/shared/fiber/ReactFiberErrorBoundary.js @@ -0,0 +1,53 @@ +/** + * Copyright 2013-present, 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. + * + * @providesModule ReactFiberErrorBoundary + * @flow + */ + +'use strict'; + +import type { Fiber } from 'ReactFiber'; + +var { + ClassComponent, +} = require('ReactTypeOfWork'); + +export type TrappedError = { + boundary: Fiber | null, + error: any, +}; + +function findClosestErrorBoundary(fiber : Fiber): Fiber | null { + let maybeErrorBoundary = fiber.return; + while (maybeErrorBoundary) { + if (maybeErrorBoundary.tag === ClassComponent) { + const instance = maybeErrorBoundary.stateNode; + if (typeof instance.unstable_handleError === 'function') { + return maybeErrorBoundary; + } + } + maybeErrorBoundary = maybeErrorBoundary.return; + } + return null; +} + +function trapError(fiber : Fiber, error : any) : TrappedError { + return { + boundary: findClosestErrorBoundary(fiber), + error, + }; +} + +function acknowledgeErrorInBoundary(boundary : Fiber, error : any) { + const instance = boundary.stateNode; + instance.unstable_handleError(error); +} + +exports.trapError = trapError; +exports.acknowledgeErrorInBoundary = acknowledgeErrorInBoundary; diff --git a/src/renderers/shared/fiber/ReactFiberScheduler.js b/src/renderers/shared/fiber/ReactFiberScheduler.js index 6ea78627da813..54ca2ef2cbb79 100644 --- a/src/renderers/shared/fiber/ReactFiberScheduler.js +++ b/src/renderers/shared/fiber/ReactFiberScheduler.js @@ -23,6 +23,7 @@ var ReactFiberCommitWork = require('ReactFiberCommitWork'); var ReactCurrentOwner = require('ReactCurrentOwner'); var { cloneFiber } = require('ReactFiber'); +var { trapError, acknowledgeErrorInBoundary } = require('ReactFiberErrorBoundary'); var { NoWork, @@ -109,9 +110,14 @@ module.exports = function(config : HostConfig) { return null; } - function commitAllWork(finishedWork : Fiber) { + function commitAllWork(finishedWork : Fiber, ignoreUnmountingErrors : boolean) { // Commit all the side-effects within a tree. - // TODO: Error handling. + + // Commit phase is meant to be atomic and non-interruptible. + // Any errors raised in it should be handled after it is over + // so that we don't end up in an inconsistent state due to user code. + // We'll keep track of all caught errors and handle them later. + let allTrappedErrors = null; // First, we'll perform all the host insertions, updates, deletions and // ref unmounts. @@ -129,7 +135,7 @@ module.exports = function(config : HostConfig) { commitInsertion(effectfulFiber); const current = effectfulFiber.alternate; commitWork(current, effectfulFiber); - // Clear the effect tag so that we know that this is inserted, before + // Clear the "placement" from effect tag so that we know that this is inserted, before // any life-cycles like componentDidMount gets called. effectfulFiber.effectTag = Update; break; @@ -140,7 +146,20 @@ module.exports = function(config : HostConfig) { break; } case Deletion: { - commitDeletion(effectfulFiber); + // Deletion might cause an error in componentWillUnmount(). + // We will continue nevertheless and handle those later on. + const trappedErrors = commitDeletion(effectfulFiber); + // There is a special case where we completely ignore errors. + // It happens when we already caught an error earlier, and the update + // is caused by an error boundary trying to render an error message. + // In this case, we want to blow away the tree without catching errors. + if (trappedErrors && !ignoreUnmountingErrors) { + if (!allTrappedErrors) { + allTrappedErrors = trappedErrors; + } else { + allTrappedErrors.push.apply(allTrappedErrors, trappedErrors); + } + } break; } } @@ -155,7 +174,11 @@ module.exports = function(config : HostConfig) { if (effectfulFiber.effectTag === Update || effectfulFiber.effectTag === PlacementAndUpdate) { const current = effectfulFiber.alternate; - commitLifeCycles(current, effectfulFiber); + const trappedError = commitLifeCycles(current, effectfulFiber); + if (trappedError) { + allTrappedErrors = allTrappedErrors || []; + allTrappedErrors.push(trappedError); + } } const next = effectfulFiber.nextEffect; // Ensure that we clean these up so that we don't accidentally keep them. @@ -173,7 +196,17 @@ module.exports = function(config : HostConfig) { if (finishedWork.effectTag !== NoEffect) { const current = finishedWork.alternate; commitWork(current, finishedWork); - commitLifeCycles(current, finishedWork); + const trappedError = commitLifeCycles(current, finishedWork); + if (trappedError) { + allTrappedErrors = allTrappedErrors || []; + allTrappedErrors.push(trappedError); + } + } + + // Now that the tree has been committed, we can handle errors. + if (allTrappedErrors) { + // TODO: handle multiple errors with distinct boundaries. + handleError(allTrappedErrors[0]); } } @@ -195,7 +228,7 @@ module.exports = function(config : HostConfig) { workInProgress.pendingWorkPriority = newPriority; } - function completeUnitOfWork(workInProgress : Fiber) : ?Fiber { + function completeUnitOfWork(workInProgress : Fiber, ignoreUnmountingErrors : boolean) : ?Fiber { while (true) { // The current, flushed, state of this fiber is the alternate. // Ideally nothing should rely on this, but relying on it here @@ -267,7 +300,7 @@ module.exports = function(config : HostConfig) { // "next" scheduled work since we've already scanned passed. That // also ensures that work scheduled during reconciliation gets deferred. // const hasMoreWork = workInProgress.pendingWorkPriority !== NoWork; - commitAllWork(workInProgress); + commitAllWork(workInProgress, ignoreUnmountingErrors); const nextWork = findNextUnitOfWork(); // if (!nextWork && hasMoreWork) { // TODO: This can happen when some deep work completes and we don't @@ -281,7 +314,7 @@ module.exports = function(config : HostConfig) { } } - function performUnitOfWork(workInProgress : Fiber) : ?Fiber { + function performUnitOfWork(workInProgress : Fiber, ignoreUnmountingErrors : boolean) : ?Fiber { // The current, flushed, state of this fiber is the alternate. // Ideally nothing should rely on this, but relying on it here // means that we don't need an additional field on the work in @@ -302,7 +335,7 @@ module.exports = function(config : HostConfig) { ReactFiberInstrumentation.debugTool.onWillCompleteWork(workInProgress); } // If this doesn't spawn new work, complete the current work. - next = completeUnitOfWork(workInProgress); + next = completeUnitOfWork(workInProgress, ignoreUnmountingErrors); if (__DEV__ && ReactFiberInstrumentation.debugTool) { ReactFiberInstrumentation.debugTool.onDidCompleteWork(workInProgress); } @@ -313,13 +346,13 @@ module.exports = function(config : HostConfig) { return next; } - function performDeferredWork(deadline) { + function performDeferredWorkUnsafe(deadline) { if (!nextUnitOfWork) { nextUnitOfWork = findNextUnitOfWork(); } while (nextUnitOfWork) { if (deadline.timeRemaining() > timeHeuristicForUnitOfWork) { - nextUnitOfWork = performUnitOfWork(nextUnitOfWork); + nextUnitOfWork = performUnitOfWork(nextUnitOfWork, false); if (!nextUnitOfWork) { // Find more work. We might have time to complete some more. nextUnitOfWork = findNextUnitOfWork(); @@ -331,6 +364,23 @@ module.exports = function(config : HostConfig) { } } + function performDeferredWork(deadline) { + try { + performDeferredWorkUnsafe(deadline); + } catch (error) { + const failedUnitOfWork = nextUnitOfWork; + // Reset because it points to the error boundary: + nextUnitOfWork = null; + if (!failedUnitOfWork) { + // We shouldn't end up here because nextUnitOfWork + // should always be set while work is being performed. + throw error; + } + const trappedError = trapError(failedUnitOfWork, error); + handleError(trappedError); + } + } + function scheduleDeferredWork(root : FiberRoot, priority : PriorityLevel) { // We must reset the current unit of work pointer so that we restart the // search from the root during the next tick, in case there is now higher @@ -362,12 +412,12 @@ module.exports = function(config : HostConfig) { } } - function performAnimationWork() { + function performAnimationWorkUnsafe() { // Always start from the root nextUnitOfWork = findNextUnitOfWork(); while (nextUnitOfWork && nextPriorityLevel !== NoWork) { - nextUnitOfWork = performUnitOfWork(nextUnitOfWork); + nextUnitOfWork = performUnitOfWork(nextUnitOfWork, false); if (!nextUnitOfWork) { // Keep searching for animation work until there's no more left nextUnitOfWork = findNextUnitOfWork(); @@ -380,6 +430,23 @@ module.exports = function(config : HostConfig) { } } + function performAnimationWork() { + try { + performAnimationWorkUnsafe(); + } catch (error) { + const failedUnitOfWork = nextUnitOfWork; + // Reset because it points to the error boundary: + nextUnitOfWork = null; + if (!failedUnitOfWork) { + // We shouldn't end up here because nextUnitOfWork + // should always be set while work is being performed. + throw error; + } + const trappedError = trapError(failedUnitOfWork, error); + handleError(trappedError); + } + } + function scheduleAnimationWork(root: FiberRoot, priorityLevel : PriorityLevel) { // Set the priority on the root, without deprioritizing if (root.current.pendingWorkPriority === NoWork || @@ -404,6 +471,59 @@ module.exports = function(config : HostConfig) { } } + function handleError(trappedError) { + const boundary = trappedError.boundary; + const error = trappedError.error; + if (!boundary) { + throw error; + } + + try { + // Give error boundary a chance to update its state + acknowledgeErrorInBoundary(boundary, error); + + // We will process an update caused by an error boundary with synchronous priority. + // This leaves us free to not keep track of whether a boundary has errored. + // If it errors again, we will just catch the error and synchronously propagate it higher. + + // First, traverse upwards and set pending synchronous priority on the whole tree. + let fiber = boundary; + while (fiber) { + fiber.pendingWorkPriority = SynchronousPriority; + if (fiber.alternate) { + fiber.alternate.pendingWorkPriority = SynchronousPriority; + } + if (!fiber.return) { + if (fiber.tag === HostContainer) { + // We found the root. + // Now go to the second phase and update it synchronously. + break; + } else { + throw new Error('Invalid root'); + } + } + fiber = fiber.return; + } + + if (!fiber) { + throw new Error('Could not find an error boundary root.'); + } + + // Find the work in progress tree. + const root : FiberRoot = (fiber.stateNode : any); + fiber = root.current.alternate; + + // Perform all the work synchronously. + while (fiber) { + fiber = performUnitOfWork(fiber, true); + } + } catch (nextError) { + // Propagate error to the next boundary or rethrow. + const nextTrappedError = trapError(boundary, nextError); + handleError(nextTrappedError); + } + } + function scheduleWork(root : FiberRoot) { if (defaultPriority === SynchronousPriority) { throw new Error('Not implemented yet'); diff --git a/src/renderers/shared/stack/reconciler/__tests__/ReactErrorBoundaries-test.js b/src/renderers/shared/stack/reconciler/__tests__/ReactErrorBoundaries-test.js index ac5e410cad911..78fa5fe6ce8ca 100644 --- a/src/renderers/shared/stack/reconciler/__tests__/ReactErrorBoundaries-test.js +++ b/src/renderers/shared/stack/reconciler/__tests__/ReactErrorBoundaries-test.js @@ -11,6 +11,8 @@ 'use strict'; +var ReactDOMFeatureFlags = require('ReactDOMFeatureFlags'); + var React; var ReactDOM; @@ -33,6 +35,9 @@ describe('ReactErrorBoundaries', () => { var Normal; beforeEach(() => { + // TODO: Fiber isn't error resilient and one test can bring down them all. + jest.resetModuleRegistry(); + ReactDOM = require('ReactDOM'); React = require('React'); @@ -46,7 +51,7 @@ describe('ReactErrorBoundaries', () => { } render() { log.push('BrokenConstructor render'); - return
; + return
{this.props.children}
; } componentWillMount() { log.push('BrokenConstructor componentWillMount'); @@ -75,7 +80,7 @@ describe('ReactErrorBoundaries', () => { } render() { log.push('BrokenComponentWillMount render'); - return
; + return
{this.props.children}
; } componentWillMount() { log.push('BrokenComponentWillMount componentWillMount [!]'); @@ -105,7 +110,7 @@ describe('ReactErrorBoundaries', () => { } render() { log.push('BrokenComponentDidMount render'); - return
; + return
{this.props.children}
; } componentWillMount() { log.push('BrokenComponentDidMount componentWillMount'); @@ -135,7 +140,7 @@ describe('ReactErrorBoundaries', () => { } render() { log.push('BrokenComponentWillReceiveProps render'); - return
; + return
{this.props.children}
; } componentWillMount() { log.push('BrokenComponentWillReceiveProps componentWillMount'); @@ -165,7 +170,7 @@ describe('ReactErrorBoundaries', () => { } render() { log.push('BrokenComponentWillUpdate render'); - return
; + return
{this.props.children}
; } componentWillMount() { log.push('BrokenComponentWillUpdate componentWillMount'); @@ -195,7 +200,7 @@ describe('ReactErrorBoundaries', () => { } render() { log.push('BrokenComponentDidUpdate render'); - return
; + return
{this.props.children}
; } componentWillMount() { log.push('BrokenComponentDidUpdate componentWillMount'); @@ -225,7 +230,7 @@ describe('ReactErrorBoundaries', () => { } render() { log.push('BrokenComponentWillUnmount render'); - return
; + return
{this.props.children}
; } componentWillMount() { log.push('BrokenComponentWillUnmount componentWillMount'); @@ -459,52 +464,52 @@ describe('ReactErrorBoundaries', () => { }; }); - // 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'); + if (ReactDOMFeatureFlags.useFiber) { + // This test implements a new feature in Fiber. + it('catches 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
{this.props.children}
; } - 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', - ]); + var statefulInst; + var container = document.createElement('div'); + ReactDOM.render( + + statefulInst = inst} /> + , + container + ); - log.length = 0; - ReactDOM.unmountComponentAtNode(container); - expect(log).toEqual([ - 'ErrorBoundary componentWillUnmount', - ]); - }); + log.length = 0; + expect(() => { + fail = true; + statefulInst.forceUpdate(); + }).not.toThrow(); + + expect(log).toEqual([ + 'Stateful render [!]', + 'ErrorBoundary unstable_handleError', + 'ErrorBoundary componentWillUpdate', + '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'); @@ -526,6 +531,12 @@ describe('ReactErrorBoundaries', () => { 'BrokenRender render [!]', // Catch and render an error message 'ErrorBoundary unstable_handleError', + ...(ReactDOMFeatureFlags.useFiber ? [ + // The initial render was aborted, so + // Fiber retries from the root. + 'ErrorBoundary constructor', + 'ErrorBoundary componentWillMount', + ] : []), 'ErrorBoundary render error', 'ErrorBoundary componentDidMount', ]); @@ -553,6 +564,12 @@ describe('ReactErrorBoundaries', () => { 'BrokenConstructor constructor [!]', // Catch and render an error message 'ErrorBoundary unstable_handleError', + ...(ReactDOMFeatureFlags.useFiber ? [ + // The initial render was aborted, so + // Fiber retries from the root. + 'ErrorBoundary constructor', + 'ErrorBoundary componentWillMount', + ] : []), 'ErrorBoundary render error', 'ErrorBoundary componentDidMount', ]); @@ -581,6 +598,12 @@ describe('ReactErrorBoundaries', () => { 'BrokenComponentWillMount componentWillMount [!]', // Catch and render an error message 'ErrorBoundary unstable_handleError', + ...(ReactDOMFeatureFlags.useFiber ? [ + // The initial render was aborted, so + // Fiber retries from the root. + 'ErrorBoundary constructor', + 'ErrorBoundary componentWillMount', + ] : []), 'ErrorBoundary render error', 'ErrorBoundary componentDidMount', ]); @@ -615,6 +638,12 @@ describe('ReactErrorBoundaries', () => { 'BrokenRender render [!]', // Handle the error: 'ErrorBoundary unstable_handleError', + ...(ReactDOMFeatureFlags.useFiber ? [ + // The initial render was aborted, so + // Fiber retries from the root. + 'ErrorBoundary constructor', + 'ErrorBoundary componentWillMount', + ] : []), 'ErrorBoundary render error', // Mount the error message: 'ErrorMessage constructor', @@ -632,40 +661,64 @@ describe('ReactErrorBoundaries', () => { ]); }); - // 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(() => { + if (ReactDOMFeatureFlags.useFiber) { + // This test implements a new feature in Fiber. + it('catches errors in componentDidMount', () => { + var container = document.createElement('div'); 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', - ]); - }); + expect(log).toEqual([ + 'ErrorBoundary constructor', + 'ErrorBoundary componentWillMount', + 'ErrorBoundary render success', + 'BrokenComponentWillUnmount constructor', + 'BrokenComponentWillUnmount componentWillMount', + 'BrokenComponentWillUnmount render', + 'Normal constructor', + 'Normal componentWillMount', + 'Normal render', + 'BrokenComponentDidMount constructor', + 'BrokenComponentDidMount componentWillMount', + 'BrokenComponentDidMount render', + 'LastChild constructor', + 'LastChild componentWillMount', + 'LastChild render', + // Start flushing didMount queue + 'Normal componentDidMount', + 'BrokenComponentWillUnmount componentDidMount', + 'BrokenComponentDidMount componentDidMount [!]', + // Continue despite the error + 'LastChild componentDidMount', + 'ErrorBoundary componentDidMount', + // Now we are ready to handle the error + 'ErrorBoundary unstable_handleError', + 'ErrorBoundary componentWillUpdate', + 'ErrorBoundary render error', + // Safely unmount every child + 'BrokenComponentWillUnmount componentWillUnmount [!]', + // Continue unmounting safely despite any errors + 'Normal componentWillUnmount', + 'BrokenComponentDidMount componentWillUnmount', + 'LastChild componentWillUnmount', + // The update has finished + 'ErrorBoundary componentDidUpdate', + ]); + + log.length = 0; + ReactDOM.unmountComponentAtNode(container); + expect(log).toEqual([ + 'ErrorBoundary componentWillUnmount', + ]); + }); + } it('propagates errors on retry on mounting', () => { var container = document.createElement('div'); @@ -688,15 +741,30 @@ describe('ReactErrorBoundaries', () => { '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 + // The first error boundary catches the error. + // However, it doesn't adjust its state so next render will also fail. 'NoopErrorBoundary unstable_handleError', + ...(ReactDOMFeatureFlags.useFiber ? [ + // The initial render was aborted, so + // Fiber retries from the root. + 'ErrorBoundary constructor', + 'ErrorBoundary componentWillMount', + 'ErrorBoundary render success', + 'NoopErrorBoundary constructor', + 'NoopErrorBoundary componentWillMount', + ] : []), 'NoopErrorBoundary render', 'BrokenRender constructor', 'BrokenRender componentWillMount', 'BrokenRender render [!]', // This time, the error propagates to the higher boundary 'ErrorBoundary unstable_handleError', + ...(ReactDOMFeatureFlags.useFiber ? [ + // The initial render was aborted, so + // Fiber retries from the root. + 'ErrorBoundary constructor', + 'ErrorBoundary componentWillMount', + ] : []), // Render the error 'ErrorBoundary render error', 'ErrorBoundary componentDidMount', @@ -726,6 +794,12 @@ describe('ReactErrorBoundaries', () => { 'BrokenComponentWillMountErrorBoundary componentWillMount [!]', // The error propagates to the higher boundary 'ErrorBoundary unstable_handleError', + ...(ReactDOMFeatureFlags.useFiber ? [ + // The initial render was aborted, so + // Fiber retries from the root. + 'ErrorBoundary constructor', + 'ErrorBoundary componentWillMount', + ] : []), // Render the error 'ErrorBoundary render error', 'ErrorBoundary componentDidMount', @@ -762,9 +836,24 @@ describe('ReactErrorBoundaries', () => { // The first error boundary catches the error // It adjusts state but throws displaying the message 'BrokenRenderErrorBoundary unstable_handleError', + ...(ReactDOMFeatureFlags.useFiber ? [ + // The initial render was aborted, so + // Fiber retries from the root. + 'ErrorBoundary constructor', + 'ErrorBoundary componentWillMount', + 'ErrorBoundary render success', + 'BrokenRenderErrorBoundary constructor', + 'BrokenRenderErrorBoundary componentWillMount', + ] : []), 'BrokenRenderErrorBoundary render error [!]', // The error propagates to the higher boundary 'ErrorBoundary unstable_handleError', + ...(ReactDOMFeatureFlags.useFiber ? [ + // The initial render was aborted, so + // Fiber retries from the root. + 'ErrorBoundary constructor', + 'ErrorBoundary componentWillMount', + ] : []), // Render the error 'ErrorBoundary render error', 'ErrorBoundary componentDidMount', @@ -822,6 +911,12 @@ describe('ReactErrorBoundaries', () => { 'BrokenRender render [!]', // Error boundary catches the error 'ErrorBoundary unstable_handleError', + ...(ReactDOMFeatureFlags.useFiber ? [ + // The initial render was aborted, so + // Fiber retries from the root. + 'ErrorBoundary constructor', + 'ErrorBoundary componentWillMount', + ] : []), // Render the error message 'ErrorBoundary render error', 'ErrorBoundary componentDidMount', @@ -860,8 +955,16 @@ describe('ReactErrorBoundaries', () => { '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', + ...(ReactDOMFeatureFlags.useFiber ? [ + // The initial render was aborted, so + // Fiber retries from the root. + 'ErrorBoundary constructor', + 'ErrorBoundary componentWillMount', + ] : [ + // Stack reconciler resets ref on update, as it doesn't know ref was never set. + // This is unnecessary, and Fiber doesn't do it: + 'Child ref is set to null', + ]), 'ErrorBoundary render error', // Ref to error message should get set: 'Error message ref is set to [object HTMLDivElement]', @@ -932,10 +1035,20 @@ describe('ReactErrorBoundaries', () => { // BrokenConstructor will abort rendering: 'BrokenConstructor constructor [!]', 'ErrorBoundary unstable_handleError', - // Unmount the previously mounted components: - 'Normal componentWillUnmount', + ...(ReactDOMFeatureFlags.useFiber ? [ + // The initial render was aborted, so + // Fiber retries from the root. + 'ErrorBoundary componentWillReceiveProps', + 'ErrorBoundary componentWillUpdate', + // Fiber renders first, then unmounts in a batch: + 'ErrorBoundary render error', + 'Normal componentWillUnmount', + ] : [ + // Stack unmounts first, then renders: + 'Normal componentWillUnmount', + 'ErrorBoundary render error', + ]), // Normal2 does not get lifefycle because it was never mounted - 'ErrorBoundary render error', 'ErrorBoundary componentDidUpdate', ]); @@ -980,10 +1093,20 @@ describe('ReactErrorBoundaries', () => { 'BrokenComponentWillMount constructor', 'BrokenComponentWillMount componentWillMount [!]', 'ErrorBoundary unstable_handleError', - // Unmount the previously mounted components: - 'Normal componentWillUnmount', + ...(ReactDOMFeatureFlags.useFiber ? [ + // The initial render was aborted, so + // Fiber retries from the root. + 'ErrorBoundary componentWillReceiveProps', + 'ErrorBoundary componentWillUpdate', + // Fiber renders first, then unmounts in a batch: + 'ErrorBoundary render error', + 'Normal componentWillUnmount', + ] : [ + // Stack unmounts first, then renders: + 'Normal componentWillUnmount', + 'ErrorBoundary render error', + ]), // Normal2 does not get lifefycle because it was never mounted - 'ErrorBoundary render error', 'ErrorBoundary componentDidUpdate', ]); @@ -1023,11 +1146,21 @@ describe('ReactErrorBoundaries', () => { // BrokenComponentWillReceiveProps will abort rendering: 'BrokenComponentWillReceiveProps componentWillReceiveProps [!]', 'ErrorBoundary unstable_handleError', - // Unmount the previously mounted components: - 'Normal componentWillUnmount', - 'BrokenComponentWillReceiveProps componentWillUnmount', - // Render error: - 'ErrorBoundary render error', + ...(ReactDOMFeatureFlags.useFiber ? [ + // The initial render was aborted, so + // Fiber retries from the root. + 'ErrorBoundary componentWillReceiveProps', + 'ErrorBoundary componentWillUpdate', + // Fiber renders first, then unmounts in a batch: + 'ErrorBoundary render error', + 'Normal componentWillUnmount', + 'BrokenComponentWillReceiveProps componentWillUnmount', + ] : [ + // Stack unmounts first, then renders: + 'Normal componentWillUnmount', + 'BrokenComponentWillReceiveProps componentWillUnmount', + 'ErrorBoundary render error', + ]), 'ErrorBoundary componentDidUpdate', ]); @@ -1068,11 +1201,21 @@ describe('ReactErrorBoundaries', () => { 'BrokenComponentWillUpdate componentWillReceiveProps', 'BrokenComponentWillUpdate componentWillUpdate [!]', 'ErrorBoundary unstable_handleError', - // Unmount the previously mounted components: - 'Normal componentWillUnmount', - 'BrokenComponentWillUpdate componentWillUnmount', - // Render error: - 'ErrorBoundary render error', + ...(ReactDOMFeatureFlags.useFiber ? [ + // The initial render was aborted, so + // Fiber retries from the root. + 'ErrorBoundary componentWillReceiveProps', + 'ErrorBoundary componentWillUpdate', + // Fiber renders first, then unmounts in a batch: + 'ErrorBoundary render error', + 'Normal componentWillUnmount', + 'BrokenComponentWillUpdate componentWillUnmount', + ] : [ + // Stack unmounts first, then renders: + 'Normal componentWillUnmount', + 'BrokenComponentWillUpdate componentWillUnmount', + 'ErrorBoundary render error', + ]), 'ErrorBoundary componentDidUpdate', ]); @@ -1118,10 +1261,20 @@ describe('ReactErrorBoundaries', () => { 'BrokenRender componentWillMount', 'BrokenRender render [!]', 'ErrorBoundary unstable_handleError', - // Unmount the previously mounted components: - 'Normal componentWillUnmount', + ...(ReactDOMFeatureFlags.useFiber ? [ + // The initial render was aborted, so + // Fiber retries from the root. + 'ErrorBoundary componentWillReceiveProps', + 'ErrorBoundary componentWillUpdate', + // Fiber renders first, then unmounts in a batch: + 'ErrorBoundary render error', + 'Normal componentWillUnmount', + ] : [ + // Stack unmounts first, then renders: + 'Normal componentWillUnmount', + 'ErrorBoundary render error', + ]), // Normal2 does not get lifefycle because it was never mounted - 'ErrorBoundary render error', 'ErrorBoundary componentDidUpdate', ]); @@ -1177,9 +1330,19 @@ describe('ReactErrorBoundaries', () => { 'BrokenRender componentWillMount', 'BrokenRender render [!]', 'ErrorBoundary unstable_handleError', - // Unmount the previously mounted components: - 'Child1 ref is set to null', - 'ErrorBoundary render error', + ...(ReactDOMFeatureFlags.useFiber ? [ + // The initial render was aborted, so + // Fiber retries from the root. + 'ErrorBoundary componentWillReceiveProps', + 'ErrorBoundary componentWillUpdate', + // Fiber renders first, resets refs later + 'ErrorBoundary render error', + 'Child1 ref is set to null', + ] : [ + // Stack resets ref first, renders later + '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', @@ -1193,48 +1356,49 @@ describe('ReactErrorBoundaries', () => { ]); }); - // 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(() => { + if (ReactDOMFeatureFlags.useFiber) { + // This test implements a new feature in Fiber. + it('catches errors in componentDidUpdate', () => { + var container = document.createElement('div'); 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', - ]); - }); + log.length = 0; + ReactDOM.render( + + + , + container + ); + expect(log).toEqual([ + 'ErrorBoundary componentWillReceiveProps', + 'ErrorBoundary componentWillUpdate', + 'ErrorBoundary render success', + 'BrokenComponentDidUpdate componentWillReceiveProps', + 'BrokenComponentDidUpdate componentWillUpdate', + 'BrokenComponentDidUpdate render', + // All lifecycles run + 'BrokenComponentDidUpdate componentDidUpdate [!]', + 'ErrorBoundary componentDidUpdate', + // Then, error is handled + 'ErrorBoundary unstable_handleError', + 'ErrorBoundary componentWillUpdate', + 'ErrorBoundary render error', + 'BrokenComponentDidUpdate componentWillUnmount', + 'ErrorBoundary componentDidUpdate', + ]); + + log.length = 0; + ReactDOM.unmountComponentAtNode(container); + expect(log).toEqual([ + 'ErrorBoundary componentWillUnmount', + ]); + }); + } it('recovers from componentWillUnmount errors on update', () => { var container = document.createElement('div'); @@ -1242,7 +1406,7 @@ describe('ReactErrorBoundaries', () => { - + , container ); @@ -1251,7 +1415,6 @@ describe('ReactErrorBoundaries', () => { ReactDOM.render( - , container ); @@ -1260,23 +1423,39 @@ describe('ReactErrorBoundaries', () => { 'ErrorBoundary componentWillReceiveProps', 'ErrorBoundary componentWillUpdate', 'ErrorBoundary render success', - // Update existing children: - 'BrokenComponentWillUnmount componentWillReceiveProps', - 'BrokenComponentWillUnmount componentWillUpdate', - 'BrokenComponentWillUnmount render', + // Update existing child: '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 + ...(ReactDOMFeatureFlags.useFiber ? [ + // Fiber proceeds with lifecycles despite errors + 'Normal componentWillUnmount', + // The components have updated in this phase + 'BrokenComponentWillUnmount componentDidUpdate', + 'ErrorBoundary componentDidUpdate', + // Now that commit phase is done, Fiber handles errors + 'ErrorBoundary unstable_handleError', + // The initial render was aborted, so + // Fiber retries from the root. + 'ErrorBoundary componentWillUpdate', + // Render an error now (stack will do it later) + 'ErrorBoundary render error', + // Attempt to unmount previous child: + 'BrokenComponentWillUnmount componentWillUnmount [!]', + // Done + 'ErrorBoundary componentDidUpdate', + ] : [ + // Stack will handle error immediately + 'ErrorBoundary unstable_handleError', + // Attempt to unmount previous children: + 'BrokenComponentWillUnmount componentWillUnmount [!]', + 'Normal componentWillUnmount', + // Render an error now (Fiber will do it earlier) + 'ErrorBoundary render error', + 'ErrorBoundary componentDidUpdate', + ]), ]); log.length = 0; @@ -1321,13 +1500,32 @@ describe('ReactErrorBoundaries', () => { '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', + ...(ReactDOMFeatureFlags.useFiber ? [ + // Fiber proceeds with lifecycles despite errors + 'BrokenComponentWillUnmount componentDidUpdate', + 'Normal componentDidUpdate', + 'ErrorBoundary componentDidUpdate', + // Now that commit phase is done, Fiber handles errors + 'ErrorBoundary unstable_handleError', + // The initial render was aborted, so + // Fiber retries from the root. + 'ErrorBoundary componentWillUpdate', + // Render an error now (stack will do it later) + 'ErrorBoundary render error', + // Attempt to unmount previous child: + 'Normal componentWillUnmount', + 'BrokenComponentWillUnmount componentWillUnmount [!]', + // Done + 'ErrorBoundary componentDidUpdate', + ] : [ + 'ErrorBoundary unstable_handleError', + // Attempt to unmount previous children: + 'Normal componentWillUnmount', + 'BrokenComponentWillUnmount componentWillUnmount [!]', + // Stack calls lifecycles first, then renders. + 'ErrorBoundary render error', + 'ErrorBoundary componentDidUpdate', + ]), ]); log.length = 0; @@ -1474,7 +1672,7 @@ describe('ReactErrorBoundaries', () => { if (fail) { throw new Error('Hello'); } - return
; + return
{this.props.children}
; } }