From ac0c85c5662b0837b339d75083948b07ecf9a0d7 Mon Sep 17 00:00:00 2001 From: Jakub Piasecki Date: Mon, 20 Apr 2026 14:21:06 +0200 Subject: [PATCH 1/3] Don't emit duplicate events --- .../core/NativeViewGestureHandler.kt | 19 +++++++ .../apple/Handlers/RNNativeViewHandler.mm | 57 +++++++++++-------- .../src/web/handlers/GestureHandler.ts | 23 +++++++- .../web/handlers/NativeViewGestureHandler.ts | 23 ++++++++ 4 files changed, 95 insertions(+), 27 deletions(-) diff --git a/packages/react-native-gesture-handler/android/src/main/java/com/swmansion/gesturehandler/core/NativeViewGestureHandler.kt b/packages/react-native-gesture-handler/android/src/main/java/com/swmansion/gesturehandler/core/NativeViewGestureHandler.kt index a254d9ac63..73e0262c08 100644 --- a/packages/react-native-gesture-handler/android/src/main/java/com/swmansion/gesturehandler/core/NativeViewGestureHandler.kt +++ b/packages/react-native-gesture-handler/android/src/main/java/com/swmansion/gesturehandler/core/NativeViewGestureHandler.kt @@ -31,6 +31,10 @@ class NativeViewGestureHandler : GestureHandler() { private var hook: NativeViewGestureHandlerHook = defaultHook + private data class ActiveUpdateSnapshot(val pointerInside: Boolean, val numberOfPointers: Int, val pointerType: Int) + + private var lastActiveUpdate: ActiveUpdateSnapshot? = null + init { shouldCancelWhenOutside = true } @@ -163,6 +167,21 @@ class NativeViewGestureHandler : GestureHandler() { override fun onReset() { this.hook = defaultHook + lastActiveUpdate = null + } + + override fun dispatchHandlerUpdate(event: MotionEvent) { + val snapshot = ActiveUpdateSnapshot(isWithinBounds, numberOfPointers, pointerType) + if (snapshot == lastActiveUpdate) { + return + } + lastActiveUpdate = snapshot + super.dispatchHandlerUpdate(event) + } + + override fun dispatchStateChange(newState: Int, prevState: Int) { + lastActiveUpdate = null + super.dispatchStateChange(newState, prevState) } override fun wantsToAttachDirectlyToView() = true diff --git a/packages/react-native-gesture-handler/apple/Handlers/RNNativeViewHandler.mm b/packages/react-native-gesture-handler/apple/Handlers/RNNativeViewHandler.mm index b97925e0ee..1cf1eac1dd 100644 --- a/packages/react-native-gesture-handler/apple/Handlers/RNNativeViewHandler.mm +++ b/packages/react-native-gesture-handler/apple/Handlers/RNNativeViewHandler.mm @@ -113,6 +113,7 @@ - (void)updateStateIfScrollView @implementation RNNativeViewGestureHandler { BOOL _shouldActivateOnStart; BOOL _disallowInterruption; + RNGestureHandlerEventExtraData *_lastActiveExtraData; } - (instancetype)initWithTag:(NSNumber *)tag @@ -180,6 +181,16 @@ - (void)unbindFromView [super unbindFromView]; } +- (void)sendActiveStateEventIfChangedForView:(UIView *)sender extraData:(RNGestureHandlerEventExtraData *)extraData +{ + if ([_lastActiveExtraData.data isEqualToDictionary:extraData.data]) { + return; + } + + _lastActiveExtraData = extraData; + [self sendEventsInState:RNGestureHandlerStateActive forViewWithTag:sender.reactTag withExtraData:extraData]; +} + - (void)handleTouchDown:(UIView *)sender forEvent:(UIEvent *)event { [self setCurrentPointerTypeForEvent:event]; @@ -202,11 +213,11 @@ - (void)handleTouchDown:(UIView *)sender forEvent:(UIEvent *)event withNumberOfTouches:event.allTouches.count withPointerType:_pointerType]]; - [self sendEventsInState:RNGestureHandlerStateActive - forViewWithTag:sender.reactTag - withExtraData:[RNGestureHandlerEventExtraData forPointerInside:YES - withNumberOfTouches:event.allTouches.count - withPointerType:_pointerType]]; + _lastActiveExtraData = nil; + [self sendActiveStateEventIfChangedForView:sender + extraData:[RNGestureHandlerEventExtraData forPointerInside:YES + withNumberOfTouches:event.allTouches.count + withPointerType:_pointerType]]; } - (void)handleTouchUpOutside:(UIView *)sender forEvent:(UIEvent *)event @@ -238,30 +249,27 @@ - (void)handleDragExit:(UIView *)sender forEvent:(UIEvent *)event UIControl *control = (UIControl *)sender; [control cancelTrackingWithEvent:event]; } else { - [self sendEventsInState:RNGestureHandlerStateActive - forViewWithTag:sender.reactTag - withExtraData:[RNGestureHandlerEventExtraData forPointerInside:NO - withNumberOfTouches:event.allTouches.count - withPointerType:_pointerType]]; + [self sendActiveStateEventIfChangedForView:sender + extraData:[RNGestureHandlerEventExtraData forPointerInside:NO + withNumberOfTouches:event.allTouches.count + withPointerType:_pointerType]]; } } - (void)handleDragEnter:(UIView *)sender forEvent:(UIEvent *)event { - [self sendEventsInState:RNGestureHandlerStateActive - forViewWithTag:sender.reactTag - withExtraData:[RNGestureHandlerEventExtraData forPointerInside:YES - withNumberOfTouches:event.allTouches.count - withPointerType:_pointerType]]; + [self sendActiveStateEventIfChangedForView:sender + extraData:[RNGestureHandlerEventExtraData forPointerInside:YES + withNumberOfTouches:event.allTouches.count + withPointerType:_pointerType]]; } - (void)handleDragInside:(UIView *)sender forEvent:(UIEvent *)event { - [self sendEventsInState:RNGestureHandlerStateActive - forViewWithTag:sender.reactTag - withExtraData:[RNGestureHandlerEventExtraData forPointerInside:YES - withNumberOfTouches:event.allTouches.count - withPointerType:_pointerType]]; + [self sendActiveStateEventIfChangedForView:sender + extraData:[RNGestureHandlerEventExtraData forPointerInside:YES + withNumberOfTouches:event.allTouches.count + withPointerType:_pointerType]]; } - (void)handleDragOutside:(UIView *)sender forEvent:(UIEvent *)event @@ -270,11 +278,10 @@ - (void)handleDragOutside:(UIView *)sender forEvent:(UIEvent *)event return; } - [self sendEventsInState:RNGestureHandlerStateActive - forViewWithTag:sender.reactTag - withExtraData:[RNGestureHandlerEventExtraData forPointerInside:NO - withNumberOfTouches:event.allTouches.count - withPointerType:_pointerType]]; + [self sendActiveStateEventIfChangedForView:sender + extraData:[RNGestureHandlerEventExtraData forPointerInside:NO + withNumberOfTouches:event.allTouches.count + withPointerType:_pointerType]]; } - (void)handleTouchCancel:(UIView *)sender forEvent:(UIEvent *)event diff --git a/packages/react-native-gesture-handler/src/web/handlers/GestureHandler.ts b/packages/react-native-gesture-handler/src/web/handlers/GestureHandler.ts index d4983d237e..1bbbd3a70d 100644 --- a/packages/react-native-gesture-handler/src/web/handlers/GestureHandler.ts +++ b/packages/react-native-gesture-handler/src/web/handlers/GestureHandler.ts @@ -14,6 +14,7 @@ import { tagMessage } from '../../utils'; import type { GestureStateChangeEventWithHandlerData, GestureUpdateEventWithHandlerData, + HandlerData, SingleGestureName, } from '../../v3/types'; import type { @@ -430,11 +431,13 @@ export default abstract class GestureHandler implements IGestureHandler { onGestureHandlerReanimatedStateChange, }: PropsRef = this.propsRef!.current; + const isStateChange = this.lastSentState !== newState; + const resultEvent: ResultEvent = !usesNativeOrVirtualDetector( this.actionType ) ? this.transformEventData(newState, oldState) - : this.lastSentState !== newState + : isStateChange ? this.transformStateChangeEvent(newState, oldState) : this.transformUpdateEvent(newState); @@ -442,7 +445,7 @@ export default abstract class GestureHandler implements IGestureHandler { // Here the order is flipped to avoid workarounds such as making backup of the state and setting it to undefined first, then changing it back // Flipping order with setting oldState to undefined solves issue, when events were being sent twice instead of once // However, this may cause trouble in the future (but for now we don't know that) - if (this.lastSentState !== newState) { + if (isStateChange) { this.lastSentState = newState; if (this.forReanimated) { @@ -456,6 +459,16 @@ export default abstract class GestureHandler implements IGestureHandler { return; } + // Cover only V3 path due to different event shape + if (!isStateChange && usesNativeOrVirtualDetector(this.actionType)) { + const handlerData = ( + resultEvent.nativeEvent as GestureUpdateEventWithHandlerData + ).handlerData; + if (this.shouldSuppressActiveUpdate(handlerData)) { + return; + } + } + (resultEvent.nativeEvent as GestureHandlerNativeEvent).oldState = undefined; if (this.forReanimated) { @@ -467,6 +480,12 @@ export default abstract class GestureHandler implements IGestureHandler { } }; + protected shouldSuppressActiveUpdate( + _handlerData: HandlerData + ): boolean { + return false; + } + private transformEventData( newState: State, oldState: State diff --git a/packages/react-native-gesture-handler/src/web/handlers/NativeViewGestureHandler.ts b/packages/react-native-gesture-handler/src/web/handlers/NativeViewGestureHandler.ts index c96a234818..af67672842 100644 --- a/packages/react-native-gesture-handler/src/web/handlers/NativeViewGestureHandler.ts +++ b/packages/react-native-gesture-handler/src/web/handlers/NativeViewGestureHandler.ts @@ -2,6 +2,8 @@ import { Platform } from 'react-native'; import type { ActionType } from '../../ActionType'; import { State } from '../../State'; +import { deepEqual } from '../../utils'; +import type { HandlerData } from '../../v3/types'; import { SingleGestureName } from '../../v3/types'; import { DEFAULT_TOUCH_SLOP } from '../constants'; import type { AdaptedEvent, Config, PropsRef } from '../interfaces'; @@ -20,6 +22,8 @@ export default class NativeViewGestureHandler extends GestureHandler { private startY = 0; private minDistSq = DEFAULT_TOUCH_SLOP * DEFAULT_TOUCH_SLOP; + private lastActiveHandlerData: Record | null = null; + public constructor( delegate: GestureHandlerDelegate ) { @@ -199,4 +203,23 @@ export default class NativeViewGestureHandler extends GestureHandler { ), }; } + + protected override shouldSuppressActiveUpdate( + handlerData: HandlerData + ): boolean { + const current = handlerData as Record; + if ( + this.lastActiveHandlerData && + deepEqual(this.lastActiveHandlerData, current) + ) { + return true; + } + this.lastActiveHandlerData = current; + return false; + } + + public override reset(): void { + super.reset(); + this.lastActiveHandlerData = null; + } } From ab0dc1230615b332262d9944d58d1230ac4aa3e7 Mon Sep 17 00:00:00 2001 From: Jakub Piasecki Date: Tue, 21 Apr 2026 11:41:02 +0200 Subject: [PATCH 2/3] Remove cast, update type --- .../src/web/handlers/NativeViewGestureHandler.ts | 8 +++++--- 1 file changed, 5 insertions(+), 3 deletions(-) diff --git a/packages/react-native-gesture-handler/src/web/handlers/NativeViewGestureHandler.ts b/packages/react-native-gesture-handler/src/web/handlers/NativeViewGestureHandler.ts index af67672842..c9702d2990 100644 --- a/packages/react-native-gesture-handler/src/web/handlers/NativeViewGestureHandler.ts +++ b/packages/react-native-gesture-handler/src/web/handlers/NativeViewGestureHandler.ts @@ -3,6 +3,7 @@ import { Platform } from 'react-native'; import type { ActionType } from '../../ActionType'; import { State } from '../../State'; import { deepEqual } from '../../utils'; +import type { NativeHandlerData } from '../../v3/hooks/gestures/native/NativeTypes'; import type { HandlerData } from '../../v3/types'; import { SingleGestureName } from '../../v3/types'; import { DEFAULT_TOUCH_SLOP } from '../constants'; @@ -10,6 +11,7 @@ import type { AdaptedEvent, Config, PropsRef } from '../interfaces'; import type { GestureHandlerDelegate } from '../tools/GestureHandlerDelegate'; import GestureHandler from './GestureHandler'; import type IGestureHandler from './IGestureHandler'; + export default class NativeViewGestureHandler extends GestureHandler { private buttonRole!: boolean; @@ -22,7 +24,7 @@ export default class NativeViewGestureHandler extends GestureHandler { private startY = 0; private minDistSq = DEFAULT_TOUCH_SLOP * DEFAULT_TOUCH_SLOP; - private lastActiveHandlerData: Record | null = null; + private lastActiveHandlerData: HandlerData | null = null; public constructor( delegate: GestureHandlerDelegate @@ -205,9 +207,9 @@ export default class NativeViewGestureHandler extends GestureHandler { } protected override shouldSuppressActiveUpdate( - handlerData: HandlerData + handlerData: HandlerData ): boolean { - const current = handlerData as Record; + const current = handlerData; if ( this.lastActiveHandlerData && deepEqual(this.lastActiveHandlerData, current) From b5d49ff6e09e99f6320898128444048aa8d78e94 Mon Sep 17 00:00:00 2001 From: Jakub Piasecki Date: Tue, 21 Apr 2026 11:47:58 +0200 Subject: [PATCH 3/3] Remove local variable --- .../src/web/handlers/NativeViewGestureHandler.ts | 5 ++--- 1 file changed, 2 insertions(+), 3 deletions(-) diff --git a/packages/react-native-gesture-handler/src/web/handlers/NativeViewGestureHandler.ts b/packages/react-native-gesture-handler/src/web/handlers/NativeViewGestureHandler.ts index c9702d2990..d78256becc 100644 --- a/packages/react-native-gesture-handler/src/web/handlers/NativeViewGestureHandler.ts +++ b/packages/react-native-gesture-handler/src/web/handlers/NativeViewGestureHandler.ts @@ -209,14 +209,13 @@ export default class NativeViewGestureHandler extends GestureHandler { protected override shouldSuppressActiveUpdate( handlerData: HandlerData ): boolean { - const current = handlerData; if ( this.lastActiveHandlerData && - deepEqual(this.lastActiveHandlerData, current) + deepEqual(this.lastActiveHandlerData, handlerData) ) { return true; } - this.lastActiveHandlerData = current; + this.lastActiveHandlerData = handlerData; return false; }