I ship an Expo mobile app where creators approve batches of content from confirmation dialogs. The shared design-system button uses react-native-gesture-handler GestureDetector, not a plain Pressable. On list and detail screens, Accept and Bulk Accept fire every time. Inside React Native Modal sheets the same buttons go dead: no redbox, no log line, taps that feel like glass on Android and sometimes vanish on the first press on iOS. Staging caught it before production because QA lived on flat screens and rarely opened the modal flows first. Creators could bulk-approve twenty items on a list, then open one confirmation sheet and tap Accept into nothing. No crash overlay, just a dialog that looked ready while the gesture pipeline was disconnected.
The trap is trusting the app root. App.tsx already wraps the tree in GestureHandlerRootView, and every screen-level button proves gestures work. When dialog buttons fail, I grepped the button component instead of the modal shell. Wrong file.
Modal is a second native window
React Native Modal does not stay under your main React tree on the native side. It opens another window. Everything inside that window sits outside the root GestureHandlerRootView from App.tsx. GestureDetector needs that root to deliver touches into the gesture system. Without it, events evaporate. The button still paints; onPress never runs.
That is why the same Button works on a scroll screen and dies in a dialog. The component is fine. The gesture root never followed the modal into the second window.
Wrap every modal content tree
The change is structural, not a prop on Button. Nest GestureHandlerRootView as the first child inside every RN Modal you own: dialogs, media viewers, lightboxes, anything imported from react-native as Modal.
import { Modal as RNModal } from 'react-native';
import { GestureHandlerRootView } from 'react-native-gesture-handler';
<RNModal visible={visible} animationType='fade' transparent>
<GestureHandlerRootView style={{ flex: 1 }}>{/* dialog chrome + GestureDetector buttons */}</GestureHandlerRootView>
</RNModal>;style={{ flex: 1 }} matters when the modal is transparent or full-screen. Without flex the root can collapse; layout looks fine while hit targets misbehave on some devices.
After the staging fix I audited every Modal import. Shared dialog primitives got the wrapper once so product screens did not each remember it. Media viewers and full-screen previews follow the same rule. If it mounts through Modal, it gets its own gesture root before any GestureDetector child.
React Native does not warn that modal content left the gesture hierarchy. Revenue workflows stall and operators assume the app froze.
Why Jest stayed green
Unit tests mock GestureDetector as Pressable. Modal tests passed while real devices failed. A structural test catches the regression: render the dialog with visible, assert RNModal descendants include GestureHandlerRootView (or query a gesture-root test id), and fail if the wrapper is missing.
Device QA on iOS and Android still belongs in the checklist. The mock hides the native window split. CI can only guard tree shape.
I still reach for Pressable on throwaway prototypes. Production buttons stay on GestureDetector for swipe and long-press parity. That choice is fine as long as every modal brings its own gesture root along for the ride.
Once I treated modal as new window, dead Accept buttons stopped feeling like a button bug. They were a missing gesture root where the app root never reached. The shared dialog primitive now ships with the wrapper baked in so feature teams cannot forget it when they copy-paste a new sheet.
Happy coding! Sander