From 9289a322bcb3aa286df1335b6d7a1ffe555346b1 Mon Sep 17 00:00:00 2001 From: "J.T. James" Date: Thu, 9 Jul 2026 15:22:05 -0400 Subject: [PATCH] Discard measured responder region that excludes the grant touch MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit The responder is granted by a native hit test, so the touch that granted it necessarily lies inside the view's true on-screen region. When a view has been repositioned by native code after layout (e.g. a native sheet footer pinned with AutoLayout), measure() still reports the stale layout frame; Pressability then cancels the press on the first touchMove because the touch is outside the measured rect — press feedback fires, onPress never does. Stationary simulator taps emit no move events, so the bug only reproduces on physical devices. Validate the measured region against the grant touch position: if the region excludes the point where the press was granted, the measurement is provably stale, so discard it. A null region already means "skip press-rect checks", and correct measurements are unaffected since they contain the grant point by construction. Co-Authored-By: Claude Fable 5 --- .../Libraries/Pressability/Pressability.js | 28 ++++++++ .../__tests__/Pressability-test.js | 64 +++++++++++++++++++ 2 files changed, 92 insertions(+) diff --git a/packages/react-native/Libraries/Pressability/Pressability.js b/packages/react-native/Libraries/Pressability/Pressability.js index 0be465bec546..a84fd8cd1b1a 100644 --- a/packages/react-native/Libraries/Pressability/Pressability.js +++ b/packages/react-native/Libraries/Pressability/Pressability.js @@ -388,6 +388,10 @@ export default class Pressability { pageX: number, pageY: number, }>; + _touchGrantPosition: ?Readonly<{ + pageX: number, + pageY: number, + }>; _touchActivateTime: ?number; _touchState: TouchState = 'NOT_RESPONDER'; @@ -710,6 +714,7 @@ export default class Pressability { ): void { if (isTerminalSignal(signal)) { this._touchActivatePosition = null; + this._touchGrantPosition = null; this._cancelLongPressDelayTimeout(); } @@ -720,6 +725,12 @@ export default class Pressability { const isActivationTransition = !isActivationSignal(prevState) && isActivationSignal(nextState); + if (isInitialTransition) { + const touch = getTouchFromPressEvent(event); + this._touchGrantPosition = + touch == null ? null : {pageX: touch.pageX, pageY: touch.pageY}; + } + if (isInitialTransition || isActivationTransition) { this._measureResponderRegion(); } @@ -826,6 +837,23 @@ export default class Pressability { right: pageX + width, top: pageY, }; + + // The responder was granted by a native hit test, so the touch that + // granted it must lie inside the view's true on-screen region. If the + // measured region excludes that touch, the measurement is stale — e.g. the + // view was repositioned by native code (sheet footers, modals) after + // layout, and `measure` still reports the pre-move frame. Using such a + // region would cancel the press on the first touch move, so discard it. + const grantPosition = this._touchGrantPosition; + if ( + grantPosition != null && + !this._isTouchWithinResponderRegion( + grantPosition as $FlowFixMe, + this._responderRegion, + ) + ) { + this._responderRegion = null; + } }; _isTouchWithinResponderRegion( diff --git a/packages/react-native/Libraries/Pressability/__tests__/Pressability-test.js b/packages/react-native/Libraries/Pressability/__tests__/Pressability-test.js index 05a46d31e9bd..ccc23ff1b56e 100644 --- a/packages/react-native/Libraries/Pressability/__tests__/Pressability-test.js +++ b/packages/react-native/Libraries/Pressability/__tests__/Pressability-test.js @@ -641,6 +641,70 @@ describe('Pressability', () => { jest.advanceTimersByTime(CONFIGURED_DEFAULT_MIN_PRESS_DURATION); expect(config.onPressOut).toBeCalled(); }); + + it('is called when the measured region excludes the grant touch', () => { + // A view repositioned by native code after layout (e.g. a native sheet + // footer) measures at its stale layout position, far from where the + // press actually landed. Such a measurement must not be used to cancel + // the press on the first touch move. + mockUIManagerMeasure(); + const {config, handlers} = createMockPressability(); + + handlers.onStartShouldSetResponder(); + handlers.onResponderGrant( + createMockPressEvent({ + registrationName: 'onResponderGrant', + pageX: 276, + pageY: 467, + }), + ); + handlers.onResponderMove( + createMockPressEvent({ + registrationName: 'onResponderMove', + pageX: 276, + pageY: 467, + }), + ); + handlers.onResponderRelease( + createMockPressEvent({ + registrationName: 'onResponderRelease', + pageX: 276, + pageY: 467, + }), + ); + + expect(config.onPress).toBeCalled(); + }); + + it('is not called when the touch leaves a region containing the grant touch', () => { + mockUIManagerMeasure(); + const {config, handlers} = createMockPressability(); + + handlers.onStartShouldSetResponder(); + handlers.onResponderGrant( + createMockPressEvent({ + registrationName: 'onResponderGrant', + pageX: 10, + pageY: 10, + }), + ); + handlers.onResponderMove( + createMockPressEvent({ + registrationName: 'onResponderMove', + pageX: 500, + pageY: 500, + }), + ); + handlers.onResponderRelease( + createMockPressEvent({ + registrationName: 'onResponderRelease', + pageX: 500, + pageY: 500, + }), + ); + + expect(config.onPress).not.toBeCalled(); + }); }); describe('onPressIn', () => {