Skip to content

[iOS][Fabric] Button views are recycled with stale gesture recognizers attached, blocking the ancestor ScrollView pan — shouldBeRecycled guard is TARGET_OS_OSX only #4340

Description

@juliancit0

Description

On iOS with the New Architecture, RNGestureHandlerButtonComponentView participates in Fabric's view recycling. The opt-out that prevents this is compiled only for macOS:

// apple/RNGestureHandlerButtonComponentView.mm (2.30.0, lines 24–31; unchanged through main)
#if TARGET_OS_OSX
// Here we want to disable view recycling on buttons. Listeners are not removed from views when
// they're being unmounted, therefore after navigating through other screens buttons may have
// different actions then they are supposed to have.
+ (BOOL)shouldBeRecycled
{
  return NO;
}
#endif

The comment already describes the defect: listeners are not removed on unmount. On iOS the consequence is worse than "wrong action" — when a screen containing many RNGH Pressables unmounts (e.g. closing a bottom sheet hosted in an RN Modal), its button views return to Fabric's app-wide recycle pool with their gesture recognizers still attached and enabled. A Pressable mounted later on a different screen dequeues one of these poisoned views. The stale recognizer captures touch-begin, and an ancestor React Native ScrollView's pan can then never activate for drags that start on that button.

Symptom signature (very distinctive):

  • Drag starting on a row (RNGH Pressable): ScrollView does not scroll.
  • Drag starting on empty space between rows: scrolls normally.
  • Taps on the rows still fire (onPressIn/onPressOut complete normally).
  • Waiting does not heal it (object pool, not a race). Navigating away and back (full remount) fixes it.

Instrumented while wedged: the ScrollView's onTouchStart fires, the row's onPressInonPressOut fire, but onScrollBeginDrag never fires — the touch reaches the scroller, the button releases, yet the scroll pan never activates.

What we ruled out:

  • 2.31.0 does not fix it. We built the app against 2.31.0 on a physical device (confirmed reattachHandlersIfNeeded compiled into the binary) — still reproduces. GestureDetector becomes unresponsive when parent has display none #3937 / Fix GestureDetector unresponsive after display:none toggle (New Arch) #3964 address recycled buttons getting the wrong action after display: none toggles; this issue is a stale recognizer blocking an ancestor scroll pan. Different defect, same root (recycled views keep handler state).
  • Swapping the victim screen's scroller to RNGH's ScrollView does not help.
  • Wrapping the victim screen's scroller in an additional GestureHandlerRootView does not help. (Both consistent with the block living inside the recycled child's recognizer, not in arbitration configuration.)
  • Not related to react-native-screens (reproduces unchanged after bumping to 4.25.2).

Workaround (app-level): forcing an extra Fabric commit during the sheet's teardown flushes the poisoned state — we mount a hidden <Animated.View exiting={FadeOut.duration(200)} style={{ height: 0 }} /> (Reanimated) inside the sheet. With that present, the wedge never occurs. We found this because one of our sheets happened to have an exiting-animated header and was mysteriously immune while its siblings wedged.

Suggested fix: either extend the shouldBeRecycled opt-out to iOS (the existing comment already justifies it), or strip/disable attached recognizers in prepareForRecycle so recycled views come out of the pool clean. Related precedent: #4290 fixed the same leak-onto-recycled-views pattern for hover UIPointerInteractions.

Steps to reproduce

  1. New Architecture iOS app (bare RN or Expo).
  2. Screen A shows a bottom sheet hosted in an RN Modal, containing a list where each row is (or contains) an RNGH Pressable.
  3. Open the sheet, then close it (unmounting the sheet's view tree), and promptly push Screen B.
  4. Screen B renders a plain react-native ScrollView whose rows are RNGH Pressables.
  5. Try to scroll Screen B by dragging on a row → nothing. Drag in a gap between rows → scrolls. Tap a row → works.
  6. Pop back and re-enter Screen B → everything works (until the next sheet open/close).

Closing the sheet and navigating quickly makes it deterministic — the freshly recycled views are reused while their recognizers are stale. In our production app this reproduces 100% of the time with the recipe above.

A link to a Gist, an Expo Snack or a link to a repository based on this template that reproduces the bug.

Minimal standalone reproducer (the component shape from Steps to reproduce) is being finalized — I will link it in a comment within days. The recipe above is deterministic in the production app where this was diagnosed and instrumented.

Gesture Handler version

2.30.0 (also reproduced on-device with 2.31.0; the guard is unchanged through 2.32.0 and main)

React Native version

0.83.6

Platforms

iOS

JavaScript runtime

Hermes

Workflow

Expo Dev Client

Architecture

New architecture (Fabric)

Build type

Debug mode (dev client) and release builds — reproduces in both

Device

Real device and iOS simulator

Device model

iPhone (iOS 26); also reproduces on iOS Simulator

Acknowledgements

Yes

Metadata

Metadata

Assignees

No one assigned

    Type

    No type

    Projects

    No projects

    Milestone

    No milestone

    Relationships

    None yet

    Development

    No branches or pull requests

    Issue actions