From c54e7496264d0ba687b8f574ae8cee36f88c3cb7 Mon Sep 17 00:00:00 2001 From: Brian Vaughn Date: Wed, 5 Aug 2020 17:50:01 -0400 Subject: [PATCH] Fix runtime error that happens if a passive destroy function throws within an unmounted tree A passive effect's cleanup function may throw after an unmount. In that event, React sometimes threw an uncaught runtime error trying to access a property on a null stateNode field. This commit fixes that (and adds a regression test). --- .../src/ReactFiberCommitWork.old.js | 5 +- .../src/ReactFiberWorkLoop.old.js | 1 + .../ReactHooksWithNoopRenderer-test.js | 100 ++++++++++++++++++ 3 files changed, 105 insertions(+), 1 deletion(-) diff --git a/packages/react-reconciler/src/ReactFiberCommitWork.old.js b/packages/react-reconciler/src/ReactFiberCommitWork.old.js index f255f8b622200..8e280e1600510 100644 --- a/packages/react-reconciler/src/ReactFiberCommitWork.old.js +++ b/packages/react-reconciler/src/ReactFiberCommitWork.old.js @@ -1011,6 +1011,10 @@ function detachFiberMutation(fiber: Fiber) { // with findDOMNode and how it requires the sibling field to carry out // traversal in a later effect. See PR #16820. We now clear the sibling // field after effects, see: detachFiberAfterEffects. + // + // Don't disconnect stateNode now; it will be detached in detachFiberAfterEffects. + // It may be required if the current component is an error boundary, + // and one of its descendants throws while unmounting a passive effect. fiber.alternate = null; fiber.child = null; fiber.dependencies = null; @@ -1020,7 +1024,6 @@ function detachFiberMutation(fiber: Fiber) { fiber.memoizedState = null; fiber.pendingProps = null; fiber.return = null; - fiber.stateNode = null; fiber.updateQueue = null; if (__DEV__) { fiber._debugOwner = null; diff --git a/packages/react-reconciler/src/ReactFiberWorkLoop.old.js b/packages/react-reconciler/src/ReactFiberWorkLoop.old.js index 80ad473992efd..36670a257f0ec 100644 --- a/packages/react-reconciler/src/ReactFiberWorkLoop.old.js +++ b/packages/react-reconciler/src/ReactFiberWorkLoop.old.js @@ -3793,4 +3793,5 @@ export function act(callback: () => Thenable): Thenable { function detachFiberAfterEffects(fiber: Fiber): void { fiber.sibling = null; + fiber.stateNode = null; } diff --git a/packages/react-reconciler/src/__tests__/ReactHooksWithNoopRenderer-test.js b/packages/react-reconciler/src/__tests__/ReactHooksWithNoopRenderer-test.js index 362bb26b25e3f..eaa222456fc66 100644 --- a/packages/react-reconciler/src/__tests__/ReactHooksWithNoopRenderer-test.js +++ b/packages/react-reconciler/src/__tests__/ReactHooksWithNoopRenderer-test.js @@ -2316,6 +2316,106 @@ describe('ReactHooksWithNoopRenderer', () => { expect(Scheduler).toFlushAndYieldThrough(['Unmount: 1']); expect(ReactNoop.getChildren()).toEqual([]); }); + + describe('errors thrown in passive destroy function within unmounted trees', () => { + let BrokenUseEffectCleanup; + let ErrorBoundary; + let LogOnlyErrorBoundary; + + beforeEach(() => { + BrokenUseEffectCleanup = function() { + useEffect(() => { + Scheduler.unstable_yieldValue('BrokenUseEffectCleanup useEffect'); + return () => { + Scheduler.unstable_yieldValue( + 'BrokenUseEffectCleanup useEffect destroy', + ); + throw new Error('Expected error'); + }; + }, []); + + return 'inner child'; + }; + + ErrorBoundary = class extends React.Component { + state = {error: null}; + static getDerivedStateFromError(error) { + Scheduler.unstable_yieldValue( + `ErrorBoundary static getDerivedStateFromError`, + ); + return {error}; + } + componentDidCatch(error, info) { + Scheduler.unstable_yieldValue(`ErrorBoundary componentDidCatch`); + } + render() { + if (this.state.error) { + Scheduler.unstable_yieldValue('ErrorBoundary render error'); + return 'ErrorBoundary fallback'; + } + Scheduler.unstable_yieldValue('ErrorBoundary render success'); + return this.props.children; + } + }; + + LogOnlyErrorBoundary = class extends React.Component { + componentDidCatch(error, info) { + Scheduler.unstable_yieldValue( + `LogOnlyErrorBoundary componentDidCatch`, + ); + } + render() { + Scheduler.unstable_yieldValue(`LogOnlyErrorBoundary render`); + return this.props.children; + } + }; + }); + + it('should not error if the nearest unmounted boundary is log-only', () => { + function Conditional({showChildren}) { + if (showChildren) { + return ( + + + + ); + } else { + return null; + } + } + + act(() => { + ReactNoop.render( + + + , + ); + }); + + expect(Scheduler).toHaveYielded([ + 'ErrorBoundary render success', + 'LogOnlyErrorBoundary render', + 'BrokenUseEffectCleanup useEffect', + ]); + + act(() => { + ReactNoop.render( + + + , + ); + expect(Scheduler).toFlushAndYieldThrough([ + 'ErrorBoundary render success', + ]); + }); + + expect(Scheduler).toHaveYielded([ + 'BrokenUseEffectCleanup useEffect destroy', + // This should call componentDidCatch too, but we'll address that in a follow up. + // 'LogOnlyErrorBoundary componentDidCatch', + ]); + }); + }); }); describe('useLayoutEffect', () => {