@samitouri / QOS-React / commits / 4bcf67e746

Support onGestureEnter/Exit/Share/Update events (#35556)

This is like the onEnter/Exit/Share/Update events but for gestures. It allows manually controlling the animation using the passed timeline.

Sebastian Markbåge committed Jan 19, 2026 at 19:26 UTC 4bcf67e74657086ff7d8f951d6365db06cf1f72c
5 files changed +154 -6
fixtures/view-transition/src/components/Page.js
+46 -1
@@ -86,6 +86,48 @@ export default function Page({url, navigate}) {
86 viewTransition.new.animate(keyframes, 250);
87 }
88
89 + function onGestureTransition(
90 + timeline,
91 + {rangeStart, rangeEnd},
92 + viewTransition,
93 + types
94 + ) {
95 + const keyframes = [
96 + {rotate: '0deg', transformOrigin: '30px 8px'},
97 + {rotate: '360deg', transformOrigin: '30px 8px'},
98 + ];
99 + const reverse = rangeStart > rangeEnd;
100 + if (timeline instanceof AnimationTimeline) {
101 + // Native Timeline
102 + const options = {
103 + timeline: timeline,
104 + direction: reverse ? 'normal' : 'reverse',
105 + rangeStart: (reverse ? rangeEnd : rangeStart) + '%',
106 + rangeEnd: (reverse ? rangeStart : rangeEnd) + '%',
107 + };
108 + viewTransition.old.animate(keyframes, options);
109 + viewTransition.new.animate(keyframes, options);
110 + } else {
111 + // Custom Timeline
112 + const options = {
113 + direction: reverse ? 'normal' : 'reverse',
114 + // We set the delay and duration to represent the span of the range.
115 + delay: reverse ? rangeEnd : rangeStart,
116 + duration: reverse ? rangeStart - rangeEnd : rangeEnd - rangeStart,
117 + };
118 + const animation1 = viewTransition.old.animate(keyframes, options);
119 + const animation2 = viewTransition.new.animate(keyframes, options);
120 + // Let the custom timeline take control of driving the animations.
121 + const cleanup1 = timeline.animate(animation1);
122 + const cleanup2 = timeline.animate(animation2);
123 + // TODO: Support returning a clean up function from ViewTransition events.
124 + // return () => {
125 + // cleanup1();
126 + // cleanup2();
127 + // };
128 + }
129 + }
130 +
131 function swipeAction() {
132 navigate(show ? '/?a' : '/?b');
133 }
@@ -131,7 +173,10 @@ export default function Page({url, navigate}) {
173 );
174
175 const exclamation = (
134 - <ViewTransition name="exclamation" onShare={onTransition}>
176 + <ViewTransition
177 + name="exclamation"
178 + onShare={onTransition}
179 + onGestureShare={onGestureTransition}>
180 <span>
181 <div>!</div>
182 </span>
packages/react-reconciler/src/ReactFiberApplyGesture.js
+9 -1
@@ -82,6 +82,7 @@ import {
82 enableComponentPerformanceTrack,
83 } from 'shared/ReactFeatureFlags';
84 import {trackAnimatingTask} from './ReactProfilerTimer';
85 +import {scheduleGestureTransitionEvent} from './ReactFiberWorkLoop';
86
87 let didWarnForRootClone = false;
88
@@ -280,6 +281,7 @@ function applyAppearingPairViewTransition(child: Fiber): void {
281 if (clones !== null) {
282 applyViewTransitionToClones(name, className, clones, child);
283 }
284 + scheduleGestureTransitionEvent(child, props.onGestureShare);
285 }
286 }
287 }
@@ -310,6 +312,11 @@ function applyExitViewTransition(placement: Fiber): void {
312 if (clones !== null) {
313 applyViewTransitionToClones(name, className, clones, placement);
314 }
315 + if (state.paired) {
316 + scheduleGestureTransitionEvent(placement, props.onGestureShare);
317 + } else {
318 + scheduleGestureTransitionEvent(placement, props.onGestureExit);
319 + }
320 }
321 }
322
@@ -1123,7 +1130,8 @@ function applyViewTransitionsOnFiber(finishedWork: Fiber) {
1130 // TODO: If this doesn't end up canceled, because a parent animates,
1131 // then we should probably issue an event since this instance is part of it.
1132 } else {
1126 - // TODO: Schedule gesture events.
1133 + const props: ViewTransitionProps = finishedWork.memoizedProps;
1134 + scheduleGestureTransitionEvent(finishedWork, props.onGestureUpdate);
1135 // If this boundary did update, we cannot cancel its children so those are dropped.
1136 popViewTransitionCancelableScope(prevCancelableChildren);
1137 }
packages/react-reconciler/src/ReactFiberCommitViewTransitions.js
+6 -3
@@ -34,7 +34,10 @@ import {
34 hasInstanceAffectedParent,
35 wasInstanceInViewport,
36 } from './ReactFiberConfig';
37 -import {scheduleViewTransitionEvent} from './ReactFiberWorkLoop';
37 +import {
38 + scheduleViewTransitionEvent,
39 + scheduleGestureTransitionEvent,
40 +} from './ReactFiberWorkLoop';
41 import {
42 getViewTransitionName,
43 getViewTransitionClassName,
@@ -312,7 +315,7 @@ export function commitEnterViewTransitions(
315
316 if (!state.paired) {
317 if (gesture) {
315 - // TODO: Schedule gesture events.
318 + scheduleGestureTransitionEvent(placement, props.onGestureEnter);
319 } else {
320 scheduleViewTransitionEvent(placement, props.onEnter);
321 }
@@ -848,7 +851,7 @@ export function measureNestedViewTransitions(
851 // Nothing changed.
852 } else {
853 if (gesture) {
851 - // TODO: Schedule gesture events.
854 + scheduleGestureTransitionEvent(child, props.onGestureUpdate);
855 } else {
856 scheduleViewTransitionEvent(child, props.onUpdate);
857 }
packages/react-reconciler/src/ReactFiberWorkLoop.js
+64 -1
@@ -9,7 +9,11 @@
9
10 import {REACT_STRICT_MODE_TYPE} from 'shared/ReactSymbols';
11
12 -import type {Wakeable, Thenable} from 'shared/ReactTypes';
12 +import type {
13 + Wakeable,
14 + Thenable,
15 + GestureOptionsRequired,
16 +} from 'shared/ReactTypes';
17 import type {Fiber, FiberRoot} from './ReactInternalTypes';
18 import type {Lanes, Lane} from './ReactFiberLane';
19 import type {ActivityState} from './ReactFiberActivityComponent';
@@ -26,6 +30,7 @@ import type {
30 Resource,
31 ViewTransitionInstance,
32 RunningViewTransition,
33 + GestureTimeline,
34 SuspendedState,
35 } from './ReactFiberConfig';
36 import type {RootState} from './ReactFiberRoot';
@@ -913,6 +918,42 @@ export function scheduleViewTransitionEvent(
918 }
919 }
920
921 +export function scheduleGestureTransitionEvent(
922 + fiber: Fiber,
923 + callback: ?(
924 + timeline: GestureTimeline,
925 + options: GestureOptionsRequired,
926 + instance: ViewTransitionInstance,
927 + types: Array<string>,
928 + ) => void,
929 +): void {
930 + if (enableGestureTransition) {
931 + if (callback != null) {
932 + const applyingGesture = pendingEffectsRoot.pendingGestures;
933 + if (applyingGesture !== null) {
934 + const state: ViewTransitionState = fiber.stateNode;
935 + let instance = state.ref;
936 + if (instance === null) {
937 + instance = state.ref = createViewTransitionInstance(
938 + getViewTransitionName(fiber.memoizedProps, state),
939 + );
940 + }
941 + const timeline = applyingGesture.provider;
942 + const options = {
943 + rangeStart: applyingGesture.rangeStart,
944 + rangeEnd: applyingGesture.rangeEnd,
945 + };
946 + if (pendingViewTransitionEvents === null) {
947 + pendingViewTransitionEvents = [];
948 + }
949 + pendingViewTransitionEvents.push(
950 + callback.bind(null, timeline, options, instance),
951 + );
952 + }
953 + }
954 + }
955 +}
956 +
957 export function peekDeferredLane(): Lane {
958 return workInProgressDeferredLane;
959 }
@@ -4352,6 +4393,8 @@ function applyGestureOnRoot(
4393 startAnimating(pendingEffectsLanes);
4394 }
4395
4396 + pendingViewTransitionEvents = null;
4397 +
4398 const prevTransition = ReactSharedInternals.T;
4399 ReactSharedInternals.T = null;
4400 const previousPriority = getCurrentUpdatePriority();
@@ -4476,6 +4519,26 @@ function flushGestureAnimations(): void {
4519 ReactSharedInternals.T = prevTransition;
4520 }
4521
4522 + if (enableViewTransition) {
4523 + // We should now be after the startGestureTransition's .ready call which is late enough
4524 + // to start animating any pseudo-elements. We have also already applied any adjustments
4525 + // we do to the built-in animations which can now be read by the refs.
4526 + const pendingEvents = pendingViewTransitionEvents;
4527 + let pendingTypes = pendingTransitionTypes;
4528 + pendingTransitionTypes = null;
4529 + if (pendingEvents !== null) {
4530 + pendingViewTransitionEvents = null;
4531 + if (pendingTypes === null) {
4532 + // Normalize the type. This is lazily created only for events.
4533 + pendingTypes = [];
4534 + }
4535 + for (let i = 0; i < pendingEvents.length; i++) {
4536 + const viewTransitionEvent = pendingEvents[i];
4537 + viewTransitionEvent(pendingTypes);
4538 + }
4539 + }
4540 + }
4541 +
4542 if (enableProfilerTimer && enableComponentPerformanceTrack) {
4543 finalizeRender(lanes, commitEndTime);
4544 }
packages/shared/ReactTypes.js
+29
@@ -290,6 +290,11 @@ export type ViewTransitionClass =
290 | string
291 | ViewTransitionClassPerType;
292
293 +export type GestureOptionsRequired = {
294 + rangeStart: number,
295 + rangeEnd: number,
296 +};
297 +
298 export type ViewTransitionProps = {
299 name?: string,
300 children?: ReactNodeList,
@@ -302,6 +307,30 @@ export type ViewTransitionProps = {
307 onExit?: (instance: ViewTransitionInstance, types: Array<string>) => void,
308 onShare?: (instance: ViewTransitionInstance, types: Array<string>) => void,
309 onUpdate?: (instance: ViewTransitionInstance, types: Array<string>) => void,
310 + onGestureEnter?: (
311 + timeline: GestureProvider,
312 + options: GestureOptionsRequired,
313 + instance: ViewTransitionInstance,
314 + types: Array<string>,
315 + ) => void,
316 + onGestureExit?: (
317 + timeline: GestureProvider,
318 + options: GestureOptionsRequired,
319 + instance: ViewTransitionInstance,
320 + types: Array<string>,
321 + ) => void,
322 + onGestureShare?: (
323 + timeline: GestureProvider,
324 + options: GestureOptionsRequired,
325 + instance: ViewTransitionInstance,
326 + types: Array<string>,
327 + ) => void,
328 + onGestureUpdate?: (
329 + timeline: GestureProvider,
330 + options: GestureOptionsRequired,
331 + instance: ViewTransitionInstance,
332 + types: Array<string>,
333 + ) => void,
334 };
335
336 export type ActivityProps = {