From 04d140c1138e3aa9dfdbf02a12faa7b49c28514e Mon Sep 17 00:00:00 2001 From: Andrew Clark Date: Wed, 6 Apr 2022 23:47:17 -0400 Subject: [PATCH] Fix: Don't call cWU if already unmounted When a tree goes offscreen, we unmount all the effects just like we would in a normal deletion. (Conceptually it _is_ a deletion; we keep the fiber around so we can reuse its state if the tree mounts again.) If an offscreen component gets deleted "for real", we shouldn't unmount it again. The fix is to track on the stack whether we're inside a hidden tree. We already had a stack variable for this purpose, called `offscreenSubtreeWasHidden`, in another part of the commit phase, so I reused that variable instead of creating a new one. (The name is a bit confusing: "was" refers to the current tree before this commit. So, the "previous current".) Co-authored-by: dan --- .../src/ReactFiberCommitWork.new.js | 135 +++--- .../src/ReactFiberCommitWork.old.js | 135 +++--- .../ReactSuspenseEffectsSemantics-test.js | 1 - .../ReactSuspenseEffectsSemanticsDOM-test.js | 386 +++++++++++++++++- 4 files changed, 555 insertions(+), 102 deletions(-) diff --git a/packages/react-reconciler/src/ReactFiberCommitWork.new.js b/packages/react-reconciler/src/ReactFiberCommitWork.new.js index 6913fa9e0d008..89811a726bd7d 100644 --- a/packages/react-reconciler/src/ReactFiberCommitWork.new.js +++ b/packages/react-reconciler/src/ReactFiberCommitWork.new.js @@ -1596,7 +1596,9 @@ function commitDeletionEffectsOnFiber( switch (deletedFiber.tag) { case HostComponent: case HostText: { - safelyDetachRef(deletedFiber, nearestMountedAncestor); + if (!offscreenSubtreeWasHidden) { + safelyDetachRef(deletedFiber, nearestMountedAncestor); + } // We only need to remove the nearest host child. Set the host parent // to `null` on the stack to indicate that nested children don't // need to be removed. @@ -1696,54 +1698,56 @@ function commitDeletionEffectsOnFiber( case ForwardRef: case MemoComponent: case SimpleMemoComponent: { - const updateQueue: FunctionComponentUpdateQueue | null = (deletedFiber.updateQueue: any); - if (updateQueue !== null) { - const lastEffect = updateQueue.lastEffect; - if (lastEffect !== null) { - const firstEffect = lastEffect.next; - - let effect = firstEffect; - do { - const {destroy, tag} = effect; - if (destroy !== undefined) { - if ((tag & HookInsertion) !== NoHookEffect) { - safelyCallDestroy( - deletedFiber, - nearestMountedAncestor, - destroy, - ); - } else if ((tag & HookLayout) !== NoHookEffect) { - if (enableSchedulingProfiler) { - markComponentLayoutEffectUnmountStarted(deletedFiber); - } - - if ( - enableProfilerTimer && - enableProfilerCommitHooks && - deletedFiber.mode & ProfileMode - ) { - startLayoutEffectTimer(); - safelyCallDestroy( - deletedFiber, - nearestMountedAncestor, - destroy, - ); - recordLayoutEffectDuration(deletedFiber); - } else { + if (!offscreenSubtreeWasHidden) { + const updateQueue: FunctionComponentUpdateQueue | null = (deletedFiber.updateQueue: any); + if (updateQueue !== null) { + const lastEffect = updateQueue.lastEffect; + if (lastEffect !== null) { + const firstEffect = lastEffect.next; + + let effect = firstEffect; + do { + const {destroy, tag} = effect; + if (destroy !== undefined) { + if ((tag & HookInsertion) !== NoHookEffect) { safelyCallDestroy( deletedFiber, nearestMountedAncestor, destroy, ); - } + } else if ((tag & HookLayout) !== NoHookEffect) { + if (enableSchedulingProfiler) { + markComponentLayoutEffectUnmountStarted(deletedFiber); + } - if (enableSchedulingProfiler) { - markComponentLayoutEffectUnmountStopped(); + if ( + enableProfilerTimer && + enableProfilerCommitHooks && + deletedFiber.mode & ProfileMode + ) { + startLayoutEffectTimer(); + safelyCallDestroy( + deletedFiber, + nearestMountedAncestor, + destroy, + ); + recordLayoutEffectDuration(deletedFiber); + } else { + safelyCallDestroy( + deletedFiber, + nearestMountedAncestor, + destroy, + ); + } + + if (enableSchedulingProfiler) { + markComponentLayoutEffectUnmountStopped(); + } } } - } - effect = effect.next; - } while (effect !== firstEffect); + effect = effect.next; + } while (effect !== firstEffect); + } } } @@ -1755,14 +1759,16 @@ function commitDeletionEffectsOnFiber( return; } case ClassComponent: { - safelyDetachRef(deletedFiber, nearestMountedAncestor); - const instance = deletedFiber.stateNode; - if (typeof instance.componentWillUnmount === 'function') { - safelyCallComponentWillUnmount( - deletedFiber, - nearestMountedAncestor, - instance, - ); + if (!offscreenSubtreeWasHidden) { + safelyDetachRef(deletedFiber, nearestMountedAncestor); + const instance = deletedFiber.stateNode; + if (typeof instance.componentWillUnmount === 'function') { + safelyCallComponentWillUnmount( + deletedFiber, + nearestMountedAncestor, + instance, + ); + } } recursivelyTraverseDeletionEffects( finishedRoot, @@ -1782,6 +1788,27 @@ function commitDeletionEffectsOnFiber( ); return; } + case OffscreenComponent: { + // If this offscreen component is hidden, we already unmounted it. Before + // deleting the children, track that it's already unmounted so that we + // don't attempt to unmount the effects again. + // TODO: If the tree is hidden, in most cases we should be able to skip + // over the nested children entirely. An exception is we haven't yet found + // the topmost host node to delete, which we already track on the stack. + // But the other case is portals, which need to be detached no matter how + // deeply they are nested. We should use a subtree flag to track whether a + // subtree includes a nested portal. + const prevOffscreenSubtreeWasHidden = offscreenSubtreeWasHidden; + offscreenSubtreeWasHidden = + prevOffscreenSubtreeWasHidden || deletedFiber.memoizedState !== null; + recursivelyTraverseDeletionEffects( + finishedRoot, + nearestMountedAncestor, + deletedFiber, + ); + offscreenSubtreeWasHidden = prevOffscreenSubtreeWasHidden; + break; + } default: { recursivelyTraverseDeletionEffects( finishedRoot, @@ -2216,13 +2243,21 @@ function commitMutationEffectsOnFiber( return; } case OffscreenComponent: { + const wasHidden = current !== null && current.memoizedState !== null; + + // Before committing the children, track on the stack whether this + // offscreen subtree was already hidden, so that we don't unmount the + // effects again. + const prevOffscreenSubtreeWasHidden = offscreenSubtreeWasHidden; + offscreenSubtreeWasHidden = prevOffscreenSubtreeWasHidden || wasHidden; recursivelyTraverseMutationEffects(root, finishedWork, lanes); + offscreenSubtreeWasHidden = prevOffscreenSubtreeWasHidden; + commitReconciliationEffects(finishedWork); if (flags & Visibility) { const newState: OffscreenState | null = finishedWork.memoizedState; const isHidden = newState !== null; - const wasHidden = current !== null && current.memoizedState !== null; const offscreenBoundary: Fiber = finishedWork; if (supportsMutation) { diff --git a/packages/react-reconciler/src/ReactFiberCommitWork.old.js b/packages/react-reconciler/src/ReactFiberCommitWork.old.js index 6f303dda44062..6fd1bd0db08d8 100644 --- a/packages/react-reconciler/src/ReactFiberCommitWork.old.js +++ b/packages/react-reconciler/src/ReactFiberCommitWork.old.js @@ -1596,7 +1596,9 @@ function commitDeletionEffectsOnFiber( switch (deletedFiber.tag) { case HostComponent: case HostText: { - safelyDetachRef(deletedFiber, nearestMountedAncestor); + if (!offscreenSubtreeWasHidden) { + safelyDetachRef(deletedFiber, nearestMountedAncestor); + } // We only need to remove the nearest host child. Set the host parent // to `null` on the stack to indicate that nested children don't // need to be removed. @@ -1696,54 +1698,56 @@ function commitDeletionEffectsOnFiber( case ForwardRef: case MemoComponent: case SimpleMemoComponent: { - const updateQueue: FunctionComponentUpdateQueue | null = (deletedFiber.updateQueue: any); - if (updateQueue !== null) { - const lastEffect = updateQueue.lastEffect; - if (lastEffect !== null) { - const firstEffect = lastEffect.next; - - let effect = firstEffect; - do { - const {destroy, tag} = effect; - if (destroy !== undefined) { - if ((tag & HookInsertion) !== NoHookEffect) { - safelyCallDestroy( - deletedFiber, - nearestMountedAncestor, - destroy, - ); - } else if ((tag & HookLayout) !== NoHookEffect) { - if (enableSchedulingProfiler) { - markComponentLayoutEffectUnmountStarted(deletedFiber); - } - - if ( - enableProfilerTimer && - enableProfilerCommitHooks && - deletedFiber.mode & ProfileMode - ) { - startLayoutEffectTimer(); - safelyCallDestroy( - deletedFiber, - nearestMountedAncestor, - destroy, - ); - recordLayoutEffectDuration(deletedFiber); - } else { + if (!offscreenSubtreeWasHidden) { + const updateQueue: FunctionComponentUpdateQueue | null = (deletedFiber.updateQueue: any); + if (updateQueue !== null) { + const lastEffect = updateQueue.lastEffect; + if (lastEffect !== null) { + const firstEffect = lastEffect.next; + + let effect = firstEffect; + do { + const {destroy, tag} = effect; + if (destroy !== undefined) { + if ((tag & HookInsertion) !== NoHookEffect) { safelyCallDestroy( deletedFiber, nearestMountedAncestor, destroy, ); - } + } else if ((tag & HookLayout) !== NoHookEffect) { + if (enableSchedulingProfiler) { + markComponentLayoutEffectUnmountStarted(deletedFiber); + } - if (enableSchedulingProfiler) { - markComponentLayoutEffectUnmountStopped(); + if ( + enableProfilerTimer && + enableProfilerCommitHooks && + deletedFiber.mode & ProfileMode + ) { + startLayoutEffectTimer(); + safelyCallDestroy( + deletedFiber, + nearestMountedAncestor, + destroy, + ); + recordLayoutEffectDuration(deletedFiber); + } else { + safelyCallDestroy( + deletedFiber, + nearestMountedAncestor, + destroy, + ); + } + + if (enableSchedulingProfiler) { + markComponentLayoutEffectUnmountStopped(); + } } } - } - effect = effect.next; - } while (effect !== firstEffect); + effect = effect.next; + } while (effect !== firstEffect); + } } } @@ -1755,14 +1759,16 @@ function commitDeletionEffectsOnFiber( return; } case ClassComponent: { - safelyDetachRef(deletedFiber, nearestMountedAncestor); - const instance = deletedFiber.stateNode; - if (typeof instance.componentWillUnmount === 'function') { - safelyCallComponentWillUnmount( - deletedFiber, - nearestMountedAncestor, - instance, - ); + if (!offscreenSubtreeWasHidden) { + safelyDetachRef(deletedFiber, nearestMountedAncestor); + const instance = deletedFiber.stateNode; + if (typeof instance.componentWillUnmount === 'function') { + safelyCallComponentWillUnmount( + deletedFiber, + nearestMountedAncestor, + instance, + ); + } } recursivelyTraverseDeletionEffects( finishedRoot, @@ -1782,6 +1788,27 @@ function commitDeletionEffectsOnFiber( ); return; } + case OffscreenComponent: { + // If this offscreen component is hidden, we already unmounted it. Before + // deleting the children, track that it's already unmounted so that we + // don't attempt to unmount the effects again. + // TODO: If the tree is hidden, in most cases we should be able to skip + // over the nested children entirely. An exception is we haven't yet found + // the topmost host node to delete, which we already track on the stack. + // But the other case is portals, which need to be detached no matter how + // deeply they are nested. We should use a subtree flag to track whether a + // subtree includes a nested portal. + const prevOffscreenSubtreeWasHidden = offscreenSubtreeWasHidden; + offscreenSubtreeWasHidden = + prevOffscreenSubtreeWasHidden || deletedFiber.memoizedState !== null; + recursivelyTraverseDeletionEffects( + finishedRoot, + nearestMountedAncestor, + deletedFiber, + ); + offscreenSubtreeWasHidden = prevOffscreenSubtreeWasHidden; + break; + } default: { recursivelyTraverseDeletionEffects( finishedRoot, @@ -2216,13 +2243,21 @@ function commitMutationEffectsOnFiber( return; } case OffscreenComponent: { + const wasHidden = current !== null && current.memoizedState !== null; + + // Before committing the children, track on the stack whether this + // offscreen subtree was already hidden, so that we don't unmount the + // effects again. + const prevOffscreenSubtreeWasHidden = offscreenSubtreeWasHidden; + offscreenSubtreeWasHidden = prevOffscreenSubtreeWasHidden || wasHidden; recursivelyTraverseMutationEffects(root, finishedWork, lanes); + offscreenSubtreeWasHidden = prevOffscreenSubtreeWasHidden; + commitReconciliationEffects(finishedWork); if (flags & Visibility) { const newState: OffscreenState | null = finishedWork.memoizedState; const isHidden = newState !== null; - const wasHidden = current !== null && current.memoizedState !== null; const offscreenBoundary: Fiber = finishedWork; if (supportsMutation) { diff --git a/packages/react-reconciler/src/__tests__/ReactSuspenseEffectsSemantics-test.js b/packages/react-reconciler/src/__tests__/ReactSuspenseEffectsSemantics-test.js index bdda4f8313dfa..1825d41fd1da6 100644 --- a/packages/react-reconciler/src/__tests__/ReactSuspenseEffectsSemantics-test.js +++ b/packages/react-reconciler/src/__tests__/ReactSuspenseEffectsSemantics-test.js @@ -1980,7 +1980,6 @@ describe('ReactSuspenseEffectsSemantics', () => { // Destroy layout and passive effects in the errored tree. 'App destroy layout', - 'ThrowsInWillUnmount componentWillUnmount', 'Text:Fallback destroy layout', 'Text:Outside destroy layout', 'Text:Inside destroy passive', diff --git a/packages/react-reconciler/src/__tests__/ReactSuspenseEffectsSemanticsDOM-test.js b/packages/react-reconciler/src/__tests__/ReactSuspenseEffectsSemanticsDOM-test.js index cb1196baffe6a..00c126bb36450 100644 --- a/packages/react-reconciler/src/__tests__/ReactSuspenseEffectsSemanticsDOM-test.js +++ b/packages/react-reconciler/src/__tests__/ReactSuspenseEffectsSemanticsDOM-test.js @@ -10,18 +10,39 @@ 'use strict'; let React; +let ReactDOM; let ReactDOMClient; +let Scheduler; let act; +let container; describe('ReactSuspenseEffectsSemanticsDOM', () => { beforeEach(() => { jest.resetModules(); React = require('react'); + ReactDOM = require('react-dom'); ReactDOMClient = require('react-dom/client'); + Scheduler = require('scheduler'); act = require('jest-react').act; + + container = document.createElement('div'); + document.body.appendChild(container); + }); + + afterEach(() => { + document.body.removeChild(container); }); + async function fakeImport(result) { + return {default: result}; + } + + function Text(props) { + Scheduler.unstable_yieldValue(props.text); + return props.text; + } + it('should not cause a cycle when combined with a render phase update', () => { let scheduleSuspendingUpdate; @@ -63,7 +84,7 @@ describe('ReactSuspenseEffectsSemanticsDOM', () => { } act(() => { - const root = ReactDOMClient.createRoot(document.createElement('div')); + const root = ReactDOMClient.createRoot(container); root.render(); }); @@ -71,4 +92,367 @@ describe('ReactSuspenseEffectsSemanticsDOM', () => { scheduleSuspendingUpdate(); }); }); + + it('does not destroy layout effects twice when hidden child is removed', async () => { + function ChildA({label}) { + React.useLayoutEffect(() => { + Scheduler.unstable_yieldValue('Did mount: ' + label); + return () => { + Scheduler.unstable_yieldValue('Will unmount: ' + label); + }; + }, []); + return ; + } + + function ChildB({label}) { + React.useLayoutEffect(() => { + Scheduler.unstable_yieldValue('Did mount: ' + label); + return () => { + Scheduler.unstable_yieldValue('Will unmount: ' + label); + }; + }, []); + return ; + } + + const LazyChildA = React.lazy(() => fakeImport(ChildA)); + const LazyChildB = React.lazy(() => fakeImport(ChildB)); + + function Parent({swap}) { + return ( + }> + {swap ? : } + + ); + } + + const root = ReactDOMClient.createRoot(container); + act(() => { + root.render(); + }); + expect(Scheduler).toHaveYielded(['Loading...']); + + await LazyChildA; + expect(Scheduler).toFlushAndYield(['A', 'Did mount: A']); + expect(container.innerHTML).toBe('A'); + + // Swap the position of A and B + ReactDOM.flushSync(() => { + root.render(); + }); + expect(Scheduler).toHaveYielded(['Loading...', 'Will unmount: A']); + expect(container.innerHTML).toBe('Loading...'); + + await LazyChildB; + expect(Scheduler).toFlushAndYield(['B', 'Did mount: B']); + expect(container.innerHTML).toBe('B'); + }); + + it('does not destroy ref cleanup twice when hidden child is removed', async () => { + function ChildA({label}) { + return ( + { + if (node) { + Scheduler.unstable_yieldValue('Ref mount: ' + label); + } else { + Scheduler.unstable_yieldValue('Ref unmount: ' + label); + } + }}> + + + ); + } + + function ChildB({label}) { + return ( + { + if (node) { + Scheduler.unstable_yieldValue('Ref mount: ' + label); + } else { + Scheduler.unstable_yieldValue('Ref unmount: ' + label); + } + }}> + + + ); + } + + const LazyChildA = React.lazy(() => fakeImport(ChildA)); + const LazyChildB = React.lazy(() => fakeImport(ChildB)); + + function Parent({swap}) { + return ( + }> + {swap ? : } + + ); + } + + const root = ReactDOMClient.createRoot(container); + act(() => { + root.render(); + }); + expect(Scheduler).toHaveYielded(['Loading...']); + + await LazyChildA; + expect(Scheduler).toFlushAndYield(['A', 'Ref mount: A']); + expect(container.innerHTML).toBe('A'); + + // Swap the position of A and B + ReactDOM.flushSync(() => { + root.render(); + }); + expect(Scheduler).toHaveYielded(['Loading...', 'Ref unmount: A']); + expect(container.innerHTML).toBe( + 'ALoading...', + ); + + await LazyChildB; + expect(Scheduler).toFlushAndYield(['B', 'Ref mount: B']); + expect(container.innerHTML).toBe('B'); + }); + + it('does not call componentWillUnmount twice when hidden child is removed', async () => { + class ChildA extends React.Component { + componentDidMount() { + Scheduler.unstable_yieldValue('Did mount: ' + this.props.label); + } + componentWillUnmount() { + Scheduler.unstable_yieldValue('Will unmount: ' + this.props.label); + } + render() { + return ; + } + } + + class ChildB extends React.Component { + componentDidMount() { + Scheduler.unstable_yieldValue('Did mount: ' + this.props.label); + } + componentWillUnmount() { + Scheduler.unstable_yieldValue('Will unmount: ' + this.props.label); + } + render() { + return ; + } + } + + const LazyChildA = React.lazy(() => fakeImport(ChildA)); + const LazyChildB = React.lazy(() => fakeImport(ChildB)); + + function Parent({swap}) { + return ( + }> + {swap ? : } + + ); + } + + const root = ReactDOMClient.createRoot(container); + act(() => { + root.render(); + }); + expect(Scheduler).toHaveYielded(['Loading...']); + + await LazyChildA; + expect(Scheduler).toFlushAndYield(['A', 'Did mount: A']); + expect(container.innerHTML).toBe('A'); + + // Swap the position of A and B + ReactDOM.flushSync(() => { + root.render(); + }); + expect(Scheduler).toHaveYielded(['Loading...', 'Will unmount: A']); + expect(container.innerHTML).toBe('Loading...'); + + await LazyChildB; + expect(Scheduler).toFlushAndYield(['B', 'Did mount: B']); + expect(container.innerHTML).toBe('B'); + }); + + it('does not destroy layout effects twice when parent suspense is removed', async () => { + function ChildA({label}) { + React.useLayoutEffect(() => { + Scheduler.unstable_yieldValue('Did mount: ' + label); + return () => { + Scheduler.unstable_yieldValue('Will unmount: ' + label); + }; + }, []); + return ; + } + function ChildB({label}) { + React.useLayoutEffect(() => { + Scheduler.unstable_yieldValue('Did mount: ' + label); + return () => { + Scheduler.unstable_yieldValue('Will unmount: ' + label); + }; + }, []); + return ; + } + const LazyChildA = React.lazy(() => fakeImport(ChildA)); + const LazyChildB = React.lazy(() => fakeImport(ChildB)); + + function Parent({swap}) { + return ( + }> + {swap ? : } + + ); + } + + const root = ReactDOMClient.createRoot(container); + act(() => { + root.render(); + }); + expect(Scheduler).toHaveYielded(['Loading...']); + + await LazyChildA; + expect(Scheduler).toFlushAndYield(['A', 'Did mount: A']); + expect(container.innerHTML).toBe('A'); + + // Swap the position of A and B + ReactDOM.flushSync(() => { + root.render(); + }); + expect(Scheduler).toHaveYielded(['Loading...', 'Will unmount: A']); + expect(container.innerHTML).toBe('Loading...'); + + // Destroy the whole tree, including the hidden A + ReactDOM.flushSync(() => { + root.render(

Hello

); + }); + expect(Scheduler).toFlushAndYield([]); + expect(container.innerHTML).toBe('

Hello

'); + }); + + it('does not destroy ref cleanup twice when parent suspense is removed', async () => { + function ChildA({label}) { + return ( + { + if (node) { + Scheduler.unstable_yieldValue('Ref mount: ' + label); + } else { + Scheduler.unstable_yieldValue('Ref unmount: ' + label); + } + }}> + + + ); + } + + function ChildB({label}) { + return ( + { + if (node) { + Scheduler.unstable_yieldValue('Ref mount: ' + label); + } else { + Scheduler.unstable_yieldValue('Ref unmount: ' + label); + } + }}> + + + ); + } + + const LazyChildA = React.lazy(() => fakeImport(ChildA)); + const LazyChildB = React.lazy(() => fakeImport(ChildB)); + + function Parent({swap}) { + return ( + }> + {swap ? : } + + ); + } + + const root = ReactDOMClient.createRoot(container); + act(() => { + root.render(); + }); + expect(Scheduler).toHaveYielded(['Loading...']); + + await LazyChildA; + expect(Scheduler).toFlushAndYield(['A', 'Ref mount: A']); + expect(container.innerHTML).toBe('A'); + + // Swap the position of A and B + ReactDOM.flushSync(() => { + root.render(); + }); + expect(Scheduler).toHaveYielded(['Loading...', 'Ref unmount: A']); + expect(container.innerHTML).toBe( + 'ALoading...', + ); + + // Destroy the whole tree, including the hidden A + ReactDOM.flushSync(() => { + root.render(

Hello

); + }); + expect(Scheduler).toFlushAndYield([]); + expect(container.innerHTML).toBe('

Hello

'); + }); + + it('does not call componentWillUnmount twice when parent suspense is removed', async () => { + class ChildA extends React.Component { + componentDidMount() { + Scheduler.unstable_yieldValue('Did mount: ' + this.props.label); + } + componentWillUnmount() { + Scheduler.unstable_yieldValue('Will unmount: ' + this.props.label); + } + render() { + return ; + } + } + + class ChildB extends React.Component { + componentDidMount() { + Scheduler.unstable_yieldValue('Did mount: ' + this.props.label); + } + componentWillUnmount() { + Scheduler.unstable_yieldValue('Will unmount: ' + this.props.label); + } + render() { + return ; + } + } + + const LazyChildA = React.lazy(() => fakeImport(ChildA)); + const LazyChildB = React.lazy(() => fakeImport(ChildB)); + + function Parent({swap}) { + return ( + }> + {swap ? : } + + ); + } + + const root = ReactDOMClient.createRoot(container); + act(() => { + root.render(); + }); + expect(Scheduler).toHaveYielded(['Loading...']); + + await LazyChildA; + expect(Scheduler).toFlushAndYield(['A', 'Did mount: A']); + expect(container.innerHTML).toBe('A'); + + // Swap the position of A and B + ReactDOM.flushSync(() => { + root.render(); + }); + expect(Scheduler).toHaveYielded(['Loading...', 'Will unmount: A']); + expect(container.innerHTML).toBe('Loading...'); + + // Destroy the whole tree, including the hidden A + ReactDOM.flushSync(() => { + root.render(

Hello

); + }); + expect(Scheduler).toFlushAndYield([]); + expect(container.innerHTML).toBe('

Hello

'); + }); });