Merge ViewTransition layout/onLayout props into update/onUpdate (#32723)
We currently have the ability to have a separate animation for a ViewTransition that relayouts but doesn't actually have any internal mutations. This can be useful if you want to separate just a move from for example flashing an update. However, we're concerned that this might be more confusion than its worth because subtle differences in mutations can cause it to trigger the other case. The existence of the property name might also make you start looking for it to solve something that it's not meant for. We already fallback to using the "update" property if it exists but layout doesn't. So if we ever decide to add this back it would backwards compatible. We've also shown in implementation that it can work.
Sebastian Markbåge committed
Mar 24, 2025 at 14:04 UTC
42a57ea8027de8af55e6f4483c3b9a8f4cba31fb
4 files changed
+16
-114
packages/react-reconciler/src/ReactFiberApplyGesture.js
+4
-8
@@ -308,7 +308,7 @@ function applyNestedViewTransition(child: Fiber): void {
308
const name = getViewTransitionName(props, state);
309
const className: ?string = getViewTransitionClassName(
310
props.className,
311
- props.layout,
311
+ props.update,
312
);
313
if (className !== 'none') {
314
const clones = state.clones;
@@ -335,17 +335,13 @@ function applyUpdateViewTransition(current: Fiber, finishedWork: Fiber): void {
335
// we would use. However, since this animation is going in reverse we actually
336
// want the props from "current" since that's the class that would've won if
337
// it was the normal direction. To preserve the same effect in either direction.
338
- let className: ?string = getViewTransitionClassName(
338
+ const className: ?string = getViewTransitionClassName(
339
newProps.className,
340
newProps.update,
341
);
342
if (className === 'none') {
343
- className = getViewTransitionClassName(newProps.className, newProps.layout);
344
- if (className === 'none') {
345
- // If both update and layout are both "none" then we don't have to
346
- // apply a name. Since we won't animate this boundary.
347
- return;
348
- }
343
+ // If update is "none" then we don't have to apply a name. Since we won't animate this boundary.
344
+ return;
345
}
346
const clones = state.clones;
347
// If there are no clones at this point, that should mean that there are no
packages/react-reconciler/src/ReactFiberCommitViewTransitions.js
+11
-96
@@ -469,17 +469,13 @@ export function commitBeforeUpdateViewTransition(
469
// For example, if update="foo" layout="none" and it turns out this was
470
// a layout only change, then the "foo" class will be applied even though
471
// it was not actually an update. Which is a bug.
472
- let className: ?string = getViewTransitionClassName(
472
+ const className: ?string = getViewTransitionClassName(
473
newProps.className,
474
newProps.update,
475
);
476
if (className === 'none') {
477
- className = getViewTransitionClassName(newProps.className, newProps.layout);
478
- if (className === 'none') {
479
- // If both update and layout are both "none" then we don't have to
480
- // apply a name. Since we won't animate this boundary.
481
- return;
482
- }
477
+ // If update is "none" then we don't have to apply a name. Since we won't animate this boundary.
478
+ return;
479
}
480
applyViewTransitionToHostInstances(
481
current.child,
@@ -500,7 +496,7 @@ export function commitNestedViewTransitions(changedParent: Fiber): void {
496
const name = getViewTransitionName(props, child.stateNode);
497
const className: ?string = getViewTransitionClassName(
498
props.className,
503
- props.layout,
499
+ props.update,
500
);
501
if (className !== 'none') {
502
applyViewTransitionToHostInstances(
@@ -590,61 +586,6 @@ export function restoreNestedViewTransitions(changedParent: Fiber): void {
586
}
587
}
588
593
-export function cancelViewTransitionHostInstances(
594
- child: null | Fiber,
595
- oldName: string,
596
- stopAtNestedViewTransitions: boolean,
597
-): void {
598
- viewTransitionHostInstanceIdx = 0;
599
- cancelViewTransitionHostInstancesRecursive(
600
- child,
601
- oldName,
602
- stopAtNestedViewTransitions,
603
- );
604
-}
605
-
606
-function cancelViewTransitionHostInstancesRecursive(
607
- child: null | Fiber,
608
- oldName: string,
609
- stopAtNestedViewTransitions: boolean,
610
-): void {
611
- if (!supportsMutation) {
612
- return;
613
- }
614
- while (child !== null) {
615
- if (child.tag === HostComponent) {
616
- const instance: Instance = child.stateNode;
617
- if (viewTransitionCancelableChildren === null) {
618
- viewTransitionCancelableChildren = [];
619
- }
620
- viewTransitionCancelableChildren.push(
621
- instance,
622
- oldName,
623
- child.memoizedProps,
624
- );
625
- viewTransitionHostInstanceIdx++;
626
- } else if (
627
- child.tag === OffscreenComponent &&
628
- child.memoizedState !== null
629
- ) {
630
- // Skip any hidden subtrees. They were or are effectively not there.
631
- } else if (
632
- child.tag === ViewTransitionComponent &&
633
- stopAtNestedViewTransitions
634
- ) {
635
- // Skip any nested view transitions for updates since in that case the
636
- // inner most one is the one that handles the update.
637
- } else {
638
- cancelViewTransitionHostInstancesRecursive(
639
- child.child,
640
- oldName,
641
- stopAtNestedViewTransitions,
642
- );
643
- }
644
- child = child.sibling;
645
- }
646
-}
647
-
589
export function measureViewTransitionHostInstances(
590
parentViewTransition: Fiber,
591
child: null | Fiber,
@@ -792,40 +733,14 @@ export function measureUpdateViewTransition(
733
const state: ViewTransitionState = newFiber.stateNode;
734
const newName = getViewTransitionName(props, state);
735
const oldName = getViewTransitionName(oldFiber.memoizedProps, state);
795
- const updateClassName: ?string = getViewTransitionClassName(
736
+ // Whether it ends up having been updated or relayout we apply the update class name.
737
+ const className: ?string = getViewTransitionClassName(
738
props.className,
739
props.update,
740
);
799
- const layoutClassName: ?string = getViewTransitionClassName(
800
- props.className,
801
- props.layout,
802
- );
803
- let className: ?string;
804
- if (updateClassName === 'none') {
805
- if (layoutClassName === 'none') {
806
- // If both update and layout class name were none, then we didn't apply any
807
- // names in the before update phase so we shouldn't now neither.
808
- return false;
809
- }
810
- // We don't care if this is mutated or children layout changed, but we still
811
- // measure each instance to see if it moved and therefore should apply layout.
812
- finishedWork.flags &= ~Update;
813
- className = layoutClassName;
814
- } else if ((finishedWork.flags & Update) !== NoFlags) {
815
- // It was updated and we have an appropriate class name to apply.
816
- className = updateClassName;
817
- } else {
818
- if (layoutClassName === 'none') {
819
- // If we did not update, then all changes are considered a layout. We'll
820
- // attempt to cancel.
821
- // This should use the Fiber that got names applied in the snapshot phase
822
- // since those are the ones we're trying to cancel.
823
- cancelViewTransitionHostInstances(oldFiber.child, oldName, true);
824
- return false;
825
- }
826
- // We didn't update but we might still apply layout so we measure each
827
- // instance to see if it moved or resized.
828
- className = layoutClassName;
741
+ if (className === 'none') {
742
+ // If update is "none" then we don't have to apply a name. Since we won't animate this boundary.
743
+ return false;
744
}
745
// If nothing changed due to a mutation, or children changing size
746
// and the measurements end up unchanged, we should restore it to not animate.
@@ -873,7 +788,7 @@ export function measureNestedViewTransitions(
788
const name = getViewTransitionName(props, state);
789
const className: ?string = getViewTransitionClassName(
790
props.className,
876
- props.layout,
791
+ props.update,
792
);
793
let previousMeasurements: null | Array<InstanceMeasurement>;
794
if (gesture) {
@@ -902,7 +817,7 @@ export function measureNestedViewTransitions(
817
if (gesture) {
818
// TODO: Schedule gesture events.
819
} else {
905
- scheduleViewTransitionEvent(child, props.onLayout);
820
+ scheduleViewTransitionEvent(child, props.onUpdate);
821
}
822
}
823
} else if ((child.subtreeFlags & ViewTransitionStatic) !== NoFlags) {
packages/react-reconciler/src/ReactFiberCommitWork.js
+1
-8
@@ -2440,8 +2440,6 @@ function commitAfterMutationEffectsOnFiber(
2440
break;
2441
}
2442
case ViewTransitionComponent: {
2443
- const wasMutated = (finishedWork.flags & Update) !== NoFlags;
2444
-
2443
const prevContextChanged = viewTransitionContextChanged;
2444
const prevCancelableChildren = pushViewTransitionCancelableScope();
2445
viewTransitionContextChanged = false;
@@ -2477,12 +2475,7 @@ function commitAfterMutationEffectsOnFiber(
2475
// then we should probably issue an event since this instance is part of it.
2476
} else {
2477
const props: ViewTransitionProps = finishedWork.memoizedProps;
2480
- scheduleViewTransitionEvent(
2481
- finishedWork,
2482
- wasMutated || viewTransitionContextChanged
2483
- ? props.onUpdate
2484
- : props.onLayout,
2485
- );
2478
+ scheduleViewTransitionEvent(finishedWork, props.onUpdate);
2479
2480
// If this boundary did update, we cannot cancel its children so those are dropped.
2481
popViewTransitionCancelableScope(prevCancelableChildren);
packages/react-reconciler/src/ReactFiberViewTransitionComponent.js
-2
@@ -32,12 +32,10 @@ export type ViewTransitionProps = {
32
className?: ViewTransitionClass,
33
enter?: ViewTransitionClass,
34
exit?: ViewTransitionClass,
35
- layout?: ViewTransitionClass,
35
share?: ViewTransitionClass,
36
update?: ViewTransitionClass,
37
onEnter?: (instance: ViewTransitionInstance, types: Array<string>) => void,
38
onExit?: (instance: ViewTransitionInstance, types: Array<string>) => void,
40
- onLayout?: (instance: ViewTransitionInstance, types: Array<string>) => void,
39
onShare?: (instance: ViewTransitionInstance, types: Array<string>) => void,
40
onUpdate?: (instance: ViewTransitionInstance, types: Array<string>) => void,
41
};