@samitouri / QOS-React / commits / c55ffb5ca3

Add Clean Up Callbacks to View Transition and Gesture Transition Events (#35564)

Stacked on #35556 and #35559. Given that we don't automatically clean up all view transition animations since #35337 and browsers are buggy, it's important that you clean up any `Animation` started manually from the events. However, there was no clean up function for when the View Transition is forced to stop. This also makes it harder to clean up custom timers etc too. This lets you return a clean up function from all the events on `<ViewTransition>`.

Sebastian Markbåge committed Jan 19, 2026 at 19:27 UTC c55ffb5ca3b2ba3a09a978629d5a1548c0e8085b
10 files changed +105 -27
fixtures/view-transition/src/components/Page.js
+16 -9
@@ -82,8 +82,12 @@ export default function Page({url, navigate}) {
82 {rotate: '0deg', transformOrigin: '30px 8px'},
83 {rotate: '360deg', transformOrigin: '30px 8px'},
84 ];
85 - viewTransition.old.animate(keyframes, 250);
86 - viewTransition.new.animate(keyframes, 250);
85 + const animation1 = viewTransition.old.animate(keyframes, 250);
86 + const animation2 = viewTransition.new.animate(keyframes, 250);
87 + return () => {
88 + animation1.cancel();
89 + animation2.cancel();
90 + };
91 }
92
93 function onGestureTransition(
@@ -105,8 +109,12 @@ export default function Page({url, navigate}) {
109 rangeStart: (reverse ? rangeEnd : rangeStart) + '%',
110 rangeEnd: (reverse ? rangeStart : rangeEnd) + '%',
111 };
108 - viewTransition.old.animate(keyframes, options);
109 - viewTransition.new.animate(keyframes, options);
112 + const animation1 = viewTransition.old.animate(keyframes, options);
113 + const animation2 = viewTransition.new.animate(keyframes, options);
114 + return () => {
115 + animation1.cancel();
116 + animation2.cancel();
117 + };
118 } else {
119 // Custom Timeline
120 const options = {
@@ -120,11 +128,10 @@ export default function Page({url, navigate}) {
128 // Let the custom timeline take control of driving the animations.
129 const cleanup1 = timeline.animate(animation1);
130 const cleanup2 = timeline.animate(animation2);
123 - // TODO: Support returning a clean up function from ViewTransition events.
124 - // return () => {
125 - // cleanup1();
126 - // cleanup2();
127 - // };
131 + return () => {
132 + cleanup1();
133 + cleanup2();
134 + };
135 }
136 }
137
packages/react-art/src/ReactFiberConfigART.js
+7
@@ -549,6 +549,13 @@ export function startGestureTransition() {
549
550 export function stopViewTransition(transition: RunningViewTransition) {}
551
552 +export function addViewTransitionFinishedListener(
553 + transition: RunningViewTransition,
554 + callback: () => void,
555 +) {
556 + callback();
557 +}
558 +
559 export type ViewTransitionInstance = null | {name: string, ...};
560
561 export function createViewTransitionInstance(
packages/react-dom-bindings/src/client/ReactFiberConfigDOM.js
+8
@@ -2337,6 +2337,7 @@ export function startViewTransition(
2337
2338 export type RunningViewTransition = {
2339 skipTransition(): void,
2340 + finished: Promise<void>,
2341 ...
2342 };
2343
@@ -2784,6 +2785,13 @@ export function stopViewTransition(transition: RunningViewTransition) {
2785 transition.skipTransition();
2786 }
2787
2788 +export function addViewTransitionFinishedListener(
2789 + transition: RunningViewTransition,
2790 + callback: () => void,
2791 +) {
2792 + transition.finished.finally(callback);
2793 +}
2794 +
2795 interface ViewTransitionPseudoElementType extends mixin$Animatable {
2796 _scope: HTMLElement;
2797 _selector: string;
packages/react-native-renderer/src/ReactFiberConfigNative.js
+7
@@ -713,6 +713,13 @@ export function startGestureTransition(
713
714 export function stopViewTransition(transition: RunningViewTransition) {}
715
716 +export function addViewTransitionFinishedListener(
717 + transition: RunningViewTransition,
718 + callback: () => void,
719 +) {
720 + callback();
721 +}
722 +
723 export type ViewTransitionInstance = null | {name: string, ...};
724
725 export function createViewTransitionInstance(
packages/react-noop-renderer/src/createReactNoop.js
+7
@@ -888,6 +888,13 @@ function createReactNoop(reconciler: Function, useMutation: boolean) {
888
889 stopViewTransition(transition: RunningViewTransition) {},
890
891 + addViewTransitionFinishedListener(
892 + transition: RunningViewTransition,
893 + callback: () => void,
894 + ) {
895 + callback();
896 + },
897 +
898 createViewTransitionInstance(name: string): ViewTransitionInstance {
899 return null;
900 },
packages/react-reconciler/src/ReactFiberConfigWithNoMutation.js
+1
@@ -54,6 +54,7 @@ export const startViewTransition = shim;
54 export type RunningViewTransition = null;
55 export const startGestureTransition = shim;
56 export const stopViewTransition = shim;
57 +export const addViewTransitionFinishedListener = shim;
58 export type ViewTransitionInstance = null | {name: string, ...};
59 export const createViewTransitionInstance = shim;
60 export type GestureTimeline = any;
packages/react-reconciler/src/ReactFiberWorkLoop.js
+30 -10
@@ -120,6 +120,7 @@ import {
120 startViewTransition,
121 startGestureTransition,
122 stopViewTransition,
123 + addViewTransitionFinishedListener,
124 createViewTransitionInstance,
125 flushHydrationEvents,
126 } from './ReactFiberConfig';
@@ -733,8 +734,9 @@ let pendingEffectsRenderEndTime: number = -0; // Profiling-only
734 let pendingPassiveTransitions: Array<Transition> | null = null;
735 let pendingRecoverableErrors: null | Array<CapturedValue<mixed>> = null;
736 let pendingViewTransition: null | RunningViewTransition = null;
736 -let pendingViewTransitionEvents: Array<(types: Array<string>) => void> | null =
737 - null;
737 +let pendingViewTransitionEvents: Array<
738 + (types: Array<string>) => void | (() => void),
739 +> | null = null;
740 let pendingTransitionTypes: null | TransitionTypes = null;
741 let pendingDidIncludeRenderPhaseUpdate: boolean = false;
742 let pendingSuspendedCommitReason: SuspendedCommitReason = null; // Profiling-only
@@ -899,7 +901,10 @@ export function requestDeferredLane(): Lane {
901
902 export function scheduleViewTransitionEvent(
903 fiber: Fiber,
902 - callback: ?(instance: ViewTransitionInstance, types: Array<string>) => void,
904 + callback: ?(
905 + instance: ViewTransitionInstance,
906 + types: Array<string>,
907 + ) => void | (() => void),
908 ): void {
909 if (enableViewTransition) {
910 if (callback != null) {
@@ -925,7 +930,7 @@ export function scheduleGestureTransitionEvent(
930 options: GestureOptionsRequired,
931 instance: ViewTransitionInstance,
932 types: Array<string>,
928 - ) => void,
933 + ) => void | (() => void),
934 ): void {
935 if (enableGestureTransition) {
936 if (callback != null) {
@@ -4143,6 +4148,7 @@ function flushSpawnedWork(): void {
4148
4149 pendingEffectsStatus = NO_PENDING_EFFECTS;
4150
4151 + const committedViewTransition = pendingViewTransition;
4152 pendingViewTransition = null; // The view transition has now fully started.
4153
4154 // Tell Scheduler to yield at the end of the frame, so the browser has an
@@ -4262,9 +4268,14 @@ function flushSpawnedWork(): void {
4268 // Normalize the type. This is lazily created only for events.
4269 pendingTypes = [];
4270 }
4265 - for (let i = 0; i < pendingEvents.length; i++) {
4266 - const viewTransitionEvent = pendingEvents[i];
4267 - viewTransitionEvent(pendingTypes);
4271 + if (committedViewTransition !== null) {
4272 + for (let i = 0; i < pendingEvents.length; i++) {
4273 + const viewTransitionEvent = pendingEvents[i];
4274 + const cleanup = viewTransitionEvent(pendingTypes);
4275 + if (cleanup !== undefined) {
4276 + addViewTransitionFinishedListener(committedViewTransition, cleanup);
4277 + }
4278 + }
4279 }
4280 }
4281 }
@@ -4532,9 +4543,18 @@ function flushGestureAnimations(): void {
4543 // Normalize the type. This is lazily created only for events.
4544 pendingTypes = [];
4545 }
4535 - for (let i = 0; i < pendingEvents.length; i++) {
4536 - const viewTransitionEvent = pendingEvents[i];
4537 - viewTransitionEvent(pendingTypes);
4546 + const appliedGesture = root.pendingGestures;
4547 + if (appliedGesture !== null) {
4548 + const runningTransition = appliedGesture.running;
4549 + if (runningTransition !== null) {
4550 + for (let i = 0; i < pendingEvents.length; i++) {
4551 + const viewTransitionEvent = pendingEvents[i];
4552 + const cleanup = viewTransitionEvent(pendingTypes);
4553 + if (cleanup !== undefined) {
4554 + addViewTransitionFinishedListener(runningTransition, cleanup);
4555 + }
4556 + }
4557 + }
4558 }
4559 }
4560 }
packages/react-reconciler/src/forks/ReactFiberConfig.custom.js
+2
@@ -162,6 +162,8 @@ export const hasInstanceAffectedParent = $$$config.hasInstanceAffectedParent;
162 export const startViewTransition = $$$config.startViewTransition;
163 export const startGestureTransition = $$$config.startGestureTransition;
164 export const stopViewTransition = $$$config.stopViewTransition;
165 +export const addViewTransitionFinishedListener =
166 + $$$config.addViewTransitionFinishedListener;
167 export const getCurrentGestureOffset = $$$config.getCurrentGestureOffset;
168 export const createViewTransitionInstance =
169 $$$config.createViewTransitionInstance;
packages/react-test-renderer/src/ReactFiberConfigTestHost.js
+7
@@ -459,6 +459,13 @@ export function startGestureTransition(
459
460 export function stopViewTransition(transition: RunningViewTransition) {}
461
462 +export function addViewTransitionFinishedListener(
463 + transition: RunningViewTransition,
464 + callback: () => void,
465 +) {
466 + callback();
467 +}
468 +
469 export type ViewTransitionInstance = null | {name: string, ...};
470
471 export function createViewTransitionInstance(
packages/shared/ReactTypes.js
+20 -8
@@ -303,34 +303,46 @@ export type ViewTransitionProps = {
303 exit?: ViewTransitionClass,
304 share?: ViewTransitionClass,
305 update?: ViewTransitionClass,
306 - onEnter?: (instance: ViewTransitionInstance, types: Array<string>) => void,
307 - onExit?: (instance: ViewTransitionInstance, types: Array<string>) => void,
308 - onShare?: (instance: ViewTransitionInstance, types: Array<string>) => void,
309 - onUpdate?: (instance: ViewTransitionInstance, types: Array<string>) => void,
306 + onEnter?: (
307 + instance: ViewTransitionInstance,
308 + types: Array<string>,
309 + ) => void | (() => void),
310 + onExit?: (
311 + instance: ViewTransitionInstance,
312 + types: Array<string>,
313 + ) => void | (() => void),
314 + onShare?: (
315 + instance: ViewTransitionInstance,
316 + types: Array<string>,
317 + ) => void | (() => void),
318 + onUpdate?: (
319 + instance: ViewTransitionInstance,
320 + types: Array<string>,
321 + ) => void | (() => void),
322 onGestureEnter?: (
323 timeline: GestureProvider,
324 options: GestureOptionsRequired,
325 instance: ViewTransitionInstance,
326 types: Array<string>,
315 - ) => void,
327 + ) => void | (() => void),
328 onGestureExit?: (
329 timeline: GestureProvider,
330 options: GestureOptionsRequired,
331 instance: ViewTransitionInstance,
332 types: Array<string>,
321 - ) => void,
333 + ) => void | (() => void),
334 onGestureShare?: (
335 timeline: GestureProvider,
336 options: GestureOptionsRequired,
337 instance: ViewTransitionInstance,
338 types: Array<string>,
327 - ) => void,
339 + ) => void | (() => void),
340 onGestureUpdate?: (
341 timeline: GestureProvider,
342 options: GestureOptionsRequired,
343 instance: ViewTransitionInstance,
344 types: Array<string>,
333 - ) => void,
345 + ) => void | (() => void),
346 };
347
348 export type ActivityProps = {