From 5af62289c0674b8e930281143669b1b0c1547238 Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?Micha=C5=82?= Date: Mon, 17 Aug 2026 12:46:43 +0200 Subject: [PATCH 1/3] Cleanup also in prepareForRecycle --- .../apple/RNGestureHandlerDetector.mm | 41 ++++++++++++------- 1 file changed, 26 insertions(+), 15 deletions(-) diff --git a/packages/react-native-gesture-handler/apple/RNGestureHandlerDetector.mm b/packages/react-native-gesture-handler/apple/RNGestureHandlerDetector.mm index af3d46fe31..2a31f7ebe1 100644 --- a/packages/react-native-gesture-handler/apple/RNGestureHandlerDetector.mm +++ b/packages/react-native-gesture-handler/apple/RNGestureHandlerDetector.mm @@ -42,24 +42,10 @@ - (instancetype)initWithFrame:(CGRect)frame return self; } -// TODO: I'm not sure whether this is the correct place for cleanup -// Possibly allowing recycling and doing this in prepareForRecycle would be better - (void)willMoveToWindow:(RNGHWindow *)newWindow { if (newWindow == nil) { - RNGestureHandlerManager *handlerManager = [RNGestureHandlerModule handlerManagerForModuleId:_moduleId]; - react_native_assert(handlerManager != nullptr && "Tried to access a non-existent handler manager"); - - [handlerManager.registry cancelAllObservationsForOwner:self]; - - for (NSNumber *handlerTag in _attachedHandlers) { - [handlerManager.registry detachHandlerWithTag:handlerTag fromHostDetector:self]; - } - - [_attachedHandlers removeAllObjects]; - _subscribedVirtualHandlers.clear(); - [_subscribedHandlers removeAllObjects]; - [_nativeHandlers removeAllObjects]; + [self detachAndCleanupHandlers]; } else { const auto &props = *std::static_pointer_cast(_props); [self attachHandlers:props.handlerTags @@ -70,6 +56,30 @@ - (void)willMoveToWindow:(RNGHWindow *)newWindow } } +// Detaching in `willMoveToWindow:` alone is not enough - a detector unmounted while +// its ancestor is already off-window (e.g. an inactive screen) never gets that call, +// and would enter the recycle pool still carrying recognizers of live handlers. +- (void)detachAndCleanupHandlers +{ + if (_moduleId == -1) { + return; + } + + RNGestureHandlerManager *handlerManager = [RNGestureHandlerModule handlerManagerForModuleId:_moduleId]; + react_native_assert(handlerManager != nullptr && "Tried to access a non-existent handler manager"); + + [handlerManager.registry cancelAllObservationsForOwner:self]; + + for (NSNumber *handlerTag in _attachedHandlers) { + [handlerManager.registry detachHandlerWithTag:handlerTag fromHostDetector:self]; + } + + [_attachedHandlers removeAllObjects]; + _subscribedVirtualHandlers.clear(); + [_subscribedHandlers removeAllObjects]; + [_nativeHandlers removeAllObjects]; +} + - (void)setDefaultProps { static const auto defaultProps = std::make_shared(); @@ -144,6 +154,7 @@ - (void)prepareForRecycle { [super prepareForRecycle]; + [self detachAndCleanupHandlers]; [self setDefaultProps]; } From eaaf74d32ef8045037ca521c5b97d2a4175a3c4c Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?Micha=C5=82?= Date: Mon, 17 Aug 2026 14:25:36 +0200 Subject: [PATCH 2/3] find --- .../apple/RNGestureHandlerDetector.mm | 14 ++++++++++---- .../apple/RNGestureHandlerModule.h | 4 ++++ .../apple/RNGestureHandlerModule.mm | 9 ++++++++- 3 files changed, 22 insertions(+), 5 deletions(-) diff --git a/packages/react-native-gesture-handler/apple/RNGestureHandlerDetector.mm b/packages/react-native-gesture-handler/apple/RNGestureHandlerDetector.mm index 2a31f7ebe1..86a537b00b 100644 --- a/packages/react-native-gesture-handler/apple/RNGestureHandlerDetector.mm +++ b/packages/react-native-gesture-handler/apple/RNGestureHandlerDetector.mm @@ -45,7 +45,7 @@ - (instancetype)initWithFrame:(CGRect)frame - (void)willMoveToWindow:(RNGHWindow *)newWindow { if (newWindow == nil) { - [self detachAndCleanupHandlers]; + [self detachAllHandlers]; } else { const auto &props = *std::static_pointer_cast(_props); [self attachHandlers:props.handlerTags @@ -59,14 +59,20 @@ - (void)willMoveToWindow:(RNGHWindow *)newWindow // Detaching in `willMoveToWindow:` alone is not enough - a detector unmounted while // its ancestor is already off-window (e.g. an inactive screen) never gets that call, // and would enter the recycle pool still carrying recognizers of live handlers. -- (void)detachAndCleanupHandlers +- (void)detachAllHandlers { if (_moduleId == -1) { return; } RNGestureHandlerManager *handlerManager = [RNGestureHandlerModule handlerManagerForModuleId:_moduleId]; - react_native_assert(handlerManager != nullptr && "Tried to access a non-existent handler manager"); + if (handlerManager == nil) { + // A nil manager for a known moduleId means the module was invalidated mid-teardown + // and there is nothing left to clean up. An unknown moduleId is a real bug. + react_native_assert( + [RNGestureHandlerModule hasModuleWithId:_moduleId] && "Tried to access a non-existent handler manager"); + return; + } [handlerManager.registry cancelAllObservationsForOwner:self]; @@ -154,7 +160,7 @@ - (void)prepareForRecycle { [super prepareForRecycle]; - [self detachAndCleanupHandlers]; + [self detachAllHandlers]; [self setDefaultProps]; } diff --git a/packages/react-native-gesture-handler/apple/RNGestureHandlerModule.h b/packages/react-native-gesture-handler/apple/RNGestureHandlerModule.h index 1268a63055..51c8b42559 100644 --- a/packages/react-native-gesture-handler/apple/RNGestureHandlerModule.h +++ b/packages/react-native-gesture-handler/apple/RNGestureHandlerModule.h @@ -11,4 +11,8 @@ + (RNGestureHandlerManager *)handlerManagerForModuleId:(int)moduleId; +// Whether a module with this id was ever registered. The manager may still be nil +// after the module is invalidated - invalidation nulls the entry without erasing it. ++ (BOOL)hasModuleWithId:(int)moduleId; + @end diff --git a/packages/react-native-gesture-handler/apple/RNGestureHandlerModule.mm b/packages/react-native-gesture-handler/apple/RNGestureHandlerModule.mm index 18659f0d27..a55842f682 100644 --- a/packages/react-native-gesture-handler/apple/RNGestureHandlerModule.mm +++ b/packages/react-native-gesture-handler/apple/RNGestureHandlerModule.mm @@ -50,7 +50,14 @@ @implementation RNGestureHandlerModule { + (RNGestureHandlerManager *)handlerManagerForModuleId:(int)moduleId { - return _managers[moduleId]; + // Not operator[] - that would insert a null entry on a miss. + const auto it = _managers.find(moduleId); + return it == _managers.end() ? nil : it->second; +} + ++ (BOOL)hasModuleWithId:(int)moduleId +{ + return _managers.find(moduleId) != _managers.end(); } RCT_EXPORT_MODULE() From 7dc8a73a4ff1198b4d82a200c9ebac157246521d Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?Micha=C5=82?= Date: Mon, 17 Aug 2026 15:28:47 +0200 Subject: [PATCH 3/3] CodeRabbit --- .../apple/RNGestureHandlerModule.h | 3 --- .../apple/RNGestureHandlerModule.mm | 5 +++++ .../apple/RNGestureHandlerRegistry.h | 1 + .../apple/RNGestureHandlerRegistry.m | 7 +++++++ 4 files changed, 13 insertions(+), 3 deletions(-) diff --git a/packages/react-native-gesture-handler/apple/RNGestureHandlerModule.h b/packages/react-native-gesture-handler/apple/RNGestureHandlerModule.h index 51c8b42559..d4a515cb5d 100644 --- a/packages/react-native-gesture-handler/apple/RNGestureHandlerModule.h +++ b/packages/react-native-gesture-handler/apple/RNGestureHandlerModule.h @@ -10,9 +10,6 @@ : RCTEventEmitter + (RNGestureHandlerManager *)handlerManagerForModuleId:(int)moduleId; - -// Whether a module with this id was ever registered. The manager may still be nil -// after the module is invalidated - invalidation nulls the entry without erasing it. + (BOOL)hasModuleWithId:(int)moduleId; @end diff --git a/packages/react-native-gesture-handler/apple/RNGestureHandlerModule.mm b/packages/react-native-gesture-handler/apple/RNGestureHandlerModule.mm index a55842f682..231c897d04 100644 --- a/packages/react-native-gesture-handler/apple/RNGestureHandlerModule.mm +++ b/packages/react-native-gesture-handler/apple/RNGestureHandlerModule.mm @@ -70,6 +70,11 @@ + (BOOL)requiresMainQueueSetup - (void)invalidate { RNGestureHandlerManager *handlerManager = [RNGestureHandlerModule handlerManagerForModuleId:_moduleId]; + + // Clear observations before the entry is nulled - the async drop below keeps the registry + // alive for one main-queue hop, and dropAllHandlers does not touch them. + [handlerManager.registry removeAllObservations]; + dispatch_async(dispatch_get_main_queue(), ^{ [handlerManager dropAllGestureHandlers]; }); diff --git a/packages/react-native-gesture-handler/apple/RNGestureHandlerRegistry.h b/packages/react-native-gesture-handler/apple/RNGestureHandlerRegistry.h index 82649ed83c..8a1030536a 100644 --- a/packages/react-native-gesture-handler/apple/RNGestureHandlerRegistry.h +++ b/packages/react-native-gesture-handler/apple/RNGestureHandlerRegistry.h @@ -33,6 +33,7 @@ typedef void (^RNGestureHandlerReadyBlock)(RNGestureHandler *_Nonnull handler); - (void)cancelObservationForTag:(nonnull NSNumber *)handlerTag owner:(nonnull id)owner; - (void)cancelAllObservationsForOwner:(nonnull id)owner; +- (void)removeAllObservations; @property (nonatomic, readonly, nonnull) NSDictionary *handlers; diff --git a/packages/react-native-gesture-handler/apple/RNGestureHandlerRegistry.m b/packages/react-native-gesture-handler/apple/RNGestureHandlerRegistry.m index 558f0629b8..e56c3f009f 100644 --- a/packages/react-native-gesture-handler/apple/RNGestureHandlerRegistry.m +++ b/packages/react-native-gesture-handler/apple/RNGestureHandlerRegistry.m @@ -97,6 +97,13 @@ - (void)cancelAllObservationsForOwner:(id)owner } } +- (void)removeAllObservations +{ + @synchronized(_handlers) { + [_observers removeAllObjects]; + } +} + - (void)attachHandlerWithTag:(NSNumber *)handlerTag toView:(RNGHUIView *)view withActionType:(RNGestureHandlerActionType)actionType