From 4bcf67e74657086ff7d8f951d6365db06cf1f72c Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?Sebastian=20Markb=C3=A5ge?= Date: Mon, 19 Jan 2026 19:26:09 -0500 Subject: [PATCH 1/5] Support onGestureEnter/Exit/Share/Update events (#35556) This is like the onEnter/Exit/Share/Update events but for gestures. It allows manually controlling the animation using the passed timeline. --- .../view-transition/src/components/Page.js | 47 +++++++++++++- .../src/ReactFiberApplyGesture.js | 10 ++- .../src/ReactFiberCommitViewTransitions.js | 9 ++- .../src/ReactFiberWorkLoop.js | 65 ++++++++++++++++++- packages/shared/ReactTypes.js | 29 +++++++++ 5 files changed, 154 insertions(+), 6 deletions(-) diff --git a/fixtures/view-transition/src/components/Page.js b/fixtures/view-transition/src/components/Page.js index 6227e9ebc5f..962ef168b4c 100644 --- a/fixtures/view-transition/src/components/Page.js +++ b/fixtures/view-transition/src/components/Page.js @@ -86,6 +86,48 @@ export default function Page({url, navigate}) { viewTransition.new.animate(keyframes, 250); } + function onGestureTransition( + timeline, + {rangeStart, rangeEnd}, + viewTransition, + types + ) { + const keyframes = [ + {rotate: '0deg', transformOrigin: '30px 8px'}, + {rotate: '360deg', transformOrigin: '30px 8px'}, + ]; + const reverse = rangeStart > rangeEnd; + if (timeline instanceof AnimationTimeline) { + // Native Timeline + const options = { + timeline: timeline, + direction: reverse ? 'normal' : 'reverse', + rangeStart: (reverse ? rangeEnd : rangeStart) + '%', + rangeEnd: (reverse ? rangeStart : rangeEnd) + '%', + }; + viewTransition.old.animate(keyframes, options); + viewTransition.new.animate(keyframes, options); + } else { + // Custom Timeline + const options = { + direction: reverse ? 'normal' : 'reverse', + // We set the delay and duration to represent the span of the range. + delay: reverse ? rangeEnd : rangeStart, + duration: reverse ? rangeStart - rangeEnd : rangeEnd - rangeStart, + }; + const animation1 = viewTransition.old.animate(keyframes, options); + const animation2 = viewTransition.new.animate(keyframes, options); + // 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(); + // }; + } + } + function swipeAction() { navigate(show ? '/?a' : '/?b'); } @@ -131,7 +173,10 @@ export default function Page({url, navigate}) { ); const exclamation = ( - +
!
diff --git a/packages/react-reconciler/src/ReactFiberApplyGesture.js b/packages/react-reconciler/src/ReactFiberApplyGesture.js index f18e33b57ba..40bdeb7a342 100644 --- a/packages/react-reconciler/src/ReactFiberApplyGesture.js +++ b/packages/react-reconciler/src/ReactFiberApplyGesture.js @@ -82,6 +82,7 @@ import { enableComponentPerformanceTrack, } from 'shared/ReactFeatureFlags'; import {trackAnimatingTask} from './ReactProfilerTimer'; +import {scheduleGestureTransitionEvent} from './ReactFiberWorkLoop'; let didWarnForRootClone = false; @@ -280,6 +281,7 @@ function applyAppearingPairViewTransition(child: Fiber): void { if (clones !== null) { applyViewTransitionToClones(name, className, clones, child); } + scheduleGestureTransitionEvent(child, props.onGestureShare); } } } @@ -310,6 +312,11 @@ function applyExitViewTransition(placement: Fiber): void { if (clones !== null) { applyViewTransitionToClones(name, className, clones, placement); } + if (state.paired) { + scheduleGestureTransitionEvent(placement, props.onGestureShare); + } else { + scheduleGestureTransitionEvent(placement, props.onGestureExit); + } } } @@ -1123,7 +1130,8 @@ function applyViewTransitionsOnFiber(finishedWork: Fiber) { // TODO: If this doesn't end up canceled, because a parent animates, // then we should probably issue an event since this instance is part of it. } else { - // TODO: Schedule gesture events. + const props: ViewTransitionProps = finishedWork.memoizedProps; + scheduleGestureTransitionEvent(finishedWork, props.onGestureUpdate); // If this boundary did update, we cannot cancel its children so those are dropped. popViewTransitionCancelableScope(prevCancelableChildren); } diff --git a/packages/react-reconciler/src/ReactFiberCommitViewTransitions.js b/packages/react-reconciler/src/ReactFiberCommitViewTransitions.js index 64940c31957..a9edc0c84d2 100644 --- a/packages/react-reconciler/src/ReactFiberCommitViewTransitions.js +++ b/packages/react-reconciler/src/ReactFiberCommitViewTransitions.js @@ -34,7 +34,10 @@ import { hasInstanceAffectedParent, wasInstanceInViewport, } from './ReactFiberConfig'; -import {scheduleViewTransitionEvent} from './ReactFiberWorkLoop'; +import { + scheduleViewTransitionEvent, + scheduleGestureTransitionEvent, +} from './ReactFiberWorkLoop'; import { getViewTransitionName, getViewTransitionClassName, @@ -312,7 +315,7 @@ export function commitEnterViewTransitions( if (!state.paired) { if (gesture) { - // TODO: Schedule gesture events. + scheduleGestureTransitionEvent(placement, props.onGestureEnter); } else { scheduleViewTransitionEvent(placement, props.onEnter); } @@ -848,7 +851,7 @@ export function measureNestedViewTransitions( // Nothing changed. } else { if (gesture) { - // TODO: Schedule gesture events. + scheduleGestureTransitionEvent(child, props.onGestureUpdate); } else { scheduleViewTransitionEvent(child, props.onUpdate); } diff --git a/packages/react-reconciler/src/ReactFiberWorkLoop.js b/packages/react-reconciler/src/ReactFiberWorkLoop.js index 5bc334364ad..206bf797d60 100644 --- a/packages/react-reconciler/src/ReactFiberWorkLoop.js +++ b/packages/react-reconciler/src/ReactFiberWorkLoop.js @@ -9,7 +9,11 @@ import {REACT_STRICT_MODE_TYPE} from 'shared/ReactSymbols'; -import type {Wakeable, Thenable} from 'shared/ReactTypes'; +import type { + Wakeable, + Thenable, + GestureOptionsRequired, +} from 'shared/ReactTypes'; import type {Fiber, FiberRoot} from './ReactInternalTypes'; import type {Lanes, Lane} from './ReactFiberLane'; import type {ActivityState} from './ReactFiberActivityComponent'; @@ -26,6 +30,7 @@ import type { Resource, ViewTransitionInstance, RunningViewTransition, + GestureTimeline, SuspendedState, } from './ReactFiberConfig'; import type {RootState} from './ReactFiberRoot'; @@ -913,6 +918,42 @@ export function scheduleViewTransitionEvent( } } +export function scheduleGestureTransitionEvent( + fiber: Fiber, + callback: ?( + timeline: GestureTimeline, + options: GestureOptionsRequired, + instance: ViewTransitionInstance, + types: Array, + ) => void, +): void { + if (enableGestureTransition) { + if (callback != null) { + const applyingGesture = pendingEffectsRoot.pendingGestures; + if (applyingGesture !== null) { + const state: ViewTransitionState = fiber.stateNode; + let instance = state.ref; + if (instance === null) { + instance = state.ref = createViewTransitionInstance( + getViewTransitionName(fiber.memoizedProps, state), + ); + } + const timeline = applyingGesture.provider; + const options = { + rangeStart: applyingGesture.rangeStart, + rangeEnd: applyingGesture.rangeEnd, + }; + if (pendingViewTransitionEvents === null) { + pendingViewTransitionEvents = []; + } + pendingViewTransitionEvents.push( + callback.bind(null, timeline, options, instance), + ); + } + } + } +} + export function peekDeferredLane(): Lane { return workInProgressDeferredLane; } @@ -4352,6 +4393,8 @@ function applyGestureOnRoot( startAnimating(pendingEffectsLanes); } + pendingViewTransitionEvents = null; + const prevTransition = ReactSharedInternals.T; ReactSharedInternals.T = null; const previousPriority = getCurrentUpdatePriority(); @@ -4476,6 +4519,26 @@ function flushGestureAnimations(): void { ReactSharedInternals.T = prevTransition; } + if (enableViewTransition) { + // We should now be after the startGestureTransition's .ready call which is late enough + // to start animating any pseudo-elements. We have also already applied any adjustments + // we do to the built-in animations which can now be read by the refs. + const pendingEvents = pendingViewTransitionEvents; + let pendingTypes = pendingTransitionTypes; + pendingTransitionTypes = null; + if (pendingEvents !== null) { + pendingViewTransitionEvents = null; + if (pendingTypes === null) { + // 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); + } + } + } + if (enableProfilerTimer && enableComponentPerformanceTrack) { finalizeRender(lanes, commitEndTime); } diff --git a/packages/shared/ReactTypes.js b/packages/shared/ReactTypes.js index 65ed43c063c..2596f02c995 100644 --- a/packages/shared/ReactTypes.js +++ b/packages/shared/ReactTypes.js @@ -290,6 +290,11 @@ export type ViewTransitionClass = | string | ViewTransitionClassPerType; +export type GestureOptionsRequired = { + rangeStart: number, + rangeEnd: number, +}; + export type ViewTransitionProps = { name?: string, children?: ReactNodeList, @@ -302,6 +307,30 @@ export type ViewTransitionProps = { onExit?: (instance: ViewTransitionInstance, types: Array) => void, onShare?: (instance: ViewTransitionInstance, types: Array) => void, onUpdate?: (instance: ViewTransitionInstance, types: Array) => void, + onGestureEnter?: ( + timeline: GestureProvider, + options: GestureOptionsRequired, + instance: ViewTransitionInstance, + types: Array, + ) => void, + onGestureExit?: ( + timeline: GestureProvider, + options: GestureOptionsRequired, + instance: ViewTransitionInstance, + types: Array, + ) => void, + onGestureShare?: ( + timeline: GestureProvider, + options: GestureOptionsRequired, + instance: ViewTransitionInstance, + types: Array, + ) => void, + onGestureUpdate?: ( + timeline: GestureProvider, + options: GestureOptionsRequired, + instance: ViewTransitionInstance, + types: Array, + ) => void, }; export type ActivityProps = { From a49952b303a646c17f4d667956639300840a3e5b Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?Sebastian=20Markb=C3=A5ge?= Date: Mon, 19 Jan 2026 19:26:28 -0500 Subject: [PATCH 2/5] 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. --- .../src/client/ReactFiberConfigDOM.js | 13 ++++++++++--- 1 file changed, 10 insertions(+), 3 deletions(-) diff --git a/packages/react-dom-bindings/src/client/ReactFiberConfigDOM.js b/packages/react-dom-bindings/src/client/ReactFiberConfigDOM.js index 66e46961d81..4d3bb0248b1 100644 --- a/packages/react-dom-bindings/src/client/ReactFiberConfigDOM.js +++ b/packages/react-dom-bindings/src/client/ReactFiberConfigDOM.js @@ -2372,6 +2372,7 @@ function animateGesture( targetElement: Element, pseudoElement: string, timeline: GestureTimeline, + viewTransitionAnimations: Array, customTimelineCleanup: Array<() => void>, rangeStart: number, rangeEnd: number, @@ -2464,7 +2465,7 @@ function animateGesture( if (timeline instanceof AnimationTimeline) { // Native Timeline // $FlowFixMe[incompatible-call] - targetElement.animate(keyframes, { + const animation = targetElement.animate(keyframes, { pseudoElement: pseudoElement, // Set the timeline to the current gesture timeline to drive the updates. timeline: timeline, @@ -2482,6 +2483,7 @@ function animateGesture( rangeStart: (reverse ? rangeEnd : rangeStart) + '%', rangeEnd: (reverse ? rangeStart : rangeEnd) + '%', }); + viewTransitionAnimations.push(animation); } else { // Custom Timeline // $FlowFixMe[incompatible-call] @@ -2554,8 +2556,10 @@ export function startGestureTransition( // $FlowFixMe const pseudoElement: ?string = effect.pseudoElement; if (pseudoElement == null) { - } else if (pseudoElement.startsWith('::view-transition')) { - viewTransitionAnimations.push(animations[i]); + } else if ( + pseudoElement.startsWith('::view-transition') && + effect.target === documentElement + ) { const timing = effect.getTiming(); const duration = // $FlowFixMe[prop-missing] @@ -2648,6 +2652,7 @@ export function startGestureTransition( effect.target, pseudoElement, timeline, + viewTransitionAnimations, customTimelineCleanup, adjustedRangeStart, adjustedRangeEnd, @@ -2675,6 +2680,7 @@ export function startGestureTransition( effect.target, pseudoElementName, timeline, + viewTransitionAnimations, customTimelineCleanup, rangeStart, rangeEnd, @@ -2696,6 +2702,7 @@ export function startGestureTransition( duration: 1, }); blockingAnim.pause(); + viewTransitionAnimations.push(blockingAnim); animateCallback(); }; // In Chrome, "new" animations are not ready in the ready callback. We have to wait From c55ffb5ca3b2ba3a09a978629d5a1548c0e8085b Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?Sebastian=20Markb=C3=A5ge?= Date: Mon, 19 Jan 2026 19:27:45 -0500 Subject: [PATCH 3/5] 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 ``. --- .../view-transition/src/components/Page.js | 25 +++++++----- 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/ReactFiberWorkLoop.js | 40 ++++++++++++++----- .../src/forks/ReactFiberConfig.custom.js | 2 + .../src/ReactFiberConfigTestHost.js | 7 ++++ packages/shared/ReactTypes.js | 28 +++++++++---- 10 files changed, 105 insertions(+), 27 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(); + }; } } 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/ReactFiberWorkLoop.js b/packages/react-reconciler/src/ReactFiberWorkLoop.js index 206bf797d60..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'; @@ -733,8 +734,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 +901,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 +930,7 @@ export function scheduleGestureTransitionEvent( options: GestureOptionsRequired, instance: ViewTransitionInstance, types: Array, - ) => void, + ) => void | (() => void), ): void { if (enableGestureTransition) { if (callback != null) { @@ -4143,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 @@ -4262,9 +4268,14 @@ 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); + if (committedViewTransition !== null) { + for (let i = 0; i < pendingEvents.length; i++) { + const viewTransitionEvent = pendingEvents[i]; + const cleanup = viewTransitionEvent(pendingTypes); + if (cleanup !== undefined) { + addViewTransitionFinishedListener(committedViewTransition, cleanup); + } + } } } } @@ -4532,9 +4543,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) { + 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); + } + } + } } } } 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( 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 1ecd99c774131a6e5c689ee6fe38236e2032f693 Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?Sebastian=20Markb=C3=A5ge?= Date: Mon, 19 Jan 2026 19:27:59 -0500 Subject: [PATCH 4/5] Temporarily Mount useInsertionEffect while a Gesture snapshot is being computed (#35565) `useInsertionEffect` is meant to be used to insert `