@samitouri / QOS-React / commits / 4b3728f05e

[Fiber] Track Appearing Named ViewTransition in the accumulateSuspenseyCommit Phase (#32254)

When a named ViewTransition component unmounts in one place and mounts in a different place we need to match these up so we know a pair has been created. Since the unmounts are tracked in the snapshot phase we need some way to track the mounts before that. Originally the way I did that is by reusing the render phase since there was no other phase in the commit before that. However, that's not quite correct. Just because something is visited in render doesn't mean it'll commit. E.g. if that tree ends up suspending or erroring. Which would lead to a false positive on match. The unmount shouldn't animate in that case. (Un)fortunately we have already added a traversal before the snapshot phase for tracking suspensey CSS. The `accumulateSuspenseyCommit` phase. This needs to find new mounts of Suspensey CSS or if there was a reappearing Offscreen boundary it needs to find any Suspensey CSS already inside that tree. This is exactly the same traversal we need to find newly appearing View Transition components. So we can just reuse that.

Sebastian Markbåge committed Jan 30, 2025 at 12:13 UTC 4b3728f05efbab9624e981339d8a0992a58f9a41
5 files changed +66 -122
packages/react-reconciler/src/ReactFiberBeginWork.js
+5 -7
@@ -97,6 +97,7 @@ import {
97 Passive,
98 DidDefer,
99 ViewTransitionNamedStatic,
100 + ViewTransitionNamedMount,
101 LayoutStatic,
102 } from './ReactFiberFlags';
103 import {
@@ -266,7 +267,6 @@ import {
267 markSkippedUpdateLanes,
268 getWorkInProgressRoot,
269 peekDeferredLane,
269 - trackAppearingViewTransition,
270 } from './ReactFiberWorkLoop';
271 import {enqueueConcurrentRenderForLane} from './ReactFiberConcurrentUpdates';
272 import {pushCacheProvider, CacheContext} from './ReactFiberCacheComponent';
@@ -3243,12 +3243,10 @@ function updateViewTransition(
3243 if (pendingProps.name != null && pendingProps.name !== 'auto') {
3244 // Explicitly named boundary. We track it so that we can pair it up with another explicit
3245 // boundary if we get deleted.
3246 - workInProgress.flags |= ViewTransitionNamedStatic;
3247 - if (current === null) {
3248 - // This is a new mount. We track it in case we end up having a deletion with the same name.
3249 - // TODO: A problem with this strategy is that this subtree might not actually end up mounted.
3250 - trackAppearingViewTransition(instance, pendingProps.name);
3251 - }
3246 + workInProgress.flags |=
3247 + current === null
3248 + ? ViewTransitionNamedMount | ViewTransitionNamedStatic
3249 + : ViewTransitionNamedStatic;
3250 } else {
3251 // Assign an auto generated name using the useId algorthim if an explicit one is not provided.
3252 // We don't need the name yet but we do it here to allow hydration state to be used.
packages/react-reconciler/src/ReactFiberCommitWork.js
+52 -29
@@ -276,6 +276,10 @@ export let shouldFireAfterActiveInstanceBlur: boolean = false;
276
277 export let shouldStartViewTransition: boolean = false;
278
279 +// This tracks named ViewTransition components found in the accumulateSuspenseyCommit
280 +// phase that might need to find deleted pairs in the beforeMutation phase.
281 +let appearingViewTransitions: Map<string, ViewTransitionState> | null = null;
282 +
283 // Used during the commit phase to track whether a parent ViewTransition component
284 // might have been affected by any mutations / relayouts below.
285 let viewTransitionContextChanged: boolean = false;
@@ -288,7 +292,6 @@ export function commitBeforeMutationEffects(
292 root: FiberRoot,
293 firstChild: Fiber,
294 committedLanes: Lanes,
291 - appearingViewTransitions: Map<string, ViewTransitionState> | null,
295 ): void {
296 focusedInstanceHandle = prepareForCommit(root.containerInfo);
297 shouldFireAfterActiveInstanceBlur = false;
@@ -299,19 +302,15 @@ export function commitBeforeMutationEffects(
302 includesOnlyViewTransitionEligibleLanes(committedLanes);
303
304 nextEffect = firstChild;
302 - commitBeforeMutationEffects_begin(
303 - isViewTransitionEligible,
304 - appearingViewTransitions,
305 - );
305 + commitBeforeMutationEffects_begin(isViewTransitionEligible);
306
307 // We no longer need to track the active instance fiber
308 focusedInstanceHandle = null;
309 + // We've found any matched pairs and can now reset.
310 + appearingViewTransitions = null;
311 }
312
311 -function commitBeforeMutationEffects_begin(
312 - isViewTransitionEligible: boolean,
313 - appearingViewTransitions: Map<string, ViewTransitionState> | null,
314 -) {
313 +function commitBeforeMutationEffects_begin(isViewTransitionEligible: boolean) {
314 // If this commit is eligible for a View Transition we look into all mutated subtrees.
315 // TODO: We could optimize this by marking these with the Snapshot subtree flag in the render phase.
316 const subtreeMask = isViewTransitionEligible
@@ -331,7 +330,6 @@ function commitBeforeMutationEffects_begin(
330 commitBeforeMutationEffectsDeletion(
331 deletion,
332 isViewTransitionEligible,
334 - appearingViewTransitions,
333 );
334 }
335 }
@@ -364,7 +362,7 @@ function commitBeforeMutationEffects_begin(
362 isViewTransitionEligible
363 ) {
364 // Was previously mounted as visible but is now hidden.
367 - commitExitViewTransitions(current, appearingViewTransitions);
365 + commitExitViewTransitions(current);
366 }
367 // Skip before mutation effects of the children because they're hidden.
368 commitBeforeMutationEffects_complete(isViewTransitionEligible);
@@ -528,7 +526,6 @@ function commitBeforeMutationEffectsOnFiber(
526 function commitBeforeMutationEffectsDeletion(
527 deletion: Fiber,
528 isViewTransitionEligible: boolean,
531 - appearingViewTransitions: Map<string, ViewTransitionState> | null,
529 ) {
530 if (enableCreateEventHandleAPI) {
531 // TODO (effects) It would be nice to avoid calling doesFiberContain()
@@ -541,7 +538,7 @@ function commitBeforeMutationEffectsDeletion(
538 }
539 }
540 if (isViewTransitionEligible) {
544 - commitExitViewTransitions(deletion, appearingViewTransitions);
541 + commitExitViewTransitions(deletion);
542 }
543 }
544
@@ -745,14 +742,15 @@ function commitEnterViewTransitions(placement: Fiber): void {
742 }
743 }
744
748 -function commitDeletedPairViewTransitions(
749 - deletion: Fiber,
750 - appearingViewTransitions: Map<string, ViewTransitionState>,
751 -): void {
752 - if (appearingViewTransitions.size === 0) {
745 +function commitDeletedPairViewTransitions(deletion: Fiber): void {
746 + if (
747 + appearingViewTransitions === null ||
748 + appearingViewTransitions.size === 0
749 + ) {
750 // We've found all.
751 return;
752 }
753 + const pairs = appearingViewTransitions;
754 if ((deletion.subtreeFlags & ViewTransitionNamedStatic) === NoFlags) {
755 // This has no named view transitions in its subtree.
756 return;
@@ -769,7 +767,7 @@ function commitDeletedPairViewTransitions(
767 const props: ViewTransitionProps = child.memoizedProps;
768 const name = props.name;
769 if (name != null && name !== 'auto') {
772 - const pair = appearingViewTransitions.get(name);
770 + const pair = pairs.get(name);
771 if (pair !== undefined) {
772 const className: ?string = getViewTransitionClassName(
773 props.className,
@@ -802,23 +800,20 @@ function commitDeletedPairViewTransitions(
800 }
801 // Delete the entry so that we know when we've found all of them
802 // and can stop searching (size reaches zero).
805 - appearingViewTransitions.delete(name);
806 - if (appearingViewTransitions.size === 0) {
803 + pairs.delete(name);
804 + if (pairs.size === 0) {
805 break;
806 }
807 }
808 }
809 }
812 - commitDeletedPairViewTransitions(child, appearingViewTransitions);
810 + commitDeletedPairViewTransitions(child);
811 }
812 child = child.sibling;
813 }
814 }
815
818 -function commitExitViewTransitions(
819 - deletion: Fiber,
820 - appearingViewTransitions: Map<string, ViewTransitionState> | null,
821 -): void {
816 +function commitExitViewTransitions(deletion: Fiber): void {
817 if (deletion.tag === ViewTransitionComponent) {
818 const props: ViewTransitionProps = deletion.memoizedProps;
819 const name = getViewTransitionName(props, deletion.stateNode);
@@ -863,17 +858,17 @@ function commitExitViewTransitions(
858 }
859 if (appearingViewTransitions !== null) {
860 // Look for more pairs deeper in the tree.
866 - commitDeletedPairViewTransitions(deletion, appearingViewTransitions);
861 + commitDeletedPairViewTransitions(deletion);
862 }
863 } else if ((deletion.subtreeFlags & ViewTransitionStatic) !== NoFlags) {
864 let child = deletion.child;
865 while (child !== null) {
871 - commitExitViewTransitions(child, appearingViewTransitions);
866 + commitExitViewTransitions(child);
867 child = child.sibling;
868 }
869 } else {
870 if (appearingViewTransitions !== null) {
876 - commitDeletedPairViewTransitions(deletion, appearingViewTransitions);
871 + commitDeletedPairViewTransitions(deletion);
872 }
873 }
874 }
@@ -4813,8 +4808,13 @@ export function commitPassiveUnmountEffects(finishedWork: Fiber): void {
4808 // already in the "current" tree. Because their visibility has changed, the
4809 // browser may not have prerendered them yet. So we check the MaySuspendCommit
4810 // flag instead.
4811 +//
4812 +// Note that MaySuspendCommit and ShouldSuspendCommit also includes named
4813 +// ViewTransitions so that we know to also visit those to collect appearing
4814 +// pairs.
4815 let suspenseyCommitFlag = ShouldSuspendCommit;
4816 export function accumulateSuspenseyCommit(finishedWork: Fiber): void {
4817 + appearingViewTransitions = null;
4818 accumulateSuspenseyCommitOnFiber(finishedWork);
4819 }
4820
@@ -4893,6 +4893,29 @@ function accumulateSuspenseyCommitOnFiber(fiber: Fiber) {
4893 }
4894 break;
4895 }
4896 + case ViewTransitionComponent: {
4897 + if (enableViewTransition) {
4898 + if ((fiber.flags & suspenseyCommitFlag) !== NoFlags) {
4899 + const props: ViewTransitionProps = fiber.memoizedProps;
4900 + const name: ?string | 'auto' = props.name;
4901 + if (name != null && name !== 'auto') {
4902 + // This is a named ViewTransition being mounted or reappearing. Let's add it to
4903 + // the map so we can match it with deletions later.
4904 + if (appearingViewTransitions === null) {
4905 + appearingViewTransitions = new Map();
4906 + }
4907 + // Reset the pair in case we didn't end up restoring the instance in previous commits.
4908 + // This shouldn't really happen anymore but just in case. We could maybe add an invariant.
4909 + const instance: ViewTransitionState = fiber.stateNode;
4910 + instance.paired = null;
4911 + appearingViewTransitions.set(name, instance);
4912 + }
4913 + }
4914 + recursivelyAccumulateSuspenseyCommit(fiber);
4915 + break;
4916 + }
4917 + // Fallthrough
4918 + }
4919 default: {
4920 recursivelyAccumulateSuspenseyCommit(fiber);
4921 }
packages/react-reconciler/src/ReactFiberCompleteWork.js
-42
@@ -28,10 +28,6 @@ import type {
28 OffscreenState,
29 OffscreenQueue,
30 } from './ReactFiberActivityComponent';
31 -import type {
32 - ViewTransitionProps,
33 - ViewTransitionState,
34 -} from './ReactFiberViewTransitionComponent';
31 import {isOffscreenManual} from './ReactFiberActivityComponent';
32 import type {TracingMarkerInstance} from './ReactFiberTracingMarkerComponent';
33 import type {Cache} from './ReactFiberCacheComponent';
@@ -99,7 +95,6 @@ import {
95 ShouldSuspendCommit,
96 Cloned,
97 ViewTransitionStatic,
102 - ViewTransitionNamedStatic,
98 } from './ReactFiberFlags';
99
100 import {
@@ -164,7 +159,6 @@ import {
159 getWorkInProgressTransitions,
160 shouldRemainOnPreviousScreen,
161 markSpawnedRetryLane,
167 - trackAppearingViewTransition,
162 } from './ReactFiberWorkLoop';
163 import {
164 OffscreenLane,
@@ -947,34 +941,6 @@ function completeDehydratedSuspenseBoundary(
941 }
942 }
943
950 -function trackReappearingViewTransitions(workInProgress: Fiber): void {
951 - if ((workInProgress.subtreeFlags & ViewTransitionNamedStatic) === NoFlags) {
952 - // This has no named view transitions in its subtree.
953 - return;
954 - }
955 - // This needs to search for any explicitly named reappearing View Transitions,
956 - // whether they were updated in this transition or unchanged from before.
957 - let child = workInProgress.child;
958 - while (child !== null) {
959 - if (child.tag === OffscreenComponent && child.memoizedState === null) {
960 - // This tree is currently hidden so we skip it.
961 - } else {
962 - if (
963 - child.tag === ViewTransitionComponent &&
964 - (child.flags & ViewTransitionNamedStatic) !== NoFlags
965 - ) {
966 - const props: ViewTransitionProps = child.memoizedProps;
967 - if (props.name != null && props.name !== 'auto') {
968 - const instance: ViewTransitionState = child.stateNode;
969 - trackAppearingViewTransition(instance, props.name);
970 - }
971 - }
972 - trackReappearingViewTransitions(child);
973 - }
974 - child = child.sibling;
975 - }
976 -}
977 -
944 function completeWork(
945 current: Fiber | null,
946 workInProgress: Fiber,
@@ -1796,14 +1762,6 @@ function completeWork(
1762 const prevIsHidden = prevState !== null;
1763 if (prevIsHidden !== nextIsHidden) {
1764 workInProgress.flags |= Visibility;
1799 - if (enableViewTransition && !nextIsHidden) {
1800 - // If we're revealing a new tree, we need to find any named
1801 - // ViewTransitions inside it that might have a deleted pair.
1802 - // We do this in the complete phase in case the tree has
1803 - // changed during the reveal but we have to do it before we
1804 - // find the first deleted pair in the before mutation phase.
1805 - trackReappearingViewTransitions(workInProgress);
1806 - }
1765 }
1766 } else {
1767 // On initial mount, we only need a Visibility effect if the tree
packages/react-reconciler/src/ReactFiberFlags.js
+5 -2
@@ -44,6 +44,7 @@ export const StoreConsistency = /* */ 0b0000000000000000100000000000
44 // possible, because we're about to run out of bits.
45 export const ScheduleRetry = StoreConsistency;
46 export const ShouldSuspendCommit = Visibility;
47 +export const ViewTransitionNamedMount = ShouldSuspendCommit;
48 export const DidDefer = ContentReset;
49 export const FormReset = Snapshot;
50 export const AffectedParentLayout = ContentReset;
@@ -74,8 +75,10 @@ export const PassiveStatic = /* */ 0b0000000100000000000000000000
75 export const MaySuspendCommit = /* */ 0b0000001000000000000000000000000;
76 // ViewTransitionNamedStatic tracks explicitly name ViewTransition components deeply
77 // that might need to be visited during clean up. This is similar to SnapshotStatic
77 -// if there was any other use for it.
78 -export const ViewTransitionNamedStatic = /* */ SnapshotStatic;
78 +// if there was any other use for it. It also needs to run in the same phase as
79 +// MaySuspendCommit tracking.
80 +export const ViewTransitionNamedStatic =
81 + /* */ SnapshotStatic | MaySuspendCommit;
82 // ViewTransitionStatic tracks whether there are an ViewTransition components from
83 // the nearest HostComponent down. It resets at every HostComponent level.
84 export const ViewTransitionStatic = /* */ 0b0000010000000000000000000000000;
packages/react-reconciler/src/ReactFiberWorkLoop.js
+4 -42
@@ -432,12 +432,6 @@ let workInProgressRootConcurrentErrors: Array<CapturedValue<mixed>> | null =
432 // We will log them once the tree commits.
433 let workInProgressRootRecoverableErrors: Array<CapturedValue<mixed>> | null =
434 null;
435 -// This tracks named ViewTransition components that might need to find deleted
436 -// pairs in the snapshot phase.
437 -let workInProgressAppearingViewTransitions: Map<
438 - string,
439 - ViewTransitionState,
440 -> | null = null;
435
436 // Tracks when an update occurs during the render phase.
437 let workInProgressRootDidIncludeRecursiveRenderUpdate: boolean = false;
@@ -1318,7 +1312,6 @@ function finishConcurrentRender(
1312 lanes,
1313 workInProgressRootRecoverableErrors,
1314 workInProgressTransitions,
1321 - workInProgressAppearingViewTransitions,
1315 workInProgressRootDidIncludeRecursiveRenderUpdate,
1316 workInProgressDeferredLane,
1317 workInProgressRootInterleavedUpdatedLanes,
@@ -1368,7 +1361,6 @@ function finishConcurrentRender(
1361 finishedWork,
1362 workInProgressRootRecoverableErrors,
1363 workInProgressTransitions,
1371 - workInProgressAppearingViewTransitions,
1364 workInProgressRootDidIncludeRecursiveRenderUpdate,
1365 lanes,
1366 workInProgressDeferredLane,
@@ -1390,7 +1382,6 @@ function finishConcurrentRender(
1382 finishedWork,
1383 workInProgressRootRecoverableErrors,
1384 workInProgressTransitions,
1393 - workInProgressAppearingViewTransitions,
1385 workInProgressRootDidIncludeRecursiveRenderUpdate,
1386 lanes,
1387 workInProgressDeferredLane,
@@ -1410,7 +1401,6 @@ function commitRootWhenReady(
1401 finishedWork: Fiber,
1402 recoverableErrors: Array<CapturedValue<mixed>> | null,
1403 transitions: Array<Transition> | null,
1413 - appearingViewTransitions: Map<string, ViewTransitionState> | null,
1404 didIncludeRenderPhaseUpdate: boolean,
1405 lanes: Lanes,
1406 spawnedLane: Lane,
@@ -1442,9 +1432,9 @@ function commitRootWhenReady(
1432 // the suspensey resources. The renderer is responsible for accumulating
1433 // all the load events. This all happens in a single synchronous
1434 // transaction, so it track state in its own module scope.
1445 - if (maySuspendCommit) {
1446 - accumulateSuspenseyCommit(finishedWork);
1447 - }
1435 + // This will also track any newly added or appearing ViewTransition
1436 + // components for the purposes of forming pairs.
1437 + accumulateSuspenseyCommit(finishedWork);
1438 if (isViewTransitionEligible) {
1439 suspendOnActiveViewTransition(root.containerInfo);
1440 }
@@ -1468,7 +1458,6 @@ function commitRootWhenReady(
1458 lanes,
1459 recoverableErrors,
1460 transitions,
1471 - appearingViewTransitions,
1461 didIncludeRenderPhaseUpdate,
1462 spawnedLane,
1463 updatedLanes,
@@ -1492,7 +1481,6 @@ function commitRootWhenReady(
1481 lanes,
1482 recoverableErrors,
1483 transitions,
1495 - appearingViewTransitions,
1484 didIncludeRenderPhaseUpdate,
1485 spawnedLane,
1486 updatedLanes,
@@ -1962,7 +1950,6 @@ function prepareFreshStack(root: FiberRoot, lanes: Lanes): Fiber {
1950 workInProgressRootConcurrentErrors = null;
1951 workInProgressRootRecoverableErrors = null;
1952 workInProgressRootDidIncludeRecursiveRenderUpdate = false;
1965 - workInProgressAppearingViewTransitions = null;
1953
1954 // Get the lanes that are entangled with whatever we're about to render. We
1955 // track these separately so we can distinguish the priority of the render
@@ -2302,25 +2289,6 @@ export function renderHasNotSuspendedYet(): boolean {
2289 return workInProgressRootExitStatus === RootInProgress;
2290 }
2291
2305 -export function trackAppearingViewTransition(
2306 - instance: ViewTransitionState,
2307 - name: string,
2308 -): void {
2309 - if (workInProgressAppearingViewTransitions === null) {
2310 - if (
2311 - !includesOnlyViewTransitionEligibleLanes(workInProgressRootRenderLanes)
2312 - ) {
2313 - return;
2314 - }
2315 - workInProgressAppearingViewTransitions = new Map();
2316 - }
2317 - // Reset the pair in case we didn't end up restoring the instance in previous commits.
2318 - // This could happen since we don't actually commit all tracked instances if they end
2319 - // up in a non-committed subtree.
2320 - instance.paired = null;
2321 - workInProgressAppearingViewTransitions.set(name, instance);
2322 -}
2323 -
2292 // TODO: Over time, this function and renderRootConcurrent have become more
2293 // and more similar. Not sure it makes sense to maintain forked paths. Consider
2294 // unifying them again.
@@ -3230,7 +3198,6 @@ function commitRoot(
3198 lanes: Lanes,
3199 recoverableErrors: null | Array<CapturedValue<mixed>>,
3200 transitions: Array<Transition> | null,
3233 - appearingViewTransitions: Map<string, ViewTransitionState> | null,
3201 didIncludeRenderPhaseUpdate: boolean,
3202 spawnedLane: Lane,
3203 updatedLanes: Lanes,
@@ -3460,12 +3427,7 @@ function commitRoot(
3427 // The first phase a "before mutation" phase. We use this phase to read the
3428 // state of the host tree right before we mutate it. This is where
3429 // getSnapshotBeforeUpdate is called.
3463 - commitBeforeMutationEffects(
3464 - root,
3465 - finishedWork,
3466 - lanes,
3467 - appearingViewTransitions,
3468 - );
3430 + commitBeforeMutationEffects(root, finishedWork, lanes);
3431 } finally {
3432 // Reset the priority to the previous non-sync value.
3433 executionContext = prevExecutionContext;