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;