@samitouri / QOS-React-1 / commits / 4cf906380d

Optimize gesture by allowing the original work in progress tree to be a suspended commit (#35510)

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.)

Sebastian Markbåge committed Jan 15, 2026 at 20:51 UTC 4cf906380d5d3282f1df3c8c34cf642e86a3a0a3
4 files changed +125 -27
fixtures/view-transition/src/components/SwipeRecognizer.js
+6 -5
@@ -114,16 +114,17 @@ export default function SwipeRecognizer({
114 );
115 }
116 function onGestureEnd(changed) {
117 - // Reset scroll
118 - if (changed) {
119 - // Trigger side-effects
120 - startTransition(action);
121 - }
117 + // We cancel the gesture before invoking side-effects to allow the gesture lane to fully commit
118 + // before scheduling new updates.
119 if (activeGesture.current !== null) {
120 const cancelGesture = activeGesture.current;
121 activeGesture.current = null;
122 cancelGesture();
123 }
124 + if (changed) {
125 + // Trigger side-effects
126 + startTransition(action);
127 + }
128 }
129 function onScrollEnd() {
130 if (touchTimeline.current) {
packages/react-reconciler/src/ReactFiberGestureScheduler.js
+23 -3
@@ -35,6 +35,7 @@ export type ScheduledGesture = {
35 rangeEnd: number, // The percentage along the timeline where the "destination" state is reached.
36 types: null | TransitionTypes, // Any addTransitionType call made during startGestureTransition.
37 running: null | RunningViewTransition, // Used to cancel the running transition after we're done.
38 + commit: null | (() => void), // Callback to run to commit if there's a pending commit.
39 committing: boolean, // If the gesture was released in a committed state and should actually commit.
40 revertLane: Lane, // The Lane that we'll use to schedule the revert.
41 prev: null | ScheduledGesture, // The previous scheduled gesture in the queue for this root.
@@ -64,6 +65,7 @@ export function scheduleGesture(
65 rangeEnd: 100, // Uninitialized
66 types: null,
67 running: null,
68 + commit: null,
69 committing: false,
70 revertLane: NoLane, // Starts uninitialized.
71 prev: prev,
@@ -164,9 +166,17 @@ export function cancelScheduledGesture(
166 // lane to actually commit it.
167 gesture.committing = true;
168 if (root.pendingGestures === gesture) {
167 - // Ping the root given the new state. This is similar to pingSuspendedRoot.
168 - // This will either schedule the gesture lane to be committed possibly from its current state.
169 - pingGestureRoot(root);
169 + const commitCallback = gesture.commit;
170 + if (commitCallback !== null) {
171 + gesture.commit = null;
172 + // If we already have a commit prepared we can immediately commit the tree
173 + // without rerendering.
174 + // TODO: Consider scheduling this in a task instead of synchronously inside the last cancellation.s
175 + commitCallback();
176 + } else {
177 + // Ping the root given the new state. This is similar to pingSuspendedRoot.
178 + pingGestureRoot(root);
179 + }
180 }
181 } else {
182 // If we're not going to commit this gesture we can stop the View Transition
@@ -235,3 +245,13 @@ export function stopCommittedGesture(root: FiberRoot) {
245 }
246 }
247 }
248 +
249 +export function scheduleGestureCommit(
250 + gesture: ScheduledGesture,
251 + callback: () => void,
252 +): () => void {
253 + gesture.commit = callback;
254 + return function () {
255 + gesture.commit = null;
256 + };
257 +}
packages/react-reconciler/src/ReactFiberPerformanceTrack.js
+35
@@ -1569,6 +1569,41 @@ export function logPaintYieldPhase(
1569 }
1570 }
1571
1572 +export function logApplyGesturePhase(
1573 + startTime: number,
1574 + endTime: number,
1575 + debugTask: null | ConsoleTask,
1576 +): void {
1577 + if (supportsUserTiming) {
1578 + if (endTime <= startTime) {
1579 + return;
1580 + }
1581 + if (__DEV__ && debugTask) {
1582 + debugTask.run(
1583 + // $FlowFixMe[method-unbinding]
1584 + console.timeStamp.bind(
1585 + console,
1586 + 'Create Ghost Tree',
1587 + startTime,
1588 + endTime,
1589 + currentTrack,
1590 + LANES_TRACK_GROUP,
1591 + 'secondary-dark',
1592 + ),
1593 + );
1594 + } else {
1595 + console.timeStamp(
1596 + 'Create Ghost Tree',
1597 + startTime,
1598 + endTime,
1599 + currentTrack,
1600 + LANES_TRACK_GROUP,
1601 + 'secondary-dark',
1602 + );
1603 + }
1604 + }
1605 +}
1606 +
1607 export function logStartViewTransitionYieldPhase(
1608 startTime: number,
1609 endTime: number,
packages/react-reconciler/src/ReactFiberWorkLoop.js
+61 -19
@@ -92,6 +92,7 @@ import {
92 logSuspendedYieldTime,
93 setCurrentTrackFromLanes,
94 markAllLanesInOrder,
95 + logApplyGesturePhase,
96 } from './ReactFiberPerformanceTrack';
97
98 import {
@@ -398,7 +399,10 @@ import {
399 } from './ReactFiberRootScheduler';
400 import {getMaskedContext, getUnmaskedContext} from './ReactFiberLegacyContext';
401 import {logUncaughtError} from './ReactFiberErrorLogger';
401 -import {stopCommittedGesture} from './ReactFiberGestureScheduler';
402 +import {
403 + scheduleGestureCommit,
404 + stopCommittedGesture,
405 +} from './ReactFiberGestureScheduler';
406 import {claimQueuedTransitionTypes} from './ReactFiberTransitionTypes';
407
408 const PossiblyWeakMap = typeof WeakMap === 'function' ? WeakMap : Map;
@@ -1542,11 +1546,10 @@ function completeRootWhenReady(
1546 isViewTransitionEligible ||
1547 (isGestureTransition &&
1548 root.pendingGestures !== null &&
1545 - // If we're committing this gesture and it already has a View Transition
1546 - // running, then we don't have to wait for that gesture. We'll stop it
1547 - // when we commit.
1548 - (root.pendingGestures.running === null ||
1549 - !root.pendingGestures.committing))
1549 + // If this gesture already has a View Transition running then we don't
1550 + // have to wait on that one before proceeding. We may hold the commit
1551 + // on the gesture committing later on in completeRoot.
1552 + root.pendingGestures.running === null)
1553 ) {
1554 // Wait for any pending View Transition (including gestures) to finish.
1555 suspendOnActiveViewTransition(suspendedState, root.containerInfo);
@@ -3463,6 +3466,16 @@ function completeRoot(
3466 if (enableProfilerTimer && enableComponentPerformanceTrack) {
3467 // Log the previous render phase once we commit. I.e. we weren't interrupted.
3468 setCurrentTrackFromLanes(lanes);
3469 + if (isGestureRender(lanes)) {
3470 + // Clamp the render start time in case if something else on this lane was committed
3471 + // (such as this same tree before).
3472 + if (completedRenderStartTime < gestureClampTime) {
3473 + completedRenderStartTime = gestureClampTime;
3474 + }
3475 + if (completedRenderEndTime < gestureClampTime) {
3476 + completedRenderEndTime = gestureClampTime;
3477 + }
3478 + }
3479 if (exitStatus === RootErrored) {
3480 logErroredRenderPhase(
3481 completedRenderStartTime,
@@ -3580,7 +3593,38 @@ function completeRoot(
3593 } else {
3594 // If we already have a gesture running, we don't update it in place
3595 // even if we have a new tree. Instead we wait until we can commit.
3596 + if (enableProfilerTimer && enableComponentPerformanceTrack) {
3597 + // Clamp at the render time since we're not going to finish the rest
3598 + // of this commit or apply yet.
3599 + finalizeRender(lanes, completedRenderEndTime);
3600 + }
3601 + // We are no longer committing.
3602 + pendingEffectsRoot = (null: any); // Clear for GC purposes.
3603 + pendingFinishedWork = (null: any); // Clear for GC purposes.
3604 + pendingEffectsLanes = NoLanes;
3605 }
3606 + // Schedule the root to be committed when the gesture completes.
3607 + root.cancelPendingCommit = scheduleGestureCommit(
3608 + committingGesture,
3609 + completeRoot.bind(
3610 + null,
3611 + root,
3612 + finishedWork,
3613 + lanes,
3614 + recoverableErrors,
3615 + transitions,
3616 + didIncludeRenderPhaseUpdate,
3617 + spawnedLane,
3618 + updatedLanes,
3619 + suspendedRetryLanes,
3620 + didSkipSuspendedSiblings,
3621 + exitStatus,
3622 + suspendedState,
3623 + 'Waiting for the Gesture to finish' /* suspendedCommitReason */,
3624 + completedRenderStartTime,
3625 + completedRenderEndTime,
3626 + ),
3627 + );
3628 return;
3629 }
3630 }
@@ -4368,6 +4412,15 @@ function flushGestureMutations(): void {
4412 ReactSharedInternals.T = prevTransition;
4413 }
4414
4415 + if (enableProfilerTimer && enableComponentPerformanceTrack) {
4416 + recordCommitEndTime();
4417 + logApplyGesturePhase(
4418 + pendingEffectsRenderEndTime,
4419 + commitEndTime,
4420 + animatingTask,
4421 + );
4422 + }
4423 +
4424 pendingEffectsStatus = PENDING_GESTURE_ANIMATION_PHASE;
4425 }
4426
@@ -4385,10 +4438,11 @@ function flushGestureAnimations(): void {
4438 const lanes = pendingEffectsLanes;
4439
4440 if (enableProfilerTimer && enableComponentPerformanceTrack) {
4441 + const startViewTransitionStartTime = commitEndTime;
4442 // Update the new commitEndTime to when we started the animation.
4443 recordCommitEndTime();
4444 logStartViewTransitionYieldPhase(
4391 - pendingEffectsRenderEndTime,
4445 + startViewTransitionStartTime,
4446 commitEndTime,
4447 pendingDelayedCommitReason === ABORTED_VIEW_TRANSITION_COMMIT,
4448 animatingTask,
@@ -4904,18 +4958,6 @@ export function pingGestureRoot(root: FiberRoot): void {
4958 if (gesture === null) {
4959 return;
4960 }
4907 - if (
4908 - root.cancelPendingCommit !== null &&
4909 - isGestureRender(pendingEffectsLanes)
4910 - ) {
4911 - // We have a suspended commit which we'll discard and rerender.
4912 - // TODO: Just use this commit since it's ready to go.
4913 - const cancelPendingCommit = root.cancelPendingCommit;
4914 - if (cancelPendingCommit !== null) {
4915 - root.cancelPendingCommit = null;
4916 - cancelPendingCommit();
4917 - }
4918 - }
4961 // Ping it for rerender and commit.
4962 markRootPinged(root, GestureLane);
4963 ensureRootIsScheduled(root);