From 13a7adb853b115f22b860697350f70da8eeb2462 Mon Sep 17 00:00:00 2001 From: yoavpagir Date: Wed, 29 Jul 2026 14:07:58 +0300 Subject: [PATCH 1/8] fix: Dialog - prevent open animation interruption by residual touch on Android (minDistance) A bottom Dialog/ActionSheet opened from a gesture-driven trigger (e.g. List.Item's TapGestureHandler firing onPress on END) can rest part-way open on Android: the residual touch leaks into the Dialog's own panGesture and drives `visibility` mid-open, interrupting the open spring. Adding a minDistance activation threshold to the pan prevents a near-static residual touch from engaging it, while drag-to-dismiss keeps working. Co-Authored-By: Claude Opus 4.8 --- .../react-native-ui-lib/src/components/dialog/index.tsx | 6 ++++++ 1 file changed, 6 insertions(+) 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..3293b99d9d 100644 --- a/packages/react-native-ui-lib/src/components/dialog/index.tsx +++ b/packages/react-native-ui-lib/src/components/dialog/index.tsx @@ -190,6 +190,12 @@ const Dialog = (props: DialogProps, ref: ForwardedRef) }; const panGesture = Gesture.Pan() + // MOBAPP-2994: require a deliberate drag before the pan engages. Without this, on Android/Fabric + // the residual touch stream from a gesture-handler trigger (e.g. List.Item's TapGestureHandler, + // which fires onPress on END while the touch is still settling) leaks into this freshly-mounted + // pan and drives `visibility` mid-open, interrupting the open spring so the sheet rests part-way. + // A plain touchable trigger (Button) lifts cleanly before the modal mounts and is unaffected. + .minDistance(10) .onStart(event => { initialTranslation.value = getTranslationReverseInterpolation(isVertical ? event.translationY : event.translationX) - visibility.value; From f4ccfb7da463c33d809cf48086f3ad02717799a0 Mon Sep 17 00:00:00 2001 From: yoavpagir Date: Sun, 2 Aug 2026 10:52:24 +0300 Subject: [PATCH 2/8] ci: trigger snapshot build From 7c1cfc36039ad65c7eaf5f4c955c732eefb977a7 Mon Sep 17 00:00:00 2001 From: yoavpagir Date: Sun, 2 Aug 2026 11:00:03 +0300 Subject: [PATCH 3/8] fix: clarify minDistance rationale (MOBAPP-2994) --- packages/react-native-ui-lib/src/components/dialog/index.tsx | 1 + 1 file changed, 1 insertion(+) 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 3293b99d9d..61fb23d615 100644 --- a/packages/react-native-ui-lib/src/components/dialog/index.tsx +++ b/packages/react-native-ui-lib/src/components/dialog/index.tsx @@ -195,6 +195,7 @@ const Dialog = (props: DialogProps, ref: ForwardedRef) // which fires onPress on END while the touch is still settling) leaks into this freshly-mounted // pan and drives `visibility` mid-open, interrupting the open spring so the sheet rests part-way. // A plain touchable trigger (Button) lifts cleanly before the modal mounts and is unaffected. + // 10dp is small enough to keep drag-to-dismiss responsive while ignoring near-static residual touches. .minDistance(10) .onStart(event => { initialTranslation.value = From 6fbd291f59f0fae75f9c3aa3ca8c79cb76026c64 Mon Sep 17 00:00:00 2001 From: yoavpagir Date: Sun, 2 Aug 2026 14:29:04 +0300 Subject: [PATCH 4/8] test: add minDistance to Pan gesture jest mock (MOBAPP-2994) --- packages/react-native-ui-lib/jestSetup/jest-setup.js | 1 + 1 file changed, 1 insertion(+) 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(); From 5df3f71cdafa143c05461b346ed36c76e55b574f Mon Sep 17 00:00:00 2001 From: Yedidya Kennard Date: Tue, 1 Sep 2026 12:08:19 +0300 Subject: [PATCH 5/8] fix: Dialog - add open-animation watchdog for stranded Android opens Two Android-only failure modes share this remedy, both traced to RN 0.79's ModalHostViewScreenSize() returning Size{0,0} on Android while iOS returns a real RCTScreenSize (facebook/react-native#51048, fixed only in RN 0.81): the dialog's open() call is gated on onLayout measuring a non-zero size, so inside a 0x0 Modal it can either never fire (visibility stuck at 0) or fire but have its spring orphaned mid-flight by the same underlying glitch (visibility stuck partway). The watchdog polls visibility every 400ms and re-opens only when the value is unchanged since the last sample - never while it is decreasing, which is what a close() in progress looks like before modalVisibility flips - and gives up permanently after 8 attempts. This treats the symptom, not the underlying RN bug, which stays open until RN 0.81. Verified in production CI: previously-deterministic Android failures now pass on attempt 1 with no retries - sites-list-android-move-to-trash-confirm, sites-list-android-action-sheet-opens, invites-admin-android-action-sheet-opens, and the sites-list suite on both platforms. --- .../src/components/dialog/index.tsx | 54 +++++++++++++++++++ 1 file changed, 54 insertions(+) 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 61fb23d615..6e64f13f7e 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,11 @@ import {DialogProps, DialogDirections, DialogDirectionsEnum, DialogHeaderProps} export {DialogProps, DialogDirections, DialogDirectionsEnum, DialogHeaderProps}; const THRESHOLD_VELOCITY = 750; +// Watchdog cadence for an open animation that never completes. Comfortably longer than a +// healthy open (~240ms measured at 60fps), so a normal open always wins and the watchdog is +// a no-op. Capped so an unrecoverable case degrades to previous behaviour, not a spin. +const OPEN_WATCHDOG_INTERVAL_MS = 400; +const OPEN_WATCHDOG_MAX_ATTEMPTS = 8; export interface DialogStatics { directions: typeof DialogDirectionsEnum; @@ -123,6 +128,55 @@ const Dialog = (props: DialogProps, ref: ForwardedRef) // eslint-disable-next-line react-hooks/exhaustive-deps }, [modalVisibility, wasMeasured]); + // Watchdog for a dialog that is visible but whose open animation never completes. + // + // Two Android-only failures share this remedy, both traced to RN 0.79 returning Size{0,0} from + // ModalHostViewScreenSize() (ReactCommon/.../modal/platform/cxx/ModalHostViewUtils.cpp; iOS + // returns a real RCTScreenSize) so the Modal's Fabric state starts 0x0 - facebook/react-native#51048, + // fixed only in RN 0.81: + // + // never opens `open()` above is gated on `wasMeasured`, which only flips once onLayout + // reports non-zero width AND height. Inside a 0x0 Modal that may never happen + // and nothing sits behind the gate. Measured at 60fps: visibility stays 0.000. + // opens partly `open()` ran, the spring advanced normally for ~50ms then froze indefinitely - + // 0.012 -> 0.043 -> 0.075 -> 0.122, flat after, against a healthy + // 0.016 -> 0.110 -> 0.310 -> 0.569 over ~240ms. Orphaned, not overwritten: a raw + // write cancels the animation and jumps within one frame rather than tracking + // the curve first. + // + // Armed on `modalVisibility` ALONE, never on `wasMeasured`. An earlier version gated on + // measurement and missed the case where the watchdog itself opens the dialog while unmeasured + // and that animation is then orphaned - seen in CI as a sheet stranded at alpha 0.102 with the + // watchdog never armed. Gating on measurement is what causes the bug; the watchdog must not + // repeat it. + // + // Only re-opens a FROZEN animation. `close()` animates visibility to 0 while `modalVisibility` + // is still true (it only flips in withTiming's completion callback), so a watchdog that just + // checked `visibility < 1` would re-open a dialog the user is dismissing. A closing animation + // changes between ticks and a strand does not, so compare against the previous sample and bail + // out permanently on any decrease. + 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 From a5a10b67cae7ec0129c3ca9f16ee83dc1a4d7316 Mon Sep 17 00:00:00 2001 From: Yedidya Kennard Date: Tue, 1 Sep 2026 12:08:28 +0300 Subject: [PATCH 6/8] test: cover Dialog open-animation watchdog Mounts the dialog already visible so open()/close() and the watchdog effect close over the same render's shared value (react-native-reanimated's jest mock, unlike the real implementation, allocates a fresh value per call rather than persisting it across renders). Covers: recovering a dialog stuck since mount and self-clearing once it reaches full visibility; retrying while frozen and permanently stopping at the attempt cap; never re-opening while visibility is decreasing (a close in progress). --- .../dialog/__tests__/index.new.spec.tsx | 88 +++++++++++++++++++ 1 file changed, 88 insertions(+) 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..894076a02a 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,90 @@ describe('Dialog sanity checks', () => { expect(dialogDriver.isVisible()).toBeFalsy(); }); }); + +// Mirrors the private OPEN_WATCHDOG_INTERVAL_MS / OPEN_WATCHDOG_MAX_ATTEMPTS constants in index.tsx +// (not exported, so the values are duplicated here). +const WATCHDOG_INTERVAL_MS = 400; +const WATCHDOG_MAX_ATTEMPTS = 8; + +// These drive the dialog straight to `visible` on the very first render (as opposed to +// TestCase2, which flips a `visible` prop after mount) so that `open`/`close` - each memoized +// once with a stable dependency array - and the watchdog effect all close over the *same* +// mount-time render. That matters only because of the test double: the real Reanimated +// useSharedValue returns one ref-backed value for the component's whole lifetime, but +// react-native-reanimated/mock's useSharedValue (used repo-wide via jestSetup/jest-setup.js) +// allocates a brand-new value on every call, so a scenario that goes through the dialog's +// normal visible-prop-then-modalVisibility-state transition (two renders) would have `open()` +// and the watchdog reading two different mock values - a mock artifact, not a real one. +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(); + // No mockImplementation/mockReturnValue: withSpring resolves to its real target value (1). + const withSpringSpy = jest.spyOn(Reanimated, 'withSpring'); + const {dialogDriver} = getDriver(); + expect(dialogDriver.isVisible()).toBeTruthy(); + expect(withSpringSpy).not.toHaveBeenCalled(); + + // First tick: visibility has been stuck at its initial 0 since mount - the watchdog opens it. + act(() => { + jest.advanceTimersByTime(WATCHDOG_INTERVAL_MS); + }); + expect(withSpringSpy).toHaveBeenCalledTimes(1); + + // Second tick: visibility reached 1, so the watchdog clears itself and stops 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(); + // Every open() call lands on the same value, so visibility never advances on its own - + // the "opens partly, then freezes" failure, generalized to any stuck value. + 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; + // More than the single first-tick retry: the watchdog kept trying while stuck. + expect(attemptsMade).toBeGreaterThan(1); + // Never more than one retry per tick it was armed for. + expect(attemptsMade).toBeLessThanOrEqual(WATCHDOG_MAX_ATTEMPTS + 1); + + act(() => { + jest.advanceTimersByTime(WATCHDOG_INTERVAL_MS * 5); + }); + // No further growth long after the cap: the watchdog has 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 below its watched baseline the way an in-progress close() would, but + // without invoking the completion callback - so modalVisibility stays true, matching a + // close that is still animating (it only flips modalVisibility once the real animation + // finishes). + 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); + }); + // Decreasing visibility reads as a close in progress, not a stalled open: no re-open, ever. + expect(withSpringSpy).not.toHaveBeenCalled(); + }); +}); From f8e2a93517029afc585f7458892ff429abb73537 Mon Sep 17 00:00:00 2001 From: Yedidya Kennard Date: Tue, 1 Sep 2026 12:14:47 +0300 Subject: [PATCH 7/8] refactor: trim Dialog watchdog comments to the non-obvious rationale --- .../dialog/__tests__/index.new.spec.tsx | 34 +++++--------- .../src/components/dialog/index.tsx | 44 ++++--------------- 2 files changed, 19 insertions(+), 59 deletions(-) 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 894076a02a..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 @@ -111,20 +111,13 @@ describe('Dialog sanity checks', () => { }); }); -// Mirrors the private OPEN_WATCHDOG_INTERVAL_MS / OPEN_WATCHDOG_MAX_ATTEMPTS constants in index.tsx -// (not exported, so the values are duplicated here). +// Mirrors the non-exported constants in index.tsx. const WATCHDOG_INTERVAL_MS = 400; const WATCHDOG_MAX_ATTEMPTS = 8; -// These drive the dialog straight to `visible` on the very first render (as opposed to -// TestCase2, which flips a `visible` prop after mount) so that `open`/`close` - each memoized -// once with a stable dependency array - and the watchdog effect all close over the *same* -// mount-time render. That matters only because of the test double: the real Reanimated -// useSharedValue returns one ref-backed value for the component's whole lifetime, but -// react-native-reanimated/mock's useSharedValue (used repo-wide via jestSetup/jest-setup.js) -// allocates a brand-new value on every call, so a scenario that goes through the dialog's -// normal visible-prop-then-modalVisibility-state transition (two renders) would have `open()` -// and the watchdog reading two different mock values - a mock artifact, not a real one. +// 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(); @@ -133,19 +126,18 @@ describe('Dialog open animation watchdog', () => { it('recovers a dialog that never opens, then stops once it reaches full visibility', () => { jest.useFakeTimers(); - // No mockImplementation/mockReturnValue: withSpring resolves to its real target value (1). const withSpringSpy = jest.spyOn(Reanimated, 'withSpring'); const {dialogDriver} = getDriver(); expect(dialogDriver.isVisible()).toBeTruthy(); expect(withSpringSpy).not.toHaveBeenCalled(); - // First tick: visibility has been stuck at its initial 0 since mount - the watchdog opens it. + // Stuck at 0 since mount - the watchdog opens it. act(() => { jest.advanceTimersByTime(WATCHDOG_INTERVAL_MS); }); expect(withSpringSpy).toHaveBeenCalledTimes(1); - // Second tick: visibility reached 1, so the watchdog clears itself and stops for good. + // Reached 1, so the watchdog clears itself for good. act(() => { jest.advanceTimersByTime(WATCHDOG_INTERVAL_MS * (WATCHDOG_MAX_ATTEMPTS + 3)); }); @@ -154,8 +146,7 @@ describe('Dialog open animation watchdog', () => { it('keeps retrying while the open animation stays frozen, then permanently gives up at the attempt cap', () => { jest.useFakeTimers(); - // Every open() call lands on the same value, so visibility never advances on its own - - // the "opens partly, then freezes" failure, generalized to any stuck value. + // 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(); @@ -163,25 +154,21 @@ describe('Dialog open animation watchdog', () => { jest.advanceTimersByTime(WATCHDOG_INTERVAL_MS * (WATCHDOG_MAX_ATTEMPTS + 3)); }); const attemptsMade = withSpringSpy.mock.calls.length; - // More than the single first-tick retry: the watchdog kept trying while stuck. expect(attemptsMade).toBeGreaterThan(1); - // Never more than one retry per tick it was armed for. expect(attemptsMade).toBeLessThanOrEqual(WATCHDOG_MAX_ATTEMPTS + 1); act(() => { jest.advanceTimersByTime(WATCHDOG_INTERVAL_MS * 5); }); - // No further growth long after the cap: the watchdog has permanently given up, not paused. + // 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 below its watched baseline the way an in-progress close() would, but - // without invoking the completion callback - so modalVisibility stays true, matching a - // close that is still animating (it only flips modalVisibility once the real animation - // finishes). + // 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(); @@ -193,7 +180,6 @@ describe('Dialog open animation watchdog', () => { act(() => { jest.advanceTimersByTime(WATCHDOG_INTERVAL_MS * 3); }); - // Decreasing visibility reads as a close in progress, not a stalled open: no re-open, ever. 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 6e64f13f7e..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,9 +29,7 @@ import {DialogProps, DialogDirections, DialogDirectionsEnum, DialogHeaderProps} export {DialogProps, DialogDirections, DialogDirectionsEnum, DialogHeaderProps}; const THRESHOLD_VELOCITY = 750; -// Watchdog cadence for an open animation that never completes. Comfortably longer than a -// healthy open (~240ms measured at 60fps), so a normal open always wins and the watchdog is -// a no-op. Capped so an unrecoverable case degrades to previous behaviour, not a spin. +// 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; @@ -128,33 +126,12 @@ const Dialog = (props: DialogProps, ref: ForwardedRef) // eslint-disable-next-line react-hooks/exhaustive-deps }, [modalVisibility, wasMeasured]); - // Watchdog for a dialog that is visible but whose open animation never completes. - // - // Two Android-only failures share this remedy, both traced to RN 0.79 returning Size{0,0} from - // ModalHostViewScreenSize() (ReactCommon/.../modal/platform/cxx/ModalHostViewUtils.cpp; iOS - // returns a real RCTScreenSize) so the Modal's Fabric state starts 0x0 - facebook/react-native#51048, - // fixed only in RN 0.81: - // - // never opens `open()` above is gated on `wasMeasured`, which only flips once onLayout - // reports non-zero width AND height. Inside a 0x0 Modal that may never happen - // and nothing sits behind the gate. Measured at 60fps: visibility stays 0.000. - // opens partly `open()` ran, the spring advanced normally for ~50ms then froze indefinitely - - // 0.012 -> 0.043 -> 0.075 -> 0.122, flat after, against a healthy - // 0.016 -> 0.110 -> 0.310 -> 0.569 over ~240ms. Orphaned, not overwritten: a raw - // write cancels the animation and jumps within one frame rather than tracking - // the curve first. - // - // Armed on `modalVisibility` ALONE, never on `wasMeasured`. An earlier version gated on - // measurement and missed the case where the watchdog itself opens the dialog while unmeasured - // and that animation is then orphaned - seen in CI as a sheet stranded at alpha 0.102 with the - // watchdog never armed. Gating on measurement is what causes the bug; the watchdog must not - // repeat it. - // - // Only re-opens a FROZEN animation. `close()` animates visibility to 0 while `modalVisibility` - // is still true (it only flips in withTiming's completion callback), so a watchdog that just - // checked `visibility < 1` would re-open a dialog the user is dismissing. A closing animation - // changes between ticks and a strand does not, so compare against the previous sample and bail - // out permanently on any decrease. + // 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; @@ -244,12 +221,9 @@ const Dialog = (props: DialogProps, ref: ForwardedRef) }; const panGesture = Gesture.Pan() - // MOBAPP-2994: require a deliberate drag before the pan engages. Without this, on Android/Fabric - // the residual touch stream from a gesture-handler trigger (e.g. List.Item's TapGestureHandler, - // which fires onPress on END while the touch is still settling) leaks into this freshly-mounted + // 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. - // A plain touchable trigger (Button) lifts cleanly before the modal mounts and is unaffected. - // 10dp is small enough to keep drag-to-dismiss responsive while ignoring near-static residual touches. .minDistance(10) .onStart(event => { initialTranslation.value = From 94cf0a32aef84cd69285cc2ca10e7996e1979f0c Mon Sep 17 00:00:00 2001 From: Yedidya Kennard Date: Tue, 1 Sep 2026 16:07:41 +0300 Subject: [PATCH 8/8] chore: retrigger CI