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).

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

    Fields

    No fields configured for issues without a type.

    Projects

    No projects

    Milestone

    No milestone

    Relationships

    None yet

    Development

    No branches or pull requests

    Issue actions