Skip to content

Pressability silently drops presses when measure() disagrees with the native hit test (Fabric, natively repositioned views) #57502

Description

@joeltjames

Description

On Fabric, measure() answers from the shadow tree. Native libraries that reposition mounted React views with AutoLayout after layout — e.g. UISheetPresentationController wrappers like @lodev09/react-native-true-sheet, which pins its footer slot to the sheet bottom (lodev09/react-native-true-sheet#726) — make that answer wrong.

Pressability measures the responder view on grant (_measureResponderRegion) and, on every touchMove, cancels the press if the touch is outside the measured rect. When the rect is stale, a button that natively hit-tested and granted the responder shows press feedback (onPressIn) but never fires onPress — the first move event silently deactivates the press via LEAVE_PRESS_RECT.

The failure is device-dependent and invisible in development:

  • Stationary simulator taps emit zero move events, so the region check never runs and everything works.
  • Real fingers always jitter; older hardware (tested iPhone XS-class, iOS 16/18) reliably emits move events — even zero-delta ones — while newer device/OS combos appear to coalesce them away.

Capture from a physical iPhone — the press is granted on the button, the touch is at pageY 456, but the same view measures at y 0; the zero-delta move then fails _isTouchWithinResponderRegion and the press dies:

measureInWindow → {x: 0, y: 0, width: 375, height: 95.7}
touchStart  → pageY: 456.3   (onPressIn fires)
touchMove   → pageY: 456.3   (press silently deactivated here)
touchEnd    → pageY: 456.3   (no onPress)

Proposed hardening

The responder was granted by a native hit test, so the grant touch necessarily lies inside the view's true on-screen region. If the async-measured region excludes the grant point, the measurement is provably stale — discard it. _responderRegion == null already means "skip press-rect checks", and _measureCallback already discards all-zero measurements as precedent. Correct measurements are unaffected, since they contain the grant point by construction.

Patch we're running in production (happy to open a PR):

--- a/Libraries/Pressability/Pressability.js
+++ b/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 natively (UISheetPresentationController 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: $FlowFixMe),
+        this._responderRegion,
+      )
+    ) {
+      this._responderRegion = null;
+    }
   };
 
   _isTouchWithinResponderRegion(

Steps to reproduce

A Snack isn't possible — the trigger requires a native library that repositions a mounted view outside of Yoga. Concrete case with exact steps: lodev09/react-native-true-sheet#726 (render a Pressable in TrueSheet's footer slot, tap it on an older physical iPhone: press feedback fires, onPress doesn't). Any host view moved by native code after mount reproduces it, since measure() keeps reporting the pre-move frame while touch events carry real coordinates.

React Native Version

0.83.6

Affected Platforms

Runtime - iOS (observed); the logic is cross-platform.

Output of npx @react-native-community/cli info

Observed in a production app (Expo SDK 55, RN 0.83.6, new architecture, Hermes) on physical iPhones running iOS 16/18. Not device-setup-specific.

Context

Found while working on the Planning Center mobile app after sheet-footer buttons stopped responding for users on older iPhones; diagnosed and fixed with the help of Claude (Fable).

Activity

Sign up for free to join this conversation on GitHub. Already have an account? Sign in to comment

Metadata

Metadata

Assignees

No one assigned

    Labels

    Needs: Author FeedbackNeeds: ReproThis issue could be improved with a clear list of steps to reproduce the issue.

    Type

    No type

    Projects

    No projects

      Milestone

      No milestone

      Relationships

      None yet

      Development

      No branches or pull requests

      Issue actions