@samitouri / QOS-React-2 / commits / 540efebcc3

View Transition Events (#32041)

This adds five events to `<ViewTransition>` that triggers when React wants to animate it. - `onEnter`: The `<ViewTransition>` or its parent Component is mounted and there's no other `<ViewTransition>` with the same name being deleted. - `onExit`: The `<ViewTransition>` or its parent Component is unmounted and there's no other `<ViewTransition>` with the same name being deleted. - `onLayout`: 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. - `onShare`: This `<ViewTransition>` is being mounted and another `<ViewTransition>` instance with the same name is being unmounted elsewhere. - `onUpdate`: The content of `<ViewTransition>` has changed either due to DOM mutations or because an inner child `<ViewTransition>` has resized. Only one of these events is fired per Transition. If you want to cover all updates you have to listen to `onLayout`, `onShare` and `onUpdate`. We could potentially do something like fire `onUpdate` if `onLayout` or `onShare` isn't specified but it's a little sketchy to have behavior based on if someone is listening since it limits adding wrappers that may or may not need it. Each takes a `ViewTransitionInstance` as an argument so you don't need a ref to animate it. ```js <ViewTransition onEnter={inst => inst.new.animate(keyframes, options)}> ``` The timing of this event is after the View Transition's `ready` state which means that's too late to do any changes to the View Transition's snapshots but now both the new and old pseudo-elements are ready to animate. The order of `onExit` is parent first, where as the others are child first. This mimics effect mount/unmount. I implement this by adding to a queue in the commit phase and then call it while we're finishing up the commit. This is after layout effects but before passive effects since passive effects fire after the animation is `finished`.

Sebastian Markbåge committed Jan 12, 2025 at 13:16 UTC 540efebcc34357c98412a96805bfd9244d6aa678
4 files changed +93 -19
fixtures/view-transition/src/components/Page.js
+10 -16
@@ -1,8 +1,6 @@
1 import React, {
2 unstable_ViewTransition as ViewTransition,
3 unstable_Activity as Activity,
4 - useRef,
5 - useLayoutEffect,
4 } from 'react';
5
6 import './Page.css';
@@ -37,21 +35,17 @@ function Component() {
35 }
36
37 export default function Page({url, navigate}) {
40 - const ref = useRef();
38 const show = url === '/?b';
42 - useLayoutEffect(() => {
43 - const viewTransition = ref.current;
44 - requestAnimationFrame(() => {
45 - const keyframes = [
46 - {rotate: '0deg', transformOrigin: '30px 8px'},
47 - {rotate: '360deg', transformOrigin: '30px 8px'},
48 - ];
49 - viewTransition.old.animate(keyframes, 300);
50 - viewTransition.new.animate(keyframes, 300);
51 - });
52 - }, [show]);
39 + function onTransition(viewTransition) {
40 + const keyframes = [
41 + {rotate: '0deg', transformOrigin: '30px 8px'},
42 + {rotate: '360deg', transformOrigin: '30px 8px'},
43 + ];
44 + viewTransition.old.animate(keyframes, 250);
45 + viewTransition.new.animate(keyframes, 250);
46 + }
47 const exclamation = (
54 - <ViewTransition name="exclamation">
48 + <ViewTransition name="exclamation" onShare={onTransition}>
49 <span>!</span>
50 </ViewTransition>
51 );
@@ -76,7 +70,7 @@ export default function Page({url, navigate}) {
70 {a}
71 </div>
72 )}
79 - <ViewTransition ref={ref}>
73 + <ViewTransition>
74 {show ? <div>hello{exclamation}</div> : <section>Loading</section>}
75 </ViewTransition>
76 <p>scroll me</p>
packages/react-reconciler/src/ReactFiberCommitWork.js
+32 -1
@@ -186,6 +186,7 @@ import {
186 addMarkerIncompleteCallbackToPendingTransition,
187 addMarkerCompleteCallbackToPendingTransition,
188 retryDehydratedSuspenseBoundary,
189 + scheduleViewTransitionEvent,
190 } from './ReactFiberWorkLoop';
191 import {
192 HasEffect as HookHasEffect,
@@ -649,6 +650,7 @@ function commitAppearingPairViewTransitions(placement: Fiber): void {
650 if (child.tag === OffscreenComponent && child.memoizedState === null) {
651 // This tree was already hidden so we skip it.
652 } else {
653 + commitAppearingPairViewTransitions(child);
654 if (
655 child.tag === ViewTransitionComponent &&
656 (child.flags & ViewTransitionNamedStatic) !== NoFlags
@@ -682,7 +684,6 @@ function commitAppearingPairViewTransitions(placement: Fiber): void {
684 }
685 }
686 }
685 - commitAppearingPairViewTransitions(child);
687 }
688 child = child.sibling;
689 }
@@ -701,12 +702,18 @@ function commitEnterViewTransitions(placement: Fiber): void {
702 false,
703 );
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);
712 +
713 + const state: ViewTransitionState = placement.stateNode;
714 + if (!state.paired) {
715 + scheduleViewTransitionEvent(placement, props.onEnter);
716 + }
717 }
718 } else if ((placement.subtreeFlags & ViewTransitionStatic) !== NoFlags) {
719 let child = placement.child;
@@ -764,6 +771,9 @@ function commitDeletedPairViewTransitions(
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 }
778 // Delete the entry so that we know when we've found all of them
779 // and can stop searching (size reaches zero).
@@ -811,9 +821,16 @@ function commitExitViewTransitions(
821 // Delete the entry so that we know when we've found all of them
822 // and can stop searching (size reaches zero).
823 appearingViewTransitions.delete(name);
824 + // Note: If the other side ends up outside the viewport, we'll still run this.
825 + // Therefore it's possible for onShare to be called with only an old snapshot.
826 + scheduleViewTransitionEvent(deletion, props.onShare);
827 + } else {
828 + scheduleViewTransitionEvent(deletion, props.onExit);
829 }
830 // Look for more pairs deeper in the tree.
831 commitDeletedPairViewTransitions(deletion, appearingViewTransitions);
832 + } else {
833 + scheduleViewTransitionEvent(deletion, props.onExit);
834 }
835 } else if ((deletion.subtreeFlags & ViewTransitionStatic) !== NoFlags) {
836 let child = deletion.child;
@@ -1118,6 +1135,8 @@ function measureNestedViewTransitions(changedParent: Fiber): void {
1135 child.memoizedState,
1136 false,
1137 );
1138 + const props: ViewTransitionProps = child.memoizedProps;
1139 + scheduleViewTransitionEvent(child, props.onLayout);
1140 }
1141 } else if ((child.subtreeFlags & ViewTransitionStatic) !== NoFlags) {
1142 measureNestedViewTransitions(child);
@@ -3075,6 +3094,8 @@ function commitAfterMutationEffectsOnFiber(
3094 (Placement | Update | ChildDeletion | ContentReset | Visibility)) !==
3095 NoFlags
3096 ) {
3097 + const wasMutated = (finishedWork.flags & Update) !== NoFlags;
3098 +
3099 const prevContextChanged = viewTransitionContextChanged;
3100 const prevCancelableChildren = viewTransitionCancelableChildren;
3101 viewTransitionContextChanged = false;
@@ -3103,7 +3124,17 @@ function commitAfterMutationEffectsOnFiber(
3124 );
3125 viewTransitionCancelableChildren = prevCancelableChildren;
3126 }
3127 + // TODO: If this doesn't end up canceled, because a parent animates,
3128 + // then we should probably issue an event since this instance is part of it.
3129 } else {
3130 + const props: ViewTransitionProps = finishedWork.memoizedProps;
3131 + scheduleViewTransitionEvent(
3132 + finishedWork,
3133 + wasMutated || viewTransitionContextChanged
3134 + ? props.onUpdate
3135 + : props.onLayout,
3136 + );
3137 +
3138 // If this boundary did update, we cannot cancel its children so those are dropped.
3139 viewTransitionCancelableChildren = prevCancelableChildren;
3140 }
packages/react-reconciler/src/ReactFiberViewTransitionComponent.js
+5
@@ -21,6 +21,11 @@ export type ViewTransitionProps = {
21 name?: string,
22 className?: string,
23 children?: ReactNodeList,
24 + onEnter?: (instance: ViewTransitionInstance) => void,
25 + onExit?: (instance: ViewTransitionInstance) => void,
26 + onLayout?: (instance: ViewTransitionInstance) => void,
27 + onShare?: (instance: ViewTransitionInstance) => void,
28 + onUpdate?: (instance: ViewTransitionInstance) => void,
29 };
30
31 export type ViewTransitionState = {
packages/react-reconciler/src/ReactFiberWorkLoop.js
+46 -2
@@ -21,9 +21,12 @@ import type {
21 TransitionAbort,
22 } from './ReactFiberTracingMarkerComponent';
23 import type {OffscreenInstance} from './ReactFiberActivityComponent';
24 -import type {Resource} from './ReactFiberConfig';
24 +import type {Resource, ViewTransitionInstance} from './ReactFiberConfig';
25 import type {RootState} from './ReactFiberRoot';
26 -import type {ViewTransitionState} from './ReactFiberViewTransitionComponent';
26 +import {
27 + getViewTransitionName,
28 + type ViewTransitionState,
29 +} from './ReactFiberViewTransitionComponent';
30
31 import {
32 enableCreateEventHandleAPI,
@@ -95,6 +98,7 @@ import {
98 resolveUpdatePriority,
99 trackSchedulerEvent,
100 startViewTransition,
101 + createViewTransitionInstance,
102 } from './ReactFiberConfig';
103
104 import {createWorkInProgress, resetWorkInProgress} from './ReactFiber';
@@ -649,6 +653,7 @@ let pendingEffectsRemainingLanes: Lanes = NoLanes;
653 let pendingEffectsRenderEndTime: number = -0; // Profiling-only
654 let pendingPassiveTransitions: Array<Transition> | null = null;
655 let pendingRecoverableErrors: null | Array<CapturedValue<mixed>> = null;
656 +let pendingViewTransitionEvents: Array<() => void> | null = null;
657 let pendingDidIncludeRenderPhaseUpdate: boolean = false;
658 let pendingSuspendedCommitReason: SuspendedCommitReason = IMMEDIATE_COMMIT; // Profiling-only
659
@@ -797,6 +802,27 @@ export function requestDeferredLane(): Lane {
802 return workInProgressDeferredLane;
803 }
804
805 +export function scheduleViewTransitionEvent(
806 + fiber: Fiber,
807 + callback: ?(instance: ViewTransitionInstance) => void,
808 +): void {
809 + if (enableViewTransition) {
810 + if (callback != null) {
811 + const state: ViewTransitionState = fiber.stateNode;
812 + let instance = state.ref;
813 + if (instance === null) {
814 + instance = state.ref = createViewTransitionInstance(
815 + getViewTransitionName(fiber.memoizedProps, state),
816 + );
817 + }
818 + if (pendingViewTransitionEvents === null) {
819 + pendingViewTransitionEvents = [];
820 + }
821 + pendingViewTransitionEvents.push(callback.bind(null, instance));
822 + }
823 + }
824 +}
825 +
826 export function peekDeferredLane(): Lane {
827 return workInProgressDeferredLane;
828 }
@@ -3322,6 +3348,9 @@ function commitRoot(
3348 pendingEffectsRemainingLanes = remainingLanes;
3349 pendingPassiveTransitions = transitions;
3350 pendingRecoverableErrors = recoverableErrors;
3351 + if (enableViewTransition) {
3352 + pendingViewTransitionEvents = null;
3353 + }
3354 pendingDidIncludeRenderPhaseUpdate = didIncludeRenderPhaseUpdate;
3355 if (enableProfilerTimer) {
3356 pendingEffectsRenderEndTime = completedRenderEndTime;
@@ -3673,6 +3702,21 @@ function flushSpawnedWork(): void {
3702 }
3703 }
3704
3705 + if (enableViewTransition) {
3706 + // We should now be after the startViewTransition's .ready call which is late enough
3707 + // to start animating any pseudo-elements. We do this before flushing any passive
3708 + // effects or spawned sync work since this is still part of the previous commit.
3709 + // Even though conceptually it's like its own task between layout effets and passive.
3710 + const pendingEvents = pendingViewTransitionEvents;
3711 + if (pendingEvents !== null) {
3712 + pendingViewTransitionEvents = null;
3713 + for (let i = 0; i < pendingEvents.length; i++) {
3714 + const viewTransitionEvent = pendingEvents[i];
3715 + viewTransitionEvent();
3716 + }
3717 + }
3718 + }
3719 +
3720 // If the passive effects are the result of a discrete render, flush them
3721 // synchronously at the end of the current task so that the result is
3722 // immediately observable. Otherwise, we assume that they are not