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