diff --git a/packages/react-native-ui-lib/jestSetup/jest-setup.js b/packages/react-native-ui-lib/jestSetup/jest-setup.js index 992dbe430d..57bae298da 100644 --- a/packages/react-native-ui-lib/jestSetup/jest-setup.js +++ b/packages/react-native-ui-lib/jestSetup/jest-setup.js @@ -79,6 +79,7 @@ jest.mock('react-native-gesture-handler', PanMock.onFinalize = getDefaultMockedHandler('onFinalize'); PanMock.activateAfterLongPress = getDefaultMockedHandler('activateAfterLongPress'); PanMock.enabled = getDefaultMockedHandler('enabled'); + PanMock.minDistance = getDefaultMockedHandler('minDistance'); PanMock.hitSlop = getDefaultMockedHandler('hitSlop'); PanMock.onTouchesMove = getDefaultMockedHandler('onTouchesMove'); PanMock.prepare = jest.fn(); diff --git a/packages/react-native-ui-lib/src/components/dialog/__tests__/index.new.spec.tsx b/packages/react-native-ui-lib/src/components/dialog/__tests__/index.new.spec.tsx index 8ea792235a..89727dfdb4 100644 --- a/packages/react-native-ui-lib/src/components/dialog/__tests__/index.new.spec.tsx +++ b/packages/react-native-ui-lib/src/components/dialog/__tests__/index.new.spec.tsx @@ -1,5 +1,6 @@ import React, {useRef, useState, useEffect, useCallback} from 'react'; import {render, act} from '@testing-library/react-native'; +import * as Reanimated from 'react-native-reanimated'; import Dialog, {DialogProps} from '../index'; import {DialogDriver} from '../Dialog.driver.new'; import View from '../../../components/view'; @@ -109,3 +110,76 @@ describe('Dialog sanity checks', () => { expect(dialogDriver.isVisible()).toBeFalsy(); }); }); + +// Mirrors the non-exported constants in index.tsx. +const WATCHDOG_INTERVAL_MS = 400; +const WATCHDOG_MAX_ATTEMPTS = 8; + +// Mounted already `visible` so open/close and the watchdog share one render. Reanimated's mock +// useSharedValue returns a new value per call (the real one is ref-backed for the component's +// lifetime), so a post-mount `visible` flip would have them reading different values. +describe('Dialog open animation watchdog', () => { + afterEach(() => { + jest.useRealTimers(); + jest.restoreAllMocks(); + }); + + it('recovers a dialog that never opens, then stops once it reaches full visibility', () => { + jest.useFakeTimers(); + const withSpringSpy = jest.spyOn(Reanimated, 'withSpring'); + const {dialogDriver} = getDriver(); + expect(dialogDriver.isVisible()).toBeTruthy(); + expect(withSpringSpy).not.toHaveBeenCalled(); + + // Stuck at 0 since mount - the watchdog opens it. + act(() => { + jest.advanceTimersByTime(WATCHDOG_INTERVAL_MS); + }); + expect(withSpringSpy).toHaveBeenCalledTimes(1); + + // Reached 1, so the watchdog clears itself for good. + act(() => { + jest.advanceTimersByTime(WATCHDOG_INTERVAL_MS * (WATCHDOG_MAX_ATTEMPTS + 3)); + }); + expect(withSpringSpy).toHaveBeenCalledTimes(1); + }); + + it('keeps retrying while the open animation stays frozen, then permanently gives up at the attempt cap', () => { + jest.useFakeTimers(); + // open() always lands on the same value, so visibility never advances: the frozen-open failure. + const withSpringSpy = jest.spyOn(Reanimated, 'withSpring').mockReturnValue(0.5); + getDriver(); + + act(() => { + jest.advanceTimersByTime(WATCHDOG_INTERVAL_MS * (WATCHDOG_MAX_ATTEMPTS + 3)); + }); + const attemptsMade = withSpringSpy.mock.calls.length; + expect(attemptsMade).toBeGreaterThan(1); + expect(attemptsMade).toBeLessThanOrEqual(WATCHDOG_MAX_ATTEMPTS + 1); + + act(() => { + jest.advanceTimersByTime(WATCHDOG_INTERVAL_MS * 5); + }); + // No growth long after the cap: permanently given up, not paused. + expect(withSpringSpy).toHaveBeenCalledTimes(attemptsMade); + }); + + it('does not re-open while the dialog is closing (visibility decreasing)', () => { + jest.useFakeTimers(); + const withSpringSpy = jest.spyOn(Reanimated, 'withSpring'); + // Drive visibility down as an in-progress close() would, without the completion callback - + // so modalVisibility stays true, matching a close that is still animating. + const withTimingSpy = jest.spyOn(Reanimated, 'withTiming').mockReturnValue(-0.1); + const {dialogDriver} = getDriver(); + + act(() => { + dialogDriver.pressOnBackground(); + }); + expect(withTimingSpy).toHaveBeenCalledTimes(1); + + act(() => { + jest.advanceTimersByTime(WATCHDOG_INTERVAL_MS * 3); + }); + expect(withSpringSpy).not.toHaveBeenCalled(); + }); +}); diff --git a/packages/react-native-ui-lib/src/components/dialog/index.tsx b/packages/react-native-ui-lib/src/components/dialog/index.tsx index 0e943f33cb..bc6a5395a5 100644 --- a/packages/react-native-ui-lib/src/components/dialog/index.tsx +++ b/packages/react-native-ui-lib/src/components/dialog/index.tsx @@ -29,6 +29,9 @@ import {DialogProps, DialogDirections, DialogDirectionsEnum, DialogHeaderProps} export {DialogProps, DialogDirections, DialogDirectionsEnum, DialogHeaderProps}; const THRESHOLD_VELOCITY = 750; +// Longer than a healthy open (~240ms), so a normal open always wins and the watchdog no-ops. +const OPEN_WATCHDOG_INTERVAL_MS = 400; +const OPEN_WATCHDOG_MAX_ATTEMPTS = 8; export interface DialogStatics { directions: typeof DialogDirectionsEnum; @@ -123,6 +126,34 @@ const Dialog = (props: DialogProps, ref: ForwardedRef) // eslint-disable-next-line react-hooks/exhaustive-deps }, [modalVisibility, wasMeasured]); + // Recovers a dialog whose open animation never completes. On Android with RN 0.79 the Modal's + // Fabric state can start 0x0 (facebook/react-native#51048, fixed in RN 0.81), so the dialog + // either never opens - `open()` above is gated on `wasMeasured`, which never flips - or opens + // part-way and freezes. Armed on `modalVisibility` alone, since gating on measurement is the + // bug being worked around. Re-opens a frozen animation only: `close()` animates while + // `modalVisibility` is still true, so a decreasing value is a dismiss in progress, not a strand. + useEffect(() => { + if (!modalVisibility) { + return; + } + let attempts = 0; + let previous = visibility.value; + const interval = setInterval(() => { + const current = visibility.value; + attempts += 1; + if (current >= 1 || current < previous || attempts > OPEN_WATCHDOG_MAX_ATTEMPTS) { + clearInterval(interval); + return; + } + if (current === previous) { + open(); + } + previous = current; + }, OPEN_WATCHDOG_INTERVAL_MS); + return () => clearInterval(interval); + // eslint-disable-next-line react-hooks/exhaustive-deps + }, [modalVisibility]); + const alignmentStyle = useMemo(() => { return {flex: 1, alignItems: 'center', ...extractAlignmentsValues(props)}; // eslint-disable-next-line react-hooks/exhaustive-deps @@ -190,6 +221,10 @@ const Dialog = (props: DialogProps, ref: ForwardedRef) }; const panGesture = Gesture.Pan() + // MOBAPP-2994: require a deliberate drag before the pan engages. On Android/Fabric the residual + // touch from a gesture-handler trigger (e.g. List.Item) otherwise leaks into this freshly-mounted + // pan and drives `visibility` mid-open, interrupting the open spring so the sheet rests part-way. + .minDistance(10) .onStart(event => { initialTranslation.value = getTranslationReverseInterpolation(isVertical ? event.translationY : event.translationX) - visibility.value;