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.)
This commit is contained in:
Sebastian Markbåge
2026-01-15 20:51:36 -05:00
committed by GitHub
parent c0d3400cb1
commit 3c142f4419
4 changed files with 125 additions and 27 deletions
@@ -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) {
+23 -3
View File
@@ -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;
};
}
@@ -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,
+61 -19
View File
@@ -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);