@samitouri / QOS-React-2 / commits / 4ea064eb09

Don't fire passive effects during initial mount of a hidden Offscreen tree (#24967)

* Change OffscreenInstance isHidden to bitmask The isHidden field of OffscreenInstance is a boolean that represents whether the tree is currently hidden. To implement resuable effects, we need to also track whether the passive effects are currently connected. So I've changed this field to a bitmask. No other behavior has changed in this commit. I'll update the effects behavior in the following steps. * Extract passive mount effects to separate functions I'm about to add a "reappear passive effects" function that will share much of the same code as commitPassiveMountEffectOnFiber. To minimize the duplicated code, I've extracted the shared parts into separate functions, similar to what I did for commitLayoutEffectOnFiber and reappearLayoutEffects. This may not save much on code size because Closure will likely inline some of it, anyway, but it makes it harder for the two paths to accidentally diverge. * Don't mount passive effects in a new hidden tree This changes the behavior of Offscreen so that passive effects do not fire when prerendering a brand new tree. Previously, Offscreen did not affect passive effects at all — only layout effects, which mount or unmount whenever the visibility of the tree changes. When hiding an already visible tree, the behavior of passive effects is unchanged, for now; unlike layout effects, the passive effects will not get unmounted. Pre-rendered updates to a hidden tree in this state will also fire normally. This is only temporary, though — the plan is for passive effects to act more like layout effects, and unmount them when the tree is hidden. Perhaps after a delay so that if the visibility toggles quickly back and forth, the effects don't need to remount. I'll implement this separately. * "Atomic" passive commit effects must always fire There are a few cases where commit phase logic always needs to fire even inside a hidden tree. In general, we should try to design algorithms that don't depend on a commit effect running during prerendering, but there's at least one case where I think it makes sense. The experimental Cache component uses reference counting to keep track of the lifetime of a cache instance. This allows us to expose an AbortSignal object that data frameworks can use to cancel aborted requests. These cache objects are considered alive even inside a prerendered tree. To implement this I added an "atomic" passive effect traversal that runs even when a tree is hidden. (As a follow up, we should add a special subtree flag so that we can skip over nodes that don't have them. There are a number of similar subtree flag optimizations that we have planned, so I'll leave them for a later refactor.) The only other feature that currently depends on this behavior is Transition Tracing. I did not add a test for this because Transition Tracing is still in development and doesn't yet work with Offscreen.

Andrew Clark committed Jul 29, 2022 at 19:22 UTC 4ea064eb0915b355b584bff376e90dbae0e8b169
10 files changed +1250 -322
packages/react-reconciler/src/ReactFiber.new.js
+3 -2
@@ -61,6 +61,7 @@ import {
61 CacheComponent,
62 TracingMarkerComponent,
63 } from './ReactWorkTags';
64 +import {OffscreenVisible} from './ReactFiberOffscreenComponent';
65 import getComponentNameFromFiber from 'react-reconciler/src/getComponentNameFromFiber';
66
67 import {isDevToolsPresent} from './ReactFiberDevToolsHook.new';
@@ -717,7 +718,7 @@ export function createFiberFromOffscreen(
718 fiber.elementType = REACT_OFFSCREEN_TYPE;
719 fiber.lanes = lanes;
720 const primaryChildInstance: OffscreenInstance = {
720 - isHidden: false,
721 + visibility: OffscreenVisible,
722 pendingMarkers: null,
723 retryCache: null,
724 transitions: null,
@@ -738,7 +739,7 @@ export function createFiberFromLegacyHidden(
739 // Adding a stateNode for legacy hidden because it's currently using
740 // the offscreen implementation, which depends on a state node
741 const instance: OffscreenInstance = {
741 - isHidden: false,
742 + visibility: OffscreenVisible,
743 pendingMarkers: null,
744 transitions: null,
745 retryCache: null,
packages/react-reconciler/src/ReactFiber.old.js
+3 -2
@@ -61,6 +61,7 @@ import {
61 CacheComponent,
62 TracingMarkerComponent,
63 } from './ReactWorkTags';
64 +import {OffscreenVisible} from './ReactFiberOffscreenComponent';
65 import getComponentNameFromFiber from 'react-reconciler/src/getComponentNameFromFiber';
66
67 import {isDevToolsPresent} from './ReactFiberDevToolsHook.old';
@@ -717,7 +718,7 @@ export function createFiberFromOffscreen(
718 fiber.elementType = REACT_OFFSCREEN_TYPE;
719 fiber.lanes = lanes;
720 const primaryChildInstance: OffscreenInstance = {
720 - isHidden: false,
721 + visibility: OffscreenVisible,
722 pendingMarkers: null,
723 retryCache: null,
724 transitions: null,
@@ -738,7 +739,7 @@ export function createFiberFromLegacyHidden(
739 // Adding a stateNode for legacy hidden because it's currently using
740 // the offscreen implementation, which depends on a state node
741 const instance: OffscreenInstance = {
741 - isHidden: false,
742 + visibility: OffscreenVisible,
743 pendingMarkers: null,
744 transitions: null,
745 retryCache: null,
packages/react-reconciler/src/ReactFiberCommitWork.new.js
+519 -145
@@ -173,6 +173,10 @@ import {
173 } from './ReactFiberDevToolsHook.new';
174 import {releaseCache, retainCache} from './ReactFiberCacheComponent.new';
175 import {clearTransitionsForLanes} from './ReactFiberLane.new';
176 +import {
177 + OffscreenVisible,
178 + OffscreenPassiveEffectsConnected,
179 +} from './ReactFiberOffscreenComponent';
180
181 let didWarnAboutUndefinedSnapshotBeforeUpdate: Set<mixed> | null = null;
182 if (__DEV__) {
@@ -2424,14 +2428,8 @@ function commitMutationEffectsOnFiber(
2428 const offscreenFiber: Fiber = (finishedWork.child: any);
2429
2430 if (offscreenFiber.flags & Visibility) {
2427 - const offscreenInstance: OffscreenInstance = offscreenFiber.stateNode;
2431 const newState: OffscreenState | null = offscreenFiber.memoizedState;
2432 const isHidden = newState !== null;
2430 -
2431 - // Track the current state on the Offscreen instance so we can
2432 - // read it during an event
2433 - offscreenInstance.isHidden = isHidden;
2434 -
2433 if (isHidden) {
2434 const wasHidden =
2435 offscreenFiber.alternate !== null &&
@@ -2485,7 +2483,11 @@ function commitMutationEffectsOnFiber(
2483
2484 // Track the current state on the Offscreen instance so we can
2485 // read it during an event
2488 - offscreenInstance.isHidden = isHidden;
2486 + if (isHidden) {
2487 + offscreenInstance.visibility &= ~OffscreenVisible;
2488 + } else {
2489 + offscreenInstance.visibility |= OffscreenVisible;
2490 + }
2491
2492 if (isHidden) {
2493 if (!wasHidden) {
@@ -2871,6 +2873,167 @@ function recursivelyTraverseReappearLayoutEffects(
2873 setCurrentDebugFiberInDEV(prevDebugFiber);
2874 }
2875
2876 +function commitHookPassiveMountEffects(
2877 + finishedWork: Fiber,
2878 + hookFlags: HookFlags,
2879 +) {
2880 + if (
2881 + enableProfilerTimer &&
2882 + enableProfilerCommitHooks &&
2883 + finishedWork.mode & ProfileMode
2884 + ) {
2885 + startPassiveEffectTimer();
2886 + try {
2887 + commitHookEffectListMount(hookFlags, finishedWork);
2888 + } catch (error) {
2889 + captureCommitPhaseError(finishedWork, finishedWork.return, error);
2890 + }
2891 + recordPassiveEffectDuration(finishedWork);
2892 + } else {
2893 + try {
2894 + commitHookEffectListMount(hookFlags, finishedWork);
2895 + } catch (error) {
2896 + captureCommitPhaseError(finishedWork, finishedWork.return, error);
2897 + }
2898 + }
2899 +}
2900 +
2901 +function commitOffscreenPassiveMountEffects(
2902 + current: Fiber | null,
2903 + finishedWork: Fiber,
2904 + instance: OffscreenInstance,
2905 +) {
2906 + if (enableCache) {
2907 + let previousCache: Cache | null = null;
2908 + if (
2909 + current !== null &&
2910 + current.memoizedState !== null &&
2911 + current.memoizedState.cachePool !== null
2912 + ) {
2913 + previousCache = current.memoizedState.cachePool.pool;
2914 + }
2915 + let nextCache: Cache | null = null;
2916 + if (
2917 + finishedWork.memoizedState !== null &&
2918 + finishedWork.memoizedState.cachePool !== null
2919 + ) {
2920 + nextCache = finishedWork.memoizedState.cachePool.pool;
2921 + }
2922 + // Retain/release the cache used for pending (suspended) nodes.
2923 + // Note that this is only reached in the non-suspended/visible case:
2924 + // when the content is suspended/hidden, the retain/release occurs
2925 + // via the parent Suspense component (see case above).
2926 + if (nextCache !== previousCache) {
2927 + if (nextCache != null) {
2928 + retainCache(nextCache);
2929 + }
2930 + if (previousCache != null) {
2931 + releaseCache(previousCache);
2932 + }
2933 + }
2934 + }
2935 +
2936 + if (enableTransitionTracing) {
2937 + // TODO: Pre-rendering should not be counted as part of a transition. We
2938 + // may add separate logs for pre-rendering, but it's not part of the
2939 + // primary metrics.
2940 + const offscreenState: OffscreenState = finishedWork.memoizedState;
2941 + const queue: OffscreenQueue | null = (finishedWork.updateQueue: any);
2942 +
2943 + const isHidden = offscreenState !== null;
2944 + if (queue !== null) {
2945 + if (isHidden) {
2946 + const transitions = queue.transitions;
2947 + if (transitions !== null) {
2948 + transitions.forEach(transition => {
2949 + // Add all the transitions saved in the update queue during
2950 + // the render phase (ie the transitions associated with this boundary)
2951 + // into the transitions set.
2952 + if (instance.transitions === null) {
2953 + instance.transitions = new Set();
2954 + }
2955 + instance.transitions.add(transition);
2956 + });
2957 + }
2958 +
2959 + const markerInstances = queue.markerInstances;
2960 + if (markerInstances !== null) {
2961 + markerInstances.forEach(markerInstance => {
2962 + const markerTransitions = markerInstance.transitions;
2963 + // There should only be a few tracing marker transitions because
2964 + // they should be only associated with the transition that
2965 + // caused them
2966 + if (markerTransitions !== null) {
2967 + markerTransitions.forEach(transition => {
2968 + if (instance.transitions === null) {
2969 + instance.transitions = new Set();
2970 + } else if (instance.transitions.has(transition)) {
2971 + if (markerInstance.pendingBoundaries === null) {
2972 + markerInstance.pendingBoundaries = new Map();
2973 + }
2974 + if (instance.pendingMarkers === null) {
2975 + instance.pendingMarkers = new Set();
2976 + }
2977 +
2978 + instance.pendingMarkers.add(markerInstance);
2979 + }
2980 + });
2981 + }
2982 + });
2983 + }
2984 + }
2985 +
2986 + finishedWork.updateQueue = null;
2987 + }
2988 +
2989 + commitTransitionProgress(finishedWork);
2990 + }
2991 +}
2992 +
2993 +function commitCachePassiveMountEffect(
2994 + current: Fiber | null,
2995 + finishedWork: Fiber,
2996 +) {
2997 + if (enableCache) {
2998 + let previousCache: Cache | null = null;
2999 + if (finishedWork.alternate !== null) {
3000 + previousCache = finishedWork.alternate.memoizedState.cache;
3001 + }
3002 + const nextCache = finishedWork.memoizedState.cache;
3003 + // Retain/release the cache. In theory the cache component
3004 + // could be "borrowing" a cache instance owned by some parent,
3005 + // in which case we could avoid retaining/releasing. But it
3006 + // is non-trivial to determine when that is the case, so we
3007 + // always retain/release.
3008 + if (nextCache !== previousCache) {
3009 + retainCache(nextCache);
3010 + if (previousCache != null) {
3011 + releaseCache(previousCache);
3012 + }
3013 + }
3014 + }
3015 +}
3016 +
3017 +function commitTracingMarkerPassiveMountEffect(finishedWork: Fiber) {
3018 + // Get the transitions that were initiatized during the render
3019 + // and add a start transition callback for each of them
3020 + const instance = finishedWork.stateNode;
3021 + if (
3022 + instance.transitions !== null &&
3023 + (instance.pendingBoundaries === null ||
3024 + instance.pendingBoundaries.size === 0)
3025 + ) {
3026 + instance.transitions.forEach(transition => {
3027 + addMarkerCompleteCallbackToPendingTransition(
3028 + finishedWork.memoizedProps.name,
3029 + instance.transitions,
3030 + );
3031 + });
3032 + instance.transitions = null;
3033 + instance.pendingBoundaries = null;
3034 + }
3035 +}
3036 +
3037 export function commitPassiveMountEffects(
3038 root: FiberRoot,
3039 finishedWork: Fiber,
@@ -2916,6 +3079,9 @@ function commitPassiveMountOnFiber(
3079 committedLanes: Lanes,
3080 committedTransitions: Array<Transition> | null,
3081 ): void {
3082 + // When updating this function, also update reconnectPassiveEffects, which does
3083 + // most of the same things when an offscreen tree goes from hidden -> visible,
3084 + // or when toggling effects inside a hidden tree.
3085 const flags = finishedWork.flags;
3086 switch (finishedWork.tag) {
3087 case FunctionComponent:
@@ -2928,31 +3094,10 @@ function commitPassiveMountOnFiber(
3094 committedTransitions,
3095 );
3096 if (flags & Passive) {
2931 - if (
2932 - enableProfilerTimer &&
2933 - enableProfilerCommitHooks &&
2934 - finishedWork.mode & ProfileMode
2935 - ) {
2936 - startPassiveEffectTimer();
2937 - try {
2938 - commitHookEffectListMount(
2939 - HookPassive | HookHasEffect,
2940 - finishedWork,
2941 - );
2942 - } catch (error) {
2943 - captureCommitPhaseError(finishedWork, finishedWork.return, error);
2944 - }
2945 - recordPassiveEffectDuration(finishedWork);
2946 - } else {
2947 - try {
2948 - commitHookEffectListMount(
2949 - HookPassive | HookHasEffect,
2950 - finishedWork,
2951 - );
2952 - } catch (error) {
2953 - captureCommitPhaseError(finishedWork, finishedWork.return, error);
2954 - }
2955 - }
3097 + commitHookPassiveMountEffects(
3098 + finishedWork,
3099 + HookPassive | HookHasEffect,
3100 + );
3101 }
3102 break;
3103 }
@@ -3013,6 +3158,82 @@ function commitPassiveMountOnFiber(
3158 }
3159 case LegacyHiddenComponent:
3160 case OffscreenComponent: {
3161 + // TODO: Pass `current` as argument to this function
3162 + const instance: OffscreenInstance = finishedWork.stateNode;
3163 + const nextState: OffscreenState | null = finishedWork.memoizedState;
3164 +
3165 + const isHidden = nextState !== null;
3166 +
3167 + if (isHidden) {
3168 + if (instance.visibility & OffscreenPassiveEffectsConnected) {
3169 + // The effects are currently connected. Update them.
3170 + recursivelyTraversePassiveMountEffects(
3171 + finishedRoot,
3172 + finishedWork,
3173 + committedLanes,
3174 + committedTransitions,
3175 + );
3176 + } else {
3177 + if (finishedWork.mode & ConcurrentMode) {
3178 + // The effects are currently disconnected. Since the tree is hidden,
3179 + // don't connect them. This also applies to the initial render.
3180 + if (enableCache || enableTransitionTracing) {
3181 + // "Atomic" effects are ones that need to fire on every commit,
3182 + // even during pre-rendering. An example is updating the reference
3183 + // count on cache instances.
3184 + recursivelyTraverseAtomicPassiveEffects(
3185 + finishedRoot,
3186 + finishedWork,
3187 + committedLanes,
3188 + committedTransitions,
3189 + );
3190 + }
3191 + } else {
3192 + // Legacy Mode: Fire the effects even if the tree is hidden.
3193 + instance.visibility |= OffscreenPassiveEffectsConnected;
3194 + recursivelyTraversePassiveMountEffects(
3195 + finishedRoot,
3196 + finishedWork,
3197 + committedLanes,
3198 + committedTransitions,
3199 + );
3200 + }
3201 + }
3202 + } else {
3203 + // Tree is visible
3204 + if (instance.visibility & OffscreenPassiveEffectsConnected) {
3205 + // The effects are currently connected. Update them.
3206 + recursivelyTraversePassiveMountEffects(
3207 + finishedRoot,
3208 + finishedWork,
3209 + committedLanes,
3210 + committedTransitions,
3211 + );
3212 + } else {
3213 + // The effects are currently disconnected. Reconnect them, while also
3214 + // firing effects inside newly mounted trees. This also applies to
3215 + // the initial render.
3216 + instance.visibility |= OffscreenPassiveEffectsConnected;
3217 +
3218 + const includeWorkInProgressEffects =
3219 + (finishedWork.subtreeFlags & PassiveMask) !== NoFlags;
3220 + recursivelyTraverseReconnectPassiveEffects(
3221 + finishedRoot,
3222 + finishedWork,
3223 + committedLanes,
3224 + committedTransitions,
3225 + includeWorkInProgressEffects,
3226 + );
3227 + }
3228 + }
3229 +
3230 + if (flags & Passive) {
3231 + const current = finishedWork.alternate;
3232 + commitOffscreenPassiveMountEffects(current, finishedWork, instance);
3233 + }
3234 + break;
3235 + }
3236 + case CacheComponent: {
3237 recursivelyTraversePassiveMountEffects(
3238 finishedRoot,
3239 finishedWork,
@@ -3020,146 +3241,294 @@ function commitPassiveMountOnFiber(
3241 committedTransitions,
3242 );
3243 if (flags & Passive) {
3023 - if (enableCache) {
3024 - let previousCache: Cache | null = null;
3025 - if (
3026 - finishedWork.alternate !== null &&
3027 - finishedWork.alternate.memoizedState !== null &&
3028 - finishedWork.alternate.memoizedState.cachePool !== null
3029 - ) {
3030 - previousCache = finishedWork.alternate.memoizedState.cachePool.pool;
3031 - }
3032 - let nextCache: Cache | null = null;
3033 - if (
3034 - finishedWork.memoizedState !== null &&
3035 - finishedWork.memoizedState.cachePool !== null
3036 - ) {
3037 - nextCache = finishedWork.memoizedState.cachePool.pool;
3038 - }
3039 - // Retain/release the cache used for pending (suspended) nodes.
3040 - // Note that this is only reached in the non-suspended/visible case:
3041 - // when the content is suspended/hidden, the retain/release occurs
3042 - // via the parent Suspense component (see case above).
3043 - if (nextCache !== previousCache) {
3044 - if (nextCache != null) {
3045 - retainCache(nextCache);
3046 - }
3047 - if (previousCache != null) {
3048 - releaseCache(previousCache);
3049 - }
3050 - }
3244 + // TODO: Pass `current` as argument to this function
3245 + const current = finishedWork.alternate;
3246 + commitCachePassiveMountEffect(current, finishedWork);
3247 + }
3248 + break;
3249 + }
3250 + case TracingMarkerComponent: {
3251 + if (enableTransitionTracing) {
3252 + recursivelyTraversePassiveMountEffects(
3253 + finishedRoot,
3254 + finishedWork,
3255 + committedLanes,
3256 + committedTransitions,
3257 + );
3258 + if (flags & Passive) {
3259 + commitTracingMarkerPassiveMountEffect(finishedWork);
3260 }
3261 + break;
3262 + }
3263 + // Intentional fallthrough to next branch
3264 + }
3265 + // eslint-disable-next-line-no-fallthrough
3266 + default: {
3267 + recursivelyTraversePassiveMountEffects(
3268 + finishedRoot,
3269 + finishedWork,
3270 + committedLanes,
3271 + committedTransitions,
3272 + );
3273 + break;
3274 + }
3275 + }
3276 +}
3277
3053 - if (enableTransitionTracing) {
3054 - const isFallback = finishedWork.memoizedState;
3055 - const queue: OffscreenQueue | null = (finishedWork.updateQueue: any);
3056 - const instance: OffscreenInstance = finishedWork.stateNode;
3057 -
3058 - if (queue !== null) {
3059 - if (isFallback) {
3060 - const transitions = queue.transitions;
3061 - if (transitions !== null) {
3062 - transitions.forEach(transition => {
3063 - // Add all the transitions saved in the update queue during
3064 - // the render phase (ie the transitions associated with this boundary)
3065 - // into the transitions set.
3066 - if (instance.transitions === null) {
3067 - instance.transitions = new Set();
3068 - }
3069 - instance.transitions.add(transition);
3070 - });
3071 - }
3278 +function recursivelyTraverseReconnectPassiveEffects(
3279 + finishedRoot: FiberRoot,
3280 + parentFiber: Fiber,
3281 + committedLanes: Lanes,
3282 + committedTransitions: Array<Transition> | null,
3283 + includeWorkInProgressEffects: boolean,
3284 +) {
3285 + // This function visits both newly finished work and nodes that were re-used
3286 + // from a previously committed tree. We cannot check non-static flags if the
3287 + // node was reused.
3288 + const childShouldIncludeWorkInProgressEffects =
3289 + includeWorkInProgressEffects &&
3290 + (parentFiber.subtreeFlags & PassiveMask) !== NoFlags;
3291
3073 - const markerInstances = queue.markerInstances;
3074 - if (markerInstances !== null) {
3075 - markerInstances.forEach(markerInstance => {
3076 - const markerTransitions = markerInstance.transitions;
3077 - // There should only be a few tracing marker transitions because
3078 - // they should be only associated with the transition that
3079 - // caused them
3080 - if (markerTransitions !== null) {
3081 - markerTransitions.forEach(transition => {
3082 - if (instance.transitions === null) {
3083 - instance.transitions = new Set();
3084 - } else if (instance.transitions.has(transition)) {
3085 - if (markerInstance.pendingBoundaries === null) {
3086 - markerInstance.pendingBoundaries = new Map();
3087 - }
3088 - if (instance.pendingMarkers === null) {
3089 - instance.pendingMarkers = new Set();
3090 - }
3091 -
3092 - instance.pendingMarkers.add(markerInstance);
3093 - }
3094 - });
3095 - }
3096 - });
3097 - }
3098 - }
3292 + // TODO (Offscreen) Check: flags & (RefStatic | LayoutStatic)
3293 + const prevDebugFiber = getCurrentDebugFiberInDEV();
3294 + let child = parentFiber.child;
3295 + while (child !== null) {
3296 + reconnectPassiveEffects(
3297 + finishedRoot,
3298 + child,
3299 + committedLanes,
3300 + committedTransitions,
3301 + childShouldIncludeWorkInProgressEffects,
3302 + );
3303 + child = child.sibling;
3304 + }
3305 + setCurrentDebugFiberInDEV(prevDebugFiber);
3306 +}
3307
3100 - finishedWork.updateQueue = null;
3308 +function reconnectPassiveEffects(
3309 + finishedRoot: FiberRoot,
3310 + finishedWork: Fiber,
3311 + committedLanes: Lanes,
3312 + committedTransitions: Array<Transition> | null,
3313 + // This function visits both newly finished work and nodes that were re-used
3314 + // from a previously committed tree. We cannot check non-static flags if the
3315 + // node was reused.
3316 + includeWorkInProgressEffects: boolean,
3317 +) {
3318 + const flags = finishedWork.flags;
3319 + switch (finishedWork.tag) {
3320 + case FunctionComponent:
3321 + case ForwardRef:
3322 + case SimpleMemoComponent: {
3323 + recursivelyTraverseReconnectPassiveEffects(
3324 + finishedRoot,
3325 + finishedWork,
3326 + committedLanes,
3327 + committedTransitions,
3328 + includeWorkInProgressEffects,
3329 + );
3330 + // TODO: Check for PassiveStatic flag
3331 + commitHookPassiveMountEffects(finishedWork, HookPassive);
3332 + break;
3333 + }
3334 + // Unlike commitPassiveMountOnFiber, we don't need to handle HostRoot
3335 + // because this function only visits nodes that are inside an
3336 + // Offscreen fiber.
3337 + // case HostRoot: {
3338 + // ...
3339 + // }
3340 + case LegacyHiddenComponent:
3341 + case OffscreenComponent: {
3342 + const instance: OffscreenInstance = finishedWork.stateNode;
3343 + const nextState: OffscreenState | null = finishedWork.memoizedState;
3344 +
3345 + const isHidden = nextState !== null;
3346 +
3347 + if (isHidden) {
3348 + if (instance.visibility & OffscreenPassiveEffectsConnected) {
3349 + // The effects are currently connected. Update them.
3350 + recursivelyTraverseReconnectPassiveEffects(
3351 + finishedRoot,
3352 + finishedWork,
3353 + committedLanes,
3354 + committedTransitions,
3355 + includeWorkInProgressEffects,
3356 + );
3357 + } else {
3358 + if (finishedWork.mode & ConcurrentMode) {
3359 + // The effects are currently disconnected. Since the tree is hidden,
3360 + // don't connect them. This also applies to the initial render.
3361 + if (enableCache || enableTransitionTracing) {
3362 + // "Atomic" effects are ones that need to fire on every commit,
3363 + // even during pre-rendering. An example is updating the reference
3364 + // count on cache instances.
3365 + recursivelyTraverseAtomicPassiveEffects(
3366 + finishedRoot,
3367 + finishedWork,
3368 + committedLanes,
3369 + committedTransitions,
3370 + );
3371 + }
3372 + } else {
3373 + // Legacy Mode: Fire the effects even if the tree is hidden.
3374 + instance.visibility |= OffscreenPassiveEffectsConnected;
3375 + recursivelyTraverseReconnectPassiveEffects(
3376 + finishedRoot,
3377 + finishedWork,
3378 + committedLanes,
3379 + committedTransitions,
3380 + includeWorkInProgressEffects,
3381 + );
3382 }
3383 + }
3384 + } else {
3385 + // Tree is visible
3386 +
3387 + // Since we're already inside a reconnecting tree, it doesn't matter
3388 + // whether the effects are currently connected. In either case, we'll
3389 + // continue traversing the tree and firing all the effects.
3390 + //
3391 + // We do need to set the "connected" flag on the instance, though.
3392 + instance.visibility |= OffscreenPassiveEffectsConnected;
3393
3103 - commitTransitionProgress(finishedWork);
3394 + recursivelyTraverseReconnectPassiveEffects(
3395 + finishedRoot,
3396 + finishedWork,
3397 + committedLanes,
3398 + committedTransitions,
3399 + includeWorkInProgressEffects,
3400 + );
3401 + }
3402 +
3403 + if (includeWorkInProgressEffects && flags & Passive) {
3404 + // TODO: Pass `current` as argument to this function
3405 + const current: Fiber | null = finishedWork.alternate;
3406 + commitOffscreenPassiveMountEffects(current, finishedWork, instance);
3407 + }
3408 + break;
3409 + }
3410 + case CacheComponent: {
3411 + recursivelyTraverseReconnectPassiveEffects(
3412 + finishedRoot,
3413 + finishedWork,
3414 + committedLanes,
3415 + committedTransitions,
3416 + includeWorkInProgressEffects,
3417 + );
3418 + if (includeWorkInProgressEffects && flags & Passive) {
3419 + // TODO: Pass `current` as argument to this function
3420 + const current = finishedWork.alternate;
3421 + commitCachePassiveMountEffect(current, finishedWork);
3422 + }
3423 + break;
3424 + }
3425 + case TracingMarkerComponent: {
3426 + if (enableTransitionTracing) {
3427 + recursivelyTraverseReconnectPassiveEffects(
3428 + finishedRoot,
3429 + finishedWork,
3430 + committedLanes,
3431 + committedTransitions,
3432 + includeWorkInProgressEffects,
3433 + );
3434 + if (includeWorkInProgressEffects && flags & Passive) {
3435 + commitTracingMarkerPassiveMountEffect(finishedWork);
3436 }
3437 + break;
3438 + }
3439 + // Intentional fallthrough to next branch
3440 + }
3441 + // eslint-disable-next-line-no-fallthrough
3442 + default: {
3443 + recursivelyTraverseReconnectPassiveEffects(
3444 + finishedRoot,
3445 + finishedWork,
3446 + committedLanes,
3447 + committedTransitions,
3448 + includeWorkInProgressEffects,
3449 + );
3450 + break;
3451 + }
3452 + }
3453 +}
3454 +
3455 +function recursivelyTraverseAtomicPassiveEffects(
3456 + finishedRoot: FiberRoot,
3457 + parentFiber: Fiber,
3458 + committedLanes: Lanes,
3459 + committedTransitions: Array<Transition> | null,
3460 +) {
3461 + // "Atomic" effects are ones that need to fire on every commit, even during
3462 + // pre-rendering. We call this function when traversing a hidden tree whose
3463 + // regular effects are currently disconnected.
3464 + const prevDebugFiber = getCurrentDebugFiberInDEV();
3465 + // TODO: Add special flag for atomic effects
3466 + if (parentFiber.subtreeFlags & PassiveMask) {
3467 + let child = parentFiber.child;
3468 + while (child !== null) {
3469 + setCurrentDebugFiberInDEV(child);
3470 + commitAtomicPassiveEffects(
3471 + finishedRoot,
3472 + child,
3473 + committedLanes,
3474 + committedTransitions,
3475 + );
3476 + child = child.sibling;
3477 + }
3478 + }
3479 + setCurrentDebugFiberInDEV(prevDebugFiber);
3480 +}
3481 +
3482 +function commitAtomicPassiveEffects(
3483 + finishedRoot: FiberRoot,
3484 + finishedWork: Fiber,
3485 + committedLanes: Lanes,
3486 + committedTransitions: Array<Transition> | null,
3487 +) {
3488 + // "Atomic" effects are ones that need to fire on every commit, even during
3489 + // pre-rendering. We call this function when traversing a hidden tree whose
3490 + // regular effects are currently disconnected.
3491 + const flags = finishedWork.flags;
3492 + switch (finishedWork.tag) {
3493 + case OffscreenComponent: {
3494 + recursivelyTraverseAtomicPassiveEffects(
3495 + finishedRoot,
3496 + finishedWork,
3497 + committedLanes,
3498 + committedTransitions,
3499 + );
3500 + if (flags & Passive) {
3501 + // TODO: Pass `current` as argument to this function
3502 + const current = finishedWork.alternate;
3503 + const instance: OffscreenInstance = finishedWork.stateNode;
3504 + commitOffscreenPassiveMountEffects(current, finishedWork, instance);
3505 }
3506 break;
3507 }
3508 case CacheComponent: {
3109 - recursivelyTraversePassiveMountEffects(
3509 + recursivelyTraverseAtomicPassiveEffects(
3510 finishedRoot,
3511 finishedWork,
3512 committedLanes,
3513 committedTransitions,
3514 );
3515 if (flags & Passive) {
3116 - if (enableCache) {
3117 - let previousCache: Cache | null = null;
3118 - if (finishedWork.alternate !== null) {
3119 - previousCache = finishedWork.alternate.memoizedState.cache;
3120 - }
3121 - const nextCache = finishedWork.memoizedState.cache;
3122 - // Retain/release the cache. In theory the cache component
3123 - // could be "borrowing" a cache instance owned by some parent,
3124 - // in which case we could avoid retaining/releasing. But it
3125 - // is non-trivial to determine when that is the case, so we
3126 - // always retain/release.
3127 - if (nextCache !== previousCache) {
3128 - retainCache(nextCache);
3129 - if (previousCache != null) {
3130 - releaseCache(previousCache);
3131 - }
3132 - }
3133 - }
3516 + // TODO: Pass `current` as argument to this function
3517 + const current = finishedWork.alternate;
3518 + commitCachePassiveMountEffect(current, finishedWork);
3519 }
3520 break;
3521 }
3522 case TracingMarkerComponent: {
3523 if (enableTransitionTracing) {
3139 - recursivelyTraversePassiveMountEffects(
3524 + recursivelyTraverseAtomicPassiveEffects(
3525 finishedRoot,
3526 finishedWork,
3527 committedLanes,
3528 committedTransitions,
3529 );
3530 if (flags & Passive) {
3146 - // Get the transitions that were initiatized during the render
3147 - // and add a start transition callback for each of them
3148 - const instance = finishedWork.stateNode;
3149 - if (
3150 - instance.transitions !== null &&
3151 - (instance.pendingBoundaries === null ||
3152 - instance.pendingBoundaries.size === 0)
3153 - ) {
3154 - instance.transitions.forEach(transition => {
3155 - addMarkerCompleteCallbackToPendingTransition(
3156 - finishedWork.memoizedProps.name,
3157 - instance.transitions,
3158 - );
3159 - });
3160 - instance.transitions = null;
3161 - instance.pendingBoundaries = null;
3162 - }
3531 + commitTracingMarkerPassiveMountEffect(finishedWork);
3532 }
3533 break;
3534 }
@@ -3167,7 +3536,7 @@ function commitPassiveMountOnFiber(
3536 }
3537 // eslint-disable-next-line-no-fallthrough
3538 default: {
3170 - recursivelyTraversePassiveMountEffects(
3539 + recursivelyTraverseAtomicPassiveEffects(
3540 finishedRoot,
3541 finishedWork,
3542 committedLanes,
@@ -3275,6 +3644,11 @@ function commitPassiveUnmountOnFiber(finishedWork: Fiber): void {
3644 }
3645 break;
3646 }
3647 + // TODO: Disconnect passive effects when a tree is hidden, perhaps after
3648 + // a delay.
3649 + // case OffscreenComponent: {
3650 + // ...
3651 + // }
3652 default: {
3653 recursivelyTraversePassiveUnmountEffects(finishedWork);
3654 break;
packages/react-reconciler/src/ReactFiberCommitWork.old.js
+519 -145
@@ -173,6 +173,10 @@ import {
173 } from './ReactFiberDevToolsHook.old';
174 import {releaseCache, retainCache} from './ReactFiberCacheComponent.old';
175 import {clearTransitionsForLanes} from './ReactFiberLane.old';
176 +import {
177 + OffscreenVisible,
178 + OffscreenPassiveEffectsConnected,
179 +} from './ReactFiberOffscreenComponent';
180
181 let didWarnAboutUndefinedSnapshotBeforeUpdate: Set<mixed> | null = null;
182 if (__DEV__) {
@@ -2424,14 +2428,8 @@ function commitMutationEffectsOnFiber(
2428 const offscreenFiber: Fiber = (finishedWork.child: any);
2429
2430 if (offscreenFiber.flags & Visibility) {
2427 - const offscreenInstance: OffscreenInstance = offscreenFiber.stateNode;
2431 const newState: OffscreenState | null = offscreenFiber.memoizedState;
2432 const isHidden = newState !== null;
2430 -
2431 - // Track the current state on the Offscreen instance so we can
2432 - // read it during an event
2433 - offscreenInstance.isHidden = isHidden;
2434 -
2433 if (isHidden) {
2434 const wasHidden =
2435 offscreenFiber.alternate !== null &&
@@ -2485,7 +2483,11 @@ function commitMutationEffectsOnFiber(
2483
2484 // Track the current state on the Offscreen instance so we can
2485 // read it during an event
2488 - offscreenInstance.isHidden = isHidden;
2486 + if (isHidden) {
2487 + offscreenInstance.visibility &= ~OffscreenVisible;
2488 + } else {
2489 + offscreenInstance.visibility |= OffscreenVisible;
2490 + }
2491
2492 if (isHidden) {
2493 if (!wasHidden) {
@@ -2871,6 +2873,167 @@ function recursivelyTraverseReappearLayoutEffects(
2873 setCurrentDebugFiberInDEV(prevDebugFiber);
2874 }
2875
2876 +function commitHookPassiveMountEffects(
2877 + finishedWork: Fiber,
2878 + hookFlags: HookFlags,
2879 +) {
2880 + if (
2881 + enableProfilerTimer &&
2882 + enableProfilerCommitHooks &&
2883 + finishedWork.mode & ProfileMode
2884 + ) {
2885 + startPassiveEffectTimer();
2886 + try {
2887 + commitHookEffectListMount(hookFlags, finishedWork);
2888 + } catch (error) {
2889 + captureCommitPhaseError(finishedWork, finishedWork.return, error);
2890 + }
2891 + recordPassiveEffectDuration(finishedWork);
2892 + } else {
2893 + try {
2894 + commitHookEffectListMount(hookFlags, finishedWork);
2895 + } catch (error) {
2896 + captureCommitPhaseError(finishedWork, finishedWork.return, error);
2897 + }
2898 + }
2899 +}
2900 +
2901 +function commitOffscreenPassiveMountEffects(
2902 + current: Fiber | null,
2903 + finishedWork: Fiber,
2904 + instance: OffscreenInstance,
2905 +) {
2906 + if (enableCache) {
2907 + let previousCache: Cache | null = null;
2908 + if (
2909 + current !== null &&
2910 + current.memoizedState !== null &&
2911 + current.memoizedState.cachePool !== null
2912 + ) {
2913 + previousCache = current.memoizedState.cachePool.pool;
2914 + }
2915 + let nextCache: Cache | null = null;
2916 + if (
2917 + finishedWork.memoizedState !== null &&
2918 + finishedWork.memoizedState.cachePool !== null
2919 + ) {
2920 + nextCache = finishedWork.memoizedState.cachePool.pool;
2921 + }
2922 + // Retain/release the cache used for pending (suspended) nodes.
2923 + // Note that this is only reached in the non-suspended/visible case:
2924 + // when the content is suspended/hidden, the retain/release occurs
2925 + // via the parent Suspense component (see case above).
2926 + if (nextCache !== previousCache) {
2927 + if (nextCache != null) {
2928 + retainCache(nextCache);
2929 + }
2930 + if (previousCache != null) {
2931 + releaseCache(previousCache);
2932 + }
2933 + }
2934 + }
2935 +
2936 + if (enableTransitionTracing) {
2937 + // TODO: Pre-rendering should not be counted as part of a transition. We
2938 + // may add separate logs for pre-rendering, but it's not part of the
2939 + // primary metrics.
2940 + const offscreenState: OffscreenState = finishedWork.memoizedState;
2941 + const queue: OffscreenQueue | null = (finishedWork.updateQueue: any);
2942 +
2943 + const isHidden = offscreenState !== null;
2944 + if (queue !== null) {
2945 + if (isHidden) {
2946 + const transitions = queue.transitions;
2947 + if (transitions !== null) {
2948 + transitions.forEach(transition => {
2949 + // Add all the transitions saved in the update queue during
2950 + // the render phase (ie the transitions associated with this boundary)
2951 + // into the transitions set.
2952 + if (instance.transitions === null) {
2953 + instance.transitions = new Set();
2954 + }
2955 + instance.transitions.add(transition);
2956 + });
2957 + }
2958 +
2959 + const markerInstances = queue.markerInstances;
2960 + if (markerInstances !== null) {
2961 + markerInstances.forEach(markerInstance => {
2962 + const markerTransitions = markerInstance.transitions;
2963 + // There should only be a few tracing marker transitions because
2964 + // they should be only associated with the transition that
2965 + // caused them
2966 + if (markerTransitions !== null) {
2967 + markerTransitions.forEach(transition => {
2968 + if (instance.transitions === null) {
2969 + instance.transitions = new Set();
2970 + } else if (instance.transitions.has(transition)) {
2971 + if (markerInstance.pendingBoundaries === null) {
2972 + markerInstance.pendingBoundaries = new Map();
2973 + }
2974 + if (instance.pendingMarkers === null) {
2975 + instance.pendingMarkers = new Set();
2976 + }
2977 +
2978 + instance.pendingMarkers.add(markerInstance);
2979 + }
2980 + });
2981 + }
2982 + });
2983 + }
2984 + }
2985 +
2986 + finishedWork.updateQueue = null;
2987 + }
2988 +
2989 + commitTransitionProgress(finishedWork);
2990 + }
2991 +}
2992 +
2993 +function commitCachePassiveMountEffect(
2994 + current: Fiber | null,
2995 + finishedWork: Fiber,
2996 +) {
2997 + if (enableCache) {
2998 + let previousCache: Cache | null = null;
2999 + if (finishedWork.alternate !== null) {
3000 + previousCache = finishedWork.alternate.memoizedState.cache;
3001 + }
3002 + const nextCache = finishedWork.memoizedState.cache;
3003 + // Retain/release the cache. In theory the cache component
3004 + // could be "borrowing" a cache instance owned by some parent,
3005 + // in which case we could avoid retaining/releasing. But it
3006 + // is non-trivial to determine when that is the case, so we
3007 + // always retain/release.
3008 + if (nextCache !== previousCache) {
3009 + retainCache(nextCache);
3010 + if (previousCache != null) {
3011 + releaseCache(previousCache);
3012 + }
3013 + }
3014 + }
3015 +}
3016 +
3017 +function commitTracingMarkerPassiveMountEffect(finishedWork: Fiber) {
3018 + // Get the transitions that were initiatized during the render
3019 + // and add a start transition callback for each of them
3020 + const instance = finishedWork.stateNode;
3021 + if (
3022 + instance.transitions !== null &&
3023 + (instance.pendingBoundaries === null ||
3024 + instance.pendingBoundaries.size === 0)
3025 + ) {
3026 + instance.transitions.forEach(transition => {
3027 + addMarkerCompleteCallbackToPendingTransition(
3028 + finishedWork.memoizedProps.name,
3029 + instance.transitions,
3030 + );
3031 + });
3032 + instance.transitions = null;
3033 + instance.pendingBoundaries = null;
3034 + }
3035 +}
3036 +
3037 export function commitPassiveMountEffects(
3038 root: FiberRoot,
3039 finishedWork: Fiber,
@@ -2916,6 +3079,9 @@ function commitPassiveMountOnFiber(
3079 committedLanes: Lanes,
3080 committedTransitions: Array<Transition> | null,
3081 ): void {
3082 + // When updating this function, also update reconnectPassiveEffects, which does
3083 + // most of the same things when an offscreen tree goes from hidden -> visible,
3084 + // or when toggling effects inside a hidden tree.
3085 const flags = finishedWork.flags;
3086 switch (finishedWork.tag) {
3087 case FunctionComponent:
@@ -2928,31 +3094,10 @@ function commitPassiveMountOnFiber(
3094 committedTransitions,
3095 );
3096 if (flags & Passive) {
2931 - if (
2932 - enableProfilerTimer &&
2933 - enableProfilerCommitHooks &&
2934 - finishedWork.mode & ProfileMode
2935 - ) {
2936 - startPassiveEffectTimer();
2937 - try {
2938 - commitHookEffectListMount(
2939 - HookPassive | HookHasEffect,
2940 - finishedWork,
2941 - );
2942 - } catch (error) {
2943 - captureCommitPhaseError(finishedWork, finishedWork.return, error);
2944 - }
2945 - recordPassiveEffectDuration(finishedWork);
2946 - } else {
2947 - try {
2948 - commitHookEffectListMount(
2949 - HookPassive | HookHasEffect,
2950 - finishedWork,
2951 - );
2952 - } catch (error) {
2953 - captureCommitPhaseError(finishedWork, finishedWork.return, error);
2954 - }
2955 - }
3097 + commitHookPassiveMountEffects(
3098 + finishedWork,
3099 + HookPassive | HookHasEffect,
3100 + );
3101 }
3102 break;
3103 }
@@ -3013,6 +3158,82 @@ function commitPassiveMountOnFiber(
3158 }
3159 case LegacyHiddenComponent:
3160 case OffscreenComponent: {
3161 + // TODO: Pass `current` as argument to this function
3162 + const instance: OffscreenInstance = finishedWork.stateNode;
3163 + const nextState: OffscreenState | null = finishedWork.memoizedState;
3164 +
3165 + const isHidden = nextState !== null;
3166 +
3167 + if (isHidden) {
3168 + if (instance.visibility & OffscreenPassiveEffectsConnected) {
3169 + // The effects are currently connected. Update them.
3170 + recursivelyTraversePassiveMountEffects(
3171 + finishedRoot,
3172 + finishedWork,
3173 + committedLanes,
3174 + committedTransitions,
3175 + );
3176 + } else {
3177 + if (finishedWork.mode & ConcurrentMode) {
3178 + // The effects are currently disconnected. Since the tree is hidden,
3179 + // don't connect them. This also applies to the initial render.
3180 + if (enableCache || enableTransitionTracing) {
3181 + // "Atomic" effects are ones that need to fire on every commit,
3182 + // even during pre-rendering. An example is updating the reference
3183 + // count on cache instances.
3184 + recursivelyTraverseAtomicPassiveEffects(
3185 + finishedRoot,
3186 + finishedWork,
3187 + committedLanes,
3188 + committedTransitions,
3189 + );
3190 + }
3191 + } else {
3192 + // Legacy Mode: Fire the effects even if the tree is hidden.
3193 + instance.visibility |= OffscreenPassiveEffectsConnected;
3194 + recursivelyTraversePassiveMountEffects(
3195 + finishedRoot,
3196 + finishedWork,
3197 + committedLanes,
3198 + committedTransitions,
3199 + );
3200 + }
3201 + }
3202 + } else {
3203 + // Tree is visible
3204 + if (instance.visibility & OffscreenPassiveEffectsConnected) {
3205 + // The effects are currently connected. Update them.
3206 + recursivelyTraversePassiveMountEffects(
3207 + finishedRoot,
3208 + finishedWork,
3209 + committedLanes,
3210 + committedTransitions,
3211 + );
3212 + } else {
3213 + // The effects are currently disconnected. Reconnect them, while also
3214 + // firing effects inside newly mounted trees. This also applies to
3215 + // the initial render.
3216 + instance.visibility |= OffscreenPassiveEffectsConnected;
3217 +
3218 + const includeWorkInProgressEffects =
3219 + (finishedWork.subtreeFlags & PassiveMask) !== NoFlags;
3220 + recursivelyTraverseReconnectPassiveEffects(
3221 + finishedRoot,
3222 + finishedWork,
3223 + committedLanes,
3224 + committedTransitions,
3225 + includeWorkInProgressEffects,
3226 + );
3227 + }
3228 + }
3229 +
3230 + if (flags & Passive) {
3231 + const current = finishedWork.alternate;
3232 + commitOffscreenPassiveMountEffects(current, finishedWork, instance);
3233 + }
3234 + break;
3235 + }
3236 + case CacheComponent: {
3237 recursivelyTraversePassiveMountEffects(
3238 finishedRoot,
3239 finishedWork,
@@ -3020,146 +3241,294 @@ function commitPassiveMountOnFiber(
3241 committedTransitions,
3242 );
3243 if (flags & Passive) {
3023 - if (enableCache) {
3024 - let previousCache: Cache | null = null;
3025 - if (
3026 - finishedWork.alternate !== null &&
3027 - finishedWork.alternate.memoizedState !== null &&
3028 - finishedWork.alternate.memoizedState.cachePool !== null
3029 - ) {
3030 - previousCache = finishedWork.alternate.memoizedState.cachePool.pool;
3031 - }
3032 - let nextCache: Cache | null = null;
3033 - if (
3034 - finishedWork.memoizedState !== null &&
3035 - finishedWork.memoizedState.cachePool !== null
3036 - ) {
3037 - nextCache = finishedWork.memoizedState.cachePool.pool;
3038 - }
3039 - // Retain/release the cache used for pending (suspended) nodes.
3040 - // Note that this is only reached in the non-suspended/visible case:
3041 - // when the content is suspended/hidden, the retain/release occurs
3042 - // via the parent Suspense component (see case above).
3043 - if (nextCache !== previousCache) {
3044 - if (nextCache != null) {
3045 - retainCache(nextCache);
3046 - }
3047 - if (previousCache != null) {
3048 - releaseCache(previousCache);
3049 - }
3050 - }
3244 + // TODO: Pass `current` as argument to this function
3245 + const current = finishedWork.alternate;
3246 + commitCachePassiveMountEffect(current, finishedWork);
3247 + }
3248 + break;
3249 + }
3250 + case TracingMarkerComponent: {
3251 + if (enableTransitionTracing) {
3252 + recursivelyTraversePassiveMountEffects(
3253 + finishedRoot,
3254 + finishedWork,
3255 + committedLanes,
3256 + committedTransitions,
3257 + );
3258 + if (flags & Passive) {
3259 + commitTracingMarkerPassiveMountEffect(finishedWork);
3260 }
3261 + break;
3262 + }
3263 + // Intentional fallthrough to next branch
3264 + }
3265 + // eslint-disable-next-line-no-fallthrough
3266 + default: {
3267 + recursivelyTraversePassiveMountEffects(
3268 + finishedRoot,
3269 + finishedWork,
3270 + committedLanes,
3271 + committedTransitions,
3272 + );
3273 + break;
3274 + }
3275 + }
3276 +}
3277
3053 - if (enableTransitionTracing) {
3054 - const isFallback = finishedWork.memoizedState;
3055 - const queue: OffscreenQueue | null = (finishedWork.updateQueue: any);
3056 - const instance: OffscreenInstance = finishedWork.stateNode;
3057 -
3058 - if (queue !== null) {
3059 - if (isFallback) {
3060 - const transitions = queue.transitions;
3061 - if (transitions !== null) {
3062 - transitions.forEach(transition => {
3063 - // Add all the transitions saved in the update queue during
3064 - // the render phase (ie the transitions associated with this boundary)
3065 - // into the transitions set.
3066 - if (instance.transitions === null) {
3067 - instance.transitions = new Set();
3068 - }
3069 - instance.transitions.add(transition);
3070 - });
3071 - }
3278 +function recursivelyTraverseReconnectPassiveEffects(
3279 + finishedRoot: FiberRoot,
3280 + parentFiber: Fiber,
3281 + committedLanes: Lanes,
3282 + committedTransitions: Array<Transition> | null,
3283 + includeWorkInProgressEffects: boolean,
3284 +) {
3285 + // This function visits both newly finished work and nodes that were re-used
3286 + // from a previously committed tree. We cannot check non-static flags if the
3287 + // node was reused.
3288 + const childShouldIncludeWorkInProgressEffects =
3289 + includeWorkInProgressEffects &&
3290 + (parentFiber.subtreeFlags & PassiveMask) !== NoFlags;
3291
3073 - const markerInstances = queue.markerInstances;
3074 - if (markerInstances !== null) {
3075 - markerInstances.forEach(markerInstance => {
3076 - const markerTransitions = markerInstance.transitions;
3077 - // There should only be a few tracing marker transitions because
3078 - // they should be only associated with the transition that
3079 - // caused them
3080 - if (markerTransitions !== null) {
3081 - markerTransitions.forEach(transition => {
3082 - if (instance.transitions === null) {
3083 - instance.transitions = new Set();
3084 - } else if (instance.transitions.has(transition)) {
3085 - if (markerInstance.pendingBoundaries === null) {
3086 - markerInstance.pendingBoundaries = new Map();
3087 - }
3088 - if (instance.pendingMarkers === null) {
3089 - instance.pendingMarkers = new Set();
3090 - }
3091 -
3092 - instance.pendingMarkers.add(markerInstance);
3093 - }
3094 - });
3095 - }
3096 - });
3097 - }
3098 - }
3292 + // TODO (Offscreen) Check: flags & (RefStatic | LayoutStatic)
3293 + const prevDebugFiber = getCurrentDebugFiberInDEV();
3294 + let child = parentFiber.child;
3295 + while (child !== null) {
3296 + reconnectPassiveEffects(
3297 + finishedRoot,
3298 + child,
3299 + committedLanes,
3300 + committedTransitions,
3301 + childShouldIncludeWorkInProgressEffects,
3302 + );
3303 + child = child.sibling;
3304 + }
3305 + setCurrentDebugFiberInDEV(prevDebugFiber);
3306 +}
3307
3100 - finishedWork.updateQueue = null;
3308 +function reconnectPassiveEffects(
3309 + finishedRoot: FiberRoot,
3310 + finishedWork: Fiber,
3311 + committedLanes: Lanes,
3312 + committedTransitions: Array<Transition> | null,
3313 + // This function visits both newly finished work and nodes that were re-used
3314 + // from a previously committed tree. We cannot check non-static flags if the
3315 + // node was reused.
3316 + includeWorkInProgressEffects: boolean,
3317 +) {
3318 + const flags = finishedWork.flags;
3319 + switch (finishedWork.tag) {
3320 + case FunctionComponent:
3321 + case ForwardRef:
3322 + case SimpleMemoComponent: {
3323 + recursivelyTraverseReconnectPassiveEffects(
3324 + finishedRoot,
3325 + finishedWork,
3326 + committedLanes,
3327 + committedTransitions,
3328 + includeWorkInProgressEffects,
3329 + );
3330 + // TODO: Check for PassiveStatic flag
3331 + commitHookPassiveMountEffects(finishedWork, HookPassive);
3332 + break;
3333 + }
3334 + // Unlike commitPassiveMountOnFiber, we don't need to handle HostRoot
3335 + // because this function only visits nodes that are inside an
3336 + // Offscreen fiber.
3337 + // case HostRoot: {
3338 + // ...
3339 + // }
3340 + case LegacyHiddenComponent:
3341 + case OffscreenComponent: {
3342 + const instance: OffscreenInstance = finishedWork.stateNode;
3343 + const nextState: OffscreenState | null = finishedWork.memoizedState;
3344 +
3345 + const isHidden = nextState !== null;
3346 +
3347 + if (isHidden) {
3348 + if (instance.visibility & OffscreenPassiveEffectsConnected) {
3349 + // The effects are currently connected. Update them.
3350 + recursivelyTraverseReconnectPassiveEffects(
3351 + finishedRoot,
3352 + finishedWork,
3353 + committedLanes,
3354 + committedTransitions,
3355 + includeWorkInProgressEffects,
3356 + );
3357 + } else {
3358 + if (finishedWork.mode & ConcurrentMode) {
3359 + // The effects are currently disconnected. Since the tree is hidden,
3360 + // don't connect them. This also applies to the initial render.
3361 + if (enableCache || enableTransitionTracing) {
3362 + // "Atomic" effects are ones that need to fire on every commit,
3363 + // even during pre-rendering. An example is updating the reference
3364 + // count on cache instances.
3365 + recursivelyTraverseAtomicPassiveEffects(
3366 + finishedRoot,
3367 + finishedWork,
3368 + committedLanes,
3369 + committedTransitions,
3370 + );
3371 + }
3372 + } else {
3373 + // Legacy Mode: Fire the effects even if the tree is hidden.
3374 + instance.visibility |= OffscreenPassiveEffectsConnected;
3375 + recursivelyTraverseReconnectPassiveEffects(
3376 + finishedRoot,
3377 + finishedWork,
3378 + committedLanes,
3379 + committedTransitions,
3380 + includeWorkInProgressEffects,
3381 + );
3382 }
3383 + }
3384 + } else {
3385 + // Tree is visible
3386 +
3387 + // Since we're already inside a reconnecting tree, it doesn't matter
3388 + // whether the effects are currently connected. In either case, we'll
3389 + // continue traversing the tree and firing all the effects.
3390 + //
3391 + // We do need to set the "connected" flag on the instance, though.
3392 + instance.visibility |= OffscreenPassiveEffectsConnected;
3393
3103 - commitTransitionProgress(finishedWork);
3394 + recursivelyTraverseReconnectPassiveEffects(
3395 + finishedRoot,
3396 + finishedWork,
3397 + committedLanes,
3398 + committedTransitions,
3399 + includeWorkInProgressEffects,
3400 + );
3401 + }
3402 +
3403 + if (includeWorkInProgressEffects && flags & Passive) {
3404 + // TODO: Pass `current` as argument to this function
3405 + const current: Fiber | null = finishedWork.alternate;
3406 + commitOffscreenPassiveMountEffects(current, finishedWork, instance);
3407 + }
3408 + break;
3409 + }
3410 + case CacheComponent: {
3411 + recursivelyTraverseReconnectPassiveEffects(
3412 + finishedRoot,
3413 + finishedWork,
3414 + committedLanes,
3415 + committedTransitions,
3416 + includeWorkInProgressEffects,
3417 + );
3418 + if (includeWorkInProgressEffects && flags & Passive) {
3419 + // TODO: Pass `current` as argument to this function
3420 + const current = finishedWork.alternate;
3421 + commitCachePassiveMountEffect(current, finishedWork);
3422 + }
3423 + break;
3424 + }
3425 + case TracingMarkerComponent: {
3426 + if (enableTransitionTracing) {
3427 + recursivelyTraverseReconnectPassiveEffects(
3428 + finishedRoot,
3429 + finishedWork,
3430 + committedLanes,
3431 + committedTransitions,
3432 + includeWorkInProgressEffects,
3433 + );
3434 + if (includeWorkInProgressEffects && flags & Passive) {
3435 + commitTracingMarkerPassiveMountEffect(finishedWork);
3436 }
3437 + break;
3438 + }
3439 + // Intentional fallthrough to next branch
3440 + }
3441 + // eslint-disable-next-line-no-fallthrough
3442 + default: {
3443 + recursivelyTraverseReconnectPassiveEffects(
3444 + finishedRoot,
3445 + finishedWork,
3446 + committedLanes,
3447 + committedTransitions,
3448 + includeWorkInProgressEffects,
3449 + );
3450 + break;
3451 + }
3452 + }
3453 +}
3454 +
3455 +function recursivelyTraverseAtomicPassiveEffects(
3456 + finishedRoot: FiberRoot,
3457 + parentFiber: Fiber,
3458 + committedLanes: Lanes,
3459 + committedTransitions: Array<Transition> | null,
3460 +) {
3461 + // "Atomic" effects are ones that need to fire on every commit, even during
3462 + // pre-rendering. We call this function when traversing a hidden tree whose
3463 + // regular effects are currently disconnected.
3464 + const prevDebugFiber = getCurrentDebugFiberInDEV();
3465 + // TODO: Add special flag for atomic effects
3466 + if (parentFiber.subtreeFlags & PassiveMask) {
3467 + let child = parentFiber.child;
3468 + while (child !== null) {
3469 + setCurrentDebugFiberInDEV(child);
3470 + commitAtomicPassiveEffects(
3471 + finishedRoot,
3472 + child,
3473 + committedLanes,
3474 + committedTransitions,
3475 + );
3476 + child = child.sibling;
3477 + }
3478 + }
3479 + setCurrentDebugFiberInDEV(prevDebugFiber);
3480 +}
3481 +
3482 +function commitAtomicPassiveEffects(
3483 + finishedRoot: FiberRoot,
3484 + finishedWork: Fiber,
3485 + committedLanes: Lanes,
3486 + committedTransitions: Array<Transition> | null,
3487 +) {
3488 + // "Atomic" effects are ones that need to fire on every commit, even during
3489 + // pre-rendering. We call this function when traversing a hidden tree whose
3490 + // regular effects are currently disconnected.
3491 + const flags = finishedWork.flags;
3492 + switch (finishedWork.tag) {
3493 + case OffscreenComponent: {
3494 + recursivelyTraverseAtomicPassiveEffects(
3495 + finishedRoot,
3496 + finishedWork,
3497 + committedLanes,
3498 + committedTransitions,
3499 + );
3500 + if (flags & Passive) {
3501 + // TODO: Pass `current` as argument to this function
3502 + const current = finishedWork.alternate;
3503 + const instance: OffscreenInstance = finishedWork.stateNode;
3504 + commitOffscreenPassiveMountEffects(current, finishedWork, instance);
3505 }
3506 break;
3507 }
3508 case CacheComponent: {
3109 - recursivelyTraversePassiveMountEffects(
3509 + recursivelyTraverseAtomicPassiveEffects(
3510 finishedRoot,
3511 finishedWork,
3512 committedLanes,
3513 committedTransitions,
3514 );
3515 if (flags & Passive) {
3116 - if (enableCache) {
3117 - let previousCache: Cache | null = null;
3118 - if (finishedWork.alternate !== null) {
3119 - previousCache = finishedWork.alternate.memoizedState.cache;
3120 - }
3121 - const nextCache = finishedWork.memoizedState.cache;
3122 - // Retain/release the cache. In theory the cache component
3123 - // could be "borrowing" a cache instance owned by some parent,
3124 - // in which case we could avoid retaining/releasing. But it
3125 - // is non-trivial to determine when that is the case, so we
3126 - // always retain/release.
3127 - if (nextCache !== previousCache) {
3128 - retainCache(nextCache);
3129 - if (previousCache != null) {
3130 - releaseCache(previousCache);
3131 - }
3132 - }
3133 - }
3516 + // TODO: Pass `current` as argument to this function
3517 + const current = finishedWork.alternate;
3518 + commitCachePassiveMountEffect(current, finishedWork);
3519 }
3520 break;
3521 }
3522 case TracingMarkerComponent: {
3523 if (enableTransitionTracing) {
3139 - recursivelyTraversePassiveMountEffects(
3524 + recursivelyTraverseAtomicPassiveEffects(
3525 finishedRoot,
3526 finishedWork,
3527 committedLanes,
3528 committedTransitions,
3529 );
3530 if (flags & Passive) {
3146 - // Get the transitions that were initiatized during the render
3147 - // and add a start transition callback for each of them
3148 - const instance = finishedWork.stateNode;
3149 - if (
3150 - instance.transitions !== null &&
3151 - (instance.pendingBoundaries === null ||
3152 - instance.pendingBoundaries.size === 0)
3153 - ) {
3154 - instance.transitions.forEach(transition => {
3155 - addMarkerCompleteCallbackToPendingTransition(
3156 - finishedWork.memoizedProps.name,
3157 - instance.transitions,
3158 - );
3159 - });
3160 - instance.transitions = null;
3161 - instance.pendingBoundaries = null;
3162 - }
3531 + commitTracingMarkerPassiveMountEffect(finishedWork);
3532 }
3533 break;
3534 }
@@ -3167,7 +3536,7 @@ function commitPassiveMountOnFiber(
3536 }
3537 // eslint-disable-next-line-no-fallthrough
3538 default: {
3170 - recursivelyTraversePassiveMountEffects(
3539 + recursivelyTraverseAtomicPassiveEffects(
3540 finishedRoot,
3541 finishedWork,
3542 committedLanes,
@@ -3275,6 +3644,11 @@ function commitPassiveUnmountOnFiber(finishedWork: Fiber): void {
3644 }
3645 break;
3646 }
3647 + // TODO: Disconnect passive effects when a tree is hidden, perhaps after
3648 + // a delay.
3649 + // case OffscreenComponent: {
3650 + // ...
3651 + // }
3652 default: {
3653 recursivelyTraversePassiveUnmountEffects(finishedWork);
3654 break;
packages/react-reconciler/src/ReactFiberConcurrentUpdates.new.js
+5 -1
@@ -31,6 +31,7 @@ import {
31 } from './ReactFiberLane.new';
32 import {NoFlags, Placement, Hydrating} from './ReactFiberFlags';
33 import {HostRoot, OffscreenComponent} from './ReactWorkTags';
34 +import {OffscreenVisible} from './ReactFiberOffscreenComponent';
35
36 export type ConcurrentUpdate = {
37 next: ConcurrentUpdate,
@@ -217,7 +218,10 @@ function markUpdateLaneFromFiberToRoot(
218 // account for it. (There may be other cases that we haven't discovered,
219 // too.)
220 const offscreenInstance: OffscreenInstance | null = parent.stateNode;
220 - if (offscreenInstance !== null && offscreenInstance.isHidden) {
221 + if (
222 + offscreenInstance !== null &&
223 + !(offscreenInstance.visibility & OffscreenVisible)
224 + ) {
225 isHidden = true;
226 }
227 }
packages/react-reconciler/src/ReactFiberConcurrentUpdates.old.js
+5 -1
@@ -31,6 +31,7 @@ import {
31 } from './ReactFiberLane.old';
32 import {NoFlags, Placement, Hydrating} from './ReactFiberFlags';
33 import {HostRoot, OffscreenComponent} from './ReactWorkTags';
34 +import {OffscreenVisible} from './ReactFiberOffscreenComponent';
35
36 export type ConcurrentUpdate = {
37 next: ConcurrentUpdate,
@@ -217,7 +218,10 @@ function markUpdateLaneFromFiberToRoot(
218 // account for it. (There may be other cases that we haven't discovered,
219 // too.)
220 const offscreenInstance: OffscreenInstance | null = parent.stateNode;
220 - if (offscreenInstance !== null && offscreenInstance.isHidden) {
221 + if (
222 + offscreenInstance !== null &&
223 + !(offscreenInstance.visibility & OffscreenVisible)
224 + ) {
225 isHidden = true;
226 }
227 }
packages/react-reconciler/src/ReactFiberFlags.js
+1 -1
@@ -87,7 +87,7 @@ export const MutationMask =
87 export const LayoutMask = Update | Callback | Ref | Visibility;
88
89 // TODO: Split into PassiveMountMask and PassiveUnmountMask
90 -export const PassiveMask = Passive | ChildDeletion;
90 +export const PassiveMask = Passive | Visibility | ChildDeletion;
91
92 // Union of tags that don't get reset on clones.
93 // This allows certain concepts to persist without recalculating them,
packages/react-reconciler/src/ReactFiberOffscreenComponent.js
+6 -1
@@ -42,8 +42,13 @@ export type OffscreenQueue = {|
42 wakeables: Set<Wakeable> | null,
43 |};
44
45 +type OffscreenVisibility = number;
46 +
47 +export const OffscreenVisible = /* */ 0b01;
48 +export const OffscreenPassiveEffectsConnected = /* */ 0b10;
49 +
50 export type OffscreenInstance = {|
46 - isHidden: boolean,
51 + visibility: OffscreenVisibility,
52 pendingMarkers: Set<TracingMarkerInstance> | null,
53 transitions: Set<Transition> | null,
54 retryCache: WeakSet<Wakeable> | Set<Wakeable> | null,
packages/react-reconciler/src/__tests__/ReactCache-test.js
+34
@@ -6,6 +6,7 @@ let getCacheForType;
6 let Scheduler;
7 let act;
8 let Suspense;
9 +let Offscreen;
10 let useCacheRefresh;
11 let startTransition;
12 let useState;
@@ -23,6 +24,7 @@ describe('ReactCache', () => {
24 Scheduler = require('scheduler');
25 act = require('jest-react').act;
26 Suspense = React.Suspense;
27 + Offscreen = React.unstable_Offscreen;
28 getCacheSignal = React.unstable_getCacheSignal;
29 getCacheForType = React.unstable_getCacheForType;
30 useCacheRefresh = React.unstable_useCacheRefresh;
@@ -1590,4 +1592,36 @@ describe('ReactCache', () => {
1592 ]);
1593 expect(root).toMatchRenderedOutput('Bye!');
1594 });
1595 +
1596 + // @gate enableOffscreen
1597 + // @gate enableCache
1598 + test('prerender a new cache boundary inside an Offscreen tree', async () => {
1599 + function App({prerenderMore}) {
1600 + return (
1601 + <Offscreen mode="hidden">
1602 + <div>
1603 + {prerenderMore ? (
1604 + <Cache>
1605 + <AsyncText text="More" />
1606 + </Cache>
1607 + ) : null}
1608 + </div>
1609 + </Offscreen>
1610 + );
1611 + }
1612 +
1613 + const root = ReactNoop.createRoot();
1614 + await act(async () => {
1615 + root.render(<App prerenderMore={false} />);
1616 + });
1617 + expect(Scheduler).toHaveYielded([]);
1618 + expect(root).toMatchRenderedOutput(<div hidden={true} />);
1619 +
1620 + seedNextTextCache('More');
1621 + await act(async () => {
1622 + root.render(<App prerenderMore={true} />);
1623 + });
1624 + expect(Scheduler).toHaveYielded(['More']);
1625 + expect(root).toMatchRenderedOutput(<div hidden={true}>More</div>);
1626 + });
1627 });
packages/react-reconciler/src/__tests__/ReactOffscreen-test.js
+155 -24
@@ -486,22 +486,29 @@ describe('ReactOffscreen', () => {
486 // hidden tree share the same lane, but are processed at different times
487 // because of the timing of when they were scheduled.
488
489 + // This functions checks whether the "outer" and "inner" states are
490 + // consistent in the rendered output.
491 + let currentOuter = null;
492 + let currentInner = null;
493 + function areOuterAndInnerConsistent() {
494 + return (
495 + currentOuter === null ||
496 + currentInner === null ||
497 + currentOuter === currentInner
498 + );
499 + }
500 +
501 let setInner;
490 - function Child({outer}) {
502 + function Child() {
503 const [inner, _setInner] = useState(0);
504 setInner = _setInner;
505
506 useEffect(() => {
495 - // Inner and outer values are always updated simultaneously, so they
496 - // should always be consistent.
497 - if (inner !== outer) {
498 - Scheduler.unstable_yieldValue(
499 - 'Tearing! Inner and outer are inconsistent!',
500 - );
501 - } else {
502 - Scheduler.unstable_yieldValue('Inner and outer are consistent');
503 - }
504 - }, [inner, outer]);
507 + currentInner = inner;
508 + return () => {
509 + currentInner = null;
510 + };
511 + }, [inner]);
512
513 return <Text text={'Inner: ' + inner} />;
514 }
@@ -510,11 +517,19 @@ describe('ReactOffscreen', () => {
517 function App({show}) {
518 const [outer, _setOuter] = useState(0);
519 setOuter = _setOuter;
520 +
521 + useEffect(() => {
522 + currentOuter = outer;
523 + return () => {
524 + currentOuter = null;
525 + };
526 + }, [outer]);
527 +
528 return (
529 <>
530 <Text text={'Outer: ' + outer} />
531 <Offscreen mode={show ? 'visible' : 'hidden'}>
517 - <Child outer={outer} />
532 + <Child />
533 </Offscreen>
534 </>
535 );
@@ -525,17 +540,14 @@ describe('ReactOffscreen', () => {
540 await act(async () => {
541 root.render(<App show={false} />);
542 });
528 - expect(Scheduler).toHaveYielded([
529 - 'Outer: 0',
530 - 'Inner: 0',
531 - 'Inner and outer are consistent',
532 - ]);
543 + expect(Scheduler).toHaveYielded(['Outer: 0', 'Inner: 0']);
544 expect(root).toMatchRenderedOutput(
545 <>
546 <span prop="Outer: 0" />
547 <span hidden={true} prop="Inner: 0" />
548 </>,
549 );
550 + expect(areOuterAndInnerConsistent()).toBe(true);
551
552 await act(async () => {
553 // Update a value both inside and outside the hidden tree. These values
@@ -570,8 +582,6 @@ describe('ReactOffscreen', () => {
582 // update were erroneously processed, then Inner would be inconsistent
583 // with Outer.
584 'Inner: 1',
573 -
574 - 'Inner and outer are consistent',
585 ]);
586 expect(root).toMatchRenderedOutput(
587 <>
@@ -579,18 +589,16 @@ describe('ReactOffscreen', () => {
589 <span prop="Inner: 1" />
590 </>,
591 );
592 + expect(areOuterAndInnerConsistent()).toBe(true);
593 });
583 - expect(Scheduler).toHaveYielded([
584 - 'Outer: 2',
585 - 'Inner: 2',
586 - 'Inner and outer are consistent',
587 - ]);
594 + expect(Scheduler).toHaveYielded(['Outer: 2', 'Inner: 2']);
595 expect(root).toMatchRenderedOutput(
596 <>
597 <span prop="Outer: 2" />
598 <span prop="Inner: 2" />
599 </>,
600 );
601 + expect(areOuterAndInnerConsistent()).toBe(true);
602 });
603
604 // @gate enableOffscreen
@@ -867,4 +875,127 @@ describe('ReactOffscreen', () => {
875 ]);
876 },
877 );
878 +
879 + // @gate enableOffscreen
880 + it('defer passive effects when prerendering a new Offscreen tree', async () => {
881 + function Child({label}) {
882 + useEffect(() => {
883 + Scheduler.unstable_yieldValue('Mount ' + label);
884 + return () => {
885 + Scheduler.unstable_yieldValue('Unmount ' + label);
886 + };
887 + }, [label]);
888 + return <Text text={label} />;
889 + }
890 +
891 + function App({showMore}) {
892 + return (
893 + <>
894 + <Child label="Shell" />
895 + <Offscreen mode={showMore ? 'visible' : 'hidden'}>
896 + <Child label="More" />
897 + </Offscreen>
898 + </>
899 + );
900 + }
901 +
902 + const root = ReactNoop.createRoot();
903 +
904 + // Mount the app without showing the extra content
905 + await act(async () => {
906 + root.render(<App showMore={false} />);
907 + });
908 + expect(Scheduler).toHaveYielded([
909 + // First mount the outer visible shell
910 + 'Shell',
911 + 'Mount Shell',
912 +
913 + // Then prerender the hidden extra context. The passive effects in the
914 + // hidden tree should not fire
915 + 'More',
916 + // Does not fire
917 + // 'Mount More',
918 + ]);
919 + // The hidden content has been prerendered
920 + expect(root).toMatchRenderedOutput(
921 + <>
922 + <span prop="Shell" />
923 + <span hidden={true} prop="More" />
924 + </>,
925 + );
926 +
927 + // Reveal the prerendered tree
928 + await act(async () => {
929 + root.render(<App showMore={true} />);
930 + });
931 + expect(Scheduler).toHaveYielded([
932 + 'Shell',
933 + 'More',
934 +
935 + // Mount the passive effects in the newly revealed tree, the ones that
936 + // were skipped during pre-rendering.
937 + 'Mount More',
938 + ]);
939 + });
940 +
941 + // @gate enableOffscreen
942 + it("don't defer passive effects when prerendering in a tree whose effects are already connected", async () => {
943 + function Child({label}) {
944 + useEffect(() => {
945 + Scheduler.unstable_yieldValue('Mount ' + label);
946 + return () => {
947 + Scheduler.unstable_yieldValue('Unmount ' + label);
948 + };
949 + }, [label]);
950 + return <Text text={label} />;
951 + }
952 +
953 + function App({showMore, step}) {
954 + return (
955 + <>
956 + <Child label={'Shell ' + step} />
957 + <Offscreen mode={showMore ? 'visible' : 'hidden'}>
958 + <Child label={'More ' + step} />
959 + </Offscreen>
960 + </>
961 + );
962 + }
963 +
964 + const root = ReactNoop.createRoot();
965 +
966 + // Mount the app, including the extra content
967 + await act(async () => {
968 + root.render(<App showMore={true} step={1} />);
969 + });
970 + expect(Scheduler).toHaveYielded([
971 + 'Shell 1',
972 + 'More 1',
973 + 'Mount Shell 1',
974 + 'Mount More 1',
975 + ]);
976 + expect(root).toMatchRenderedOutput(
977 + <>
978 + <span prop="Shell 1" />
979 + <span prop="More 1" />
980 + </>,
981 + );
982 +
983 + // Hide the extra content. while also updating one of its props
984 + await act(async () => {
985 + root.render(<App showMore={false} step={2} />);
986 + });
987 + expect(Scheduler).toHaveYielded([
988 + // First update the outer visible shell
989 + 'Shell 2',
990 + 'Unmount Shell 1',
991 + 'Mount Shell 2',
992 +
993 + // Then prerender the update to the hidden content. Since the effects
994 + // are already connected inside the hidden tree, we don't defer updates
995 + // to them.
996 + 'More 2',
997 + 'Unmount More 1',
998 + 'Mount More 2',
999 + ]);
1000 + });
1001 });