Skip to content

[ios][core] Fix SwiftUI menu dismiss tap still sending touchStart and onPressIn to RN views - #49775

Merged
intergalacticspacehighway merged 2 commits into
mainfrom
@nishan/fix-menu-dismiss-pressin
Sep 6, 2026
Merged

intergalacticspacehighway merged 2 commits into
mainfrom
@nishan/fix-menu-dismiss-pressin

Conversation

@expo-tuft

@expo-tuft expo-tuft Bot commented Sep 6, 2026

Copy link
Copy Markdown
Contributor

Why

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

How

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.

Test Plan

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.

Checklist

… 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.
@expo-bot expo-bot added the contributor: internal PR author is a member, owner, or collaborator of the expo org label Sep 6, 2026
Co-authored-by: intergalacticspacehighway <[email protected]>
@github-actions

github-actions Bot commented Sep 6, 2026

Copy link
Copy Markdown
Contributor

Subscribed to pull request

File Patterns Mentions
packages/expo-modules-core/** @Kudo, @lukmccall

Generated by CodeMention

@intergalacticspacehighway

Copy link
Copy Markdown
Contributor

@expo-bot review

@expo-bot expo-bot added the bot: passed checks ExpoBot has nothing to complain about label Sep 6, 2026
@expo-bot

expo-bot commented Sep 6, 2026

Copy link
Copy Markdown
Collaborator

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 🤖

@expo-bot

expo-bot commented Sep 6, 2026

Copy link
Copy Markdown
Collaborator

🤖 AI code review

Decision: Ready for human review

Overall PR risk: Medium. The change modifies existing behavior on the iOS touch path: SystemMenuTouchGate now acts as its own UIGestureRecognizerDelegate and detaches RCTSurfaceTouchHandler from gestureRecognizer(_:shouldReceive:), with the previous touchesBegan path kept as a fallback. The blast radius is every window that hosts an Expo SwiftUI view, but the open-menu guard keeps the new path inert unless a context menu container is present, and the change is one self-contained Swift file plus a new unit test, so a revert restores the old behavior. The most plausible breakage is a real touch being dropped before React Native sees touchStart if the menu container detection matches a view that is not menu chrome. No reviewer traced a concrete failure or exploit path, and the one reported item was a changelog credit question with no runtime effect, so nothing is kept.

No findings.


This review is advisory — it never blocks a merge and never auto-approves.

@intergalacticspacehighway
intergalacticspacehighway merged commit 9680a35 into main Sep 6, 2026
45 of 48 checks passed
@intergalacticspacehighway
intergalacticspacehighway deleted the @nishan/fix-menu-dismiss-pressin branch September 6, 2026 01:59
intergalacticspacehighway added a commit that referenced this pull request Sep 6, 2026
… 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)
@alanjhughes alanjhughes added the published Changes from the PR have been published to npm label Sep 10, 2026
Sign up for free to join this conversation on GitHub. Already have an account? Sign in to comment

Labels

bot: fingerprint changed bot: passed checks ExpoBot has nothing to complain about contributor: internal PR author is a member, owner, or collaborator of the expo org published Changes from the PR have been published to npm sdk-57

Projects

None yet

Development

Successfully merging this pull request may close these issues.

[@expo/ui][iOS] Dismissing a SwiftUI Menu by tapping outside also delivers the tap to the RN view underneath

3 participants