@samitouri / QOS-React / commits / e233218359

Track "Animating" Entry for Gestures while the Gesture is Still On-going (#34548)

Stacked on #34546. Same as #34538 but for gestures. Includes various fixes. This shows how it ends with a Transition when you release in the committed state. Note how the Animation of the Gesture continues until the Transition is done so that the handoff is seamless. <img width="853" height="134" alt="Screenshot 2025-09-20 at 7 37 29 PM" src="https://github.com/user-attachments/assets/6192a033-4bec-43b9-884b-77e3a6f00da6" />

Sebastian Markbåge committed Sep 24, 2025 at 11:26 UTC e2332183591ff3a5657c3322a21bcdcccae32088
7 files changed +110 -91
packages/react-dom-bindings/src/client/ReactFiberConfigDOM.js
+13
@@ -2320,6 +2320,9 @@ export function startViewTransition(
2320 mutationCallback();
2321 layoutCallback();
2322 // Skip afterMutationCallback(). We don't need it since we're not animating.
2323 + if (enableProfilerTimer) {
2324 + finishedAnimation();
2325 + }
2326 spawnedWorkCallback();
2327 // Skip passiveCallback(). Spawned work will schedule a task.
2328 return null;
@@ -2509,6 +2512,7 @@ export function startGestureTransition(
2512 mutationCallback: () => void,
2513 animateCallback: () => void,
2514 errorCallback: mixed => void,
2515 + finishedAnimation: () => void, // Profiling-only
2516 ): null | RunningViewTransition {
2517 const ownerDocument: Document =
2518 rootContainer.nodeType === DOCUMENT_NODE
@@ -2723,6 +2727,12 @@ export function startGestureTransition(
2727 // $FlowFixMe[prop-missing]
2728 ownerDocument.__reactViewTransition = null;
2729 }
2730 + if (enableProfilerTimer) {
2731 + // Signal that the Transition was unable to continue. We do that here
2732 + // instead of when we stop the running View Transition to ensure that
2733 + // we cover cases when something else stops it early.
2734 + finishedAnimation();
2735 + }
2736 });
2737 return transition;
2738 } catch (x) {
@@ -2735,6 +2745,9 @@ export function startGestureTransition(
2745 // Run through the sequence to put state back into a consistent state.
2746 mutationCallback();
2747 animateCallback();
2748 + if (enableProfilerTimer) {
2749 + finishedAnimation();
2750 + }
2751 return null;
2752 }
2753 }
packages/react-native-renderer/src/ReactFiberConfigNative.js
+9
@@ -35,6 +35,8 @@ import {
35 } from 'react-reconciler/src/ReactEventPriorities';
36 import type {Fiber} from 'react-reconciler/src/ReactInternalTypes';
37
38 +import {enableProfilerTimer} from 'shared/ReactFeatureFlags';
39 +
40 import {REACT_CONTEXT_TYPE} from 'shared/ReactSymbols';
41 import type {ReactContext} from 'shared/ReactTypes';
42
@@ -680,6 +682,9 @@ export function startViewTransition(
682 layoutCallback();
683 // Skip afterMutationCallback(). We don't need it since we're not animating.
684 spawnedWorkCallback();
685 + if (enableProfilerTimer) {
686 + finishedAnimation();
687 + }
688 // Skip passiveCallback(). Spawned work will schedule a task.
689 return null;
690 }
@@ -696,9 +701,13 @@ export function startGestureTransition(
701 mutationCallback: () => void,
702 animateCallback: () => void,
703 errorCallback: mixed => void,
704 + finishedAnimation: () => void, // Profiling-only
705 ): null | RunningViewTransition {
706 mutationCallback();
707 animateCallback();
708 + if (enableProfilerTimer) {
709 + finishedAnimation();
710 + }
711 return null;
712 }
713
packages/react-reconciler/src/ReactFiberApplyGesture.js
+18 -6
@@ -77,6 +77,12 @@ import {
77 getViewTransitionClassName,
78 } from './ReactFiberViewTransitionComponent';
79
80 +import {
81 + enableProfilerTimer,
82 + enableComponentPerformanceTrack,
83 +} from 'shared/ReactFeatureFlags';
84 +import {trackAnimatingTask} from './ReactProfilerTimer';
85 +
86 let didWarnForRootClone = false;
87
88 // Used during the apply phase to track whether a parent ViewTransition component
@@ -101,6 +107,7 @@ function applyViewTransitionToClones(
107 name: string,
108 className: ?string,
109 clones: Array<Instance>,
110 + fiber: Fiber,
111 ): void {
112 // This gets called when we have found a pair, but after the clone in created. The clone is
113 // created by the insertion side. If the insertion side if found before the deletion side
@@ -117,6 +124,11 @@ function applyViewTransitionToClones(
124 className,
125 );
126 }
127 + if (enableProfilerTimer && enableComponentPerformanceTrack) {
128 + if (fiber._debugTask != null) {
129 + trackAnimatingTask(fiber._debugTask);
130 + }
131 + }
132 }
133
134 function trackDeletedPairViewTransitions(deletion: Fiber): void {
@@ -171,7 +183,7 @@ function trackDeletedPairViewTransitions(deletion: Fiber): void {
183 // If we have clones that means that we've already visited this
184 // ViewTransition boundary before and we can now apply the name
185 // to those clones. Otherwise, we have to wait until we clone it.
174 - applyViewTransitionToClones(name, className, clones);
186 + applyViewTransitionToClones(name, className, clones, child);
187 }
188 }
189 if (pairs.size === 0) {
@@ -221,7 +233,7 @@ function trackEnterViewTransitions(deletion: Fiber): void {
233 // If we have clones that means that we've already visited this
234 // ViewTransition boundary before and we can now apply the name
235 // to those clones. Otherwise, we have to wait until we clone it.
224 - applyViewTransitionToClones(name, className, clones);
236 + applyViewTransitionToClones(name, className, clones, deletion);
237 }
238 }
239 }
@@ -266,7 +278,7 @@ function applyAppearingPairViewTransition(child: Fiber): void {
278 // If there are no clones at this point, that should mean that there are no
279 // HostComponent children in this ViewTransition.
280 if (clones !== null) {
269 - applyViewTransitionToClones(name, className, clones);
281 + applyViewTransitionToClones(name, className, clones, child);
282 }
283 }
284 }
@@ -296,7 +308,7 @@ function applyExitViewTransition(placement: Fiber): void {
308 // If there are no clones at this point, that should mean that there are no
309 // HostComponent children in this ViewTransition.
310 if (clones !== null) {
299 - applyViewTransitionToClones(name, className, clones);
311 + applyViewTransitionToClones(name, className, clones, placement);
312 }
313 }
314 }
@@ -314,7 +326,7 @@ function applyNestedViewTransition(child: Fiber): void {
326 // If there are no clones at this point, that should mean that there are no
327 // HostComponent children in this ViewTransition.
328 if (clones !== null) {
317 - applyViewTransitionToClones(name, className, clones);
329 + applyViewTransitionToClones(name, className, clones, child);
330 }
331 }
332 }
@@ -346,7 +358,7 @@ function applyUpdateViewTransition(current: Fiber, finishedWork: Fiber): void {
358 // If there are no clones at this point, that should mean that there are no
359 // HostComponent children in this ViewTransition.
360 if (clones !== null) {
349 - applyViewTransitionToClones(oldName, className, clones);
361 + applyViewTransitionToClones(oldName, className, clones, finishedWork);
362 }
363 }
364
packages/react-reconciler/src/ReactFiberPerformanceTrack.js
+8 -42
@@ -753,7 +753,6 @@ export function logBlockingStart(
753 }
754
755 export function logGestureStart(
756 - startTime: number,
756 updateTime: number,
757 eventTime: number,
758 eventType: null | string,
@@ -774,22 +773,15 @@ export function logGestureStart(
773 } else {
774 updateTime = renderStartTime;
775 }
777 - if (startTime > 0) {
778 - if (startTime > updateTime) {
779 - startTime = updateTime;
780 - }
781 - } else {
782 - startTime = updateTime;
783 - }
776 if (eventTime > 0) {
785 - if (eventTime > startTime) {
786 - eventTime = startTime;
777 + if (eventTime > updateTime) {
778 + eventTime = updateTime;
779 }
780 } else {
789 - eventTime = startTime;
781 + eventTime = updateTime;
782 }
783
792 - if (startTime > eventTime && eventType !== null) {
784 + if (updateTime > eventTime && eventType !== null) {
785 // Log the time from the event timeStamp until we started a gesture.
786 const color = eventIsRepeat ? 'secondary-light' : 'warning';
787 if (__DEV__ && debugTask) {
@@ -798,7 +790,7 @@ export function logGestureStart(
790 console,
791 eventIsRepeat ? 'Consecutive' : 'Event: ' + eventType,
792 eventTime,
801 - startTime,
793 + updateTime,
794 currentTrack,
795 LANES_TRACK_GROUP,
796 color,
@@ -808,36 +800,10 @@ export function logGestureStart(
800 console.timeStamp(
801 eventIsRepeat ? 'Consecutive' : 'Event: ' + eventType,
802 eventTime,
811 - startTime,
812 - currentTrack,
813 - LANES_TRACK_GROUP,
814 - color,
815 - );
816 - }
817 - }
818 - if (updateTime > startTime) {
819 - // Log the time from when we started a gesture until we called setState or started rendering.
820 - if (__DEV__ && debugTask) {
821 - debugTask.run(
822 - // $FlowFixMe[method-unbinding]
823 - console.timeStamp.bind(
824 - console,
825 - 'Gesture',
826 - startTime,
827 - updateTime,
828 - currentTrack,
829 - LANES_TRACK_GROUP,
830 - 'primary-dark',
831 - ),
832 - );
833 - } else {
834 - console.timeStamp(
835 - 'Gesture',
836 - startTime,
803 updateTime,
804 currentTrack,
805 LANES_TRACK_GROUP,
840 - 'primary-dark',
806 + color,
807 );
808 }
809 }
@@ -846,8 +812,8 @@ export function logGestureStart(
812 const label = isPingedUpdate
813 ? 'Promise Resolved'
814 : renderStartTime - updateTime > 5
849 - ? 'Update Blocked'
850 - : 'Update';
815 + ? 'Gesture Blocked'
816 + : 'Gesture';
817 if (__DEV__) {
818 const properties = [];
819 if (updateComponentName != null) {
packages/react-reconciler/src/ReactFiberWorkLoop.js
+39 -7
@@ -284,7 +284,6 @@ import {
284 blockingEventIsRepeat,
285 blockingSuspendedTime,
286 gestureClampTime,
287 - gestureStartTime,
287 gestureUpdateTime,
288 gestureUpdateTask,
289 gestureUpdateType,
@@ -307,6 +306,7 @@ import {
306 transitionSuspendedTime,
307 clearBlockingTimers,
308 clearGestureTimers,
309 + clearGestureUpdates,
310 clearTransitionTimers,
311 clampBlockingTimers,
312 clampGestureTimers,
@@ -1981,10 +1981,6 @@ function prepareFreshStack(root: FiberRoot, lanes: Lanes): Fiber {
1981 workInProgressUpdateTask = null;
1982 if (isGestureRender(lanes)) {
1983 workInProgressUpdateTask = gestureUpdateTask;
1984 - const clampedStartTime =
1985 - gestureStartTime >= 0 && gestureStartTime < gestureClampTime
1986 - ? gestureClampTime
1987 - : gestureStartTime;
1984 const clampedUpdateTime =
1985 gestureUpdateTime >= 0 && gestureUpdateTime < gestureClampTime
1986 ? gestureClampTime
@@ -2018,7 +2014,6 @@ function prepareFreshStack(root: FiberRoot, lanes: Lanes): Fiber {
2014 );
2015 }
2016 logGestureStart(
2021 - clampedStartTime,
2017 clampedUpdateTime,
2018 clampedEventTime,
2019 gestureEventType,
@@ -2054,7 +2049,10 @@ function prepareFreshStack(root: FiberRoot, lanes: Lanes): Fiber {
2049 lanes,
2050 previousUpdateTask,
2051 );
2057 - } else if (includesBlockingLane(animatingLanes)) {
2052 + } else if (
2053 + !isGestureRender(animatingLanes) &&
2054 + includesBlockingLane(animatingLanes)
2055 + ) {
2056 // If this lane is still animating, log the time from previous render finishing to now as animating.
2057 setCurrentTrackFromLanes(SyncLane);
2058 logAnimatingPhase(
@@ -3528,6 +3526,11 @@ function commitRoot(
3526 // Gestures don't clear their lanes while the gesture is still active but it
3527 // might not be scheduled to do any more renders and so we shouldn't schedule
3528 // any more gesture lane work until a new gesture is scheduled.
3529 + if (enableProfilerTimer && (remainingLanes & GestureLane) !== NoLanes) {
3530 + // We need to clear any updates scheduled so that we can treat future updates
3531 + // as the cause of the render.
3532 + clearGestureUpdates();
3533 + }
3534 remainingLanes &= ~GestureLane;
3535 }
3536
@@ -4251,6 +4254,10 @@ function commitGestureOnRoot(
4254 }
4255 deleteScheduledGesture(root, finishedGesture);
4256
4257 + if (enableProfilerTimer && enableComponentPerformanceTrack) {
4258 + startAnimating(pendingEffectsLanes);
4259 + }
4260 +
4261 const prevTransition = ReactSharedInternals.T;
4262 ReactSharedInternals.T = null;
4263 const previousPriority = getCurrentUpdatePriority();
@@ -4278,6 +4285,10 @@ function commitGestureOnRoot(
4285 flushGestureMutations,
4286 flushGestureAnimations,
4287 reportViewTransitionError,
4288 + enableProfilerTimer
4289 + ? // This callback fires after "pendingEffects" so we need to snapshot the arguments.
4290 + finishedViewTransition.bind(null, pendingEffectsLanes)
4291 + : (null: any),
4292 );
4293 }
4294
@@ -4320,6 +4331,23 @@ function flushGestureAnimations(): void {
4331 if (pendingEffectsStatus !== PENDING_GESTURE_ANIMATION_PHASE) {
4332 return;
4333 }
4334 +
4335 + const lanes = pendingEffectsLanes;
4336 +
4337 + if (enableProfilerTimer && enableComponentPerformanceTrack) {
4338 + // Update the new commitEndTime to when we started the animation.
4339 + recordCommitEndTime();
4340 + logStartViewTransitionYieldPhase(
4341 + pendingEffectsRenderEndTime,
4342 + commitEndTime,
4343 + pendingDelayedCommitReason === ABORTED_VIEW_TRANSITION_COMMIT,
4344 + animatingTask,
4345 + );
4346 + if (pendingDelayedCommitReason !== ABORTED_VIEW_TRANSITION_COMMIT) {
4347 + pendingDelayedCommitReason = ANIMATION_STARTED_COMMIT;
4348 + }
4349 + }
4350 +
4351 pendingEffectsStatus = NO_PENDING_EFFECTS;
4352 const root = pendingEffectsRoot;
4353 const finishedWork = pendingFinishedWork;
@@ -4344,6 +4372,10 @@ function flushGestureAnimations(): void {
4372 ReactSharedInternals.T = prevTransition;
4373 }
4374
4375 + if (enableProfilerTimer && enableComponentPerformanceTrack) {
4376 + finalizeRender(lanes, commitEndTime);
4377 + }
4378 +
4379 // Now that we've rendered this lane. Start working on the next lane.
4380 ensureRootIsScheduled(root);
4381 }
packages/react-reconciler/src/ReactProfilerTimer.js
+18 -36
@@ -77,7 +77,6 @@ export let blockingEventIsRepeat: boolean = false;
77 export let blockingSuspendedTime: number = -1.1;
78
79 export let gestureClampTime: number = -0;
80 -export let gestureStartTime: number = -1.1; // First startGestureTransition call before setOptimistic.
80 export let gestureUpdateTime: number = -1.1; // First setOptimistic scheduled inside startGestureTransition.
81 export let gestureUpdateTask: null | ConsoleTask = null; // First sync setState's stack trace.
82 export let gestureUpdateType: UpdateType = 0;
@@ -134,18 +133,16 @@ export function startUpdateTimerByLane(
133 if (__DEV__ && fiber != null) {
134 gestureUpdateComponentName = getComponentNameFromFiber(fiber);
135 }
137 - if (gestureStartTime < 0) {
138 - const newEventTime = resolveEventTimeStamp();
139 - const newEventType = resolveEventType();
140 - if (
141 - newEventTime !== gestureEventTime ||
142 - newEventType !== gestureEventType
143 - ) {
144 - gestureEventIsRepeat = false;
145 - }
146 - gestureEventTime = newEventTime;
147 - gestureEventType = newEventType;
136 + const newEventTime = resolveEventTimeStamp();
137 + const newEventType = resolveEventType();
138 + if (
139 + newEventTime !== gestureEventTime ||
140 + newEventType !== gestureEventType
141 + ) {
142 + gestureEventIsRepeat = false;
143 }
144 + gestureEventTime = newEventTime;
145 + gestureEventType = newEventType;
146 }
147 } else if (isBlockingLane(lane)) {
148 if (blockingUpdateTime < 0) {
@@ -334,36 +331,12 @@ export function clearTransitionTimers(): void {
331 transitionClampTime = now();
332 }
333
337 -export function startGestureTransitionTimer(): void {
338 - if (!enableProfilerTimer || !enableComponentPerformanceTrack) {
339 - return;
340 - }
341 - if (gestureStartTime < 0 && gestureUpdateTime < 0) {
342 - gestureStartTime = now();
343 - const newEventTime = resolveEventTimeStamp();
344 - const newEventType = resolveEventType();
345 - if (
346 - newEventTime !== gestureEventTime ||
347 - newEventType !== gestureEventType
348 - ) {
349 - gestureEventIsRepeat = false;
350 - }
351 - gestureEventTime = newEventTime;
352 - gestureEventType = newEventType;
353 - }
354 -}
355 -
334 export function hasScheduledGestureTransitionWork(): boolean {
335 // If we have call setOptimistic on a gesture
336 return gestureUpdateTime > -1;
337 }
338
361 -export function clearGestureTransitionTimer(): void {
362 - gestureStartTime = -1.1;
363 -}
364 -
339 export function clearGestureTimers(): void {
366 - gestureStartTime = -1.1;
340 gestureUpdateTime = -1.1;
341 gestureUpdateType = 0;
342 gestureSuspendedTime = -1.1;
@@ -371,6 +344,15 @@ export function clearGestureTimers(): void {
344 gestureClampTime = now();
345 }
346
347 +export function clearGestureUpdates(): void {
348 + // Same as clearGestureTimers but doesn't reset the clamp time because we didn't
349 + // actually emit a render.
350 + gestureUpdateTime = -1.1;
351 + gestureUpdateType = 0;
352 + gestureSuspendedTime = -1.1;
353 + gestureEventIsRepeat = true;
354 +}
355 +
356 export function clampBlockingTimers(finalTime: number): void {
357 if (!enableProfilerTimer || !enableComponentPerformanceTrack) {
358 return;
packages/react-test-renderer/src/ReactFiberConfigTestHost.js
+5
@@ -17,6 +17,7 @@ import {
17 NoEventPriority,
18 type EventPriority,
19 } from 'react-reconciler/src/ReactEventPriorities';
20 +import {enableProfilerTimer} from 'shared/ReactFeatureFlags';
21
22 export {default as rendererVersion} from 'shared/ReactVersion'; // TODO: Consider exporting the react-native version.
23 export const rendererPackageName = 'react-test-renderer';
@@ -446,9 +447,13 @@ export function startGestureTransition(
447 mutationCallback: () => void,
448 animateCallback: () => void,
449 errorCallback: mixed => void,
450 + finishedAnimation: () => void, // Profiling-only
451 ): null | RunningViewTransition {
452 mutationCallback();
453 animateCallback();
454 + if (enableProfilerTimer) {
455 + finishedAnimation();
456 + }
457 return null;
458 }
459