Repository: facebook/react
Pull request: Add Gesture Track in Performance Tab

Stacked on #34543.

For the `startGestureTransition` API, we previously illustrated this in the "Blocking" track because the render itself is blocking. However, it's possible to have updates coming into the blocking track while a gesture is animating (e.g. the setInterval in the fixture). It's therefore useful to be able to visualize the Gesture on its own track so that we can show sync updates at the same time as the animation.

We could potentially merge this with the Transition track but I find that a bit misleading. Especially since a Gesture would often then be followed by a Transition right after. So I added a separate track for it. It is its own lane after all. Although it's possible that maybe this track becomes the same as the Optimistic track if that's ever extracted out of sync.

<img width="868" height="449" alt="Screenshot 2025-09-20 at 1 26 49 PM" src="https://github.com/user-attachments/assets/68c5c944-b9ef-4979-a11c-2f3805acada6" />


Changed files:
filename: packages/react-reconciler/src/ReactFiberLane.js
patch: @@ -28,6 +28,7 @@ import {
   retryLaneExpirationMs,
   disableLegacyMode,
   enableDefaultTransitionIndicator,
+  enableGestureTransition,
 } from 'shared/ReactFeatureFlags';
 import {isDevToolsPresent} from './ReactFiberDevToolsHook';
 import {clz32} from './clz32';
@@ -710,6 +711,9 @@ export function isTransitionLane(lane: Lane): boolean {
 }
 
 export function isGestureRender(lanes: Lanes): boolean {
+  if (!enableGestureTransition) {
+    return false;
+  }
   // This should render only the one lane.
   return lanes === GestureLane;
 }
@@ -1271,11 +1275,13 @@ export function getGroupNameOfHighestPriorityLane(lanes: Lanes): string {
       InputContinuousHydrationLane |
       InputContinuousLane |
       DefaultHydrationLane |
-      DefaultLane |
-      GestureLane)
+      DefaultLane)
   ) {
     return 'Blocking';
   }
+  if (lanes & GestureLane) {
+    return 'Gesture';
+  }
   if (lanes & (TransitionHydrationLane | TransitionLanes)) {
     return 'Transition';
   }

filename: packages/react-reconciler/src/ReactFiberPerformanceTrack.js
patch: @@ -33,7 +33,10 @@ import {
   addObjectDiffToProperties,
 } from 'shared/ReactPerformanceTrackProperties';
 
-import {enableProfilerTimer} from 'shared/ReactFeatureFlags';
+import {
+  enableProfilerTimer,
+  enableGestureTransition,
+} from 'shared/ReactFeatureFlags';
 
 const supportsUserTiming =
   enableProfilerTimer &&
@@ -68,6 +71,16 @@ export function markAllLanesInOrder() {
       LANES_TRACK_GROUP,
       'primary-light',
     );
+    if (enableGestureTransition) {
+      console.timeStamp(
+        'Gesture Track',
+        0.003,
+        0.003,
+        'Gesture',
+        LANES_TRACK_GROUP,
+        'primary-light',
+      );
+    }
     console.timeStamp(
       'Transition Track',
       0.003,
@@ -739,6 +752,145 @@ export function logBlockingStart(
   }
 }
 
+export function logGestureStart(
+  startTime: number,
+  updateTime: number,
+  eventTime: number,
+  eventType: null | string,
+  eventIsRepeat: boolean,
+  isPingedUpdate: boolean,
+  renderStartTime: number,
+  debugTask: null | ConsoleTask, // DEV-only
+  updateMethodName: null | string,
+  updateComponentName: null | string,
+): void {
+  if (supportsUserTiming) {
+    currentTrack = 'Gesture';
+    // Clamp start times
+    if (updateTime > 0) {
+      if (updateTime > renderStartTime) {
+        updateTime = renderStartTime;
+      }
+    } else {
+      updateTime = renderStartTime;
+    }
+    if (startTime > 0) {
+      if (startTime > updateTime) {
+        startTime = updateTime;
+      }
+    } else {
+      startTime = updateTime;
+    }
+    if (eventTime > 0) {
+      if (eventTime > startTime) {
+        eventTime = startTime;
+      }
+    } else {
+      eventTime = startTime;
+    }
+
+    if (startTime > eventTime && eventType !== null) {
+      // Log the time from the event timeStamp until we started a gesture.
+      const color = eventIsRepeat ? 'secondary-light' : 'warning';
+      if (__DEV__ && debugTask) {
+        debugTask.run(
+          console.timeStamp.bind(
+            console,
+            eventIsRepeat ? 'Consecutive' : 'Event: ' + eventType,
+            eventTime,
+            startTime,
+            currentTrack,
+            LANES_TRACK_GROUP,
+            color,
+          ),
+        );
+      } else {
+        console.timeStamp(
+          eventIsRepeat ? 'Consecutive' : 'Event: ' + eventType,
+          eventTime,
+          startTime,
+          currentTrack,
+          LANES_TRACK_GROUP,
+          color,
+        );
+      }
+    }
+    if (updateTime > startTime) {
+      // Log the time from when we started a gesture until we called setState or started rendering.
+      if (__DEV__ && debugTask) {
+        debugTask.run(
+          // $FlowFixMe[method-unbinding]
+          console.timeStamp.bind(
+            console,
+            'Gesture',
+            startTime,
+            updateTime,
+            currentTrack,
+            LANES_TRACK_GROUP,
+            'primary-dark',
+          ),
+        );
+      } else {
+        console.timeStamp(
+          'Gesture',
+          startTime,
+          updateTime,
+          currentTrack,
+          LANES_TRACK_GROUP,
+          'primary-dark',
+        );
+      }
+    }
+    if (renderStartTime > updateTime) {
+      // Log the time from when we called setState until we started rendering.
+      const label = isPingedUpdate
+        ? 'Promise Resolved'
+        : renderStartTime - updateTime > 5
+          ? 'Update Blocked'
+          : 'Update';
+      if (__DEV__) {
+        const properties = [];
+        if (updateComponentName != null) {
+          properties.push(['Component name', updateComponentName]);
+        }
+        if (updateMethodName != null) {
+          properties.push(['Method name', updateMethodName]);
+        }
+        const measureOptions = {
+          start: updateTime,
+          end: renderStartTime,
+          detail: {
+            devtools: {
+              properties,
+              track: currentTrack,
+              trackGroup: LANES_TRACK_GROUP,
+              color: 'primary-light',
+            },
+          },
+        };
+
+        if (debugTask) {
+          debugTask.run(
+            // $FlowFixMe[method-unbinding]
+            performance.measure.bind(performance, label, measureOptions),
+          );
+        } else {
+          performance.measure(label, measureOptions);
+        }
+      } else {
+        console.timeStamp(
+          label,
+          updateTime,
+          renderStartTime,
+          currentTrack,
+          LANES_TRACK_GROUP,
+          'primary-light',
+        );
+      }
+    }
+  }
+}
+
 export function logTransitionStart(
   startTime: number,
   updateTime: number,

filename: packages/react-reconciler/src/ReactFiberWorkLoop.js
patch: @@ -71,6 +71,7 @@ import {
 } from './Scheduler';
 import {
   logBlockingStart,
+  logGestureStart,
   logTransitionStart,
   logRenderPhase,
   logInterruptedRenderPhase,
@@ -282,6 +283,17 @@ import {
   blockingEventType,
   blockingEventIsRepeat,
   blockingSuspendedTime,
+  gestureClampTime,
+  gestureStartTime,
+  gestureUpdateTime,
+  gestureUpdateTask,
+  gestureUpdateType,
+  gestureUpdateMethodName,
+  gestureUpdateComponentName,
+  gestureEventTime,
+  gestureEventType,
+  gestureEventIsRepeat,
+  gestureSuspendedTime,
   transitionClampTime,
   transitionStartTime,
   transitionUpdateTime,
@@ -294,8 +306,10 @@ import {
   transitionEventIsRepeat,
   transitionSuspendedTime,
   clearBlockingTimers,
+  clearGestureTimers,
   clearTransitionTimers,
   clampBlockingTimers,
+  clampGestureTimers,
   clampTransitionTimers,
   clampRetryTimers,
   clampIdleTimers,
@@ -1898,7 +1912,9 @@ function resetWorkInProgressStack() {
 
 function finalizeRender(lanes: Lanes, finalizationTime: number): void {
   if (enableProfilerTimer && enableComponentPerformanceTrack) {
-    if (includesBlockingLane(lanes)) {
+    if (isGestureRender(lanes)) {
+      clampGestureTimers(finalizationTime);
+    } else if (includesBlockingLane(lanes)) {
       clampBlockingTimers(finalizationTime);
     }
     if (includesTransitionLane(lanes)) {
@@ -1963,7 +1979,58 @@ function prepareFreshStack(root: FiberRoot, lanes: Lanes): Fiber {
     const previousUpdateTask = workInProgressUpdateTask;
 
     workInProgressUpdateTask = null;
-    if (includesBlockingLane(lanes)) {
+    if (isGestureRender(lanes)) {
+      workInProgressUpdateTask = gestureUpdateTask;
+      const clampedStartTime =
+        gestureStartTime >= 0 && gestureStartTime < gestureClampTime
+          ? gestureClampTime
+          : gestureStartTime;
+      const clampedUpdateTime =
+        gestureUpdateTime >= 0 && gestureUpdateTime < gestureClampTime
+          ? gestureClampTime
+          : gestureUpdateTime;
+      const clampedEventTime =
+        gestureEventTime >= 0 && gestureEventTime < gestureClampTime
+          ? gestureClampTime
+          : gestureEventTime;
+      const clampedRenderStartTime =
+        // Clamp the suspended time to the first event/update.
+        clampedEventTime >= 0
+          ? clampedEventTime
+          : clampedUpdateTime >= 0
+            ? clampedUpdateTime
+            : renderStartTime;
+      if (gestureSuspendedTime >= 0) {
+        setCurrentTrackFromLanes(GestureLane);
+        logSuspendedWithDelayPhase(
+          gestureSuspendedTime,
+          clampedRenderStartTime,
+          lanes,
+          workInProgressUpdateTask,
+        );
+      } else if (isGestureRender(animatingLanes)) {
+        // If this lane is still animating, log the time from previous render finishing to now as animating.
+        setCurrentTrackFromLanes(GestureLane);
+        logAnimatingPhase(
+          gestureClampTime,
+          clampedRenderStartTime,
+          animatingTask,
+        );
+      }
+      logGestureStart(
+        clampedStartTime,
+        clampedUpdateTime,
+        clampedEventTime,
+        gestureEventType,
+        gestureEventIsRepeat,
+        gestureUpdateType === PINGED_UPDATE,
+        renderStartTime,
+        gestureUpdateTask,
+        gestureUpdateMethodName,
+        gestureUpdateComponentName,
+      );
+      clearGestureTimers();
+    } else if (includesBlockingLane(lanes)) {
       workInProgressUpdateTask = blockingUpdateTask;
       const clampedUpdateTime =
         blockingUpdateTime >= 0 && blockingUpdateTime < blockingClampTime
@@ -3716,12 +3783,12 @@ function finishedViewTransition(lanes: Lanes): void {
     // If an affected track isn't in the middle of rendering or committing, log from the previous
     // finished render until the end of the animation.
     if (
-      includesBlockingLane(lanes) &&
-      !includesBlockingLane(workInProgressRootRenderLanes) &&
-      !includesBlockingLane(pendingEffectsLanes)
+      isGestureRender(lanes) &&
+      !isGestureRender(workInProgressRootRenderLanes) &&
+      !isGestureRender(pendingEffectsLanes)
     ) {
-      setCurrentTrackFromLanes(SyncLane);
-      logAnimatingPhase(blockingClampTime, now(), task);
+      setCurrentTrackFromLanes(GestureLane);
+      logAnimatingPhase(gestureClampTime, now(), task);
     }
     if (
       includesTransitionLane(lanes) &&

filename: packages/react-reconciler/src/ReactProfilerTimer.js
patch: @@ -18,6 +18,7 @@ import type {CapturedValue} from './ReactCapturedValue';
 import {
   isTransitionLane,
   isBlockingLane,
+  isGestureRender,
   includesTransitionLane,
   includesBlockingLane,
   NoLanes,
@@ -74,6 +75,19 @@ export let blockingEventTime: number = -1.1; // Event timeStamp of the first set
 export let blockingEventType: null | string = null; // Event type of the first setState.
 export let blockingEventIsRepeat: boolean = false;
 export let blockingSuspendedTime: number = -1.1;
+
+export let gestureClampTime: number = -0;
+export let gestureStartTime: number = -1.1; // First startGestureTransition call before setOptimistic.
+export let gestureUpdateTime: number = -1.1; // First setOptimistic scheduled inside startGestureTransition.
+export let gestureUpdateTask: null | ConsoleTask = null; // First sync setState's stack trace.
+export let gestureUpdateType: UpdateType = 0;
+export let gestureUpdateMethodName: null | string = null; // The name of the method that caused first gesture update.
+export let gestureUpdateComponentName: null | string = null; // The name of the component where first gesture update happened.
+export let gestureEventTime: number = -1.1; // Event timeStamp of the first setState.
+export let gestureEventType: null | string = null; // Event type of the first setState.
+export let gestureEventIsRepeat: boolean = false;
+export let gestureSuspendedTime: number = -1.1;
+
 // TODO: This should really be one per Transition lane.
 export let transitionClampTime: number = -0;
 export let transitionStartTime: number = -1.1; // First startTransition call before setState.
@@ -112,7 +126,28 @@ export function startUpdateTimerByLane(
   if (!enableProfilerTimer || !enableComponentPerformanceTrack) {
     return;
   }
-  if (isBlockingLane(lane)) {
+  if (isGestureRender(lane)) {
+    if (gestureUpdateTime < 0) {
+      gestureUpdateTime = now();
+      gestureUpdateTask = createTask(method);
+      gestureUpdateMethodName = method;
+      if (__DEV__ && fiber != null) {
+        gestureUpdateComponentName = getComponentNameFromFiber(fiber);
+      }
+      if (gestureStartTime < 0) {
+        const newEventTime = resolveEventTimeStamp();
+        const newEventType = resolveEventType();
+        if (
+          newEventTime !== gestureEventTime ||
+          newEventType !== gestureEventType
+        ) {
+          gestureEventIsRepeat = false;
+        }
+        gestureEventTime = newEventTime;
+        gestureEventType = newEventType;
+      }
+    }
+  } else if (isBlockingLane(lane)) {
     if (blockingUpdateTime < 0) {
       blockingUpdateTime = now();
       blockingUpdateTask = createTask(method);
@@ -218,7 +253,13 @@ export function startPingTimerByLanes(lanes: Lanes): void {
   // Mark the update time and clamp anything before it because we don't want
   // to show the event time for pings but we also don't want to clear it
   // because we still need to track if this was a repeat.
-  if (includesBlockingLane(lanes)) {
+  if (isGestureRender(lanes)) {
+    if (gestureUpdateTime < 0) {
+      gestureClampTime = gestureUpdateTime = now();
+      gestureUpdateTask = createTask('Promise Resolved');
+      gestureUpdateType = PINGED_UPDATE;
+    }
+  } else if (includesBlockingLane(lanes)) {
     if (blockingUpdateTime < 0) {
       blockingClampTime = blockingUpdateTime = now();
       blockingUpdateTask = createTask('Promise Resolved');
@@ -237,7 +278,9 @@ export function trackSuspendedTime(lanes: Lanes, renderEndTime: number) {
   if (!enableProfilerTimer || !enableComponentPerformanceTrack) {
     return;
   }
-  if (includesBlockingLane(lanes)) {
+  if (isGestureRender(lanes)) {
+    gestureSuspendedTime = renderEndTime;
+  } else if (includesBlockingLane(lanes)) {
     blockingSuspendedTime = renderEndTime;
   } else if (includesTransitionLane(lanes)) {
     transitionSuspendedTime = renderEndTime;
@@ -291,6 +334,43 @@ export function clearTransitionTimers(): void {
   transitionClampTime = now();
 }
 
+export function startGestureTransitionTimer(): void {
+  if (!enableProfilerTimer || !enableComponentPerformanceTrack) {
+    return;
+  }
+  if (gestureStartTime < 0 && gestureUpdateTime < 0) {
+    gestureStartTime = now();
+    const newEventTime = resolveEventTimeStamp();
+    const newEventType = resolveEventType();
+    if (
+      newEventTime !== gestureEventTime ||
+      newEventType !== gestureEventType
+    ) {
+      gestureEventIsRepeat = false;
+    }
+    gestureEventTime = newEventTime;
+    gestureEventType = newEventType;
+  }
+}
+
+export function hasScheduledGestureTransitionWork(): boolean {
+  // If we have call setOptimistic on a gesture
+  return gestureUpdateTime > -1;
+}
+
+export function clearGestureTransitionTimer(): void {
+  gestureStartTime = -1.1;
+}
+
+export function clearGestureTimers(): void {
+  gestureStartTime = -1.1;
+  gestureUpdateTime = -1.1;
+  gestureUpdateType = 0;
+  gestureSuspendedTime = -1.1;
+  gestureEventIsRepeat = true;
+  gestureClampTime = now();
+}
+
 export function clampBlockingTimers(finalTime: number): void {
   if (!enableProfilerTimer || !enableComponentPerformanceTrack) {
     return;
@@ -301,6 +381,16 @@ export function clampBlockingTimers(finalTime: number): void {
   blockingClampTime = finalTime;
 }
 
+export function clampGestureTimers(finalTime: number): void {
+  if (!enableProfilerTimer || !enableComponentPerformanceTrack) {
+    return;
+  }
+  // If we had new updates come in while we were still rendering or committing, we don't want
+  // those update times to create overlapping tracks in the performance timeline so we clamp
+  // them to the end of the commit phase.
+  gestureClampTime = finalTime;
+}
+
 export function clampTransitionTimers(finalTime: number): void {
   if (!enableProfilerTimer || !enableComponentPerformanceTrack) {
     return;