11 min readReact NativeExpoFabricNew ArchitectureiOS

What we wish we'd known before debugging React Native touches on Fabric

Six concrete things that cost us a weekend on our Expo SDK 55 app: Modal UIWindow isolation, the pointerEvents prop trap, PanResponder swallowing child taps, and when to reach for a Portal-based custom sheet instead of @gorhom/bottom-sheet.

A batch of touch bugs from shipping an Expo SDK 55 app on the New Architecture (Fabric): Pressables that fired onPressIn but not onPress inside modals, a double-thumb time slider that jittered on real devices, and a BlurView backdrop that was quietly blocking taps. None of it showed up on the simulator. These are the six fixes that mattered, mostly written for our future selves.

Standard caveat: the issue trackers of the libraries involved usually have more up-to-date information than the hosted docs. We've linked specific issues inline where relevant. For @gorhom/bottom-sheet in particular, its issue tracker has been around since 2021 and is where most of this context lives.

1. iOS <Modal> creates a separate UIWindow, and Fabric is stricter about it

React Native's <Modal> on iOS presents a new UIWindow over the main one (see RCTModalHostViewController in the RN source). Under the old architecture the JS responder system tolerated touches routing across that window boundary. Under Fabric's synchronous event dispatch it no longer does, at least not reliably.

Symptoms we hit:

  • A Pressable inside a Modal fires onPressIn, but onPress never arrives. The responder terminates between touch-down and touch-up.
  • A BottomSheetModal rendered inside a <Modal> (or anything using @gorhom/portal) paints behind the modal rather than over it, because the portal provider at the app root doesn't cross the window boundary.
  • Gesture handlers installed in the main window occasionally pick up touches near the screen edge inside an overlaid modal.
  • The simulator doesn't show any of this, because simulator touch synthesis bypasses the real iOS gesture coordinator.

One concrete measurement, from the device-test harness we ran for the companion Liquid Glass post: 100 taps on a Pressable wrapped inside a SwiftUI-Host glass view inside an RN <Modal> produced 100 onPressIn events and 0 onPress events. The simulator rendered the same setup as working.

For the places where we still needed gesture-handler or bottom-sheet providers inside a modal, mounting them inside the modal body (rather than relying on the ones at the app root) was enough:

<Modal visible={visible}>
  <GestureHandlerRootView style={{ flex: 1 }}>
    <BottomSheetModalProvider>
      {/* your content */}
    </BottomSheetModalProvider>
  </GestureHandlerRootView>
</Modal>

Where we could, we pulled the RN <Modal> out entirely and replaced it with a BottomSheetModal or a @gorhom/portal-based custom sheet. Both render in the same UIWindow, which sidesteps the whole boundary problem.

2. pointerEvents as a prop is ignored on Fabric

pointerEvents="box-none" as a View or Animated.View prop looks fine in the simulator, but on Fabric production builds it's ignored. The view falls back to "auto" and blocks touches.

Since RN 0.71 (Jan 2023) the property belongs on style to align with the W3C CSS version. The old prop form was deprecated quietly at the time, and Fabric only honours the style form.

// Ignored on Fabric
<Animated.View pointerEvents="box-none" />

// Works
<Animated.View style={{ pointerEvents: "box-none" }} />

Grepping our own codebase for pointerEvents=" and migrating every instance cleaned up a batch of otherwise-unexplained touch failures. The RN 0.71 release notes mention the change under "W3C-aligned event system", but it's the kind of thing we missed on the upgrade.

3. PanResponder.onStartShouldSetPanResponder: () => true blocks child Pressable taps

A lot of gesture components (including older forks of react-native-multi-slider) claim the responder on every touch-down:

PanResponder.create({
  onStartShouldSetPanResponder: () => true,
  onMoveShouldSetPanResponder: () => true,
  onPanResponderGrant: () => startDrag(),
  onPanResponderMove: (e, g) => move(g),
  // …
});

Returning true from onStartShouldSetPanResponder claims the responder on touch-down, before any child Pressable sees the event. In our case, tap handlers on the start/end time labels inside the slider stopped firing entirely.

PanResponder.create({
  // Let taps fall through to children.
  onStartShouldSetPanResponder: () => false,
  // Claim the drag once the finger actually moves.
  onMoveShouldSetPanResponder: () => true,
  onPanResponderGrant: () => startDrag(),
  onPanResponderMove: (e, g) => move(g),
  // …
});

With that change, taps with no movement reach the child Pressable and drags still trigger onMoveShouldSetPanResponder so the parent takes over for the slide. The PanResponder docs don't state this pattern directly, but the worked example on the page uses it, and the gesture responder system reference is where the negotiation lives if you want the full picture.

4. @gorhom/bottom-sheet already ships a Portal you can reuse

BottomSheetModalProvider wraps its children in its own PortalProvider from @gorhom/portal, using a dynamic rootHostName. Not in the hosted docs, but visible at the top of BottomSheetModalProvider.tsx:

<PortalProvider rootHostName={hostName}>{children}</PortalProvider>

Our app already wrapped content in BottomSheetModalProvider at the root, which meant we didn't need a second PortalProvider to use @gorhom/portal directly – a named <PortalHost name="..." /> inside the existing provider's scope, targeted with <Portal hostName="...">...</Portal>, was enough.

Gotcha: the PortalHost renders its contents as a React Fragment. If you want portaled content to overlay the screen, wrap the host in an absolute-fill View. Otherwise the children render inline at whatever tree position the host sits at, with no layout of their own:

<View style={[StyleSheet.absoluteFill, { pointerEvents: "box-none" }]}>
  <PortalHost name="my-overlay" />
</View>

Use pointerEvents: "box-none" via style (see item 2) so the wrapper doesn't block touches when the portal is empty.

5. Portal-based custom sheet as a last resort for gesture conflicts

@gorhom/bottom-sheet gets into trouble when the sheet contains a child with its own gesture behaviour – in our case a two-thumb time slider built on top of a PanResponder-based react-native-multi-slider fork. The way we got most cases working before this one was tuning enableContentPanningGesture, enableHandlePanningGesture, enableOverDrag, activeOffsetX, and failOffsetY. When that combination stops landing, rewriting the sheet turned out to be less work for us than trying to rewrite the child.

A @gorhom/portal-based custom sheet ends up being about 150 lines:

  • <Portal hostName="..."> renders the overlay above everything else in the same UIWindow.
  • An Animated.View slides up from the bottom via translateY. Keyboard avoidance is a second Animated.Value added to the transform, driven by keyboardWillShow / keyboardWillHide.
  • A backdrop layer with a Pressable handles dismiss.
  • Content is a plain <View>. No BottomSheetScrollView, no gesture coordinator.

The tradeoff is losing BottomSheet's snap points, drag-to-dismiss, and keyboard handling – you reimplement whichever of those you actually want.

Caveat worth flagging: we reached for Portal too early, on a slider that turned out to have its own bug (item 6). In hindsight, enableContentPanningGesture={false} plus fixing the child component's PanResponder would probably have landed on the same behaviour with a lot less code churn. Portal is the right tool for us now, but it wasn't the minimum tool for that specific bug.

6. componentDidUpdate plus a toggled-boolean drag flag causes slider jitter

Specific to class-component sliders that take values as a prop and emit changes via onValuesChange. The shape generalises though. The pattern:

  1. User drags a thumb. Slider calls onValuesChange.
  2. Parent updates state, re-renders with a new values prop.
  3. Slider's componentDidUpdate sees the prop change and resets internal state, including pastOne, the anchor position onPanResponderMove uses to compute dx.
  4. Next onPanResponderMove frame computes the wrong delta. The marker jumps.

The fork we were using had a guard for exactly this:

if (this.state.onePressed || this.state.twoPressed) return;
// ...reset state from props...

That works if onePressed is a proper boolean, set true on press-in and false on press-out. In our fork it was toggled on each event (onePressed: !this.state.onePressed), which drifts out of sync after the first use – the guard ends up returning true when it should be false and vice versa, so it stops catching prop updates during a drag.

What worked for us was a separate instance variable, set in startOne/startTwo and cleared in endOne/endTwo, independent of any render state:

// In startOne/startTwo:
this.dragging = 'one'; // or 'two'

// In endOne/endTwo:
this.dragging = null;

// In componentDidUpdate:
if (this.dragging !== null) return;

If you hit one of these

Most of what cost us time here was documented somewhere (usually in a four-year-old GitHub issue comment rather than the hosted docs). A couple of the specifics weren't. Happy to swap notes or hear what we missed: hello@greatworkeveryone.com.

Written while shipping Shift It 3.0. Available April 20, 2026.