Skip to content
1 change: 1 addition & 0 deletions packages/react-native-ui-lib/jestSetup/jest-setup.js
Original file line number Diff line number Diff line change
Expand Up @@ -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();
Expand Down
Original file line number Diff line number Diff line change
@@ -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';
Expand Down Expand Up @@ -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(<TestCase1 visible/>);
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(<TestCase1 visible/>);

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(<TestCase1 visible/>);

act(() => {
dialogDriver.pressOnBackground();
});
expect(withTimingSpy).toHaveBeenCalledTimes(1);

act(() => {
jest.advanceTimersByTime(WATCHDOG_INTERVAL_MS * 3);
});
expect(withSpringSpy).not.toHaveBeenCalled();
});
});
35 changes: 35 additions & 0 deletions packages/react-native-ui-lib/src/components/dialog/index.tsx
Original file line number Diff line number Diff line change
Expand Up @@ -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;
Expand Down Expand Up @@ -123,6 +126,34 @@ const Dialog = (props: DialogProps, ref: ForwardedRef<DialogImperativeMethods>)
// 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
Expand Down Expand Up @@ -190,6 +221,10 @@ const Dialog = (props: DialogProps, ref: ForwardedRef<DialogImperativeMethods>)
};

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;
Expand Down
Loading