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 = {