From 596f0ac1d563a1dbb7814119c81c67bb00698d70 Mon Sep 17 00:00:00 2001 From: Sebastian Markbage Date: Mon, 19 Jan 2026 13:15:33 -0500 Subject: [PATCH 1/3] Add stop callback called when gesture stops --- .../src/ReactFiberGestureScheduler.js | 18 ++++++++ .../src/ReactFiberWorkLoop.js | 42 ++++++++++++++----- packages/shared/ReactTypes.js | 28 +++++++++---- 3 files changed, 70 insertions(+), 18 deletions(-) diff --git a/packages/react-reconciler/src/ReactFiberGestureScheduler.js b/packages/react-reconciler/src/ReactFiberGestureScheduler.js index 373e8167cab..944cc96caa4 100644 --- a/packages/react-reconciler/src/ReactFiberGestureScheduler.js +++ b/packages/react-reconciler/src/ReactFiberGestureScheduler.js @@ -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. + stopCallbacks: null | Array<() => void>, // Custom clean up callbacks to invoke when we stop the animation. 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. @@ -65,6 +66,7 @@ export function scheduleGesture( rangeEnd: 100, // Uninitialized types: null, running: null, + stopCallbacks: null, commit: null, committing: false, revertLane: NoLane, // Starts uninitialized. @@ -208,6 +210,14 @@ export function cancelScheduledGesture( if (runningTransition !== null) { stopViewTransition(runningTransition); } + const stopCallbacks = gesture.stopCallbacks; + if (stopCallbacks !== null) { + gesture.stopCallbacks = null; + for (let i = 0; i < stopCallbacks.length; i++) { + const stop = stopCallbacks[i]; + stop(); + } + } } else { // This was not the current gesture so it doesn't affect the current render. gesture.prev.next = gesture.next; @@ -243,6 +253,14 @@ export function stopCommittedGesture(root: FiberRoot) { committedGesture.running = null; stopViewTransition(runningTransition); } + const stopCallbacks = committedGesture.stopCallbacks; + if (stopCallbacks !== null) { + committedGesture.stopCallbacks = null; + for (let i = 0; i < stopCallbacks.length; i++) { + const stop = stopCallbacks[i]; + stop(); + } + } } } diff --git a/packages/react-reconciler/src/ReactFiberWorkLoop.js b/packages/react-reconciler/src/ReactFiberWorkLoop.js index 206bf797d60..4997d065aaf 100644 --- a/packages/react-reconciler/src/ReactFiberWorkLoop.js +++ b/packages/react-reconciler/src/ReactFiberWorkLoop.js @@ -733,8 +733,9 @@ let pendingEffectsRenderEndTime: number = -0; // Profiling-only let pendingPassiveTransitions: Array | null = null; let pendingRecoverableErrors: null | Array> = null; let pendingViewTransition: null | RunningViewTransition = null; -let pendingViewTransitionEvents: Array<(types: Array) => void> | null = - null; +let pendingViewTransitionEvents: Array< + (types: Array) => void | (() => void), +> | null = null; let pendingTransitionTypes: null | TransitionTypes = null; let pendingDidIncludeRenderPhaseUpdate: boolean = false; let pendingSuspendedCommitReason: SuspendedCommitReason = null; // Profiling-only @@ -899,7 +900,10 @@ export function requestDeferredLane(): Lane { export function scheduleViewTransitionEvent( fiber: Fiber, - callback: ?(instance: ViewTransitionInstance, types: Array) => void, + callback: ?( + instance: ViewTransitionInstance, + types: Array, + ) => void | (() => void), ): void { if (enableViewTransition) { if (callback != null) { @@ -925,7 +929,7 @@ export function scheduleGestureTransitionEvent( options: GestureOptionsRequired, instance: ViewTransitionInstance, types: Array, - ) => void, + ) => void | (() => void), ): void { if (enableGestureTransition) { if (callback != null) { @@ -4262,9 +4266,18 @@ function flushSpawnedWork(): void { // Normalize the type. This is lazily created only for events. pendingTypes = []; } - for (let i = 0; i < pendingEvents.length; i++) { - const viewTransitionEvent = pendingEvents[i]; - viewTransitionEvent(pendingTypes); + const committedGesture = root.pendingGestures; + if (committedGesture !== null && committedGesture.running !== null) { + for (let i = 0; i < pendingEvents.length; i++) { + const viewTransitionEvent = pendingEvents[i]; + const cleanup = viewTransitionEvent(pendingTypes); + if (cleanup !== undefined) { + if (committedGesture.stopCallbacks === null) { + committedGesture.stopCallbacks = []; + } + committedGesture.stopCallbacks.push(cleanup); + } + } } } } @@ -4532,9 +4545,18 @@ function flushGestureAnimations(): void { // Normalize the type. This is lazily created only for events. pendingTypes = []; } - for (let i = 0; i < pendingEvents.length; i++) { - const viewTransitionEvent = pendingEvents[i]; - viewTransitionEvent(pendingTypes); + const appliedGesture = root.pendingGestures; + if (appliedGesture !== null && appliedGesture.running !== null) { + for (let i = 0; i < pendingEvents.length; i++) { + const viewTransitionEvent = pendingEvents[i]; + const cleanup = viewTransitionEvent(pendingTypes); + if (cleanup !== undefined) { + if (appliedGesture.stopCallbacks === null) { + appliedGesture.stopCallbacks = []; + } + appliedGesture.stopCallbacks.push(cleanup); + } + } } } } diff --git a/packages/shared/ReactTypes.js b/packages/shared/ReactTypes.js index 2596f02c995..e58c36f0a0c 100644 --- a/packages/shared/ReactTypes.js +++ b/packages/shared/ReactTypes.js @@ -303,34 +303,46 @@ export type ViewTransitionProps = { exit?: ViewTransitionClass, share?: ViewTransitionClass, update?: ViewTransitionClass, - onEnter?: (instance: ViewTransitionInstance, types: Array) => void, - onExit?: (instance: ViewTransitionInstance, types: Array) => void, - onShare?: (instance: ViewTransitionInstance, types: Array) => void, - onUpdate?: (instance: ViewTransitionInstance, types: Array) => void, + onEnter?: ( + instance: ViewTransitionInstance, + types: Array, + ) => void | (() => void), + onExit?: ( + instance: ViewTransitionInstance, + types: Array, + ) => void | (() => void), + onShare?: ( + instance: ViewTransitionInstance, + types: Array, + ) => void | (() => void), + onUpdate?: ( + instance: ViewTransitionInstance, + types: Array, + ) => void | (() => void), onGestureEnter?: ( timeline: GestureProvider, options: GestureOptionsRequired, instance: ViewTransitionInstance, types: Array, - ) => void, + ) => void | (() => void), onGestureExit?: ( timeline: GestureProvider, options: GestureOptionsRequired, instance: ViewTransitionInstance, types: Array, - ) => void, + ) => void | (() => void), onGestureShare?: ( timeline: GestureProvider, options: GestureOptionsRequired, instance: ViewTransitionInstance, types: Array, - ) => void, + ) => void | (() => void), onGestureUpdate?: ( timeline: GestureProvider, options: GestureOptionsRequired, instance: ViewTransitionInstance, types: Array, - ) => void, + ) => void | (() => void), }; export type ActivityProps = { From 3300568c09ee201b68149d977bf46b0277cd103d Mon Sep 17 00:00:00 2001 From: Sebastian Markbage Date: Mon, 19 Jan 2026 13:24:41 -0500 Subject: [PATCH 2/3] Listen to the raw native view transition We need to fire the callbacks regardless of what stops it even if we are not the one to stop it. --- packages/react-art/src/ReactFiberConfigART.js | 7 +++++ .../src/client/ReactFiberConfigDOM.js | 8 ++++++ .../src/ReactFiberConfigNative.js | 7 +++++ .../src/createReactNoop.js | 7 +++++ .../src/ReactFiberConfigWithNoMutation.js | 1 + .../src/ReactFiberGestureScheduler.js | 18 ------------- .../src/ReactFiberWorkLoop.js | 26 +++++++++---------- .../src/forks/ReactFiberConfig.custom.js | 2 ++ .../src/ReactFiberConfigTestHost.js | 7 +++++ 9 files changed, 51 insertions(+), 32 deletions(-) diff --git a/packages/react-art/src/ReactFiberConfigART.js b/packages/react-art/src/ReactFiberConfigART.js index ca2745beea5..50873af6da0 100644 --- a/packages/react-art/src/ReactFiberConfigART.js +++ b/packages/react-art/src/ReactFiberConfigART.js @@ -549,6 +549,13 @@ export function startGestureTransition() { export function stopViewTransition(transition: RunningViewTransition) {} +export function addViewTransitionFinishedListener( + transition: RunningViewTransition, + callback: () => void, +) { + callback(); +} + export type ViewTransitionInstance = null | {name: string, ...}; export function createViewTransitionInstance( diff --git a/packages/react-dom-bindings/src/client/ReactFiberConfigDOM.js b/packages/react-dom-bindings/src/client/ReactFiberConfigDOM.js index 4d3bb0248b1..6a07839d37d 100644 --- a/packages/react-dom-bindings/src/client/ReactFiberConfigDOM.js +++ b/packages/react-dom-bindings/src/client/ReactFiberConfigDOM.js @@ -2337,6 +2337,7 @@ export function startViewTransition( export type RunningViewTransition = { skipTransition(): void, + finished: Promise, ... }; @@ -2784,6 +2785,13 @@ export function stopViewTransition(transition: RunningViewTransition) { transition.skipTransition(); } +export function addViewTransitionFinishedListener( + transition: RunningViewTransition, + callback: () => void, +) { + transition.finished.finally(callback); +} + interface ViewTransitionPseudoElementType extends mixin$Animatable { _scope: HTMLElement; _selector: string; diff --git a/packages/react-native-renderer/src/ReactFiberConfigNative.js b/packages/react-native-renderer/src/ReactFiberConfigNative.js index 89da4108fc9..fcf356776c2 100644 --- a/packages/react-native-renderer/src/ReactFiberConfigNative.js +++ b/packages/react-native-renderer/src/ReactFiberConfigNative.js @@ -713,6 +713,13 @@ export function startGestureTransition( export function stopViewTransition(transition: RunningViewTransition) {} +export function addViewTransitionFinishedListener( + transition: RunningViewTransition, + callback: () => void, +) { + callback(); +} + export type ViewTransitionInstance = null | {name: string, ...}; export function createViewTransitionInstance( diff --git a/packages/react-noop-renderer/src/createReactNoop.js b/packages/react-noop-renderer/src/createReactNoop.js index db69232d129..79d1e1a8c9b 100644 --- a/packages/react-noop-renderer/src/createReactNoop.js +++ b/packages/react-noop-renderer/src/createReactNoop.js @@ -888,6 +888,13 @@ function createReactNoop(reconciler: Function, useMutation: boolean) { stopViewTransition(transition: RunningViewTransition) {}, + addViewTransitionFinishedListener( + transition: RunningViewTransition, + callback: () => void, + ) { + callback(); + }, + createViewTransitionInstance(name: string): ViewTransitionInstance { return null; }, diff --git a/packages/react-reconciler/src/ReactFiberConfigWithNoMutation.js b/packages/react-reconciler/src/ReactFiberConfigWithNoMutation.js index 09e64b0aa08..79cf3990a72 100644 --- a/packages/react-reconciler/src/ReactFiberConfigWithNoMutation.js +++ b/packages/react-reconciler/src/ReactFiberConfigWithNoMutation.js @@ -54,6 +54,7 @@ export const startViewTransition = shim; export type RunningViewTransition = null; export const startGestureTransition = shim; export const stopViewTransition = shim; +export const addViewTransitionFinishedListener = shim; export type ViewTransitionInstance = null | {name: string, ...}; export const createViewTransitionInstance = shim; export type GestureTimeline = any; diff --git a/packages/react-reconciler/src/ReactFiberGestureScheduler.js b/packages/react-reconciler/src/ReactFiberGestureScheduler.js index 944cc96caa4..373e8167cab 100644 --- a/packages/react-reconciler/src/ReactFiberGestureScheduler.js +++ b/packages/react-reconciler/src/ReactFiberGestureScheduler.js @@ -35,7 +35,6 @@ 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. - stopCallbacks: null | Array<() => void>, // Custom clean up callbacks to invoke when we stop the animation. 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. @@ -66,7 +65,6 @@ export function scheduleGesture( rangeEnd: 100, // Uninitialized types: null, running: null, - stopCallbacks: null, commit: null, committing: false, revertLane: NoLane, // Starts uninitialized. @@ -210,14 +208,6 @@ export function cancelScheduledGesture( if (runningTransition !== null) { stopViewTransition(runningTransition); } - const stopCallbacks = gesture.stopCallbacks; - if (stopCallbacks !== null) { - gesture.stopCallbacks = null; - for (let i = 0; i < stopCallbacks.length; i++) { - const stop = stopCallbacks[i]; - stop(); - } - } } else { // This was not the current gesture so it doesn't affect the current render. gesture.prev.next = gesture.next; @@ -253,14 +243,6 @@ export function stopCommittedGesture(root: FiberRoot) { committedGesture.running = null; stopViewTransition(runningTransition); } - const stopCallbacks = committedGesture.stopCallbacks; - if (stopCallbacks !== null) { - committedGesture.stopCallbacks = null; - for (let i = 0; i < stopCallbacks.length; i++) { - const stop = stopCallbacks[i]; - stop(); - } - } } } diff --git a/packages/react-reconciler/src/ReactFiberWorkLoop.js b/packages/react-reconciler/src/ReactFiberWorkLoop.js index 4997d065aaf..b03f5eff159 100644 --- a/packages/react-reconciler/src/ReactFiberWorkLoop.js +++ b/packages/react-reconciler/src/ReactFiberWorkLoop.js @@ -120,6 +120,7 @@ import { startViewTransition, startGestureTransition, stopViewTransition, + addViewTransitionFinishedListener, createViewTransitionInstance, flushHydrationEvents, } from './ReactFiberConfig'; @@ -4147,6 +4148,7 @@ function flushSpawnedWork(): void { pendingEffectsStatus = NO_PENDING_EFFECTS; + const committedViewTransition = pendingViewTransition; pendingViewTransition = null; // The view transition has now fully started. // Tell Scheduler to yield at the end of the frame, so the browser has an @@ -4266,16 +4268,12 @@ function flushSpawnedWork(): void { // Normalize the type. This is lazily created only for events. pendingTypes = []; } - const committedGesture = root.pendingGestures; - if (committedGesture !== null && committedGesture.running !== null) { + if (committedViewTransition !== null) { for (let i = 0; i < pendingEvents.length; i++) { const viewTransitionEvent = pendingEvents[i]; const cleanup = viewTransitionEvent(pendingTypes); if (cleanup !== undefined) { - if (committedGesture.stopCallbacks === null) { - committedGesture.stopCallbacks = []; - } - committedGesture.stopCallbacks.push(cleanup); + addViewTransitionFinishedListener(committedViewTransition, cleanup); } } } @@ -4546,15 +4544,15 @@ function flushGestureAnimations(): void { pendingTypes = []; } const appliedGesture = root.pendingGestures; - if (appliedGesture !== null && appliedGesture.running !== null) { - for (let i = 0; i < pendingEvents.length; i++) { - const viewTransitionEvent = pendingEvents[i]; - const cleanup = viewTransitionEvent(pendingTypes); - if (cleanup !== undefined) { - if (appliedGesture.stopCallbacks === null) { - appliedGesture.stopCallbacks = []; + if (appliedGesture !== null) { + const runningTransition = appliedGesture.running; + if (runningTransition !== null) { + for (let i = 0; i < pendingEvents.length; i++) { + const viewTransitionEvent = pendingEvents[i]; + const cleanup = viewTransitionEvent(pendingTypes); + if (cleanup !== undefined) { + addViewTransitionFinishedListener(runningTransition, cleanup); } - appliedGesture.stopCallbacks.push(cleanup); } } } diff --git a/packages/react-reconciler/src/forks/ReactFiberConfig.custom.js b/packages/react-reconciler/src/forks/ReactFiberConfig.custom.js index a6c6613eb39..1785fa9aaec 100644 --- a/packages/react-reconciler/src/forks/ReactFiberConfig.custom.js +++ b/packages/react-reconciler/src/forks/ReactFiberConfig.custom.js @@ -162,6 +162,8 @@ export const hasInstanceAffectedParent = $$$config.hasInstanceAffectedParent; export const startViewTransition = $$$config.startViewTransition; export const startGestureTransition = $$$config.startGestureTransition; export const stopViewTransition = $$$config.stopViewTransition; +export const addViewTransitionFinishedListener = + $$$config.addViewTransitionFinishedListener; export const getCurrentGestureOffset = $$$config.getCurrentGestureOffset; export const createViewTransitionInstance = $$$config.createViewTransitionInstance; diff --git a/packages/react-test-renderer/src/ReactFiberConfigTestHost.js b/packages/react-test-renderer/src/ReactFiberConfigTestHost.js index e18523bc04e..6b04a36d297 100644 --- a/packages/react-test-renderer/src/ReactFiberConfigTestHost.js +++ b/packages/react-test-renderer/src/ReactFiberConfigTestHost.js @@ -459,6 +459,13 @@ export function startGestureTransition( export function stopViewTransition(transition: RunningViewTransition) {} +export function addViewTransitionFinishedListener( + transition: RunningViewTransition, + callback: () => void, +) { + callback(); +} + export type ViewTransitionInstance = null | {name: string, ...}; export function createViewTransitionInstance( From e7ff876ca2877f20bd1ca4d430fcaafb534475a4 Mon Sep 17 00:00:00 2001 From: Sebastian Markbage Date: Mon, 19 Jan 2026 13:36:14 -0500 Subject: [PATCH 3/3] Use in fixture --- .../view-transition/src/components/Page.js | 25 ++++++++++++------- 1 file changed, 16 insertions(+), 9 deletions(-) diff --git a/fixtures/view-transition/src/components/Page.js b/fixtures/view-transition/src/components/Page.js index 962ef168b4c..f91dc44e58d 100644 --- a/fixtures/view-transition/src/components/Page.js +++ b/fixtures/view-transition/src/components/Page.js @@ -82,8 +82,12 @@ export default function Page({url, navigate}) { {rotate: '0deg', transformOrigin: '30px 8px'}, {rotate: '360deg', transformOrigin: '30px 8px'}, ]; - viewTransition.old.animate(keyframes, 250); - viewTransition.new.animate(keyframes, 250); + const animation1 = viewTransition.old.animate(keyframes, 250); + const animation2 = viewTransition.new.animate(keyframes, 250); + return () => { + animation1.cancel(); + animation2.cancel(); + }; } function onGestureTransition( @@ -105,8 +109,12 @@ export default function Page({url, navigate}) { rangeStart: (reverse ? rangeEnd : rangeStart) + '%', rangeEnd: (reverse ? rangeStart : rangeEnd) + '%', }; - viewTransition.old.animate(keyframes, options); - viewTransition.new.animate(keyframes, options); + const animation1 = viewTransition.old.animate(keyframes, options); + const animation2 = viewTransition.new.animate(keyframes, options); + return () => { + animation1.cancel(); + animation2.cancel(); + }; } else { // Custom Timeline const options = { @@ -120,11 +128,10 @@ export default function Page({url, navigate}) { // Let the custom timeline take control of driving the animations. const cleanup1 = timeline.animate(animation1); const cleanup2 = timeline.animate(animation2); - // TODO: Support returning a clean up function from ViewTransition events. - // return () => { - // cleanup1(); - // cleanup2(); - // }; + return () => { + cleanup1(); + cleanup2(); + }; } }