@samitouri / QOS-React / commits / 17d274dc12

Remove Mutation Check Around commit/measureUpdateViewTransition (#32617)

There's two ways to find updated View Transitions. One is the "commit/measureNestedViewTransitions" pass which is used to find things in unchanged subtrees. This can only lead to the relayout case since there's can't possibly be any mutations in the subtree. This is only triggered when none of the direct siblings have any mutations at all. The other case is "commit/measureUpdateViewTransition" which is for a ViewTransition that itself has mutations scheduled inside of it which leads to the "update" case. However, there's a case between these two cases. When a direct sibling has a mutation but there's also a ViewTransition exactly at the same level. In that case we can't bail out on the whole set of children so we won't trigger the "nested" case. Previously we also didn't trigger the "commit/measureUpdateViewTransition" case because we first checked if that had any mutations inside of it at all. This leads to neither case picking up this boundary. We could check if the ViewTransition itself has any mutations inside and if not trigger the nested path. There's a simpler way though. Because `commit/measureUpdateViewTransition` is actually just optimistic. The flags are pessimistic and we don't know for sure if there will actually be a mutation until we've traversed the tree. It can sometimes lead to the "relayout" case. So we can just use that same path, knowing that it'll just lead to the layout pass. Therefore it's safe to just remove this check.

Sebastian Markbåge committed Mar 14, 2025 at 17:38 UTC 17d274dc127400b31379a1b26c5be53599c36aa5
2 files changed +49 -63
packages/react-reconciler/src/ReactFiberCommitWork.js
+47 -61
@@ -100,7 +100,7 @@ import {
100 Hydrating,
101 Passive,
102 BeforeMutationMask,
103 - BeforeMutationTransitionMask,
103 + BeforeAndAfterMutationTransitionMask,
104 MutationMask,
105 LayoutMask,
106 PassiveMask,
@@ -311,7 +311,7 @@ function commitBeforeMutationEffects_begin(isViewTransitionEligible: boolean) {
311 // If this commit is eligible for a View Transition we look into all mutated subtrees.
312 // TODO: We could optimize this by marking these with the Snapshot subtree flag in the render phase.
313 const subtreeMask = isViewTransitionEligible
314 - ? BeforeMutationTransitionMask
314 + ? BeforeAndAfterMutationTransitionMask
315 : BeforeMutationMask;
316 while (nextEffect !== null) {
317 const fiber = nextEffect;
@@ -487,16 +487,8 @@ function commitBeforeMutationEffectsOnFiber(
487 if (current === null) {
488 // This is a new mount. We should have handled this as part of the
489 // Placement effect or it is deeper inside a entering transition.
490 - } else if (
491 - (finishedWork.subtreeFlags &
492 - (Placement |
493 - Update |
494 - ChildDeletion |
495 - ContentReset |
496 - Visibility)) !==
497 - NoFlags
498 - ) {
499 - // Something mutated within this subtree. This might need to cause
490 + } else {
491 + // Something may have mutated within this subtree. This might need to cause
492 // a cross-fade of this parent. We first assign old names to the
493 // previous tree in the before mutation phase in case we need to.
494 // TODO: This walks the tree that we might continue walking anyway.
@@ -2440,7 +2432,7 @@ function recursivelyTraverseAfterMutationEffects(
2432 lanes: Lanes,
2433 ) {
2434 // We need to visit the same nodes that we visited in the before mutation phase.
2443 - if (parentFiber.subtreeFlags & BeforeMutationTransitionMask) {
2435 + if (parentFiber.subtreeFlags & BeforeAndAfterMutationTransitionMask) {
2436 let child = parentFiber.child;
2437 while (child !== null) {
2438 commitAfterMutationEffectsOnFiber(child, root, lanes);
@@ -2525,63 +2517,57 @@ function commitAfterMutationEffectsOnFiber(
2517 break;
2518 }
2519 case ViewTransitionComponent: {
2528 - if (
2529 - (finishedWork.subtreeFlags &
2530 - (Placement | Update | ChildDeletion | ContentReset | Visibility)) !==
2531 - NoFlags
2532 - ) {
2533 - const wasMutated = (finishedWork.flags & Update) !== NoFlags;
2520 + const wasMutated = (finishedWork.flags & Update) !== NoFlags;
2521
2535 - const prevContextChanged = viewTransitionContextChanged;
2536 - const prevCancelableChildren = pushViewTransitionCancelableScope();
2537 - viewTransitionContextChanged = false;
2538 - recursivelyTraverseAfterMutationEffects(root, finishedWork, lanes);
2522 + const prevContextChanged = viewTransitionContextChanged;
2523 + const prevCancelableChildren = pushViewTransitionCancelableScope();
2524 + viewTransitionContextChanged = false;
2525 + recursivelyTraverseAfterMutationEffects(root, finishedWork, lanes);
2526
2540 - if (viewTransitionContextChanged) {
2541 - finishedWork.flags |= Update;
2542 - }
2527 + if (viewTransitionContextChanged) {
2528 + finishedWork.flags |= Update;
2529 + }
2530
2544 - const inViewport = measureUpdateViewTransition(current, finishedWork);
2531 + const inViewport = measureUpdateViewTransition(current, finishedWork);
2532
2546 - if ((finishedWork.flags & Update) === NoFlags || !inViewport) {
2547 - // If this boundary didn't update, then we may be able to cancel its children.
2548 - // We bubble them up to the parent set to be determined later if we can cancel.
2549 - // Similarly, if old and new state was outside the viewport, we can skip it
2550 - // even if it did update.
2551 - if (prevCancelableChildren === null) {
2552 - // Bubbling up this whole set to the parent.
2553 - } else {
2554 - // Merge with parent set.
2555 - // $FlowFixMe[method-unbinding]
2556 - prevCancelableChildren.push.apply(
2557 - prevCancelableChildren,
2558 - viewTransitionCancelableChildren,
2559 - );
2560 - popViewTransitionCancelableScope(prevCancelableChildren);
2561 - }
2562 - // TODO: If this doesn't end up canceled, because a parent animates,
2563 - // then we should probably issue an event since this instance is part of it.
2533 + if ((finishedWork.flags & Update) === NoFlags || !inViewport) {
2534 + // If this boundary didn't update, then we may be able to cancel its children.
2535 + // We bubble them up to the parent set to be determined later if we can cancel.
2536 + // Similarly, if old and new state was outside the viewport, we can skip it
2537 + // even if it did update.
2538 + if (prevCancelableChildren === null) {
2539 + // Bubbling up this whole set to the parent.
2540 } else {
2565 - const props: ViewTransitionProps = finishedWork.memoizedProps;
2566 - scheduleViewTransitionEvent(
2567 - finishedWork,
2568 - wasMutated || viewTransitionContextChanged
2569 - ? props.onUpdate
2570 - : props.onLayout,
2541 + // Merge with parent set.
2542 + // $FlowFixMe[method-unbinding]
2543 + prevCancelableChildren.push.apply(
2544 + prevCancelableChildren,
2545 + viewTransitionCancelableChildren,
2546 );
2572 -
2573 - // If this boundary did update, we cannot cancel its children so those are dropped.
2547 popViewTransitionCancelableScope(prevCancelableChildren);
2548 }
2549 + // TODO: If this doesn't end up canceled, because a parent animates,
2550 + // then we should probably issue an event since this instance is part of it.
2551 + } else {
2552 + const props: ViewTransitionProps = finishedWork.memoizedProps;
2553 + scheduleViewTransitionEvent(
2554 + finishedWork,
2555 + wasMutated || viewTransitionContextChanged
2556 + ? props.onUpdate
2557 + : props.onLayout,
2558 + );
2559
2577 - if ((finishedWork.flags & AffectedParentLayout) !== NoFlags) {
2578 - // This boundary changed size in a way that may have caused its parent to
2579 - // relayout. We need to bubble this information up to the parent.
2580 - viewTransitionContextChanged = true;
2581 - } else {
2582 - // Otherwise, we restore it to whatever the parent had found so far.
2583 - viewTransitionContextChanged = prevContextChanged;
2584 - }
2560 + // If this boundary did update, we cannot cancel its children so those are dropped.
2561 + popViewTransitionCancelableScope(prevCancelableChildren);
2562 + }
2563 +
2564 + if ((finishedWork.flags & AffectedParentLayout) !== NoFlags) {
2565 + // This boundary changed size in a way that may have caused its parent to
2566 + // relayout. We need to bubble this information up to the parent.
2567 + viewTransitionContextChanged = true;
2568 + } else {
2569 + // Otherwise, we restore it to whatever the parent had found so far.
2570 + viewTransitionContextChanged = prevContextChanged;
2571 }
2572 break;
2573 }
packages/react-reconciler/src/ReactFiberFlags.js
+2 -2
@@ -108,8 +108,8 @@ export const BeforeMutationMask: number =
108
109 // For View Transition support we use the snapshot phase to scan the tree for potentially
110 // affected ViewTransition components.
111 -export const BeforeMutationTransitionMask: number =
112 - Snapshot | Update | Placement | ChildDeletion | Visibility;
111 +export const BeforeAndAfterMutationTransitionMask: number =
112 + Snapshot | Update | Placement | ChildDeletion | Visibility | ContentReset;
113
114 export const MutationMask =
115 Placement |