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