diff --git a/config/eslint/eslint.seatbelt.tsv b/config/eslint/eslint.seatbelt.tsv
index 8a1e221e216a..30ad39060cde 100644
--- a/config/eslint/eslint.seatbelt.tsv
+++ b/config/eslint/eslint.seatbelt.tsv
@@ -116,8 +116,6 @@
"../../src/components/Modal/BaseModal.tsx" "react-hooks/set-state-in-effect" 1
"../../src/components/Modal/Global/ConfirmModalWrapper.tsx" "@typescript-eslint/no-deprecated/ConfirmModal" 1
"../../src/components/Modal/ReanimatedModal/Container/index.web.tsx" "react-hooks/refs" 1
-"../../src/components/Modal/ReanimatedModal/index.tsx" "@typescript-eslint/no-deprecated/InteractionManager.clearInteractionHandle" 1
-"../../src/components/Modal/ReanimatedModal/index.tsx" "@typescript-eslint/no-deprecated/InteractionManager.createInteractionHandle" 1
"../../src/components/MoneyReportHeaderActions/MoneyReportHeaderSecondaryActions.tsx" "@typescript-eslint/no-deprecated/InteractionManager.runAfterInteractions" 1
"../../src/components/MoneyReportHeaderModals.tsx" "@typescript-eslint/no-deprecated/InteractionManager.runAfterInteractions" 1
"../../src/components/MoneyRequestAmountInput.tsx" "react-hooks/immutability" 2
diff --git a/src/components/Modal/ReanimatedModal/Backdrop/index.tsx b/src/components/Modal/ReanimatedModal/Backdrop/index.tsx
index 433317abba31..98f41ee4173a 100644
--- a/src/components/Modal/ReanimatedModal/Backdrop/index.tsx
+++ b/src/components/Modal/ReanimatedModal/Backdrop/index.tsx
@@ -1,8 +1,9 @@
import React from 'react';
-import Animated, {Keyframe} from 'react-native-reanimated';
+import Animated, {Keyframe, ReduceMotion} from 'react-native-reanimated';
import type {BackdropProps} from '@components/Modal/ReanimatedModal/types';
import {getModalInAnimation, getModalOutAnimation} from '@components/Modal/ReanimatedModal/utils';
import {PressableWithoutFeedback} from '@components/Pressable';
+import useAnimationTransition from '@hooks/useAnimationTransition';
import useLocalize from '@hooks/useLocalize';
import useThemeStyles from '@hooks/useThemeStyles';
import variables from '@styles/variables';
@@ -18,19 +19,14 @@ function Backdrop({
}: BackdropProps) {
const styles = useThemeStyles();
const {translate} = useLocalize();
+ const {onAnimationComplete} = useAnimationTransition();
- const Entering = new Keyframe(getModalInAnimation('fadeIn')).duration(animationInTiming);
- const Exiting = new Keyframe(getModalOutAnimation('fadeOut')).duration(animationOutTiming);
-
- const BackdropOverlay = (
-
- {!!customBackdrop && customBackdrop}
-
- );
+ const Entering = new Keyframe(getModalInAnimation('fadeIn'))
+ .duration(animationInTiming)
+ // ReduceMotion.Never ensures the callback fires even when system motion is reduced
+ .reduceMotion(ReduceMotion.Never)
+ .withCallback(onAnimationComplete);
+ const Exiting = new Keyframe(getModalOutAnimation('fadeOut')).duration(animationOutTiming).reduceMotion(ReduceMotion.Never).withCallback(onAnimationComplete);
if (!customBackdrop) {
return (
@@ -40,12 +36,24 @@ function Backdrop({
onPressIn={onBackdropPress}
sentryLabel={CONST.SENTRY_LABEL.REANIMATED_MODAL.BACKDROP}
>
- {BackdropOverlay}
+
);
}
- return BackdropOverlay;
+ return (
+
+ {customBackdrop}
+
+ );
}
export default Backdrop;
diff --git a/src/components/Modal/ReanimatedModal/Container/GestureHandler.tsx b/src/components/Modal/ReanimatedModal/Container/GestureHandler.tsx
index 0507a1f9d8ed..3af5a56d3a2f 100644
--- a/src/components/Modal/ReanimatedModal/Container/GestureHandler.tsx
+++ b/src/components/Modal/ReanimatedModal/Container/GestureHandler.tsx
@@ -1,5 +1,5 @@
import type {PropsWithChildren} from 'react';
-import React, {useMemo} from 'react';
+import React from 'react';
import type {GestureStateChangeEvent, GestureType, PanGestureHandlerEventPayload} from 'react-native-gesture-handler';
import {Gesture, GestureDetector} from 'react-native-gesture-handler';
import {useSharedValue} from 'react-native-reanimated';
@@ -52,18 +52,14 @@ function hasSwipeEnded(
function GestureHandler({swipeDirection, onSwipeComplete, swipeThreshold = 100, children}: PropsWithChildren) {
const initialTranslationX = useSharedValue(0);
const initialTranslationY = useSharedValue(0);
- const panGesture: GestureType = useMemo(
- () =>
- Gesture.Pan()
- .onStart((e) => {
- initialTranslationX.set(e.translationX);
- initialTranslationY.set(e.translationY);
- })
- .onEnd((e) => {
- hasSwipeEnded(e, {x: initialTranslationX.get(), y: initialTranslationY.get()}, swipeThreshold, swipeDirection, onSwipeComplete);
- }),
- [initialTranslationX, initialTranslationY, onSwipeComplete, swipeDirection, swipeThreshold],
- );
+ const panGesture: GestureType = Gesture.Pan()
+ .onStart((e) => {
+ initialTranslationX.set(e.translationX);
+ initialTranslationY.set(e.translationY);
+ })
+ .onEnd((e) => {
+ hasSwipeEnded(e, {x: initialTranslationX.get(), y: initialTranslationY.get()}, swipeThreshold, swipeDirection, onSwipeComplete);
+ });
if (!swipeDirection || !swipeDirection?.length || !onSwipeComplete) {
return children;
diff --git a/src/components/Modal/ReanimatedModal/Container/index.tsx b/src/components/Modal/ReanimatedModal/Container/index.tsx
index d8ec179f4519..a5a04ebe4dc2 100644
--- a/src/components/Modal/ReanimatedModal/Container/index.tsx
+++ b/src/components/Modal/ReanimatedModal/Container/index.tsx
@@ -1,10 +1,10 @@
-import React, {useMemo} from 'react';
+import React from 'react';
import {View} from 'react-native';
import Animated, {Keyframe} from 'react-native-reanimated';
import {scheduleOnRN} from 'react-native-worklets';
-import type ReanimatedModalProps from '@components/Modal/ReanimatedModal/types';
import type {ContainerProps} from '@components/Modal/ReanimatedModal/types';
import {getModalInAnimation, getModalOutAnimation} from '@components/Modal/ReanimatedModal/utils';
+import useAnimationTransition from '@hooks/useAnimationTransition';
import useThemeStyles from '@hooks/useThemeStyles';
import CONST from '@src/CONST';
import GestureHandler from './GestureHandler';
@@ -22,28 +22,23 @@ function Container({
swipeDirection,
swipeThreshold = 100,
...props
-}: Partial & ContainerProps) {
+}: ContainerProps) {
const styles = useThemeStyles();
+ const {onAnimationComplete} = useAnimationTransition();
- const Entering = useMemo(() => {
- const AnimationIn = new Keyframe(getModalInAnimation(animationIn));
+ const Entering = new Keyframe(getModalInAnimation(animationIn)).duration(animationInTiming).withCallback(() => {
+ 'worklet';
- return AnimationIn.duration(animationInTiming).withCallback(() => {
- 'worklet';
+ scheduleOnRN(onOpenCallBack);
+ scheduleOnRN(onAnimationComplete);
+ });
- scheduleOnRN(onOpenCallBack);
- });
- }, [animationIn, animationInTiming, onOpenCallBack]);
+ const Exiting = new Keyframe(getModalOutAnimation(animationOut)).duration(animationOutTiming).withCallback(() => {
+ 'worklet';
- const Exiting = useMemo(() => {
- const AnimationOut = new Keyframe(getModalOutAnimation(animationOut));
-
- return AnimationOut.duration(animationOutTiming).withCallback(() => {
- 'worklet';
-
- scheduleOnRN(onCloseCallBack);
- });
- }, [animationOutTiming, onCloseCallBack, animationOut]);
+ scheduleOnRN(onCloseCallBack);
+ scheduleOnRN(onAnimationComplete);
+ });
return (
{
- onCloseCallbackRef.current = onCloseCallBack;
- }, [onCloseCallBack]);
-
useEffect(() => {
if (isInitiated.get()) {
return;
@@ -41,24 +38,26 @@ function Container({
// we enable the animations to make sure they are called
reduceMotion: ReduceMotion.Never,
},
- onOpenCallBack,
+ () => {
+ onOpenCallBack();
+ onAnimationComplete();
+ },
),
);
- }, [animationInTiming, onOpenCallBack, initProgress, isInitiated]);
+ }, [animationInTiming, onOpenCallBack, onAnimationComplete, initProgress, isInitiated]);
// instead of an entering transition since keyframe animations break keyboard on mWeb Chrome (#62799)
- const animatedStyles = useAnimatedStyle(() => getModalInAnimationStyle(animationIn)(initProgress.get()), [initProgress]);
+ const animatedStyles = useAnimatedStyle(() => getModalInAnimationStyle(animationIn)(initProgress.get()), [animationIn, initProgress]);
- const Exiting = useMemo(
- () =>
- new Keyframe(getModalOutAnimation(animationOut))
- .duration(animationOutTiming)
- .withCallback(() => onCloseCallbackRef.current())
- // on web the callbacks are not called when animations are disabled with the reduced motion setting on
- // we enable the animations to make sure they are called
- .reduceMotion(ReduceMotion.Never),
- [animationOutTiming, animationOut],
- );
+ const Exiting = new Keyframe(getModalOutAnimation(animationOut))
+ .duration(animationOutTiming)
+ .withCallback(() => {
+ onCloseCallBack();
+ onAnimationComplete();
+ })
+ // on web the callbacks are not called when animations are disabled with the reduced motion setting on
+ // we enable the animations to make sure they are called
+ .reduceMotion(ReduceMotion.Never);
return (
(() => (isVisible ? 'open' : 'closed'));
const {windowWidth, windowHeight} = useWindowDimensions();
+ const styles = useThemeStyles();
const backHandlerListener = useRef(null);
- const handleRef = useRef(undefined);
- const transitionHandleRef = useRef(null);
- const styles = useThemeStyles();
+ // When isVisible changes, advance the state machine from stable states only.
+ // Mid-animation changes are ignored — the animation runs to completion and the
+ // final isVisible value is honored when the callback fires.
+ useOnValueChange(isVisible, (_, nextIsVisible) => {
+ if (nextIsVisible && modalState === 'closed') {
+ setModalState('opening');
+ } else if (!nextIsVisible && modalState === 'open') {
+ setModalState('closing');
+ }
+ });
+
+ const isTransitioning = modalState === 'opening' || modalState === 'closing';
+ const backdropStyle: ViewStyle = {width: windowWidth, height: windowHeight, backgroundColor: backdropColor};
+ const modalStyle = {zIndex: StyleSheet.flatten(style)?.zIndex};
- const onBackButtonPressHandler = useCallback(() => {
+ const tryClose = () => {
if (shouldIgnoreBackHandlerDuringTransition && isTransitioning) {
return false;
}
- if (isVisibleState) {
+ if (isVisible) {
onBackButtonPress();
return true;
}
return false;
- }, [isVisibleState, onBackButtonPress, isTransitioning, shouldIgnoreBackHandlerDuringTransition]);
+ };
- const handleEscape = useCallback(
- (e: KeyboardEvent) => {
- if (e.key !== 'Escape' || onBackButtonPressHandler() !== true) {
- return;
- }
- e.stopImmediatePropagation();
- },
- [onBackButtonPressHandler],
- );
+ const tryCloseEffectEvent = useEffectEvent(() => tryClose());
+
+ const handleEscape = useEffectEvent((e: KeyboardEvent) => {
+ if (e.key !== 'Escape' || tryClose() !== true) {
+ return;
+ }
+ e.stopImmediatePropagation();
+ });
+
+ const onOpenCallBack = () => {
+ setModalState('open');
+ onModalShow();
+ };
+
+ const onCloseCallBack = () => {
+ setModalState('closed');
+
+ // Because on Android, the Modal's onDismiss callback does not work reliably. There's a reported issue at:
+ // https://stackoverflow.com/questions/58937956/react-native-modal-ondismiss-not-invoked
+ // Therefore, we manually call onModalHide() here for Android.
+ if (getPlatform() === CONST.PLATFORM.ANDROID) {
+ onModalHide();
+ }
+ };
useEffect(() => {
if (getPlatform() === CONST.PLATFORM.WEB) {
document.body.addEventListener('keyup', handleEscape, {capture: true});
} else {
- backHandlerListener.current = BackHandler.addEventListener('hardwareBackPress', onBackButtonPressHandler);
+ backHandlerListener.current = BackHandler.addEventListener('hardwareBackPress', tryCloseEffectEvent);
}
return () => {
@@ -101,95 +126,26 @@ function ReanimatedModal({
backHandlerListener.current?.remove();
}
};
- }, [handleEscape, onBackButtonPressHandler]);
-
- useEffect(
- () => () => {
- if (handleRef.current) {
- // eslint-disable-next-line @typescript-eslint/no-deprecated
- InteractionManager.clearInteractionHandle(handleRef.current);
- }
- if (transitionHandleRef.current) {
- TransitionTracker.endTransition(transitionHandleRef.current);
- transitionHandleRef.current = null;
- }
-
- setIsVisibleState(false);
- setIsContainerOpen(false);
- },
+ }, []);
- [],
- );
-
- useEffect(() => {
- if (isVisible && !isContainerOpen && !isTransitioning) {
- // eslint-disable-next-line @typescript-eslint/no-deprecated
- handleRef.current = InteractionManager.createInteractionHandle();
- transitionHandleRef.current = TransitionTracker.startTransition();
+ const fireTransitionCallbacks = useEffectEvent(() => {
+ if (modalState === 'opening') {
onModalWillShow();
-
- // eslint-disable-next-line react-hooks/set-state-in-effect
- setIsVisibleState(true);
- setIsTransitioning(true);
- } else if (!isVisible && isContainerOpen && !isTransitioning) {
- handleRef.current = InteractionManager.createInteractionHandle();
- transitionHandleRef.current = TransitionTracker.startTransition();
+ } else if (modalState === 'closing') {
onModalWillHide();
-
blurActiveElement();
- setIsVisibleState(false);
- setIsTransitioning(true);
- }
- // eslint-disable-next-line react-hooks/exhaustive-deps
- }, [isVisible, isContainerOpen, isTransitioning]);
-
- const backdropStyle: ViewStyle = useMemo(() => {
- return {width: windowWidth, height: windowHeight, backgroundColor: backdropColor};
- }, [windowWidth, windowHeight, backdropColor]);
-
- const onOpenCallBack = useCallback(() => {
- setIsTransitioning(false);
- setIsContainerOpen(true);
- if (handleRef.current) {
- // eslint-disable-next-line @typescript-eslint/no-deprecated
- InteractionManager.clearInteractionHandle(handleRef.current);
- }
- if (transitionHandleRef.current) {
- TransitionTracker.endTransition(transitionHandleRef.current);
- transitionHandleRef.current = null;
}
- onModalShow();
- }, [onModalShow]);
+ });
- const onCloseCallBack = useCallback(() => {
- setIsTransitioning(false);
- setIsContainerOpen(false);
- if (handleRef.current) {
- InteractionManager.clearInteractionHandle(handleRef.current);
- }
- if (transitionHandleRef.current) {
- TransitionTracker.endTransition(transitionHandleRef.current);
- transitionHandleRef.current = null;
- }
-
- // Because on Android, the Modal's onDismiss callback does not work reliably. There's a reported issue at:
- // https://stackoverflow.com/questions/58937956/react-native-modal-ondismiss-not-invoked
- // Therefore, we manually call onModalHide() here for Android.
- if (getPlatform() === CONST.PLATFORM.ANDROID) {
- onModalHide();
- }
- }, [onModalHide]);
-
- const modalStyle = useMemo(() => {
- return {zIndex: StyleSheet.flatten(style)?.zIndex};
- }, [style]);
+ useEffect(() => {
+ fireTransitionCallbacks();
+ }, [modalState]);
const containerView = (
);
- if (!coverScreen && isVisibleState) {
+ if (!coverScreen && isVisible) {
return (
);
}
- const isBackdropMounted = isVisibleState || ((isTransitioning || isContainerOpen !== isVisibleState) && getPlatform() === CONST.PLATFORM.WEB);
- const modalVisibility = isVisibleState || isTransitioning || isContainerOpen !== isVisibleState;
+
+ // Backdrop stays mounted on web during 'closing' so it can play its own exit animation
+ // while the Container is still finishing its exit animation.
+ const isBackdropMounted = isVisible || (modalState === 'closing' && getPlatform() === CONST.PLATFORM.WEB);
+ const modalVisibility = modalState !== 'closed';
+
return (
{
@@ -255,7 +214,7 @@ function ReanimatedModal({
pointerEvents="box-none"
style={[style, {margin: 0}]}
>
- {isVisibleState && containerView}
+ {(modalState === 'opening' || modalState === 'open') && containerView}
) : (
- {isVisibleState && containerView}
+ {(modalState === 'opening' || modalState === 'open') && containerView}
)}
diff --git a/src/components/Modal/ReanimatedModal/types.ts b/src/components/Modal/ReanimatedModal/types.ts
index b3b446b7d312..43b5d72d8300 100644
--- a/src/components/Modal/ReanimatedModal/types.ts
+++ b/src/components/Modal/ReanimatedModal/types.ts
@@ -20,7 +20,7 @@ type GestureHandlerProps = {
onSwipeComplete?: () => void;
/** Threshold for swipe gesture. */
- swipeThreshold: number;
+ swipeThreshold?: number;
/** Threshold for swipe gesture. */
swipeDirection?: SwipeDirection | SwipeDirection[];
@@ -33,7 +33,7 @@ type ReanimatedModalProps = ViewProps &
GestureProps &
GestureHandlerProps & {
/** Content inside the modal */
- children: ReactNode;
+ children?: ReactNode;
/** Style applied to the modal container */
style?: StyleProp;
@@ -162,9 +162,6 @@ type BackdropProps = {
/** Callback fired when pressing the backdrop */
onBackdropPress?: () => void;
- /** Delay set to animation on enter */
- animationInDelay?: number;
-
/** Timing of animation on enter */
animationInTiming?: number;
@@ -178,7 +175,7 @@ type BackdropProps = {
isBackdropVisible: boolean;
};
-type ContainerProps = {
+type ContainerProps = ViewProps & {
/** This function is called by open animation callback */
onOpenCallBack: () => void;
@@ -193,6 +190,27 @@ type ContainerProps = {
/** Animation played when modal disappears */
animationOut: AnimationOut;
+
+ /** Duration of the animation when modal appears */
+ animationInTiming?: number;
+
+ /** Duration of the animation when modal disappears */
+ animationOutTiming?: number;
+
+ /** Style applied to the modal container */
+ style?: StyleProp;
+
+ /** Modal type */
+ type?: ValueOf;
+
+ /** Callback to be fired on swipe gesture */
+ onSwipeComplete?: () => void;
+
+ /** Direction of swipe gesture */
+ swipeDirection?: SwipeDirection | SwipeDirection[];
+
+ /** Threshold for swipe gesture */
+ swipeThreshold?: number;
};
export default ReanimatedModalProps;
diff --git a/src/hooks/useAnimationTransition.ts b/src/hooks/useAnimationTransition.ts
new file mode 100644
index 000000000000..2128e021f91c
--- /dev/null
+++ b/src/hooks/useAnimationTransition.ts
@@ -0,0 +1,55 @@
+import {useLayoutEffect, useRef} from 'react';
+// eslint-disable-next-line no-restricted-imports
+import {InteractionManager} from 'react-native';
+// eslint-disable-next-line no-restricted-imports
+import TransitionTracker from '@libs/Navigation/TransitionTracker';
+// eslint-disable-next-line no-restricted-imports
+import type {TransitionHandle} from '@libs/Navigation/TransitionTracker';
+
+/**
+ * Manages TransitionTracker and InteractionManager handle lifecycle directly from
+ * component mount/unmount and animation-completion callbacks, bypassing React state.
+ *
+ * A transition handle is started when the component mounts (entering animation begins)
+ * and when the component is about to unmount (exiting animation begins via Reanimated's
+ * ghost-node mechanism). The returned `onAnimationComplete` callback ends the handle
+ * when the animation finishes.
+ *
+ * The `endTransition`-before-`startTransition` pattern in `startTransition` makes the
+ * hook resilient to React Strict Mode's mount→cleanup→remount double-invoke: handles
+ * are always balanced (every start has a matching end before the next start begins).
+ */
+function useAnimationTransition() {
+ const handleRef = useRef(null);
+ const interactionHandleRef = useRef(undefined);
+
+ const endTransition = () => {
+ if (handleRef.current) {
+ TransitionTracker.endTransition(handleRef.current);
+ handleRef.current = null;
+ }
+ if (interactionHandleRef.current !== undefined) {
+ // eslint-disable-next-line @typescript-eslint/no-deprecated
+ InteractionManager.clearInteractionHandle(interactionHandleRef.current);
+ interactionHandleRef.current = undefined;
+ }
+ };
+
+ const startTransition = () => {
+ endTransition();
+ handleRef.current = TransitionTracker.startTransition();
+ // eslint-disable-next-line @typescript-eslint/no-deprecated
+ interactionHandleRef.current = InteractionManager.createInteractionHandle();
+ };
+
+ useLayoutEffect(() => {
+ startTransition(); // entering animation starts on mount
+ return () => {
+ startTransition(); // exiting animation starts on React unmount (ghost-node mechanism)
+ };
+ }, [startTransition]);
+
+ return {onAnimationComplete: endTransition};
+}
+
+export default useAnimationTransition;
diff --git a/src/hooks/useOnValueChange.ts b/src/hooks/useOnValueChange.ts
new file mode 100644
index 000000000000..8e2345c27884
--- /dev/null
+++ b/src/hooks/useOnValueChange.ts
@@ -0,0 +1,30 @@
+import {useState} from 'react';
+
+/**
+ * Calls `onChange` during render whenever `value` changes, using React's
+ * render-time state adjustment pattern.
+ *
+ * Unlike useEffect, there is no intermediate commit with stale state —
+ * React discards the first render and immediately re-renders with whatever
+ * state onChange sets, producing a single DOM commit.
+ *
+ * The callback must only call setState (or other render-safe operations).
+ * Side effects (API calls, subscriptions, mutations) belong in useEffect.
+ *
+ * Although this pattern is more efficient than an Effect, most components
+ * shouldn't need it either. No matter how you do it, adjusting state based
+ * on props or other state makes your data flow more difficult to understand
+ * and debug. Always check whether you can reset all state with a key or
+ * calculate everything during rendering instead.
+ *
+ * See: https://react.dev/learn/you-might-not-need-an-effect#adjusting-some-state-when-a-prop-changes
+ */
+function useOnValueChange(value: T, onChange: (prevValue: T, nextValue: T) => void): void {
+ const [prev, setPrev] = useState(value);
+ if (!Object.is(prev, value)) {
+ setPrev(value);
+ onChange(prev, value);
+ }
+}
+
+export default useOnValueChange;
diff --git a/tests/unit/ReanimatedModalTest.tsx b/tests/unit/ReanimatedModalTest.tsx
new file mode 100644
index 000000000000..e46c6f648875
--- /dev/null
+++ b/tests/unit/ReanimatedModalTest.tsx
@@ -0,0 +1,290 @@
+/**
+ * Unit tests for ReanimatedModal's transition state machine.
+ *
+ * These tests guard against regressions introduced by incorrect isTransitioning
+ * derivation (isVisible !== isContainerOpen). The derived approach fails when
+ * isVisible oscillates back to match isContainerOpen mid-animation, causing
+ * premature handle cleanup and broken animations.
+ *
+ * Related staging regressions:
+ * - https://github.com/Expensify/App/issues/90438 (RHP does not animate)
+ * - https://github.com/Expensify/App/issues/90442 (Unable to delete on Android)
+ * - https://github.com/Expensify/App/issues/90463 (Modal not displayed on Android)
+ * - https://github.com/Expensify/App/issues/90510 (Web flickering on close)
+ */
+import {act, render, screen} from '@testing-library/react-native';
+import React from 'react';
+// eslint-disable-next-line no-restricted-imports
+import {InteractionManager} from 'react-native';
+import ReanimatedModal from '@components/Modal/ReanimatedModal';
+// eslint-disable-next-line no-restricted-imports
+import TransitionTracker from '@libs/Navigation/TransitionTracker';
+
+// ---------------------------------------------------------------------------
+// Container mock
+//
+// Reanimated's Keyframe.withCallback() is a no-op in the test environment,
+// so onOpenCallBack / onCloseCallBack are never called by animations.
+// This mock captures the latest callback so tests can trigger it manually
+// to simulate animation completion.
+//
+// The mock also uses useAnimationTransition to mirror real Container behavior:
+// handles are created/ended in the same lifecycle as the real component, so
+// tests can spy on TransitionTracker and InteractionManager at this level.
+// ---------------------------------------------------------------------------
+let capturedOnOpenCallBack: (() => void) | undefined;
+
+jest.mock('@components/Modal/ReanimatedModal/Container', () => {
+ // eslint-disable-next-line @typescript-eslint/no-unsafe-assignment
+ const {View} = require('react-native');
+ const {default: useAnimationTransition} = require('@hooks/useAnimationTransition') as {
+ default: () => {onAnimationComplete: () => void};
+ };
+ // Hooks can assign to external variables (RC only restricts this in components).
+ // We use a hook to wire the captured callback, keeping MockContainer RC-compliant.
+ function useCaptureOpenCallback(onOpenCallBack: () => void, onAnimationComplete: () => void) {
+ capturedOnOpenCallBack = () => {
+ onOpenCallBack();
+ onAnimationComplete();
+ };
+ }
+
+ function MockContainer({onOpenCallBack, onCloseCallBack: _onCloseCallBack, children, ...rest}: Record) {
+ const {onAnimationComplete} = useAnimationTransition();
+
+ // Wire the captured callback to also fire onAnimationComplete, matching
+ // real Container behavior where both callbacks fire at animation end.
+ useCaptureOpenCallback(onOpenCallBack as () => void, onAnimationComplete);
+
+ return (
+
+ {children as React.ReactNode}
+
+ );
+ }
+ return MockContainer;
+});
+
+jest.mock('@components/Modal/ReanimatedModal/Backdrop', () => {
+ // eslint-disable-next-line @typescript-eslint/no-unsafe-assignment
+ const {View} = require('react-native');
+ function MockBackdrop({children}: {children?: React.ReactNode}) {
+ return {children};
+ }
+ return MockBackdrop;
+});
+
+jest.mock('@components/FocusTrap/FocusTrapForModal', () => {
+ // eslint-disable-next-line @typescript-eslint/no-unsafe-assignment
+ const {View} = require('react-native');
+ function MockFocusTrap({children}: {children?: React.ReactNode}) {
+ return {children};
+ }
+ return MockFocusTrap;
+});
+
+jest.mock('@hooks/useThemeStyles', () => () => ({}));
+jest.mock('@hooks/useWindowDimensions', () => () => ({windowWidth: 375, windowHeight: 667}));
+jest.mock('@libs/Accessibility/blurActiveElement', () => jest.fn());
+
+// Use a non-web platform so the back handler path is exercised and
+// platform-specific branches stay consistent across tests.
+jest.mock('@libs/getPlatform', () => jest.fn(() => 'ios'));
+
+// ---------------------------------------------------------------------------
+
+describe('ReanimatedModal', () => {
+ let startTransitionSpy: jest.SpyInstance;
+ let createHandleSpy: jest.SpyInstance;
+ let clearHandleSpy: jest.SpyInstance;
+
+ beforeEach(() => {
+ capturedOnOpenCallBack = undefined;
+ startTransitionSpy = jest.spyOn(TransitionTracker, 'startTransition');
+ createHandleSpy = jest.spyOn(InteractionManager, 'createInteractionHandle');
+ clearHandleSpy = jest.spyOn(InteractionManager, 'clearInteractionHandle');
+ });
+
+ afterEach(() => {
+ // Drain any open transitions so TransitionTracker's internal counter
+ // is clean for the next test.
+ jest.runAllTimers();
+ jest.clearAllMocks();
+ });
+
+ // -----------------------------------------------------------------------
+ // Helpers
+ // -----------------------------------------------------------------------
+
+ /** Simulates the opening animation completing. */
+ function completeOpenAnimation() {
+ act(() => {
+ capturedOnOpenCallBack?.();
+ });
+ }
+
+ // -----------------------------------------------------------------------
+ // Oscillation tests — guard for https://github.com/Expensify/App/issues/90438
+ // -----------------------------------------------------------------------
+
+ describe('transition handles during isVisible oscillation', () => {
+ /**
+ * Regression guard for https://github.com/Expensify/App/issues/90438
+ *
+ * When isVisible briefly flips false → true while the closing animation is
+ * still running (modalState is still 'closing'), the transition should remain
+ * active. The derived-value bug ends the transition prematurely because
+ * isTransitioning = isVisible !== isContainerOpen = true !== true = false,
+ * which triggers the handles effect's cleanup.
+ */
+ it('does not end an active transition when isVisible oscillates during a closing animation', async () => {
+ const afterTransitionsCallback = jest.fn();
+ const {rerender, unmount} = render();
+
+ // 1. Open the modal and complete the entering animation.
+ rerender();
+ expect(startTransitionSpy).toHaveBeenCalledTimes(1);
+ completeOpenAnimation();
+ // modalState is now 'open'; transition handles cleared.
+
+ // 2. Begin closing.
+ rerender();
+ expect(startTransitionSpy).toHaveBeenCalledTimes(2);
+ // Closing animation is in progress — onCloseCallBack has NOT fired yet.
+
+ // 3. isVisible oscillates back to true before the exit animation finishes.
+ // This simulates a prop change mid-animation, e.g. from rapid RHP navigation.
+ rerender();
+ // modalState is still 'closing' (derived-value bug would compute isTransitioning=false here).
+ // With the bug: isTransitioning = true !== true = false → the handles effect cleanup
+ // fires → endTransition called prematurely.
+
+ // 4. Queue a callback that should run only after all transitions end.
+ TransitionTracker.runAfterTransitions({callback: afterTransitionsCallback});
+ await jest.runAllTimersAsync();
+
+ // ASSERTION: The closing transition is still in progress, so the callback
+ // should NOT have fired yet. With the bug, endTransition was called in step 3,
+ // leaving no active transition, so the callback fires immediately.
+ expect(afterTransitionsCallback).not.toHaveBeenCalled();
+
+ unmount();
+ });
+
+ /**
+ * Regression guard for https://github.com/Expensify/App/issues/90442
+ * and https://github.com/Expensify/App/issues/90463
+ *
+ * The interaction handle created at the start of the closing animation must
+ * not be cleared until onCloseCallBack fires.
+ *
+ * Note: InteractionManager.runAfterInteractions is mocked to fire immediately
+ * in tests (regardless of active handles), so we guard this regression by
+ * asserting that clearInteractionHandle is NOT called an extra time during
+ * oscillation — meaning the closing-phase handle remains active.
+ */
+ it('does not clear the interaction handle prematurely when isVisible oscillates during a closing animation', async () => {
+ const {rerender, unmount} = render();
+
+ // 1. Open and complete the entering animation.
+ rerender();
+ completeOpenAnimation();
+ // Opening-phase handle cleared by onOpenCallBack.
+ const clearCountAfterOpen = clearHandleSpy.mock.calls.length;
+
+ // 2. Begin closing.
+ rerender();
+ // Closing animation is in progress — a new interaction handle is now active.
+
+ // 3. isVisible oscillates back before the exit animation finishes.
+ rerender();
+ // ASSERTION: clearInteractionHandle must NOT have been called again.
+ // The closing-phase handle should still be active.
+ // With the bug: isTransitioning would derive to false → effect cleanup fires
+ // → clearInteractionHandle called prematurely.
+ expect(clearHandleSpy.mock.calls.length).toBe(clearCountAfterOpen);
+
+ unmount();
+ });
+ });
+
+ // -----------------------------------------------------------------------
+ // Container visibility test — guard for https://github.com/Expensify/App/issues/90510
+ // -----------------------------------------------------------------------
+
+ describe('container visibility during animations', () => {
+ /**
+ * Regression guard for https://github.com/Expensify/App/issues/90510
+ *
+ * When isVisible changes to false (closing begins), the Container unmounts from
+ * React so that Reanimated can play the Exiting animation via its ghost-node
+ * mechanism. If isVisible then oscillates back to true mid-animation, the
+ * Container must NOT re-mount — re-mounting while the ghost-node exit animation
+ * is playing causes a visual flash (the regression symptom).
+ *
+ * The fix: modalState stays 'closing' through isVisible oscillation. The
+ * Container condition `(modalState === 'opening' || modalState === 'open')` is
+ * false throughout, so the Container stays unmounted until onCloseCallBack fires.
+ */
+ it('does not re-mount the Container when isVisible oscillates during a closing animation', async () => {
+ const {rerender, unmount} = render();
+
+ // 1. Open the modal and complete the entering animation.
+ rerender();
+ completeOpenAnimation();
+ // modalState is now 'open'. Container is mounted.
+ expect(screen.getByTestId('mock-modal-container')).toBeOnTheScreen();
+
+ // 2. Begin closing — Container unmounts to trigger its Exiting animation.
+ rerender();
+ // modalState transitions to 'closing'. Container unmounts (condition becomes false).
+ // Reanimated ghost node keeps it visually alive for the animation.
+ expect(screen.queryByTestId('mock-modal-container')).toBeNull();
+
+ // 3. isVisible oscillates back to true.
+ rerender();
+ // ASSERTION: Container must NOT re-mount — modalState stays 'closing'.
+ // With the bug (derived isTransitioning or {isVisible && containerView}),
+ // the Container would re-mount here, clashing with the ghost-node animation.
+ expect(screen.queryByTestId('mock-modal-container')).toBeNull();
+
+ unmount();
+ });
+ });
+
+ // -----------------------------------------------------------------------
+ // Baseline: handle lifecycle in a normal open → close cycle
+ //
+ // This test documents the CORRECT lifecycle: handles are cleared when the
+ // animation callback fires, not prematurely.
+ // -----------------------------------------------------------------------
+
+ describe('interaction handle lifecycle', () => {
+ it('clears the interaction handle exactly when the opening animation callback fires', async () => {
+ const {rerender, unmount} = render();
+
+ rerender();
+
+ // One handle should have been created for the opening animation.
+ expect(createHandleSpy).toHaveBeenCalledTimes(1);
+
+ // Capture how many times clearInteractionHandle has been called so far.
+ // The exact count may vary due to effect cleanup runs (e.g., transitioning
+ // from 'closed' with no active handle = no-op), but what matters is that
+ // the count does NOT increase again until the animation callback fires.
+ const clearCountBeforeAnimation = clearHandleSpy.mock.calls.length;
+
+ completeOpenAnimation();
+
+ // ASSERTION: clearInteractionHandle must have been called at least once more
+ // after the animation completes — the opening-phase handle must be released
+ // exactly when onOpenCallBack fires, not before.
+ expect(clearHandleSpy.mock.calls.length).toBeGreaterThan(clearCountBeforeAnimation);
+
+ unmount();
+ });
+ });
+});