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).
Description
On Fabric,
measure()answers from the shadow tree. Native libraries that reposition mounted React views with AutoLayout after layout — e.g.UISheetPresentationControllerwrappers 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.Pressabilitymeasures the responder view on grant (_measureResponderRegion) and, on everytouchMove, 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 firesonPress— the first move event silently deactivates the press viaLEAVE_PRESS_RECT.The failure is device-dependent and invisible in development:
Capture from a physical iPhone — the press is granted on the button, the touch is at
pageY 456, but the same view measures aty 0; the zero-delta move then fails_isTouchWithinResponderRegionand the press dies: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 == nullalready means "skip press-rect checks", and_measureCallbackalready 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):
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
Pressablein TrueSheet'sfooterslot, tap it on an older physical iPhone: press feedback fires,onPressdoesn't). Any host view moved by native code after mount reproduces it, sincemeasure()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 infoObserved 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).