Repository navigation
[ios][core] Fix SwiftUI menu dismiss tap still sending touchStart and onPressIn to RN views - #49775
Conversation
… onPressIn to RN views The gate added in #48463 tells React Native's RCTSurfaceTouchHandler to ignore the tap that closes a SwiftUI menu from its own touchesBegan. UIKit delivers touchesBegan to RN's handler first, and that handler sends touchStart to JS synchronously, so the Pressable underneath still received touchStart, onPressIn and (via the cancel at reset) onPressOut. Only onPress was stopped. The gate is now its own UIGestureRecognizerDelegate and issues the ignore from gestureRecognizer(_:shouldReceive:), which UIKit calls before it delivers the touch to any recognizer. RN's handler never registers the touch, so JS sees nothing. The touchesBegan path is kept as a fallback. Verified on an iPhone 17 Pro (iOS 26.5) simulator against both the swift-ui Menu and the community MenuView: dismiss tap counters go from 0/1/0/1/1 to 0/0/0/0/0 (onPress/touchStart/touchEnd/pressIn/pressOut); direct taps, menu item taps, taps during the dismiss animation and taps after a dismiss are unchanged. Fixes #48419 follow-up report.
Co-authored-by: intergalacticspacehighway <[email protected]>
|
Subscribed to pull request
Generated by CodeMention |
|
@expo-bot review |
|
The Pull Request introduced fingerprint changes against the base commit: 7065cfe Fingerprint diff[
{
"op": "changed",
"beforeSource": {
"type": "dir",
"filePath": "../../packages/@expo/log-box",
"reasons": [
"expoAutolinkingIos",
"expoAutolinkingAndroid"
],
"hash": "c969c86da47cb3ba9b6d9058d653d492a3f15a3c"
},
"afterSource": {
"type": "dir",
"filePath": "../../packages/@expo/log-box",
"reasons": [
"expoAutolinkingIos",
"expoAutolinkingAndroid"
],
"hash": "7d1a8575c62abf7aeca104b1bba40a2877936608"
}
},
{
"op": "changed",
"beforeSource": {
"type": "dir",
"filePath": "../../packages/expo",
"reasons": [
"expoAutolinkingIos",
"expoAutolinkingAndroid",
"expoConfigPlugins",
"expoConfigPlugins",
"rncoreAutolinkingAndroid",
"rncoreAutolinkingIos"
],
"hash": "7100c6b170c94d664fe1688011c3f5acc2aeaed3"
},
"afterSource": {
"type": "dir",
"filePath": "../../packages/expo",
"reasons": [
"expoAutolinkingIos",
"expoAutolinkingAndroid",
"expoConfigPlugins",
"expoConfigPlugins",
"rncoreAutolinkingAndroid",
"rncoreAutolinkingIos"
],
"hash": "a43fb20bdcb2ff6d84400d0ca1be629a7acbb843"
}
},
{
"op": "changed",
"beforeSource": {
"type": "dir",
"filePath": "../../packages/expo-dev-launcher",
"reasons": [
"expoAutolinkingIos",
"expoAutolinkingAndroid"
],
"hash": "46fa864727ce89e9bf08039fdeb4defdc6f09ccf"
},
"afterSource": {
"type": "dir",
"filePath": "../../packages/expo-dev-launcher",
"reasons": [
"expoAutolinkingIos",
"expoAutolinkingAndroid"
],
"hash": "839925690efcc89c4ef7fba1256a62b34d867ba7"
}
},
{
"op": "changed",
"beforeSource": {
"type": "dir",
"filePath": "../../packages/expo-dev-menu",
"reasons": [
"expoAutolinkingIos",
"expoAutolinkingAndroid"
],
"hash": "2d75267c1d39b1072717d8ddc5b2d07b1b4979a0"
},
"afterSource": {
"type": "dir",
"filePath": "../../packages/expo-dev-menu",
"reasons": [
"expoAutolinkingIos",
"expoAutolinkingAndroid"
],
"hash": "c5cdf8848b33d944012233f65245a1e3693d5b9d"
}
},
{
"op": "changed",
"beforeSource": {
"type": "dir",
"filePath": "../../packages/expo-gl",
"reasons": [
"expoAutolinkingIos",
"expoAutolinkingAndroid"
],
"hash": "58b7a8300d9799d585c7557377aff62db6faab92"
},
"afterSource": {
"type": "dir",
"filePath": "../../packages/expo-gl",
"reasons": [
"expoAutolinkingIos",
"expoAutolinkingAndroid"
],
"hash": "f0b61f7913a9d6486e17f21cb6b5fa862ce29f7d"
}
},
{
"op": "changed",
"beforeSource": {
"type": "dir",
"filePath": "../../packages/expo-modules-core",
"reasons": [
"expoAutolinkingIos",
"expoAutolinkingAndroid",
"expoAutolinkingIos"
],
"hash": "490c42a0be5e864fd34f2e7d21badd0e9fca0cf2"
},
"afterSource": {
"type": "dir",
"filePath": "../../packages/expo-modules-core",
"reasons": [
"expoAutolinkingIos",
"expoAutolinkingAndroid",
"expoAutolinkingIos"
],
"hash": "79ed92333eb8c7fd287d42b4dfa2c14cde6be8f5"
}
}
]Generated by PR labeler 🤖 |
🤖 AI code reviewDecision: Ready for human review Overall PR risk: Medium. The change modifies existing behavior on the iOS touch path: No findings. This review is advisory — it never blocks a merge and never auto-approves. |
… onPressIn to RN views (#49775) Follow-up to #48463, which fixed #48419. That PR stopped the menu dismiss tap from firing `onPress` on the React Native view underneath. A [later report on the issue](#48419 (comment)) shows the tap still reaches JS: with the community `MenuView`, the `Pressable` under the finger records `touchStart = 1`, `pressIn = 1`, `pressOut = 1`. Only `onPress` was suppressed. The cause is when the gate acts. `SystemMenuTouchGate` calls `ignore(_:for:)` on `RCTSurfaceTouchHandler` from its own `touchesBegan`. UIKit delivers `touchesBegan` to React Native's handler first, and that handler sends `touchStart` to JS synchronously, so JS has already seen the touch by the time the gate runs. The late `ignore` then makes the handler cancel the touch at `reset`, which is what produces the trailing `onPressOut`. `SystemMenuTouchGate` is now its own `UIGestureRecognizerDelegate` and issues the ignore from `gestureRecognizer(_:shouldReceive:)`. UIKit calls that method before it delivers the event's touches to any recognizer, so React Native's handler never registers the touch at all — no `touchStart`, no press, no `touchEnd`. The `touchesBegan` path is kept as a fallback for the case where React Native's handler receives the touch before the delegate runs. Both paths share one `detachSurfaceTouchHandlers(from:for:)` helper, and the open-menu check is unchanged, so taps made during the dismiss animation still pass through as before. Verified on an iPhone 17 Pro (iOS 26.5) simulator, against both the `@expo/ui/swift-ui` `Menu` and the community `MenuView`, using the counter repro from the issue comment. Dismiss tap counters (`onPress` / `touchStart` / `touchEnd` / `pressIn` / `pressOut`): | | before | after | | --- | --- | --- | | dismiss tap over a `Pressable` | 0 / 1 / 0 / 1 / 1 | 0 / 0 / 0 / 0 / 0 | Unchanged: direct taps with no menu open, menu item taps, taps during the dismiss animation, and taps after a dismiss. Also adds `packages/expo-modules-core/ios/Tests/SystemMenuTouchGateTests.swift`, covering the open-menu detection rules, the handler lookup along the superview chain, and the new delegate wiring. - [x] I added a `changelog.md` entry and rebuilt the package sources according to [this short guide](https://github.com/expo/expo/blob/main/CONTRIBUTING.md#-before-submitting) - [x] This diff will work correctly for `npx expo prebuild` & EAS Build (eg: updated a module plugin). - [x] Conforms with the [Documentation Writing Style Guide](https://github.com/expo/expo/blob/main/guides/Expo%20Documentation%20Writing%20Style%20Guide.md) --------- Co-authored-by: nishan (o^▽^o) <[email protected]> Co-authored-by: expo-tuft[bot] <288127324+expo-tuft[bot]@users.noreply.github.com> Co-authored-by: intergalacticspacehighway <[email protected]> (cherry picked from commit 9680a35)
Why
Follow-up to #48463, which fixed #48419.
That PR stopped the menu dismiss tap from firing
onPresson the React Native view underneath. A later report on the issue shows the tap still reaches JS: with the communityMenuView, thePressableunder the finger recordstouchStart = 1,pressIn = 1,pressOut = 1. OnlyonPresswas suppressed.The cause is when the gate acts.
SystemMenuTouchGatecallsignore(_:for:)onRCTSurfaceTouchHandlerfrom its owntouchesBegan. UIKit deliverstouchesBeganto React Native's handler first, and that handler sendstouchStartto JS synchronously, so JS has already seen the touch by the time the gate runs. The lateignorethen makes the handler cancel the touch atreset, which is what produces the trailingonPressOut.How
SystemMenuTouchGateis now its ownUIGestureRecognizerDelegateand issues the ignore fromgestureRecognizer(_:shouldReceive:). UIKit calls that method before it delivers the event's touches to any recognizer, so React Native's handler never registers the touch at all — notouchStart, no press, notouchEnd.The
touchesBeganpath is kept as a fallback for the case where React Native's handler receives the touch before the delegate runs. Both paths share onedetachSurfaceTouchHandlers(from:for:)helper, and the open-menu check is unchanged, so taps made during the dismiss animation still pass through as before.Test Plan
Verified on an iPhone 17 Pro (iOS 26.5) simulator, against both the
@expo/ui/swift-uiMenuand the communityMenuView, using the counter repro from the issue comment.Dismiss tap counters (
onPress/touchStart/touchEnd/pressIn/pressOut):PressableUnchanged: direct taps with no menu open, menu item taps, taps during the dismiss animation, and taps after a dismiss.
Also adds
packages/expo-modules-core/ios/Tests/SystemMenuTouchGateTests.swift, covering the open-menu detection rules, the handler lookup along the superview chain, and the new delegate wiring.Checklist
changelog.mdentry and rebuilt the package sources according to this short guidenpx expo prebuild& EAS Build (eg: updated a module plugin).