From b551a86ebe7c5e71c144da7f247a48cd1225b5f6 Mon Sep 17 00:00:00 2001 From: Sebastian Markbage Date: Sun, 11 Jan 2026 22:44:52 -0500 Subject: [PATCH 1/8] Add the suffix to cancelled view transition names --- .../react-reconciler/src/ReactFiberCommitViewTransitions.js | 6 +++++- 1 file changed, 5 insertions(+), 1 deletion(-) diff --git a/packages/react-reconciler/src/ReactFiberCommitViewTransitions.js b/packages/react-reconciler/src/ReactFiberCommitViewTransitions.js index 20abaa673c2..64940c31957 100644 --- a/packages/react-reconciler/src/ReactFiberCommitViewTransitions.js +++ b/packages/react-reconciler/src/ReactFiberCommitViewTransitions.js @@ -711,7 +711,11 @@ function measureViewTransitionHostInstancesRecursive( } viewTransitionCancelableChildren.push( instance, - oldName, + viewTransitionHostInstanceIdx === 0 + ? oldName + : // If we have multiple Host Instances below, we add a suffix to the name to give + // each one a unique name. + oldName + '_' + viewTransitionHostInstanceIdx, child.memoizedProps, ); } From 375892e0711ce9eb0154c609e0439753e247a6fa Mon Sep 17 00:00:00 2001 From: Sebastian Markbage Date: Wed, 7 Jan 2026 20:34:22 -0500 Subject: [PATCH 2/8] Model gestures committing by committing the optimistic Gesture Lane Instead of keeping track of which gestures to stop during the next commit, we instead track on the pending gesture whether that gesture should commit that lane next time we get there in the React commit sequence. --- .../src/ReactFiberGestureScheduler.js | 71 +++++++++---------- .../react-reconciler/src/ReactFiberRoot.js | 1 - .../src/ReactFiberWorkLoop.js | 47 ++++++------ .../src/ReactInternalTypes.js | 1 - 4 files changed, 57 insertions(+), 63 deletions(-) diff --git a/packages/react-reconciler/src/ReactFiberGestureScheduler.js b/packages/react-reconciler/src/ReactFiberGestureScheduler.js index ed61c59b05a..e7bd8b7a357 100644 --- a/packages/react-reconciler/src/ReactFiberGestureScheduler.js +++ b/packages/react-reconciler/src/ReactFiberGestureScheduler.js @@ -12,11 +12,7 @@ import type {GestureOptions} from 'shared/ReactTypes'; import type {GestureTimeline, RunningViewTransition} from './ReactFiberConfig'; import type {TransitionTypes} from 'react/src/ReactTransitionType'; -import { - GestureLane, - includesBlockingLane, - includesTransitionLane, -} from './ReactFiberLane'; +import {GestureLane} from './ReactFiberLane'; import {ensureRootIsScheduled} from './ReactFiberRootScheduler'; import {getCurrentGestureOffset, stopViewTransition} from './ReactFiberConfig'; @@ -28,6 +24,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. + committing: boolean, // If the gesture was released in a committed state and should actually commit. prev: null | ScheduledGesture, // The previous scheduled gesture in the queue for this root. next: null | ScheduledGesture, // The next scheduled gesture in the queue for this root. }; @@ -55,6 +52,7 @@ export function scheduleGesture( rangeEnd: 100, // Uninitialized types: null, running: null, + committing: false, prev: prev, next: null, }; @@ -120,10 +118,9 @@ export function cancelScheduledGesture( root: FiberRoot, gesture: ScheduledGesture, ): void { + const shouldCommit = false; // TODO: Determine if this was released to snap back or commit forward. gesture.count--; if (gesture.count === 0) { - // Delete the scheduled gesture from the pending queue. - deleteScheduledGesture(root, gesture); // TODO: If we're currently rendering this gesture, we need to restart the render // on a different gesture or cancel the render.. // TODO: We might want to pause the View Transition at this point since you should @@ -131,30 +128,24 @@ export function cancelScheduledGesture( // just commit the gesture state. const runningTransition = gesture.running; if (runningTransition !== null) { - const pendingLanesExcludingGestureLane = root.pendingLanes & ~GestureLane; - if ( - includesBlockingLane(pendingLanesExcludingGestureLane) || - includesTransitionLane(pendingLanesExcludingGestureLane) - ) { - // If we have pending work we schedule the gesture to be stopped at the next commit. - // This ensures that we don't snap back to the previous state until we have - // had a chance to commit any resulting updates. - const existing = root.stoppingGestures; - if (existing !== null) { - gesture.next = existing; - existing.prev = gesture; - } - root.stoppingGestures = gesture; + if (shouldCommit) { + // If we are going to commit this gesture in its to state, we need to wait to + // stop it until it commits. We should now schedule a render at the gesture + // lane to actually commit it. + gesture.committing = true; + // TODO: Treat this the same as pinging a Transition. } else { + // If we're not going to commit this gesture we can stop the View Transition + // right away and delete the scheduled gesture from the pending queue. + deleteScheduledGesture(root, gesture); gesture.running = null; - // If there's no work scheduled so we can stop the View Transition right away. stopViewTransition(runningTransition); } } } } -export function deleteScheduledGesture( +function deleteScheduledGesture( root: FiberRoot, gesture: ScheduledGesture, ): void { @@ -168,10 +159,6 @@ export function deleteScheduledGesture( root.pendingLanes &= ~GestureLane; } } - if (root.stoppingGestures === gesture) { - // This should not really happen the way we use it now but just in case we start. - root.stoppingGestures = gesture.next; - } } else { gesture.prev.next = gesture.next; if (gesture.next !== null) { @@ -182,17 +169,25 @@ export function deleteScheduledGesture( } } -export function stopCompletedGestures(root: FiberRoot) { - let gesture = root.stoppingGestures; - root.stoppingGestures = null; - while (gesture !== null) { - if (gesture.running !== null) { - stopViewTransition(gesture.running); - gesture.running = null; +export function stopCommittedGesture(root: FiberRoot) { + // The top was just committed. We can delete it from the queue + // and stop its View Transition now. + const committedGesture = root.pendingGestures; + if (committedGesture !== null) { + const nextGesture = committedGesture.next; + if (nextGesture === null) { + // Gestures don't clear their lanes while the gesture is still active but it + // might not be scheduled to do any more renders and so we shouldn't schedule + // any more gesture lane work until a new gesture is scheduled. + root.pendingLanes &= ~GestureLane; + } else { + nextGesture.prev = null; + } + root.pendingGestures = nextGesture; + const runningTransition = committedGesture.running; + if (runningTransition !== null) { + committedGesture.running = null; + stopViewTransition(runningTransition); } - const nextGesture = gesture.next; - gesture.next = null; - gesture.prev = null; - gesture = nextGesture; } } diff --git a/packages/react-reconciler/src/ReactFiberRoot.js b/packages/react-reconciler/src/ReactFiberRoot.js index 908893db948..26386597ee4 100644 --- a/packages/react-reconciler/src/ReactFiberRoot.js +++ b/packages/react-reconciler/src/ReactFiberRoot.js @@ -115,7 +115,6 @@ function FiberRootNode( if (enableGestureTransition) { this.pendingGestures = null; - this.stoppingGestures = null; this.gestureClone = null; } diff --git a/packages/react-reconciler/src/ReactFiberWorkLoop.js b/packages/react-reconciler/src/ReactFiberWorkLoop.js index f7200458be1..6b288e7c2b3 100644 --- a/packages/react-reconciler/src/ReactFiberWorkLoop.js +++ b/packages/react-reconciler/src/ReactFiberWorkLoop.js @@ -395,10 +395,7 @@ import { } from './ReactFiberRootScheduler'; import {getMaskedContext, getUnmaskedContext} from './ReactFiberLegacyContext'; import {logUncaughtError} from './ReactFiberErrorLogger'; -import { - deleteScheduledGesture, - stopCompletedGestures, -} from './ReactFiberGestureScheduler'; +import {stopCommittedGesture} from './ReactFiberGestureScheduler'; import {claimQueuedTransitionTypes} from './ReactFiberTransitionTypes'; const PossiblyWeakMap = typeof WeakMap === 'function' ? WeakMap : Map; @@ -1537,12 +1534,18 @@ function commitRootWhenReady( // This will also track any newly added or appearing ViewTransition // components for the purposes of forming pairs. accumulateSuspenseyCommit(finishedWork, lanes, suspendedState); - if (isViewTransitionEligible || isGestureTransition) { - // If we're stopping gestures we don't have to wait for any pending - // view transition. We'll stop it when we commit. - if (!enableGestureTransition || root.stoppingGestures === null) { - suspendOnActiveViewTransition(suspendedState, root.containerInfo); - } + if ( + isViewTransitionEligible || + (isGestureRender && + 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)) + ) { + // Wait for any pending View Transition (including gestures) to finish. + suspendOnActiveViewTransition(suspendedState, root.containerInfo); } // For timeouts we use the previous fallback commit for retries and // the start time of the transition for transitions. This offset @@ -3489,9 +3492,9 @@ function commitRoot( markCommitStopped(); } if (enableGestureTransition) { - // Stop any gestures that were completed and is now being reverted. - if (root.stoppingGestures !== null) { - stopCompletedGestures(root); + // Stop any gestures that were committed. + if (isGestureRender(lanes)) { + stopCommittedGesture(root); } } return; @@ -3705,20 +3708,19 @@ function commitRoot( } } - let willStartViewTransition = shouldStartViewTransition; if (enableGestureTransition) { - // Stop any gestures that were completed and is now being committed. - if (root.stoppingGestures !== null) { - stopCompletedGestures(root); - // If we are in the process of stopping some gesture we shouldn't start - // a View Transition because that would start from the previous state to - // the next state. - willStartViewTransition = false; + // Stop any gestures that were committed. + if (isGestureRender(lanes)) { + stopCommittedGesture(root); + // Note that shouldStartViewTransition should always be false here because + // committing a gesture never starts a new View Transition itself since it's + // not a View Transition eligible lane. Only follow up Transition commits can + // cause animate. } } pendingEffectsStatus = PENDING_MUTATION_PHASE; - if (enableViewTransition && willStartViewTransition) { + if (enableViewTransition && shouldStartViewTransition) { if (enableProfilerTimer && enableComponentPerformanceTrack) { startAnimating(lanes); } @@ -4252,7 +4254,6 @@ function commitGestureOnRoot( ensureRootIsScheduled(root); return; } - deleteScheduledGesture(root, finishedGesture); if (enableProfilerTimer && enableComponentPerformanceTrack) { startAnimating(pendingEffectsLanes); diff --git a/packages/react-reconciler/src/ReactInternalTypes.js b/packages/react-reconciler/src/ReactInternalTypes.js index 775b69d211f..ce22050123c 100644 --- a/packages/react-reconciler/src/ReactInternalTypes.js +++ b/packages/react-reconciler/src/ReactInternalTypes.js @@ -292,7 +292,6 @@ type BaseFiberRootProperties = { transitionTypes: null | TransitionTypes, // TODO: Make this a LaneMap. // enableGestureTransition only pendingGestures: null | ScheduledGesture, - stoppingGestures: null | ScheduledGesture, gestureClone: null | Instance, }; From 1274400e16be13b854a004e90c6097bdb5080e0e Mon Sep 17 00:00:00 2001 From: Sebastian Markbage Date: Wed, 7 Jan 2026 21:23:51 -0500 Subject: [PATCH 3/8] Clarify that the difference between applying vs committing a gesture When completing a root we can apply the gesture which starts the animation but it's not committed. We mark the lane as suspended at that point. --- .../src/ReactFiberGestureScheduler.js | 14 +- .../src/ReactFiberWorkLoop.js | 156 +++++++++++------- 2 files changed, 112 insertions(+), 58 deletions(-) diff --git a/packages/react-reconciler/src/ReactFiberGestureScheduler.js b/packages/react-reconciler/src/ReactFiberGestureScheduler.js index e7bd8b7a357..bc4cb717f1d 100644 --- a/packages/react-reconciler/src/ReactFiberGestureScheduler.js +++ b/packages/react-reconciler/src/ReactFiberGestureScheduler.js @@ -12,7 +12,7 @@ import type {GestureOptions} from 'shared/ReactTypes'; import type {GestureTimeline, RunningViewTransition} from './ReactFiberConfig'; import type {TransitionTypes} from 'react/src/ReactTransitionType'; -import {GestureLane} from './ReactFiberLane'; +import {GestureLane, markRootFinished, NoLane, NoLanes} from './ReactFiberLane'; import {ensureRootIsScheduled} from './ReactFiberRootScheduler'; import {getCurrentGestureOffset, stopViewTransition} from './ReactFiberConfig'; @@ -137,9 +137,21 @@ export function cancelScheduledGesture( } else { // If we're not going to commit this gesture we can stop the View Transition // right away and delete the scheduled gesture from the pending queue. + markRootFinished( + root, + GestureLane, + root.pendingLanes, + NoLane, + NoLane, + NoLanes, + ); deleteScheduledGesture(root, gesture); gesture.running = null; stopViewTransition(runningTransition); + // If we have any more gestures to pick up after this, make sure they're scheduled. + if (root.pendingGestures !== null) { + ensureRootIsScheduled(root); + } } } } diff --git a/packages/react-reconciler/src/ReactFiberWorkLoop.js b/packages/react-reconciler/src/ReactFiberWorkLoop.js index 6b288e7c2b3..9273774b133 100644 --- a/packages/react-reconciler/src/ReactFiberWorkLoop.js +++ b/packages/react-reconciler/src/ReactFiberWorkLoop.js @@ -1401,7 +1401,7 @@ function finishConcurrentRender( if (shouldForceFlushFallbacksInDEV()) { // We're inside an `act` scope. Commit immediately. - commitRoot( + completeRoot( root, finishedWork, lanes, @@ -1411,6 +1411,7 @@ function finishConcurrentRender( workInProgressDeferredLane, workInProgressRootInterleavedUpdatedLanes, workInProgressSuspendedRetryLanes, + workInProgressRootDidSkipSuspendedSiblings, exitStatus, null, null, @@ -1452,7 +1453,7 @@ function finishConcurrentRender( // run one after the other. pendingEffectsLanes = lanes; root.timeoutHandle = scheduleTimeout( - commitRootWhenReady.bind( + completeRootWhenReady.bind( null, root, finishedWork, @@ -1474,7 +1475,7 @@ function finishConcurrentRender( return; } } - commitRootWhenReady( + completeRootWhenReady( root, finishedWork, workInProgressRootRecoverableErrors, @@ -1493,7 +1494,7 @@ function finishConcurrentRender( } } -function commitRootWhenReady( +function completeRootWhenReady( root: FiberRoot, finishedWork: Fiber, recoverableErrors: Array> | null, @@ -1572,7 +1573,7 @@ function commitRootWhenReady( // root again. pendingEffectsLanes = lanes; root.cancelPendingCommit = schedulePendingCommit( - commitRoot.bind( + completeRoot.bind( null, root, finishedWork, @@ -1583,6 +1584,7 @@ function commitRootWhenReady( spawnedLane, updatedLanes, suspendedRetryLanes, + didSkipSuspendedSiblings, exitStatus, suspendedState, enableProfilerTimer @@ -1599,7 +1601,7 @@ function commitRootWhenReady( } // Otherwise, commit immediately.; - commitRoot( + completeRoot( root, finishedWork, lanes, @@ -1609,6 +1611,7 @@ function commitRootWhenReady( spawnedLane, updatedLanes, suspendedRetryLanes, + didSkipSuspendedSiblings, exitStatus, suspendedState, suspendedCommitReason, @@ -3416,7 +3419,7 @@ function unwindUnitOfWork(unitOfWork: Fiber, skipSiblings: boolean): void { workInProgress = null; } -function commitRoot( +function completeRoot( root: FiberRoot, finishedWork: null | Fiber, lanes: Lanes, @@ -3426,6 +3429,7 @@ function commitRoot( spawnedLane: Lane, updatedLanes: Lanes, suspendedRetryLanes: Lanes, + didSkipSuspendedSiblings: boolean, exitStatus: RootExitStatus, suspendedState: null | SuspendedState, suspendedCommitReason: SuspendedCommitReason, // Profiling-only @@ -3516,10 +3520,95 @@ function commitRoot( ); } + if (root === workInProgressRoot) { + // We can reset these now that they are finished. + workInProgressRoot = null; + workInProgress = null; + workInProgressRootRenderLanes = NoLanes; + } else { + // This indicates that the last root we worked on is not the same one that + // we're committing now. This most commonly happens when a suspended root + // times out. + } + + // workInProgressX might be overwritten, so we want + // to store it in pendingPassiveX until they get processed + // We need to pass this through as an argument to completeRoot + // because workInProgressX might have changed between + // the previous render and commit if we throttle the commit + // with setTimeout + pendingFinishedWork = finishedWork; + pendingEffectsRoot = root; + pendingEffectsLanes = lanes; + pendingPassiveTransitions = transitions; + pendingRecoverableErrors = recoverableErrors; + pendingDidIncludeRenderPhaseUpdate = didIncludeRenderPhaseUpdate; + if (enableProfilerTimer) { + pendingEffectsRenderEndTime = completedRenderEndTime; + pendingSuspendedCommitReason = suspendedCommitReason; + pendingDelayedCommitReason = IMMEDIATE_COMMIT; + pendingSuspendedViewTransitionReason = null; + } + + if (enableGestureTransition && isGestureRender(lanes)) { + const committingGesture = root.pendingGestures; + if (committingGesture !== null && !committingGesture.committing) { + // This gesture is not ready to commit yet. We'll mark it as suspended and + // start a gesture transition which isn't really a side-effect. Then later + // we might come back around to actually committing the root. + const didAttemptEntireTree = !didSkipSuspendedSiblings; + markRootSuspended(root, lanes, spawnedLane, didAttemptEntireTree); + if (committingGesture.running === null) { + applyGestureOnRoot( + root, + finishedWork, + recoverableErrors, + suspendedState, + enableProfilerTimer + ? suspendedCommitReason === null + ? completedRenderEndTime + : commitStartTime + : 0, + ); + } 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. + } + return; + } + } + + // If we're not starting a gesture we now actually commit the root. + commitRoot( + root, + finishedWork, + lanes, + spawnedLane, + updatedLanes, + suspendedRetryLanes, + suspendedState, + suspendedCommitReason, + completedRenderEndTime, + ); +} + +function commitRoot( + root: FiberRoot, + finishedWork: Fiber, + lanes: Lanes, + spawnedLane: Lane, + updatedLanes: Lanes, + suspendedRetryLanes: Lanes, + suspendedState: null | SuspendedState, + suspendedCommitReason: SuspendedCommitReason, // Profiling-only + completedRenderEndTime: number, // Profiling-only +) { // Check which lanes no longer have any work scheduled on them, and mark // those as finished. let remainingLanes = mergeLanes(finishedWork.lanes, finishedWork.childLanes); + pendingEffectsRemainingLanes = remainingLanes; + // Make sure to account for lanes that were updated by a concurrent event // during the render phase; don't mark them as finished. const concurrentlyUpdatedLanes = getConcurrentlyUpdatedLanes(); @@ -3549,53 +3638,6 @@ function commitRoot( // Reset this before firing side effects so we can detect recursive updates. didIncludeCommitPhaseUpdate = false; - if (root === workInProgressRoot) { - // We can reset these now that they are finished. - workInProgressRoot = null; - workInProgress = null; - workInProgressRootRenderLanes = NoLanes; - } else { - // This indicates that the last root we worked on is not the same one that - // we're committing now. This most commonly happens when a suspended root - // times out. - } - - // workInProgressX might be overwritten, so we want - // to store it in pendingPassiveX until they get processed - // We need to pass this through as an argument to commitRoot - // because workInProgressX might have changed between - // the previous render and commit if we throttle the commit - // with setTimeout - pendingFinishedWork = finishedWork; - pendingEffectsRoot = root; - pendingEffectsLanes = lanes; - pendingEffectsRemainingLanes = remainingLanes; - pendingPassiveTransitions = transitions; - pendingRecoverableErrors = recoverableErrors; - pendingDidIncludeRenderPhaseUpdate = didIncludeRenderPhaseUpdate; - if (enableProfilerTimer) { - pendingEffectsRenderEndTime = completedRenderEndTime; - pendingSuspendedCommitReason = suspendedCommitReason; - pendingDelayedCommitReason = IMMEDIATE_COMMIT; - pendingSuspendedViewTransitionReason = null; - } - - if (enableGestureTransition && isGestureRender(lanes)) { - // This is a special kind of render that doesn't commit regular effects. - commitGestureOnRoot( - root, - finishedWork, - recoverableErrors, - suspendedState, - enableProfilerTimer - ? suspendedCommitReason === null - ? completedRenderEndTime - : commitStartTime - : 0, - ); - return; - } - // If there are pending passive effects, schedule a callback to process them. // Do this as early as possible, so it is queued before anything else that // might get scheduled in the commit phase. (See #16714.) @@ -4150,7 +4192,7 @@ function flushSpawnedWork(): void { flushPendingEffects(); } - // Always call this before exiting `commitRoot`, to ensure that any + // Always call this before exiting `completeRoot`, to ensure that any // additional work on this root is scheduled. ensureRootIsScheduled(root); @@ -4239,7 +4281,7 @@ function flushSpawnedWork(): void { } } -function commitGestureOnRoot( +function applyGestureOnRoot( root: FiberRoot, finishedWork: Fiber, recoverableErrors: null | Array>, @@ -4462,7 +4504,7 @@ function flushPassiveEffects(): boolean { // flushPassiveEffectsImpl const root = pendingEffectsRoot; // Cache and clear the remaining lanes flag; it must be reset since this - // method can be called from various places, not always from commitRoot + // method can be called from various places, not always from completeRoot // where the remaining lanes are known const remainingLanes = pendingEffectsRemainingLanes; pendingEffectsRemainingLanes = NoLanes; From 89b9a3f29e352b67c0e7c2b1535265a191c0e575 Mon Sep 17 00:00:00 2001 From: Sebastian Markbage Date: Wed, 7 Jan 2026 22:42:03 -0500 Subject: [PATCH 4/8] Schedule the optimistic render for commit at the end This will rerender the gesture lane and then commit it which stops the gesture. After that any Transitions can be applied on top. --- .../src/ReactFiberGestureScheduler.js | 62 +++++++++++-------- .../react-reconciler/src/ReactFiberHooks.js | 2 +- 2 files changed, 37 insertions(+), 27 deletions(-) diff --git a/packages/react-reconciler/src/ReactFiberGestureScheduler.js b/packages/react-reconciler/src/ReactFiberGestureScheduler.js index bc4cb717f1d..43f9feb4393 100644 --- a/packages/react-reconciler/src/ReactFiberGestureScheduler.js +++ b/packages/react-reconciler/src/ReactFiberGestureScheduler.js @@ -12,7 +12,13 @@ import type {GestureOptions} from 'shared/ReactTypes'; import type {GestureTimeline, RunningViewTransition} from './ReactFiberConfig'; import type {TransitionTypes} from 'react/src/ReactTransitionType'; -import {GestureLane, markRootFinished, NoLane, NoLanes} from './ReactFiberLane'; +import { + GestureLane, + markRootPinged, + markRootFinished, + NoLane, + NoLanes, +} from './ReactFiberLane'; import {ensureRootIsScheduled} from './ReactFiberRootScheduler'; import {getCurrentGestureOffset, stopViewTransition} from './ReactFiberConfig'; @@ -118,7 +124,7 @@ export function cancelScheduledGesture( root: FiberRoot, gesture: ScheduledGesture, ): void { - const shouldCommit = false; // TODO: Determine if this was released to snap back or commit forward. + const shouldCommit = true; // TODO: Determine if this was released to snap back or commit forward. gesture.count--; if (gesture.count === 0) { // TODO: If we're currently rendering this gesture, we need to restart the render @@ -127,31 +133,33 @@ export function cancelScheduledGesture( // no longer be able to update the position of anything but it might be better to // just commit the gesture state. const runningTransition = gesture.running; - if (runningTransition !== null) { - if (shouldCommit) { - // If we are going to commit this gesture in its to state, we need to wait to - // stop it until it commits. We should now schedule a render at the gesture - // lane to actually commit it. - gesture.committing = true; - // TODO: Treat this the same as pinging a Transition. - } else { - // If we're not going to commit this gesture we can stop the View Transition - // right away and delete the scheduled gesture from the pending queue. - markRootFinished( - root, - GestureLane, - root.pendingLanes, - NoLane, - NoLane, - NoLanes, - ); - deleteScheduledGesture(root, gesture); - gesture.running = null; + if (runningTransition !== null && shouldCommit) { + // If we are going to commit this gesture in its to state, we need to wait to + // stop it until it commits. We should now schedule a render at the gesture + // lane to actually commit it. + gesture.committing = true; + // Ping the root to actually commmit. This is similar to pingSuspendedRoot. + markRootPinged(root, GestureLane); + ensureRootIsScheduled(root); + } else { + // If we're not going to commit this gesture we can stop the View Transition + // right away and delete the scheduled gesture from the pending queue. + markRootFinished( + root, + GestureLane, + root.pendingLanes, + NoLane, + NoLane, + NoLanes, + ); + deleteScheduledGesture(root, gesture); + gesture.running = null; + if (runningTransition !== null) { stopViewTransition(runningTransition); - // If we have any more gestures to pick up after this, make sure they're scheduled. - if (root.pendingGestures !== null) { - ensureRootIsScheduled(root); - } + } + // If we have any more gestures to pick up after this, make sure they're scheduled. + if (root.pendingGestures !== null) { + ensureRootIsScheduled(root); } } } @@ -186,6 +194,8 @@ export function stopCommittedGesture(root: FiberRoot) { // and stop its View Transition now. const committedGesture = root.pendingGestures; if (committedGesture !== null) { + // Mark it as no longer committing and should no longer be included in rerenders. + committedGesture.committing = false; const nextGesture = committedGesture.next; if (nextGesture === null) { // Gestures don't clear their lanes while the gesture is still active but it diff --git a/packages/react-reconciler/src/ReactFiberHooks.js b/packages/react-reconciler/src/ReactFiberHooks.js index 66a390ebb81..b64b2b9e8ec 100644 --- a/packages/react-reconciler/src/ReactFiberHooks.js +++ b/packages/react-reconciler/src/ReactFiberHooks.js @@ -1384,7 +1384,7 @@ function updateReducerImpl( // ScheduledGesture. const scheduledGesture = update.gesture; if (scheduledGesture !== null) { - if (scheduledGesture.count === 0) { + if (scheduledGesture.count === 0 && !scheduledGesture.committing) { // This gesture has already been cancelled. We can clean up this update. update = update.next; continue; From 1aa58dd23e2471df7c2123dbfa9357555774ef78 Mon Sep 17 00:00:00 2001 From: Sebastian Markbage Date: Wed, 7 Jan 2026 22:47:49 -0500 Subject: [PATCH 5/8] Update fixture to have a state that only updates when the real state commits This part doesn't animate until we release. --- .../view-transition/src/components/Page.js | 25 +++++++++++-------- 1 file changed, 14 insertions(+), 11 deletions(-) diff --git a/fixtures/view-transition/src/components/Page.js b/fixtures/view-transition/src/components/Page.js index fbaa9017171..6227e9ebc5f 100644 --- a/fixtures/view-transition/src/components/Page.js +++ b/fixtures/view-transition/src/components/Page.js @@ -171,17 +171,20 @@ export default function Page({url, navigate}) { }}>

{!show ? 'A' + counter : 'B'}

- {show ? ( -
- {a} - {b} -
- ) : ( -
- {b} - {a} -
- )} + { + // Using url instead of renderedUrl here lets us only update this on commit. + url === '/?b' ? ( +
+ {a} + {b} +
+ ) : ( +
+ {b} + {a} +
+ ) + } {show ? (
hello{exclamation}
From 10565ea907b69823425f9bfbbed915bcf298caa6 Mon Sep 17 00:00:00 2001 From: Sebastian Markbage Date: Fri, 9 Jan 2026 23:16:26 -0500 Subject: [PATCH 6/8] Commit conditionally depending on the progress is more towards the range end --- .../src/ReactFiberGestureScheduler.js | 105 +++++++++--------- .../src/ReactFiberWorkLoop.js | 51 +++++++++ 2 files changed, 105 insertions(+), 51 deletions(-) diff --git a/packages/react-reconciler/src/ReactFiberGestureScheduler.js b/packages/react-reconciler/src/ReactFiberGestureScheduler.js index 43f9feb4393..f6db756303b 100644 --- a/packages/react-reconciler/src/ReactFiberGestureScheduler.js +++ b/packages/react-reconciler/src/ReactFiberGestureScheduler.js @@ -12,15 +12,10 @@ import type {GestureOptions} from 'shared/ReactTypes'; import type {GestureTimeline, RunningViewTransition} from './ReactFiberConfig'; import type {TransitionTypes} from 'react/src/ReactTransitionType'; -import { - GestureLane, - markRootPinged, - markRootFinished, - NoLane, - NoLanes, -} from './ReactFiberLane'; +import {GestureLane, markRootFinished, NoLane, NoLanes} from './ReactFiberLane'; import {ensureRootIsScheduled} from './ReactFiberRootScheduler'; import {getCurrentGestureOffset, stopViewTransition} from './ReactFiberConfig'; +import {pingGestureRoot, restartGestureRoot} from './ReactFiberWorkLoop'; // This type keeps track of any scheduled or active gestures. export type ScheduledGesture = { @@ -124,9 +119,20 @@ export function cancelScheduledGesture( root: FiberRoot, gesture: ScheduledGesture, ): void { - const shouldCommit = true; // TODO: Determine if this was released to snap back or commit forward. gesture.count--; if (gesture.count === 0) { + // If the end state is closer to the end than the beginning then we commit into the + // end state before reverting back (or applying a new Transition). + // Otherwise we just revert back and don't commit. + let shouldCommit: boolean; + const finalOffset = getCurrentGestureOffset(gesture.provider); + const rangeStart = gesture.rangeStart; + const rangeEnd = gesture.rangeEnd; + if (rangeStart < rangeEnd) { + shouldCommit = finalOffset > rangeStart + (rangeEnd - rangeStart) / 2; + } else { + shouldCommit = finalOffset < rangeEnd + (rangeStart - rangeEnd) / 2; + } // TODO: If we're currently rendering this gesture, we need to restart the render // on a different gesture or cancel the render.. // TODO: We might want to pause the View Transition at this point since you should @@ -138,54 +144,51 @@ export function cancelScheduledGesture( // stop it until it commits. We should now schedule a render at the gesture // lane to actually commit it. gesture.committing = true; - // Ping the root to actually commmit. This is similar to pingSuspendedRoot. - markRootPinged(root, GestureLane); - ensureRootIsScheduled(root); + 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); + } } else { // If we're not going to commit this gesture we can stop the View Transition // right away and delete the scheduled gesture from the pending queue. - markRootFinished( - root, - GestureLane, - root.pendingLanes, - NoLane, - NoLane, - NoLanes, - ); - deleteScheduledGesture(root, gesture); - gesture.running = null; - if (runningTransition !== null) { - stopViewTransition(runningTransition); - } - // If we have any more gestures to pick up after this, make sure they're scheduled. - if (root.pendingGestures !== null) { - ensureRootIsScheduled(root); - } - } - } -} - -function deleteScheduledGesture( - root: FiberRoot, - gesture: ScheduledGesture, -): void { - if (gesture.prev === null) { - if (root.pendingGestures === gesture) { - root.pendingGestures = gesture.next; - if (root.pendingGestures === null) { - // Gestures don't clear their lanes while the gesture is still active but it - // might not be scheduled to do any more renders and so we shouldn't schedule - // any more gesture lane work until a new gesture is scheduled. - root.pendingLanes &= ~GestureLane; + if (gesture.prev === null) { + if (root.pendingGestures === gesture) { + // This was the currently rendering gesture. + root.pendingGestures = gesture.next; + let remainingLanes = root.pendingLanes; + if (root.pendingGestures === null) { + // Gestures don't clear their lanes while the gesture is still active but it + // might not be scheduled to do any more renders and so we shouldn't schedule + // any more gesture lane work until a new gesture is scheduled. + remainingLanes &= ~GestureLane; + } + markRootFinished( + root, + GestureLane, + remainingLanes, + NoLane, + NoLane, + NoLanes, + ); + // If we had a currently rendering gesture we need to now reset the gesture lane to + // now render the next gesture or cancel if there's no more gestures in the queue. + restartGestureRoot(root); + } + gesture.running = null; + if (runningTransition !== null) { + stopViewTransition(runningTransition); + } + } else { + // This was not the current gesture so it doesn't affect the current render. + gesture.prev.next = gesture.next; + if (gesture.next !== null) { + gesture.next.prev = gesture.prev; + } + gesture.prev = null; + gesture.next = null; } } - } else { - gesture.prev.next = gesture.next; - if (gesture.next !== null) { - gesture.next.prev = gesture.prev; - } - gesture.prev = null; - gesture.next = null; } } diff --git a/packages/react-reconciler/src/ReactFiberWorkLoop.js b/packages/react-reconciler/src/ReactFiberWorkLoop.js index 9273774b133..67e777b2a04 100644 --- a/packages/react-reconciler/src/ReactFiberWorkLoop.js +++ b/packages/react-reconciler/src/ReactFiberWorkLoop.js @@ -4892,6 +4892,57 @@ function pingSuspendedRoot( ensureRootIsScheduled(root); } +export function pingGestureRoot(root: FiberRoot): void { + const gesture = root.pendingGestures; + 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); +} + +export function restartGestureRoot(root: FiberRoot): void { + if ( + workInProgressRoot === root && + isGestureRender(workInProgressRootRenderLanes) + ) { + // The current render was a gesture but it's now defunct. We need to restart the render. + if ((executionContext & RenderContext) === NoContext) { + prepareFreshStack(root, NoLanes); + } else { + // TODO: Throw interruption when supported again. + } + } else if ( + root.cancelPendingCommit !== null && + isGestureRender(pendingEffectsLanes) + ) { + // We have a suspended commit which we'll discard. + const cancelPendingCommit = root.cancelPendingCommit; + if (cancelPendingCommit !== null) { + root.cancelPendingCommit = null; + cancelPendingCommit(); + } + } + if (root.pendingGestures !== null) { + // We still have gestures to work on. Let's schedule a restart. + markRootPinged(root, GestureLane); + } + ensureRootIsScheduled(root); +} + function retryTimedOutBoundary(boundaryFiber: Fiber, retryLane: Lane) { // The boundary fiber (a Suspense component or SuspenseList component) // previously was rendered in its fallback state. One of the promises that From 155adb4d6a2974d06343c13c1c7d5439209b7da2 Mon Sep 17 00:00:00 2001 From: Sebastian Markbage Date: Mon, 12 Jan 2026 00:38:48 -0500 Subject: [PATCH 7/8] Typo --- packages/react-reconciler/src/ReactFiberWorkLoop.js | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/packages/react-reconciler/src/ReactFiberWorkLoop.js b/packages/react-reconciler/src/ReactFiberWorkLoop.js index 67e777b2a04..4fcf936f1cb 100644 --- a/packages/react-reconciler/src/ReactFiberWorkLoop.js +++ b/packages/react-reconciler/src/ReactFiberWorkLoop.js @@ -1537,7 +1537,7 @@ function completeRootWhenReady( accumulateSuspenseyCommit(finishedWork, lanes, suspendedState); if ( isViewTransitionEligible || - (isGestureRender && + (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 From db5f5384cdccbe60d16b6dfb5fab652645ab100e Mon Sep 17 00:00:00 2001 From: Sebastian Markbage Date: Mon, 12 Jan 2026 00:36:03 -0500 Subject: [PATCH 8/8] Entangle revert --- .../src/ReactFiberGestureScheduler.js | 23 +++++++++++++++++-- .../react-reconciler/src/ReactFiberHooks.js | 9 +++++++- 2 files changed, 29 insertions(+), 3 deletions(-) diff --git a/packages/react-reconciler/src/ReactFiberGestureScheduler.js b/packages/react-reconciler/src/ReactFiberGestureScheduler.js index f6db756303b..144d5f3aa5f 100644 --- a/packages/react-reconciler/src/ReactFiberGestureScheduler.js +++ b/packages/react-reconciler/src/ReactFiberGestureScheduler.js @@ -11,9 +11,19 @@ import type {FiberRoot} from './ReactInternalTypes'; import type {GestureOptions} from 'shared/ReactTypes'; import type {GestureTimeline, RunningViewTransition} from './ReactFiberConfig'; import type {TransitionTypes} from 'react/src/ReactTransitionType'; +import type {Lane} from './ReactFiberLane'; -import {GestureLane, markRootFinished, NoLane, NoLanes} from './ReactFiberLane'; -import {ensureRootIsScheduled} from './ReactFiberRootScheduler'; +import { + GestureLane, + markRootEntangled, + markRootFinished, + NoLane, + NoLanes, +} from './ReactFiberLane'; +import { + ensureRootIsScheduled, + requestTransitionLane, +} from './ReactFiberRootScheduler'; import {getCurrentGestureOffset, stopViewTransition} from './ReactFiberConfig'; import {pingGestureRoot, restartGestureRoot} from './ReactFiberWorkLoop'; @@ -26,6 +36,7 @@ export type ScheduledGesture = { types: null | TransitionTypes, // Any addTransitionType call made during startGestureTransition. running: null | RunningViewTransition, // Used to cancel the running transition after we're done. 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. next: null | ScheduledGesture, // The next scheduled gesture in the queue for this root. }; @@ -54,6 +65,7 @@ export function scheduleGesture( types: null, running: null, committing: false, + revertLane: NoLane, // Starts uninitialized. prev: prev, next: null, }; @@ -119,6 +131,13 @@ export function cancelScheduledGesture( root: FiberRoot, gesture: ScheduledGesture, ): void { + // Entangle any Transitions started in this event with the revertLane of the gesture + // so that we commit them all together. + if (gesture.revertLane !== NoLane) { + const entangledLanes = gesture.revertLane | requestTransitionLane(null); + markRootEntangled(root, entangledLanes); + } + gesture.count--; if (gesture.count === 0) { // If the end state is closer to the end than the beginning then we commit into the diff --git a/packages/react-reconciler/src/ReactFiberHooks.js b/packages/react-reconciler/src/ReactFiberHooks.js index b64b2b9e8ec..422667c6a12 100644 --- a/packages/react-reconciler/src/ReactFiberHooks.js +++ b/packages/react-reconciler/src/ReactFiberHooks.js @@ -3792,7 +3792,14 @@ function dispatchOptimisticSetState( if (provider !== null) { // If this was a gesture, ensure we have a scheduled gesture and that // we associate this update with this specific gesture instance. - update.gesture = scheduleGesture(root, provider); + const gesture = (update.gesture = scheduleGesture(root, provider)); + // Ensure the gesture always uses the same revert lane. This can happen for + // two startGestureTransition calls to the same provider in different events. + if (gesture.revertLane === NoLane) { + gesture.revertLane = update.revertLane; + } else { + update.revertLane = gesture.revertLane; + } } } }