@samitouri / QOS-React-2 / commits / cabd8a0e70

View Transition Class Names based on event kind (#32050)

This adds five props to `<ViewTransition>` that adds a specific `view-transition-class` when React wants to animate it based on the heuristic that triggers. ```js <ViewTransition enter="slide-from-left" exit="slide-to-right" layout="slide" update="none" share="cross-fade" > ``` - `enter`: The <ViewTransition> or its parent Component is mounted and there's no other <ViewTransition> with the same name being deleted. - `exit`: The <ViewTransition> or its parent Component is unmounted and there's no other <ViewTransition> with the same name being deleted. - `layout`: There are no updates to the content inside this <ViewTransition> boundary itself but the boundary has resized or moved due to other changes to siblings. - `share`: This <ViewTransition> is being mounted and another <ViewTransition> instance with the same name is being unmounted elsewhere. - `update`: The content of <ViewTransition> has changed either due to DOM mutations or because an inner child <ViewTransition> has resized. The existing `className` is the baseline and the others are added to it to combine. This is convenient to distinguish things like `enter` / `exit` but that can already be expressed as CSS. The other cases can't be expressed as purely CSS. `"none"` is a special value that deactivates the view transition name under that condition. The most important feature of this is that you can now limit View Transitions to only tigger when a particular DOM node is affected, not when just any child updates, by opt-ing out a subtree. This is safer when added to shared parent. ```js <ViewTransition> <div> <ViewTransition className="none"> {children} </ViewTransition> </div> </ViewTransition> ``` This can't be fully expressed using neither just CSS nor the imperative refs API since we need some way to have already removed the `view-transition-name` when this happens. When you think about the implementation details it might seem a bit strange that you specify the `class` to `none` to remove the `name` but it's really about picking which animation should happen for that case default (`undefined`), a specific one (class) or none (`"none"`).

Sebastian Markbåge committed Jan 13, 2025 at 09:45 UTC cabd8a0e700713900d9573edb162e608268d09ac
3 files changed +272 -102
fixtures/view-transition/src/components/Page.js
+2 -2
@@ -57,7 +57,7 @@ export default function Page({url, navigate}) {
57 }}>
58 {show ? 'A' : 'B'}
59 </button>
60 - <ViewTransition>
60 + <ViewTransition className="none">
61 <div>
62 {show ? (
63 <div>
@@ -92,7 +92,7 @@ export default function Page({url, navigate}) {
92 <div>!!</div>
93 </ViewTransition>
94 </Activity>
95 - {show ? <Component /> : <p>&nbsp;</p>}
95 + {show ? <Component /> : null}
96 </div>
97 </ViewTransition>
98 </div>
packages/react-reconciler/src/ReactFiberCommitWork.js
+248 -99
@@ -203,7 +203,10 @@ import {
203 OffscreenDetached,
204 OffscreenPassiveEffectsConnected,
205 } from './ReactFiberActivityComponent';
206 -import {getViewTransitionName} from './ReactFiberViewTransitionComponent';
206 +import {
207 + getViewTransitionName,
208 + getViewTransitionClassName,
209 +} from './ReactFiberViewTransitionComponent';
210 import {
211 TransitionRoot,
212 TransitionTracingMarker,
@@ -504,7 +507,7 @@ function commitBeforeMutationEffectsOnFiber(
507 // We should just stash the parent ViewTransitionComponent and continue
508 // walking the tree until we find HostComponent but to do that we need
509 // to use a stack which requires refactoring this phase.
507 - commitBeforeUpdateViewTransition(current);
510 + commitBeforeUpdateViewTransition(current, finishedWork);
511 }
512 }
513 break;
@@ -663,24 +666,31 @@ function commitAppearingPairViewTransitions(placement: Fiber): void {
666 'Found a pair with an auto name. This is a bug in React.',
667 );
668 }
666 - // We found a new appearing view transition with the same name as this deletion.
667 - // We'll transition between them.
668 - viewTransitionHostInstanceIdx = 0;
669 - const inViewport = applyViewTransitionToHostInstances(
670 - child.child,
671 - props.name,
669 + const name = props.name;
670 + const className: ?string = getViewTransitionClassName(
671 props.className,
673 - null,
674 - false,
672 + props.share,
673 );
676 - if (!inViewport) {
677 - // This boundary is exiting within the viewport but is going to leave the viewport.
678 - // Instead, we treat this as an exit of the previous entry by reverting the new name.
679 - // Ideally we could undo the old transition but it's now too late. It's also on its
680 - // on snapshot. We have know was for it to paint onto the original group.
681 - // TODO: This will lead to things unexpectedly having exit animations that normally
682 - // wouldn't happen. Consider if we should just let this fly off the screen instead.
683 - restoreViewTransitionOnHostInstances(child.child, false);
674 + if (className !== 'none') {
675 + // We found a new appearing view transition with the same name as this deletion.
676 + // We'll transition between them.
677 + viewTransitionHostInstanceIdx = 0;
678 + const inViewport = applyViewTransitionToHostInstances(
679 + child.child,
680 + name,
681 + className,
682 + null,
683 + false,
684 + );
685 + if (!inViewport) {
686 + // This boundary is exiting within the viewport but is going to leave the viewport.
687 + // Instead, we treat this as an exit of the previous entry by reverting the new name.
688 + // Ideally we could undo the old transition but it's now too late. It's also on its
689 + // on snapshot. We have know was for it to paint onto the original group.
690 + // TODO: This will lead to things unexpectedly having exit animations that normally
691 + // wouldn't happen. Consider if we should just let this fly off the screen instead.
692 + restoreViewTransitionOnHostInstances(child.child, false);
693 + }
694 }
695 }
696 }
@@ -691,29 +701,37 @@ function commitAppearingPairViewTransitions(placement: Fiber): void {
701
702 function commitEnterViewTransitions(placement: Fiber): void {
703 if (placement.tag === ViewTransitionComponent) {
704 + const state: ViewTransitionState = placement.stateNode;
705 const props: ViewTransitionProps = placement.memoizedProps;
695 - const name = getViewTransitionName(props, placement.stateNode);
696 - viewTransitionHostInstanceIdx = 0;
697 - const inViewport = applyViewTransitionToHostInstances(
698 - placement.child,
699 - name,
706 + const name = getViewTransitionName(props, state);
707 + const className: ?string = getViewTransitionClassName(
708 props.className,
701 - null,
702 - false,
709 + state.paired ? props.share : props.enter,
710 );
704 - if (!inViewport) {
705 - // TODO: If this was part of a pair we will still run the onShare callback.
706 - // Revert the transition names. This boundary is not in the viewport
707 - // so we won't bother animating it.
708 - restoreViewTransitionOnHostInstances(placement.child, false);
709 - // TODO: Should we still visit the children in case a named one was in the viewport?
710 - } else {
711 - commitAppearingPairViewTransitions(placement);
711 + if (className !== 'none') {
712 + viewTransitionHostInstanceIdx = 0;
713 + const inViewport = applyViewTransitionToHostInstances(
714 + placement.child,
715 + name,
716 + className,
717 + null,
718 + false,
719 + );
720 + if (!inViewport) {
721 + // TODO: If this was part of a pair we will still run the onShare callback.
722 + // Revert the transition names. This boundary is not in the viewport
723 + // so we won't bother animating it.
724 + restoreViewTransitionOnHostInstances(placement.child, false);
725 + // TODO: Should we still visit the children in case a named one was in the viewport?
726 + } else {
727 + commitAppearingPairViewTransitions(placement);
728
713 - const state: ViewTransitionState = placement.stateNode;
714 - if (!state.paired) {
715 - scheduleViewTransitionEvent(placement, props.onEnter);
729 + if (!state.paired) {
730 + scheduleViewTransitionEvent(placement, props.onEnter);
731 + }
732 }
733 + } else {
734 + commitAppearingPairViewTransitions(placement);
735 }
736 } else if ((placement.subtreeFlags & ViewTransitionStatic) !== NoFlags) {
737 let child = placement.child;
@@ -752,28 +770,34 @@ function commitDeletedPairViewTransitions(
770 if (name != null && name !== 'auto') {
771 const pair = appearingViewTransitions.get(name);
772 if (pair !== undefined) {
755 - // We found a new appearing view transition with the same name as this deletion.
756 - viewTransitionHostInstanceIdx = 0;
757 - const inViewport = applyViewTransitionToHostInstances(
758 - child.child,
759 - name,
773 + const className: ?string = getViewTransitionClassName(
774 props.className,
761 - null,
762 - false,
775 + props.share,
776 );
764 - if (!inViewport) {
765 - // This boundary is not in the viewport so we won't treat it as a matched pair.
766 - // Revert the transition names. This avoids it flying onto the screen which can
767 - // be disruptive and doesn't really preserve any continuity anyway.
768 - restoreViewTransitionOnHostInstances(child.child, false);
769 - } else {
770 - // We'll transition between them.
771 - const oldinstance: ViewTransitionState = child.stateNode;
772 - const newInstance: ViewTransitionState = pair;
773 - newInstance.paired = oldinstance;
774 - // Note: If the other side ends up outside the viewport, we'll still run this.
775 - // Therefore it's possible for onShare to be called with only an old snapshot.
776 - scheduleViewTransitionEvent(child, props.onShare);
777 + if (className !== 'none') {
778 + // We found a new appearing view transition with the same name as this deletion.
779 + viewTransitionHostInstanceIdx = 0;
780 + const inViewport = applyViewTransitionToHostInstances(
781 + child.child,
782 + name,
783 + className,
784 + null,
785 + false,
786 + );
787 + if (!inViewport) {
788 + // This boundary is not in the viewport so we won't treat it as a matched pair.
789 + // Revert the transition names. This avoids it flying onto the screen which can
790 + // be disruptive and doesn't really preserve any continuity anyway.
791 + restoreViewTransitionOnHostInstances(child.child, false);
792 + } else {
793 + // We'll transition between them.
794 + const oldinstance: ViewTransitionState = child.stateNode;
795 + const newInstance: ViewTransitionState = pair;
796 + newInstance.paired = oldinstance;
797 + // Note: If the other side ends up outside the viewport, we'll still run this.
798 + // Therefore it's possible for onShare to be called with only an old snapshot.
799 + scheduleViewTransitionEvent(child, props.onShare);
800 + }
801 }
802 // Delete the entry so that we know when we've found all of them
803 // and can stop searching (size reaches zero).
@@ -797,22 +821,29 @@ function commitExitViewTransitions(
821 if (deletion.tag === ViewTransitionComponent) {
822 const props: ViewTransitionProps = deletion.memoizedProps;
823 const name = getViewTransitionName(props, deletion.stateNode);
800 - viewTransitionHostInstanceIdx = 0;
801 - const inViewport = applyViewTransitionToHostInstances(
802 - deletion.child,
803 - name,
824 + const pair =
825 + appearingViewTransitions !== null
826 + ? appearingViewTransitions.get(name)
827 + : undefined;
828 + const className: ?string = getViewTransitionClassName(
829 props.className,
805 - null,
806 - false,
830 + pair !== undefined ? props.share : props.exit,
831 );
808 - if (!inViewport) {
809 - // Revert the transition names. This boundary is not in the viewport
810 - // so we won't bother animating it.
811 - restoreViewTransitionOnHostInstances(deletion.child, false);
812 - // TODO: Should we still visit the children in case a named one was in the viewport?
813 - } else if (appearingViewTransitions !== null) {
814 - const pair = appearingViewTransitions.get(name);
815 - if (pair !== undefined) {
832 + if (className !== 'none') {
833 + viewTransitionHostInstanceIdx = 0;
834 + const inViewport = applyViewTransitionToHostInstances(
835 + deletion.child,
836 + name,
837 + className,
838 + null,
839 + false,
840 + );
841 + if (!inViewport) {
842 + // Revert the transition names. This boundary is not in the viewport
843 + // so we won't bother animating it.
844 + restoreViewTransitionOnHostInstances(deletion.child, false);
845 + // TODO: Should we still visit the children in case a named one was in the viewport?
846 + } else if (pair !== undefined) {
847 // We found a new appearing view transition with the same name as this deletion.
848 // We'll transition between them instead of running the normal exit.
849 const oldinstance: ViewTransitionState = deletion.stateNode;
@@ -820,6 +851,7 @@ function commitExitViewTransitions(
851 newInstance.paired = oldinstance;
852 // Delete the entry so that we know when we've found all of them
853 // and can stop searching (size reaches zero).
854 + // $FlowFixMe[incompatible-use]: Refined by the pair.
855 appearingViewTransitions.delete(name);
856 // Note: If the other side ends up outside the viewport, we'll still run this.
857 // Therefore it's possible for onShare to be called with only an old snapshot.
@@ -827,10 +859,10 @@ function commitExitViewTransitions(
859 } else {
860 scheduleViewTransitionEvent(deletion, props.onExit);
861 }
862 + }
863 + if (appearingViewTransitions !== null) {
864 // Look for more pairs deeper in the tree.
865 commitDeletedPairViewTransitions(deletion, appearingViewTransitions);
832 - } else {
833 - scheduleViewTransitionEvent(deletion, props.onExit);
866 }
867 } else if ((deletion.subtreeFlags & ViewTransitionStatic) !== NoFlags) {
868 let child = deletion.child;
@@ -845,7 +877,10 @@ function commitExitViewTransitions(
877 }
878 }
879
848 -function commitBeforeUpdateViewTransition(current: Fiber): void {
880 +function commitBeforeUpdateViewTransition(
881 + current: Fiber,
882 + finishedWork: Fiber,
883 +): void {
884 // The way we deal with multiple HostInstances as children of a View Transition in an
885 // update can get tricky. The important bit is that if you swap out n HostInstances
886 // from n HostInstances then they match up in order. Similarly, if you don't swap
@@ -862,13 +897,32 @@ function commitBeforeUpdateViewTransition(current: Fiber): void {
897 // be unexpected but it is in line with the semantics that the ViewTransition is its
898 // own layer that cross-fades its content when it updates. If you want to reorder then
899 // each child needs its own ViewTransition.
865 - const props: ViewTransitionProps = current.memoizedProps;
866 - const name = getViewTransitionName(props, current.stateNode);
900 + const oldProps: ViewTransitionProps = current.memoizedProps;
901 + const oldName = getViewTransitionName(oldProps, current.stateNode);
902 + const newProps: ViewTransitionProps = finishedWork.memoizedProps;
903 + // This className applies only if there are fewer child DOM nodes than
904 + // before or if this update should've been cancelled but we ended up with
905 + // a parent animating so we need to animate the child too.
906 + // For example, if update="foo" layout="none" and it turns out this was
907 + // a layout only change, then the "foo" class will be applied even though
908 + // it was not actually an update. Which is a bug.
909 + let className: ?string = getViewTransitionClassName(
910 + newProps.className,
911 + newProps.update,
912 + );
913 + if (className === 'none') {
914 + className = getViewTransitionClassName(newProps.className, newProps.layout);
915 + if (className === 'none') {
916 + // If both update and layout are both "none" then we don't have to
917 + // apply a name. Since we won't animate this boundary.
918 + return;
919 + }
920 + }
921 viewTransitionHostInstanceIdx = 0;
922 applyViewTransitionToHostInstances(
923 current.child,
870 - name,
871 - props.className,
924 + oldName,
925 + className,
926 (current.memoizedState = []),
927 true,
928 );
@@ -882,14 +936,20 @@ function commitNestedViewTransitions(changedParent: Fiber): void {
936 // was an update through this component then the inner one wins.
937 const props: ViewTransitionProps = child.memoizedProps;
938 const name = getViewTransitionName(props, child.stateNode);
885 - viewTransitionHostInstanceIdx = 0;
886 - applyViewTransitionToHostInstances(
887 - child.child,
888 - name,
939 + const className: ?string = getViewTransitionClassName(
940 props.className,
890 - (child.memoizedState = []),
891 - false,
941 + props.layout,
942 );
943 + if (className !== 'none') {
944 + viewTransitionHostInstanceIdx = 0;
945 + applyViewTransitionToHostInstances(
946 + child.child,
947 + name,
948 + className,
949 + (child.memoizedState = []),
950 + false,
951 + );
952 + }
953 } else if ((child.subtreeFlags & ViewTransitionStatic) !== NoFlags) {
954 commitNestedViewTransitions(child);
955 }
@@ -979,10 +1039,58 @@ function restoreNestedViewTransitions(changedParent: Fiber): void {
1039 }
1040 }
1041
1042 +function cancelViewTransitionHostInstances(
1043 + currentViewTransition: Fiber,
1044 + child: null | Fiber,
1045 + stopAtNestedViewTransitions: boolean,
1046 +): void {
1047 + if (!supportsMutation) {
1048 + return;
1049 + }
1050 + while (child !== null) {
1051 + if (child.tag === HostComponent) {
1052 + const instance: Instance = child.stateNode;
1053 + const oldName = getViewTransitionName(
1054 + currentViewTransition.memoizedProps,
1055 + currentViewTransition.stateNode,
1056 + );
1057 + if (viewTransitionCancelableChildren === null) {
1058 + viewTransitionCancelableChildren = [];
1059 + }
1060 + viewTransitionCancelableChildren.push(
1061 + instance,
1062 + oldName,
1063 + child.memoizedProps,
1064 + );
1065 + viewTransitionHostInstanceIdx++;
1066 + } else if (
1067 + child.tag === OffscreenComponent &&
1068 + child.memoizedState !== null
1069 + ) {
1070 + // Skip any hidden subtrees. They were or are effectively not there.
1071 + } else if (
1072 + child.tag === ViewTransitionComponent &&
1073 + stopAtNestedViewTransitions
1074 + ) {
1075 + // Skip any nested view transitions for updates since in that case the
1076 + // inner most one is the one that handles the update.
1077 + } else {
1078 + cancelViewTransitionHostInstances(
1079 + currentViewTransition,
1080 + child.child,
1081 + stopAtNestedViewTransitions,
1082 + );
1083 + }
1084 + child = child.sibling;
1085 + }
1086 +}
1087 +
1088 function measureViewTransitionHostInstances(
1089 currentViewTransition: Fiber,
1090 parentViewTransition: Fiber,
1091 child: null | Fiber,
1092 + name: string,
1093 + className: ?string,
1094 previousMeasurements: null | Array<InstanceMeasurement>,
1095 stopAtNestedViewTransitions: boolean,
1096 ): boolean {
@@ -1028,20 +1136,15 @@ function measureViewTransitionHostInstances(
1136 parentViewTransition.flags |= AffectedParentLayout;
1137 }
1138 if ((parentViewTransition.flags & Update) !== NoFlags) {
1031 - const props: ViewTransitionProps = parentViewTransition.memoizedProps;
1139 // We might update this node so we need to apply its new name for the new state.
1033 - const newName = getViewTransitionName(
1034 - props,
1035 - parentViewTransition.stateNode,
1036 - );
1140 applyViewTransitionName(
1141 instance,
1142 viewTransitionHostInstanceIdx === 0
1040 - ? newName
1143 + ? name
1144 : // If we have multiple Host Instances below, we add a suffix to the name to give
1145 // each one a unique name.
1043 - newName + '_' + viewTransitionHostInstanceIdx,
1044 - props.className,
1146 + name + '_' + viewTransitionHostInstanceIdx,
1147 + className,
1148 );
1149 }
1150 if (!inViewport || (parentViewTransition.flags & Update) === NoFlags) {
@@ -1083,6 +1186,8 @@ function measureViewTransitionHostInstances(
1186 currentViewTransition,
1187 parentViewTransition,
1188 child.child,
1189 + name,
1190 + className,
1191 previousMeasurements,
1192 stopAtNestedViewTransitions,
1193 )
@@ -1099,6 +1204,42 @@ function measureUpdateViewTransition(
1204 current: Fiber,
1205 finishedWork: Fiber,
1206 ): boolean {
1207 + const props: ViewTransitionProps = finishedWork.memoizedProps;
1208 + const updateClassName: ?string = getViewTransitionClassName(
1209 + props.className,
1210 + props.update,
1211 + );
1212 + const layoutClassName: ?string = getViewTransitionClassName(
1213 + props.className,
1214 + props.update,
1215 + );
1216 + let className: ?string;
1217 + if (updateClassName === 'none') {
1218 + if (layoutClassName === 'none') {
1219 + // If both update and layout class name were none, then we didn't apply any
1220 + // names in the before update phase so we shouldn't now neither.
1221 + return false;
1222 + }
1223 + // We don't care if this is mutated or children layout changed, but we still
1224 + // measure each instance to see if it moved and therefore should apply layout.
1225 + finishedWork.flags &= ~Update;
1226 + className = layoutClassName;
1227 + } else if ((finishedWork.flags & Update) !== NoFlags) {
1228 + // It was updated and we have an appropriate class name to apply.
1229 + className = updateClassName;
1230 + } else {
1231 + if (layoutClassName === 'none') {
1232 + // If we did not update, then all changes are considered a layout. We'll
1233 + // attempt to cancel.
1234 + viewTransitionHostInstanceIdx = 0;
1235 + cancelViewTransitionHostInstances(current, finishedWork.child, true);
1236 + return false;
1237 + }
1238 + // We didn't update but we might still apply layout so we measure each
1239 + // instance to see if it moved or resized.
1240 + className = layoutClassName;
1241 + }
1242 + const name = getViewTransitionName(props, finishedWork.stateNode);
1243 // If nothing changed due to a mutation, or children changing size
1244 // and the measurements end up unchanged, we should restore it to not animate.
1245 viewTransitionHostInstanceIdx = 0;
@@ -1107,6 +1248,8 @@ function measureUpdateViewTransition(
1248 current,
1249 finishedWork,
1250 finishedWork.child,
1251 + name,
1252 + className,
1253 previousMeasurements,
1254 true,
1255 );
@@ -1127,16 +1270,27 @@ function measureNestedViewTransitions(changedParent: Fiber): void {
1270 if (child.tag === ViewTransitionComponent) {
1271 const current = child.alternate;
1272 if (current !== null) {
1273 + const props: ViewTransitionProps = child.memoizedProps;
1274 + const name = getViewTransitionName(props, child.stateNode);
1275 + const className: ?string = getViewTransitionClassName(
1276 + props.className,
1277 + props.layout,
1278 + );
1279 viewTransitionHostInstanceIdx = 0;
1131 - measureViewTransitionHostInstances(
1280 + const inViewport = measureViewTransitionHostInstances(
1281 current,
1282 child,
1283 child.child,
1284 + name,
1285 + className,
1286 child.memoizedState,
1287 false,
1288 );
1138 - const props: ViewTransitionProps = child.memoizedProps;
1139 - scheduleViewTransitionEvent(child, props.onLayout);
1289 + if ((child.flags & Update) === NoFlags || !inViewport) {
1290 + // Nothing changed.
1291 + } else {
1292 + scheduleViewTransitionEvent(child, props.onLayout);
1293 + }
1294 }
1295 } else if ((child.subtreeFlags & ViewTransitionStatic) !== NoFlags) {
1296 measureNestedViewTransitions(child);
@@ -3010,11 +3164,6 @@ function recursivelyTraverseAfterMutationEffects(
3164 // its size and position. We need to measure this and if not, restore it to
3165 // not animate.
3166 measureNestedViewTransitions(parentFiber);
3013 - if ((parentFiber.flags & AffectedParentLayout) !== NoFlags) {
3014 - // This boundary changed size in a way that may have caused its parent to
3015 - // relayout. We need to bubble this information up to the parent.
3016 - viewTransitionContextChanged = true;
3017 - }
3167 }
3168 }
3169
packages/react-reconciler/src/ReactFiberViewTransitionComponent.js
+22 -1
@@ -19,8 +19,13 @@ import {getTreeId} from './ReactFiberTreeContext';
19
20 export type ViewTransitionProps = {
21 name?: string,
22 - className?: string,
22 children?: ReactNodeList,
23 + className?: 'none' | string,
24 + enter?: 'none' | string,
25 + exit?: 'none' | string,
26 + layout?: 'none' | string,
27 + share?: 'none' | string,
28 + update?: 'none' | string,
29 onEnter?: (instance: ViewTransitionInstance) => void,
30 onExit?: (instance: ViewTransitionInstance) => void,
31 onLayout?: (instance: ViewTransitionInstance) => void,
@@ -76,3 +81,19 @@ export function getViewTransitionName(
81 // We should have assigned a name by now.
82 return (instance.autoName: any);
83 }
84 +
85 +export function getViewTransitionClassName(
86 + className: ?string,
87 + eventClassName: ?string,
88 +): ?string {
89 + if (eventClassName == null) {
90 + return className;
91 + }
92 + if (eventClassName === 'none') {
93 + return eventClassName;
94 + }
95 + if (className != null) {
96 + return className + ' ' + eventClassName;
97 + }
98 + return eventClassName;
99 +}