Properly clean up gesture Animations (#35559)
Follow up to #35337. During a gesture, we always cancel the original animation and create a new one that we control. That's the one we need to add to the set that needs to be cancelled. Otherwise future gestures hang. An unfortunate consequence is that any custom ones that you start e.g. with #35556 or through other means aren't automatically cleaned up (in fact there's not even a clean up callback yet). This can lead these to freeze the whole UI afterwards. It would be really good to get this fixed in browsers instead so we can revert #35337.
Sebastian Markbåge committed
Jan 19, 2026 at 19:26 UTC
a49952b303a646c17f4d667956639300840a3e5b
1 file changed
+10
-3
packages/react-dom-bindings/src/client/ReactFiberConfigDOM.js
+10
-3
@@ -2372,6 +2372,7 @@ function animateGesture(
2372
targetElement: Element,
2373
pseudoElement: string,
2374
timeline: GestureTimeline,
2375
+ viewTransitionAnimations: Array<Animation>,
2376
customTimelineCleanup: Array<() => void>,
2377
rangeStart: number,
2378
rangeEnd: number,
@@ -2464,7 +2465,7 @@ function animateGesture(
2465
if (timeline instanceof AnimationTimeline) {
2466
// Native Timeline
2467
// $FlowFixMe[incompatible-call]
2467
- targetElement.animate(keyframes, {
2468
+ const animation = targetElement.animate(keyframes, {
2469
pseudoElement: pseudoElement,
2470
// Set the timeline to the current gesture timeline to drive the updates.
2471
timeline: timeline,
@@ -2482,6 +2483,7 @@ function animateGesture(
2483
rangeStart: (reverse ? rangeEnd : rangeStart) + '%',
2484
rangeEnd: (reverse ? rangeStart : rangeEnd) + '%',
2485
});
2486
+ viewTransitionAnimations.push(animation);
2487
} else {
2488
// Custom Timeline
2489
// $FlowFixMe[incompatible-call]
@@ -2554,8 +2556,10 @@ export function startGestureTransition(
2556
// $FlowFixMe
2557
const pseudoElement: ?string = effect.pseudoElement;
2558
if (pseudoElement == null) {
2557
- } else if (pseudoElement.startsWith('::view-transition')) {
2558
- viewTransitionAnimations.push(animations[i]);
2559
+ } else if (
2560
+ pseudoElement.startsWith('::view-transition') &&
2561
+ effect.target === documentElement
2562
+ ) {
2563
const timing = effect.getTiming();
2564
const duration =
2565
// $FlowFixMe[prop-missing]
@@ -2648,6 +2652,7 @@ export function startGestureTransition(
2652
effect.target,
2653
pseudoElement,
2654
timeline,
2655
+ viewTransitionAnimations,
2656
customTimelineCleanup,
2657
adjustedRangeStart,
2658
adjustedRangeEnd,
@@ -2675,6 +2680,7 @@ export function startGestureTransition(
2680
effect.target,
2681
pseudoElementName,
2682
timeline,
2683
+ viewTransitionAnimations,
2684
customTimelineCleanup,
2685
rangeStart,
2686
rangeEnd,
@@ -2696,6 +2702,7 @@ export function startGestureTransition(
2702
duration: 1,
2703
});
2704
blockingAnim.pause();
2705
+ viewTransitionAnimations.push(blockingAnim);
2706
animateCallback();
2707
};
2708
// In Chrome, "new" animations are not ready in the ready callback. We have to wait