diff --git a/packages/react-reconciler/src/ReactFiberBeginWork.new.js b/packages/react-reconciler/src/ReactFiberBeginWork.new.js index 2f4bfe98fb0fe..e707ddf69dfb6 100644 --- a/packages/react-reconciler/src/ReactFiberBeginWork.new.js +++ b/packages/react-reconciler/src/ReactFiberBeginWork.new.js @@ -66,6 +66,7 @@ import { DidCapture, Update, Ref, + RefStatic, ChildDeletion, ForceUpdateForLegacySuspense, StaticMask, @@ -83,6 +84,7 @@ import { enableScopeAPI, enableCache, enableLazyContextPropagation, + enableSuspenseLayoutEffectSemantics, } from 'shared/ReactFeatureFlags'; import invariant from 'shared/invariant'; import shallowEqual from 'shared/shallowEqual'; @@ -854,6 +856,9 @@ function markRef(current: Fiber | null, workInProgress: Fiber) { ) { // Schedule a Ref effect workInProgress.flags |= Ref; + if (enableSuspenseLayoutEffectSemantics) { + workInProgress.flags |= RefStatic; + } } } diff --git a/packages/react-reconciler/src/ReactFiberBeginWork.old.js b/packages/react-reconciler/src/ReactFiberBeginWork.old.js index 9ca4e4afad443..10771e018fdb1 100644 --- a/packages/react-reconciler/src/ReactFiberBeginWork.old.js +++ b/packages/react-reconciler/src/ReactFiberBeginWork.old.js @@ -66,6 +66,7 @@ import { DidCapture, Update, Ref, + RefStatic, ChildDeletion, ForceUpdateForLegacySuspense, StaticMask, @@ -83,6 +84,7 @@ import { enableScopeAPI, enableCache, enableLazyContextPropagation, + enableSuspenseLayoutEffectSemantics, } from 'shared/ReactFeatureFlags'; import invariant from 'shared/invariant'; import shallowEqual from 'shared/shallowEqual'; @@ -854,6 +856,9 @@ function markRef(current: Fiber | null, workInProgress: Fiber) { ) { // Schedule a Ref effect workInProgress.flags |= Ref; + if (enableSuspenseLayoutEffectSemantics) { + workInProgress.flags |= RefStatic; + } } } diff --git a/packages/react-reconciler/src/ReactFiberClassComponent.new.js b/packages/react-reconciler/src/ReactFiberClassComponent.new.js index ea1b4cb5e3c8d..d64527d58ab65 100644 --- a/packages/react-reconciler/src/ReactFiberClassComponent.new.js +++ b/packages/react-reconciler/src/ReactFiberClassComponent.new.js @@ -10,9 +10,15 @@ import type {Fiber} from './ReactInternalTypes'; import type {Lanes} from './ReactFiberLane.new'; import type {UpdateQueue} from './ReactUpdateQueue.new'; +import type {Flags} from './ReactFiberFlags'; import * as React from 'react'; -import {MountLayoutDev, Update, Snapshot} from './ReactFiberFlags'; +import { + LayoutStatic, + MountLayoutDev, + Update, + Snapshot, +} from './ReactFiberFlags'; import { debugRenderPhaseSideEffectsForStrictMode, disableLegacyContext, @@ -21,6 +27,7 @@ import { warnAboutDeprecatedLifecycles, enableStrictEffects, enableLazyContextPropagation, + enableSuspenseLayoutEffectSemantics, } from 'shared/ReactFeatureFlags'; import ReactStrictModeWarnings from './ReactStrictModeWarnings.new'; import {isMounted} from './ReactFiberTreeReflection'; @@ -908,16 +915,19 @@ function mountClassInstance( } if (typeof instance.componentDidMount === 'function') { + let fiberFlags: Flags = Update; + if (enableSuspenseLayoutEffectSemantics) { + fiberFlags |= LayoutStatic; + } if ( __DEV__ && enableStrictEffects && (workInProgress.mode & StrictEffectsMode) !== NoMode ) { // Never double-invoke effects for legacy roots. - workInProgress.flags |= MountLayoutDev | Update; - } else { - workInProgress.flags |= Update; + fiberFlags |= MountLayoutDev; } + workInProgress.flags |= fiberFlags; } } @@ -987,16 +997,19 @@ function resumeMountClassInstance( // If an update was already in progress, we should schedule an Update // effect even though we're bailing out, so that cWU/cDU are called. if (typeof instance.componentDidMount === 'function') { + let fiberFlags: Flags = Update; + if (enableSuspenseLayoutEffectSemantics) { + fiberFlags |= LayoutStatic; + } if ( __DEV__ && enableStrictEffects && (workInProgress.mode & StrictEffectsMode) !== NoMode ) { // Never double-invoke effects for legacy roots. - workInProgress.flags |= MountLayoutDev | Update; - } else { - workInProgress.flags |= Update; + fiberFlags |= MountLayoutDev; } + workInProgress.flags |= fiberFlags; } return false; } @@ -1039,31 +1052,37 @@ function resumeMountClassInstance( } } if (typeof instance.componentDidMount === 'function') { + let fiberFlags: Flags = Update; + if (enableSuspenseLayoutEffectSemantics) { + fiberFlags |= LayoutStatic; + } if ( __DEV__ && enableStrictEffects && (workInProgress.mode & StrictEffectsMode) !== NoMode ) { // Never double-invoke effects for legacy roots. - workInProgress.flags |= MountLayoutDev | Update; - } else { - workInProgress.flags |= Update; + fiberFlags |= MountLayoutDev; } + workInProgress.flags |= fiberFlags; } } else { // If an update was already in progress, we should schedule an Update // effect even though we're bailing out, so that cWU/cDU are called. if (typeof instance.componentDidMount === 'function') { + let fiberFlags: Flags = Update; + if (enableSuspenseLayoutEffectSemantics) { + fiberFlags |= LayoutStatic; + } if ( __DEV__ && enableStrictEffects && (workInProgress.mode & StrictEffectsMode) !== NoMode ) { // Never double-invoke effects for legacy roots. - workInProgress.flags |= MountLayoutDev | Update; - } else { - workInProgress.flags |= Update; + fiberFlags |= MountLayoutDev; } + workInProgress.flags |= fiberFlags; } // If shouldComponentUpdate returned false, we should still update the diff --git a/packages/react-reconciler/src/ReactFiberClassComponent.old.js b/packages/react-reconciler/src/ReactFiberClassComponent.old.js index b952c24cd041d..f9b4128b72f76 100644 --- a/packages/react-reconciler/src/ReactFiberClassComponent.old.js +++ b/packages/react-reconciler/src/ReactFiberClassComponent.old.js @@ -10,9 +10,15 @@ import type {Fiber} from './ReactInternalTypes'; import type {Lanes} from './ReactFiberLane.old'; import type {UpdateQueue} from './ReactUpdateQueue.old'; +import type {Flags} from './ReactFiberFlags'; import * as React from 'react'; -import {MountLayoutDev, Update, Snapshot} from './ReactFiberFlags'; +import { + LayoutStatic, + MountLayoutDev, + Update, + Snapshot, +} from './ReactFiberFlags'; import { debugRenderPhaseSideEffectsForStrictMode, disableLegacyContext, @@ -21,6 +27,7 @@ import { warnAboutDeprecatedLifecycles, enableStrictEffects, enableLazyContextPropagation, + enableSuspenseLayoutEffectSemantics, } from 'shared/ReactFeatureFlags'; import ReactStrictModeWarnings from './ReactStrictModeWarnings.old'; import {isMounted} from './ReactFiberTreeReflection'; @@ -908,16 +915,19 @@ function mountClassInstance( } if (typeof instance.componentDidMount === 'function') { + let fiberFlags: Flags = Update; + if (enableSuspenseLayoutEffectSemantics) { + fiberFlags |= LayoutStatic; + } if ( __DEV__ && enableStrictEffects && (workInProgress.mode & StrictEffectsMode) !== NoMode ) { // Never double-invoke effects for legacy roots. - workInProgress.flags |= MountLayoutDev | Update; - } else { - workInProgress.flags |= Update; + fiberFlags |= MountLayoutDev; } + workInProgress.flags |= fiberFlags; } } @@ -987,16 +997,19 @@ function resumeMountClassInstance( // If an update was already in progress, we should schedule an Update // effect even though we're bailing out, so that cWU/cDU are called. if (typeof instance.componentDidMount === 'function') { + let fiberFlags: Flags = Update; + if (enableSuspenseLayoutEffectSemantics) { + fiberFlags |= LayoutStatic; + } if ( __DEV__ && enableStrictEffects && (workInProgress.mode & StrictEffectsMode) !== NoMode ) { // Never double-invoke effects for legacy roots. - workInProgress.flags |= MountLayoutDev | Update; - } else { - workInProgress.flags |= Update; + fiberFlags |= MountLayoutDev; } + workInProgress.flags |= fiberFlags; } return false; } @@ -1039,31 +1052,37 @@ function resumeMountClassInstance( } } if (typeof instance.componentDidMount === 'function') { + let fiberFlags: Flags = Update; + if (enableSuspenseLayoutEffectSemantics) { + fiberFlags |= LayoutStatic; + } if ( __DEV__ && enableStrictEffects && (workInProgress.mode & StrictEffectsMode) !== NoMode ) { // Never double-invoke effects for legacy roots. - workInProgress.flags |= MountLayoutDev | Update; - } else { - workInProgress.flags |= Update; + fiberFlags |= MountLayoutDev; } + workInProgress.flags |= fiberFlags; } } else { // If an update was already in progress, we should schedule an Update // effect even though we're bailing out, so that cWU/cDU are called. if (typeof instance.componentDidMount === 'function') { + let fiberFlags: Flags = Update; + if (enableSuspenseLayoutEffectSemantics) { + fiberFlags |= LayoutStatic; + } if ( __DEV__ && enableStrictEffects && (workInProgress.mode & StrictEffectsMode) !== NoMode ) { // Never double-invoke effects for legacy roots. - workInProgress.flags |= MountLayoutDev | Update; - } else { - workInProgress.flags |= Update; + fiberFlags |= MountLayoutDev; } + workInProgress.flags |= fiberFlags; } // If shouldComponentUpdate returned false, we should still update the diff --git a/packages/react-reconciler/src/ReactFiberCommitWork.new.js b/packages/react-reconciler/src/ReactFiberCommitWork.new.js index e9845e1e65dc4..48150f021a32b 100644 --- a/packages/react-reconciler/src/ReactFiberCommitWork.new.js +++ b/packages/react-reconciler/src/ReactFiberCommitWork.new.js @@ -36,6 +36,7 @@ import { enableScopeAPI, enableStrictEffects, deletedTreeCleanUpLevel, + enableSuspenseLayoutEffectSemantics, } from 'shared/ReactFeatureFlags'; import { FunctionComponent, @@ -79,6 +80,8 @@ import { MutationMask, LayoutMask, PassiveMask, + LayoutStatic, + RefStatic, } from './ReactFiberFlags'; import getComponentNameFromFiber from 'react-reconciler/src/getComponentNameFromFiber'; import invariant from 'shared/invariant'; @@ -97,7 +100,7 @@ import { recordPassiveEffectDuration, startPassiveEffectTimer, } from './ReactProfilerTimer.new'; -import {ProfileMode} from './ReactTypeOfMode'; +import {ConcurrentMode, NoMode, ProfileMode} from './ReactTypeOfMode'; import {commitUpdateQueue} from './ReactUpdateQueue.new'; import { getPublicInstance, @@ -149,6 +152,14 @@ if (__DEV__) { didWarnAboutUndefinedSnapshotBeforeUpdate = new Set(); } +// Used during the commit phase to track the state of the Offscreen component stack. +// Allows us to avoid traversing the return path to find the nearest Offscreen ancestor. +// Only used when enableSuspenseLayoutEffectSemantics is enabled. +let offscreenSubtreeIsHidden: boolean = false; +const offscreenSubtreeIsHiddenStack: Array = []; +let offscreenSubtreeWasHidden: boolean = false; +const offscreenSubtreeWasHiddenStack: Array = []; + const PossiblyWeakSet = typeof WeakSet === 'function' ? WeakSet : Set; let nextEffect: Fiber | null = null; @@ -172,6 +183,32 @@ const callComponentWillUnmountWithTimer = function(current, instance) { } }; +// Capture errors so they don't interrupt mounting. +function safelyCallCommitHookLayoutEffectListMount( + current: Fiber, + nearestMountedAncestor: Fiber | null, +) { + if (__DEV__) { + invokeGuardedCallback( + null, + commitHookEffectListMount, + null, + HookLayout, + current, + ); + if (hasCaughtError()) { + const unmountError = clearCaughtError(); + captureCommitPhaseError(current, nearestMountedAncestor, unmountError); + } + } else { + try { + commitHookEffectListMount(HookLayout, current); + } catch (unmountError) { + captureCommitPhaseError(current, nearestMountedAncestor, unmountError); + } + } +} + // Capture errors so they don't interrupt unmounting. function safelyCallComponentWillUnmount( current: Fiber, @@ -199,6 +236,44 @@ function safelyCallComponentWillUnmount( } } +// Capture errors so they don't interrupt mounting. +function safelyCallComponentDidMount( + current: Fiber, + nearestMountedAncestor: Fiber | null, + instance: any, +) { + if (__DEV__) { + invokeGuardedCallback(null, instance.componentDidMount, instance); + if (hasCaughtError()) { + const unmountError = clearCaughtError(); + captureCommitPhaseError(current, nearestMountedAncestor, unmountError); + } + } else { + try { + instance.componentDidMount(); + } catch (unmountError) { + captureCommitPhaseError(current, nearestMountedAncestor, unmountError); + } + } +} + +// Capture errors so they don't interrupt mounting. +function safelyAttachRef(current: Fiber, nearestMountedAncestor: Fiber | null) { + if (__DEV__) { + invokeGuardedCallback(null, commitAttachRef, null, current); + if (hasCaughtError()) { + const unmountError = clearCaughtError(); + captureCommitPhaseError(current, nearestMountedAncestor, unmountError); + } + } else { + try { + commitAttachRef(current); + } catch (unmountError) { + captureCommitPhaseError(current, nearestMountedAncestor, unmountError); + } + } +} + function safelyDetachRef(current: Fiber, nearestMountedAncestor: Fiber | null) { const ref = current.ref; if (ref !== null) { @@ -942,6 +1017,12 @@ function commitLayoutEffectOnFiber( } function hideOrUnhideAllChildren(finishedWork, isHidden) { + // Suspense layout effects semantics don't change for legacy roots. + const isModernRoot = (finishedWork.mode & ConcurrentMode) !== NoMode; + + const current = finishedWork.alternate; + const wasHidden = current !== null && current.memoizedState !== null; + if (supportsMutation) { // We only have the top Fiber that was inserted but we need to recurse down its // children to find all the terminal nodes. @@ -954,6 +1035,25 @@ function hideOrUnhideAllChildren(finishedWork, isHidden) { } else { unhideInstance(node.stateNode, node.memoizedProps); } + + if (enableSuspenseLayoutEffectSemantics && isModernRoot) { + // This method is called during mutation; it should detach refs within a hidden subtree. + // Attaching refs should be done elsewhere though (during layout). + if ((node.flags & RefStatic) !== NoFlags) { + if (isHidden) { + safelyDetachRef(node, finishedWork); + } + } + + if ( + (node.subtreeFlags & (RefStatic | LayoutStatic)) !== NoFlags && + node.child !== null + ) { + node.child.return = node; + node = node.child; + continue; + } + } } else if (node.tag === HostText) { const instance = node.stateNode; if (isHidden) { @@ -967,13 +1067,61 @@ function hideOrUnhideAllChildren(finishedWork, isHidden) { (node.memoizedState: OffscreenState) !== null && node !== finishedWork ) { - // Found a nested Offscreen component that is hidden. Don't search - // any deeper. This tree should remain hidden. + // Found a nested Offscreen component that is hidden. + // Don't search any deeper. This tree should remain hidden. + } else if (enableSuspenseLayoutEffectSemantics && isModernRoot) { + // When a mounted Suspense subtree gets hidden again, destroy any nested layout effects. + if ((node.flags & (RefStatic | LayoutStatic)) !== NoFlags) { + switch (node.tag) { + case FunctionComponent: + case ForwardRef: + case MemoComponent: + case SimpleMemoComponent: { + // Note that refs are attached by the useImperativeHandle() hook, not by commitAttachRef() + if (isHidden && !wasHidden) { + if ( + enableProfilerTimer && + enableProfilerCommitHooks && + node.mode & ProfileMode + ) { + try { + startLayoutEffectTimer(); + commitHookEffectListUnmount(HookLayout, node, finishedWork); + } finally { + recordLayoutEffectDuration(node); + } + } else { + commitHookEffectListUnmount(HookLayout, node, finishedWork); + } + } + break; + } + case ClassComponent: { + if (isHidden && !wasHidden) { + if ((node.flags & RefStatic) !== NoFlags) { + safelyDetachRef(node, finishedWork); + } + const instance = node.stateNode; + if (typeof instance.componentWillUnmount === 'function') { + safelyCallComponentWillUnmount(node, finishedWork, instance); + } + } + break; + } + } + } + + if (node.child !== null) { + node.child.return = node; + node = node.child; + continue; + } } else if (node.child !== null) { node.child.return = node; node = node.child; continue; } + if (node === finishedWork) { return; } @@ -2143,13 +2291,49 @@ function commitLayoutEffects_begin( root: FiberRoot, committedLanes: Lanes, ) { + // Suspense layout effects semantics don't change for legacy roots. + const isModernRoot = (subtreeRoot.mode & ConcurrentMode) !== NoMode; + while (nextEffect !== null) { const fiber = nextEffect; const firstChild = fiber.child; + + if (enableSuspenseLayoutEffectSemantics && isModernRoot) { + // Keep track of the current Offscreen stack's state. + if (fiber.tag === OffscreenComponent) { + const current = fiber.alternate; + const wasHidden = current !== null && current.memoizedState !== null; + const isHidden = fiber.memoizedState !== null; + + offscreenSubtreeWasHidden = wasHidden || offscreenSubtreeWasHidden; + offscreenSubtreeIsHidden = isHidden || offscreenSubtreeIsHidden; + + offscreenSubtreeWasHiddenStack.push(wasHidden); + offscreenSubtreeIsHiddenStack.push(isHidden); + } + } + if ((fiber.subtreeFlags & LayoutMask) !== NoFlags && firstChild !== null) { ensureCorrectReturnPointer(firstChild, fiber); nextEffect = firstChild; } else { + if (enableSuspenseLayoutEffectSemantics && isModernRoot) { + const visibilityChanged = + !offscreenSubtreeIsHidden && offscreenSubtreeWasHidden; + if ( + visibilityChanged && + (fiber.subtreeFlags & LayoutStatic) !== NoFlags && + firstChild !== null + ) { + // We've just shown or hidden a Offscreen tree that contains layout effects. + // We only enter this code path for subtrees that are updated, + // because newly mounted ones would pass the LayoutMask check above. + ensureCorrectReturnPointer(firstChild, fiber); + nextEffect = firstChild; + continue; + } + } + commitLayoutMountEffects_complete(subtreeRoot, root, committedLanes); } } @@ -2160,9 +2344,76 @@ function commitLayoutMountEffects_complete( root: FiberRoot, committedLanes: Lanes, ) { + // Suspense layout effects semantics don't change for legacy roots. + const isModernRoot = (subtreeRoot.mode & ConcurrentMode) !== NoMode; + while (nextEffect !== null) { const fiber = nextEffect; - if ((fiber.flags & LayoutMask) !== NoFlags) { + + if (enableSuspenseLayoutEffectSemantics && isModernRoot) { + if (fiber.tag === OffscreenComponent) { + offscreenSubtreeWasHiddenStack.pop(); + offscreenSubtreeIsHiddenStack.pop(); + offscreenSubtreeWasHidden = + offscreenSubtreeWasHiddenStack.length > 0 && + offscreenSubtreeWasHiddenStack[ + offscreenSubtreeWasHiddenStack.length - 1 + ]; + offscreenSubtreeIsHidden = + offscreenSubtreeIsHiddenStack.length > 0 && + offscreenSubtreeIsHiddenStack[ + offscreenSubtreeIsHiddenStack.length - 1 + ]; + } + } + + if ( + enableSuspenseLayoutEffectSemantics && + isModernRoot && + offscreenSubtreeWasHidden && + !offscreenSubtreeIsHidden + ) { + // Inside of an Offscreen subtree that changed visibility during this commit. + // If this subtree was hidden, layout effects will have already been destroyed (during mutation phase) + // but if it was just shown, we need to (re)create the effects now. + if ((fiber.flags & LayoutStatic) !== NoFlags) { + switch (fiber.tag) { + case FunctionComponent: + case ForwardRef: + case SimpleMemoComponent: { + if ( + enableProfilerTimer && + enableProfilerCommitHooks && + fiber.mode & ProfileMode + ) { + try { + startLayoutEffectTimer(); + safelyCallCommitHookLayoutEffectListMount(fiber, fiber.return); + } finally { + recordLayoutEffectDuration(fiber); + } + } else { + safelyCallCommitHookLayoutEffectListMount(fiber, fiber.return); + } + break; + } + case ClassComponent: { + const instance = fiber.stateNode; + safelyCallComponentDidMount(fiber, fiber.return, instance); + break; + } + } + } + + if ((fiber.flags & RefStatic) !== NoFlags) { + switch (fiber.tag) { + case ClassComponent: + case HostComponent: + safelyAttachRef(fiber, fiber.return); + break; + } + } + } else if ((fiber.flags & LayoutMask) !== NoFlags) { const current = fiber.alternate; if (__DEV__) { setCurrentDebugFiberInDEV(fiber); diff --git a/packages/react-reconciler/src/ReactFiberCommitWork.old.js b/packages/react-reconciler/src/ReactFiberCommitWork.old.js index 6ed8560bd7649..ca3dbe363f071 100644 --- a/packages/react-reconciler/src/ReactFiberCommitWork.old.js +++ b/packages/react-reconciler/src/ReactFiberCommitWork.old.js @@ -36,6 +36,7 @@ import { enableScopeAPI, enableStrictEffects, deletedTreeCleanUpLevel, + enableSuspenseLayoutEffectSemantics, } from 'shared/ReactFeatureFlags'; import { FunctionComponent, @@ -79,6 +80,8 @@ import { MutationMask, LayoutMask, PassiveMask, + LayoutStatic, + RefStatic, } from './ReactFiberFlags'; import getComponentNameFromFiber from 'react-reconciler/src/getComponentNameFromFiber'; import invariant from 'shared/invariant'; @@ -97,7 +100,7 @@ import { recordPassiveEffectDuration, startPassiveEffectTimer, } from './ReactProfilerTimer.old'; -import {ProfileMode} from './ReactTypeOfMode'; +import {ConcurrentMode, NoMode, ProfileMode} from './ReactTypeOfMode'; import {commitUpdateQueue} from './ReactUpdateQueue.old'; import { getPublicInstance, @@ -149,6 +152,14 @@ if (__DEV__) { didWarnAboutUndefinedSnapshotBeforeUpdate = new Set(); } +// Used during the commit phase to track the state of the Offscreen component stack. +// Allows us to avoid traversing the return path to find the nearest Offscreen ancestor. +// Only used when enableSuspenseLayoutEffectSemantics is enabled. +let offscreenSubtreeIsHidden: boolean = false; +const offscreenSubtreeIsHiddenStack: Array = []; +let offscreenSubtreeWasHidden: boolean = false; +const offscreenSubtreeWasHiddenStack: Array = []; + const PossiblyWeakSet = typeof WeakSet === 'function' ? WeakSet : Set; let nextEffect: Fiber | null = null; @@ -172,6 +183,32 @@ const callComponentWillUnmountWithTimer = function(current, instance) { } }; +// Capture errors so they don't interrupt mounting. +function safelyCallCommitHookLayoutEffectListMount( + current: Fiber, + nearestMountedAncestor: Fiber | null, +) { + if (__DEV__) { + invokeGuardedCallback( + null, + commitHookEffectListMount, + null, + HookLayout, + current, + ); + if (hasCaughtError()) { + const unmountError = clearCaughtError(); + captureCommitPhaseError(current, nearestMountedAncestor, unmountError); + } + } else { + try { + commitHookEffectListMount(HookLayout, current); + } catch (unmountError) { + captureCommitPhaseError(current, nearestMountedAncestor, unmountError); + } + } +} + // Capture errors so they don't interrupt unmounting. function safelyCallComponentWillUnmount( current: Fiber, @@ -199,6 +236,44 @@ function safelyCallComponentWillUnmount( } } +// Capture errors so they don't interrupt mounting. +function safelyCallComponentDidMount( + current: Fiber, + nearestMountedAncestor: Fiber | null, + instance: any, +) { + if (__DEV__) { + invokeGuardedCallback(null, instance.componentDidMount, instance); + if (hasCaughtError()) { + const unmountError = clearCaughtError(); + captureCommitPhaseError(current, nearestMountedAncestor, unmountError); + } + } else { + try { + instance.componentDidMount(); + } catch (unmountError) { + captureCommitPhaseError(current, nearestMountedAncestor, unmountError); + } + } +} + +// Capture errors so they don't interrupt mounting. +function safelyAttachRef(current: Fiber, nearestMountedAncestor: Fiber | null) { + if (__DEV__) { + invokeGuardedCallback(null, commitAttachRef, null, current); + if (hasCaughtError()) { + const unmountError = clearCaughtError(); + captureCommitPhaseError(current, nearestMountedAncestor, unmountError); + } + } else { + try { + commitAttachRef(current); + } catch (unmountError) { + captureCommitPhaseError(current, nearestMountedAncestor, unmountError); + } + } +} + function safelyDetachRef(current: Fiber, nearestMountedAncestor: Fiber | null) { const ref = current.ref; if (ref !== null) { @@ -942,6 +1017,12 @@ function commitLayoutEffectOnFiber( } function hideOrUnhideAllChildren(finishedWork, isHidden) { + // Suspense layout effects semantics don't change for legacy roots. + const isModernRoot = (finishedWork.mode & ConcurrentMode) !== NoMode; + + const current = finishedWork.alternate; + const wasHidden = current !== null && current.memoizedState !== null; + if (supportsMutation) { // We only have the top Fiber that was inserted but we need to recurse down its // children to find all the terminal nodes. @@ -954,6 +1035,25 @@ function hideOrUnhideAllChildren(finishedWork, isHidden) { } else { unhideInstance(node.stateNode, node.memoizedProps); } + + if (enableSuspenseLayoutEffectSemantics && isModernRoot) { + // This method is called during mutation; it should detach refs within a hidden subtree. + // Attaching refs should be done elsewhere though (during layout). + if ((node.flags & RefStatic) !== NoFlags) { + if (isHidden) { + safelyDetachRef(node, finishedWork); + } + } + + if ( + (node.subtreeFlags & (RefStatic | LayoutStatic)) !== NoFlags && + node.child !== null + ) { + node.child.return = node; + node = node.child; + continue; + } + } } else if (node.tag === HostText) { const instance = node.stateNode; if (isHidden) { @@ -967,13 +1067,61 @@ function hideOrUnhideAllChildren(finishedWork, isHidden) { (node.memoizedState: OffscreenState) !== null && node !== finishedWork ) { - // Found a nested Offscreen component that is hidden. Don't search - // any deeper. This tree should remain hidden. + // Found a nested Offscreen component that is hidden. + // Don't search any deeper. This tree should remain hidden. + } else if (enableSuspenseLayoutEffectSemantics && isModernRoot) { + // When a mounted Suspense subtree gets hidden again, destroy any nested layout effects. + if ((node.flags & (RefStatic | LayoutStatic)) !== NoFlags) { + switch (node.tag) { + case FunctionComponent: + case ForwardRef: + case MemoComponent: + case SimpleMemoComponent: { + // Note that refs are attached by the useImperativeHandle() hook, not by commitAttachRef() + if (isHidden && !wasHidden) { + if ( + enableProfilerTimer && + enableProfilerCommitHooks && + node.mode & ProfileMode + ) { + try { + startLayoutEffectTimer(); + commitHookEffectListUnmount(HookLayout, node, finishedWork); + } finally { + recordLayoutEffectDuration(node); + } + } else { + commitHookEffectListUnmount(HookLayout, node, finishedWork); + } + } + break; + } + case ClassComponent: { + if (isHidden && !wasHidden) { + if ((node.flags & RefStatic) !== NoFlags) { + safelyDetachRef(node, finishedWork); + } + const instance = node.stateNode; + if (typeof instance.componentWillUnmount === 'function') { + safelyCallComponentWillUnmount(node, finishedWork, instance); + } + } + break; + } + } + } + + if (node.child !== null) { + node.child.return = node; + node = node.child; + continue; + } } else if (node.child !== null) { node.child.return = node; node = node.child; continue; } + if (node === finishedWork) { return; } @@ -2143,13 +2291,49 @@ function commitLayoutEffects_begin( root: FiberRoot, committedLanes: Lanes, ) { + // Suspense layout effects semantics don't change for legacy roots. + const isModernRoot = (subtreeRoot.mode & ConcurrentMode) !== NoMode; + while (nextEffect !== null) { const fiber = nextEffect; const firstChild = fiber.child; + + if (enableSuspenseLayoutEffectSemantics && isModernRoot) { + // Keep track of the current Offscreen stack's state. + if (fiber.tag === OffscreenComponent) { + const current = fiber.alternate; + const wasHidden = current !== null && current.memoizedState !== null; + const isHidden = fiber.memoizedState !== null; + + offscreenSubtreeWasHidden = wasHidden || offscreenSubtreeWasHidden; + offscreenSubtreeIsHidden = isHidden || offscreenSubtreeIsHidden; + + offscreenSubtreeWasHiddenStack.push(wasHidden); + offscreenSubtreeIsHiddenStack.push(isHidden); + } + } + if ((fiber.subtreeFlags & LayoutMask) !== NoFlags && firstChild !== null) { ensureCorrectReturnPointer(firstChild, fiber); nextEffect = firstChild; } else { + if (enableSuspenseLayoutEffectSemantics && isModernRoot) { + const visibilityChanged = + !offscreenSubtreeIsHidden && offscreenSubtreeWasHidden; + if ( + visibilityChanged && + (fiber.subtreeFlags & LayoutStatic) !== NoFlags && + firstChild !== null + ) { + // We've just shown or hidden a Offscreen tree that contains layout effects. + // We only enter this code path for subtrees that are updated, + // because newly mounted ones would pass the LayoutMask check above. + ensureCorrectReturnPointer(firstChild, fiber); + nextEffect = firstChild; + continue; + } + } + commitLayoutMountEffects_complete(subtreeRoot, root, committedLanes); } } @@ -2160,9 +2344,76 @@ function commitLayoutMountEffects_complete( root: FiberRoot, committedLanes: Lanes, ) { + // Suspense layout effects semantics don't change for legacy roots. + const isModernRoot = (subtreeRoot.mode & ConcurrentMode) !== NoMode; + while (nextEffect !== null) { const fiber = nextEffect; - if ((fiber.flags & LayoutMask) !== NoFlags) { + + if (enableSuspenseLayoutEffectSemantics && isModernRoot) { + if (fiber.tag === OffscreenComponent) { + offscreenSubtreeWasHiddenStack.pop(); + offscreenSubtreeIsHiddenStack.pop(); + offscreenSubtreeWasHidden = + offscreenSubtreeWasHiddenStack.length > 0 && + offscreenSubtreeWasHiddenStack[ + offscreenSubtreeWasHiddenStack.length - 1 + ]; + offscreenSubtreeIsHidden = + offscreenSubtreeIsHiddenStack.length > 0 && + offscreenSubtreeIsHiddenStack[ + offscreenSubtreeIsHiddenStack.length - 1 + ]; + } + } + + if ( + enableSuspenseLayoutEffectSemantics && + isModernRoot && + offscreenSubtreeWasHidden && + !offscreenSubtreeIsHidden + ) { + // Inside of an Offscreen subtree that changed visibility during this commit. + // If this subtree was hidden, layout effects will have already been destroyed (during mutation phase) + // but if it was just shown, we need to (re)create the effects now. + if ((fiber.flags & LayoutStatic) !== NoFlags) { + switch (fiber.tag) { + case FunctionComponent: + case ForwardRef: + case SimpleMemoComponent: { + if ( + enableProfilerTimer && + enableProfilerCommitHooks && + fiber.mode & ProfileMode + ) { + try { + startLayoutEffectTimer(); + safelyCallCommitHookLayoutEffectListMount(fiber, fiber.return); + } finally { + recordLayoutEffectDuration(fiber); + } + } else { + safelyCallCommitHookLayoutEffectListMount(fiber, fiber.return); + } + break; + } + case ClassComponent: { + const instance = fiber.stateNode; + safelyCallComponentDidMount(fiber, fiber.return, instance); + break; + } + } + } + + if ((fiber.flags & RefStatic) !== NoFlags) { + switch (fiber.tag) { + case ClassComponent: + case HostComponent: + safelyAttachRef(fiber, fiber.return); + break; + } + } + } else if ((fiber.flags & LayoutMask) !== NoFlags) { const current = fiber.alternate; if (__DEV__) { setCurrentDebugFiberInDEV(fiber); diff --git a/packages/react-reconciler/src/ReactFiberCompleteWork.new.js b/packages/react-reconciler/src/ReactFiberCompleteWork.new.js index 0924738f1e956..d27498638ce67 100644 --- a/packages/react-reconciler/src/ReactFiberCompleteWork.new.js +++ b/packages/react-reconciler/src/ReactFiberCompleteWork.new.js @@ -58,6 +58,7 @@ import { import {NoMode, ConcurrentMode, ProfileMode} from './ReactTypeOfMode'; import { Ref, + RefStatic, Update, NoFlags, DidCapture, @@ -123,6 +124,7 @@ import { enableScopeAPI, enableProfilerTimer, enableCache, + enableSuspenseLayoutEffectSemantics, } from 'shared/ReactFeatureFlags'; import { markSpawnedWork, @@ -157,6 +159,9 @@ function markUpdate(workInProgress: Fiber) { function markRef(workInProgress: Fiber) { workInProgress.flags |= Ref; + if (enableSuspenseLayoutEffectSemantics) { + workInProgress.flags |= RefStatic; + } } function hadNoMutationsEffects(current: null | Fiber, completedWork: Fiber) { diff --git a/packages/react-reconciler/src/ReactFiberCompleteWork.old.js b/packages/react-reconciler/src/ReactFiberCompleteWork.old.js index 017db23b66e44..da962bfdefee1 100644 --- a/packages/react-reconciler/src/ReactFiberCompleteWork.old.js +++ b/packages/react-reconciler/src/ReactFiberCompleteWork.old.js @@ -58,6 +58,7 @@ import { import {NoMode, ConcurrentMode, ProfileMode} from './ReactTypeOfMode'; import { Ref, + RefStatic, Update, NoFlags, DidCapture, @@ -123,6 +124,7 @@ import { enableScopeAPI, enableProfilerTimer, enableCache, + enableSuspenseLayoutEffectSemantics, } from 'shared/ReactFeatureFlags'; import { markSpawnedWork, @@ -157,6 +159,9 @@ function markUpdate(workInProgress: Fiber) { function markRef(workInProgress: Fiber) { workInProgress.flags |= Ref; + if (enableSuspenseLayoutEffectSemantics) { + workInProgress.flags |= RefStatic; + } } function hadNoMutationsEffects(current: null | Fiber, completedWork: Fiber) { diff --git a/packages/react-reconciler/src/ReactFiberFlags.js b/packages/react-reconciler/src/ReactFiberFlags.js index 5906af7f492d5..b711d730c3b2f 100644 --- a/packages/react-reconciler/src/ReactFiberFlags.js +++ b/packages/react-reconciler/src/ReactFiberFlags.js @@ -12,49 +12,51 @@ import {enableCreateEventHandleAPI} from 'shared/ReactFeatureFlags'; export type Flags = number; // Don't change these two values. They're used by React Dev Tools. -export const NoFlags = /* */ 0b000000000000000000000; -export const PerformedWork = /* */ 0b000000000000000000001; +export const NoFlags = /* */ 0b00000000000000000000000; +export const PerformedWork = /* */ 0b00000000000000000000001; // You can change the rest (and add more). -export const Placement = /* */ 0b000000000000000000010; -export const Update = /* */ 0b000000000000000000100; +export const Placement = /* */ 0b00000000000000000000010; +export const Update = /* */ 0b00000000000000000000100; export const PlacementAndUpdate = /* */ Placement | Update; -export const Deletion = /* */ 0b000000000000000001000; -export const ChildDeletion = /* */ 0b000000000000000010000; -export const ContentReset = /* */ 0b000000000000000100000; -export const Callback = /* */ 0b000000000000001000000; -export const DidCapture = /* */ 0b000000000000010000000; -export const Ref = /* */ 0b000000000000100000000; -export const Snapshot = /* */ 0b000000000001000000000; -export const Passive = /* */ 0b000000000010000000000; -export const Hydrating = /* */ 0b000000000100000000000; +export const Deletion = /* */ 0b00000000000000000001000; +export const ChildDeletion = /* */ 0b00000000000000000010000; +export const ContentReset = /* */ 0b00000000000000000100000; +export const Callback = /* */ 0b00000000000000001000000; +export const DidCapture = /* */ 0b00000000000000010000000; +export const Ref = /* */ 0b00000000000000100000000; +export const Snapshot = /* */ 0b00000000000001000000000; +export const Passive = /* */ 0b00000000000010000000000; +export const Hydrating = /* */ 0b00000000000100000000000; export const HydratingAndUpdate = /* */ Hydrating | Update; -export const Visibility = /* */ 0b000000001000000000000; +export const Visibility = /* */ 0b00000000001000000000000; export const LifecycleEffectMask = Passive | Update | Callback | Ref | Snapshot; // Union of all commit flags (flags with the lifetime of a particular commit) -export const HostEffectMask = /* */ 0b000000001111111111111; +export const HostEffectMask = /* */ 0b00000000001111111111111; // These are not really side effects, but we still reuse this field. -export const Incomplete = /* */ 0b000000010000000000000; -export const ShouldCapture = /* */ 0b000000100000000000000; -export const ForceUpdateForLegacySuspense = /* */ 0b000001000000000000000; -export const DidPropagateContext = /* */ 0b000010000000000000000; -export const NeedsPropagation = /* */ 0b000100000000000000000; +export const Incomplete = /* */ 0b00000000010000000000000; +export const ShouldCapture = /* */ 0b00000000100000000000000; +export const ForceUpdateForLegacySuspense = /* */ 0b00000001000000000000000; +export const DidPropagateContext = /* */ 0b00000010000000000000000; +export const NeedsPropagation = /* */ 0b00000100000000000000000; // Static tags describe aspects of a fiber that are not specific to a render, // e.g. a fiber uses a passive effect (even if there are no updates on this particular render). // This enables us to defer more work in the unmount case, // since we can defer traversing the tree during layout to look for Passive effects, // and instead rely on the static flag as a signal that there may be cleanup work. -export const PassiveStatic = /* */ 0b001000000000000000000; +export const RefStatic = /* */ 0b00001000000000000000000; +export const LayoutStatic = /* */ 0b00010000000000000000000; +export const PassiveStatic = /* */ 0b00100000000000000000000; // These flags allow us to traverse to fibers that have effects on mount // without traversing the entire tree after every commit for // double invoking -export const MountLayoutDev = /* */ 0b010000000000000000000; -export const MountPassiveDev = /* */ 0b100000000000000000000; +export const MountLayoutDev = /* */ 0b01000000000000000000000; +export const MountPassiveDev = /* */ 0b10000000000000000000000; // Groups of flags that are used in the commit phase to skip over trees that // don't contain effects, by checking subtreeFlags. @@ -88,4 +90,4 @@ export const PassiveMask = Passive | ChildDeletion; // Union of tags that don't get reset on clones. // This allows certain concepts to persist without recalculting them, // e.g. whether a subtree contains passive effects or portals. -export const StaticMask = PassiveStatic; +export const StaticMask = LayoutStatic | PassiveStatic | RefStatic; diff --git a/packages/react-reconciler/src/ReactFiberHooks.new.js b/packages/react-reconciler/src/ReactFiberHooks.new.js index 0c7e2e4846d8f..7c825763f086e 100644 --- a/packages/react-reconciler/src/ReactFiberHooks.new.js +++ b/packages/react-reconciler/src/ReactFiberHooks.new.js @@ -19,6 +19,7 @@ import type {HookFlags} from './ReactHookEffectTags'; import type {FiberRoot} from './ReactInternalTypes'; import type {OpaqueIDType} from './ReactFiberHostConfig'; import type {Cache} from './ReactFiberCacheComponent.new'; +import type {Flags} from './ReactFiberFlags'; import ReactSharedInternals from 'shared/ReactSharedInternals'; import { @@ -29,6 +30,7 @@ import { enableUseRefAccessWarning, enableStrictEffects, enableLazyContextPropagation, + enableSuspenseLayoutEffectSemantics, } from 'shared/ReactFeatureFlags'; import { @@ -58,11 +60,13 @@ import { import {readContext, checkIfContextChanged} from './ReactFiberNewContext.new'; import {HostRoot, CacheComponent} from './ReactWorkTags'; import { - Update as UpdateEffect, - Passive as PassiveEffect, - PassiveStatic as PassiveStaticEffect, + LayoutStatic as LayoutStaticEffect, MountLayoutDev as MountLayoutDevEffect, MountPassiveDev as MountPassiveDevEffect, + Passive as PassiveEffect, + PassiveStatic as PassiveStaticEffect, + StaticMask as StaticMaskEffect, + Update as UpdateEffect, } from './ReactFiberFlags'; import { HasEffect as HookHasEffect, @@ -475,8 +479,8 @@ export function renderWithHooks( // example, in the SuspenseList implementation. if ( current !== null && - (current.flags & PassiveStaticEffect) !== - (workInProgress.flags & PassiveStaticEffect) + (current.flags & StaticMaskEffect) !== + (workInProgress.flags & StaticMaskEffect) ) { console.error( 'Internal React error: Expected static flag was missing. Please ' + @@ -1479,20 +1483,18 @@ function mountLayoutEffect( create: () => (() => void) | void, deps: Array | void | null, ): void { + let fiberFlags: Flags = UpdateEffect; + if (enableSuspenseLayoutEffectSemantics) { + fiberFlags |= LayoutStaticEffect; + } if ( __DEV__ && enableStrictEffects && (currentlyRenderingFiber.mode & StrictEffectsMode) !== NoMode ) { - return mountEffectImpl( - MountLayoutDevEffect | UpdateEffect, - HookLayout, - create, - deps, - ); - } else { - return mountEffectImpl(UpdateEffect, HookLayout, create, deps); + fiberFlags |= MountLayoutDevEffect; } + return mountEffectImpl(fiberFlags, HookLayout, create, deps); } function updateLayoutEffect( @@ -1551,25 +1553,23 @@ function mountImperativeHandle( const effectDeps = deps !== null && deps !== undefined ? deps.concat([ref]) : null; + let fiberFlags: Flags = UpdateEffect; + if (enableSuspenseLayoutEffectSemantics) { + fiberFlags |= LayoutStaticEffect; + } if ( __DEV__ && enableStrictEffects && (currentlyRenderingFiber.mode & StrictEffectsMode) !== NoMode ) { - return mountEffectImpl( - MountLayoutDevEffect | UpdateEffect, - HookLayout, - imperativeHandleEffect.bind(null, create, ref), - effectDeps, - ); - } else { - return mountEffectImpl( - UpdateEffect, - HookLayout, - imperativeHandleEffect.bind(null, create, ref), - effectDeps, - ); + fiberFlags |= MountLayoutDevEffect; } + return mountEffectImpl( + fiberFlags, + HookLayout, + imperativeHandleEffect.bind(null, create, ref), + effectDeps, + ); } function updateImperativeHandle( diff --git a/packages/react-reconciler/src/ReactFiberHooks.old.js b/packages/react-reconciler/src/ReactFiberHooks.old.js index 6d5d355f0d711..62399001d989e 100644 --- a/packages/react-reconciler/src/ReactFiberHooks.old.js +++ b/packages/react-reconciler/src/ReactFiberHooks.old.js @@ -19,6 +19,7 @@ import type {HookFlags} from './ReactHookEffectTags'; import type {FiberRoot} from './ReactInternalTypes'; import type {OpaqueIDType} from './ReactFiberHostConfig'; import type {Cache} from './ReactFiberCacheComponent.old'; +import type {Flags} from './ReactFiberFlags'; import ReactSharedInternals from 'shared/ReactSharedInternals'; import { @@ -29,6 +30,7 @@ import { enableUseRefAccessWarning, enableStrictEffects, enableLazyContextPropagation, + enableSuspenseLayoutEffectSemantics, } from 'shared/ReactFeatureFlags'; import { @@ -58,11 +60,13 @@ import { import {readContext, checkIfContextChanged} from './ReactFiberNewContext.old'; import {HostRoot, CacheComponent} from './ReactWorkTags'; import { - Update as UpdateEffect, - Passive as PassiveEffect, - PassiveStatic as PassiveStaticEffect, + LayoutStatic as LayoutStaticEffect, MountLayoutDev as MountLayoutDevEffect, MountPassiveDev as MountPassiveDevEffect, + Passive as PassiveEffect, + PassiveStatic as PassiveStaticEffect, + StaticMask as StaticMaskEffect, + Update as UpdateEffect, } from './ReactFiberFlags'; import { HasEffect as HookHasEffect, @@ -475,8 +479,8 @@ export function renderWithHooks( // example, in the SuspenseList implementation. if ( current !== null && - (current.flags & PassiveStaticEffect) !== - (workInProgress.flags & PassiveStaticEffect) + (current.flags & StaticMaskEffect) !== + (workInProgress.flags & StaticMaskEffect) ) { console.error( 'Internal React error: Expected static flag was missing. Please ' + @@ -1479,20 +1483,18 @@ function mountLayoutEffect( create: () => (() => void) | void, deps: Array | void | null, ): void { + let fiberFlags: Flags = UpdateEffect; + if (enableSuspenseLayoutEffectSemantics) { + fiberFlags |= LayoutStaticEffect; + } if ( __DEV__ && enableStrictEffects && (currentlyRenderingFiber.mode & StrictEffectsMode) !== NoMode ) { - return mountEffectImpl( - MountLayoutDevEffect | UpdateEffect, - HookLayout, - create, - deps, - ); - } else { - return mountEffectImpl(UpdateEffect, HookLayout, create, deps); + fiberFlags |= MountLayoutDevEffect; } + return mountEffectImpl(fiberFlags, HookLayout, create, deps); } function updateLayoutEffect( @@ -1551,25 +1553,23 @@ function mountImperativeHandle( const effectDeps = deps !== null && deps !== undefined ? deps.concat([ref]) : null; + let fiberFlags: Flags = UpdateEffect; + if (enableSuspenseLayoutEffectSemantics) { + fiberFlags |= LayoutStaticEffect; + } if ( __DEV__ && enableStrictEffects && (currentlyRenderingFiber.mode & StrictEffectsMode) !== NoMode ) { - return mountEffectImpl( - MountLayoutDevEffect | UpdateEffect, - HookLayout, - imperativeHandleEffect.bind(null, create, ref), - effectDeps, - ); - } else { - return mountEffectImpl( - UpdateEffect, - HookLayout, - imperativeHandleEffect.bind(null, create, ref), - effectDeps, - ); + fiberFlags |= MountLayoutDevEffect; } + return mountEffectImpl( + fiberFlags, + HookLayout, + imperativeHandleEffect.bind(null, create, ref), + effectDeps, + ); } function updateImperativeHandle( diff --git a/packages/react-reconciler/src/__tests__/ReactLazy-test.internal.js b/packages/react-reconciler/src/__tests__/ReactLazy-test.internal.js index b7f1570823c14..b9e6b0c268061 100644 --- a/packages/react-reconciler/src/__tests__/ReactLazy-test.internal.js +++ b/packages/react-reconciler/src/__tests__/ReactLazy-test.internal.js @@ -1271,6 +1271,9 @@ describe('ReactLazy', () => { // @gate enableLazyElements it('mount and reorder lazy types', async () => { class Child extends React.Component { + componentWillUnmount() { + Scheduler.unstable_yieldValue('Did unmount: ' + this.props.label); + } componentDidMount() { Scheduler.unstable_yieldValue('Did mount: ' + this.props.label); } @@ -1348,6 +1351,12 @@ describe('ReactLazy', () => { expect(Scheduler).toFlushAndYield(['Init B2', 'Loading...']); jest.runAllTimers(); + gate(flags => { + if (flags.enableSuspenseLayoutEffectSemantics) { + expect(Scheduler).toHaveYielded(['Did unmount: A', 'Did unmount: B']); + } + }); + // The suspense boundary should've triggered now. expect(root).toMatchRenderedOutput('Loading...'); await resolveB2({default: ChildB}); @@ -1356,12 +1365,23 @@ describe('ReactLazy', () => { expect(Scheduler).toFlushAndYield(['Init A2']); await LazyChildA2; - expect(Scheduler).toFlushAndYield([ - 'b', - 'a', - 'Did update: b', - 'Did update: a', - ]); + gate(flags => { + if (flags.enableSuspenseLayoutEffectSemantics) { + expect(Scheduler).toFlushAndYield([ + 'b', + 'a', + 'Did mount: b', + 'Did mount: a', + ]); + } else { + expect(Scheduler).toFlushAndYield([ + 'b', + 'a', + 'Did update: b', + 'Did update: a', + ]); + } + }); expect(root).toMatchRenderedOutput('ba'); });