You signed in with another tab or window. Reload to refresh your session.You signed out in another tab or window. Reload to refresh your session.You switched accounts on another tab or window. Reload to refresh your session.Dismiss alert
[iOS][Fabric] Button views are recycled with stale gesture recognizers attached, blocking the ancestor ScrollView pan — shouldBeRecycled guard is TARGET_OS_OSX only #4340
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
{
returnNO;
}
#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 onPressIn → onPressOut fire, but onScrollBeginDragnever fires — the touch reaches the scroller, the button releases, yet the scroll pan never activates.
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
New Architecture iOS app (bare RN or Expo).
Screen A shows a bottom sheet hosted in an RN Modal, containing a list where each row is (or contains) an RNGH Pressable.
Open the sheet, then close it (unmounting the sheet's view tree), and promptly push Screen B.
Screen B renders a plain react-nativeScrollView whose rows are RNGH Pressables.
Try to scroll Screen B by dragging on a row → nothing. Drag in a gap between rows → scrolls. Tap a row → works.
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.
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
Description
On iOS with the New Architecture,
RNGestureHandlerButtonComponentViewparticipates in Fabric's view recycling. The opt-out that prevents this is compiled only for macOS: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 RNModal), its button views return to Fabric's app-wide recycle pool with their gesture recognizers still attached and enabled. APressablemounted later on a different screen dequeues one of these poisoned views. The stale recognizer captures touch-begin, and an ancestor React NativeScrollView's pan can then never activate for drags that start on that button.Symptom signature (very distinctive):
Pressable): ScrollView does not scroll.onPressIn/onPressOutcomplete normally).Instrumented while wedged: the ScrollView's
onTouchStartfires, the row'sonPressIn→onPressOutfire, butonScrollBeginDragnever fires — the touch reaches the scroller, the button releases, yet the scroll pan never activates.What we ruled out:
reattachHandlersIfNeededcompiled 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 afterdisplay: nonetoggles; this issue is a stale recognizer blocking an ancestor scroll pan. Different defect, same root (recycled views keep handler state).ScrollViewdoes not help.GestureHandlerRootViewdoes not help. (Both consistent with the block living inside the recycled child's recognizer, not in arbitration configuration.)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
shouldBeRecycledopt-out to iOS (the existing comment already justifies it), or strip/disable attached recognizers inprepareForRecycleso recycled views come out of the pool clean. Related precedent: #4290 fixed the same leak-onto-recycled-views pattern for hoverUIPointerInteractions.Steps to reproduce
Modal, containing a list where each row is (or contains) an RNGHPressable.react-nativeScrollViewwhose rows are RNGHPressables.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