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(); + }); + }); +});