@samitouri / QOS-React-2 / commits / 80f3d88190

Mount/unmount passive effects when Offscreen visibility changes (#24977)

* Remove unnecessary try-catch from passive deletion The individual unmount calls are already wrapped in a catch block, so this outer one serves no purpose. * Extract passive unmount effects to separate functions I'm about to add a "disconnect passive effects" function that will share much of the same code as commitPassiveUnmountOnFiber. To minimize the duplicated code, I've extracted the shared parts into separate functions, similar to what I did for commitLayoutEffectOnFiber and reappearLayoutEffects. This may not save much on code size because Closure will likely inline some of it, anyway, but it makes it harder for the two paths to accidentally diverge. * Mount/unmount passive effects on hide/show 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. * Add more coverage for nested Offscreen cases

Andrew Clark committed Jul 29, 2022 at 19:34 UTC 80f3d88190c07c2da11b5cac58a44c3b90fbc296
5 files changed +616 -177
packages/react-reconciler/src/ReactFiberCommitWork.new.js
+161 -79
@@ -3553,6 +3553,60 @@ export function commitPassiveUnmountEffects(finishedWork: Fiber): void {
3553 resetCurrentDebugFiberInDEV();
3554 }
3555
3556 +function detachAlternateSiblings(parentFiber: Fiber) {
3557 + if (deletedTreeCleanUpLevel >= 1) {
3558 + // A fiber was deleted from this parent fiber, but it's still part of the
3559 + // previous (alternate) parent fiber's list of children. Because children
3560 + // are a linked list, an earlier sibling that's still alive will be
3561 + // connected to the deleted fiber via its `alternate`:
3562 + //
3563 + // live fiber --alternate--> previous live fiber --sibling--> deleted
3564 + // fiber
3565 + //
3566 + // We can't disconnect `alternate` on nodes that haven't been deleted yet,
3567 + // but we can disconnect the `sibling` and `child` pointers.
3568 +
3569 + const previousFiber = parentFiber.alternate;
3570 + if (previousFiber !== null) {
3571 + let detachedChild = previousFiber.child;
3572 + if (detachedChild !== null) {
3573 + previousFiber.child = null;
3574 + do {
3575 + const detachedSibling = detachedChild.sibling;
3576 + detachedChild.sibling = null;
3577 + detachedChild = detachedSibling;
3578 + } while (detachedChild !== null);
3579 + }
3580 + }
3581 + }
3582 +}
3583 +
3584 +function commitHookPassiveUnmountEffects(
3585 + finishedWork: Fiber,
3586 + nearestMountedAncestor,
3587 + hookFlags: HookFlags,
3588 +) {
3589 + if (
3590 + enableProfilerTimer &&
3591 + enableProfilerCommitHooks &&
3592 + finishedWork.mode & ProfileMode
3593 + ) {
3594 + startPassiveEffectTimer();
3595 + commitHookEffectListUnmount(
3596 + hookFlags,
3597 + finishedWork,
3598 + nearestMountedAncestor,
3599 + );
3600 + recordPassiveEffectDuration(finishedWork);
3601 + } else {
3602 + commitHookEffectListUnmount(
3603 + hookFlags,
3604 + finishedWork,
3605 + nearestMountedAncestor,
3606 + );
3607 + }
3608 +}
3609 +
3610 function recursivelyTraversePassiveUnmountEffects(parentFiber: Fiber): void {
3611 // Deletions effects can be scheduled on any fiber type. They need to happen
3612 // before the children effects have fired.
@@ -3562,44 +3616,15 @@ function recursivelyTraversePassiveUnmountEffects(parentFiber: Fiber): void {
3616 if (deletions !== null) {
3617 for (let i = 0; i < deletions.length; i++) {
3618 const childToDelete = deletions[i];
3565 - try {
3566 - // TODO: Convert this to use recursion
3567 - nextEffect = childToDelete;
3568 - commitPassiveUnmountEffectsInsideOfDeletedTree_begin(
3569 - childToDelete,
3570 - parentFiber,
3571 - );
3572 - } catch (error) {
3573 - captureCommitPhaseError(childToDelete, parentFiber, error);
3574 - }
3575 - }
3576 - }
3577 -
3578 - if (deletedTreeCleanUpLevel >= 1) {
3579 - // A fiber was deleted from this parent fiber, but it's still part of
3580 - // the previous (alternate) parent fiber's list of children. Because
3581 - // children are a linked list, an earlier sibling that's still alive
3582 - // will be connected to the deleted fiber via its `alternate`:
3583 - //
3584 - // live fiber
3585 - // --alternate--> previous live fiber
3586 - // --sibling--> deleted fiber
3587 - //
3588 - // We can't disconnect `alternate` on nodes that haven't been deleted
3589 - // yet, but we can disconnect the `sibling` and `child` pointers.
3590 - const previousFiber = parentFiber.alternate;
3591 - if (previousFiber !== null) {
3592 - let detachedChild = previousFiber.child;
3593 - if (detachedChild !== null) {
3594 - previousFiber.child = null;
3595 - do {
3596 - const detachedSibling = detachedChild.sibling;
3597 - detachedChild.sibling = null;
3598 - detachedChild = detachedSibling;
3599 - } while (detachedChild !== null);
3600 - }
3619 + // TODO: Convert this to use recursion
3620 + nextEffect = childToDelete;
3621 + commitPassiveUnmountEffectsInsideOfDeletedTree_begin(
3622 + childToDelete,
3623 + parentFiber,
3624 + );
3625 }
3626 }
3627 + detachAlternateSiblings(parentFiber);
3628 }
3629
3630 const prevDebugFiber = getCurrentDebugFiberInDEV();
@@ -3622,33 +3647,40 @@ function commitPassiveUnmountOnFiber(finishedWork: Fiber): void {
3647 case SimpleMemoComponent: {
3648 recursivelyTraversePassiveUnmountEffects(finishedWork);
3649 if (finishedWork.flags & Passive) {
3625 - if (
3626 - enableProfilerTimer &&
3627 - enableProfilerCommitHooks &&
3628 - finishedWork.mode & ProfileMode
3629 - ) {
3630 - startPassiveEffectTimer();
3631 - commitHookEffectListUnmount(
3632 - HookPassive | HookHasEffect,
3633 - finishedWork,
3634 - finishedWork.return,
3635 - );
3636 - recordPassiveEffectDuration(finishedWork);
3637 - } else {
3638 - commitHookEffectListUnmount(
3639 - HookPassive | HookHasEffect,
3640 - finishedWork,
3641 - finishedWork.return,
3642 - );
3643 - }
3650 + commitHookPassiveUnmountEffects(
3651 + finishedWork,
3652 + finishedWork.return,
3653 + HookPassive | HookHasEffect,
3654 + );
3655 }
3656 break;
3657 }
3647 - // TODO: Disconnect passive effects when a tree is hidden, perhaps after
3648 - // a delay.
3649 - // case OffscreenComponent: {
3650 - // ...
3651 - // }
3658 + case OffscreenComponent: {
3659 + const instance: OffscreenInstance = finishedWork.stateNode;
3660 + const nextState: OffscreenState | null = finishedWork.memoizedState;
3661 +
3662 + const isHidden = nextState !== null;
3663 +
3664 + if (
3665 + isHidden &&
3666 + instance.visibility & OffscreenPassiveEffectsConnected &&
3667 + // For backwards compatibility, don't unmount when a tree suspends. In
3668 + // the future we may change this to unmount after a delay.
3669 + (finishedWork.return === null ||
3670 + finishedWork.return.tag !== SuspenseComponent)
3671 + ) {
3672 + // The effects are currently connected. Disconnect them.
3673 + // TODO: Add option or heuristic to delay before disconnecting the
3674 + // effects. Then if the tree reappears before the delay has elapsed, we
3675 + // can skip toggling the effects entirely.
3676 + instance.visibility &= ~OffscreenPassiveEffectsConnected;
3677 + recursivelyTraverseDisconnectPassiveEffects(finishedWork);
3678 + } else {
3679 + recursivelyTraversePassiveUnmountEffects(finishedWork);
3680 + }
3681 +
3682 + break;
3683 + }
3684 default: {
3685 recursivelyTraversePassiveUnmountEffects(finishedWork);
3686 break;
@@ -3656,6 +3688,70 @@ function commitPassiveUnmountOnFiber(finishedWork: Fiber): void {
3688 }
3689 }
3690
3691 +function recursivelyTraverseDisconnectPassiveEffects(parentFiber: Fiber): void {
3692 + // Deletions effects can be scheduled on any fiber type. They need to happen
3693 + // before the children effects have fired.
3694 + const deletions = parentFiber.deletions;
3695 +
3696 + if ((parentFiber.flags & ChildDeletion) !== NoFlags) {
3697 + if (deletions !== null) {
3698 + for (let i = 0; i < deletions.length; i++) {
3699 + const childToDelete = deletions[i];
3700 + // TODO: Convert this to use recursion
3701 + nextEffect = childToDelete;
3702 + commitPassiveUnmountEffectsInsideOfDeletedTree_begin(
3703 + childToDelete,
3704 + parentFiber,
3705 + );
3706 + }
3707 + }
3708 + detachAlternateSiblings(parentFiber);
3709 + }
3710 +
3711 + const prevDebugFiber = getCurrentDebugFiberInDEV();
3712 + // TODO: Check PassiveStatic flag
3713 + let child = parentFiber.child;
3714 + while (child !== null) {
3715 + setCurrentDebugFiberInDEV(child);
3716 + disconnectPassiveEffect(child);
3717 + child = child.sibling;
3718 + }
3719 + setCurrentDebugFiberInDEV(prevDebugFiber);
3720 +}
3721 +
3722 +function disconnectPassiveEffect(finishedWork: Fiber): void {
3723 + switch (finishedWork.tag) {
3724 + case FunctionComponent:
3725 + case ForwardRef:
3726 + case SimpleMemoComponent: {
3727 + // TODO: Check PassiveStatic flag
3728 + commitHookPassiveUnmountEffects(
3729 + finishedWork,
3730 + finishedWork.return,
3731 + HookPassive,
3732 + );
3733 + // When disconnecting passive effects, we fire the effects in the same
3734 + // order as during a deletiong: parent before child
3735 + recursivelyTraverseDisconnectPassiveEffects(finishedWork);
3736 + break;
3737 + }
3738 + case OffscreenComponent: {
3739 + const instance: OffscreenInstance = finishedWork.stateNode;
3740 + if (instance.visibility & OffscreenPassiveEffectsConnected) {
3741 + instance.visibility &= ~OffscreenPassiveEffectsConnected;
3742 + recursivelyTraverseDisconnectPassiveEffects(finishedWork);
3743 + } else {
3744 + // The effects are already disconnected.
3745 + }
3746 + break;
3747 + }
3748 + default: {
3749 + recursivelyTraverseDisconnectPassiveEffects(finishedWork);
3750 + break;
3751 + }
3752 + }
3753 +}
3754 +
3755 function commitPassiveUnmountEffectsInsideOfDeletedTree_begin(
3756 deletedSubtreeRoot: Fiber,
3757 nearestMountedAncestor: Fiber | null,
@@ -3728,25 +3824,11 @@ function commitPassiveUnmountInsideDeletedTreeOnFiber(
3824 case FunctionComponent:
3825 case ForwardRef:
3826 case SimpleMemoComponent: {
3731 - if (
3732 - enableProfilerTimer &&
3733 - enableProfilerCommitHooks &&
3734 - current.mode & ProfileMode
3735 - ) {
3736 - startPassiveEffectTimer();
3737 - commitHookEffectListUnmount(
3738 - HookPassive,
3739 - current,
3740 - nearestMountedAncestor,
3741 - );
3742 - recordPassiveEffectDuration(current);
3743 - } else {
3744 - commitHookEffectListUnmount(
3745 - HookPassive,
3746 - current,
3747 - nearestMountedAncestor,
3748 - );
3749 - }
3827 + commitHookPassiveUnmountEffects(
3828 + current,
3829 + nearestMountedAncestor,
3830 + HookPassive,
3831 + );
3832 break;
3833 }
3834 // TODO: run passive unmount effects when unmounting a root.
packages/react-reconciler/src/ReactFiberCommitWork.old.js
+161 -79
@@ -3553,6 +3553,60 @@ export function commitPassiveUnmountEffects(finishedWork: Fiber): void {
3553 resetCurrentDebugFiberInDEV();
3554 }
3555
3556 +function detachAlternateSiblings(parentFiber: Fiber) {
3557 + if (deletedTreeCleanUpLevel >= 1) {
3558 + // A fiber was deleted from this parent fiber, but it's still part of the
3559 + // previous (alternate) parent fiber's list of children. Because children
3560 + // are a linked list, an earlier sibling that's still alive will be
3561 + // connected to the deleted fiber via its `alternate`:
3562 + //
3563 + // live fiber --alternate--> previous live fiber --sibling--> deleted
3564 + // fiber
3565 + //
3566 + // We can't disconnect `alternate` on nodes that haven't been deleted yet,
3567 + // but we can disconnect the `sibling` and `child` pointers.
3568 +
3569 + const previousFiber = parentFiber.alternate;
3570 + if (previousFiber !== null) {
3571 + let detachedChild = previousFiber.child;
3572 + if (detachedChild !== null) {
3573 + previousFiber.child = null;
3574 + do {
3575 + const detachedSibling = detachedChild.sibling;
3576 + detachedChild.sibling = null;
3577 + detachedChild = detachedSibling;
3578 + } while (detachedChild !== null);
3579 + }
3580 + }
3581 + }
3582 +}
3583 +
3584 +function commitHookPassiveUnmountEffects(
3585 + finishedWork: Fiber,
3586 + nearestMountedAncestor,
3587 + hookFlags: HookFlags,
3588 +) {
3589 + if (
3590 + enableProfilerTimer &&
3591 + enableProfilerCommitHooks &&
3592 + finishedWork.mode & ProfileMode
3593 + ) {
3594 + startPassiveEffectTimer();
3595 + commitHookEffectListUnmount(
3596 + hookFlags,
3597 + finishedWork,
3598 + nearestMountedAncestor,
3599 + );
3600 + recordPassiveEffectDuration(finishedWork);
3601 + } else {
3602 + commitHookEffectListUnmount(
3603 + hookFlags,
3604 + finishedWork,
3605 + nearestMountedAncestor,
3606 + );
3607 + }
3608 +}
3609 +
3610 function recursivelyTraversePassiveUnmountEffects(parentFiber: Fiber): void {
3611 // Deletions effects can be scheduled on any fiber type. They need to happen
3612 // before the children effects have fired.
@@ -3562,44 +3616,15 @@ function recursivelyTraversePassiveUnmountEffects(parentFiber: Fiber): void {
3616 if (deletions !== null) {
3617 for (let i = 0; i < deletions.length; i++) {
3618 const childToDelete = deletions[i];
3565 - try {
3566 - // TODO: Convert this to use recursion
3567 - nextEffect = childToDelete;
3568 - commitPassiveUnmountEffectsInsideOfDeletedTree_begin(
3569 - childToDelete,
3570 - parentFiber,
3571 - );
3572 - } catch (error) {
3573 - captureCommitPhaseError(childToDelete, parentFiber, error);
3574 - }
3575 - }
3576 - }
3577 -
3578 - if (deletedTreeCleanUpLevel >= 1) {
3579 - // A fiber was deleted from this parent fiber, but it's still part of
3580 - // the previous (alternate) parent fiber's list of children. Because
3581 - // children are a linked list, an earlier sibling that's still alive
3582 - // will be connected to the deleted fiber via its `alternate`:
3583 - //
3584 - // live fiber
3585 - // --alternate--> previous live fiber
3586 - // --sibling--> deleted fiber
3587 - //
3588 - // We can't disconnect `alternate` on nodes that haven't been deleted
3589 - // yet, but we can disconnect the `sibling` and `child` pointers.
3590 - const previousFiber = parentFiber.alternate;
3591 - if (previousFiber !== null) {
3592 - let detachedChild = previousFiber.child;
3593 - if (detachedChild !== null) {
3594 - previousFiber.child = null;
3595 - do {
3596 - const detachedSibling = detachedChild.sibling;
3597 - detachedChild.sibling = null;
3598 - detachedChild = detachedSibling;
3599 - } while (detachedChild !== null);
3600 - }
3619 + // TODO: Convert this to use recursion
3620 + nextEffect = childToDelete;
3621 + commitPassiveUnmountEffectsInsideOfDeletedTree_begin(
3622 + childToDelete,
3623 + parentFiber,
3624 + );
3625 }
3626 }
3627 + detachAlternateSiblings(parentFiber);
3628 }
3629
3630 const prevDebugFiber = getCurrentDebugFiberInDEV();
@@ -3622,33 +3647,40 @@ function commitPassiveUnmountOnFiber(finishedWork: Fiber): void {
3647 case SimpleMemoComponent: {
3648 recursivelyTraversePassiveUnmountEffects(finishedWork);
3649 if (finishedWork.flags & Passive) {
3625 - if (
3626 - enableProfilerTimer &&
3627 - enableProfilerCommitHooks &&
3628 - finishedWork.mode & ProfileMode
3629 - ) {
3630 - startPassiveEffectTimer();
3631 - commitHookEffectListUnmount(
3632 - HookPassive | HookHasEffect,
3633 - finishedWork,
3634 - finishedWork.return,
3635 - );
3636 - recordPassiveEffectDuration(finishedWork);
3637 - } else {
3638 - commitHookEffectListUnmount(
3639 - HookPassive | HookHasEffect,
3640 - finishedWork,
3641 - finishedWork.return,
3642 - );
3643 - }
3650 + commitHookPassiveUnmountEffects(
3651 + finishedWork,
3652 + finishedWork.return,
3653 + HookPassive | HookHasEffect,
3654 + );
3655 }
3656 break;
3657 }
3647 - // TODO: Disconnect passive effects when a tree is hidden, perhaps after
3648 - // a delay.
3649 - // case OffscreenComponent: {
3650 - // ...
3651 - // }
3658 + case OffscreenComponent: {
3659 + const instance: OffscreenInstance = finishedWork.stateNode;
3660 + const nextState: OffscreenState | null = finishedWork.memoizedState;
3661 +
3662 + const isHidden = nextState !== null;
3663 +
3664 + if (
3665 + isHidden &&
3666 + instance.visibility & OffscreenPassiveEffectsConnected &&
3667 + // For backwards compatibility, don't unmount when a tree suspends. In
3668 + // the future we may change this to unmount after a delay.
3669 + (finishedWork.return === null ||
3670 + finishedWork.return.tag !== SuspenseComponent)
3671 + ) {
3672 + // The effects are currently connected. Disconnect them.
3673 + // TODO: Add option or heuristic to delay before disconnecting the
3674 + // effects. Then if the tree reappears before the delay has elapsed, we
3675 + // can skip toggling the effects entirely.
3676 + instance.visibility &= ~OffscreenPassiveEffectsConnected;
3677 + recursivelyTraverseDisconnectPassiveEffects(finishedWork);
3678 + } else {
3679 + recursivelyTraversePassiveUnmountEffects(finishedWork);
3680 + }
3681 +
3682 + break;
3683 + }
3684 default: {
3685 recursivelyTraversePassiveUnmountEffects(finishedWork);
3686 break;
@@ -3656,6 +3688,70 @@ function commitPassiveUnmountOnFiber(finishedWork: Fiber): void {
3688 }
3689 }
3690
3691 +function recursivelyTraverseDisconnectPassiveEffects(parentFiber: Fiber): void {
3692 + // Deletions effects can be scheduled on any fiber type. They need to happen
3693 + // before the children effects have fired.
3694 + const deletions = parentFiber.deletions;
3695 +
3696 + if ((parentFiber.flags & ChildDeletion) !== NoFlags) {
3697 + if (deletions !== null) {
3698 + for (let i = 0; i < deletions.length; i++) {
3699 + const childToDelete = deletions[i];
3700 + // TODO: Convert this to use recursion
3701 + nextEffect = childToDelete;
3702 + commitPassiveUnmountEffectsInsideOfDeletedTree_begin(
3703 + childToDelete,
3704 + parentFiber,
3705 + );
3706 + }
3707 + }
3708 + detachAlternateSiblings(parentFiber);
3709 + }
3710 +
3711 + const prevDebugFiber = getCurrentDebugFiberInDEV();
3712 + // TODO: Check PassiveStatic flag
3713 + let child = parentFiber.child;
3714 + while (child !== null) {
3715 + setCurrentDebugFiberInDEV(child);
3716 + disconnectPassiveEffect(child);
3717 + child = child.sibling;
3718 + }
3719 + setCurrentDebugFiberInDEV(prevDebugFiber);
3720 +}
3721 +
3722 +function disconnectPassiveEffect(finishedWork: Fiber): void {
3723 + switch (finishedWork.tag) {
3724 + case FunctionComponent:
3725 + case ForwardRef:
3726 + case SimpleMemoComponent: {
3727 + // TODO: Check PassiveStatic flag
3728 + commitHookPassiveUnmountEffects(
3729 + finishedWork,
3730 + finishedWork.return,
3731 + HookPassive,
3732 + );
3733 + // When disconnecting passive effects, we fire the effects in the same
3734 + // order as during a deletiong: parent before child
3735 + recursivelyTraverseDisconnectPassiveEffects(finishedWork);
3736 + break;
3737 + }
3738 + case OffscreenComponent: {
3739 + const instance: OffscreenInstance = finishedWork.stateNode;
3740 + if (instance.visibility & OffscreenPassiveEffectsConnected) {
3741 + instance.visibility &= ~OffscreenPassiveEffectsConnected;
3742 + recursivelyTraverseDisconnectPassiveEffects(finishedWork);
3743 + } else {
3744 + // The effects are already disconnected.
3745 + }
3746 + break;
3747 + }
3748 + default: {
3749 + recursivelyTraverseDisconnectPassiveEffects(finishedWork);
3750 + break;
3751 + }
3752 + }
3753 +}
3754 +
3755 function commitPassiveUnmountEffectsInsideOfDeletedTree_begin(
3756 deletedSubtreeRoot: Fiber,
3757 nearestMountedAncestor: Fiber | null,
@@ -3728,25 +3824,11 @@ function commitPassiveUnmountInsideDeletedTreeOnFiber(
3824 case FunctionComponent:
3825 case ForwardRef:
3826 case SimpleMemoComponent: {
3731 - if (
3732 - enableProfilerTimer &&
3733 - enableProfilerCommitHooks &&
3734 - current.mode & ProfileMode
3735 - ) {
3736 - startPassiveEffectTimer();
3737 - commitHookEffectListUnmount(
3738 - HookPassive,
3739 - current,
3740 - nearestMountedAncestor,
3741 - );
3742 - recordPassiveEffectDuration(current);
3743 - } else {
3744 - commitHookEffectListUnmount(
3745 - HookPassive,
3746 - current,
3747 - nearestMountedAncestor,
3748 - );
3749 - }
3827 + commitHookPassiveUnmountEffects(
3828 + current,
3829 + nearestMountedAncestor,
3830 + HookPassive,
3831 + );
3832 break;
3833 }
3834 // TODO: run passive unmount effects when unmounting a root.
packages/react-reconciler/src/ReactFiberCompleteWork.new.js
+16 -9
@@ -1513,15 +1513,22 @@ function completeWork(
1513 const nextState: OffscreenState | null = workInProgress.memoizedState;
1514 const nextIsHidden = nextState !== null;
1515
1516 - if (current !== null) {
1517 - const prevState: OffscreenState | null = current.memoizedState;
1518 - const prevIsHidden = prevState !== null;
1519 - if (
1520 - prevIsHidden !== nextIsHidden &&
1521 - // LegacyHidden doesn't do any hiding — it only pre-renders.
1522 - (!enableLegacyHidden || workInProgress.tag !== LegacyHiddenComponent)
1523 - ) {
1524 - workInProgress.flags |= Visibility;
1516 + // Schedule a Visibility effect if the visibility has changed
1517 + if (enableLegacyHidden && workInProgress.tag === LegacyHiddenComponent) {
1518 + // LegacyHidden doesn't do any hiding — it only pre-renders.
1519 + } else {
1520 + if (current !== null) {
1521 + const prevState: OffscreenState | null = current.memoizedState;
1522 + const prevIsHidden = prevState !== null;
1523 + if (prevIsHidden !== nextIsHidden) {
1524 + workInProgress.flags |= Visibility;
1525 + }
1526 + } else {
1527 + // On initial mount, we only need a Visibility effect if the tree
1528 + // is hidden.
1529 + if (nextIsHidden) {
1530 + workInProgress.flags |= Visibility;
1531 + }
1532 }
1533 }
1534
packages/react-reconciler/src/ReactFiberCompleteWork.old.js
+16 -9
@@ -1513,15 +1513,22 @@ function completeWork(
1513 const nextState: OffscreenState | null = workInProgress.memoizedState;
1514 const nextIsHidden = nextState !== null;
1515
1516 - if (current !== null) {
1517 - const prevState: OffscreenState | null = current.memoizedState;
1518 - const prevIsHidden = prevState !== null;
1519 - if (
1520 - prevIsHidden !== nextIsHidden &&
1521 - // LegacyHidden doesn't do any hiding — it only pre-renders.
1522 - (!enableLegacyHidden || workInProgress.tag !== LegacyHiddenComponent)
1523 - ) {
1524 - workInProgress.flags |= Visibility;
1516 + // Schedule a Visibility effect if the visibility has changed
1517 + if (enableLegacyHidden && workInProgress.tag === LegacyHiddenComponent) {
1518 + // LegacyHidden doesn't do any hiding — it only pre-renders.
1519 + } else {
1520 + if (current !== null) {
1521 + const prevState: OffscreenState | null = current.memoizedState;
1522 + const prevIsHidden = prevState !== null;
1523 + if (prevIsHidden !== nextIsHidden) {
1524 + workInProgress.flags |= Visibility;
1525 + }
1526 + } else {
1527 + // On initial mount, we only need a Visibility effect if the tree
1528 + // is hidden.
1529 + if (nextIsHidden) {
1530 + workInProgress.flags |= Visibility;
1531 + }
1532 }
1533 }
1534
packages/react-reconciler/src/__tests__/ReactOffscreen-test.js
+262 -1
@@ -7,6 +7,7 @@ let Offscreen;
7 let useState;
8 let useLayoutEffect;
9 let useEffect;
10 +let useMemo;
11 let startTransition;
12
13 describe('ReactOffscreen', () => {
@@ -22,6 +23,7 @@ describe('ReactOffscreen', () => {
23 useState = React.useState;
24 useLayoutEffect = React.useLayoutEffect;
25 useEffect = React.useEffect;
26 + useMemo = React.useMemo;
27 startTransition = React.startTransition;
28 });
29
@@ -939,7 +941,122 @@ describe('ReactOffscreen', () => {
941 });
942
943 // @gate enableOffscreen
942 - it("don't defer passive effects when prerendering in a tree whose effects are already connected", async () => {
944 + it('passive effects are connected and disconnected when the visibility changes', async () => {
945 + function Child({step}) {
946 + useEffect(() => {
947 + Scheduler.unstable_yieldValue(`Commit mount [${step}]`);
948 + return () => {
949 + Scheduler.unstable_yieldValue(`Commit unmount [${step}]`);
950 + };
951 + }, [step]);
952 + return <Text text={step} />;
953 + }
954 +
955 + function App({show, step}) {
956 + return (
957 + <Offscreen mode={show ? 'visible' : 'hidden'}>
958 + {useMemo(
959 + () => (
960 + <Child step={step} />
961 + ),
962 + [step],
963 + )}
964 + </Offscreen>
965 + );
966 + }
967 +
968 + const root = ReactNoop.createRoot();
969 + await act(async () => {
970 + root.render(<App show={true} step={1} />);
971 + });
972 + expect(Scheduler).toHaveYielded([1, 'Commit mount [1]']);
973 + expect(root).toMatchRenderedOutput(<span prop={1} />);
974 +
975 + // Hide the tree. This will unmount the effect.
976 + await act(async () => {
977 + root.render(<App show={false} step={1} />);
978 + });
979 + expect(Scheduler).toHaveYielded(['Commit unmount [1]']);
980 + expect(root).toMatchRenderedOutput(<span hidden={true} prop={1} />);
981 +
982 + // Update.
983 + await act(async () => {
984 + root.render(<App show={false} step={2} />);
985 + });
986 + // The update is prerendered but no effects are fired
987 + expect(Scheduler).toHaveYielded([2]);
988 + expect(root).toMatchRenderedOutput(<span hidden={true} prop={2} />);
989 +
990 + // Reveal the tree.
991 + await act(async () => {
992 + root.render(<App show={true} step={2} />);
993 + });
994 + // The update doesn't render because it was already prerendered, but we do
995 + // fire the effect.
996 + expect(Scheduler).toHaveYielded(['Commit mount [2]']);
997 + expect(root).toMatchRenderedOutput(<span prop={2} />);
998 + });
999 +
1000 + // @gate enableOffscreen
1001 + it('passive effects are unmounted on hide in the same order as during a deletion: parent before child', async () => {
1002 + function Child({label}) {
1003 + useEffect(() => {
1004 + Scheduler.unstable_yieldValue('Mount Child');
1005 + return () => {
1006 + Scheduler.unstable_yieldValue('Unmount Child');
1007 + };
1008 + }, []);
1009 + return <div>Hi</div>;
1010 + }
1011 + function Parent() {
1012 + useEffect(() => {
1013 + Scheduler.unstable_yieldValue('Mount Parent');
1014 + return () => {
1015 + Scheduler.unstable_yieldValue('Unmount Parent');
1016 + };
1017 + }, []);
1018 + return <Child />;
1019 + }
1020 +
1021 + function App({show}) {
1022 + return (
1023 + <Offscreen mode={show ? 'visible' : 'hidden'}>
1024 + <Parent />
1025 + </Offscreen>
1026 + );
1027 + }
1028 +
1029 + const root = ReactNoop.createRoot();
1030 + await act(async () => {
1031 + root.render(<App show={true} />);
1032 + });
1033 + expect(Scheduler).toHaveYielded(['Mount Child', 'Mount Parent']);
1034 +
1035 + // First demonstrate what happens during a normal deletion
1036 + await act(async () => {
1037 + root.render(null);
1038 + });
1039 + expect(Scheduler).toHaveYielded(['Unmount Parent', 'Unmount Child']);
1040 +
1041 + // Now redo the same thing but hide instead of deleting
1042 + await act(async () => {
1043 + root.render(<App show={true} />);
1044 + });
1045 + expect(Scheduler).toHaveYielded(['Mount Child', 'Mount Parent']);
1046 + await act(async () => {
1047 + root.render(<App show={false} />);
1048 + });
1049 + // The order is the same as during a deletion: parent before child
1050 + expect(Scheduler).toHaveYielded(['Unmount Parent', 'Unmount Child']);
1051 + });
1052 +
1053 + // TODO: As of now, there's no way to hide a tree without also unmounting its
1054 + // effects. (Except for Suspense, which has its own tests associated with it.)
1055 + // Re-enable this test once we add this ability. For example, we'll likely add
1056 + // either an option or a heuristic to mount passive effects inside a hidden
1057 + // tree after a delay.
1058 + // @gate enableOffscreen
1059 + it.skip("don't defer passive effects when prerendering in a tree whose effects are already connected", async () => {
1060 function Child({label}) {
1061 useEffect(() => {
1062 Scheduler.unstable_yieldValue('Mount ' + label);
@@ -998,4 +1115,148 @@ describe('ReactOffscreen', () => {
1115 'Mount More 2',
1116 ]);
1117 });
1118 +
1119 + // @gate enableOffscreen
1120 + it('does not mount effects when prerendering a nested Offscreen boundary', async () => {
1121 + function Child({label}) {
1122 + useEffect(() => {
1123 + Scheduler.unstable_yieldValue('Mount ' + label);
1124 + return () => {
1125 + Scheduler.unstable_yieldValue('Unmount ' + label);
1126 + };
1127 + }, [label]);
1128 + return <Text text={label} />;
1129 + }
1130 +
1131 + function App({showOuter, showInner}) {
1132 + return (
1133 + <Offscreen mode={showOuter ? 'visible' : 'hidden'}>
1134 + {useMemo(
1135 + () => (
1136 + <div>
1137 + <Child label="Outer" />
1138 + {showInner ? (
1139 + <Offscreen mode="visible">
1140 + <div>
1141 + <Child label="Inner" />
1142 + </div>
1143 + </Offscreen>
1144 + ) : null}
1145 + </div>
1146 + ),
1147 + [showInner],
1148 + )}
1149 + </Offscreen>
1150 + );
1151 + }
1152 +
1153 + const root = ReactNoop.createRoot();
1154 +
1155 + // Prerender the outer contents. No effects should mount.
1156 + await act(async () => {
1157 + root.render(<App showOuter={false} showInner={false} />);
1158 + });
1159 + expect(Scheduler).toHaveYielded(['Outer']);
1160 + expect(root).toMatchRenderedOutput(
1161 + <div hidden={true}>
1162 + <span prop="Outer" />
1163 + </div>,
1164 + );
1165 +
1166 + // Prerender the inner contents. No effects should mount.
1167 + await act(async () => {
1168 + root.render(<App showOuter={false} showInner={true} />);
1169 + });
1170 + expect(Scheduler).toHaveYielded(['Outer', 'Inner']);
1171 + expect(root).toMatchRenderedOutput(
1172 + <div hidden={true}>
1173 + <span prop="Outer" />
1174 + <div>
1175 + <span prop="Inner" />
1176 + </div>
1177 + </div>,
1178 + );
1179 +
1180 + // Reveal the prerendered tree
1181 + await act(async () => {
1182 + root.render(<App showOuter={true} showInner={true} />);
1183 + });
1184 + // The effects fire, but the tree is not re-rendered because it already
1185 + // prerendered.
1186 + expect(Scheduler).toHaveYielded(['Mount Outer', 'Mount Inner']);
1187 + expect(root).toMatchRenderedOutput(
1188 + <div>
1189 + <span prop="Outer" />
1190 + <div>
1191 + <span prop="Inner" />
1192 + </div>
1193 + </div>,
1194 + );
1195 + });
1196 +
1197 + // @gate enableOffscreen
1198 + it('reveal an outer Offscreen boundary without revealing an inner one', async () => {
1199 + function Child({label}) {
1200 + useEffect(() => {
1201 + Scheduler.unstable_yieldValue('Mount ' + label);
1202 + return () => {
1203 + Scheduler.unstable_yieldValue('Unmount ' + label);
1204 + };
1205 + }, [label]);
1206 + return <Text text={label} />;
1207 + }
1208 +
1209 + function App({showOuter, showInner}) {
1210 + return (
1211 + <Offscreen mode={showOuter ? 'visible' : 'hidden'}>
1212 + {useMemo(
1213 + () => (
1214 + <div>
1215 + <Child label="Outer" />
1216 + <Offscreen mode={showInner ? 'visible' : 'hidden'}>
1217 + <div>
1218 + <Child label="Inner" />
1219 + </div>
1220 + </Offscreen>
1221 + </div>
1222 + ),
1223 + [showInner],
1224 + )}
1225 + </Offscreen>
1226 + );
1227 + }
1228 +
1229 + const root = ReactNoop.createRoot();
1230 +
1231 + // Prerender the whole tree.
1232 + await act(async () => {
1233 + root.render(<App showOuter={false} showInner={false} />);
1234 + });
1235 + expect(Scheduler).toHaveYielded(['Outer', 'Inner']);
1236 + // Both the inner and the outer tree should be hidden. Hiding the inner tree
1237 + // is arguably redundant, but the advantage of hiding both is that later you
1238 + // can reveal the outer tree without having to examine the inner one.
1239 + expect(root).toMatchRenderedOutput(
1240 + <div hidden={true}>
1241 + <span prop="Outer" />
1242 + <div hidden={true}>
1243 + <span prop="Inner" />
1244 + </div>
1245 + </div>,
1246 + );
1247 +
1248 + // Reveal the outer contents. The inner tree remains hidden.
1249 + await act(async () => {
1250 + root.render(<App showOuter={true} showInner={false} />);
1251 + });
1252 + expect(Scheduler).toHaveYielded(['Mount Outer']);
1253 + expect(root).toMatchRenderedOutput(
1254 + <div>
1255 + <span prop="Outer" />
1256 + <div hidden={true}>
1257 + <span prop="Inner" />
1258 + </div>
1259 + </div>,
1260 + );
1261 + });
1262 });