Fix runtime error that happens if a passive destroy function throws within an unmounted tree (#19543)

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).
This commit is contained in:
Brian Vaughn
2020-08-05 18:22:18 -04:00
committed by GitHub
parent 5cff775502
commit e67a6b1603
3 changed files with 105 additions and 1 deletions
@@ -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;
@@ -3793,4 +3793,5 @@ export function act(callback: () => Thenable<mixed>): Thenable<void> {
function detachFiberAfterEffects(fiber: Fiber): void {
fiber.sibling = null;
fiber.stateNode = null;
}
@@ -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 (
<LogOnlyErrorBoundary>
<BrokenUseEffectCleanup />
</LogOnlyErrorBoundary>
);
} else {
return null;
}
}
act(() => {
ReactNoop.render(
<ErrorBoundary>
<Conditional showChildren={true} />
</ErrorBoundary>,
);
});
expect(Scheduler).toHaveYielded([
'ErrorBoundary render success',
'LogOnlyErrorBoundary render',
'BrokenUseEffectCleanup useEffect',
]);
act(() => {
ReactNoop.render(
<ErrorBoundary>
<Conditional showChildren={false} />
</ErrorBoundary>,
);
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', () => {