From 2026a9b15b082c3eae222286b2c6096eb44dee9f Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?Micha=C5=82?= Date: Mon, 22 Jun 2026 11:56:24 +0200 Subject: [PATCH 1/2] Change cancelation method --- .../apple/Handlers/RNTapHandler.m | 37 +++++++++++++++++-- 1 file changed, 33 insertions(+), 4 deletions(-) diff --git a/packages/react-native-gesture-handler/apple/Handlers/RNTapHandler.m b/packages/react-native-gesture-handler/apple/Handlers/RNTapHandler.m index 38cd18cfd1..c09b1cffe4 100644 --- a/packages/react-native-gesture-handler/apple/Handlers/RNTapHandler.m +++ b/packages/react-native-gesture-handler/apple/Handlers/RNTapHandler.m @@ -38,6 +38,12 @@ @implementation RNBetterTapGestureRecognizer { NSUInteger _tapsSoFar; CGPoint _initPosition; NSInteger _maxNumberOfTouches; + // Pending `cancel` invocations scheduled via dispatch_after. We use dispatch blocks instead of + // performSelector:afterDelay: because the latter schedules its timer in NSDefaultRunLoopMode only, + // which means it is starved while a sibling UIScrollView keeps the run loop in UITrackingRunLoopMode + // (during a drag or momentum deceleration). dispatch_after fires regardless of run loop mode, so the + // tap can still fail/finalize on time while a list is scrolling. See issue #3471. + NSMutableArray *_pendingCancellations; } static const NSUInteger defaultNumberOfTaps = 1; @@ -57,10 +63,33 @@ - (id)initWithGestureHandler:(RNGestureHandler *)gestureHandler _maxDeltaX = NAN; _maxDeltaY = NAN; _maxDistSq = NAN; + _pendingCancellations = [NSMutableArray array]; } return self; } +- (void)scheduleCancelAfterDelay:(NSTimeInterval)delay +{ + __weak typeof(self) weakSelf = self; + + dispatch_block_t block = dispatch_block_create(0, ^{ + [weakSelf cancel]; + }); + + [_pendingCancellations addObject:block]; + + dispatch_after(dispatch_time(DISPATCH_TIME_NOW, (int64_t)(delay * NSEC_PER_SEC)), dispatch_get_main_queue(), block); +} + +- (void)cancelPendingCancellations +{ + for (dispatch_block_t block in _pendingCancellations) { + dispatch_block_cancel(block); + } + + [_pendingCancellations removeAllObjects]; +} + - (void)triggerAction { [_gestureHandler handleGesture:self fromReset:NO fromManualStateChange:NO]; @@ -98,14 +127,14 @@ - (void)interactionsBegan:(NSSet *)touches withEvent:(UIEvent *)event } _tapsSoFar++; if (_tapsSoFar) { - [NSObject cancelPreviousPerformRequestsWithTarget:self selector:@selector(cancel) object:nil]; + [self cancelPendingCancellations]; } NSInteger numberOfTouches = [touches count]; if (numberOfTouches > _maxNumberOfTouches) { _maxNumberOfTouches = numberOfTouches; } if (!isnan(_maxDuration)) { - [self performSelector:@selector(cancel) withObject:nil afterDelay:_maxDuration]; + [self scheduleCancelAfterDelay:_maxDuration]; } self.state = UIGestureRecognizerStatePossible; [self triggerAction]; @@ -140,7 +169,7 @@ - (void)interactionsEnded:(NSSet *)touches withEvent:(UIEvent *)event if (_numberOfTaps == _tapsSoFar && _maxNumberOfTouches >= _minPointers) { self.state = UIGestureRecognizerStateEnded; } else { - [self performSelector:@selector(cancel) withObject:nil afterDelay:_maxDelay]; + [self scheduleCancelAfterDelay:_maxDelay]; } } @@ -255,7 +284,7 @@ - (void)reset [_gestureHandler.pointerTracker reset]; - [NSObject cancelPreviousPerformRequestsWithTarget:self selector:@selector(cancel) object:nil]; + [self cancelPendingCancellations]; _tapsSoFar = 0; _maxNumberOfTouches = 0; self.enabled = _gestureHandler.enabled; From 35e89636757038c21ed88f4e46b3e9fbcac4880d Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?Micha=C5=82?= Date: Mon, 22 Jun 2026 12:18:38 +0200 Subject: [PATCH 2/2] Clean timeouts in interactionsEnd --- .../react-native-gesture-handler/apple/Handlers/RNTapHandler.m | 2 ++ 1 file changed, 2 insertions(+) diff --git a/packages/react-native-gesture-handler/apple/Handlers/RNTapHandler.m b/packages/react-native-gesture-handler/apple/Handlers/RNTapHandler.m index c09b1cffe4..6ba922bf3c 100644 --- a/packages/react-native-gesture-handler/apple/Handlers/RNTapHandler.m +++ b/packages/react-native-gesture-handler/apple/Handlers/RNTapHandler.m @@ -166,6 +166,8 @@ - (void)interactionsEnded:(NSSet *)touches withEvent:(UIEvent *)event { [_gestureHandler.pointerTracker touchesEnded:touches withEvent:event]; + [self cancelPendingCancellations]; + if (_numberOfTaps == _tapsSoFar && _maxNumberOfTouches >= _minPointers) { self.state = UIGestureRecognizerStateEnded; } else {