From 7c1ba2b57d37df165da0f19c65ccc174866b5af7 Mon Sep 17 00:00:00 2001 From: Brian Vaughn Date: Tue, 6 Apr 2021 09:21:02 -0400 Subject: [PATCH] Proposed new Suspense layout effect semantics (#21079) MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit This commit contains a proposed change to layout effect semantics within Suspense subtrees: If a component mounts within a Suspense boundary and is later hidden (because of something else suspending) React will cleanup that component’s layout effects (including React-managed refs). This change will hopefully fix existing bugs that occur because of things like reading layout in a hidden tree and will also enable a point at which to e.g. pause videos and hide user-managed portals. After the suspended boundary resolves, React will setup the component’s layout effects again (including React-managed refs). The scenario described above is not common. The useTransition API should ensure that Suspense does not revert to its fallback state after being mounted. Note that these changes are primarily written in terms of the (as of yet internal) Offscreen API as we intend to provide similar effects semantics within recently shown/hidden Offscreen trees in the future. (More to follow.) (Note that all changes in this PR are behind a new feature flag, enableSuspenseLayoutEffectSemantics, which is disabled for now.) --- .../src/ReactFiberBeginWork.new.js | 5 + .../src/ReactFiberBeginWork.old.js | 5 + .../src/ReactFiberClassComponent.new.js | 45 +- .../src/ReactFiberClassComponent.old.js | 45 +- .../src/ReactFiberCommitWork.new.js | 259 +- .../src/ReactFiberCommitWork.old.js | 259 +- .../src/ReactFiberCompleteWork.new.js | 5 + .../src/ReactFiberCompleteWork.old.js | 5 + .../react-reconciler/src/ReactFiberFlags.js | 50 +- .../src/ReactFiberHooks.new.js | 52 +- .../src/ReactFiberHooks.old.js | 52 +- .../src/__tests__/ReactLazy-test.internal.js | 32 +- .../ReactSuspenseEffectsSemantics-test.js | 3097 +++++++++++++++++ .../ReactSuspenseFuzz-test.internal.js | 62 +- .../ReactSuspenseWithNoopRenderer-test.js | 31 +- packages/shared/ReactFeatureFlags.js | 5 + .../forks/ReactFeatureFlags.native-fb.js | 1 + .../forks/ReactFeatureFlags.native-oss.js | 1 + .../forks/ReactFeatureFlags.test-renderer.js | 1 + .../ReactFeatureFlags.test-renderer.native.js | 1 + .../ReactFeatureFlags.test-renderer.www.js | 1 + .../shared/forks/ReactFeatureFlags.testing.js | 1 + .../forks/ReactFeatureFlags.testing.www.js | 1 + .../forks/ReactFeatureFlags.www-dynamic.js | 1 + .../shared/forks/ReactFeatureFlags.www.js | 1 + 25 files changed, 3878 insertions(+), 140 deletions(-) create mode 100644 packages/react-reconciler/src/__tests__/ReactSuspenseEffectsSemantics-test.js diff --git a/packages/react-reconciler/src/ReactFiberBeginWork.new.js b/packages/react-reconciler/src/ReactFiberBeginWork.new.js index 2f4bfe98fb0f..e707ddf69dfb 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 9ca4e4afad44..10771e018fdb 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 ea1b4cb5e3c8..d64527d58ab6 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 b952c24cd041..f9b4128b72f7 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 e9845e1e65dc..48150f021a32 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 6ed8560bd764..ca3dbe363f07 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 0924738f1e95..d27498638ce6 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 017db23b66e4..da962bfdefee 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 5906af7f492d..b711d730c3b2 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 cbb755540e28..d5116d1579ec 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 3c7e87d02dc6..77d82bf13c86 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 b7f1570823c1..b9e6b0c26806 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 000000000000..1dbc4404040f --- /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 c67edf8a1563..a0a0d08a2c42 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 692b745f9708..9d6056e25d3d 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 dc77e5482abb..538a64e193dc 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 34d521a50834..0b1659d1625b 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 c7d533110837..80e113c8e5bf 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 100ddb697dbb..f5440f7a25f2 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 ceea27b19c59..8f31c4e7f84b 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 61ec5b6564dc..2e04322a32ce 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 26b2b9a27921..bb1c40537287 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 12da19a71c6b..7a12f6041d93 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 503d263fc9be..01937c7b125b 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 f14533535394..1e8908ea4cde 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,