From bbe4813d00d631f14e4eeb78a1e86e2c8e9a2d2c Mon Sep 17 00:00:00 2001 From: Andrew Clark Date: Fri, 22 Jul 2022 00:39:33 -0400 Subject: [PATCH] Mount/unmount passive effects on hide/show MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit This changes the behavior of Offscreen so that passive effects are unmounted when the tree is hidden, and re-mounted when the tree is revealed again. This is already how layout effects worked. In the future we will likely add an option or heuristic to only unmount the effects of a hidden tree after a delay. That way if the tree quickly switches back to visible, we can skip toggling the effects entirely. This change does not apply to suspended trees, which happen to use the Offscreen fiber type as an implementation detail. Passive effects remain mounted while the tree is suspended, for the reason described above — it's likely that the suspended tree will resolve and switch back to visible within a short time span. At a high level, what this capability enables is a feature we refer to as "resuable state". The real value proposition here isn't so much the behavior of effects — it's that you can switch back to a previously rendered tree without losing the state of the UI. --- .../src/ReactFiberCommitWork.new.js | 117 ++++++++++++++--- .../src/ReactFiberCommitWork.old.js | 117 ++++++++++++++--- .../src/__tests__/ReactOffscreen-test.js | 119 +++++++++++++++++- 3 files changed, 320 insertions(+), 33 deletions(-) diff --git a/packages/react-reconciler/src/ReactFiberCommitWork.new.js b/packages/react-reconciler/src/ReactFiberCommitWork.new.js index efb36f2a68147..3f7417fc626d8 100644 --- a/packages/react-reconciler/src/ReactFiberCommitWork.new.js +++ b/packages/react-reconciler/src/ReactFiberCommitWork.new.js @@ -3555,6 +3555,17 @@ export function commitPassiveUnmountEffects(finishedWork: Fiber): void { function detachAlternateSiblings(parentFiber: Fiber) { if (deletedTreeCleanUpLevel >= 1) { + // A fiber was deleted from this parent fiber, but it's still part of the + // previous (alternate) parent fiber's list of children. Because children + // are a linked list, an earlier sibling that's still alive will be + // connected to the deleted fiber via its `alternate`: + // + // live fiber --alternate--> previous live fiber --sibling--> deleted + // fiber + // + // We can't disconnect `alternate` on nodes that haven't been deleted yet, + // but we can disconnect the `sibling` and `child` pointers. + const previousFiber = parentFiber.alternate; if (previousFiber !== null) { let detachedChild = previousFiber.child; @@ -3613,17 +3624,6 @@ function recursivelyTraversePassiveUnmountEffects(parentFiber: Fiber): void { ); } } - // A fiber was deleted from this parent fiber, but it's still part of - // the previous (alternate) parent fiber's list of children. Because - // children are a linked list, an earlier sibling that's still alive - // will be connected to the deleted fiber via its `alternate`: - // - // live fiber - // --alternate--> previous live fiber - // --sibling--> deleted fiber - // - // We can't disconnect `alternate` on nodes that haven't been deleted - // yet, but we can disconnect the `sibling` and `child` pointers. detachAlternateSiblings(parentFiber); } @@ -3655,11 +3655,32 @@ function commitPassiveUnmountOnFiber(finishedWork: Fiber): void { } break; } - // TODO: Disconnect passive effects when a tree is hidden, perhaps after - // a delay. - // case OffscreenComponent: { - // ... - // } + case OffscreenComponent: { + const instance: OffscreenInstance = finishedWork.stateNode; + const nextState: OffscreenState | null = finishedWork.memoizedState; + + const isHidden = nextState !== null; + + if ( + isHidden && + instance.visibility & OffscreenPassiveEffectsConnected && + // For backwards compatibility, don't unmount when a tree suspends. In + // the future we may change this to unmount after a delay. + (finishedWork.return === null || + finishedWork.return.tag !== SuspenseComponent) + ) { + // The effects are currently connected. Disconnect them. + // TODO: Add option or heuristic to delay before disconnecting the + // effects. Then if the tree reappears before the delay has elapsed, we + // can skip toggling the effects entirely. + instance.visibility &= ~OffscreenPassiveEffectsConnected; + recursivelyTraverseDisconnectPassiveEffects(finishedWork); + } else { + recursivelyTraversePassiveUnmountEffects(finishedWork); + } + + break; + } default: { recursivelyTraversePassiveUnmountEffects(finishedWork); break; @@ -3667,6 +3688,70 @@ function commitPassiveUnmountOnFiber(finishedWork: Fiber): void { } } +function recursivelyTraverseDisconnectPassiveEffects(parentFiber: Fiber): void { + // Deletions effects can be scheduled on any fiber type. They need to happen + // before the children effects have fired. + const deletions = parentFiber.deletions; + + if ((parentFiber.flags & ChildDeletion) !== NoFlags) { + if (deletions !== null) { + for (let i = 0; i < deletions.length; i++) { + const childToDelete = deletions[i]; + // TODO: Convert this to use recursion + nextEffect = childToDelete; + commitPassiveUnmountEffectsInsideOfDeletedTree_begin( + childToDelete, + parentFiber, + ); + } + } + detachAlternateSiblings(parentFiber); + } + + const prevDebugFiber = getCurrentDebugFiberInDEV(); + // TODO: Check PassiveStatic flag + let child = parentFiber.child; + while (child !== null) { + setCurrentDebugFiberInDEV(child); + disconnectPassiveEffect(child); + child = child.sibling; + } + setCurrentDebugFiberInDEV(prevDebugFiber); +} + +function disconnectPassiveEffect(finishedWork: Fiber): void { + switch (finishedWork.tag) { + case FunctionComponent: + case ForwardRef: + case SimpleMemoComponent: { + // TODO: Check PassiveStatic flag + commitHookPassiveUnmountEffects( + finishedWork, + finishedWork.return, + HookPassive, + ); + // When disconnecting passive effects, we fire the effects in the same + // order as during a deletiong: parent before child + recursivelyTraverseDisconnectPassiveEffects(finishedWork); + break; + } + case OffscreenComponent: { + const instance: OffscreenInstance = finishedWork.stateNode; + if (instance.visibility & OffscreenPassiveEffectsConnected) { + instance.visibility &= ~OffscreenPassiveEffectsConnected; + recursivelyTraverseDisconnectPassiveEffects(finishedWork); + } else { + // The effects are already disconnected. + } + break; + } + default: { + recursivelyTraverseDisconnectPassiveEffects(finishedWork); + break; + } + } +} + function commitPassiveUnmountEffectsInsideOfDeletedTree_begin( deletedSubtreeRoot: Fiber, nearestMountedAncestor: Fiber | null, diff --git a/packages/react-reconciler/src/ReactFiberCommitWork.old.js b/packages/react-reconciler/src/ReactFiberCommitWork.old.js index 553c51f4b0287..b0e16e8ab2aeb 100644 --- a/packages/react-reconciler/src/ReactFiberCommitWork.old.js +++ b/packages/react-reconciler/src/ReactFiberCommitWork.old.js @@ -3555,6 +3555,17 @@ export function commitPassiveUnmountEffects(finishedWork: Fiber): void { function detachAlternateSiblings(parentFiber: Fiber) { if (deletedTreeCleanUpLevel >= 1) { + // A fiber was deleted from this parent fiber, but it's still part of the + // previous (alternate) parent fiber's list of children. Because children + // are a linked list, an earlier sibling that's still alive will be + // connected to the deleted fiber via its `alternate`: + // + // live fiber --alternate--> previous live fiber --sibling--> deleted + // fiber + // + // We can't disconnect `alternate` on nodes that haven't been deleted yet, + // but we can disconnect the `sibling` and `child` pointers. + const previousFiber = parentFiber.alternate; if (previousFiber !== null) { let detachedChild = previousFiber.child; @@ -3613,17 +3624,6 @@ function recursivelyTraversePassiveUnmountEffects(parentFiber: Fiber): void { ); } } - // A fiber was deleted from this parent fiber, but it's still part of - // the previous (alternate) parent fiber's list of children. Because - // children are a linked list, an earlier sibling that's still alive - // will be connected to the deleted fiber via its `alternate`: - // - // live fiber - // --alternate--> previous live fiber - // --sibling--> deleted fiber - // - // We can't disconnect `alternate` on nodes that haven't been deleted - // yet, but we can disconnect the `sibling` and `child` pointers. detachAlternateSiblings(parentFiber); } @@ -3655,11 +3655,32 @@ function commitPassiveUnmountOnFiber(finishedWork: Fiber): void { } break; } - // TODO: Disconnect passive effects when a tree is hidden, perhaps after - // a delay. - // case OffscreenComponent: { - // ... - // } + case OffscreenComponent: { + const instance: OffscreenInstance = finishedWork.stateNode; + const nextState: OffscreenState | null = finishedWork.memoizedState; + + const isHidden = nextState !== null; + + if ( + isHidden && + instance.visibility & OffscreenPassiveEffectsConnected && + // For backwards compatibility, don't unmount when a tree suspends. In + // the future we may change this to unmount after a delay. + (finishedWork.return === null || + finishedWork.return.tag !== SuspenseComponent) + ) { + // The effects are currently connected. Disconnect them. + // TODO: Add option or heuristic to delay before disconnecting the + // effects. Then if the tree reappears before the delay has elapsed, we + // can skip toggling the effects entirely. + instance.visibility &= ~OffscreenPassiveEffectsConnected; + recursivelyTraverseDisconnectPassiveEffects(finishedWork); + } else { + recursivelyTraversePassiveUnmountEffects(finishedWork); + } + + break; + } default: { recursivelyTraversePassiveUnmountEffects(finishedWork); break; @@ -3667,6 +3688,70 @@ function commitPassiveUnmountOnFiber(finishedWork: Fiber): void { } } +function recursivelyTraverseDisconnectPassiveEffects(parentFiber: Fiber): void { + // Deletions effects can be scheduled on any fiber type. They need to happen + // before the children effects have fired. + const deletions = parentFiber.deletions; + + if ((parentFiber.flags & ChildDeletion) !== NoFlags) { + if (deletions !== null) { + for (let i = 0; i < deletions.length; i++) { + const childToDelete = deletions[i]; + // TODO: Convert this to use recursion + nextEffect = childToDelete; + commitPassiveUnmountEffectsInsideOfDeletedTree_begin( + childToDelete, + parentFiber, + ); + } + } + detachAlternateSiblings(parentFiber); + } + + const prevDebugFiber = getCurrentDebugFiberInDEV(); + // TODO: Check PassiveStatic flag + let child = parentFiber.child; + while (child !== null) { + setCurrentDebugFiberInDEV(child); + disconnectPassiveEffect(child); + child = child.sibling; + } + setCurrentDebugFiberInDEV(prevDebugFiber); +} + +function disconnectPassiveEffect(finishedWork: Fiber): void { + switch (finishedWork.tag) { + case FunctionComponent: + case ForwardRef: + case SimpleMemoComponent: { + // TODO: Check PassiveStatic flag + commitHookPassiveUnmountEffects( + finishedWork, + finishedWork.return, + HookPassive, + ); + // When disconnecting passive effects, we fire the effects in the same + // order as during a deletiong: parent before child + recursivelyTraverseDisconnectPassiveEffects(finishedWork); + break; + } + case OffscreenComponent: { + const instance: OffscreenInstance = finishedWork.stateNode; + if (instance.visibility & OffscreenPassiveEffectsConnected) { + instance.visibility &= ~OffscreenPassiveEffectsConnected; + recursivelyTraverseDisconnectPassiveEffects(finishedWork); + } else { + // The effects are already disconnected. + } + break; + } + default: { + recursivelyTraverseDisconnectPassiveEffects(finishedWork); + break; + } + } +} + function commitPassiveUnmountEffectsInsideOfDeletedTree_begin( deletedSubtreeRoot: Fiber, nearestMountedAncestor: Fiber | null, diff --git a/packages/react-reconciler/src/__tests__/ReactOffscreen-test.js b/packages/react-reconciler/src/__tests__/ReactOffscreen-test.js index 7658c3aedbdc6..ac9da5a598ef5 100644 --- a/packages/react-reconciler/src/__tests__/ReactOffscreen-test.js +++ b/packages/react-reconciler/src/__tests__/ReactOffscreen-test.js @@ -7,6 +7,7 @@ let Offscreen; let useState; let useLayoutEffect; let useEffect; +let useMemo; let startTransition; describe('ReactOffscreen', () => { @@ -22,6 +23,7 @@ describe('ReactOffscreen', () => { useState = React.useState; useLayoutEffect = React.useLayoutEffect; useEffect = React.useEffect; + useMemo = React.useMemo; startTransition = React.startTransition; }); @@ -939,7 +941,122 @@ describe('ReactOffscreen', () => { }); // @gate enableOffscreen - it("don't defer passive effects when prerendering in a tree whose effects are already connected", async () => { + it('passive effects are connected and disconnected when the visibility changes', async () => { + function Child({step}) { + useEffect(() => { + Scheduler.unstable_yieldValue(`Commit mount [${step}]`); + return () => { + Scheduler.unstable_yieldValue(`Commit unmount [${step}]`); + }; + }, [step]); + return ; + } + + function App({show, step}) { + return ( + + {useMemo( + () => ( + + ), + [step], + )} + + ); + } + + const root = ReactNoop.createRoot(); + await act(async () => { + root.render(); + }); + expect(Scheduler).toHaveYielded([1, 'Commit mount [1]']); + expect(root).toMatchRenderedOutput(); + + // Hide the tree. This will unmount the effect. + await act(async () => { + root.render(); + }); + expect(Scheduler).toHaveYielded(['Commit unmount [1]']); + expect(root).toMatchRenderedOutput(