Repository: facebook/react
Pull request: Optimize gesture by allowing the original work in progress tree to be a suspended commit

Stacked on #35487.

This is slightly different because the first suspended commit is on blockers that prevent us from committing which still needs to be resolved first.

If a gesture lane has to be rerendered while the gesture is happening then it reenters this state with a new tree. (Currently this doesn't happen for a ping I think which is not really how it usually works but better in this case.)

Changed files:
filename: fixtures/view-transition/src/components/SwipeRecognizer.js
patch: @@ -114,16 +114,17 @@ export default function SwipeRecognizer({
     );
   }
   function onGestureEnd(changed) {
-    // Reset scroll
-    if (changed) {
-      // Trigger side-effects
-      startTransition(action);
-    }
+    // We cancel the gesture before invoking side-effects to allow the gesture lane to fully commit
+    // before scheduling new updates.
     if (activeGesture.current !== null) {
       const cancelGesture = activeGesture.current;
       activeGesture.current = null;
       cancelGesture();
     }
+    if (changed) {
+      // Trigger side-effects
+      startTransition(action);
+    }
   }
   function onScrollEnd() {
     if (touchTimeline.current) {

filename: packages/react-reconciler/src/ReactFiberGestureScheduler.js
patch: @@ -35,6 +35,7 @@ export type ScheduledGesture = {
   rangeEnd: number, // The percentage along the timeline where the "destination" state is reached.
   types: null | TransitionTypes, // Any addTransitionType call made during startGestureTransition.
   running: null | RunningViewTransition, // Used to cancel the running transition after we're done.
+  commit: null | (() => void), // Callback to run to commit if there's a pending commit.
   committing: boolean, // If the gesture was released in a committed state and should actually commit.
   revertLane: Lane, // The Lane that we'll use to schedule the revert.
   prev: null | ScheduledGesture, // The previous scheduled gesture in the queue for this root.
@@ -64,6 +65,7 @@ export function scheduleGesture(
     rangeEnd: 100, // Uninitialized
     types: null,
     running: null,
+    commit: null,
     committing: false,
     revertLane: NoLane, // Starts uninitialized.
     prev: prev,
@@ -164,9 +166,17 @@ export function cancelScheduledGesture(
       // lane to actually commit it.
       gesture.committing = true;
       if (root.pendingGestures === gesture) {
-        // Ping the root given the new state. This is similar to pingSuspendedRoot.
-        // This will either schedule the gesture lane to be committed possibly from its current state.
-        pingGestureRoot(root);
+        const commitCallback = gesture.commit;
+        if (commitCallback !== null) {
+          gesture.commit = null;
+          // If we already have a commit prepared we can immediately commit the tree
+          // without rerendering.
+          // TODO: Consider scheduling this in a task instead of synchronously inside the last cancellation.s
+          commitCallback();
+        } else {
+          // Ping the root given the new state. This is similar to pingSuspendedRoot.
+          pingGestureRoot(root);
+        }
       }
     } else {
       // If we're not going to commit this gesture we can stop the View Transition
@@ -235,3 +245,13 @@ export function stopCommittedGesture(root: FiberRoot) {
     }
   }
 }
+
+export function scheduleGestureCommit(
+  gesture: ScheduledGesture,
+  callback: () => void,
+): () => void {
+  gesture.commit = callback;
+  return function () {
+    gesture.commit = null;
+  };
+}

filename: packages/react-reconciler/src/ReactFiberPerformanceTrack.js
patch: @@ -1569,6 +1569,41 @@ export function logPaintYieldPhase(
   }
 }
 
+export function logApplyGesturePhase(
+  startTime: number,
+  endTime: number,
+  debugTask: null | ConsoleTask,
+): void {
+  if (supportsUserTiming) {
+    if (endTime <= startTime) {
+      return;
+    }
+    if (__DEV__ && debugTask) {
+      debugTask.run(
+        // $FlowFixMe[method-unbinding]
+        console.timeStamp.bind(
+          console,
+          'Create Ghost Tree',
+          startTime,
+          endTime,
+          currentTrack,
+          LANES_TRACK_GROUP,
+          'secondary-dark',
+        ),
+      );
+    } else {
+      console.timeStamp(
+        'Create Ghost Tree',
+        startTime,
+        endTime,
+        currentTrack,
+        LANES_TRACK_GROUP,
+        'secondary-dark',
+      );
+    }
+  }
+}
+
 export function logStartViewTransitionYieldPhase(
   startTime: number,
   endTime: number,

filename: packages/react-reconciler/src/ReactFiberWorkLoop.js
patch: @@ -92,6 +92,7 @@ import {
   logSuspendedYieldTime,
   setCurrentTrackFromLanes,
   markAllLanesInOrder,
+  logApplyGesturePhase,
 } from './ReactFiberPerformanceTrack';
 
 import {
@@ -398,7 +399,10 @@ import {
 } from './ReactFiberRootScheduler';
 import {getMaskedContext, getUnmaskedContext} from './ReactFiberLegacyContext';
 import {logUncaughtError} from './ReactFiberErrorLogger';
-import {stopCommittedGesture} from './ReactFiberGestureScheduler';
+import {
+  scheduleGestureCommit,
+  stopCommittedGesture,
+} from './ReactFiberGestureScheduler';
 import {claimQueuedTransitionTypes} from './ReactFiberTransitionTypes';
 
 const PossiblyWeakMap = typeof WeakMap === 'function' ? WeakMap : Map;
@@ -1542,11 +1546,10 @@ function completeRootWhenReady(
       isViewTransitionEligible ||
       (isGestureTransition &&
         root.pendingGestures !== null &&
-        // If we're committing this gesture and it already has a View Transition
-        // running, then we don't have to wait for that gesture. We'll stop it
-        // when we commit.
-        (root.pendingGestures.running === null ||
-          !root.pendingGestures.committing))
+        // If this gesture already has a View Transition running then we don't
+        // have to wait on that one before proceeding. We may hold the commit
+        // on the gesture committing later on in completeRoot.
+        root.pendingGestures.running === null)
     ) {
       // Wait for any pending View Transition (including gestures) to finish.
       suspendOnActiveViewTransition(suspendedState, root.containerInfo);
@@ -3463,6 +3466,16 @@ function completeRoot(
   if (enableProfilerTimer && enableComponentPerformanceTrack) {
     // Log the previous render phase once we commit. I.e. we weren't interrupted.
     setCurrentTrackFromLanes(lanes);
+    if (isGestureRender(lanes)) {
+      // Clamp the render start time in case if something else on this lane was committed
+      // (such as this same tree before).
+      if (completedRenderStartTime < gestureClampTime) {
+        completedRenderStartTime = gestureClampTime;
+      }
+      if (completedRenderEndTime < gestureClampTime) {
+        completedRenderEndTime = gestureClampTime;
+      }
+    }
     if (exitStatus === RootErrored) {
       logErroredRenderPhase(
         completedRenderStartTime,
@@ -3580,7 +3593,38 @@ function completeRoot(
       } else {
         // If we already have a gesture running, we don't update it in place
         // even if we have a new tree. Instead we wait until we can commit.
+        if (enableProfilerTimer && enableComponentPerformanceTrack) {
+          // Clamp at the render time since we're not going to finish the rest
+          // of this commit or apply yet.
+          finalizeRender(lanes, completedRenderEndTime);
+        }
+        // We are no longer committing.
+        pendingEffectsRoot = (null: any); // Clear for GC purposes.
+        pendingFinishedWork = (null: any); // Clear for GC purposes.
+        pendingEffectsLanes = NoLanes;
       }
+      // Schedule the root to be committed when the gesture completes.
+      root.cancelPendingCommit = scheduleGestureCommit(
+        committingGesture,
+        completeRoot.bind(
+          null,
+          root,
+          finishedWork,
+          lanes,
+          recoverableErrors,
+          transitions,
+          didIncludeRenderPhaseUpdate,
+          spawnedLane,
+          updatedLanes,
+          suspendedRetryLanes,
+          didSkipSuspendedSiblings,
+          exitStatus,
+          suspendedState,
+          'Waiting for the Gesture to finish' /* suspendedCommitReason */,
+          completedRenderStartTime,
+          completedRenderEndTime,
+        ),
+      );
       return;
     }
   }
@@ -4368,6 +4412,15 @@ function flushGestureMutations(): void {
     ReactSharedInternals.T = prevTransition;
   }
 
+  if (enableProfilerTimer && enableComponentPerformanceTrack) {
+    recordCommitEndTime();
+    logApplyGesturePhase(
+      pendingEffectsRenderEndTime,
+      commitEndTime,
+      animatingTask,
+    );
+  }
+
   pendingEffectsStatus = PENDING_GESTURE_ANIMATION_PHASE;
 }
 
@@ -4385,10 +4438,11 @@ function flushGestureAnimations(): void {
   const lanes = pendingEffectsLanes;
 
   if (enableProfilerTimer && enableComponentPerformanceTrack) {
+    const startViewTransitionStartTime = commitEndTime;
     // Update the new commitEndTime to when we started the animation.
     recordCommitEndTime();
     logStartViewTransitionYieldPhase(
-      pendingEffectsRenderEndTime,
+      startViewTransitionStartTime,
       commitEndTime,
       pendingDelayedCommitReason === ABORTED_VIEW_TRANSITION_COMMIT,
       animatingTask,
@@ -4904,18 +4958,6 @@ export function pingGestureRoot(root: FiberRoot): void {
   if (gesture === null) {
     return;
   }
-  if (
-    root.cancelPendingCommit !== null &&
-    isGestureRender(pendingEffectsLanes)
-  ) {
-    // We have a suspended commit which we'll discard and rerender.
-    // TODO: Just use this commit since it's ready to go.
-    const cancelPendingCommit = root.cancelPendingCommit;
-    if (cancelPendingCommit !== null) {
-      root.cancelPendingCommit = null;
-      cancelPendingCommit();
-    }
-  }
   // Ping it for rerender and commit.
   markRootPinged(root, GestureLane);
   ensureRootIsScheduled(root);