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 cbb755540e28d..d5116d1579ecb 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 { @@ -57,11 +59,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, @@ -474,8 +478,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 ' + @@ -1478,20 +1482,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( @@ -1550,25 +1552,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 3c7e87d02dc6f..77d82bf13c86f 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 { @@ -57,11 +59,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, @@ -474,8 +478,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 ' + @@ -1478,20 +1482,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( @@ -1550,25 +1552,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'); }); diff --git a/packages/react-reconciler/src/__tests__/ReactSuspenseEffectsSemantics-test.js b/packages/react-reconciler/src/__tests__/ReactSuspenseEffectsSemantics-test.js new file mode 100644 index 0000000000000..1dbc4404040f9 --- /dev/null +++ b/packages/react-reconciler/src/__tests__/ReactSuspenseEffectsSemantics-test.js @@ -0,0 +1,3097 @@ +/** + * Copyright (c) Facebook, Inc. and its affiliates. + * + * This source code is licensed under the MIT license found in the + * LICENSE file in the root directory of this source tree. + * + * @flow + */ + +let React; +let ReactNoop; +let Scheduler; +let Suspense; +let getCacheForType; +let caches; +let seededCache; +let ErrorBoundary; + +describe('ReactSuspenseEffectsSemantics', () => { + beforeEach(() => { + jest.resetModules(); + + React = require('react'); + ReactNoop = require('react-noop-renderer'); + Scheduler = require('scheduler'); + Suspense = React.Suspense; + + getCacheForType = React.unstable_getCacheForType; + + caches = []; + seededCache = null; + + ErrorBoundary = class extends React.Component { + state = {error: null}; + componentDidCatch(error) { + this.setState({error}); + } + render() { + if (this.state.error) { + Scheduler.unstable_yieldValue('ErrorBoundary render: catch'); + return this.props.fallback; + } + Scheduler.unstable_yieldValue('ErrorBoundary render: try'); + return this.props.children; + } + }; + }); + + function createTextCache() { + if (seededCache !== null) { + // Trick to seed a cache before it exists. + // TODO: Need a built-in API to seed data before the initial render (i.e. + // not a refresh because nothing has mounted yet). + const cache = seededCache; + seededCache = null; + return cache; + } + + const data = new Map(); + const version = caches.length + 1; + const cache = { + version, + data, + resolve(text) { + const record = data.get(text); + if (record === undefined) { + const newRecord = { + status: 'resolved', + value: text, + }; + data.set(text, newRecord); + } else if (record.status === 'pending') { + const thenable = record.value; + record.status = 'resolved'; + record.value = text; + thenable.pings.forEach(t => t()); + } + }, + reject(text, error) { + const record = data.get(text); + if (record === undefined) { + const newRecord = { + status: 'rejected', + value: error, + }; + data.set(text, newRecord); + } else if (record.status === 'pending') { + const thenable = record.value; + record.status = 'rejected'; + record.value = error; + thenable.pings.forEach(t => t()); + } + }, + }; + caches.push(cache); + return cache; + } + + function readText(text) { + const textCache = getCacheForType(createTextCache); + const record = textCache.data.get(text); + if (record !== undefined) { + switch (record.status) { + case 'pending': + Scheduler.unstable_yieldValue(`Suspend:${text}`); + throw record.value; + case 'rejected': + Scheduler.unstable_yieldValue(`Error:${text}`); + throw record.value; + case 'resolved': + return textCache.version; + } + } else { + Scheduler.unstable_yieldValue(`Suspend:${text}`); + + const thenable = { + pings: [], + then(resolve) { + if (newRecord.status === 'pending') { + thenable.pings.push(resolve); + } else { + Promise.resolve().then(() => resolve(newRecord.value)); + } + }, + }; + + const newRecord = { + status: 'pending', + value: thenable, + }; + textCache.data.set(text, newRecord); + + throw thenable; + } + } + + function Text({children = null, text}) { + Scheduler.unstable_yieldValue(`Text:${text} render`); + React.useLayoutEffect(() => { + Scheduler.unstable_yieldValue(`Text:${text} create layout`); + return () => { + Scheduler.unstable_yieldValue(`Text:${text} destroy layout`); + }; + }, []); + React.useEffect(() => { + Scheduler.unstable_yieldValue(`Text:${text} create passive`); + return () => { + Scheduler.unstable_yieldValue(`Text:${text} destroy passive`); + }; + }, []); + return {children}; + } + + function AsyncText({children = null, text}) { + readText(text); + Scheduler.unstable_yieldValue(`AsyncText:${text} render`); + React.useLayoutEffect(() => { + Scheduler.unstable_yieldValue(`AsyncText:${text} create layout`); + return () => { + Scheduler.unstable_yieldValue(`AsyncText:${text} destroy layout`); + }; + }, []); + React.useEffect(() => { + Scheduler.unstable_yieldValue(`AsyncText:${text} create passive`); + return () => { + Scheduler.unstable_yieldValue(`AsyncText:${text} destroy passive`); + }; + }, []); + return {children}; + } + + function resolveMostRecentTextCache(text) { + if (caches.length === 0) { + throw Error('Cache does not exist.'); + } else { + // Resolve the most recently created cache. An older cache can by + // resolved with `caches[index].resolve(text)`. + caches[caches.length - 1].resolve(text); + } + } + + const resolveText = resolveMostRecentTextCache; + + function span(prop, children = []) { + return {type: 'span', children, prop, hidden: false}; + } + + function spanHidden(prop, children = []) { + return {type: 'span', children, prop, hidden: true}; + } + + function advanceTimers(ms) { + // Note: This advances Jest's virtual time but not React's. Use + // ReactNoop.expire for that. + if (typeof ms !== 'number') { + throw new Error('Must specify ms'); + } + jest.advanceTimersByTime(ms); + // Wait until the end of the current tick + // We cannot use a timer since we're faking them + return Promise.resolve().then(() => {}); + } + + describe('when a component suspends during initial mount', () => { + // @gate enableCache + it('should not change behavior in concurrent mode', async () => { + class ClassText extends React.Component { + componentDidMount() { + const {text} = this.props; + Scheduler.unstable_yieldValue(`ClassText:${text} componentDidMount`); + } + componentDidUpdate() { + const {text} = this.props; + Scheduler.unstable_yieldValue(`ClassText:${text} componentDidUpdate`); + } + componentWillUnmount() { + const {text} = this.props; + Scheduler.unstable_yieldValue( + `ClassText:${text} componentWillUnmount`, + ); + } + render() { + const {children, text} = this.props; + Scheduler.unstable_yieldValue(`ClassText:${text} render`); + return {children}; + } + } + + function App({children = null}) { + Scheduler.unstable_yieldValue('App render'); + React.useLayoutEffect(() => { + Scheduler.unstable_yieldValue('App create layout'); + return () => { + Scheduler.unstable_yieldValue('App destroy layout'); + }; + }, []); + React.useEffect(() => { + Scheduler.unstable_yieldValue('App create passive'); + return () => { + Scheduler.unstable_yieldValue('App destroy passive'); + }; + }, []); + return ( + <> + }> + + {children} + + + + + ); + } + + // Mount and suspend. + await ReactNoop.act(async () => { + ReactNoop.render( + + + , + ); + }); + expect(Scheduler).toHaveYielded([ + 'App render', + 'Text:Inside:Before render', + 'Suspend:Async', + 'ClassText:Inside:After render', + 'Text:Fallback render', + 'Text:Outside render', + 'Text:Fallback create layout', + 'Text:Outside create layout', + 'App create layout', + 'Text:Fallback create passive', + 'Text:Outside create passive', + 'App create passive', + ]); + expect(ReactNoop.getChildren()).toEqual([ + span('Fallback'), + span('Outside'), + ]); + + // Resolving the suspended resource should + await ReactNoop.act(async () => { + await resolveText('Async'); + }); + expect(Scheduler).toHaveYielded([ + 'Text:Inside:Before render', + 'AsyncText:Async render', + 'ClassText:Inside:After render', + 'Text:Fallback destroy layout', + 'Text:Inside:Before create layout', + 'AsyncText:Async create layout', + 'ClassText:Inside:After componentDidMount', + 'Text:Fallback destroy passive', + 'Text:Inside:Before create passive', + 'AsyncText:Async create passive', + ]); + expect(ReactNoop.getChildren()).toEqual([ + span('Inside:Before'), + span('Async'), + span('Inside:After'), + span('Outside'), + ]); + + await ReactNoop.act(async () => { + ReactNoop.render(null); + }); + expect(Scheduler).toHaveYielded([ + 'App destroy layout', + 'Text:Inside:Before destroy layout', + 'AsyncText:Async destroy layout', + 'ClassText:Inside:After componentWillUnmount', + 'Text:Outside destroy layout', + 'App destroy passive', + 'Text:Inside:Before destroy passive', + 'AsyncText:Async destroy passive', + 'Text:Outside destroy passive', + ]); + expect(ReactNoop.getChildren()).toEqual([]); + }); + + // @gate enableCache + it('should not change behavior in sync', async () => { + class ClassText extends React.Component { + componentDidMount() { + const {text} = this.props; + Scheduler.unstable_yieldValue(`ClassText:${text} componentDidMount`); + } + componentDidUpdate() { + const {text} = this.props; + Scheduler.unstable_yieldValue(`ClassText:${text} componentDidUpdate`); + } + componentWillUnmount() { + const {text} = this.props; + Scheduler.unstable_yieldValue( + `ClassText:${text} componentWillUnmount`, + ); + } + render() { + const {children, text} = this.props; + Scheduler.unstable_yieldValue(`ClassText:${text} render`); + return {children}; + } + } + + function App({children = null}) { + Scheduler.unstable_yieldValue('App render'); + React.useLayoutEffect(() => { + Scheduler.unstable_yieldValue('App create layout'); + return () => { + Scheduler.unstable_yieldValue('App destroy layout'); + }; + }, []); + React.useEffect(() => { + Scheduler.unstable_yieldValue('App create passive'); + return () => { + Scheduler.unstable_yieldValue('App destroy passive'); + }; + }, []); + return ( + <> + }> + + {children} + + + + + ); + } + + // Mount and suspend. + ReactNoop.act(() => { + ReactNoop.renderLegacySyncRoot( + + + , + ); + }); + expect(Scheduler).toHaveYielded([ + 'App render', + 'Text:Inside:Before render', + 'Suspend:Async', + 'ClassText:Inside:After render', + 'Text:Fallback render', + 'Text:Outside render', + 'Text:Inside:Before create layout', + 'ClassText:Inside:After componentDidMount', + 'Text:Fallback create layout', + 'Text:Outside create layout', + 'App create layout', + 'Text:Inside:Before create passive', + 'Text:Fallback create passive', + 'Text:Outside create passive', + 'App create passive', + ]); + expect(ReactNoop.getChildren()).toEqual([ + spanHidden('Inside:Before'), + spanHidden('Inside:After'), + span('Fallback'), + span('Outside'), + ]); + + // Resolving the suspended resource should + await ReactNoop.act(async () => { + await resolveText('Async'); + }); + expect(Scheduler).toHaveYielded([ + 'AsyncText:Async render', + 'Text:Fallback destroy layout', + 'AsyncText:Async create layout', + 'Text:Fallback destroy passive', + 'AsyncText:Async create passive', + ]); + expect(ReactNoop.getChildren()).toEqual([ + span('Inside:Before'), + span('Async'), + span('Inside:After'), + span('Outside'), + ]); + + await ReactNoop.act(async () => { + ReactNoop.renderLegacySyncRoot(null); + }); + expect(Scheduler).toHaveYielded([ + 'App destroy layout', + 'Text:Inside:Before destroy layout', + 'AsyncText:Async destroy layout', + 'ClassText:Inside:After componentWillUnmount', + 'Text:Outside destroy layout', + 'App destroy passive', + 'Text:Inside:Before destroy passive', + 'AsyncText:Async destroy passive', + 'Text:Outside destroy passive', + ]); + expect(ReactNoop.getChildren()).toEqual([]); + }); + }); + + describe('layout effects within a tree that re-suspends in an update', () => { + // @gate enableCache + it('should not be destroyed or recreated in legacy roots', async () => { + function App({children = null}) { + Scheduler.unstable_yieldValue('App render'); + React.useLayoutEffect(() => { + Scheduler.unstable_yieldValue('App create layout'); + return () => { + Scheduler.unstable_yieldValue('App destroy layout'); + }; + }, []); + React.useEffect(() => { + Scheduler.unstable_yieldValue('App create passive'); + return () => { + Scheduler.unstable_yieldValue('App destroy passive'); + }; + }, []); + return ( + <> + }> + + {children} + + + + + ); + } + + // Mount + ReactNoop.act(() => { + ReactNoop.renderLegacySyncRoot(); + }); + expect(Scheduler).toHaveYielded([ + 'App render', + 'Text:Inside:Before render', + 'Text:Inside:After render', + 'Text:Outside render', + 'Text:Inside:Before create layout', + 'Text:Inside:After create layout', + 'Text:Outside create layout', + 'App create layout', + 'Text:Inside:Before create passive', + 'Text:Inside:After create passive', + 'Text:Outside create passive', + 'App create passive', + ]); + expect(ReactNoop.getChildren()).toEqual([ + span('Inside:Before'), + span('Inside:After'), + span('Outside'), + ]); + + // Schedule an update that causes React to suspend. + ReactNoop.act(() => { + ReactNoop.renderLegacySyncRoot( + + + , + ); + }); + expect(Scheduler).toHaveYielded([ + 'App render', + 'Text:Inside:Before render', + 'Suspend:Async', + 'Text:Inside:After render', + 'Text:Fallback render', + 'Text:Outside render', + 'Text:Fallback create layout', + 'Text:Fallback create passive', + ]); + expect(ReactNoop.getChildren()).toEqual([ + spanHidden('Inside:Before'), + spanHidden('Inside:After'), + span('Fallback'), + span('Outside'), + ]); + + await advanceTimers(1000); + + // Noop since sync root has already committed + expect(Scheduler).toHaveYielded([]); + expect(ReactNoop.getChildren()).toEqual([ + spanHidden('Inside:Before'), + spanHidden('Inside:After'), + span('Fallback'), + span('Outside'), + ]); + + // Resolving the suspended resource should re-create inner layout effects. + await ReactNoop.act(async () => { + await resolveText('Async'); + }); + expect(Scheduler).toHaveYielded([ + 'AsyncText:Async render', + 'Text:Fallback destroy layout', + 'AsyncText:Async create layout', + 'Text:Fallback destroy passive', + 'AsyncText:Async create passive', + ]); + expect(ReactNoop.getChildren()).toEqual([ + span('Inside:Before'), + span('Async'), + span('Inside:After'), + span('Outside'), + ]); + + await ReactNoop.act(async () => { + ReactNoop.renderLegacySyncRoot(null); + }); + expect(Scheduler).toHaveYielded([ + 'App destroy layout', + 'Text:Inside:Before destroy layout', + 'AsyncText:Async destroy layout', + 'Text:Inside:After destroy layout', + 'Text:Outside destroy layout', + 'App destroy passive', + 'Text:Inside:Before destroy passive', + 'AsyncText:Async destroy passive', + 'Text:Inside:After destroy passive', + 'Text:Outside destroy passive', + ]); + }); + + // @gate enableSuspenseLayoutEffectSemantics + // @gate enableCache + it('should be destroyed and recreated for function components', async () => { + function App({children = null}) { + Scheduler.unstable_yieldValue('App render'); + React.useLayoutEffect(() => { + Scheduler.unstable_yieldValue('App create layout'); + return () => { + Scheduler.unstable_yieldValue('App destroy layout'); + }; + }, []); + React.useEffect(() => { + Scheduler.unstable_yieldValue('App create passive'); + return () => { + Scheduler.unstable_yieldValue('App destroy passive'); + }; + }, []); + return ( + <> + }> + + {children} + + + + + ); + } + + await ReactNoop.act(async () => { + ReactNoop.render(); + }); + expect(Scheduler).toHaveYielded([ + 'App render', + 'Text:Inside:Before render', + 'Text:Inside:After render', + 'Text:Outside render', + 'Text:Inside:Before create layout', + 'Text:Inside:After create layout', + 'Text:Outside create layout', + 'App create layout', + 'Text:Inside:Before create passive', + 'Text:Inside:After create passive', + 'Text:Outside create passive', + 'App create passive', + ]); + expect(ReactNoop.getChildren()).toEqual([ + span('Inside:Before'), + span('Inside:After'), + span('Outside'), + ]); + + // Schedule an update that causes React to suspend. + ReactNoop.act(() => { + ReactNoop.render( + + + , + ); + }); + expect(Scheduler).toHaveYielded([ + 'App render', + 'Text:Inside:Before render', + 'Suspend:Async', + 'Text:Inside:After render', + 'Text:Fallback render', + 'Text:Outside render', + ]); + expect(ReactNoop.getChildren()).toEqual([ + span('Inside:Before'), + span('Inside:After'), + span('Outside'), + ]); + + await advanceTimers(1000); + + // Timing out should commit the fallback and destroy inner layout effects. + expect(Scheduler).toHaveYielded([ + 'Text:Inside:Before destroy layout', + 'Text:Inside:After destroy layout', + 'Text:Fallback create layout', + ]); + expect(Scheduler).toFlushAndYield(['Text:Fallback create passive']); + expect(ReactNoop.getChildren()).toEqual([ + spanHidden('Inside:Before'), + spanHidden('Inside:After'), + span('Fallback'), + span('Outside'), + ]); + + // Resolving the suspended resource should re-create inner layout effects. + await ReactNoop.act(async () => { + await resolveText('Async'); + }); + expect(Scheduler).toHaveYielded([ + 'Text:Inside:Before render', + 'AsyncText:Async render', + 'Text:Inside:After render', + 'Text:Fallback destroy layout', + 'Text:Inside:Before create layout', + 'AsyncText:Async create layout', + 'Text:Inside:After create layout', + 'Text:Fallback destroy passive', + 'AsyncText:Async create passive', + ]); + expect(ReactNoop.getChildren()).toEqual([ + span('Inside:Before'), + span('Async'), + span('Inside:After'), + span('Outside'), + ]); + + await ReactNoop.act(async () => { + ReactNoop.render(null); + }); + expect(Scheduler).toHaveYielded([ + 'App destroy layout', + 'Text:Inside:Before destroy layout', + 'AsyncText:Async destroy layout', + 'Text:Inside:After destroy layout', + 'Text:Outside destroy layout', + 'App destroy passive', + 'Text:Inside:Before destroy passive', + 'AsyncText:Async destroy passive', + 'Text:Inside:After destroy passive', + 'Text:Outside destroy passive', + ]); + }); + + // @gate enableSuspenseLayoutEffectSemantics + // @gate enableCache + it('should be destroyed and recreated for class components', async () => { + class ClassText extends React.Component { + componentDidMount() { + const {text} = this.props; + Scheduler.unstable_yieldValue(`ClassText:${text} componentDidMount`); + } + componentDidUpdate() { + const {text} = this.props; + Scheduler.unstable_yieldValue(`ClassText:${text} componentDidUpdate`); + } + componentWillUnmount() { + const {text} = this.props; + Scheduler.unstable_yieldValue( + `ClassText:${text} componentWillUnmount`, + ); + } + render() { + const {children, text} = this.props; + Scheduler.unstable_yieldValue(`ClassText:${text} render`); + return {children}; + } + } + + function App({children = null}) { + Scheduler.unstable_yieldValue('App render'); + React.useLayoutEffect(() => { + Scheduler.unstable_yieldValue('App create layout'); + return () => { + Scheduler.unstable_yieldValue('App destroy layout'); + }; + }, []); + React.useEffect(() => { + Scheduler.unstable_yieldValue('App create passive'); + return () => { + Scheduler.unstable_yieldValue('App destroy passive'); + }; + }, []); + return ( + <> + }> + + {children} + + + + + ); + } + + // Mount + await ReactNoop.act(async () => { + ReactNoop.render(); + }); + expect(Scheduler).toHaveYielded([ + 'App render', + 'ClassText:Inside:Before render', + 'ClassText:Inside:After render', + 'ClassText:Outside render', + 'ClassText:Inside:Before componentDidMount', + 'ClassText:Inside:After componentDidMount', + 'ClassText:Outside componentDidMount', + 'App create layout', + 'App create passive', + ]); + expect(ReactNoop.getChildren()).toEqual([ + span('Inside:Before'), + span('Inside:After'), + span('Outside'), + ]); + + // Schedule an update that causes React to suspend. + ReactNoop.act(() => { + ReactNoop.render( + + + , + ); + }); + expect(Scheduler).toHaveYielded([ + 'App render', + 'ClassText:Inside:Before render', + 'Suspend:Async', + 'ClassText:Inside:After render', + 'ClassText:Fallback render', + 'ClassText:Outside render', + ]); + expect(ReactNoop.getChildren()).toEqual([ + span('Inside:Before'), + span('Inside:After'), + span('Outside'), + ]); + + await advanceTimers(1000); + + // Timing out should commit the fallback and destroy inner layout effects. + expect(Scheduler).toHaveYielded([ + 'ClassText:Inside:Before componentWillUnmount', + 'ClassText:Inside:After componentWillUnmount', + 'ClassText:Fallback componentDidMount', + 'ClassText:Outside componentDidUpdate', + ]); + expect(ReactNoop.getChildren()).toEqual([ + spanHidden('Inside:Before'), + spanHidden('Inside:After'), + span('Fallback'), + span('Outside'), + ]); + + // Resolving the suspended resource should re-create inner layout effects. + await ReactNoop.act(async () => { + await resolveText('Async'); + }); + expect(Scheduler).toHaveYielded([ + 'ClassText:Inside:Before render', + 'AsyncText:Async render', + 'ClassText:Inside:After render', + 'ClassText:Fallback componentWillUnmount', + 'ClassText:Inside:Before componentDidMount', + 'AsyncText:Async create layout', + 'ClassText:Inside:After componentDidMount', + 'AsyncText:Async create passive', + ]); + expect(ReactNoop.getChildren()).toEqual([ + span('Inside:Before'), + span('Async'), + span('Inside:After'), + span('Outside'), + ]); + + await ReactNoop.act(async () => { + ReactNoop.render(null); + }); + expect(Scheduler).toHaveYielded([ + 'App destroy layout', + 'ClassText:Inside:Before componentWillUnmount', + 'AsyncText:Async destroy layout', + 'ClassText:Inside:After componentWillUnmount', + 'ClassText:Outside componentWillUnmount', + 'App destroy passive', + 'AsyncText:Async destroy passive', + ]); + }); + + // @gate enableSuspenseLayoutEffectSemantics + // @gate enableCache + it('should be destroyed and recreated when nested below host components', async () => { + function App({children = null}) { + Scheduler.unstable_yieldValue('App render'); + React.useLayoutEffect(() => { + Scheduler.unstable_yieldValue('App create layout'); + return () => { + Scheduler.unstable_yieldValue('App destroy layout'); + }; + }, []); + React.useEffect(() => { + Scheduler.unstable_yieldValue('App create passive'); + return () => { + Scheduler.unstable_yieldValue('App destroy passive'); + }; + }, []); + return ( + }> + {children} + + + + + ); + } + + // Mount + await ReactNoop.act(async () => { + ReactNoop.render(); + }); + expect(Scheduler).toHaveYielded([ + 'App render', + 'Text:Outer render', + 'Text:Inner render', + 'Text:Inner create layout', + 'Text:Outer create layout', + 'App create layout', + 'Text:Inner create passive', + 'Text:Outer create passive', + 'App create passive', + ]); + expect(ReactNoop.getChildren()).toEqual([span('Outer', [span('Inner')])]); + + // Schedule an update that causes React to suspend. + ReactNoop.act(() => { + ReactNoop.render( + + + , + ); + }); + expect(Scheduler).toHaveYielded([ + 'App render', + 'Suspend:Async', + 'Text:Outer render', + 'Text:Inner render', + 'Text:Fallback render', + ]); + expect(ReactNoop.getChildren()).toEqual([span('Outer', [span('Inner')])]); + + await advanceTimers(1000); + + // Timing out should commit the fallback and destroy inner layout effects. + expect(Scheduler).toHaveYielded([ + 'Text:Outer destroy layout', + 'Text:Inner destroy layout', + 'Text:Fallback create layout', + ]); + expect(Scheduler).toFlushAndYield(['Text:Fallback create passive']); + expect(ReactNoop.getChildren()).toEqual([ + spanHidden('Outer', [spanHidden('Inner')]), + span('Fallback'), + ]); + + // Resolving the suspended resource should re-create inner layout effects. + await ReactNoop.act(async () => { + await resolveText('Async'); + }); + expect(Scheduler).toHaveYielded([ + 'AsyncText:Async render', + 'Text:Outer render', + 'Text:Inner render', + 'Text:Fallback destroy layout', + 'AsyncText:Async create layout', + 'Text:Inner create layout', + 'Text:Outer create layout', + 'Text:Fallback destroy passive', + 'AsyncText:Async create passive', + ]); + expect(ReactNoop.getChildren()).toEqual([ + span('Async'), + span('Outer', [span('Inner')]), + ]); + + await ReactNoop.act(async () => { + ReactNoop.render(null); + }); + expect(Scheduler).toHaveYielded([ + 'App destroy layout', + 'AsyncText:Async destroy layout', + 'Text:Outer destroy layout', + 'Text:Inner destroy layout', + 'App destroy passive', + 'AsyncText:Async destroy passive', + 'Text:Outer destroy passive', + 'Text:Inner destroy passive', + ]); + }); + + // @gate enableSuspenseLayoutEffectSemantics + // @gate enableCache + it('should be destroyed and recreated even if there is a bailout because of memoization', async () => { + const MemoizedText = React.memo(Text, () => true); + + function App({children = null}) { + Scheduler.unstable_yieldValue('App render'); + React.useLayoutEffect(() => { + Scheduler.unstable_yieldValue('App create layout'); + return () => { + Scheduler.unstable_yieldValue('App destroy layout'); + }; + }, []); + React.useEffect(() => { + Scheduler.unstable_yieldValue('App create passive'); + return () => { + Scheduler.unstable_yieldValue('App destroy passive'); + }; + }, []); + return ( + }> + {children} + + + + + ); + } + + // Mount + await ReactNoop.act(async () => { + ReactNoop.render(); + }); + expect(Scheduler).toHaveYielded([ + 'App render', + 'Text:Outer render', + 'Text:MemoizedInner render', + 'Text:MemoizedInner create layout', + 'Text:Outer create layout', + 'App create layout', + 'Text:MemoizedInner create passive', + 'Text:Outer create passive', + 'App create passive', + ]); + expect(ReactNoop.getChildren()).toEqual([ + span('Outer', [span('MemoizedInner')]), + ]); + + // Schedule an update that causes React to suspend. + ReactNoop.act(() => { + ReactNoop.render( + + + , + ); + }); + expect(Scheduler).toHaveYielded([ + 'App render', + 'Suspend:Async', + 'Text:Outer render', + // Text:MemoizedInner is memoized + 'Text:Fallback render', + ]); + expect(ReactNoop.getChildren()).toEqual([ + span('Outer', [span('MemoizedInner')]), + ]); + + await advanceTimers(1000); + + // Timing out should commit the fallback and destroy inner layout effects. + // Even though the innermost layout effects are beneat a hidden HostComponent. + expect(Scheduler).toHaveYielded([ + 'Text:Outer destroy layout', + 'Text:MemoizedInner destroy layout', + 'Text:Fallback create layout', + ]); + expect(Scheduler).toFlushAndYield(['Text:Fallback create passive']); + expect(ReactNoop.getChildren()).toEqual([ + spanHidden('Outer', [spanHidden('MemoizedInner')]), + span('Fallback'), + ]); + + // Resolving the suspended resource should re-create inner layout effects. + await ReactNoop.act(async () => { + await resolveText('Async'); + }); + expect(Scheduler).toHaveYielded([ + 'AsyncText:Async render', + 'Text:Outer render', + 'Text:Fallback destroy layout', + 'AsyncText:Async create layout', + 'Text:MemoizedInner create layout', + 'Text:Outer create layout', + 'Text:Fallback destroy passive', + 'AsyncText:Async create passive', + ]); + expect(ReactNoop.getChildren()).toEqual([ + span('Async'), + span('Outer', [span('MemoizedInner')]), + ]); + + await ReactNoop.act(async () => { + ReactNoop.render(null); + }); + expect(Scheduler).toHaveYielded([ + 'App destroy layout', + 'AsyncText:Async destroy layout', + 'Text:Outer destroy layout', + 'Text:MemoizedInner destroy layout', + 'App destroy passive', + 'AsyncText:Async destroy passive', + 'Text:Outer destroy passive', + 'Text:MemoizedInner destroy passive', + ]); + }); + + // @gate enableSuspenseLayoutEffectSemantics + // @gate enableCache + it('should respect nested suspense boundaries', async () => { + function App({innerChildren = null, outerChildren = null}) { + return ( + }> + + {outerChildren} + }> + + {innerChildren} + + + ); + } + + // Mount + await ReactNoop.act(async () => { + ReactNoop.render(); + }); + expect(Scheduler).toHaveYielded([ + 'Text:Outer render', + 'Text:Inner render', + 'Text:Outer create layout', + 'Text:Inner create layout', + 'Text:Outer create passive', + 'Text:Inner create passive', + ]); + expect(ReactNoop.getChildren()).toEqual([span('Outer'), span('Inner')]); + + // Suspend the inner Suspense subtree (only inner effects should be destroyed) + ReactNoop.act(() => { + ReactNoop.render( + } />, + ); + }); + await advanceTimers(1000); + expect(Scheduler).toHaveYielded([ + 'Text:Outer render', + 'Text:Inner render', + 'Suspend:InnerAsync_1', + 'Text:InnerFallback render', + 'Text:Inner destroy layout', + 'Text:InnerFallback create layout', + ]); + expect(Scheduler).toFlushAndYield(['Text:InnerFallback create passive']); + expect(ReactNoop.getChildren()).toEqual([ + span('Outer'), + spanHidden('Inner'), + span('InnerFallback'), + ]); + + // Suspend the outer Suspense subtree (outer effects and inner fallback effects should be destroyed) + // (This check also ensures we don't destroy effects for mounted inner fallback.) + ReactNoop.act(() => { + ReactNoop.render( + } + innerChildren={} + />, + ); + }); + await advanceTimers(1000); + expect(Scheduler).toHaveYielded([ + 'Text:Outer render', + 'Suspend:OuterAsync_1', + 'Text:Inner render', + 'Suspend:InnerAsync_1', + 'Text:InnerFallback render', + 'Text:OuterFallback render', + 'Text:Outer destroy layout', + 'Text:InnerFallback destroy layout', + 'Text:OuterFallback create layout', + ]); + expect(Scheduler).toFlushAndYield(['Text:OuterFallback create passive']); + expect(ReactNoop.getChildren()).toEqual([ + spanHidden('Outer'), + spanHidden('Inner'), + spanHidden('InnerFallback'), + span('OuterFallback'), + ]); + + // Show the inner Susepnse subtree (no effects should be recreated) + await ReactNoop.act(async () => { + await resolveText('InnerAsync_1'); + }); + expect(Scheduler).toHaveYielded([ + 'Text:Outer render', + 'Suspend:OuterAsync_1', + 'Text:Inner render', + 'AsyncText:InnerAsync_1 render', + ]); + expect(ReactNoop.getChildren()).toEqual([ + spanHidden('Outer'), + spanHidden('Inner'), + spanHidden('InnerFallback'), + span('OuterFallback'), + ]); + + // Suspend the inner Suspense subtree (no effects should be destroyed) + ReactNoop.act(() => { + ReactNoop.render( + } + innerChildren={} + />, + ); + }); + await advanceTimers(1000); + expect(Scheduler).toHaveYielded([ + 'Text:Outer render', + 'Suspend:OuterAsync_1', + 'Text:Inner render', + 'Suspend:InnerAsync_2', + 'Text:InnerFallback render', + 'Text:OuterFallback render', + ]); + expect(ReactNoop.getChildren()).toEqual([ + spanHidden('Outer'), + spanHidden('Inner'), + spanHidden('InnerFallback'), + span('OuterFallback'), + ]); + + // Show the outer Susepnse subtree (only outer effects should be recreated) + await ReactNoop.act(async () => { + await resolveText('OuterAsync_1'); + }); + expect(Scheduler).toHaveYielded([ + 'Text:Outer render', + 'AsyncText:OuterAsync_1 render', + 'Text:Inner render', + 'Suspend:InnerAsync_2', + 'Text:InnerFallback render', + 'Text:OuterFallback destroy layout', + 'Text:Outer create layout', + 'AsyncText:OuterAsync_1 create layout', + 'Text:InnerFallback create layout', + 'Text:OuterFallback destroy passive', + 'AsyncText:OuterAsync_1 create passive', + ]); + expect(ReactNoop.getChildren()).toEqual([ + span('Outer'), + span('OuterAsync_1'), + spanHidden('Inner'), + span('InnerFallback'), + ]); + + // Show the inner Susepnse subtree (only inner effects should be recreated) + await ReactNoop.act(async () => { + await resolveText('InnerAsync_2'); + }); + expect(Scheduler).toHaveYielded([ + 'Text:Inner render', + 'AsyncText:InnerAsync_2 render', + 'Text:InnerFallback destroy layout', + 'Text:Inner create layout', + 'AsyncText:InnerAsync_2 create layout', + 'Text:InnerFallback destroy passive', + 'AsyncText:InnerAsync_2 create passive', + ]); + expect(ReactNoop.getChildren()).toEqual([ + span('Outer'), + span('OuterAsync_1'), + span('Inner'), + span('InnerAsync_2'), + ]); + + // Suspend the outer Suspense subtree (all effects should be destroyed) + ReactNoop.act(() => { + ReactNoop.render( + } + innerChildren={} + />, + ); + }); + await advanceTimers(1000); + expect(Scheduler).toHaveYielded([ + 'Text:Outer render', + 'Suspend:OuterAsync_2', + 'Text:Inner render', + 'AsyncText:InnerAsync_2 render', + 'Text:OuterFallback render', + 'Text:Outer destroy layout', + 'AsyncText:OuterAsync_1 destroy layout', + 'Text:Inner destroy layout', + 'AsyncText:InnerAsync_2 destroy layout', + 'Text:OuterFallback create layout', + ]); + expect(ReactNoop.getChildren()).toEqual([ + spanHidden('Outer'), + spanHidden('OuterAsync_1'), + spanHidden('Inner'), + spanHidden('InnerAsync_2'), + span('OuterFallback'), + ]); + + // Show the outer Suspense subtree (all effects should be recreated) + await ReactNoop.act(async () => { + await resolveText('OuterAsync_2'); + }); + expect(Scheduler).toHaveYielded([ + 'Text:OuterFallback create passive', + 'Text:Outer render', + 'AsyncText:OuterAsync_2 render', + 'Text:Inner render', + 'AsyncText:InnerAsync_2 render', + 'Text:OuterFallback destroy layout', + 'Text:Outer create layout', + 'AsyncText:OuterAsync_2 create layout', + 'Text:Inner create layout', + 'AsyncText:InnerAsync_2 create layout', + 'Text:OuterFallback destroy passive', + ]); + expect(ReactNoop.getChildren()).toEqual([ + span('Outer'), + span('OuterAsync_2'), + span('Inner'), + span('InnerAsync_2'), + ]); + }); + + // @gate enableSuspenseLayoutEffectSemantics + // @gate enableCache + it('should be cleaned up inside of a fallback that suspends', async () => { + function App({fallbackChildren = null, outerChildren = null}) { + return ( + <> + + }> + + {fallbackChildren} + + + + }> + + {outerChildren} + + + + ); + } + + // Mount + await ReactNoop.act(async () => { + ReactNoop.render(); + }); + expect(Scheduler).toHaveYielded([ + 'Text:Inside render', + 'Text:Outside render', + 'Text:Inside create layout', + 'Text:Outside create layout', + 'Text:Inside create passive', + 'Text:Outside create passive', + ]); + expect(ReactNoop.getChildren()).toEqual([ + span('Inside'), + span('Outside'), + ]); + + // Suspend the outer shell + ReactNoop.act(() => { + ReactNoop.render( + } />, + ); + }); + expect(Scheduler).toHaveYielded([ + 'Text:Inside render', + 'Suspend:OutsideAsync', + 'Text:Fallback:Inside render', + 'Text:Fallback:Outside render', + 'Text:Outside render', + ]); + expect(ReactNoop.getChildren()).toEqual([ + span('Inside'), + span('Outside'), + ]); + + // Timing out should commit the fallback and destroy inner layout effects. + await advanceTimers(1000); + expect(Scheduler).toHaveYielded([ + 'Text:Inside destroy layout', + 'Text:Fallback:Inside create layout', + 'Text:Fallback:Outside create layout', + ]); + expect(Scheduler).toFlushAndYield([ + 'Text:Fallback:Inside create passive', + 'Text:Fallback:Outside create passive', + ]); + expect(ReactNoop.getChildren()).toEqual([ + spanHidden('Inside'), + span('Fallback:Inside'), + span('Fallback:Outside'), + span('Outside'), + ]); + + // Suspend the fallback and verify that it's effects get cleaned up as well + ReactNoop.act(() => { + ReactNoop.render( + } + outerChildren={} + />, + ); + }); + expect(Scheduler).toHaveYielded([ + 'Text:Inside render', + 'Suspend:OutsideAsync', + 'Text:Fallback:Inside render', + 'Suspend:FallbackAsync', + 'Text:Fallback:Fallback render', + 'Text:Fallback:Outside render', + 'Text:Outside render', + ]); + expect(ReactNoop.getChildren()).toEqual([ + spanHidden('Inside'), + span('Fallback:Inside'), + span('Fallback:Outside'), + span('Outside'), + ]); + + // Timing out should commit the inner fallback and destroy outer fallback layout effects. + await advanceTimers(1000); + expect(Scheduler).toHaveYielded([ + 'Text:Fallback:Inside destroy layout', + 'Text:Fallback:Fallback create layout', + ]); + expect(Scheduler).toFlushAndYield([ + 'Text:Fallback:Fallback create passive', + ]); + expect(ReactNoop.getChildren()).toEqual([ + spanHidden('Inside'), + spanHidden('Fallback:Inside'), + span('Fallback:Fallback'), + span('Fallback:Outside'), + span('Outside'), + ]); + + // Resolving both resources should cleanup fallabck effects and recreate main effects + await ReactNoop.act(async () => { + await resolveText('FallbackAsync'); + await resolveText('OutsideAsync'); + }); + expect(Scheduler).toHaveYielded([ + 'Text:Inside render', + 'AsyncText:OutsideAsync render', + 'Text:Fallback:Fallback destroy layout', + 'Text:Fallback:Outside destroy layout', + 'Text:Inside create layout', + 'AsyncText:OutsideAsync create layout', + 'Text:Fallback:Inside destroy passive', + 'Text:Fallback:Fallback destroy passive', + 'Text:Fallback:Outside destroy passive', + 'AsyncText:OutsideAsync create passive', + ]); + expect(ReactNoop.getChildren()).toEqual([ + span('Inside'), + span('OutsideAsync'), + span('Outside'), + ]); + }); + + // @gate enableSuspenseLayoutEffectSemantics + // @gate enableCache + it('should be cleaned up inside of a fallback that suspends (alternate)', async () => { + function App({fallbackChildren = null, outerChildren = null}) { + return ( + <> + + }> + + {fallbackChildren} + + + + }> + + {outerChildren} + + + + ); + } + + // Mount + await ReactNoop.act(async () => { + ReactNoop.render(); + }); + expect(Scheduler).toHaveYielded([ + 'Text:Inside render', + 'Text:Outside render', + 'Text:Inside create layout', + 'Text:Outside create layout', + 'Text:Inside create passive', + 'Text:Outside create passive', + ]); + expect(ReactNoop.getChildren()).toEqual([ + span('Inside'), + span('Outside'), + ]); + + // Suspend both the outer boundary and the fallback + ReactNoop.act(() => { + ReactNoop.render( + } + fallbackChildren={} + />, + ); + }); + await advanceTimers(1000); + expect(Scheduler).toHaveYielded([ + 'Text:Inside render', + 'Suspend:OutsideAsync', + 'Text:Fallback:Inside render', + 'Suspend:FallbackAsync', + 'Text:Fallback:Fallback render', + 'Text:Fallback:Outside render', + 'Text:Outside render', + 'Text:Inside destroy layout', + 'Text:Fallback:Fallback create layout', + 'Text:Fallback:Outside create layout', + ]); + expect(Scheduler).toFlushAndYield([ + 'Text:Fallback:Fallback create passive', + 'Text:Fallback:Outside create passive', + ]); + expect(ReactNoop.getChildren()).toEqual([ + spanHidden('Inside'), + span('Fallback:Fallback'), + span('Fallback:Outside'), + span('Outside'), + ]); + + // Resolving the inside fallback + await ReactNoop.act(async () => { + await resolveText('FallbackAsync'); + }); + expect(Scheduler).toHaveYielded([ + 'Text:Fallback:Inside render', + 'AsyncText:FallbackAsync render', + 'Text:Fallback:Fallback destroy layout', + 'Text:Fallback:Inside create layout', + 'AsyncText:FallbackAsync create layout', + 'Text:Fallback:Fallback destroy passive', + 'Text:Fallback:Inside create passive', + 'AsyncText:FallbackAsync create passive', + ]); + expect(ReactNoop.getChildren()).toEqual([ + spanHidden('Inside'), + span('Fallback:Inside'), + span('FallbackAsync'), + span('Fallback:Outside'), + span('Outside'), + ]); + + // Resolving the outer fallback only + await ReactNoop.act(async () => { + await resolveText('OutsideAsync'); + }); + expect(Scheduler).toHaveYielded([ + 'Text:Inside render', + 'AsyncText:OutsideAsync render', + 'Text:Fallback:Inside destroy layout', + 'AsyncText:FallbackAsync destroy layout', + 'Text:Fallback:Outside destroy layout', + 'Text:Inside create layout', + 'AsyncText:OutsideAsync create layout', + 'Text:Fallback:Inside destroy passive', + 'AsyncText:FallbackAsync destroy passive', + 'Text:Fallback:Outside destroy passive', + 'AsyncText:OutsideAsync create passive', + ]); + expect(ReactNoop.getChildren()).toEqual([ + span('Inside'), + span('OutsideAsync'), + span('Outside'), + ]); + }); + + // @gate enableSuspenseLayoutEffectSemantics + // @gate enableCache + it('should be cleaned up deeper inside of a subtree that suspends', async () => { + function ConditionalSuspense({shouldSuspend}) { + if (shouldSuspend) { + readText('Suspend'); + } + return ; + } + + function App({children = null, shouldSuspend}) { + return ( + <> + }> + + + + + ); + } + + // Mount + await ReactNoop.act(async () => { + ReactNoop.render(); + }); + expect(Scheduler).toHaveYielded([ + 'Text:Inside render', + 'Text:Outside render', + 'Text:Inside create layout', + 'Text:Outside create layout', + 'Text:Inside create passive', + 'Text:Outside create passive', + ]); + expect(ReactNoop.getChildren()).toEqual([ + span('Inside'), + span('Outside'), + ]); + + // Suspending a component in the middle of the tree + // should still properly cleanup effects deeper in the tree + ReactNoop.act(() => { + ReactNoop.render(); + }); + expect(Scheduler).toHaveYielded([ + 'Suspend:Suspend', + 'Text:Fallback render', + 'Text:Outside render', + ]); + expect(ReactNoop.getChildren()).toEqual([ + span('Inside'), + span('Outside'), + ]); + + // Timing out should commit the inner fallback and destroy outer fallback layout effects. + await advanceTimers(1000); + expect(Scheduler).toHaveYielded([ + 'Text:Inside destroy layout', + 'Text:Fallback create layout', + ]); + expect(Scheduler).toFlushAndYield(['Text:Fallback create passive']); + expect(ReactNoop.getChildren()).toEqual([ + spanHidden('Inside'), + span('Fallback'), + span('Outside'), + ]); + + // Resolving should cleanup. + await ReactNoop.act(async () => { + await resolveText('Suspend'); + }); + expect(Scheduler).toHaveYielded([ + 'Text:Inside render', + 'Text:Fallback destroy layout', + 'Text:Inside create layout', + 'Text:Fallback destroy passive', + ]); + expect(ReactNoop.getChildren()).toEqual([ + span('Inside'), + span('Outside'), + ]); + }); + + describe('that throw errors', () => { + // @gate enableSuspenseLayoutEffectSemantics + // @gate enableCache + it('are properly handled for componentDidMount', async () => { + let componentDidMountShouldThrow = false; + + class ThrowsInDidMount extends React.Component { + componentWillUnmount() { + Scheduler.unstable_yieldValue( + 'ThrowsInDidMount componentWillUnmount', + ); + } + componentDidMount() { + Scheduler.unstable_yieldValue('ThrowsInDidMount componentDidMount'); + if (componentDidMountShouldThrow) { + throw Error('expected'); + } + } + render() { + Scheduler.unstable_yieldValue('ThrowsInDidMount render'); + return ; + } + } + + function App({children = null}) { + Scheduler.unstable_yieldValue('App render'); + React.useLayoutEffect(() => { + Scheduler.unstable_yieldValue('App create layout'); + return () => { + Scheduler.unstable_yieldValue('App destroy layout'); + }; + }, []); + return ( + <> + }> + {children} + + + + + + ); + } + + await ReactNoop.act(async () => { + ReactNoop.render( + }> + + , + ); + }); + expect(Scheduler).toHaveYielded([ + 'ErrorBoundary render: try', + 'App render', + 'ThrowsInDidMount render', + 'Text:Inside render', + 'Text:Outside render', + 'ThrowsInDidMount componentDidMount', + 'Text:Inside create layout', + 'Text:Outside create layout', + 'App create layout', + 'Text:Inside create passive', + 'Text:Outside create passive', + ]); + expect(ReactNoop.getChildren()).toEqual([ + span('ThrowsInDidMount'), + span('Inside'), + span('Outside'), + ]); + + // Schedule an update that causes React to suspend. + await ReactNoop.act(async () => { + ReactNoop.render( + }> + + + + , + ); + }); + expect(Scheduler).toHaveYielded([ + 'ErrorBoundary render: try', + 'App render', + 'Suspend:Async', + 'ThrowsInDidMount render', + 'Text:Inside render', + 'Text:Fallback render', + 'Text:Outside render', + 'ThrowsInDidMount componentWillUnmount', + 'Text:Inside destroy layout', + 'Text:Fallback create layout', + 'Text:Fallback create passive', + ]); + expect(ReactNoop.getChildren()).toEqual([ + spanHidden('ThrowsInDidMount'), + spanHidden('Inside'), + span('Fallback'), + span('Outside'), + ]); + + // Resolve the pending suspense and throw + componentDidMountShouldThrow = true; + await ReactNoop.act(async () => { + await resolveText('Async'); + }); + expect(Scheduler).toHaveYielded([ + 'AsyncText:Async render', + 'ThrowsInDidMount render', + 'Text:Inside render', + 'Text:Fallback destroy layout', + 'AsyncText:Async create layout', + + // Even though an error was thrown in componentDidMount, + // subsequent layout effects should still be destroyed. + 'ThrowsInDidMount componentDidMount', + 'Text:Inside create layout', + + // Finish the in-progress commit + 'Text:Fallback destroy passive', + 'AsyncText:Async create passive', + + // Destroy layout and passive effects in the errored tree. + 'App destroy layout', + 'AsyncText:Async destroy layout', + 'ThrowsInDidMount componentWillUnmount', + 'Text:Inside destroy layout', + 'Text:Outside destroy layout', + 'AsyncText:Async destroy passive', + 'Text:Inside destroy passive', + 'Text:Outside destroy passive', + + // Render fallback + 'ErrorBoundary render: catch', + 'Text:Error render', + 'Text:Error create layout', + 'Text:Error create passive', + ]); + expect(ReactNoop.getChildren()).toEqual([span('Error')]); + }); + + // @gate enableSuspenseLayoutEffectSemantics + // @gate enableCache + it('are properly handled for componentWillUnmount', async () => { + class ThrowsInWillUnmount extends React.Component { + componentDidMount() { + Scheduler.unstable_yieldValue( + 'ThrowsInWillUnmount componentDidMount', + ); + } + componentWillUnmount() { + Scheduler.unstable_yieldValue( + 'ThrowsInWillUnmount componentWillUnmount', + ); + throw Error('expected'); + } + render() { + Scheduler.unstable_yieldValue('ThrowsInWillUnmount render'); + return ; + } + } + + function App({children = null}) { + Scheduler.unstable_yieldValue('App render'); + React.useLayoutEffect(() => { + Scheduler.unstable_yieldValue('App create layout'); + return () => { + Scheduler.unstable_yieldValue('App destroy layout'); + }; + }, []); + return ( + <> + }> + {children} + + + + + + ); + } + + await ReactNoop.act(async () => { + ReactNoop.render( + }> + + , + ); + }); + expect(Scheduler).toHaveYielded([ + 'ErrorBoundary render: try', + 'App render', + 'ThrowsInWillUnmount render', + 'Text:Inside render', + 'Text:Outside render', + 'ThrowsInWillUnmount componentDidMount', + 'Text:Inside create layout', + 'Text:Outside create layout', + 'App create layout', + 'Text:Inside create passive', + 'Text:Outside create passive', + ]); + expect(ReactNoop.getChildren()).toEqual([ + span('ThrowsInWillUnmount'), + span('Inside'), + span('Outside'), + ]); + + // Schedule an update that suspends and triggers our error code. + await ReactNoop.act(async () => { + ReactNoop.render( + }> + + + + , + ); + }); + expect(Scheduler).toHaveYielded([ + 'ErrorBoundary render: try', + 'App render', + 'Suspend:Async', + 'ThrowsInWillUnmount render', + 'Text:Inside render', + 'Text:Fallback render', + 'Text:Outside render', + + // Even though an error was thrown in componentWillUnmount, + // subsequent layout effects should still be destroyed. + 'ThrowsInWillUnmount componentWillUnmount', + 'Text:Inside destroy layout', + + // Finish the in-progess commit + 'Text:Fallback create layout', + 'Text:Fallback create passive', + + // 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', + 'Text:Fallback destroy passive', + 'Text:Outside destroy passive', + + // Render fallback + 'ErrorBoundary render: catch', + 'Text:Error render', + 'Text:Error create layout', + 'Text:Error create passive', + ]); + expect(ReactNoop.getChildren()).toEqual([span('Error')]); + }); + + // @gate enableSuspenseLayoutEffectSemantics + // @gate enableCache + // @gate replayFailedUnitOfWorkWithInvokeGuardedCallback + it('are properly handled for layout effect creation', async () => { + let useLayoutEffectShouldThrow = false; + + function ThrowsInLayoutEffect() { + Scheduler.unstable_yieldValue('ThrowsInLayoutEffect render'); + React.useLayoutEffect(() => { + Scheduler.unstable_yieldValue( + 'ThrowsInLayoutEffect useLayoutEffect create', + ); + if (useLayoutEffectShouldThrow) { + throw Error('expected'); + } + return () => { + Scheduler.unstable_yieldValue( + 'ThrowsInLayoutEffect useLayoutEffect destroy', + ); + }; + }, []); + return ; + } + + function App({children = null}) { + Scheduler.unstable_yieldValue('App render'); + React.useLayoutEffect(() => { + Scheduler.unstable_yieldValue('App create layout'); + return () => { + Scheduler.unstable_yieldValue('App destroy layout'); + }; + }, []); + return ( + <> + }> + {children} + + + + + + ); + } + + await ReactNoop.act(async () => { + ReactNoop.render( + }> + + , + ); + }); + expect(Scheduler).toHaveYielded([ + 'ErrorBoundary render: try', + 'App render', + 'ThrowsInLayoutEffect render', + 'Text:Inside render', + 'Text:Outside render', + 'ThrowsInLayoutEffect useLayoutEffect create', + 'Text:Inside create layout', + 'Text:Outside create layout', + 'App create layout', + 'Text:Inside create passive', + 'Text:Outside create passive', + ]); + expect(ReactNoop.getChildren()).toEqual([ + span('ThrowsInLayoutEffect'), + span('Inside'), + span('Outside'), + ]); + + // Schedule an update that causes React to suspend. + await ReactNoop.act(async () => { + ReactNoop.render( + }> + + + + , + ); + }); + expect(Scheduler).toHaveYielded([ + 'ErrorBoundary render: try', + 'App render', + 'Suspend:Async', + 'ThrowsInLayoutEffect render', + 'Text:Inside render', + 'Text:Fallback render', + 'Text:Outside render', + 'ThrowsInLayoutEffect useLayoutEffect destroy', + 'Text:Inside destroy layout', + 'Text:Fallback create layout', + 'Text:Fallback create passive', + ]); + expect(ReactNoop.getChildren()).toEqual([ + spanHidden('ThrowsInLayoutEffect'), + spanHidden('Inside'), + span('Fallback'), + span('Outside'), + ]); + + // Resolve the pending suspense and throw + useLayoutEffectShouldThrow = true; + await ReactNoop.act(async () => { + await resolveText('Async'); + }); + expect(Scheduler).toHaveYielded([ + 'AsyncText:Async render', + 'ThrowsInLayoutEffect render', + 'Text:Inside render', + + 'Text:Fallback destroy layout', + + // Even though an error was thrown in useLayoutEffect, + // subsequent layout effects should still be created. + 'AsyncText:Async create layout', + 'ThrowsInLayoutEffect useLayoutEffect create', + 'Text:Inside create layout', + + // Finish the in-progess commit + 'Text:Fallback destroy passive', + 'AsyncText:Async create passive', + + // Destroy layout and passive effects in the errored tree. + 'App destroy layout', + 'AsyncText:Async destroy layout', + 'Text:Inside destroy layout', + 'Text:Outside destroy layout', + 'AsyncText:Async destroy passive', + 'Text:Inside destroy passive', + 'Text:Outside destroy passive', + + // Render fallback + 'ErrorBoundary render: catch', + 'Text:Error render', + 'Text:Error create layout', + 'Text:Error create passive', + ]); + expect(ReactNoop.getChildren()).toEqual([span('Error')]); + }); + + // @gate enableSuspenseLayoutEffectSemantics + // @gate enableCache + // @gate replayFailedUnitOfWorkWithInvokeGuardedCallback + it('are properly handled for layout effect descruction', async () => { + function ThrowsInLayoutEffectDestroy() { + Scheduler.unstable_yieldValue('ThrowsInLayoutEffectDestroy render'); + React.useLayoutEffect(() => { + Scheduler.unstable_yieldValue( + 'ThrowsInLayoutEffectDestroy useLayoutEffect create', + ); + return () => { + Scheduler.unstable_yieldValue( + 'ThrowsInLayoutEffectDestroy useLayoutEffect destroy', + ); + throw Error('expected'); + }; + }, []); + return ; + } + + function App({children = null}) { + Scheduler.unstable_yieldValue('App render'); + React.useLayoutEffect(() => { + Scheduler.unstable_yieldValue('App create layout'); + return () => { + Scheduler.unstable_yieldValue('App destroy layout'); + }; + }, []); + return ( + <> + }> + {children} + + + + + + ); + } + + await ReactNoop.act(async () => { + ReactNoop.render( + }> + + , + ); + }); + expect(Scheduler).toHaveYielded([ + 'ErrorBoundary render: try', + 'App render', + 'ThrowsInLayoutEffectDestroy render', + 'Text:Inside render', + 'Text:Outside render', + 'ThrowsInLayoutEffectDestroy useLayoutEffect create', + 'Text:Inside create layout', + 'Text:Outside create layout', + 'App create layout', + 'Text:Inside create passive', + 'Text:Outside create passive', + ]); + expect(ReactNoop.getChildren()).toEqual([ + span('ThrowsInLayoutEffectDestroy'), + span('Inside'), + span('Outside'), + ]); + + // Schedule an update that suspends and triggers our error code. + await ReactNoop.act(async () => { + ReactNoop.render( + }> + + + + , + ); + }); + expect(Scheduler).toHaveYielded([ + 'ErrorBoundary render: try', + 'App render', + 'Suspend:Async', + 'ThrowsInLayoutEffectDestroy render', + 'Text:Inside render', + 'Text:Fallback render', + 'Text:Outside render', + + // Even though an error was thrown in useLayoutEffect destroy, + // subsequent layout effects should still be destroyed. + 'ThrowsInLayoutEffectDestroy useLayoutEffect destroy', + 'Text:Inside destroy layout', + + // Finish the in-progess commit + 'Text:Fallback create layout', + 'Text:Fallback create passive', + + // Destroy layout and passive effects in the errored tree. + 'App destroy layout', + 'Text:Fallback destroy layout', + 'Text:Outside destroy layout', + 'Text:Inside destroy passive', + 'Text:Fallback destroy passive', + 'Text:Outside destroy passive', + + // Render fallback + 'ErrorBoundary render: catch', + 'Text:Error render', + 'Text:Error create layout', + 'Text:Error create passive', + ]); + expect(ReactNoop.getChildren()).toEqual([span('Error')]); + }); + }); + + // @gate enableSuspenseLayoutEffectSemantics + // @gate enableCache + it('should be only destroy layout effects once if a tree suspends in multiple places', async () => { + class ClassText extends React.Component { + componentDidMount() { + const {text} = this.props; + Scheduler.unstable_yieldValue(`ClassText:${text} componentDidMount`); + } + componentDidUpdate() { + const {text} = this.props; + Scheduler.unstable_yieldValue(`ClassText:${text} componentDidUpdate`); + } + componentWillUnmount() { + const {text} = this.props; + Scheduler.unstable_yieldValue( + `ClassText:${text} componentWillUnmount`, + ); + } + render() { + const {children, text} = this.props; + Scheduler.unstable_yieldValue(`ClassText:${text} render`); + return {children}; + } + } + + function App({children = null}) { + return ( + }> + + {children} + + + ); + } + + await ReactNoop.act(async () => { + ReactNoop.render(); + }); + expect(Scheduler).toHaveYielded([ + 'Text:Function render', + 'ClassText:Class render', + 'Text:Function create layout', + 'ClassText:Class componentDidMount', + 'Text:Function create passive', + ]); + expect(ReactNoop.getChildren()).toEqual([ + span('Function'), + span('Class'), + ]); + + // Schedule an update that causes React to suspend. + ReactNoop.act(() => { + ReactNoop.render( + + + + , + ); + }); + expect(Scheduler).toHaveYielded([ + 'Text:Function render', + 'Suspend:Async_1', + 'Suspend:Async_2', + 'ClassText:Class render', + 'ClassText:Fallback render', + ]); + expect(ReactNoop.getChildren()).toEqual([ + span('Function'), + span('Class'), + ]); + + await advanceTimers(1000); + + // Timing out should commit the fallback and destroy inner layout effects. + expect(Scheduler).toHaveYielded([ + 'Text:Function destroy layout', + 'ClassText:Class componentWillUnmount', + 'ClassText:Fallback componentDidMount', + ]); + expect(ReactNoop.getChildren()).toEqual([ + spanHidden('Function'), + spanHidden('Class'), + span('Fallback'), + ]); + + // Resolving the suspended resource should re-create inner layout effects. + await ReactNoop.act(async () => { + await resolveText('Async_1'); + }); + expect(Scheduler).toHaveYielded([ + 'Text:Function render', + 'AsyncText:Async_1 render', + 'Suspend:Async_2', + 'ClassText:Class render', + ]); + expect(ReactNoop.getChildren()).toEqual([ + spanHidden('Function'), + spanHidden('Class'), + span('Fallback'), + ]); + + // Resolving the suspended resource should re-create inner layout effects. + await ReactNoop.act(async () => { + await resolveText('Async_2'); + }); + expect(Scheduler).toHaveYielded([ + 'Text:Function render', + 'AsyncText:Async_1 render', + 'AsyncText:Async_2 render', + 'ClassText:Class render', + 'ClassText:Fallback componentWillUnmount', + 'Text:Function create layout', + 'AsyncText:Async_1 create layout', + 'AsyncText:Async_2 create layout', + 'ClassText:Class componentDidMount', + 'AsyncText:Async_1 create passive', + 'AsyncText:Async_2 create passive', + ]); + expect(ReactNoop.getChildren()).toEqual([ + span('Function'), + span('Async_1'), + span('Async_2'), + span('Class'), + ]); + + await ReactNoop.act(async () => { + ReactNoop.render(null); + }); + expect(Scheduler).toHaveYielded([ + 'Text:Function destroy layout', + 'AsyncText:Async_1 destroy layout', + 'AsyncText:Async_2 destroy layout', + 'ClassText:Class componentWillUnmount', + 'Text:Function destroy passive', + 'AsyncText:Async_1 destroy passive', + 'AsyncText:Async_2 destroy passive', + ]); + }); + + // @gate enableSuspenseLayoutEffectSemantics + // @gate enableCache + it('should be only destroy layout effects once if a component suspends multiple times', async () => { + class ClassText extends React.Component { + componentDidMount() { + const {text} = this.props; + Scheduler.unstable_yieldValue(`ClassText:${text} componentDidMount`); + } + componentDidUpdate() { + const {text} = this.props; + Scheduler.unstable_yieldValue(`ClassText:${text} componentDidUpdate`); + } + componentWillUnmount() { + const {text} = this.props; + Scheduler.unstable_yieldValue( + `ClassText:${text} componentWillUnmount`, + ); + } + render() { + const {children, text} = this.props; + Scheduler.unstable_yieldValue(`ClassText:${text} render`); + return {children}; + } + } + + let textToRead = null; + + function Suspender() { + Scheduler.unstable_yieldValue(`Suspender "${textToRead}" render`); + if (textToRead !== null) { + readText(textToRead); + } + return ; + } + + function App({children = null}) { + return ( + }> + + + + + ); + } + + await ReactNoop.act(async () => { + ReactNoop.render(); + }); + expect(Scheduler).toHaveYielded([ + 'Text:Function render', + 'Suspender "null" render', + 'ClassText:Class render', + 'Text:Function create layout', + 'ClassText:Class componentDidMount', + 'Text:Function create passive', + ]); + expect(ReactNoop.getChildren()).toEqual([ + span('Function'), + span('Suspender'), + span('Class'), + ]); + + // Schedule an update that causes React to suspend. + textToRead = 'A'; + ReactNoop.act(() => { + ReactNoop.render(); + }); + expect(Scheduler).toHaveYielded([ + 'Text:Function render', + 'Suspender "A" render', + 'Suspend:A', + 'ClassText:Class render', + 'ClassText:Fallback render', + ]); + expect(ReactNoop.getChildren()).toEqual([ + span('Function'), + span('Suspender'), + span('Class'), + ]); + + await advanceTimers(1000); + + // Timing out should commit the fallback and destroy inner layout effects. + expect(Scheduler).toHaveYielded([ + 'Text:Function destroy layout', + 'ClassText:Class componentWillUnmount', + 'ClassText:Fallback componentDidMount', + ]); + expect(ReactNoop.getChildren()).toEqual([ + spanHidden('Function'), + spanHidden('Suspender'), + spanHidden('Class'), + span('Fallback'), + ]); + + // Resolving the suspended resource should re-create inner layout effects. + textToRead = 'B'; + await ReactNoop.act(async () => { + await resolveText('A'); + }); + expect(Scheduler).toHaveYielded([ + 'Text:Function render', + 'Suspender "B" render', + 'Suspend:B', + 'ClassText:Class render', + ]); + expect(ReactNoop.getChildren()).toEqual([ + spanHidden('Function'), + spanHidden('Suspender'), + spanHidden('Class'), + span('Fallback'), + ]); + + // Resolving the suspended resource should re-create inner layout effects. + await ReactNoop.act(async () => { + await resolveText('B'); + }); + expect(Scheduler).toHaveYielded([ + 'Text:Function render', + 'Suspender "B" render', + 'ClassText:Class render', + 'ClassText:Fallback componentWillUnmount', + 'Text:Function create layout', + 'ClassText:Class componentDidMount', + ]); + expect(ReactNoop.getChildren()).toEqual([ + span('Function'), + span('Suspender'), + span('Class'), + ]); + + await ReactNoop.act(async () => { + ReactNoop.render(null); + }); + expect(Scheduler).toHaveYielded([ + 'Text:Function destroy layout', + 'ClassText:Class componentWillUnmount', + 'Text:Function destroy passive', + ]); + }); + }); + + describe('refs within a tree that re-suspends in an update', () => { + function RefCheckerOuter({Component}) { + const refObject = React.useRef(null); + + const manualRef = React.useMemo(() => ({current: null}), []); + const refCallback = React.useCallback(value => { + Scheduler.unstable_yieldValue( + `RefCheckerOuter refCallback value? ${value != null}`, + ); + manualRef.current = value; + }, []); + + Scheduler.unstable_yieldValue(`RefCheckerOuter render`); + + React.useLayoutEffect(() => { + Scheduler.unstable_yieldValue( + `RefCheckerOuter create layout refObject? ${refObject.current != + null} refCallback? ${manualRef.current != null}`, + ); + return () => { + Scheduler.unstable_yieldValue( + `RefCheckerOuter destroy layout refObject? ${refObject.current != + null} refCallback? ${manualRef.current != null}`, + ); + }; + }, []); + + return ( + <> + + + + + + + + ); + } + + function RefCheckerInner({forwardedRef, text}) { + Scheduler.unstable_yieldValue(`RefCheckerInner:${text} render`); + React.useLayoutEffect(() => { + Scheduler.unstable_yieldValue( + `RefCheckerInner:${text} create layout ref? ${forwardedRef.current != + null}`, + ); + return () => { + Scheduler.unstable_yieldValue( + `RefCheckerInner:${text} destroy layout ref? ${forwardedRef.current != + null}`, + ); + }; + }, []); + return null; + } + + // @gate enableCache + it('should not be cleared within legacy roots', async () => { + class ClassComponent extends React.Component { + render() { + Scheduler.unstable_yieldValue( + `ClassComponent:${this.props.prop} render`, + ); + return this.props.children; + } + } + + function App({children}) { + Scheduler.unstable_yieldValue(`App render`); + return ( + }> + {children} + + + ); + } + + ReactNoop.act(() => { + ReactNoop.renderLegacySyncRoot(); + }); + expect(Scheduler).toHaveYielded([ + 'App render', + 'RefCheckerOuter render', + 'ClassComponent:refObject render', + 'RefCheckerInner:refObject render', + 'ClassComponent:refCallback render', + 'RefCheckerInner:refCallback render', + 'RefCheckerInner:refObject create layout ref? false', + 'RefCheckerInner:refCallback create layout ref? false', + 'RefCheckerOuter refCallback value? true', + 'RefCheckerOuter create layout refObject? true refCallback? true', + ]); + expect(ReactNoop.getChildren()).toEqual([]); + + // Suspend the inner Suspense subtree (only inner effects should be destroyed) + ReactNoop.act(() => { + ReactNoop.renderLegacySyncRoot( + } />, + ); + }); + await advanceTimers(1000); + expect(Scheduler).toHaveYielded([ + 'App render', + 'Suspend:Async', + 'RefCheckerOuter render', + 'ClassComponent:refObject render', + 'RefCheckerInner:refObject render', + 'ClassComponent:refCallback render', + 'RefCheckerInner:refCallback render', + 'Text:Fallback render', + 'Text:Fallback create layout', + 'Text:Fallback create passive', + ]); + expect(ReactNoop.getChildren()).toEqual([span('Fallback')]); + + // Resolving the suspended resource should re-create inner layout effects. + await ReactNoop.act(async () => { + await resolveText('Async'); + }); + expect(Scheduler).toHaveYielded([ + 'AsyncText:Async render', + 'Text:Fallback destroy layout', + 'AsyncText:Async create layout', + 'Text:Fallback destroy passive', + 'AsyncText:Async create passive', + ]); + expect(ReactNoop.getChildren()).toEqual([span('Async')]); + + await ReactNoop.act(async () => { + ReactNoop.renderLegacySyncRoot(null); + }); + expect(Scheduler).toHaveYielded([ + 'AsyncText:Async destroy layout', + 'RefCheckerOuter destroy layout refObject? true refCallback? true', + 'RefCheckerInner:refObject destroy layout ref? false', + 'RefCheckerOuter refCallback value? false', + 'RefCheckerInner:refCallback destroy layout ref? false', + 'AsyncText:Async destroy passive', + ]); + expect(ReactNoop.getChildren()).toEqual([]); + }); + + // @gate enableSuspenseLayoutEffectSemantics + // @gate enableCache + it('should be cleared and reset for host components', async () => { + function App({children}) { + Scheduler.unstable_yieldValue(`App render`); + return ( + }> + {children} + + + ); + } + + // Mount + await ReactNoop.act(async () => { + ReactNoop.render(); + }); + expect(Scheduler).toHaveYielded([ + 'App render', + 'RefCheckerOuter render', + 'RefCheckerInner:refObject render', + 'RefCheckerInner:refCallback render', + 'RefCheckerInner:refObject create layout ref? false', + 'RefCheckerInner:refCallback create layout ref? false', + 'RefCheckerOuter refCallback value? true', + 'RefCheckerOuter create layout refObject? true refCallback? true', + ]); + expect(ReactNoop.getChildren()).toEqual([ + span('refObject'), + span('refCallback'), + ]); + + // Suspend the inner Suspense subtree (only inner effects should be destroyed) + ReactNoop.act(() => { + ReactNoop.render( + } />, + ); + }); + await advanceTimers(1000); + expect(Scheduler).toHaveYielded([ + 'App render', + 'Suspend:Async', + 'RefCheckerOuter render', + 'RefCheckerInner:refObject render', + 'RefCheckerInner:refCallback render', + 'Text:Fallback render', + 'RefCheckerOuter destroy layout refObject? true refCallback? true', + 'RefCheckerInner:refObject destroy layout ref? false', + 'RefCheckerOuter refCallback value? false', + 'RefCheckerInner:refCallback destroy layout ref? false', + 'Text:Fallback create layout', + ]); + expect(ReactNoop.getChildren()).toEqual([ + spanHidden('refObject'), + spanHidden('refCallback'), + span('Fallback'), + ]); + + // Resolving the suspended resource should re-create inner layout effects. + await ReactNoop.act(async () => { + await resolveText('Async'); + }); + expect(Scheduler).toHaveYielded([ + 'Text:Fallback create passive', + 'AsyncText:Async render', + 'RefCheckerOuter render', + 'RefCheckerInner:refObject render', + 'RefCheckerInner:refCallback render', + 'Text:Fallback destroy layout', + 'AsyncText:Async create layout', + 'RefCheckerInner:refObject create layout ref? false', + 'RefCheckerInner:refCallback create layout ref? false', + 'RefCheckerOuter refCallback value? true', + 'RefCheckerOuter create layout refObject? true refCallback? true', + 'Text:Fallback destroy passive', + 'AsyncText:Async create passive', + ]); + expect(ReactNoop.getChildren()).toEqual([ + span('Async'), + span('refObject'), + span('refCallback'), + ]); + + await ReactNoop.act(async () => { + ReactNoop.render(null); + }); + expect(Scheduler).toHaveYielded([ + 'AsyncText:Async destroy layout', + 'RefCheckerOuter destroy layout refObject? true refCallback? true', + 'RefCheckerInner:refObject destroy layout ref? false', + 'RefCheckerOuter refCallback value? false', + 'RefCheckerInner:refCallback destroy layout ref? false', + 'AsyncText:Async destroy passive', + ]); + expect(ReactNoop.getChildren()).toEqual([]); + }); + + // @gate enableSuspenseLayoutEffectSemantics + // @gate enableCache + it('should be cleared and reset for class components', async () => { + class ClassComponent extends React.Component { + render() { + Scheduler.unstable_yieldValue( + `ClassComponent:${this.props.prop} render`, + ); + return this.props.children; + } + } + + function App({children}) { + Scheduler.unstable_yieldValue(`App render`); + return ( + }> + {children} + + + ); + } + + // Mount + await ReactNoop.act(async () => { + ReactNoop.render(); + }); + expect(Scheduler).toHaveYielded([ + 'App render', + 'RefCheckerOuter render', + 'ClassComponent:refObject render', + 'RefCheckerInner:refObject render', + 'ClassComponent:refCallback render', + 'RefCheckerInner:refCallback render', + 'RefCheckerInner:refObject create layout ref? false', + 'RefCheckerInner:refCallback create layout ref? false', + 'RefCheckerOuter refCallback value? true', + 'RefCheckerOuter create layout refObject? true refCallback? true', + ]); + expect(ReactNoop.getChildren()).toEqual([]); + + // Suspend the inner Suspense subtree (only inner effects should be destroyed) + ReactNoop.act(() => { + ReactNoop.render( + } />, + ); + }); + await advanceTimers(1000); + expect(Scheduler).toHaveYielded([ + 'App render', + 'Suspend:Async', + 'RefCheckerOuter render', + 'ClassComponent:refObject render', + 'RefCheckerInner:refObject render', + 'ClassComponent:refCallback render', + 'RefCheckerInner:refCallback render', + 'Text:Fallback render', + 'RefCheckerOuter destroy layout refObject? true refCallback? true', + 'RefCheckerInner:refObject destroy layout ref? false', + 'RefCheckerOuter refCallback value? false', + 'RefCheckerInner:refCallback destroy layout ref? false', + 'Text:Fallback create layout', + ]); + expect(ReactNoop.getChildren()).toEqual([span('Fallback')]); + + // Resolving the suspended resource should re-create inner layout effects. + await ReactNoop.act(async () => { + await resolveText('Async'); + }); + expect(Scheduler).toHaveYielded([ + 'Text:Fallback create passive', + 'AsyncText:Async render', + 'RefCheckerOuter render', + 'ClassComponent:refObject render', + 'RefCheckerInner:refObject render', + 'ClassComponent:refCallback render', + 'RefCheckerInner:refCallback render', + 'Text:Fallback destroy layout', + 'AsyncText:Async create layout', + 'RefCheckerInner:refObject create layout ref? false', + 'RefCheckerInner:refCallback create layout ref? false', + 'RefCheckerOuter refCallback value? true', + 'RefCheckerOuter create layout refObject? true refCallback? true', + 'Text:Fallback destroy passive', + 'AsyncText:Async create passive', + ]); + expect(ReactNoop.getChildren()).toEqual([span('Async')]); + + await ReactNoop.act(async () => { + ReactNoop.render(null); + }); + expect(Scheduler).toHaveYielded([ + 'AsyncText:Async destroy layout', + 'RefCheckerOuter destroy layout refObject? true refCallback? true', + 'RefCheckerInner:refObject destroy layout ref? false', + 'RefCheckerOuter refCallback value? false', + 'RefCheckerInner:refCallback destroy layout ref? false', + 'AsyncText:Async destroy passive', + ]); + expect(ReactNoop.getChildren()).toEqual([]); + }); + + // @gate enableSuspenseLayoutEffectSemantics + // @gate enableCache + it('should be cleared and reset for function components with useImperativeHandle', async () => { + const FunctionComponent = React.forwardRef((props, ref) => { + Scheduler.unstable_yieldValue('FunctionComponent render'); + React.useImperativeHandle( + ref, + () => ({ + // Noop + }), + [], + ); + return props.children; + }); + FunctionComponent.displayName = 'FunctionComponent'; + + function App({children}) { + Scheduler.unstable_yieldValue(`App render`); + return ( + }> + {children} + + + ); + } + + // Mount + await ReactNoop.act(async () => { + ReactNoop.render(); + }); + expect(Scheduler).toHaveYielded([ + 'App render', + 'RefCheckerOuter render', + 'FunctionComponent render', + 'RefCheckerInner:refObject render', + 'FunctionComponent render', + 'RefCheckerInner:refCallback render', + 'RefCheckerInner:refObject create layout ref? false', + 'RefCheckerInner:refCallback create layout ref? false', + 'RefCheckerOuter refCallback value? true', + 'RefCheckerOuter create layout refObject? true refCallback? true', + ]); + expect(ReactNoop.getChildren()).toEqual([]); + + // Suspend the inner Suspense subtree (only inner effects should be destroyed) + ReactNoop.act(() => { + ReactNoop.render( + } />, + ); + }); + await advanceTimers(1000); + expect(Scheduler).toHaveYielded([ + 'App render', + 'Suspend:Async', + 'RefCheckerOuter render', + 'FunctionComponent render', + 'RefCheckerInner:refObject render', + 'FunctionComponent render', + 'RefCheckerInner:refCallback render', + 'Text:Fallback render', + 'RefCheckerOuter destroy layout refObject? true refCallback? true', + 'RefCheckerInner:refObject destroy layout ref? false', + 'RefCheckerOuter refCallback value? false', + 'RefCheckerInner:refCallback destroy layout ref? false', + 'Text:Fallback create layout', + ]); + expect(ReactNoop.getChildren()).toEqual([span('Fallback')]); + + // Resolving the suspended resource should re-create inner layout effects. + await ReactNoop.act(async () => { + await resolveText('Async'); + }); + expect(Scheduler).toHaveYielded([ + 'Text:Fallback create passive', + 'AsyncText:Async render', + 'RefCheckerOuter render', + 'FunctionComponent render', + 'RefCheckerInner:refObject render', + 'FunctionComponent render', + 'RefCheckerInner:refCallback render', + 'Text:Fallback destroy layout', + 'AsyncText:Async create layout', + 'RefCheckerInner:refObject create layout ref? false', + 'RefCheckerInner:refCallback create layout ref? false', + 'RefCheckerOuter refCallback value? true', + 'RefCheckerOuter create layout refObject? true refCallback? true', + 'Text:Fallback destroy passive', + 'AsyncText:Async create passive', + ]); + expect(ReactNoop.getChildren()).toEqual([span('Async')]); + + await ReactNoop.act(async () => { + ReactNoop.render(null); + }); + expect(Scheduler).toHaveYielded([ + 'AsyncText:Async destroy layout', + 'RefCheckerOuter destroy layout refObject? true refCallback? true', + 'RefCheckerInner:refObject destroy layout ref? false', + 'RefCheckerOuter refCallback value? false', + 'RefCheckerInner:refCallback destroy layout ref? false', + 'AsyncText:Async destroy passive', + ]); + expect(ReactNoop.getChildren()).toEqual([]); + }); + + // @gate enableSuspenseLayoutEffectSemantics + // @gate enableCache + it('should not reset for user-managed values', async () => { + function RefChecker({forwardedRef}) { + Scheduler.unstable_yieldValue(`RefChecker render`); + React.useLayoutEffect(() => { + Scheduler.unstable_yieldValue( + `RefChecker create layout ref? ${forwardedRef.current === 'test'}`, + ); + return () => { + Scheduler.unstable_yieldValue( + `RefChecker destroy layout ref? ${forwardedRef.current === + 'test'}`, + ); + }; + }, []); + return null; + } + + function App({children = null}) { + const ref = React.useRef('test'); + Scheduler.unstable_yieldValue(`App render`); + React.useLayoutEffect(() => { + Scheduler.unstable_yieldValue( + `App create layout ref? ${ref.current === 'test'}`, + ); + return () => { + Scheduler.unstable_yieldValue( + `App destroy layout ref? ${ref.current === 'test'}`, + ); + }; + }, []); + return ( + }> + {children} + + + ); + } + + // Mount + await ReactNoop.act(async () => { + ReactNoop.render(); + }); + expect(Scheduler).toHaveYielded([ + 'App render', + 'RefChecker render', + 'RefChecker create layout ref? true', + 'App create layout ref? true', + ]); + expect(ReactNoop.getChildren()).toEqual([]); + + // Suspend the inner Suspense subtree (only inner effects should be destroyed) + ReactNoop.act(() => { + ReactNoop.render( + } />, + ); + }); + await advanceTimers(1000); + expect(Scheduler).toHaveYielded([ + 'App render', + 'Suspend:Async', + 'RefChecker render', + 'Text:Fallback render', + 'RefChecker destroy layout ref? true', + 'Text:Fallback create layout', + ]); + expect(ReactNoop.getChildren()).toEqual([span('Fallback')]); + + // Resolving the suspended resource should re-create inner layout effects. + await ReactNoop.act(async () => { + await resolveText('Async'); + }); + expect(Scheduler).toHaveYielded([ + 'Text:Fallback create passive', + 'AsyncText:Async render', + 'RefChecker render', + 'Text:Fallback destroy layout', + 'AsyncText:Async create layout', + 'RefChecker create layout ref? true', + 'Text:Fallback destroy passive', + 'AsyncText:Async create passive', + ]); + expect(ReactNoop.getChildren()).toEqual([span('Async')]); + + await ReactNoop.act(async () => { + ReactNoop.render(null); + }); + expect(Scheduler).toHaveYielded([ + 'App destroy layout ref? true', + 'AsyncText:Async destroy layout', + 'RefChecker destroy layout ref? true', + 'AsyncText:Async destroy passive', + ]); + expect(ReactNoop.getChildren()).toEqual([]); + }); + + describe('that throw errors', () => { + // @gate enableSuspenseLayoutEffectSemantics + // @gate enableCache + // @gate replayFailedUnitOfWorkWithInvokeGuardedCallback + it('are properly handled in ref callbacks', async () => { + let useRefCallbackShouldThrow = false; + + function ThrowsInRefCallback() { + Scheduler.unstable_yieldValue('ThrowsInRefCallback render'); + const refCallback = React.useCallback(value => { + Scheduler.unstable_yieldValue( + 'ThrowsInRefCallback refCallback ref? ' + !!value, + ); + if (useRefCallbackShouldThrow) { + throw Error('expected'); + } + }, []); + return ; + } + + function App({children = null}) { + Scheduler.unstable_yieldValue('App render'); + React.useLayoutEffect(() => { + Scheduler.unstable_yieldValue('App create layout'); + return () => { + Scheduler.unstable_yieldValue('App destroy layout'); + }; + }, []); + return ( + <> + }> + {children} + + + + + + ); + } + + await ReactNoop.act(async () => { + ReactNoop.render( + }> + + , + ); + }); + expect(Scheduler).toHaveYielded([ + 'ErrorBoundary render: try', + 'App render', + 'ThrowsInRefCallback render', + 'Text:Inside render', + 'Text:Outside render', + 'ThrowsInRefCallback refCallback ref? true', + 'Text:Inside create layout', + 'Text:Outside create layout', + 'App create layout', + 'Text:Inside create passive', + 'Text:Outside create passive', + ]); + expect(ReactNoop.getChildren()).toEqual([ + span('ThrowsInRefCallback'), + span('Inside'), + span('Outside'), + ]); + + // Schedule an update that causes React to suspend. + await ReactNoop.act(async () => { + ReactNoop.render( + }> + + + + , + ); + }); + expect(Scheduler).toHaveYielded([ + 'ErrorBoundary render: try', + 'App render', + 'Suspend:Async', + 'ThrowsInRefCallback render', + 'Text:Inside render', + 'Text:Fallback render', + 'Text:Outside render', + 'ThrowsInRefCallback refCallback ref? false', + 'Text:Inside destroy layout', + 'Text:Fallback create layout', + 'Text:Fallback create passive', + ]); + expect(ReactNoop.getChildren()).toEqual([ + spanHidden('ThrowsInRefCallback'), + spanHidden('Inside'), + span('Fallback'), + span('Outside'), + ]); + + // Resolve the pending suspense and throw + useRefCallbackShouldThrow = true; + await ReactNoop.act(async () => { + await resolveText('Async'); + }); + expect(Scheduler).toHaveYielded([ + 'AsyncText:Async render', + 'ThrowsInRefCallback render', + 'Text:Inside render', + + // Even though an error was thrown in refCallback, + // subsequent layout effects should still be created. + 'Text:Fallback destroy layout', + 'AsyncText:Async create layout', + 'ThrowsInRefCallback refCallback ref? true', + 'Text:Inside create layout', + + // Finish the in-progress commit + 'Text:Fallback destroy passive', + 'AsyncText:Async create passive', + + // Destroy layout and passive effects in the errored tree. + 'App destroy layout', + 'AsyncText:Async destroy layout', + 'ThrowsInRefCallback refCallback ref? false', + 'Text:Inside destroy layout', + 'Text:Outside destroy layout', + 'AsyncText:Async destroy passive', + 'Text:Inside destroy passive', + 'Text:Outside destroy passive', + + // Render fallback + 'ErrorBoundary render: catch', + 'Text:Error render', + 'Text:Error create layout', + 'Text:Error create passive', + ]); + expect(ReactNoop.getChildren()).toEqual([span('Error')]); + }); + }); + }); +}); diff --git a/packages/react-reconciler/src/__tests__/ReactSuspenseFuzz-test.internal.js b/packages/react-reconciler/src/__tests__/ReactSuspenseFuzz-test.internal.js index c67edf8a15632..a0a0d08a2c42b 100644 --- a/packages/react-reconciler/src/__tests__/ReactSuspenseFuzz-test.internal.js +++ b/packages/react-reconciler/src/__tests__/ReactSuspenseFuzz-test.internal.js @@ -100,7 +100,7 @@ describe('ReactSuspenseFuzz', () => { } }, [updates]); - const fullText = `${text}:${step}`; + const fullText = `[${text}:${step}]`; const shouldSuspend = useContext(ShouldSuspendContext); @@ -163,19 +163,26 @@ describe('ReactSuspenseFuzz', () => { resolveAllTasks(); const expectedOutput = expectedRoot.getChildrenAsJSX(); - resetCache(); - ReactNoop.renderLegacySyncRoot(children); - resolveAllTasks(); - const legacyOutput = ReactNoop.getChildrenAsJSX(); - expect(legacyOutput).toEqual(expectedOutput); - ReactNoop.renderLegacySyncRoot(null); - - resetCache(); - const concurrentRoot = ReactNoop.createRoot(); - concurrentRoot.render(children); - resolveAllTasks(); - const concurrentOutput = concurrentRoot.getChildrenAsJSX(); - expect(concurrentOutput).toEqual(expectedOutput); + gate(flags => { + resetCache(); + ReactNoop.renderLegacySyncRoot(children); + resolveAllTasks(); + const legacyOutput = ReactNoop.getChildrenAsJSX(); + expect(legacyOutput).toEqual(expectedOutput); + ReactNoop.renderLegacySyncRoot(null); + + // Observable behavior differs here in a way that's expected: + // If enableSuspenseLayoutEffectSemantics is enabled, layout effects are destroyed on re-suspend + // before larger 'beginAfter' timers have a chance to fire. + if (!flags.enableSuspenseLayoutEffectSemantics) { + resetCache(); + const concurrentRoot = ReactNoop.createRoot(); + concurrentRoot.render(children); + resolveAllTasks(); + const concurrentOutput = concurrentRoot.getChildrenAsJSX(); + expect(concurrentOutput).toEqual(expectedOutput); + } + }); } function pickRandomWeighted(rand, options) { @@ -410,5 +417,32 @@ Random seed is ${SEED} , ); }); + + it('4', () => { + const {Text, testResolvedOutput} = createFuzzer(); + testResolvedOutput( + + + + + + + + , + ); + }); }); }); diff --git a/packages/react-reconciler/src/__tests__/ReactSuspenseWithNoopRenderer-test.js b/packages/react-reconciler/src/__tests__/ReactSuspenseWithNoopRenderer-test.js index 692b745f97085..9d6056e25d3db 100644 --- a/packages/react-reconciler/src/__tests__/ReactSuspenseWithNoopRenderer-test.js +++ b/packages/react-reconciler/src/__tests__/ReactSuspenseWithNoopRenderer-test.js @@ -468,19 +468,30 @@ describe('ReactSuspenseWithNoopRenderer', () => { await rejectText('Result', new Error('Failed to load: Result')); - expect(Scheduler).toFlushAndYield([ - 'Error! [Result]', + gate(flags => { + if (flags.enableSuspenseLayoutEffectSemantics) { + expect(Scheduler).toFlushAndYield([ + 'Error! [Result]', - // React retries one more time - 'Error! [Result]', + // React retries one more time + 'Error! [Result]', + ]); + expect(ReactNoop.getChildren()).toEqual([]); + } else { + expect(Scheduler).toFlushAndYield([ + 'Error! [Result]', - // Errored again on retry. Now handle it. + // React retries one more time + 'Error! [Result]', - 'Caught error: Failed to load: Result', - ]); - expect(ReactNoop.getChildren()).toEqual([ - span('Caught error: Failed to load: Result'), - ]); + // Errored again on retry. Now handle it. + 'Caught error: Failed to load: Result', + ]); + expect(ReactNoop.getChildren()).toEqual([ + span('Caught error: Failed to load: Result'), + ]); + } + }); }); // @gate enableCache diff --git a/packages/shared/ReactFeatureFlags.js b/packages/shared/ReactFeatureFlags.js index dc77e5482abbc..538a64e193dc8 100644 --- a/packages/shared/ReactFeatureFlags.js +++ b/packages/shared/ReactFeatureFlags.js @@ -125,6 +125,11 @@ export const skipUnmountedBoundaries = false; // aggressiveness. export const deletedTreeCleanUpLevel = 1; +// Destroy layout effects for components that are hidden because something suspended in an update +// and recreate them when they are shown again (after the suspended boundary has resolved). +// Note that this should be an uncommon use case and can be avoided by using the transition API. +export const enableSuspenseLayoutEffectSemantics = false; + // -------------------------- // Future APIs to be deprecated // -------------------------- diff --git a/packages/shared/forks/ReactFeatureFlags.native-fb.js b/packages/shared/forks/ReactFeatureFlags.native-fb.js index 34d521a50834c..0b1659d1625b9 100644 --- a/packages/shared/forks/ReactFeatureFlags.native-fb.js +++ b/packages/shared/forks/ReactFeatureFlags.native-fb.js @@ -47,6 +47,7 @@ export const enableFilterEmptyStringAttributesDOM = false; export const disableNativeComponentFrames = false; export const skipUnmountedBoundaries = false; export const deletedTreeCleanUpLevel = 1; +export const enableSuspenseLayoutEffectSemantics = false; export const enableNewReconciler = false; export const deferRenderPhaseUpdateToNextBatch = true; diff --git a/packages/shared/forks/ReactFeatureFlags.native-oss.js b/packages/shared/forks/ReactFeatureFlags.native-oss.js index c7d5331108375..80e113c8e5bfd 100644 --- a/packages/shared/forks/ReactFeatureFlags.native-oss.js +++ b/packages/shared/forks/ReactFeatureFlags.native-oss.js @@ -46,6 +46,7 @@ export const enableFilterEmptyStringAttributesDOM = false; export const disableNativeComponentFrames = false; export const skipUnmountedBoundaries = false; export const deletedTreeCleanUpLevel = 1; +export const enableSuspenseLayoutEffectSemantics = false; export const enableNewReconciler = false; export const deferRenderPhaseUpdateToNextBatch = true; diff --git a/packages/shared/forks/ReactFeatureFlags.test-renderer.js b/packages/shared/forks/ReactFeatureFlags.test-renderer.js index 100ddb697dbb4..f5440f7a25f2e 100644 --- a/packages/shared/forks/ReactFeatureFlags.test-renderer.js +++ b/packages/shared/forks/ReactFeatureFlags.test-renderer.js @@ -46,6 +46,7 @@ export const enableFilterEmptyStringAttributesDOM = false; export const disableNativeComponentFrames = false; export const skipUnmountedBoundaries = false; export const deletedTreeCleanUpLevel = 1; +export const enableSuspenseLayoutEffectSemantics = false; export const enableNewReconciler = false; export const deferRenderPhaseUpdateToNextBatch = true; diff --git a/packages/shared/forks/ReactFeatureFlags.test-renderer.native.js b/packages/shared/forks/ReactFeatureFlags.test-renderer.native.js index ceea27b19c59c..8f31c4e7f84bb 100644 --- a/packages/shared/forks/ReactFeatureFlags.test-renderer.native.js +++ b/packages/shared/forks/ReactFeatureFlags.test-renderer.native.js @@ -46,6 +46,7 @@ export const enableFilterEmptyStringAttributesDOM = false; export const disableNativeComponentFrames = false; export const skipUnmountedBoundaries = false; export const deletedTreeCleanUpLevel = 1; +export const enableSuspenseLayoutEffectSemantics = false; export const enableNewReconciler = false; export const deferRenderPhaseUpdateToNextBatch = true; diff --git a/packages/shared/forks/ReactFeatureFlags.test-renderer.www.js b/packages/shared/forks/ReactFeatureFlags.test-renderer.www.js index 61ec5b6564dcf..2e04322a32ced 100644 --- a/packages/shared/forks/ReactFeatureFlags.test-renderer.www.js +++ b/packages/shared/forks/ReactFeatureFlags.test-renderer.www.js @@ -46,6 +46,7 @@ export const enableFilterEmptyStringAttributesDOM = false; export const disableNativeComponentFrames = false; export const skipUnmountedBoundaries = false; export const deletedTreeCleanUpLevel = 1; +export const enableSuspenseLayoutEffectSemantics = false; export const enableNewReconciler = false; export const deferRenderPhaseUpdateToNextBatch = true; diff --git a/packages/shared/forks/ReactFeatureFlags.testing.js b/packages/shared/forks/ReactFeatureFlags.testing.js index 26b2b9a279219..bb1c40537287b 100644 --- a/packages/shared/forks/ReactFeatureFlags.testing.js +++ b/packages/shared/forks/ReactFeatureFlags.testing.js @@ -46,6 +46,7 @@ export const enableFilterEmptyStringAttributesDOM = false; export const disableNativeComponentFrames = false; export const skipUnmountedBoundaries = false; export const deletedTreeCleanUpLevel = 1; +export const enableSuspenseLayoutEffectSemantics = false; export const enableNewReconciler = false; export const deferRenderPhaseUpdateToNextBatch = true; diff --git a/packages/shared/forks/ReactFeatureFlags.testing.www.js b/packages/shared/forks/ReactFeatureFlags.testing.www.js index 12da19a71c6bc..7a12f6041d934 100644 --- a/packages/shared/forks/ReactFeatureFlags.testing.www.js +++ b/packages/shared/forks/ReactFeatureFlags.testing.www.js @@ -46,6 +46,7 @@ export const enableFilterEmptyStringAttributesDOM = false; export const disableNativeComponentFrames = false; export const skipUnmountedBoundaries = true; export const deletedTreeCleanUpLevel = 1; +export const enableSuspenseLayoutEffectSemantics = false; export const enableNewReconciler = false; export const deferRenderPhaseUpdateToNextBatch = true; diff --git a/packages/shared/forks/ReactFeatureFlags.www-dynamic.js b/packages/shared/forks/ReactFeatureFlags.www-dynamic.js index 503d263fc9be6..01937c7b125b7 100644 --- a/packages/shared/forks/ReactFeatureFlags.www-dynamic.js +++ b/packages/shared/forks/ReactFeatureFlags.www-dynamic.js @@ -18,6 +18,7 @@ export const disableInputAttributeSyncing = __VARIANT__; export const enableFilterEmptyStringAttributesDOM = __VARIANT__; export const enableLegacyFBSupport = __VARIANT__; export const skipUnmountedBoundaries = __VARIANT__; +export const enableSuspenseLayoutEffectSemantics = __VARIANT__; // Enable this flag to help with concurrent mode debugging. // It logs information to the console about React scheduling, rendering, and commit phases. diff --git a/packages/shared/forks/ReactFeatureFlags.www.js b/packages/shared/forks/ReactFeatureFlags.www.js index f145335353940..1e8908ea4cde5 100644 --- a/packages/shared/forks/ReactFeatureFlags.www.js +++ b/packages/shared/forks/ReactFeatureFlags.www.js @@ -28,6 +28,7 @@ export const { skipUnmountedBoundaries, enableStrictEffects, createRootStrictEffectsByDefault, + enableSuspenseLayoutEffectSemantics, enableUseRefAccessWarning, disableNativeComponentFrames, disableSchedulerTimeoutInWorkLoop,