From 4a625df5277eba5349df8146af978cf1c8ff3e8e Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?Micha=C5=82=20Bert?= <63123542+m-bert@users.noreply.github.com> Date: Mon, 15 Jun 2026 10:50:47 +0200 Subject: [PATCH 01/48] [Android] Do not set explicit versions in `build.gradle` (#4248) Gesture Handler loads specific Kotlin version during build. This is unnecessary and may cause build warnings, such as one described in This PR removes pinning of specific kotlin/gradle versions when Gesture Handler is not root project, so we fallback to the ones declared by the root. Unfortunately we can't simply remove those lines, as `spotless` would fail. Fixes #2307 Tested that android build correctly in basic-example, expo-example and standalone app. --------- Co-authored-by: Copilot Autofix powered by AI <175728472+Copilot@users.noreply.github.com> --- .../docs/fundamentals/installation.md | 12 ------------ .../android/build.gradle | 11 ++++++----- .../android/gradle.properties | 2 +- 3 files changed, 7 insertions(+), 18 deletions(-) diff --git a/packages/docs-gesture-handler/docs/fundamentals/installation.md b/packages/docs-gesture-handler/docs/fundamentals/installation.md index 4ca8b0e532..73675abee2 100644 --- a/packages/docs-gesture-handler/docs/fundamentals/installation.md +++ b/packages/docs-gesture-handler/docs/fundamentals/installation.md @@ -101,18 +101,6 @@ export function CustomModal({ children, ...rest }) { } ``` -##### Kotlin - -Gesture Handler on Android is implemented in Kotlin. If you need to set a specific Kotlin version to be used by the library, set the `kotlinVersion` ext property in `android/build.gradle` file and RNGH will use that version: - -```groovy -buildscript { - ext { - kotlinVersion = "1.6.21" - } -} -``` - #### iOS While developing for iOS, make sure to install [pods](https://cocoapods.org/) first before running the app: diff --git a/packages/react-native-gesture-handler/android/build.gradle b/packages/react-native-gesture-handler/android/build.gradle index 6348d17da8..7361eec0f1 100644 --- a/packages/react-native-gesture-handler/android/build.gradle +++ b/packages/react-native-gesture-handler/android/build.gradle @@ -2,17 +2,18 @@ import groovy.json.JsonSlurper import com.android.build.gradle.tasks.ExternalNativeBuildJsonTask buildscript { - def kotlin_version = rootProject.ext.has('kotlinVersion') ? rootProject.ext.get('kotlinVersion') : project.properties['RNGH_kotlinVersion'] - repositories { mavenCentral() google() } dependencies { - classpath("org.jetbrains.kotlin:kotlin-gradle-plugin:$kotlin_version") - classpath("com.android.tools.build:gradle:8.10.1") - classpath("com.diffplug.spotless:spotless-plugin-gradle:7.0.4") + if (project == rootProject) { + def kotlin_version = project.properties['RNGH_kotlinVersion'] + classpath("org.jetbrains.kotlin:kotlin-gradle-plugin:$kotlin_version") + classpath("com.android.tools.build:gradle:8.10.1") + classpath("com.diffplug.spotless:spotless-plugin-gradle:7.0.4") + } } } diff --git a/packages/react-native-gesture-handler/android/gradle.properties b/packages/react-native-gesture-handler/android/gradle.properties index 2061ead6df..da5eaedbcb 100644 --- a/packages/react-native-gesture-handler/android/gradle.properties +++ b/packages/react-native-gesture-handler/android/gradle.properties @@ -16,4 +16,4 @@ org.gradle.jvmargs=-Xmx2048m -XX:MaxMetaspaceSize=512m -XX:+HeapDumpOnOutOfMemor # This option should only be used with decoupled projects. More details, visit # http://www.gradle.org/docs/current/userguide/multi_project_builds.html#sec:decoupled_projects # org.gradle.parallel=true -RNGH_kotlinVersion=2.0.21 +RNGH_kotlinVersion=2.2.0 From a5553efc91d491c12cd43cbbebc38f2e405527c4 Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?Micha=C5=82=20Bert?= <63123542+m-bert@users.noreply.github.com> Date: Tue, 16 Jun 2026 15:51:07 +0200 Subject: [PATCH 02/48] [Android] Fix mouse interactions (#4265) events. This caused a regression in which mouse events stopped being dispatched to handlers. This PR allows mouse events to be dispatched to handlers and adds check for mouse button press in Long Press so it can activate timeout. It also removes check for obsolete SDK version and inverts logic of skipping events - `shouldActivateWithMouse` wasn't very descriptive (especially in `onHandle`), so I renamed it to `shouldSkipEvent` instead. Fixes #3889 Tested on mouse buttons and Pressable examples --- .../core/FlingGestureHandler.kt | 2 +- .../gesturehandler/core/GestureHandler.kt | 55 ++++++++----------- .../core/LongPressGestureHandler.kt | 5 +- .../gesturehandler/core/PanGestureHandler.kt | 2 +- .../gesturehandler/core/TapGestureHandler.kt | 2 +- .../gesturehandler/react/Extensions.kt | 3 + .../react/RNGestureHandlerRootView.kt | 14 +++-- 7 files changed, 41 insertions(+), 42 deletions(-) diff --git a/packages/react-native-gesture-handler/android/src/main/java/com/swmansion/gesturehandler/core/FlingGestureHandler.kt b/packages/react-native-gesture-handler/android/src/main/java/com/swmansion/gesturehandler/core/FlingGestureHandler.kt index 3a61180ec8..77cfba50d1 100644 --- a/packages/react-native-gesture-handler/android/src/main/java/com/swmansion/gesturehandler/core/FlingGestureHandler.kt +++ b/packages/react-native-gesture-handler/android/src/main/java/com/swmansion/gesturehandler/core/FlingGestureHandler.kt @@ -88,7 +88,7 @@ class FlingGestureHandler : GestureHandler() { } override fun onHandle(event: MotionEvent, sourceEvent: MotionEvent) { - if (!shouldActivateWithMouse(sourceEvent)) { + if (shouldSkipEvent(sourceEvent)) { return } diff --git a/packages/react-native-gesture-handler/android/src/main/java/com/swmansion/gesturehandler/core/GestureHandler.kt b/packages/react-native-gesture-handler/android/src/main/java/com/swmansion/gesturehandler/core/GestureHandler.kt index 91abef225b..f9bde97016 100644 --- a/packages/react-native-gesture-handler/android/src/main/java/com/swmansion/gesturehandler/core/GestureHandler.kt +++ b/packages/react-native-gesture-handler/android/src/main/java/com/swmansion/gesturehandler/core/GestureHandler.kt @@ -4,7 +4,6 @@ import android.app.Activity import android.content.Context import android.content.ContextWrapper import android.graphics.PointF -import android.os.Build import android.view.MotionEvent import android.view.MotionEvent.PointerCoords import android.view.MotionEvent.PointerProperties @@ -754,44 +753,38 @@ open class GestureHandler { return clickedButton and mouseButton != 0 } - protected fun shouldActivateWithMouse(sourceEvent: MotionEvent): Boolean { - // While using mouse, we get both sets of events, for example ACTION_DOWN and ACTION_BUTTON_PRESS. That's why we want to take actions to only one of them. - // On API >= 23, we will use events with infix BUTTON, otherwise we use standard action events (like ACTION_DOWN). + // Decides whether the gesture should ignore this event. While using a mouse we receive both the + // touch-compatible stream (ACTION_DOWN/UP/...) and the BUTTON_* events, so we act on only the + // latter, and we drop events coming from a button other than the configured `mouseButton`. + // Non-mouse input is never skipped here. + protected fun shouldSkipEvent(sourceEvent: MotionEvent): Boolean { + if (sourceEvent.getToolType(0) != MotionEvent.TOOL_TYPE_MOUSE) { + return false + } with(sourceEvent) { - // To use actionButton, we need API >= 23. - if (getToolType(0) == MotionEvent.TOOL_TYPE_MOUSE && - Build.VERSION.SDK_INT >= Build.VERSION_CODES.M + // While using mouse, we want to ignore default events for touch. + if (actionMasked == MotionEvent.ACTION_DOWN || + actionMasked == MotionEvent.ACTION_UP || + actionMasked == MotionEvent.ACTION_POINTER_UP || + actionMasked == MotionEvent.ACTION_POINTER_DOWN ) { - // While using mouse, we want to ignore default events for touch. - if (action == MotionEvent.ACTION_DOWN || - action == MotionEvent.ACTION_UP || - action == MotionEvent.ACTION_POINTER_UP || - action == MotionEvent.ACTION_POINTER_DOWN - ) { - return@shouldActivateWithMouse false - } + return@shouldSkipEvent true + } - // We don't want to do anything if wrong button was clicked. If we received event for BUTTON, we have to use actionButton to get which one was clicked. - if (action != MotionEvent.ACTION_MOVE && !isButtonInConfig(actionButton)) { - return@shouldActivateWithMouse false - } + // Skip events from a button other than the configured one. For BUTTON_* events the clicked + // button is read from `actionButton`. + if (actionMasked != MotionEvent.ACTION_MOVE && !isButtonInConfig(actionButton)) { + return@shouldSkipEvent true + } - // When we receive ACTION_MOVE, we have to check buttonState field. - if (action == MotionEvent.ACTION_MOVE && !isButtonInConfig(buttonState)) { - return@shouldActivateWithMouse false - } - } else if (Build.VERSION.SDK_INT < Build.VERSION_CODES.M) { - // We do not fully support mouse below API 23, so we will ignore BUTTON events. - if (action == MotionEvent.ACTION_BUTTON_PRESS || - action == MotionEvent.ACTION_BUTTON_RELEASE - ) { - return@shouldActivateWithMouse false - } + // For ACTION_MOVE the pressed button is read from `buttonState`. + if (actionMasked == MotionEvent.ACTION_MOVE && !isButtonInConfig(buttonState)) { + return@shouldSkipEvent true } } - return true + return false } /** diff --git a/packages/react-native-gesture-handler/android/src/main/java/com/swmansion/gesturehandler/core/LongPressGestureHandler.kt b/packages/react-native-gesture-handler/android/src/main/java/com/swmansion/gesturehandler/core/LongPressGestureHandler.kt index 32bd755b37..2abec746ea 100644 --- a/packages/react-native-gesture-handler/android/src/main/java/com/swmansion/gesturehandler/core/LongPressGestureHandler.kt +++ b/packages/react-native-gesture-handler/android/src/main/java/com/swmansion/gesturehandler/core/LongPressGestureHandler.kt @@ -66,7 +66,7 @@ class LongPressGestureHandler(context: Context) : GestureHandler() { } override fun onHandle(event: MotionEvent, sourceEvent: MotionEvent) { - if (!shouldActivateWithMouse(sourceEvent)) { + if (shouldSkipEvent(sourceEvent)) { return } @@ -99,7 +99,8 @@ class LongPressGestureHandler(context: Context) : GestureHandler() { currentPointers == numberOfPointersRequired && ( sourceEvent.actionMasked == MotionEvent.ACTION_DOWN || - sourceEvent.actionMasked == MotionEvent.ACTION_POINTER_DOWN + sourceEvent.actionMasked == MotionEvent.ACTION_POINTER_DOWN || + sourceEvent.actionMasked == MotionEvent.ACTION_BUTTON_PRESS ) ) { handler = Handler(Looper.getMainLooper()) diff --git a/packages/react-native-gesture-handler/android/src/main/java/com/swmansion/gesturehandler/core/PanGestureHandler.kt b/packages/react-native-gesture-handler/android/src/main/java/com/swmansion/gesturehandler/core/PanGestureHandler.kt index 33bd963595..6ac1f4809b 100644 --- a/packages/react-native-gesture-handler/android/src/main/java/com/swmansion/gesturehandler/core/PanGestureHandler.kt +++ b/packages/react-native-gesture-handler/android/src/main/java/com/swmansion/gesturehandler/core/PanGestureHandler.kt @@ -149,7 +149,7 @@ class PanGestureHandler(context: Context?) : GestureHandler() { } override fun onHandle(event: MotionEvent, sourceEvent: MotionEvent) { - if (!shouldActivateWithMouse(sourceEvent)) { + if (shouldSkipEvent(sourceEvent)) { return } diff --git a/packages/react-native-gesture-handler/android/src/main/java/com/swmansion/gesturehandler/core/TapGestureHandler.kt b/packages/react-native-gesture-handler/android/src/main/java/com/swmansion/gesturehandler/core/TapGestureHandler.kt index 3a4d828b5b..9169f6ef85 100644 --- a/packages/react-native-gesture-handler/android/src/main/java/com/swmansion/gesturehandler/core/TapGestureHandler.kt +++ b/packages/react-native-gesture-handler/android/src/main/java/com/swmansion/gesturehandler/core/TapGestureHandler.kt @@ -82,7 +82,7 @@ class TapGestureHandler : GestureHandler() { } override fun onHandle(event: MotionEvent, sourceEvent: MotionEvent) { - if (!shouldActivateWithMouse(sourceEvent)) { + if (shouldSkipEvent(sourceEvent)) { return } diff --git a/packages/react-native-gesture-handler/android/src/main/java/com/swmansion/gesturehandler/react/Extensions.kt b/packages/react-native-gesture-handler/android/src/main/java/com/swmansion/gesturehandler/react/Extensions.kt index 51180b29de..4509ff6fe9 100644 --- a/packages/react-native-gesture-handler/android/src/main/java/com/swmansion/gesturehandler/react/Extensions.kt +++ b/packages/react-native-gesture-handler/android/src/main/java/com/swmansion/gesturehandler/react/Extensions.kt @@ -19,3 +19,6 @@ fun Context.isScreenReaderOn() = fun MotionEvent.isHoverAction(): Boolean = action == MotionEvent.ACTION_HOVER_MOVE || action == MotionEvent.ACTION_HOVER_ENTER || action == MotionEvent.ACTION_HOVER_EXIT + +fun MotionEvent.isButtonAction(): Boolean = actionMasked == MotionEvent.ACTION_BUTTON_PRESS || + actionMasked == MotionEvent.ACTION_BUTTON_RELEASE diff --git a/packages/react-native-gesture-handler/android/src/main/java/com/swmansion/gesturehandler/react/RNGestureHandlerRootView.kt b/packages/react-native-gesture-handler/android/src/main/java/com/swmansion/gesturehandler/react/RNGestureHandlerRootView.kt index 2801b8a0f5..fe75e3a06c 100644 --- a/packages/react-native-gesture-handler/android/src/main/java/com/swmansion/gesturehandler/react/RNGestureHandlerRootView.kt +++ b/packages/react-native-gesture-handler/android/src/main/java/com/swmansion/gesturehandler/react/RNGestureHandlerRootView.kt @@ -40,12 +40,14 @@ class RNGestureHandlerRootView(context: Context?) : ReactViewGroup(context) { super.dispatchTouchEvent(event) } - override fun dispatchGenericMotionEvent(ev: MotionEvent) = - if (rootViewEnabled && ev.isHoverAction() && rootHelper!!.dispatchTouchEvent(ev)) { - true - } else { - super.dispatchGenericMotionEvent(ev) - } + override fun dispatchGenericMotionEvent(ev: MotionEvent) = if (rootViewEnabled && + (ev.isHoverAction() || ev.isButtonAction()) && + rootHelper!!.dispatchTouchEvent(ev) + ) { + true + } else { + super.dispatchGenericMotionEvent(ev) + } override fun requestDisallowInterceptTouchEvent(disallowIntercept: Boolean) { if (rootViewEnabled) { From ec6e6942e65adbc46c8c86e9d9f1eaa25b92e55c Mon Sep 17 00:00:00 2001 From: James Acklin <748181+jamesacklin@users.noreply.github.com> Date: Tue, 16 Jun 2026 14:40:24 -0400 Subject: [PATCH 03/48] [Android | Web] Disable pointer events on hidden `Swipeable` actions container (#4192) MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit ## Description The left/right action containers in `ReanimatedSwipeable` are absolute-fill overlays that animate to `opacity: 0` when not revealed. On Android, an opacity-0 view still receives touches, so the hidden side stays on top in z-order and swallows taps that should reach the visible side's actions (or the row content itself). A common repro is a quick-action button exposed by a swipe: the button visibly responds to press feedback but the `onPress` never fires because the opposite-side container intercepts it. This adds a matching `pointerEvents` toggle to `leftActionAnimation` and `rightActionAnimation`, so each container becomes `'none'` alongside its opacity going to 0, and switches back to `'auto'` once revealed. iOS and web were not affected by the original bug, but the toggle is harmless on those platforms (an opacity-0 view there is already non-interactive). Fixes #3223. ## Test plan - Carried as a local patch against `react-native-gesture-handler@2.28.0` in our app for the last few weeks. Before the patch, Android taps on a swipe-revealed action were dropped intermittently; after the patch, every tap fires on the first try. iOS behavior was unchanged. - Manual repro for reviewers: in `apps/common-app`, open a `Swipeable` example, swipe a row to reveal an action, and tap the action on Android — the action should fire on the first tap. --------- Co-authored-by: Michał Co-authored-by: Michał Bert <63123542+m-bert@users.noreply.github.com> --- .../ReanimatedSwipeable/ReanimatedSwipeable.tsx | 8 ++++++++ 1 file changed, 8 insertions(+) diff --git a/packages/react-native-gesture-handler/src/components/ReanimatedSwipeable/ReanimatedSwipeable.tsx b/packages/react-native-gesture-handler/src/components/ReanimatedSwipeable/ReanimatedSwipeable.tsx index b6134c9086..67748ba9ef 100644 --- a/packages/react-native-gesture-handler/src/components/ReanimatedSwipeable/ReanimatedSwipeable.tsx +++ b/packages/react-native-gesture-handler/src/components/ReanimatedSwipeable/ReanimatedSwipeable.tsx @@ -372,6 +372,10 @@ const Swipeable = (props: SwipeableProps) => { const leftActionAnimation = useAnimatedStyle(() => { return { opacity: showLeftProgress.value === 0 ? 0 : 1, + // Both action containers use `absoluteFill` and overlap, so the + // inactive one must not intercept touches meant for the visible + // actions. + pointerEvents: showLeftProgress.value === 0 ? 'none' : 'auto', }; }); @@ -402,6 +406,10 @@ const Swipeable = (props: SwipeableProps) => { const rightActionAnimation = useAnimatedStyle(() => { return { opacity: showRightProgress.value === 0 ? 0 : 1, + // Both action containers use `absoluteFill` and overlap, so the + // inactive one must not intercept touches meant for the visible + // actions. + pointerEvents: showRightProgress.value === 0 ? 'none' : 'auto', }; }); From a2dbfd2a0b586153ba78ce87390f8659c44495a6 Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?Micha=C5=82=20Bert?= <63123542+m-bert@users.noreply.github.com> Date: Thu, 18 Jun 2026 10:25:45 +0200 Subject: [PATCH 04/48] Remove `opacity` from `Swipeable` action panels (#4271) ## Description Row position and `opacity` depend on different SharedValue, which may lead to them animating out of sync. Since we already have `overflow: hidden;` setting `opacity` is not required. Fixes #3897 ## Test plan Tested on the existing Swipeable examples. --- .../src/components/ReanimatedSwipeable/ReanimatedSwipeable.tsx | 2 -- 1 file changed, 2 deletions(-) diff --git a/packages/react-native-gesture-handler/src/components/ReanimatedSwipeable/ReanimatedSwipeable.tsx b/packages/react-native-gesture-handler/src/components/ReanimatedSwipeable/ReanimatedSwipeable.tsx index 67748ba9ef..0ea07c0d41 100644 --- a/packages/react-native-gesture-handler/src/components/ReanimatedSwipeable/ReanimatedSwipeable.tsx +++ b/packages/react-native-gesture-handler/src/components/ReanimatedSwipeable/ReanimatedSwipeable.tsx @@ -371,7 +371,6 @@ const Swipeable = (props: SwipeableProps) => { const leftActionAnimation = useAnimatedStyle(() => { return { - opacity: showLeftProgress.value === 0 ? 0 : 1, // Both action containers use `absoluteFill` and overlap, so the // inactive one must not intercept touches meant for the visible // actions. @@ -405,7 +404,6 @@ const Swipeable = (props: SwipeableProps) => { const rightActionAnimation = useAnimatedStyle(() => { return { - opacity: showRightProgress.value === 0 ? 0 : 1, // Both action containers use `absoluteFill` and overlap, so the // inactive one must not intercept touches meant for the visible // actions. From c77ef2d50ecb7e1ac510b6e5b2c3fc1ec551bad0 Mon Sep 17 00:00:00 2001 From: Jakub Piasecki Date: Fri, 19 Jun 2026 09:55:23 +0200 Subject: [PATCH 05/48] [Android] Fix text getting selected during gestures (#4273) ## Description Fixes https://github.com/software-mansion/react-native-gesture-handler/issues/3866 When a gesture activates, the root helper's `shouldIntercept` flips to `true`, causing it to stop delivering events to the OS touch handling path. If a native view tries to detect a long press, it's possible that the event stream will stop on a `MOVE` event, triggering the long press action. This PR adds a native view cancellation step - when `shouldIntercept` flips, all views on the path from the root to the leaf that received the touch receive a synthetic `CANCEL` event, preventing that from happening. Views with an active `NativeViewGestureHandler` attached to them are exempt from that mechanism, since the cancel event would cause them to stop recognizing the event mid-stream and they've been explicitly opted in to the RNGH touch system. Note: the logic to find views to cancel relies on the `DOWN` coordinates for each pointer instead of the current ones - the goal is to cancel views that have possibly handled the `DOWN` event and started their own logic. ## Test plan Tested on the issue reproducer --- .../core/GestureHandlerOrchestrator.kt | 112 ++++++++++++++++++ .../react/RNGestureHandlerRootHelper.kt | 9 ++ 2 files changed, 121 insertions(+) diff --git a/packages/react-native-gesture-handler/android/src/main/java/com/swmansion/gesturehandler/core/GestureHandlerOrchestrator.kt b/packages/react-native-gesture-handler/android/src/main/java/com/swmansion/gesturehandler/core/GestureHandlerOrchestrator.kt index 8d78aeac38..d1c3d8985b 100644 --- a/packages/react-native-gesture-handler/android/src/main/java/com/swmansion/gesturehandler/core/GestureHandlerOrchestrator.kt +++ b/packages/react-native-gesture-handler/android/src/main/java/com/swmansion/gesturehandler/core/GestureHandlerOrchestrator.kt @@ -2,6 +2,7 @@ package com.swmansion.gesturehandler.core import android.graphics.Matrix import android.graphics.PointF +import android.util.SparseArray import android.view.MotionEvent import android.view.View import android.view.ViewGroup @@ -28,6 +29,10 @@ class GestureHandlerOrchestrator( private val awaitingHandlers = arrayListOf() private val preparedHandlers = arrayListOf() + // Used by `cancelTouchesInInterceptedViews`. + private val viewsToCancel = arrayListOf() + private val pointerDownPoints = SparseArray() + // In `onHandlerStateChange` method we iterate through `awaitingHandlers`, but calling `tryActivate` may modify this list. // To avoid `ConcurrentModificationException` we iterate through copy. There is one more problem though - if handler was // removed from `awaitingHandlers`, it was still present in copy of original list. This hashset helps us identify which handlers @@ -46,6 +51,7 @@ class GestureHandlerOrchestrator( fun onTouchEvent(event: MotionEvent): Boolean { isHandlingTouch = true val action = event.actionMasked + trackPointerDownPoints(event) if (action == MotionEvent.ACTION_DOWN || action == MotionEvent.ACTION_POINTER_DOWN || action == MotionEvent.ACTION_HOVER_MOVE @@ -622,6 +628,112 @@ class GestureHandlerOrchestrator( return false } + private fun trackPointerDownPoints(event: MotionEvent) { + val index = event.actionIndex + when (event.actionMasked) { + MotionEvent.ACTION_DOWN, MotionEvent.ACTION_POINTER_DOWN -> + pointerDownPoints.put(event.getPointerId(index), PointF(event.getX(index), event.getY(index))) + MotionEvent.ACTION_POINTER_UP -> + pointerDownPoints.remove(event.getPointerId(index)) + MotionEvent.ACTION_UP, MotionEvent.ACTION_CANCEL -> + pointerDownPoints.clear() + } + } + + fun cancelTouchesInInterceptedViews(event: MotionEvent) { + viewsToCancel.clear() + for (i in 0 until pointerDownPoints.size()) { + val point = pointerDownPoints.valueAt(i) + tempCoords[0] = point.x + tempCoords[1] = point.y + collectViewsAtPoint(wrapperView, tempCoords, viewsToCancel) + } + + if (viewsToCancel.isEmpty()) { + return + } + + val activeHandlers = gestureHandlers.filter { it.isActive } + val cancelEvent = MotionEvent.obtain(event).apply { action = MotionEvent.ACTION_CANCEL } + + for (view in viewsToCancel) { + if (view === wrapperView || isViewDrivenByActiveNativeGesture(view, activeHandlers)) { + continue + } + view.onTouchEvent(cancelEvent) + } + + cancelEvent.recycle() + viewsToCancel.clear() + } + + // Whether the view's touch is still owned by a NativeViewGestureHandler that survived arbitration. + // Only those are fed through `onTouchEvent`, so only those break if cancelled. Other handlers are + // orchestrator-driven and unaffected. + private fun isViewDrivenByActiveNativeGesture(view: View, activeHandlers: List) = + handlerRegistry.getHandlersForView(view)?.let { handlers -> + synchronized(handlers) { + handlers.any { nativeGestureSurvivesArbitration(it, activeHandlers) } + } + } ?: false + + // A native handler survives arbitration if it is active, or it does not conflict with any active handler. + private fun nativeGestureSurvivesArbitration(handler: GestureHandler, activeHandlers: List) = + handler is NativeViewGestureHandler && + (handler.isActive || activeHandlers.none { shouldHandlerBeCancelledBy(handler, it) }) + + // Collects the view path under the point (topmost child first, like touch dispatch), leaf to root. + private fun collectViewsAtPoint(view: View, coords: FloatArray, out: MutableList): Boolean { + if (shouldIgnoreSubtreeIfGestureHandlerRootView(view)) { + // A nested active root view manages its own subtree (and its own interception cancellation). + return false + } + + val pointerEvents = viewConfigHelper.getPointerEventsConfigForView(view) + if (pointerEvents == PointerEventsConfig.NONE) { + return false + } + + var found = false + if (view is ViewGroup && pointerEvents != PointerEventsConfig.BOX_ONLY) { + for (i in view.childCount - 1 downTo 0) { + val child = view.getChildAt(i) + if (!canReceiveEvents(child)) { + continue + } + val childPoint = tempPoint + transformPointToChildViewCoords(coords[0], coords[1], view, child, childPoint) + if (isClipping(child) && !isTransformedTouchPointInView(childPoint.x, childPoint.y, child)) { + continue + } + val restoreX = coords[0] + val restoreY = coords[1] + coords[0] = childPoint.x + coords[1] = childPoint.y + found = collectViewsAtPoint(child, coords, out) + coords[0] = restoreX + coords[1] = restoreY + + if (found) { + break + } + } + } + + // BOX_NONE views can't be the target themselves, only their children can + val selfIsTarget = pointerEvents != PointerEventsConfig.BOX_NONE && + isTransformedTouchPointInView(coords[0], coords[1], view) + + if (found || selfIsTarget) { + // `out` may already contain this view when several pointers share part of their path. + if (!out.contains(view)) { + out.add(view) + } + return true + } + return false + } + private fun traverseWithPointerEvents(view: View, coords: FloatArray, pointerId: Int, event: MotionEvent): Boolean = if (shouldIgnoreSubtreeIfGestureHandlerRootView(view)) { // When we encounter another active root view while traversing the view hierarchy, we want diff --git a/packages/react-native-gesture-handler/android/src/main/java/com/swmansion/gesturehandler/react/RNGestureHandlerRootHelper.kt b/packages/react-native-gesture-handler/android/src/main/java/com/swmansion/gesturehandler/react/RNGestureHandlerRootHelper.kt index 629c155117..edbdff9b19 100644 --- a/packages/react-native-gesture-handler/android/src/main/java/com/swmansion/gesturehandler/react/RNGestureHandlerRootHelper.kt +++ b/packages/react-native-gesture-handler/android/src/main/java/com/swmansion/gesturehandler/react/RNGestureHandlerRootHelper.kt @@ -19,6 +19,7 @@ class RNGestureHandlerRootHelper(private val context: ReactContext, wrappedView: private val jsGestureHandler: GestureHandler? val rootView: ViewGroup private var shouldIntercept = false + private var wasIntercepting = false private var passingTouch = false init { @@ -118,6 +119,14 @@ class RNGestureHandlerRootHelper(private val context: ReactContext, wrappedView: passingTouch = true orchestrator!!.onTouchEvent(event) passingTouch = false + + // On the transition into interception, cancel the native views the pointers landed on - the + // framework's ACTION_CANCEL never reaches them since RNGH ignores `onInterceptTouchEvent` + if (shouldIntercept && !wasIntercepting) { + orchestrator!!.cancelTouchesInInterceptedViews(event) + } + wasIntercepting = shouldIntercept + return shouldIntercept } From 5916a43ccd4d5b5aea073e88530051648a9e9c14 Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?Micha=C5=82=20Bert?= <63123542+m-bert@users.noreply.github.com> Date: Tue, 23 Jun 2026 08:56:13 +0200 Subject: [PATCH 06/48] [iOS] Move `Tap` cancelation to `dispatch_after` (#4280) MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit ## Description Fixes #3471 On iOS, in a multi-gesture setup (e.g. an `Exclusive` single-tap / double-tap combination), a `tap`'s `onDeactivate`/`onFinalize` was delayed until a `ScrollView`/`FlatList` finished its drag or momentum scroll. `onBegin` fired immediately, but the single tap never run `onActivate`/`onEnd` — it stayed in the began state and finalized as failed only once scrolling settled. `RNBetterTapGestureRecognizer` armed its `maxDuration` and `maxDelay` failure timers with `performSelector:withObject:afterDelay:`, which schedules an `NSTimer` in `NSDefaultRunLoopMode` only. While a `UIScrollView` is dragging or decelerating, the main run loop runs in `UITrackingRunLoopMode`, so those timers are starved until the scroll stops. To fix this, I replaced the two `performSelector:…afterDelay:` calls with cancellable `dispatch_after` blocks which fire regardless of run-loop mode (it is also consistent with current `LongPress` / `Fling` logic). ## Test plan
Tested on the following code: ```tsx import * as React from 'react'; import { FlatList, Pressable, StyleSheet, Text, View } from 'react-native'; import { GestureDetector, GestureHandlerRootView, useExclusiveGestures, useTapGesture, } from 'react-native-gesture-handler'; // Repro for https://github.com/software-mansion/react-native-gesture-handler/issues/3471 // // A FlatList (a plain RN ScrollView under the hood) lives OUTSIDE the // GestureDetector. Below it, a tracked box has a single-tap / double-tap // Exclusive combination (v3 API). // // Bug (iOS only): when the list has an active touch or is still in momentum // scroll, tapping the box fires onBegin immediately, but onDeactivate (v2 // onEnd) / onFinalize of the single tap are delayed until the list's touch is // released / momentum finishes. The log below timestamps every lifecycle // callback so the delay is visible. const DATA = Array.from({ length: 60 }, (_, i) => `List item ${i + 1}`); const MAX_LOG = 12; export default function App() { const [log, setLog] = React.useState([]); const seq = React.useRef(0); const append = React.useCallback((line: string) => { const t = new Date(); const pad = (n: number, len = 2) => n.toString().padStart(len, '0'); const stamp = `${pad(t.getHours())}:${pad(t.getMinutes())}:${pad( t.getSeconds() )}.${pad(t.getMilliseconds(), 3)}`; const n = (seq.current += 1); setLog((prev) => [`#${pad(n, 3)} ${stamp} ${line}`, ...prev].slice(0, MAX_LOG) ); }, []); const singleTap = useTapGesture({ disableReanimated: true, onBegin: () => append('single onBegin'), onActivate: () => append('single onActivate (v2 onStart)'), onDeactivate: () => append('single onDeactivate (v2 onEnd)'), onFinalize: () => append('single onFinalize'), }); const doubleTap = useTapGesture({ disableReanimated: true, numberOfTaps: 2, onBegin: () => append('double onBegin'), onActivate: () => append('double onActivate (v2 onStart)'), onDeactivate: () => append('double onDeactivate (v2 onEnd)'), onFinalize: () => append('double onFinalize'), }); const tap = useExclusiveGestures(doubleTap, singleTap); return ( item} renderItem={({ item }) => ( {item} )} /> Tap here (single / double) Log { seq.current = 0; setLog([]); }}> Clear {log.map((line, i) => ( {line} ))} ); } const styles = StyleSheet.create({ root: { flex: 1, backgroundColor: '#ecf0f1', }, list: { flex: 1, }, row: { paddingVertical: 16, paddingHorizontal: 12, borderBottomWidth: StyleSheet.hairlineWidth, borderBottomColor: '#bdc3c7', }, rowText: { fontSize: 16, }, tapArea: { height: 100, backgroundColor: '#3498db', alignItems: 'center', justifyContent: 'center', }, tapAreaText: { color: 'white', fontSize: 18, fontWeight: 'bold', }, logBox: { height: 220, backgroundColor: '#2c3e50', padding: 8, }, logHeader: { flexDirection: 'row', alignItems: 'center', justifyContent: 'space-between', marginBottom: 6, }, logTitle: { color: '#ecf0f1', fontSize: 14, fontWeight: 'bold', }, clearButton: { backgroundColor: '#e74c3c', paddingVertical: 4, paddingHorizontal: 12, borderRadius: 4, }, clearButtonText: { color: 'white', fontSize: 13, fontWeight: 'bold', }, logLine: { color: '#ecf0f1', fontFamily: 'Courier', fontSize: 13, }, }); ```
--- .../apple/Handlers/RNTapHandler.m | 39 +++++++++++++++++-- 1 file changed, 35 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 eafe7192b3..8c0289fc78 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]; @@ -93,14 +122,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]; @@ -132,10 +161,12 @@ - (void)interactionsEnded:(NSSet *)touches withEvent:(UIEvent *)event { [_gestureHandler.pointerTracker touchesEnded:touches withEvent:event]; + [self cancelPendingCancellations]; + if (_numberOfTaps == _tapsSoFar && _maxNumberOfTouches >= _minPointers) { self.state = UIGestureRecognizerStateEnded; } else { - [self performSelector:@selector(cancel) withObject:nil afterDelay:_maxDelay]; + [self scheduleCancelAfterDelay:_maxDelay]; } } @@ -250,7 +281,7 @@ - (void)reset [_gestureHandler.pointerTracker reset]; - [NSObject cancelPreviousPerformRequestsWithTarget:self selector:@selector(cancel) object:nil]; + [self cancelPendingCancellations]; _tapsSoFar = 0; _maxNumberOfTouches = 0; self.enabled = YES; From d146bd80ae020eebdde8523cb860d022fdad2b25 Mon Sep 17 00:00:00 2001 From: Jakub Kosmydel <104823336+kosmydel@users.noreply.github.com> Date: Mon, 29 Jun 2026 17:02:10 +0200 Subject: [PATCH 07/48] [Android] Fix ConcurrentModificationException in GestureHandlerOrchestrator (#4274) MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit ## Description Fixes a `ConcurrentModificationException` in `GestureHandlerOrchestrator` when delivering and cancelling events. Event delivery now iterates over local snapshots of `gestureHandlers` instead of a shared `preparedHandlers` field and the live `asReversed()` view, both of which could be mutated mid-iteration during re-entrant state updates. Credit to @SudoPlz for the original fix. This PR upstreams it into the main repository > [!NOTE] > We don't have a reliable repro for this. It surfaced in our Sentry logs as a crash, and the fix is based on inspection of the orchestrator's re-entrancy behavior.
Original patch confirmed to work

``` diff --git a/android/src/main/java/com/swmansion/gesturehandler/core/GestureHandlerOrchestrator.kt b/android/src/main/java/com/swmansion/gesturehandler/core/GestureHandlerOrchestrator.kt index ced298e52339d96f871144598a5ed519cf526a89..ce87cc710eea5fa98a27dcf1f94878d38c5b40e0 100644 --- a/android/src/main/java/com/swmansion/gesturehandler/core/GestureHandlerOrchestrator.kt +++ b/android/src/main/java/com/swmansion/gesturehandler/core/GestureHandlerOrchestrator.kt @@ -250,19 +250,18 @@ class GestureHandlerOrchestrator( } private fun deliverEventToGestureHandlers(event: MotionEvent) { - // Copy handlers to "prepared handlers" array, because the list of active handlers can change - // as a result of state updates - preparedHandlers.clear() - preparedHandlers.addAll(gestureHandlers) + // Copy handlers to local array, because the list of active handlers can change + // as a result of state updates. Create a local snapshot to avoid race conditions. + val handlersToProcess = gestureHandlers.toMutableList() // We want to deliver events to active handlers first in order of their activation (handlers // that activated first will first get event delivered). Otherwise we deliver events in the // order in which handlers has been added ("most direct" children goes first). Therefore we rely // on Arrays.sort providing a stable sort (as children are registered in order in which they // should be tested) - preparedHandlers.sortWith(handlersComparator) + handlersToProcess.sortWith(handlersComparator) - for (handler in preparedHandlers) { + for (handler in handlersToProcess) { deliverEventToGestureHandler(handler, event) } } @@ -274,11 +273,8 @@ class GestureHandlerOrchestrator( handler.cancel() } - // Copy handlers to "prepared handlers" array, because the list of active handlers can change - // as a result of state updates - preparedHandlers.clear() - preparedHandlers.addAll(gestureHandlers) - + // Use reversed() directly to create a snapshot and avoid race conditions + // when the list of active handlers changes as a result of state updates. for (handler in gestureHandlers.asReversed()) { handler.cancel() } ```

## Test steps 1. Build and run the Android example app (`apps/basic-example`, `yarn android`). 2. Exercise screens with multiple interacting gesture handlers (e.g. nested gestures, swipeables) and confirm no regressions in gesture behavior. Made with [Cursor](https://cursor.com) --------- Co-authored-by: Cursor Co-authored-by: Michał Bert <63123542+m-bert@users.noreply.github.com> --- .../core/GestureHandlerOrchestrator.kt | 36 ++++++++++++------- 1 file changed, 23 insertions(+), 13 deletions(-) diff --git a/packages/react-native-gesture-handler/android/src/main/java/com/swmansion/gesturehandler/core/GestureHandlerOrchestrator.kt b/packages/react-native-gesture-handler/android/src/main/java/com/swmansion/gesturehandler/core/GestureHandlerOrchestrator.kt index d1c3d8985b..a5119fd56c 100644 --- a/packages/react-native-gesture-handler/android/src/main/java/com/swmansion/gesturehandler/core/GestureHandlerOrchestrator.kt +++ b/packages/react-native-gesture-handler/android/src/main/java/com/swmansion/gesturehandler/core/GestureHandlerOrchestrator.kt @@ -27,7 +27,16 @@ class GestureHandlerOrchestrator( var minimumAlphaForTraversal = DEFAULT_MIN_ALPHA_FOR_TRAVERSAL private val gestureHandlers = arrayListOf() private val awaitingHandlers = arrayListOf() - private val preparedHandlers = arrayListOf() + + // Pool of reusable lists for snapshotting `gestureHandlers` during event delivery. + private val handlerListPool = ArrayDeque>() + + private fun obtainHandlerList() = handlerListPool.pollLast() ?: ArrayList() + + private fun recycleHandlerList(list: ArrayList) { + list.clear() + handlerListPool.addLast(list) + } // Used by `cancelTouchesInInterceptedViews`. private val viewsToCancel = arrayListOf() @@ -258,20 +267,24 @@ class GestureHandlerOrchestrator( } private fun deliverEventToGestureHandlers(event: MotionEvent) { - // Copy handlers to "prepared handlers" array, because the list of active handlers can change - // as a result of state updates - preparedHandlers.clear() - preparedHandlers.addAll(gestureHandlers) + // Snapshot handlers into a pooled list, because the list of active handlers can change + // as a result of state updates (and delivery can be re-entrant). + val handlersToProcess = obtainHandlerList() + handlersToProcess.addAll(gestureHandlers) // We want to deliver events to active handlers first in order of their activation (handlers // that activated first will first get event delivered). Otherwise we deliver events in the // order in which handlers has been added ("most direct" children goes first). Therefore we rely // on Arrays.sort providing a stable sort (as children are registered in order in which they // should be tested) - preparedHandlers.sortWith(handlersComparator) + handlersToProcess.sortWith(handlersComparator) - for (handler in preparedHandlers) { - deliverEventToGestureHandler(handler, event) + try { + for (handler in handlersToProcess) { + deliverEventToGestureHandler(handler, event) + } + } finally { + recycleHandlerList(handlersToProcess) } } @@ -282,12 +295,9 @@ class GestureHandlerOrchestrator( handler.cancel() } - // Copy handlers to "prepared handlers" array, because the list of active handlers can change + // Iterate over a copy, because the list of active handlers can change // as a result of state updates - preparedHandlers.clear() - preparedHandlers.addAll(gestureHandlers) - - for (handler in gestureHandlers.asReversed()) { + for (handler in gestureHandlers.reversed()) { handler.cancel() } } From 2642f13fd42a02e433e41fc425311cfc9ec464b7 Mon Sep 17 00:00:00 2001 From: petterikorpimaa <129196760+petterikorpimaa@users.noreply.github.com> Date: Thu, 2 Jul 2026 12:46:25 +0300 Subject: [PATCH 08/48] fix: remove UIPointerInteraction from the view when unbinding the hover handler (#4291) ## Description Fixes #4290. `RNHoverGestureHandler` adds a `UIPointerInteraction` to the view in `bindToView:`, but never actually removes it. In `unbindFromView`, `[super unbindFromView]` runs first and detaches the gesture recognizer, which sets `self.recognizer.view` to nil. The following `removeInteraction:` call is then a message to nil and does nothing, so the interaction stays on the `UIView` for the rest of its life. With Fabric view recycling, that leaked interaction later belongs to an unrelated component. Its delegate (the recognizer) is gone by then, and a `UIPointerInteraction` without a delegate applies the system default pointer effect over the whole view. The visible result is native hover effects (very prominent with the Liquid Glass style on iPadOS 26) appearing on random elements that never had a hover gesture, accumulating as hover handlers unmount and remount. This PR captures the view reference before calling `[super unbindFromView]` and removes the interaction from the captured reference. ## Test plan Tested in an app that uses `Gesture.Hover()` on most of its toolbar and menu items, on a real iPad (iPadOS 26) with Apple Pencil hover and a trackpad: - Before the change: after a few mount/unmount cycles of hover-enabled views, hovering over unrelated elements shows the native hover effect on random views. - With the change (applied to the app as a package patch): the stray hover effects no longer appear, and hover gestures keep working as before. - The `RNGestureHandler` pod compiles cleanly with the change. --- .../apple/Handlers/RNHoverHandler.m | 3 ++- 1 file changed, 2 insertions(+), 1 deletion(-) diff --git a/packages/react-native-gesture-handler/apple/Handlers/RNHoverHandler.m b/packages/react-native-gesture-handler/apple/Handlers/RNHoverHandler.m index 320cb8cb26..621a857d65 100644 --- a/packages/react-native-gesture-handler/apple/Handlers/RNHoverHandler.m +++ b/packages/react-native-gesture-handler/apple/Handlers/RNHoverHandler.m @@ -132,8 +132,9 @@ - (void)unbindFromView { #if CHECK_TARGET(13_4) if (@available(iOS 13.4, *)) { - [super unbindFromView]; + // Remove the interaction before [super unbindFromView] detaches the recognizer and nils recognizer.view. [self.recognizer.view removeInteraction:_pointerInteraction]; + [super unbindFromView]; } #endif } From b888946b61aa09964d6847a71b57d505942acbc2 Mon Sep 17 00:00:00 2001 From: Jakub Piasecki Date: Mon, 6 Jul 2026 15:09:30 +0200 Subject: [PATCH 09/48] [Android] Don't delay child pressed state in buttons (#4296) https://github.com/react/react-native/pull/57127 added an override for `shouldDelayChildPressedState` in React Native containers to prevent them from delaying the pressed state in children. All components extending them will get this for free, but our button implementation doesn't extend `ReactViewGroup` but `ViewGroup`. This PR adds override for `shouldDelayChildPressedState` in `RNGestureHandlerButtonViewManager`. --- .../gesturehandler/react/RNGestureHandlerButtonViewManager.kt | 2 ++ 1 file changed, 2 insertions(+) diff --git a/packages/react-native-gesture-handler/android/src/main/java/com/swmansion/gesturehandler/react/RNGestureHandlerButtonViewManager.kt b/packages/react-native-gesture-handler/android/src/main/java/com/swmansion/gesturehandler/react/RNGestureHandlerButtonViewManager.kt index e4114c40fa..b3b5d2c845 100644 --- a/packages/react-native-gesture-handler/android/src/main/java/com/swmansion/gesturehandler/react/RNGestureHandlerButtonViewManager.kt +++ b/packages/react-native-gesture-handler/android/src/main/java/com/swmansion/gesturehandler/react/RNGestureHandlerButtonViewManager.kt @@ -554,6 +554,8 @@ class RNGestureHandlerButtonViewManager : // by default Viewgroup would pass hotspot change events } + override fun shouldDelayChildPressedState(): Boolean = false + private fun findGestureHandlerRootView(): RNGestureHandlerRootView? { var parent: ViewParent? = this.parent var gestureHandlerRootView: RNGestureHandlerRootView? = null From 9bd5731b01ce340ce80430c29c3c961ef66a78d3 Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?Micha=C5=82=20Bert?= <63123542+m-bert@users.noreply.github.com> Date: Wed, 8 Jul 2026 10:04:06 +0200 Subject: [PATCH 10/48] [macOS] Add coordinates to `Hover` events (#4304) ## Description I've noticed that on `macOS` `Hover` gesture does not provide coordinates, while on other platforms it does. This PR fixes this mismatch. ## Test plan
Details ```tsx import { View } from 'react-native'; import { GestureDetector, GestureHandlerRootView, useHoverGesture, } from 'react-native-gesture-handler'; export default function App() { const hover = useHoverGesture({ onBegin: (e) => console.log(e), }); return ( ); } ```
--- .../apple/Handlers/RNHoverHandler.m | 47 +++++++++---------- 1 file changed, 22 insertions(+), 25 deletions(-) diff --git a/packages/react-native-gesture-handler/apple/Handlers/RNHoverHandler.m b/packages/react-native-gesture-handler/apple/Handlers/RNHoverHandler.m index 621a857d65..464b9c7488 100644 --- a/packages/react-native-gesture-handler/apple/Handlers/RNHoverHandler.m +++ b/packages/react-native-gesture-handler/apple/Handlers/RNHoverHandler.m @@ -219,33 +219,30 @@ - (void)unbindFromView _view = nil; } +- (RNGestureHandlerEventExtraData *)extraDataForEvent:(NSEvent *)event +{ + CGPoint windowLocation = [event locationInWindow]; + CGPoint relativePos = [_view convertPoint:windowLocation fromView:nil]; + CGPoint absolutePos = [_view.window.contentView convertPoint:windowLocation fromView:nil]; + + return [RNGestureHandlerEventExtraData forPosition:relativePos + withAbsolutePosition:absolutePos + withNumberOfTouches:1 + withPointerType:_pointerType]; +} + - (void)mouseEntered:(NSEvent *)event { - [self sendEventsInState:RNGestureHandlerStateBegan - forViewWithTag:_view.reactTag - withExtraData:[RNGestureHandlerEventExtraData forPointerInside:YES - withNumberOfTouches:1 - withPointerType:_pointerType]]; - [self sendEventsInState:RNGestureHandlerStateActive - forViewWithTag:_view.reactTag - withExtraData:[RNGestureHandlerEventExtraData forPointerInside:YES - withNumberOfTouches:1 - withPointerType:_pointerType]]; -} - -- (void)mouseExited:(NSEvent *)theEvent -{ - [self sendEventsInState:RNGestureHandlerStateEnd - forViewWithTag:_view.reactTag - withExtraData:[RNGestureHandlerEventExtraData forPointerInside:NO - withNumberOfTouches:1 - withPointerType:_pointerType]]; - - [self sendEventsInState:RNGestureHandlerStateUndetermined - forViewWithTag:_view.reactTag - withExtraData:[RNGestureHandlerEventExtraData forPointerInside:NO - withNumberOfTouches:1 - withPointerType:_pointerType]]; + RNGestureHandlerEventExtraData *extraData = [self extraDataForEvent:event]; + [self sendEventsInState:RNGestureHandlerStateBegan forViewWithTag:_view.reactTag withExtraData:extraData]; + [self sendEventsInState:RNGestureHandlerStateActive forViewWithTag:_view.reactTag withExtraData:extraData]; +} + +- (void)mouseExited:(NSEvent *)event +{ + RNGestureHandlerEventExtraData *extraData = [self extraDataForEvent:event]; + [self sendEventsInState:RNGestureHandlerStateEnd forViewWithTag:_view.reactTag withExtraData:extraData]; + [self sendEventsInState:RNGestureHandlerStateUndetermined forViewWithTag:_view.reactTag withExtraData:extraData]; } @end From 4d2fc0159e4d4fd8dff4984efa6c504327688484 Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?Micha=C5=82=20Bert?= <63123542+m-bert@users.noreply.github.com> Date: Thu, 9 Jul 2026 10:33:00 +0200 Subject: [PATCH 11/48] [iOS] Fix js responder cancelation in modals (#4306) MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit On iOS, when a gesture activates inside a `react-native-screens` route presented as `formSheet` or `modal`, the in-flight touch of a core RN `Pressable`/`Touchable` underneath is not cancelled — the press completes and `onPress` fires on release, alongside the gesture. In this PR registration walk-up now also stops at a modally-presented `RNSScreenView`, so the root recognizer is attached to the screen view and travels with it when `UIKit` reparents it. When the walk-up dead-ends at `nil`, registration is retried once on the next run loop turn, when the mounting transaction has finished and the hierarchy is connected. Fixes #4305
Tested on the following code: ```tsx import { useNavigation } from '@react-navigation/native'; import { createNativeStackNavigator, type NativeStackNavigationProp, } from '@react-navigation/native-stack'; import React, { useState } from 'react'; import { Button, Pressable, StyleSheet, Text, View } from 'react-native'; import { GestureDetector, usePanGesture } from 'react-native-gesture-handler'; import Animated, { useAnimatedStyle, useSharedValue, withSpring, } from 'react-native-reanimated'; // Repro for https://github.com/software-mansion/react-native-gesture-handler/issues/4305 // // Ported to the v3 API (usePanGesture) with explicit `cancelsJSResponder`. // // iOS: when a Pan gesture activates inside a react-native-screens native-stack // route presented as `formSheet` (or `modal`), the in-flight JS-responder touch // of a core RN Pressable underneath is NOT cancelled — on release `onPress` // fires alongside the gesture. On a push route the pan activation cancels the // press as expected. // // How to test (iOS): // 1. Swipe the row horizontally on the home screen — the press counter must // NOT increment (control). // 2. Open the push route, swipe the row — counter must NOT increment. // 3. Open the formSheet / modal route, swipe the row — BUG: the counter // increments on release. function Row({ label }: { label: string }) { const [pressCount, setPressCount] = useState(0); const translateX = useSharedValue(0); const pan = usePanGesture({ activeOffsetX: [-12, 12], failOffsetY: [-12, 12], cancelsJSResponder: true, onUpdate: (e) => { 'worklet'; translateX.value = Math.min(0, e.translationX); }, onFinalize: () => { 'worklet'; translateX.value = withSpring(0); }, }); const animatedStyle = useAnimatedStyle(() => ({ transform: [{ translateX: translateX.value }], })); return ( {label} [styles.row, pressed && styles.rowPressed]} onPress={() => { console.log(`onPress fired (${label})`); setPressCount((c) => c + 1); }}> Swipe me left onPress fired: {pressCount} {pressCount > 0 ? '❌' : ''} ); } type StackParamList = { home: undefined; push: undefined; sheet: undefined; modal: undefined; }; function HomeScreen() { const navigation = useNavigation>(); return (
--- .../apple/RNGestureHandlerManager.mm | 68 +++++++++++++++++-- 1 file changed, 63 insertions(+), 5 deletions(-) diff --git a/packages/react-native-gesture-handler/apple/RNGestureHandlerManager.mm b/packages/react-native-gesture-handler/apple/RNGestureHandlerManager.mm index f4eb812cec..62d53b8bec 100644 --- a/packages/react-native-gesture-handler/apple/RNGestureHandlerManager.mm +++ b/packages/react-native-gesture-handler/apple/RNGestureHandlerManager.mm @@ -274,20 +274,50 @@ - (void)reattachHandlersIfNeeded #pragma mark Root Views Management +#ifdef RCT_NEW_ARCH_ENABLED +#if !TARGET_OS_OSX +static BOOL RNGHIsScreensTouchHandlerHost(RNGHUIView *view) +{ + static Class fullWindowOverlayContainerClass; + static Class screenViewClass; + static SEL isModalSelector; + static dispatch_once_t onceToken; + dispatch_once(&onceToken, ^{ + fullWindowOverlayContainerClass = NSClassFromString(@"RNSFullWindowOverlayContainer"); + screenViewClass = NSClassFromString(@"RNSScreenView"); + isModalSelector = NSSelectorFromString(@"isModal"); + }); + + if (fullWindowOverlayContainerClass != nil && [view isKindOfClass:fullWindowOverlayContainerClass]) { + return YES; + } + + if (screenViewClass != nil && [view isKindOfClass:screenViewClass]) { + // For now we consider only modals + return [view respondsToSelector:isModalSelector] && [[view valueForKey:@"isModal"] boolValue]; + } + + return NO; +} +#endif // !TARGET_OS_OSX +#endif // RCT_NEW_ARCH_ENABLED + - (void)registerViewWithGestureRecognizerAttachedIfNeeded:(RNGHUIView *)childView +{ + [self registerViewWithGestureRecognizerAttachedIfNeeded:childView isRetry:NO]; +} + +- (void)registerViewWithGestureRecognizerAttachedIfNeeded:(RNGHUIView *)childView isRetry:(BOOL)isRetry { #ifdef RCT_NEW_ARCH_ENABLED RNGHUIView *touchHandlerView = childView; #if !TARGET_OS_OSX - Class fullWindowOverlayContainerClass = NSClassFromString(@"RNSFullWindowOverlayContainer"); - if ([[childView reactViewController] isKindOfClass:[RCTFabricModalHostViewController class]]) { touchHandlerView = [childView reactViewController].view; } else { - while ( - touchHandlerView != nil && ![touchHandlerView isKindOfClass:[RCTSurfaceView class]] && - (fullWindowOverlayContainerClass == nil || ![touchHandlerView isKindOfClass:fullWindowOverlayContainerClass])) { + while (touchHandlerView != nil && ![touchHandlerView isKindOfClass:[RCTSurfaceView class]] && + !RNGHIsScreensTouchHandlerHost(touchHandlerView)) { touchHandlerView = touchHandlerView.superview; } } @@ -323,6 +353,19 @@ - (void)registerViewWithGestureRecognizerAttachedIfNeeded:(RNGHUIView *)childVie #endif // RCT_NEW_ARCH_ENABLED if (touchHandlerView == nil) { +#ifdef RCT_NEW_ARCH_ENABLED +#if !TARGET_OS_OSX + // Handlers are attached while the mounting transaction is still in progress — the view's + // ancestor chain may not be assembled yet (Fabric connects subtrees children-first), in + // which case the walk above dead-ends before reaching any touch-handling root. Retry once + // on the next run loop turn, when mounting has finished and the hierarchy is connected. + if (!isRetry) { + dispatch_async(dispatch_get_main_queue(), ^{ + [self registerViewWithGestureRecognizerAttachedIfNeeded:childView isRetry:YES]; + }); + } +#endif // !TARGET_OS_OSX +#endif // RCT_NEW_ARCH_ENABLED return; } @@ -381,6 +424,21 @@ - (void)gestureRecognizer:(UIGestureRecognizer *)gestureRecognizer break; } } + +#ifdef RCT_NEW_ARCH_ENABLED +#if !TARGET_OS_OSX + if (touchHandler == nil && [viewWithTouchHandler respondsToSelector:NSSelectorFromString(@"touchHandler")]) { + // An RNSScreenView may not have the touch handler attached directly (e.g. touches on it are + // still driven by an ancestor's touch handler) — ask react-native-screens for the one + // responsible for this screen. + id screenTouchHandler = [viewWithTouchHandler valueForKey:@"touchHandler"]; + if ([screenTouchHandler isKindOfClass:[RCTSurfaceTouchHandler class]]) { + touchHandler = screenTouchHandler; + } + } +#endif // !TARGET_OS_OSX +#endif // RCT_NEW_ARCH_ENABLED + [touchHandler setEnabled:NO]; [touchHandler setEnabled:YES]; } From e0b978561d153307668387d9e3f4b483a5221bb0 Mon Sep 17 00:00:00 2001 From: Grzegorz Karolczyk <42680223+haxonadora@users.noreply.github.com> Date: Fri, 10 Jul 2026 11:53:47 +0200 Subject: [PATCH 12/48] Fix Easing import in LongPress example (#4298) ## Description `Easing` should be imported from `react-native-reanimated` instead of `react-native`. `Easing` from `react-native` is not a worklet like the one from `react-native-reanimated`. Using `Easing` from `react-native` here throws an error `[Worklets] Tried to synchronously call a Remote Function. Called "BezierEasing" on the UI Runtime` --- .../static/examples/LongPressGestureBasic.js | 3 ++- 1 file changed, 2 insertions(+), 1 deletion(-) diff --git a/packages/docs-gesture-handler/static/examples/LongPressGestureBasic.js b/packages/docs-gesture-handler/static/examples/LongPressGestureBasic.js index 6df3275205..d88472bd25 100644 --- a/packages/docs-gesture-handler/static/examples/LongPressGestureBasic.js +++ b/packages/docs-gesture-handler/static/examples/LongPressGestureBasic.js @@ -4,8 +4,9 @@ import { GestureDetector, GestureHandlerRootView, } from 'react-native-gesture-handler'; -import { Easing, StyleSheet } from 'react-native'; +import { StyleSheet } from 'react-native'; import Animated, { + Easing, interpolateColor, useAnimatedStyle, useSharedValue, From e290b95618860af210d961d2733380eb9392c5e8 Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?Micha=C5=82=20Bert?= <63123542+m-bert@users.noreply.github.com> Date: Tue, 21 Jul 2026 08:30:37 +0200 Subject: [PATCH 13/48] [iOS] Fix gesture callback guarantees when view is detached mid-gesture (#4321) MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit On iOS, changing `zIndex` during an active gesture makes Fabric remount the view subtree, which cancels the in-flight touch. On iOS 26 the recognizer is reset to `Possible` and the pointer tracker processes the cancellation before the recognizer's cancel action fires — `reset` cleared `_lastState` too early, so the `CANCELLED` event reached JS as `UNDETERMINED` → `CANCELLED` instead of `ACTIVE` → `CANCELLED`. As a result `onDeactivate` never fired, and a stray `BEGAN` event was emitted afterwards from the reset path, breaking the documented `onBegin`/`onFinalize` and `onActivate`/`onDeactivate` guarantees. Fixes #4320
Tested on existing examples and repro from issue: ```tsx import { useEffect, useState } from 'react'; import { Pressable, Text, View } from 'react-native'; import { GestureDetector, GestureHandlerRootView, usePanGesture, } from 'react-native-gesture-handler'; import Animated, { cancelAnimation, SharedValue, useAnimatedStyle, useFrameCallback, useSharedValue, withSpring, } from 'react-native-reanimated'; import { scheduleOnRN } from 'react-native-worklets'; type BoxId = 'A' | 'B'; type EventCounts = Record>; const initialCounts: EventCounts = { A: {}, B: {}, }; function countEvent( setCounts: (updater: (prev: EventCounts) => EventCounts) => void, boxId: BoxId, eventName: string ) { setCounts((prev) => ({ ...prev, [boxId]: { ...prev[boxId], [eventName]: (prev[boxId][eventName] ?? 0) + 1, }, })); } function ReproBox({ boxId, color, top, left, frontBox, record, }: { boxId: BoxId; color: string; top: number; left: number; frontBox: SharedValue<0 | 1>; record: (boxId: BoxId, eventName: string) => void; }) { const x = useSharedValue(0); const y = useSharedValue(0); const startX = useSharedValue(0); const startY = useSharedValue(0); const scale = useSharedValue(1); const activeTouches = useSharedValue(0); const mark = (eventName: string) => { 'worklet'; scheduleOnRN(record, boxId, eventName); }; const resetVisualState = () => { 'worklet'; activeTouches.set(0); cancelAnimation(scale); scale.set(withSpring(1)); }; const finish = (eventName: string) => { 'worklet'; mark(eventName); resetVisualState(); }; const gesture = usePanGesture({ minDistance: 0, minVelocity: 0, onBegin: (event) => { mark('onBegin'); activeTouches.set(event.numberOfPointers); startX.set(x.get()); startY.set(y.get()); cancelAnimation(scale); scale.set(withSpring(1.15)); }, onActivate: () => { mark('onActivate'); }, onUpdate: (event) => { x.set(startX.get() + event.translationX); y.set(startY.get() + event.translationY); }, onTouchesDown: (event) => { mark('onTouchesDown'); activeTouches.set(event.allTouches.length); }, onTouchesMove: (event) => { activeTouches.set(event.allTouches.length); }, onTouchesUp: (event) => { mark('onTouchesUp'); activeTouches.set(event.allTouches.length); if (event.allTouches.length === 0) { resetVisualState(); } }, onTouchesCancel: () => { finish('onTouchesCancel'); }, onDeactivate: () => { finish('onDeactivate'); }, onFinalize: () => { finish('onFinalize'); }, }); const animatedStyle = useAnimatedStyle(() => { const isDragging = activeTouches.get() > 0; const isFront = frontBox.get() === (boxId === 'A' ? 0 : 1); const zIndex = isFront ? 30 : 10; return { zIndex, elevation: zIndex, transform: [ { translateX: x.get() }, { translateY: y.get() }, { scale: isDragging ? scale.get() : 1 }, ], }; }); return ( {boxId} z swaps on UI ); } function EventColumn({ boxId, counts, }: { boxId: BoxId; counts: Record; }) { const touchEndCount = (counts.onTouchesUp ?? 0) + (counts.onTouchesCancel ?? 0); return ( Box {boxId} Begin / Finalize: {counts.onBegin ?? 0} / {counts.onFinalize ?? 0} Activate / Deactivate: {counts.onActivate ?? 0} /{' '} {counts.onDeactivate ?? 0} TouchesDown / Touch end: {counts.onTouchesDown ?? 0} / {touchEndCount} TouchesUp: {counts.onTouchesUp ?? 0} TouchesCancel: {counts.onTouchesCancel ?? 0} ); } function ControlButton({ label, color, onPress, }: { label: string; color: string; onPress: () => void; }) { return ( ({ minHeight: 42, borderRadius: 8, paddingHorizontal: 14, paddingVertical: 10, backgroundColor: color, opacity: pressed ? 0.72 : 1, alignItems: 'center', justifyContent: 'center', })}> {label} ); } export default function App() { const [autoFlip, setAutoFlip] = useState(true); const [counts, setCounts] = useState(initialCounts); const frontBox = useSharedValue<0 | 1>(0); const flipElapsedMs = useSharedValue(0); const frameCallback = useFrameCallback((frameInfo) => { const deltaMs = frameInfo.timeSincePreviousFrame ?? 0; flipElapsedMs.set(flipElapsedMs.get() + deltaMs); if (flipElapsedMs.get() < 900) { return; } flipElapsedMs.set(flipElapsedMs.get() % 900); frontBox.set(frontBox.get() === 0 ? 1 : 0); }, true); useEffect(() => { frameCallback.setActive(autoFlip); }, [autoFlip, frameCallback]); const record = (boxId: BoxId, eventName: string) => { countEvent(setCounts, boxId, eventName); }; const resetCounts = () => { setCounts(initialCounts); }; const flipNow = () => { flipElapsedMs.set(0); frontBox.set(frontBox.get() === 0 ? 1 : 0); }; return ( RNGH zIndex Swap Repro Drag one box, then start dragging the other while the timer flips which native view has the higher zIndex/elevation. Watch whether the first box receives matching gesture lifecycle callbacks and matching touch lifecycle callbacks. setAutoFlip((prev) => !prev)} /> zIndex/elevation swaps on the UI thread every 900ms. ); } ```
--- .../apple/RNGestureHandler.mm | 53 ++++++++++++++++--- 1 file changed, 47 insertions(+), 6 deletions(-) diff --git a/packages/react-native-gesture-handler/apple/RNGestureHandler.mm b/packages/react-native-gesture-handler/apple/RNGestureHandler.mm index b79554fdb7..ea38a8334b 100644 --- a/packages/react-native-gesture-handler/apple/RNGestureHandler.mm +++ b/packages/react-native-gesture-handler/apple/RNGestureHandler.mm @@ -253,6 +253,24 @@ - (void)bindToView:(RNGHUIView *)view - (void)unbindFromView { + // If the gesture is still in flight - e.g. the view is being unmounted mid-gesture - deliver + // the final event now, while the recognizer is still attached and the target view is known. + // Otherwise the onBegin/onFinalize and onActivate/onDeactivate guarantees would be broken + // and `_lastState` would never be cleared by `reset`. + if (self.recognizer.view != nil && + (_lastState == RNGestureHandlerStateBegan || _lastState == RNGestureHandlerStateActive)) { + if ([self eventTagForRecognizer:self.recognizer] != nil) { + [self handleGesture:self.recognizer + inState:_lastState == RNGestureHandlerStateActive ? RNGestureHandlerStateCancelled + : RNGestureHandlerStateFailed]; + } else { + // The event has no tag to be dispatched with, so it cannot be delivered on any path - reset + // the bookkeeping so the handler doesn't stay in-flight forever. + _lastState = RNGestureHandlerStateUndetermined; + _state = RNGestureHandlerStateBegan; + } + } + [self.recognizer.view removeGestureRecognizer:self.recognizer]; self.recognizer.delegate = nil; self.viewTag = nil; @@ -314,21 +332,38 @@ - (void)handleGesture:(UIGestureRecognizer *)recognizer fromReset:(BOOL)fromRese // // While this solution is not great, we decided to check whether sending events was triggered from `reset` method. // This way we can detect double Began mapping by checking previous sent state and current state of recognizer. - if (fromReset && _lastState == RNGestureHandlerStateBegan && - self.recognizer.state == UIGestureRecognizerStatePossible) { - _state = RNGestureHandlerStateFailed; + // + // The same applies to gestures interrupted mid-flight, e.g. when the view is unmounted during an active + // gesture the recognizer may be reset without its cancel action ever firing. + // If the last sent state is not final, synthesize the missing final event so that the + // `onBegin`/`onFinalize` and `onActivate`/`onDeactivate` guarantees hold. + if (fromReset && self.recognizer.state == UIGestureRecognizerStatePossible) { + if (_lastState == RNGestureHandlerStateBegan) { + _state = RNGestureHandlerStateFailed; + } else if (_lastState == RNGestureHandlerStateActive) { + _state = RNGestureHandlerStateCancelled; + } else { + // The final event was already delivered; mapping Possible to Began here would emit a stray + // BEGAN event after the gesture has finished. + return; + } } [self handleGesture:recognizer inState:_state]; } +- (nullable NSNumber *)eventTagForRecognizer:(UIGestureRecognizer *)recognizer +{ + return [self chooseViewForInteraction:recognizer].reactTag; +} + - (void)handleGesture:(UIGestureRecognizer *)recognizer inState:(RNGestureHandlerState)state { _state = state; RNGestureHandlerEventExtraData *eventData = [self eventExtraData:recognizer]; - RNGHUIView *view = [self chooseViewForInteraction:recognizer]; + NSNumber *tag = [self eventTagForRecognizer:recognizer]; - [self sendEventsInState:self.state forViewWithTag:view.reactTag withExtraData:eventData]; + [self sendEventsInState:self.state forViewWithTag:tag withExtraData:eventData]; } - (void)sendEventsInState:(RNGestureHandlerState)state @@ -636,7 +671,13 @@ - (void)reset // might be called after some pointers are down, and after state manipulation by the user. // Pointer tracker calls this method when it resets, and in that case it no longer tracks // any pointers, thus entering this if - if (!_needsPointerData || _pointerTracker.trackedPointersCount == 0) { + // + // Also do not clear _lastState while the gesture is in flight (BEGAN/ACTIVE) - the final + // state-change event hasn't been dispatched yet. When the view is removed mid-gesture, + // the pointer tracker resets before the recognizer's cancel action fires; clearing _lastState + // here would corrupt the prevState of the outgoing CANCELLED event and break the onActivate/onDeactivate guarantee. + if ((!_needsPointerData || _pointerTracker.trackedPointersCount == 0) && _lastState != RNGestureHandlerStateBegan && + _lastState != RNGestureHandlerStateActive) { _lastState = RNGestureHandlerStateUndetermined; _state = RNGestureHandlerStateBegan; } From a723d65d10929ca347c5a562560e2b1dacd64f23 Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?Micha=C5=82=20Bert?= <63123542+m-bert@users.noreply.github.com> Date: Tue, 21 Jul 2026 16:15:18 +0200 Subject: [PATCH 14/48] Export `PressableEvent` type (#4324) As stated in [this discussion](https://github.com/software-mansion/react-native-gesture-handler/discussions/3512), `PressableEvent` is not exported. This PR adds this export.
Tested on the following code: import React from 'react'; import { StyleSheet, Text, View } from 'react-native'; import type { PressableEvent } from 'react-native-gesture-handler'; import { Pressable } from 'react-native-gesture-handler'; export default function EmptyExample() { const handlePress = (e: PressableEvent) => { console.log(e.nativeEvent.changedTouches); }; return ( { console.log(e.nativeEvent.changedTouches); }} /> ); } const styles = StyleSheet.create({ container: { flex: 1, justifyContent: 'center', alignItems: 'center', }, });
--- .../src/components/Pressable/index.ts | 1 + packages/react-native-gesture-handler/src/index.ts | 1 + 2 files changed, 2 insertions(+) diff --git a/packages/react-native-gesture-handler/src/components/Pressable/index.ts b/packages/react-native-gesture-handler/src/components/Pressable/index.ts index 79ce14a8f0..eb017f3feb 100644 --- a/packages/react-native-gesture-handler/src/components/Pressable/index.ts +++ b/packages/react-native-gesture-handler/src/components/Pressable/index.ts @@ -1,4 +1,5 @@ export type { + PressableEvent, PressableProps, PressableStateCallbackType, } from './PressableProps'; diff --git a/packages/react-native-gesture-handler/src/index.ts b/packages/react-native-gesture-handler/src/index.ts index dcf7998e59..e04f577a45 100644 --- a/packages/react-native-gesture-handler/src/index.ts +++ b/packages/react-native-gesture-handler/src/index.ts @@ -146,6 +146,7 @@ export type { export type { SwipeableProps } from './components/Swipeable'; export { default as Swipeable } from './components/Swipeable'; export type { + PressableEvent, PressableProps, PressableStateCallbackType, } from './components/Pressable'; From 1ad48b85fd4b46e2cb12411f78f7b6e29d5bda82 Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?Micha=C5=82=20Bert?= <63123542+m-bert@users.noreply.github.com> Date: Wed, 22 Jul 2026 15:05:24 +0200 Subject: [PATCH 15/48] [Web] Fix incorrectly calculated `timeDelta` (#4329) MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit ## Description Time delta in `Rotation` gesture was incorrectly calculated 😢 ## Test plan
Well, I don't think it is necessary, but repro below: ```tsx // Repro for web Rotation velocity bug: RotationGestureDetector.timeDelta // returned `currentTime + previousTime` (a sum of two absolute DOM // timestamps) instead of their difference. RotationGestureHandler divides // the rotation delta by it, so the reported velocity was orders of // magnitude too small and kept shrinking the longer the page stayed open. // // The screen logs the velocity reported on rotation events next to a // reference velocity computed on the JS side from rotation deltas and // wall-clock time (both in rad/ms). Rotate with two fingers on a touch // device, or press "Simulate rotation" on desktop web — it dispatches a // synthetic two-finger 90° twist over ~0.7 s. // // Broken: ratio ~0.0001 or less (and drifting down). Fixed: ratio ~1. import React, { useRef, useState } from 'react'; import { Platform, StyleSheet, Text, View } from 'react-native'; import { GestureDetector, GestureHandlerRootView, useRotationGesture, } from 'react-native-gesture-handler'; export default function EmptyExample() { const [log, setLog] = useState([]); const reference = useRef<{ rotation: number; time: number } | null>(null); const samples = useRef<{ reported: number; expected: number }[]>([]); // Samples are collected in refs and rendered once when the gesture ends — // a setState per update would re-render mid-gesture and reconfigure the // detector, cutting the rotation short. const rotation = useRotationGesture({ runOnJS: true, onActivate: (e) => { reference.current = { rotation: e.rotation, time: performance.now() }; samples.current = []; }, onUpdate: (e) => { const now = performance.now(); const previous = reference.current; if (!previous || now - previous.time < 30) { return; } const expected = (e.rotation - previous.rotation) / (now - previous.time); reference.current = { rotation: e.rotation, time: now }; if (Math.abs(expected) < 1e-6) { return; } samples.current.push({ reported: e.velocity, expected }); }, onFinalize: () => { const collected = samples.current; if (collected.length === 0) { return; } const mean = (values: number[]) => values.reduce((sum, value) => sum + value, 0) / values.length; const ratio = mean(collected.map((s) => s.reported)) / mean(collected.map((s) => s.expected)); setLog([ '--- rotation finished ---', ...collected .slice(-8) .map( (s) => `velocity=${s.reported.toExponential(3)} ` + `expected≈${s.expected.toExponential(3)} ` + `ratio=${(s.reported / s.expected).toFixed(4)}` ), `=== mean reported/expected ratio: ${ratio.toFixed(4)} ===`, ]); }, }); const simulateRotation = async () => { // Synthetic pointers are not "active pointers", so the browser throws // NotFoundError when the library calls setPointerCapture on them. // Swallow that in the demo — capture is irrelevant here. const globals = window as unknown as { __captureShimmed?: boolean }; if (!globals.__captureShimmed) { globals.__captureShimmed = true; for (const method of [ 'setPointerCapture', 'releasePointerCapture', ] as const) { const original = Element.prototype[method]; Element.prototype[method] = function (pointerId: number) { try { original.call(this, pointerId); } catch { // ignore NotFoundError for synthetic pointers } }; } } // GestureDetector does not forward the child ref on web — locate the // box through its testID instead. const node = document.querySelector( '[data-testid="rotationBox"]' ); if (!node) { return; } const rect = node.getBoundingClientRect(); const centerX = rect.left + rect.width / 2; const centerY = rect.top + rect.height / 2; const radius = Math.min(rect.width, rect.height) / 3; const fire = ( type: string, pointerId: number, isPrimary: boolean, angle: number, side: 1 | -1 ) => node.dispatchEvent( new PointerEvent(type, { pointerId, pointerType: 'touch', isPrimary, clientX: centerX + side * radius * Math.cos(angle), clientY: centerY + side * radius * Math.sin(angle), buttons: 1, bubbles: true, cancelable: true, }) ); const nextFrame = () => new Promise((resolve) => { requestAnimationFrame(() => resolve()); }); // Only one finger orbits while the other stays planted: one pointermove // per frame keeps consecutive event timestamps a full frame apart, like // real hardware. Moving both fingers would put two moves in the same // frame ~0.1 ms apart and make per-update velocity noisy. const totalAngle = Math.PI / 2; const steps = 40; fire('pointerdown', 101, true, 0, 1); fire('pointerdown', 102, false, 0, -1); for (let i = 1; i <= steps; i++) { await nextFrame(); fire('pointermove', 101, true, (totalAngle * i) / steps, 1); } fire('pointerup', 101, true, totalAngle, 1); fire('pointerup', 102, false, 0, -1); }; return ( Rotation velocity repro (web) 90° over ~0.7 s ≈ 2.4e-3 rad/ms.{'\n'} Broken: velocity ~1e-7, ratio ≈ 0. Fixed: ratio ≈ 1. rotate here {Platform.OS === 'web' && ( { void simulateRotation(); }}> Simulate rotation )} {log.map((entry, i) => ( {entry} ))} ); } const styles = StyleSheet.create({ container: { flex: 1, alignItems: 'center', paddingTop: 60, }, title: { fontSize: 16, fontWeight: 'bold', }, hint: { textAlign: 'center', marginVertical: 12, color: '#666', }, box: { width: 260, height: 260, backgroundColor: 'mediumpurple', borderRadius: 16, justifyContent: 'center', alignItems: 'center', }, boxLabel: { color: 'white', fontSize: 18, }, button: { marginTop: 16, paddingVertical: 8, paddingHorizontal: 20, backgroundColor: '#4630eb', color: 'white', borderRadius: 8, overflow: 'hidden', fontSize: 15, }, log: { marginTop: 20, minHeight: 220, alignSelf: 'stretch', paddingHorizontal: 24, }, logLine: { fontFamily: 'monospace', fontSize: 12, }, }); ```
--- .../src/web/detectors/RotationGestureDetector.ts | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/packages/react-native-gesture-handler/src/web/detectors/RotationGestureDetector.ts b/packages/react-native-gesture-handler/src/web/detectors/RotationGestureDetector.ts index c294d6f0c8..711f27f341 100644 --- a/packages/react-native-gesture-handler/src/web/detectors/RotationGestureDetector.ts +++ b/packages/react-native-gesture-handler/src/web/detectors/RotationGestureDetector.ts @@ -163,6 +163,6 @@ export default class RotationGestureDetector } public get timeDelta() { - return this.currentTime + this.previousTime; + return this.currentTime - this.previousTime; } } From 85152504b7ff31a788fb224a83d8c7783afe4bbb Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?Micha=C5=82=20Bert?= <63123542+m-bert@users.noreply.github.com> Date: Wed, 22 Jul 2026 15:05:35 +0200 Subject: [PATCH 16/48] [Web] Fix incorrect `Tap` offset (#4330) ## Description Fixes incorrectly calculated `Tap` offset. ## Test plan This time I don't think it is necessary --- .../src/web/handlers/TapGestureHandler.ts | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/packages/react-native-gesture-handler/src/web/handlers/TapGestureHandler.ts b/packages/react-native-gesture-handler/src/web/handlers/TapGestureHandler.ts index fce9212881..d690eca51e 100644 --- a/packages/react-native-gesture-handler/src/web/handlers/TapGestureHandler.ts +++ b/packages/react-native-gesture-handler/src/web/handlers/TapGestureHandler.ts @@ -159,7 +159,7 @@ export default class TapGestureHandler extends GestureHandler { this.tracker.removeFromTracker(event.pointerId); this.offsetX += this.lastX - this.startX; - this.offsetY += this.lastY = this.startY; + this.offsetY += this.lastY - this.startY; this.updateLastCoords(); From 58e083ce4b40f23b6135eba256aaca47c3251151 Mon Sep 17 00:00:00 2001 From: Jakub Piasecki Date: Thu, 23 Jul 2026 11:16:14 +0200 Subject: [PATCH 17/48] [Android] Guard update events to only be dispatched in ACTIVE state (#4332) 1. Removes unused `isFirstEvent` 2. Adds a guard to ensure `update` events are only dispatched in `ACTIVE` state. I've noticed that `dispatchHandlerUpdate` was being called in state `END`. I didn't observe it in the runtime, only in the debugger while working on `Touchable` optimizations. --- .../swmansion/gesturehandler/core/GestureHandlerOrchestrator.kt | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/packages/react-native-gesture-handler/android/src/main/java/com/swmansion/gesturehandler/core/GestureHandlerOrchestrator.kt b/packages/react-native-gesture-handler/android/src/main/java/com/swmansion/gesturehandler/core/GestureHandlerOrchestrator.kt index a5119fd56c..f694878ebe 100644 --- a/packages/react-native-gesture-handler/android/src/main/java/com/swmansion/gesturehandler/core/GestureHandlerOrchestrator.kt +++ b/packages/react-native-gesture-handler/android/src/main/java/com/swmansion/gesturehandler/core/GestureHandlerOrchestrator.kt @@ -330,7 +330,7 @@ class GestureHandlerOrchestrator( if (!handler.isAwaiting || action != MotionEvent.ACTION_MOVE) { val isFirstEvent = handler.state == 0 handler.handle(event, sourceEvent) - if (handler.isActive) { + if (handler.state == GestureHandler.STATE_ACTIVE && handler.isActive) { // After handler is done waiting for other one to fail its progress should be // reset, otherwise there may be a visible jump in values sent by the handler. // When handler is waiting it's already activated but the `isAwaiting` flag From 35a0723bec4d2c94f65c1dc7ae5ed0ee8473b675 Mon Sep 17 00:00:00 2001 From: Hugo Extrat Date: Fri, 24 Jul 2026 09:32:21 +0200 Subject: [PATCH 18/48] Fix fatal crash `Cannot read property 'translationX' of undefined` when a touch event is serialized without `allTouches` (#4316) MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit > [!NOTE] > This PR was written with AI assistance (Claude), based on a production crash investigated from Sentry. Fixes a fatal, unhandled production crash in apps using the v3 API (`usePanGesture` + `GestureDetector`) with Reanimated on Android (Fabric / New Architecture): ``` TypeError: Cannot read property 'translationX' of undefined at diffCalculator at getChangeEventCalculator at handleUpdateEvent at eventHandler ``` Since the error is thrown inside a worklet running on the UI thread, it results in a native `CppException` caught by the `UncaughtExceptionHandler` — a hard crash, not a JS redbox. **Observed trigger:** very fast repeated taps on a screen with a `GestureDetector` + pan gesture (reproduced in production on a low-end Android 15 device, but the underlying race is not device-specific). The failure is a chain across the Android event serialization and the v3 event classification: 1. **Kotlin — `GestureHandler.kt`**: `dispatchTouchEvent()` only guards on `changedTouchesPayload != null`. In `dispatchTouchUpEvent()`, `extractAllPointersData()` runs *before* the changed pointer is re-added to `trackedPointers`. If the tracked pointers were already cleared at that point (e.g. `cancelPointers()` fired by a rapid succession of taps racing with the touch-up dispatch), `allTouchesPayload` is `null` while `changedTouchesPayload` is still populated — so the event is dispatched anyway. 2. **Kotlin — `RNGestureHandlerTouchEvent.kt`**: the serializer omitted the key entirely when the payload was `null`: ```kotlin handler.consumeAllTouchesPayload()?.let { putArray("allTouches", it) } ``` → a touch event can reach JS **without the `allTouches` key**. Note that iOS always serializes `allTouches`/`changedTouches` as (possibly empty) arrays (`RNGestureHandlerManager.mm` initializes `.allTouches = {}`), so this was also a platform inconsistency. 3. **TS — `src/v3/hooks/utils/eventUtils.ts`**: touch events are discriminated by key presence: ```ts export function isTouchEvent(...) { 'worklet'; return 'allTouches' in event; } ``` Key absent → the touch event is **misclassified as an update event**. 4. **TS — `src/v3/hooks/callbacks/eventHandler.ts`**: the event is routed to `handleUpdateEvent()` → `getChangeEventCalculator()` reads `current.handlerData` (`undefined` for a touch event) and passes it to the gesture's `diffCalculator`, which reads `current.translationX` → `TypeError` in a UI-thread worklet → fatal crash. **JS (defensive, worklet-safe, no behavior change for valid events):** - `eventHandler()` now drops events that are neither state-change events, nor touch events, nor carry `handlerData`, instead of treating them as update events. (`handlerData` is present on all well-formed update events on every platform: Android `createNativeEventData`, web `GestureHandler.ts`, and `jestUtils`.) - `getChangeEventCalculator()` returns the event unchanged when `handlerData` is `undefined` instead of calling the diff calculator with undefined data. **Android (root cause):** - `RNGestureHandlerTouchEvent.createEventData()` always serializes `allTouches` and `changedTouches`, falling back to empty arrays instead of omitting the keys — matching the iOS implementation. - New regression test in `src/__tests__/api_v3.test.tsx`: fires a malformed touch event (no `allTouches`, no `oldState`, no `handlerData`) through a pan gesture's `jsEventHandler`. Without the JS fix it reproduces the exact production error (`Cannot read properties of undefined (reading 'translationX')` through `diffCalculator`); with the fix the event is dropped without invoking any callback, and subsequent valid update events still compute `changeX`/`changeY` correctly. - New unit tests for `getChangeEventCalculator` in `src/__tests__/utils.test.tsx`: change payload computed for well-formed events; event returned untouched when `handlerData` is missing. - Full Jest suite passes (80/80), `yarn ts-check` clean, `yarn lint:js` clean. - Android: `spotlessCheck` passes, library compiles via `apps/basic-example` (`:react-native-gesture-handler:compileDebugKotlin` — BUILD SUCCESSFUL). --------- Co-authored-by: Michał --- .../gesturehandler/react/RNGestureHandlerTouchEvent.kt | 9 ++------- 1 file changed, 2 insertions(+), 7 deletions(-) diff --git a/packages/react-native-gesture-handler/android/src/main/java/com/swmansion/gesturehandler/react/RNGestureHandlerTouchEvent.kt b/packages/react-native-gesture-handler/android/src/main/java/com/swmansion/gesturehandler/react/RNGestureHandlerTouchEvent.kt index 69876ecd48..90ff4d2c71 100644 --- a/packages/react-native-gesture-handler/android/src/main/java/com/swmansion/gesturehandler/react/RNGestureHandlerTouchEvent.kt +++ b/packages/react-native-gesture-handler/android/src/main/java/com/swmansion/gesturehandler/react/RNGestureHandlerTouchEvent.kt @@ -54,13 +54,8 @@ class RNGestureHandlerTouchEvent private constructor() : Event Date: Fri, 24 Jul 2026 16:19:29 +0200 Subject: [PATCH 19/48] Don't dispatch orphaned touch events (#4341) MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit ## Description Follow-up to #4316, fixing the root cause of the malformed touch events on the native side. `GestureHandler` on Android keeps two parallel pointer structures with different lifecycles: - `trackedPointerIDs` — which pointers belong to the gesture. Always maintained (`startTrackingPointer` runs unconditionally) and consulted by `wantsEvent`. - `trackedPointers` — per-pointer position data used to build touch events. Only maintained while `needsPointerData` is true, i.e. while touch callbacks are attached. When touch callbacks are attached **mid-gesture** — e.g. a callback attached conditionally, where the attaching re-render is triggered from `onBegin` — `needsPointerData` flips from `false` to `true` while a pointer is already down. That pointer's DOWN was never recorded into `trackedPointers`, but the UP still passes `wantsEvent`, so `dispatchTouchUpEvent()`: 1. built `allTouches` from an empty map — the `null` payload that used to reach JS without the `allTouches` key and crash the v3 update pipeline (fixed defensively in #4316), 2. reported a pointer in `changedTouches` that the JS side has never seen go down, 3. decremented `trackedPointersCount` that was never incremented for that pointer, serializing `numberOfTouches: -1` and corrupting the counter that `moveToState` uses to decide whether to dispatch `TOUCH_CANCEL` (with multi-touch this can suppress a legitimate cancel event). iOS had the same asymmetry with a different failure mode: `RNGestureHandlerPointerTracker` guards the counter (`unregisterTouch` returns `-1`, no underflow), but still dispatched the orphaned event with `id: -1` in `changedTouches`. Same for moves of an unregistered touch in `touchesMoved`. Web is not affected: its `PointerTracker` is populated unconditionally and only *sending* is gated on `needsPointerData`, so mid-gesture flips always produce well-formed events there. ## Test plan
Tested on the following code: ```tsx import React, { useState } from 'react'; import { StyleSheet, Text, View } from 'react-native'; import { GestureDetector, usePanGesture } from 'react-native-gesture-handler'; // Repro / verification screen for the orphaned-touch-event fix (follow-up to // #4316). // // Purple box ("flip"): touch callbacks are attached conditionally, so // `needsPointerData` flips false -> true mid-gesture (the onBegin re-render // attaches onTouchesUp). Press, hold ~300ms, release: // - without the fix: an orphaned onTouchesUp fires for a pointer that never // reported a down (Android: numberOfTouches -1, iOS: changed touch id -1; // on pre-#4316 builds this crashed the pan change calculator instead) // - with the fix: the orphaned up event is skipped - counter stays 0 // // Green box ("static"): touch callbacks attached from the start - the normal // path. Each tap must count down+up (counter += 2) with and without the fix. export default function OrphanedTouchRepro() { const [flipTouches, setFlipTouches] = useState(0); const [staticTouches, setStaticTouches] = useState(0); const [lastOrphan, setLastOrphan] = useState('none'); const [pressed, setPressed] = useState(false); const flipPan = usePanGesture({ onBegin: () => setPressed(true), onFinalize: () => setPressed(false), onUpdate: () => {}, runOnJS: true, onTouchesUp: pressed ? (e) => { setFlipTouches((c) => c + 1); setLastOrphan( `numberOfTouches: ${e.numberOfTouches}, ` + `changed id: ${e.changedTouches[0]?.id ?? 'none'}` ); } : undefined, }); const staticPan = usePanGesture({ onTouchesDown: () => setStaticTouches((c) => c + 1), onTouchesUp: () => setStaticTouches((c) => c + 1), onUpdate: () => {}, runOnJS: true, }); return ( flip: {flipTouches} static: {staticTouches} last orphaned up: {lastOrphan} flip: hold ~300ms (expect 0) static (expect +2 per tap) ); } const styles = StyleSheet.create({ container: { flex: 1, justifyContent: 'center', alignItems: 'center', }, counter: { fontSize: 16, marginBottom: 8, }, orphanInfo: { fontSize: 13, opacity: 0.6, marginBottom: 24, }, box: { width: 260, height: 150, borderRadius: 16, justifyContent: 'center', alignItems: 'center', marginVertical: 12, }, flipBox: { backgroundColor: 'mediumpurple', }, staticBox: { backgroundColor: 'mediumseagreen', }, boxLabel: { color: 'white', fontSize: 16, }, }); ```
--- .../gesturehandler/core/GestureHandler.kt | 7 ++- .../apple/RNGestureHandlerPointerTracker.m | 44 ++++++++++++++----- 2 files changed, 40 insertions(+), 11 deletions(-) diff --git a/packages/react-native-gesture-handler/android/src/main/java/com/swmansion/gesturehandler/core/GestureHandler.kt b/packages/react-native-gesture-handler/android/src/main/java/com/swmansion/gesturehandler/core/GestureHandler.kt index f9bde97016..5828c60e33 100644 --- a/packages/react-native-gesture-handler/android/src/main/java/com/swmansion/gesturehandler/core/GestureHandler.kt +++ b/packages/react-native-gesture-handler/android/src/main/java/com/swmansion/gesturehandler/core/GestureHandler.kt @@ -425,10 +425,15 @@ open class GestureHandler { } private fun dispatchTouchUpEvent(event: MotionEvent, sourceEvent: MotionEvent) { + val pointerId = event.getPointerId(event.actionIndex) + + if (trackedPointers[pointerId] == null) { + return + } + extractAllPointersData() changedTouchesPayload = null touchEventType = RNGestureHandlerTouchEvent.EVENT_TOUCH_UP - val pointerId = event.getPointerId(event.actionIndex) val offsetX = sourceEvent.rawX - sourceEvent.x val offsetY = sourceEvent.rawY - sourceEvent.y diff --git a/packages/react-native-gesture-handler/apple/RNGestureHandlerPointerTracker.m b/packages/react-native-gesture-handler/apple/RNGestureHandlerPointerTracker.m index 2bef5e5d40..b6c5c0be0a 100644 --- a/packages/react-native-gesture-handler/apple/RNGestureHandlerPointerTracker.m +++ b/packages/react-native-gesture-handler/apple/RNGestureHandlerPointerTracker.m @@ -115,18 +115,25 @@ - (void)touchesBegan:(NSSet *)touches withEvent:(UIEvent *)event _eventType = RNGHTouchEventTypePointerDown; NSDictionary *data[touches.count]; + int changedCount = 0; for (int i = 0; i < [touches count]; i++) { RNGHUITouch *touch = [[touches allObjects] objectAtIndex:i]; int index = [self registerTouch:touch]; - if (index >= 0) { - _trackedPointersCount++; + + if (index < 0) { + continue; } - data[i] = [self extractPointerData:index forTouch:touch]; + _trackedPointersCount++; + data[changedCount++] = [self extractPointerData:index forTouch:touch]; + } + + if (changedCount == 0) { + return; } - _changedPointersData = [[NSArray alloc] initWithObjects:data count:[touches count]]; + _changedPointersData = [[NSArray alloc] initWithObjects:data count:changedCount]; // extract all touches last to include the ones that were just added [self extractAllTouches]; [self sendEvent]; @@ -141,14 +148,24 @@ - (void)touchesMoved:(NSSet *)touches withEvent:(UIEvent *)event _eventType = RNGHTouchEventTypePointerMove; NSDictionary *data[touches.count]; + int changedCount = 0; for (int i = 0; i < [touches count]; i++) { RNGHUITouch *touch = [[touches allObjects] objectAtIndex:i]; int index = [self findTouchIndex:touch]; - data[i] = [self extractPointerData:index forTouch:touch]; + + if (index < 0) { + continue; + } + + data[changedCount++] = [self extractPointerData:index forTouch:touch]; } - _changedPointersData = [[NSArray alloc] initWithObjects:data count:[touches count]]; + if (changedCount == 0) { + return; + } + + _changedPointersData = [[NSArray alloc] initWithObjects:data count:changedCount]; [self extractAllTouches]; [self sendEvent]; } @@ -165,18 +182,25 @@ - (void)touchesEnded:(NSSet *)touches withEvent:(UIEvent *)event _eventType = RNGHTouchEventTypePointerUp; NSDictionary *data[touches.count]; + int changedCount = 0; for (int i = 0; i < [touches count]; i++) { RNGHUITouch *touch = [[touches allObjects] objectAtIndex:i]; int index = [self unregisterTouch:touch]; - if (index >= 0) { - _trackedPointersCount--; + + if (index < 0) { + continue; } - data[i] = [self extractPointerData:index forTouch:touch]; + _trackedPointersCount--; + data[changedCount++] = [self extractPointerData:index forTouch:touch]; + } + + if (changedCount == 0) { + return; } - _changedPointersData = [[NSArray alloc] initWithObjects:data count:[touches count]]; + _changedPointersData = [[NSArray alloc] initWithObjects:data count:changedCount]; [self sendEvent]; } From 3fc18b4bc7383556236d3f4219ae77e3133d4d30 Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?Micha=C5=82=20Bert?= <63123542+m-bert@users.noreply.github.com> Date: Mon, 27 Jul 2026 11:00:40 +0200 Subject: [PATCH 20/48] Fix `minVelocity` props behavior (#4327) MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit `Pan`'s velocity-based activation criteria (`minVelocity`, `minVelocityX`, `minVelocityY`) had three long-standing problems: 1. **Android: `minVelocityY` tested the horizontal velocity.** `shouldActivate()` declared `val vy = velocityY` but compared `vx` in both comparisons of the Y branch, so a pan configured with only `minVelocityY` activated on fast horizontal movement and never on vertical movement. The typo predates the 2022 repo restructure (#2270). 2. **All platforms: per-axis thresholds were compared with sign.** A positive `minVelocityY` only activated on downward movement, negative only on upward. This is surprising — `minVelocityY: 100` reads as "vertical speed of at least 100", not "drag down". The signed logic appears to have been copied from the offset-range checks (`activeOffsetX/Y`), where signed semantics actually make sense. The checks now compare absolute values: `abs(velocity) >= abs(threshold)`, so movement in either direction along the axis activates. (`minVelocity` already compared the velocity vector magnitude and is unchanged.) 3. **Web: `minVelocity` meant something different than on native.** It was mapped onto the per-axis X/Y thresholds ("either axis exceeds the value") instead of the vector magnitude like Android and iOS compute it. The `minVelocitySq` field existed for this but was never assigned from the config. It is now wired up, matching native behavior (e.g. a diagonal drag at 600 pt/s with `minVelocity: 500` now activates on web as it does on native). None of these props were documented anywhere. This PR adds them to the docs (new API + 2.x pages) and to the JSDoc of all three API layers, described as speed "expressed in points per second". > [!WARNING] > I've used [`grep.app`](https://grep.app/) to check whether these props are used and seems that they're not, so it _should be safe_ to introduce these changes.
Tested on the following code: ```tsx // Repro for Pan minVelocityY issues: // 1. Android compared vx (horizontal velocity) in the minVelocityY branch, // so a fast horizontal swipe wrongly activated the pan. // 2. All platforms compared the velocity with its sign, so a positive // minVelocityY only activated on downward movement. The checks now use // absolute values: either direction along the axis activates. // // Setup: minDistance is set huge so distance can never activate the pan — // only the velocity criteria can. Expected: swiping DOWN or UP faster than // 500 activates; a fast horizontal swipe never does. import React, { useState } from 'react'; import { StyleSheet, Text, View } from 'react-native'; import { GestureDetector, GestureHandlerRootView, usePanGesture, } from 'react-native-gesture-handler'; export default function EmptyExample() { const [log, setLog] = useState([]); const append = (entry: string) => setLog((prev) => [...prev.slice(-8), entry]); const pan = usePanGesture({ minVelocityY: 800, runOnJS: true, onActivate: (e) => { append( `ACTIVATED vx=${Math.round(e.velocityX)} vy=${Math.round(e.velocityY)}` ); }, onFinalize: (e) => { if (e.canceled) { append('finished without activation'); } }, }); return ( minVelocityY = 500, minDistance = 10000 Fast horizontal swipe should NOT activate.{'\n'} Fast vertical swipe (up OR down) SHOULD activate. swipe here {log.map((entry, i) => ( {entry} ))} ); } const styles = StyleSheet.create({ container: { flex: 1, alignItems: 'center', paddingTop: 60, }, title: { fontSize: 16, fontWeight: 'bold', }, hint: { textAlign: 'center', marginVertical: 12, color: '#666', }, box: { width: 300, height: 300, backgroundColor: 'tomato', borderRadius: 16, justifyContent: 'center', alignItems: 'center', }, boxLabel: { color: 'white', fontSize: 18, }, log: { marginTop: 20, minHeight: 200, alignSelf: 'stretch', paddingHorizontal: 24, }, logLine: { fontFamily: 'monospace', fontSize: 13, }, }); ```
--- .../docs/gesture-handlers/pan-gh.md | 12 ++++++++++++ .../docs/gestures/pan-gesture.md | 12 ++++++++++++ .../gesturehandler/core/PanGestureHandler.kt | 9 +++------ .../apple/Handlers/RNPanHandler.m | 4 ++-- .../apple/RNGestureHandler.h | 2 ++ .../src/handlers/PanGestureHandler.ts | 15 +++++++++++++++ .../src/handlers/gestures/panGesture.ts | 9 ++++++--- .../src/web/handlers/PanGestureHandler.ts | 9 +++------ 8 files changed, 55 insertions(+), 17 deletions(-) diff --git a/packages/docs-gesture-handler/docs/gesture-handlers/pan-gh.md b/packages/docs-gesture-handler/docs/gesture-handlers/pan-gh.md index d70c80cdd0..923e39edbc 100644 --- a/packages/docs-gesture-handler/docs/gesture-handlers/pan-gh.md +++ b/packages/docs-gesture-handler/docs/gesture-handlers/pan-gh.md @@ -52,6 +52,18 @@ See [set of properties inherited from base handler class](/docs/gesture-handlers Minimum distance the finger (or multiple finger) need to travel before the handler [activates](/docs/under-the-hood/state#active). Expressed in points. +### `minVelocity` + +Minimum speed the pointer has to reach in order for the handler to [activate](/docs/2.x/under-the-hood/state#active). Expressed in points per second. + +### `minVelocityX` + +Minimum speed along X axis the pointer has to reach in order for the handler to [activate](/docs/2.x/under-the-hood/state#active). Expressed in points per second. + +### `minVelocityY` + +Minimum speed along Y axis the pointer has to reach in order for the handler to [activate](/docs/2.x/under-the-hood/state#active). Expressed in points per second. + ### `minPointers` A number of fingers that is required to be placed before handler can [activate](/docs/under-the-hood/state#active). Should be a higher or equal to 0 integer. diff --git a/packages/docs-gesture-handler/docs/gestures/pan-gesture.md b/packages/docs-gesture-handler/docs/gestures/pan-gesture.md index 0e8f16ad33..456c7f5fe2 100644 --- a/packages/docs-gesture-handler/docs/gestures/pan-gesture.md +++ b/packages/docs-gesture-handler/docs/gestures/pan-gesture.md @@ -130,6 +130,18 @@ If you wish to track the "center of mass" virtual pointer and account for its ch Minimum distance the finger (or multiple finger) need to travel before the gesture [activates](/docs/fundamentals/states-events#active). Expressed in points. +### `minVelocity(value: number)` + +Minimum speed the pointer has to reach in order for the gesture to [activate](/docs/2.x/fundamentals/states-events#active). Expressed in points per second. + +### `minVelocityX(value: number)` + +Minimum speed along X axis the pointer has to reach in order for the gesture to [activate](/docs/2.x/fundamentals/states-events#active). Expressed in points per second. + +### `minVelocityY(value: number)` + +Minimum speed along Y axis the pointer has to reach in order for the gesture to [activate](/docs/2.x/fundamentals/states-events#active). Expressed in points per second. + ### `minPointers(value: number)` A number of fingers that is required to be placed before gesture can [activate](/docs/fundamentals/states-events#active). Should be a higher or equal to 0 integer. diff --git a/packages/react-native-gesture-handler/android/src/main/java/com/swmansion/gesturehandler/core/PanGestureHandler.kt b/packages/react-native-gesture-handler/android/src/main/java/com/swmansion/gesturehandler/core/PanGestureHandler.kt index 6ac1f4809b..02767d50ae 100644 --- a/packages/react-native-gesture-handler/android/src/main/java/com/swmansion/gesturehandler/core/PanGestureHandler.kt +++ b/packages/react-native-gesture-handler/android/src/main/java/com/swmansion/gesturehandler/core/PanGestureHandler.kt @@ -11,6 +11,7 @@ import com.facebook.react.uimanager.PixelUtil import com.swmansion.gesturehandler.core.GestureUtils.getLastPointerX import com.swmansion.gesturehandler.core.GestureUtils.getLastPointerY import com.swmansion.gesturehandler.react.eventbuilders.PanGestureHandlerEventDataBuilder +import kotlin.math.abs class PanGestureHandler(context: Context?) : GestureHandler() { var velocityX = 0f @@ -113,15 +114,11 @@ class PanGestureHandler(context: Context?) : GestureHandler() { return true } val vx = velocityX - if (minVelocityX != MIN_VALUE_IGNORE && - (minVelocityX < 0 && vx <= minVelocityX || minVelocityX in 0.0f..vx) - ) { + if (minVelocityX != MIN_VALUE_IGNORE && abs(vx) >= abs(minVelocityX)) { return true } val vy = velocityY - if (minVelocityY != MIN_VALUE_IGNORE && - (minVelocityY < 0 && vx <= minVelocityY || minVelocityY in 0.0f..vx) - ) { + if (minVelocityY != MIN_VALUE_IGNORE && abs(vy) >= abs(minVelocityY)) { return true } val velocitySq = vx * vx + vy * vy diff --git a/packages/react-native-gesture-handler/apple/Handlers/RNPanHandler.m b/packages/react-native-gesture-handler/apple/Handlers/RNPanHandler.m index 361bf4b592..0aa631cf34 100644 --- a/packages/react-native-gesture-handler/apple/Handlers/RNPanHandler.m +++ b/packages/react-native-gesture-handler/apple/Handlers/RNPanHandler.m @@ -333,10 +333,10 @@ - (BOOL)shouldActivateUnderCustomCriteria } CGPoint velocity = [self velocityInView:self.view]; - if (TEST_MIN_IF_NOT_NAN(velocity.x, _minVelocityX)) { + if (TEST_ABS_MIN_IF_NOT_NAN(velocity.x, _minVelocityX)) { return YES; } - if (TEST_MIN_IF_NOT_NAN(velocity.y, _minVelocityY)) { + if (TEST_ABS_MIN_IF_NOT_NAN(velocity.y, _minVelocityY)) { return YES; } if (TEST_MIN_IF_NOT_NAN(VEC_LEN_SQ(velocity), _minVelocitySq)) { diff --git a/packages/react-native-gesture-handler/apple/RNGestureHandler.h b/packages/react-native-gesture-handler/apple/RNGestureHandler.h index 4f11824a2f..c7fff09461 100644 --- a/packages/react-native-gesture-handler/apple/RNGestureHandler.h +++ b/packages/react-native-gesture-handler/apple/RNGestureHandler.h @@ -14,6 +14,8 @@ #define TEST_MIN_IF_NOT_NAN(value, limit) \ (!isnan(limit) && ((limit < 0 && value <= limit) || (limit >= 0 && value >= limit))) +#define TEST_ABS_MIN_IF_NOT_NAN(value, limit) (!isnan(limit) && fabs(value) >= fabs(limit)) + #define TEST_MAX_IF_NOT_NAN(value, max) (!isnan(max) && ((max < 0 && value < max) || (max >= 0 && value > max))) #define APPLY_PROP(recognizer, config, type, prop, propName) \ diff --git a/packages/react-native-gesture-handler/src/handlers/PanGestureHandler.ts b/packages/react-native-gesture-handler/src/handlers/PanGestureHandler.ts index 53ad17e589..b3c78dd2b4 100644 --- a/packages/react-native-gesture-handler/src/handlers/PanGestureHandler.ts +++ b/packages/react-native-gesture-handler/src/handlers/PanGestureHandler.ts @@ -65,9 +65,24 @@ interface CommonPanProperties { */ maxPointers?: number; + /** + * Minimum speed the pointer has to reach in order to activate the handler. + * Expressed in points per second. + */ minVelocity?: number; + + /** + * Minimum speed along X axis the pointer has to reach in order to activate + * the handler. Expressed in points per second. + */ minVelocityX?: number; + + /** + * Minimum speed along Y axis the pointer has to reach in order to activate + * the handler. Expressed in points per second. + */ minVelocityY?: number; + activateAfterLongPress?: number; } diff --git a/packages/react-native-gesture-handler/src/handlers/gestures/panGesture.ts b/packages/react-native-gesture-handler/src/handlers/gestures/panGesture.ts index 366a2725a0..7b517d8b96 100644 --- a/packages/react-native-gesture-handler/src/handlers/gestures/panGesture.ts +++ b/packages/react-native-gesture-handler/src/handlers/gestures/panGesture.ts @@ -147,7 +147,8 @@ export class PanGesture extends ContinousBaseGesture< } /** - * Minimum velocity the finger has to reach in order to activate handler. + * Minimum speed the pointer has to reach in order to activate handler. + * Expressed in points per second. * @param velocity */ minVelocity(velocity: number) { @@ -156,7 +157,8 @@ export class PanGesture extends ContinousBaseGesture< } /** - * Minimum velocity along X axis the finger has to reach in order to activate handler. + * Minimum speed along X axis the pointer has to reach in order to activate handler. + * Expressed in points per second. * @param velocity */ minVelocityX(velocity: number) { @@ -165,7 +167,8 @@ export class PanGesture extends ContinousBaseGesture< } /** - * Minimum velocity along Y axis the finger has to reach in order to activate handler. + * Minimum speed along Y axis the pointer has to reach in order to activate handler. + * Expressed in points per second. * @param velocity */ minVelocityY(velocity: number) { diff --git a/packages/react-native-gesture-handler/src/web/handlers/PanGestureHandler.ts b/packages/react-native-gesture-handler/src/web/handlers/PanGestureHandler.ts index e812bf6a7b..8c7debac9e 100644 --- a/packages/react-native-gesture-handler/src/web/handlers/PanGestureHandler.ts +++ b/packages/react-native-gesture-handler/src/web/handlers/PanGestureHandler.ts @@ -82,8 +82,7 @@ export default class PanGestureHandler extends GestureHandler { } if (this.config.minVelocity !== undefined) { - this.minVelocityX = this.config.minVelocity; - this.minVelocityY = this.config.minVelocity; + this.minVelocitySq = this.config.minVelocity * this.config.minVelocity; } if (this.config.minVelocityX !== undefined) { @@ -452,8 +451,7 @@ export default class PanGestureHandler extends GestureHandler { if ( this.minVelocityX !== Number.MAX_SAFE_INTEGER && - ((this.minVelocityX < 0 && vx <= this.minVelocityX) || - (this.minVelocityX >= 0 && this.minVelocityX <= vx)) + Math.abs(vx) >= Math.abs(this.minVelocityX) ) { return true; } @@ -461,8 +459,7 @@ export default class PanGestureHandler extends GestureHandler { const vy: number = this.velocityY; if ( this.minVelocityY !== Number.MAX_SAFE_INTEGER && - ((this.minVelocityY < 0 && vy <= this.minVelocityY) || - (this.minVelocityY >= 0 && this.minVelocityY <= vy)) + Math.abs(vy) >= Math.abs(this.minVelocityY) ) { return true; } From 3892be4ea9c283943551db4f5bafdb8c406c50d5 Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?Micha=C5=82=20Bert?= <63123542+m-bert@users.noreply.github.com> Date: Tue, 28 Jul 2026 11:10:49 +0200 Subject: [PATCH 21/48] [iOS] Check if `enableTrackpadTwoFingerGesture` is `nil` before applying (#4349) On `iOS`, `enableTrackpadTwoFingerGesture` was read in `RNPanGestureHandler`'s `updateConfig:` without checking whether the key is present in the config, and `allowedScrollTypesMask` was only ever set when the value was truthy. This PR changes it to match other props behavior. Checked that basic-example builds correctly --- .../apple/Handlers/RNPanHandler.m | 6 +++--- 1 file changed, 3 insertions(+), 3 deletions(-) diff --git a/packages/react-native-gesture-handler/apple/Handlers/RNPanHandler.m b/packages/react-native-gesture-handler/apple/Handlers/RNPanHandler.m index 0aa631cf34..53a812af0e 100644 --- a/packages/react-native-gesture-handler/apple/Handlers/RNPanHandler.m +++ b/packages/react-native-gesture-handler/apple/Handlers/RNPanHandler.m @@ -405,9 +405,9 @@ - (void)configure:(NSDictionary *)config #if !TARGET_OS_OSX && !TARGET_OS_TV && __IPHONE_OS_VERSION_MAX_ALLOWED >= 130400 if (@available(iOS 13.4, *)) { - bool enableTrackpadTwoFingerGesture = [RCTConvert BOOL:config[@"enableTrackpadTwoFingerGesture"]]; - if (enableTrackpadTwoFingerGesture) { - recognizer.allowedScrollTypesMask = UIScrollTypeMaskAll; + id enableTrackpadTwoFingerGesture = config[@"enableTrackpadTwoFingerGesture"]; + if (enableTrackpadTwoFingerGesture != nil) { + recognizer.allowedScrollTypesMask = [RCTConvert BOOL:enableTrackpadTwoFingerGesture] ? UIScrollTypeMaskAll : 0; } } From 4919e24c18a87ede358a7e834b99a609f0e69b58 Mon Sep 17 00:00:00 2001 From: Jakub Piasecki Date: Tue, 4 Aug 2026 08:47:30 +0200 Subject: [PATCH 22/48] [Android] Fix basic-example `hermesc` path (#4371) ## Description I've noticed that release builds of basic example fail on android due to the wrong path of hermesc set. `hermesc` path is resolved with the working dir set to the app directory and React Native will automatically select the right [OS-specific path](https://github.com/react/react-native/blob/63e9c1544834a43b1b44af4033184b1eab6f2ffa/packages/gradle-plugin/react-native-gradle-plugin/src/main/kotlin/com/facebook/react/utils/PathUtils.kt#L137-L139). ## Test plan Build the app in release mode or run `./gradlew :app:createBundleReleaseJsAndAssets` before & after this change. --- apps/basic-example/android/app/build.gradle | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/apps/basic-example/android/app/build.gradle b/apps/basic-example/android/app/build.gradle index fb0f30240c..66d925ab91 100644 --- a/apps/basic-example/android/app/build.gradle +++ b/apps/basic-example/android/app/build.gradle @@ -45,7 +45,7 @@ react { /* Hermes Commands */ // The hermes compiler command to run. By default it is 'hermesc' - hermesCommand = "../../../../node_modules/react-native/sdks/hermesc/osx-bin/hermesc" + hermesCommand = "../../node_modules/hermes-compiler/hermesc/%OS-BIN%/hermesc" // // The list of flags to pass to the Hermes compiler. By default is "-O", "-output-source-map" // hermesFlags = ["-O", "-output-source-map"] From fc2c566d6d038339f1f7c4fa5ffa3ad2c9284d97 Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?Micha=C5=82=20Bert?= <63123542+m-bert@users.noreply.github.com> Date: Thu, 6 Aug 2026 10:56:06 +0200 Subject: [PATCH 23/48] [Apple] Remove trailing semicolons from `RNGHGestureRecognizerState*` macros (#4391) ## Description The `RNGHGestureRecognizerState*` compatibility macros in `RNGHUIKit.h` carried a trailing semicolon in their definitions, e.g. `#define RNGHGestureRecognizerStateFailed UIGestureRecognizerStateFailed;`. Any expression-context usage, such as `if (state == RNGHGestureRecognizerStateFailed)`, expands to `if (state == UIGestureRecognizerStateFailed;)` and fails to compile with a confusing `unexpected ';' before ')'` error. Removing the semicolons makes the macros usable in any context; existing call sites are unaffected. ## Test plan - Verified the failure mode before the fix: an expression-context usage added to `RNGestureHandlerModule.mm` fails to compile, with clang's note pointing at the semicolon in the macro definition; - Verified after the fix: the same expression-context usage compiles, and the existing statement usages behave identically (single semicolon instead of a double one after expansion). --- .../apple/RNGHUIKit.h | 20 +++++++++---------- 1 file changed, 10 insertions(+), 10 deletions(-) diff --git a/packages/react-native-gesture-handler/apple/RNGHUIKit.h b/packages/react-native-gesture-handler/apple/RNGHUIKit.h index dc0b742ca2..9ebefd5d14 100644 --- a/packages/react-native-gesture-handler/apple/RNGHUIKit.h +++ b/packages/react-native-gesture-handler/apple/RNGHUIKit.h @@ -6,11 +6,11 @@ typedef UIView RNGHUIView; typedef UITouch RNGHUITouch; typedef UIScrollView RNGHUIScrollView; -#define RNGHGestureRecognizerStateFailed UIGestureRecognizerStateFailed; -#define RNGHGestureRecognizerStatePossible UIGestureRecognizerStatePossible; -#define RNGHGestureRecognizerStateCancelled UIGestureRecognizerStateCancelled; -#define RNGHGestureRecognizerStateBegan UIGestureRecognizerStateBegan; -#define RNGHGestureRecognizerStateEnded UIGestureRecognizerStateEnded; +#define RNGHGestureRecognizerStateFailed UIGestureRecognizerStateFailed +#define RNGHGestureRecognizerStatePossible UIGestureRecognizerStatePossible +#define RNGHGestureRecognizerStateCancelled UIGestureRecognizerStateCancelled +#define RNGHGestureRecognizerStateBegan UIGestureRecognizerStateBegan +#define RNGHGestureRecognizerStateEnded UIGestureRecognizerStateEnded #else // TARGET_OS_OSX [ @@ -20,10 +20,10 @@ typedef RCTUIView RNGHUIView; typedef RCTUITouch RNGHUITouch; typedef NSScrollView RNGHUIScrollView; -#define RNGHGestureRecognizerStateFailed NSGestureRecognizerStateFailed; -#define RNGHGestureRecognizerStatePossible NSGestureRecognizerStatePossible; -#define RNGHGestureRecognizerStateCancelled NSGestureRecognizerStateCancelled; -#define RNGHGestureRecognizerStateBegan NSGestureRecognizerStateBegan; -#define RNGHGestureRecognizerStateEnded NSGestureRecognizerStateEnded; +#define RNGHGestureRecognizerStateFailed NSGestureRecognizerStateFailed +#define RNGHGestureRecognizerStatePossible NSGestureRecognizerStatePossible +#define RNGHGestureRecognizerStateCancelled NSGestureRecognizerStateCancelled +#define RNGHGestureRecognizerStateBegan NSGestureRecognizerStateBegan +#define RNGHGestureRecognizerStateEnded NSGestureRecognizerStateEnded #endif // ] TARGET_OS_OSX From 362c68f5fcc29b0b46360ecd893287f359ed41aa Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?Micha=C5=82=20Bert?= <63123542+m-bert@users.noreply.github.com> Date: Thu, 6 Aug 2026 11:01:44 +0200 Subject: [PATCH 24/48] [macOS] Fix touch events never being delivered (#4390) MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit ## Description `onTouchesMove` and `onTouchesUp` callbacks never fired on macOS — a gesture produced a single `onTouchesDown` and then a cancel sweep when the recognizer reset, never a move or up event. Found while testing the `manualActivation` fix (#4389), which this bug masks entirely in its real-world form (activating from `onTouchesMove`). `RNGestureHandlerPointerTracker` tracks pointers by storing the touch object at registration and matching subsequent events by object identity (`_trackedPointers[index] == touch`). That's correct on iOS, where a `UITouch` is one stable object for the whole lifetime of a touch. On macOS the recognizers forward `NSEvent`s, and every event in a mouse sequence is a fresh instance — so `findTouchIndex:` / `unregisterTouch:` never matched, move/up events were dropped with `changedCount == 0`, and the registered pointer leaked until the tracker's `reset` swept it out via `cancelPointers` (which is why JS saw down → cancel instead of down → moves → up). Since macOS has exactly one mouse pointer, the tracker now matches the tracked slot itself on macOS instead of comparing object identity, and `touchesMoved` replaces the stored event with the latest one so `extractAllTouches` reports the pointer's current position rather than where the sequence started. ## Test plan
Tested on the following code: ```tsx import React, { useEffect } from 'react'; import { StyleSheet, Text, View } from 'react-native'; import { GestureDetector, GestureStateManager, usePanGesture, } from 'react-native-gesture-handler'; import { useSharedValue } from 'react-native-reanimated'; export default function EmptyExample() { // Box A: manualActivation, JS never calls activate(). // Expected: down -> move stream -> up, onFinalize canceled=true, never onActivate. const neverActivatePan = usePanGesture({ manualActivation: true, onBegin: () => console.log('[never-activate] onBegin'), onActivate: () => console.log('[never-activate] onActivate (SHOULD NOT HAPPEN)'), onTouchesDown: () => console.log('[never-activate] onTouchesDown'), onTouchesMove: () => console.log('[never-activate] onTouchesMove'), onTouchesUp: () => console.log('[never-activate] onTouchesUp'), onDeactivate: () => console.log('[never-activate] onDeactivate'), onFinalize: (e) => console.log(`[never-activate] onFinalize canceled=${e.canceled}`), }); // Box B: manualActivation, activates from onTouchesMove — the real-world pattern. // Expected: onActivate on first movement (tx ~0), onUpdate while dragging, // onTouchesUp + onDeactivate + onFinalize canceled=false on release. const selfTag = useSharedValue(-1); const selfActivatePan = usePanGesture({ manualActivation: true, onTouchesDown: () => console.log(`[self-activate] onTouchesDown tag=${selfTag.value}`), onTouchesMove: () => { console.log(`[self-activate] onTouchesMove tag=${selfTag.value}`); if (selfTag.value !== -1) { GestureStateManager.activate(selfTag.value); } }, onTouchesUp: () => console.log('[self-activate] onTouchesUp'), onBegin: () => console.log('[self-activate] onBegin'), onActivate: (e) => console.log(`[self-activate] onActivate tx=${e.translationX.toFixed(1)} ty=${e.translationY.toFixed(1)}`), onUpdate: (e) => console.log(`[self-activate] onUpdate tx=${e.translationX.toFixed(1)} ty=${e.translationY.toFixed(1)}`), onDeactivate: () => console.log('[self-activate] onDeactivate'), onFinalize: (e) => console.log(`[self-activate] onFinalize canceled=${e.canceled}`), }); useEffect(() => { selfTag.value = selfActivatePan.handlerTag; }, [selfActivatePan.handlerTag, selfTag]); return ( A: manualActivation, never activated — click & drag, release B: manualActivation, self-activates on touch move — drag ); } const styles = StyleSheet.create({ container: { flex: 1, justifyContent: 'center', alignItems: 'center', gap: 8 }, label: { marginTop: 16, fontSize: 15, opacity: 0.6 }, box: { width: 150, height: 150, borderRadius: 12 }, }); ```
--- .../apple/RNGestureHandlerPointerTracker.m | 30 +++++++++++++++++++ 1 file changed, 30 insertions(+) diff --git a/packages/react-native-gesture-handler/apple/RNGestureHandlerPointerTracker.m b/packages/react-native-gesture-handler/apple/RNGestureHandlerPointerTracker.m index b6c5c0be0a..c9eea3c079 100644 --- a/packages/react-native-gesture-handler/apple/RNGestureHandlerPointerTracker.m +++ b/packages/react-native-gesture-handler/apple/RNGestureHandlerPointerTracker.m @@ -38,7 +38,14 @@ - (int)registerTouch:(RNGHUITouch *)touch - (int)unregisterTouch:(RNGHUITouch *)touch { for (int index = 0; index < MAX_POINTERS_COUNT; index++) { +#if TARGET_OS_OSX + // A macOS mouse sequence delivers a fresh NSEvent per event, so identity matching + // (valid on iOS, where a UITouch is one stable object for the whole touch) never + // matches. There is only one mouse pointer — match the tracked slot instead. + if (_trackedPointers[index] != nil) { +#else if (_trackedPointers[index] == touch) { +#endif _trackedPointers[index] = nil; return index; } @@ -50,7 +57,12 @@ - (int)unregisterTouch:(RNGHUITouch *)touch - (int)findTouchIndex:(RNGHUITouch *)touch { for (int index = 0; index < MAX_POINTERS_COUNT; index++) { +#if TARGET_OS_OSX + // See unregisterTouch: — identity matching cannot work for NSEvents. + if (_trackedPointers[index] != nil) { +#else if (_trackedPointers[index] == touch) { +#endif return index; } } @@ -158,6 +170,12 @@ - (void)touchesMoved:(NSSet *)touches withEvent:(UIEvent *)event continue; } +#if TARGET_OS_OSX + // Replace the stored mouse-down event with the latest one so extractAllTouches + // reads the pointer's current position, not where the sequence started. + _trackedPointers[index] = touch; +#endif + data[changedCount++] = [self extractPointerData:index forTouch:touch]; } @@ -176,6 +194,18 @@ - (void)touchesEnded:(NSSet *)touches withEvent:(UIEvent *)event return; } +#if TARGET_OS_OSX + // Refresh the tracked slot with the ending event so extractAllTouches below reports + // the pointer's final position. On iOS the stored UITouch is a live object that + // always reads its current location; a stored NSEvent is a stale snapshot. + for (RNGHUITouch *touch in touches) { + int index = [self findTouchIndex:touch]; + if (index >= 0) { + _trackedPointers[index] = touch; + } + } +#endif + // extract all touches first to include the ones that were just lifted [self extractAllTouches]; From 4c7865c8d4fdb348088e86856780e13517c8f924 Mon Sep 17 00:00:00 2001 From: Jakub Kosmydel <104823336+kosmydel@users.noreply.github.com> Date: Thu, 6 Aug 2026 11:18:55 +0200 Subject: [PATCH 25/48] fix: crash when mount listener fires after GestureDetector unmount (#4268) MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit ## Description `useMountReactions` registers a `MountRegistry` listener in `useEffect`. That listener can fire after the owning `GestureDetector` has already unmounted (e.g. a related gesture mounts while this detector is gone). Calling `updateDetector` in that case updates a detached detector and can crash (occasionally in `findNodeHandle`). Guard with the existing `state.isMounted` flag — the same pattern `attachHandlers` already uses for the microtask after unmount. Credit to @hannojg for the original investigation/fix direction. ## Changes - In `useMountReactions`, return early from the mount listener when `!state.isMounted`. ## Why not `useLayoutEffect`? An earlier version of this PR also moved the subscription to `useIsomorphicLayoutEffect` (same phase as GestureDetector attach/drop). We A/B tested `useLayoutEffect` vs stock `useEffect`, both with this early return. Crash rates were equivalent; the `isMounted` check alone is sufficient, so this PR keeps `useEffect`. --- .../handlers/gestures/GestureDetector/useMountReactions.ts | 6 ++++++ 1 file changed, 6 insertions(+) diff --git a/packages/react-native-gesture-handler/src/handlers/gestures/GestureDetector/useMountReactions.ts b/packages/react-native-gesture-handler/src/handlers/gestures/GestureDetector/useMountReactions.ts index 61190d29a9..ff4628f24f 100644 --- a/packages/react-native-gesture-handler/src/handlers/gestures/GestureDetector/useMountReactions.ts +++ b/packages/react-native-gesture-handler/src/handlers/gestures/GestureDetector/useMountReactions.ts @@ -27,6 +27,12 @@ export function useMountReactions( ) { useEffect(() => { return MountRegistry.addMountListener((gesture) => { + // The detector may already be unmounted when this fires; bail out to avoid + // updating a detached detector. + if (!state.isMounted) { + return; + } + // At this point the ref in the gesture config should be updated, so we can check if one of the gestures // set in a relation with the gesture got mounted. If so, we need to update the detector to propagate // the changes to the native side. From 4ed49fa450676d878659577ad2525314cfa4e7e5 Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?Micha=C5=82=20Bert?= <63123542+m-bert@users.noreply.github.com> Date: Thu, 6 Aug 2026 11:56:47 +0200 Subject: [PATCH 26/48] [macOS] Fix `Pan` activation criteria being ignored (#4387) MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit On macOS, all of `Pan`'s custom activation criteria — `minDistance`, `activeOffsetX/Y`, `minVelocity(X/Y)` — were silently ignored: the handler activated the moment the mouse button went down. Two things combined to cause this: 1. Both custom-activation code paths in `RNPanHandler.m` were compiled out for macOS. The early-activation guard in `interactionsBegan` and the `shouldActivateUnderCustomCriteria` check in `interactionsMoved` were wrapped in `#if !TARGET_OS_TV && !TARGET_OS_OSX`, so the config values were stored but never evaluated. (Only the fail criteria worked — `shouldFailUnderCustomCriteria` runs unconditionally.) 2. `NSPanGestureRecognizer` begins on mouse-down. Unlike `UIPanGestureRecognizer`, which has a built-in ~10 pt hysteresis, the AppKit recognizer transitions to `Began` immediately, so without the check there is nothing preventing a click from activating the handler. The iOS implementation holds the recognizer back with the `minimumNumberOfTouches = 20` trick, which has no AppKit equivalent. Instead, on macOS the recognizer is held in the `Possible` state explicitly: - When a mouse-down arrives and custom activation criteria are configured, a `_blockAutomaticActivation` flag is set. - A `setState:` override swallows the superclass's `Began`/`Changed` transitions while the flag is up. The superclass keeps receiving events, so `translationInView:` / `velocityInView:` stay valid — which also keeps `failOffsetX/Y` and the JS event payload working. - `interactionsMoved` now evaluates `shouldActivateUnderCustomCriteria` on macOS and, once it passes, clears the flag, sets `Began` and resets the translation to zero — same semantics as iOS/Android (translation counts from the activation point). - A mouse-up before the criteria are met arrives at `setState:` as `Ended` and is rewritten to `Failed`, so the gesture finalizes correctly (`onFinalize` with `canceled=true`). - The flag is cleared in `reset` and in `activateAfterLongPress` (which already forces `minDistSq >= 100`, so the block is active while waiting for the long-press timer).
Tested on the following code: ```tsx import React from 'react'; import { StyleSheet, Text, View } from 'react-native'; import { GestureDetector, usePanGesture } from 'react-native-gesture-handler'; export default function EmptyExample() { const minDistPan = usePanGesture({ minDistance: 50, onBegin: () => console.log('[minDist] onBegin'), onActivate: (e) => console.log(`[minDist] onActivate tx=${e.translationX.toFixed(1)} ty=${e.translationY.toFixed(1)}`), onDeactivate: () => console.log('[minDist] onDeactivate'), onFinalize: (e) => console.log(`[minDist] onFinalize canceled=${e.canceled}`), }); const activeOffsetPan = usePanGesture({ activeOffsetX: [-50, 50], onBegin: () => console.log('[activeOffsetX] onBegin'), onActivate: (e) => console.log(`[activeOffsetX] onActivate tx=${e.translationX.toFixed(1)} ty=${e.translationY.toFixed(1)}`), onDeactivate: () => console.log('[activeOffsetX] onDeactivate'), onFinalize: (e) => console.log(`[activeOffsetX] onFinalize canceled=${e.canceled}`), }); const minVelocityPan = usePanGesture({ minVelocity: 800, onBegin: () => console.log('[minVelocity] onBegin'), onActivate: (e) => console.log(`[minVelocity] onActivate tx=${e.translationX.toFixed(1)} ty=${e.translationY.toFixed(1)}`), onDeactivate: () => console.log('[minVelocity] onDeactivate'), onFinalize: (e) => console.log(`[minVelocity] onFinalize canceled=${e.canceled}`), }); return ( minDistance: 50 activeOffsetX: [-50, 50] minVelocity: 800 ); } const styles = StyleSheet.create({ container: { flex: 1, justifyContent: 'center', alignItems: 'center', gap: 8 }, label: { marginTop: 16, fontSize: 15, opacity: 0.6 }, box: { width: 140, height: 140, borderRadius: 12 }, }); ```
--- .../apple/Handlers/RNPanHandler.m | 41 ++++++++++++++++++- 1 file changed, 39 insertions(+), 2 deletions(-) diff --git a/packages/react-native-gesture-handler/apple/Handlers/RNPanHandler.m b/packages/react-native-gesture-handler/apple/Handlers/RNPanHandler.m index 53a812af0e..49a6bf1f94 100644 --- a/packages/react-native-gesture-handler/apple/Handlers/RNPanHandler.m +++ b/packages/react-native-gesture-handler/apple/Handlers/RNPanHandler.m @@ -42,7 +42,9 @@ - (id)initWithGestureHandler:(RNGestureHandler *)gestureHandler; @implementation RNBetterPanGestureRecognizer { __weak RNGestureHandler *_gestureHandler; -#if !TARGET_OS_OSX +#if TARGET_OS_OSX + BOOL _blockAutomaticActivation; +#else NSUInteger _realMinimumNumberOfTouches; #endif BOOL _hasCustomActivationCriteria; @@ -114,6 +116,9 @@ - (void)tryUpdateStylusData:(UIEvent *)event - (void)activateAfterLongPress { +#if TARGET_OS_OSX + _blockAutomaticActivation = NO; +#endif self.state = UIGestureRecognizerStateBegan; // Send event in ACTIVE state because UIGestureRecognizerStateBegan is mapped to RNGestureHandlerStateBegan [_gestureHandler handleGesture:self inState:RNGestureHandlerStateActive]; @@ -139,6 +144,11 @@ - (void)interactionsBegan:(NSSet *)touches withEvent:(UIEvent *)event #endif #if TARGET_OS_OSX + // NSPanGestureRecognizer transitions to the Began state on mouse-down, without any + // built-in movement hysteresis. To honor the custom activation criteria (minDist, + // activeOffsets, minVelocity) we hold the recognizer in the Possible state until + // they are met in interactionsMoved. + _blockAutomaticActivation = _hasCustomActivationCriteria; [super mouseDown:event]; #else [super touchesBegan:touches withEvent:event]; @@ -176,7 +186,14 @@ - (void)interactionsMoved:(NSSet *)touches withEvent:(UIEvent *)event } } -#if !TARGET_OS_TV && !TARGET_OS_OSX +#if TARGET_OS_OSX + if (_hasCustomActivationCriteria && self.state == UIGestureRecognizerStatePossible && + [self shouldActivateUnderCustomCriteria]) { + _blockAutomaticActivation = NO; + self.state = UIGestureRecognizerStateBegan; + [self setTranslation:CGPointMake(0, 0) inView:self.view]; + } +#elif !TARGET_OS_TV if (_hasCustomActivationCriteria && self.state == UIGestureRecognizerStatePossible && [self shouldActivateUnderCustomCriteria]) { super.minimumNumberOfTouches = _realMinimumNumberOfTouches; @@ -209,6 +226,23 @@ - (void)interactionsCancelled:(NSSet *)touches withEvent:(UIEvent *)event #if TARGET_OS_OSX +- (void)setState:(NSGestureRecognizerState)state +{ + if (_blockAutomaticActivation) { + if (state == NSGestureRecognizerStateBegan || state == NSGestureRecognizerStateChanged) { + // Hold the recognizer in the Possible state until the custom activation criteria + // are met — the superclass keeps tracking the mouse regardless, so translation + // and velocity stay valid for the criteria checks. + return; + } + if (state == NSGestureRecognizerStateEnded) { + // Mouse released before the activation criteria were met — the gesture failed. + state = NSGestureRecognizerStateFailed; + } + } + [super setState:state]; +} + - (void)mouseDown:(NSEvent *)event { [_gestureHandler setCurrentPointerTypeToMouse]; @@ -265,6 +299,9 @@ - (void)reset [_gestureHandler.pointerTracker reset]; [NSObject cancelPreviousPerformRequestsWithTarget:self selector:@selector(activateAfterLongPress) object:nil]; self.enabled = YES; +#if TARGET_OS_OSX + _blockAutomaticActivation = NO; +#endif [super reset]; [_gestureHandler reset]; From ad6e542468ce680b352af71bdc6cdbe3919d8a7f Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?Micha=C5=82=20Bert?= <63123542+m-bert@users.noreply.github.com> Date: Thu, 6 Aug 2026 12:52:04 +0200 Subject: [PATCH 27/48] [macOS] Fix `manualActivation` never blocking gesture activation (#4389) MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit ## Description `manualActivation: true` has never worked on macOS — the handler activated on its own as if the flag wasn't set. Found while verifying a review comment on #4387. Four stacked defects in `RNManualActivationRecognizer`, all macOS-specific: 1. The failure requirement was never established. The blocking mechanism relies on `shouldBeRequiredToFailByGestureRecognizer:`, which is a `UIGestureRecognizer` *subclass* hook (`UIGestureRecognizerSubclass.h`). `NSGestureRecognizer` has no such hook, so AppKit never called it and the handler's recognizer never waited for the blocker — a pan activated straight from mouse-down. AppKit only consults the *delegate*; since the blocker is already its own `NSGestureRecognizerDelegate`, the two-arg delegate callback now forwards to the shared logic. 2. AppKit does not reliably deny the dependent recognizer when the blocker recognizes. On iOS, the blocker completing (`Began` → `Ended` in its action handler) causes UIKit to fail the recognizer that required it to fail — that's how a released-without-`activate()` gesture is discarded. On AppKit, completing the blocker can instead *flush* the dependent recognizer's buffered recognition: with the requirement in place, releasing after a drag delivered the pan's entire withheld sequence (`Began`/`Changed`/`Ended` with the full accumulated translation) instead of discarding it, while a motionless press-release was discarded correctly (traced with state-transition logging). The blocker now explicitly fails the handler's recognizer before completing. 3. The release-without-activation cleanup was dead code. The original macOS port (#2588) crossed the `touchesBegan`/`touchesEnded` bodies: `mouseDown` incremented `_activePointers` and then checked `if (_activePointers == 0)` — never true after an increment — while `mouseUp` only decremented. The zero-check now lives in `mouseUp`, mirroring `touchesEnded` on iOS. 4. Stale pointer count after a JS `activate()`. `stopActivationBlocker` disables the blocker, so it misses the subsequent mouse-up; iOS recovers in `touchesCancelled`, which has no AppKit equivalent. The count would stay at 1 and the cleanup in (3) would never fire again. `reset` now zeroes `_activePointers`. ## Test plan
Tested on the following code: ```tsx import React, { useEffect } from 'react'; import { StyleSheet, Text, View } from 'react-native'; import { GestureDetector, GestureStateManager, usePanGesture, } from 'react-native-gesture-handler'; import { useSharedValue } from 'react-native-reanimated'; export default function EmptyExample() { // Box A: manualActivation, JS never calls activate(). // Expected: every press/drag/release cycle logs onBegin -> onFinalize canceled=true, // never onActivate, and behaves identically on every repeat. const neverActivatePan = usePanGesture({ manualActivation: true, onBegin: () => console.log('[never-activate] onBegin'), onActivate: () => console.log('[never-activate] onActivate (SHOULD NOT HAPPEN)'), onDeactivate: () => console.log('[never-activate] onDeactivate'), onFinalize: (e) => console.log(`[never-activate] onFinalize canceled=${e.canceled}`), }); // Box B: manualActivation, activates itself from a timer while the button is held // (onTouchesMove is not delivered on macOS — separate bug). // Expected: hold past 300 ms -> onActivate (tx ~0), onUpdate while dragging, // onDeactivate + onFinalize canceled=false on release. Release before 300 ms -> // canceled=true and the late activate() is a no-op. const selfTag = useSharedValue(-1); const selfActivatePan = usePanGesture({ manualActivation: true, onTouchesDown: () => { const tag = selfTag.value; setTimeout(() => { console.log(`[self-activate] calling activate(${tag})`); GestureStateManager.activate(tag); }, 300); }, onBegin: () => console.log('[self-activate] onBegin'), onActivate: (e) => console.log(`[self-activate] onActivate tx=${e.translationX.toFixed(1)} ty=${e.translationY.toFixed(1)}`), onUpdate: (e) => console.log(`[self-activate] onUpdate tx=${e.translationX.toFixed(1)} ty=${e.translationY.toFixed(1)}`), onDeactivate: () => console.log('[self-activate] onDeactivate'), onFinalize: (e) => console.log(`[self-activate] onFinalize canceled=${e.canceled}`), }); useEffect(() => { selfTag.value = selfActivatePan.handlerTag; }, [selfActivatePan.handlerTag, selfTag]); return ( A: manualActivation, never activated — click & drag, release B: manualActivation, self-activates 300 ms after press ); } const styles = StyleSheet.create({ container: { flex: 1, justifyContent: 'center', alignItems: 'center', gap: 8 }, label: { marginTop: 16, fontSize: 15, opacity: 0.6 }, box: { width: 150, height: 150, borderRadius: 12 }, }); ```
--- .../apple/RNManualActivationRecognizer.m | 31 +++++++++++++++---- 1 file changed, 25 insertions(+), 6 deletions(-) diff --git a/packages/react-native-gesture-handler/apple/RNManualActivationRecognizer.m b/packages/react-native-gesture-handler/apple/RNManualActivationRecognizer.m index 2ecaef6e2e..d99b1eed3c 100644 --- a/packages/react-native-gesture-handler/apple/RNManualActivationRecognizer.m +++ b/packages/react-native-gesture-handler/apple/RNManualActivationRecognizer.m @@ -22,24 +22,31 @@ - (id)initWithGestureHandler:(RNGestureHandler *)gestureHandler - (void)handleGesture:(UIGestureRecognizer *)recognizer { if (recognizer.state == UIGestureRecognizerStateBegan) { +#if TARGET_OS_OSX + // On iOS, this recognizer completing is enough to deny the handler's recognizer — + // UIKit fails a recognizer whose required-to-fail dependency recognizes. AppKit + // does not do this reliably: completing the blocker can flush the dependent + // recognizer's buffered recognition instead of discarding it. + _handler.recognizer.state = UIGestureRecognizerStateFailed; +#endif self.state = UIGestureRecognizerStateEnded; [self reset]; } } #if TARGET_OS_OSX -- (void)mouseUp:(NSEvent *)event +- (void)mouseDown:(NSEvent *)event { - [super mouseUp:event]; + [super mouseDown:event]; - _activePointers -= 1; + _activePointers += 1; } -- (void)mouseDown:(NSEvent *)event +- (void)mouseUp:(NSEvent *)event { - [super mouseDown:event]; + [super mouseUp:event]; - _activePointers += 1; + _activePointers -= 1; if (_activePointers == 0) { self.state = UIGestureRecognizerStateBegan; @@ -80,6 +87,7 @@ - (void)touchesCancelled:(NSSet *)touches withEvent:(UIEvent *)ev - (void)reset { self.enabled = YES; + _activePointers = 0; [super reset]; } @@ -106,4 +114,15 @@ - (BOOL)shouldBeRequiredToFailByGestureRecognizer:(UIGestureRecognizer *)otherGe return NO; } +#if TARGET_OS_OSX +// On iOS the method above is a UIGestureRecognizer subclass override, called by the system +// to establish the failure requirement. NSGestureRecognizer has no such subclass hook — +// AppKit only consults the delegate, so forward the delegate callback to the shared logic. +- (BOOL)gestureRecognizer:(NSGestureRecognizer *)gestureRecognizer + shouldBeRequiredToFailByGestureRecognizer:(NSGestureRecognizer *)otherGestureRecognizer +{ + return [self shouldBeRequiredToFailByGestureRecognizer:otherGestureRecognizer]; +} +#endif + @end From f2ab06c4fde43b8b10f7e26e96aeb8c588f33607 Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?Micha=C5=82=20Bert?= <63123542+m-bert@users.noreply.github.com> Date: Fri, 7 Aug 2026 12:09:10 +0200 Subject: [PATCH 28/48] [macOS] Fix `Fling` not sending touch events and begin/end states consistently (#4395) MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit The macOS `Fling` recognizer is a separate `NSGestureRecognizer` implementation with several gaps compared to its iOS counterpart: - it never fed `pointerTracker`, so `onTouches*` callbacks never fired; - it never dispatched BEGAN itself — on successful flicks `onBegin` only arrived at activation time (AppKit coerces the recognizer's `Possible → Changed` transition into `Began` first, which accidentally supplies it mid-drag instead of at touch-down); - failed gestures (click, slow drag, too-slow flick) produced no events at all — no `onBegin`, no `onFinalize` — breaking the begin/finalize pairing, because the fail path fires no action and there was no `reset` override to deliver the final state; - it never set the pointer type. All of this is now mirrored from the iOS recognizer: tracker calls in `mouseDown`/`mouseDragged`/`mouseUp`, a BEGAN `triggerAction` on mouse-down, a `reset` override with `triggerActionFromReset`, and `setCurrentPointerTypeToMouse`. Touch-event delivery on macOS additionally requires #4390 (pointer tracker matching `NSEvent`s); there is no code dependency between the two PRs. Tested in `macos-example` (with #4390 merged locally) using a v3 `useFlingGesture` with all state and touch callbacks: - fast flick: `onTouchesDown` → `onBegin` → `onTouchesMove`s → `onActivate` → `onTouchesUp` → `onDeactivate` → `onFinalize canceled=false`; - slow drag: fails via the max-duration timer, `onFinalize canceled=true` (previously: no events at all); - plain click: `onTouchesDown` → `onBegin` → `onTouchesUp` → `onFinalize canceled=true` (previously: no events at all); - repeated gestures behave identically (reset works). In the timer-fail path `onTouchesCancel` arrives after `onFinalize` — the same reset ordering the iOS recognizer produces. --- .../apple/Handlers/RNFlingHandler.m | 48 +++++++++++++++++-- 1 file changed, 44 insertions(+), 4 deletions(-) diff --git a/packages/react-native-gesture-handler/apple/Handlers/RNFlingHandler.m b/packages/react-native-gesture-handler/apple/Handlers/RNFlingHandler.m index eeb48043b3..ffb189c77d 100644 --- a/packages/react-native-gesture-handler/apple/Handlers/RNFlingHandler.m +++ b/packages/react-native-gesture-handler/apple/Handlers/RNFlingHandler.m @@ -95,7 +95,8 @@ - (CGPoint)getLastLocation #else @interface RNBetterSwipeGestureRecognizer : NSGestureRecognizer { - dispatch_block_t failFlingAction; + // scheduled on mouseDown; fails the gesture if the fling doesn't complete within maxDuration + dispatch_block_t maxDurationTimeout; int maxDuration; int minVelocity; double defaultAlignmentCone; @@ -137,18 +138,34 @@ - (void)handleGesture:(NSPanGestureRecognizer *)gestureRecognizer [_gestureHandler handleGesture:self]; } +- (void)triggerAction +{ + [_gestureHandler handleGesture:self fromReset:NO]; +} + +- (void)triggerActionFromReset +{ + [_gestureHandler handleGesture:self fromReset:YES]; +} + - (void)mouseDown:(NSEvent *)event { + [_gestureHandler setCurrentPointerTypeToMouse]; + [_gestureHandler reset]; [super mouseDown:event]; startPosition = [self locationInView:self.view]; startTime = CACurrentMediaTime(); + [_gestureHandler.pointerTracker touchesBegan:[NSSet setWithObject:event] withEvent:event]; + // Send the BEGAN event, mirroring what the iOS recognizer does in touchesBegan. + [self triggerAction]; + self.state = NSGestureRecognizerStatePossible; __weak typeof(self) weakSelf = self; - failFlingAction = dispatch_block_create(0, ^{ + maxDurationTimeout = dispatch_block_create(0, ^{ __strong typeof(self) strongSelf = weakSelf; if (strongSelf) { @@ -159,13 +176,15 @@ - (void)mouseDown:(NSEvent *)event dispatch_after( dispatch_time(DISPATCH_TIME_NOW, (int64_t)(maxDuration * NSEC_PER_SEC)), dispatch_get_main_queue(), - failFlingAction); + maxDurationTimeout); } - (void)mouseDragged:(NSEvent *)event { [super mouseDragged:event]; + [_gestureHandler.pointerTracker touchesMoved:[NSSet setWithObject:event] withEvent:event]; + NSPoint currentPosition = [self locationInView:self.view]; double currentTime = CACurrentMediaTime(); @@ -180,16 +199,37 @@ - (void)mouseDragged:(NSEvent *)event [self tryActivate:velocityVector]; } +- (void)cancelMaxDurationTimeout +{ + if (maxDurationTimeout != nil) { + dispatch_block_cancel(maxDurationTimeout); + maxDurationTimeout = nil; + } +} + - (void)mouseUp:(NSEvent *)event { [super mouseUp:event]; - dispatch_block_cancel(failFlingAction); + [_gestureHandler.pointerTracker touchesEnded:[NSSet setWithObject:event] withEvent:event]; + + [self cancelMaxDurationTimeout]; self.state = self.state == NSGestureRecognizerStateChanged ? NSGestureRecognizerStateEnded : NSGestureRecognizerStateFailed; } +- (void)reset +{ + [self triggerActionFromReset]; + [_gestureHandler.pointerTracker reset]; + // The gesture may end without a mouse-up (view unmount, cancellation) — a still-scheduled + // timeout would fire later and set the state to Failed during the next gesture. + [self cancelMaxDurationTimeout]; + [super reset]; + [_gestureHandler reset]; +} + - (void)tryActivate:(Vector *)velocityVector { bool isAligned = NO; From 2dc645519947833e256163ce5e08a5a7a24b4535 Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?Micha=C5=82=20Bert?= <63123542+m-bert@users.noreply.github.com> Date: Fri, 7 Aug 2026 12:14:35 +0200 Subject: [PATCH 29/48] [Android] Fix handlers cancelled while awaiting leaking in the orchestrator (#4402) MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit On Android, cancelling a handler while it is awaiting another one (e.g. the single tap in `Exclusive(doubleTap, singleTap)` waiting for the double tap to fail) leaves it in the orchestrator forever. Both cleanup paths in `cleanupFinishedHandlers` skip handlers with `isAwaiting` set, and the rescue loop in `onHandlerStateChange` never reaches it because `dropGestureHandler` drops interaction relations on the JS thread before the posted cancel runs on the UI thread, so `shouldHandlerWaitForOther` no longer matches. The leaked handler stays in `gestureHandlers`, which makes `ButtonViewGroup.shouldBeginWithRecordedHandlers` return `false` on every subsequent touch. As a result all button-based touchables (`Pressable`, `RectButton`, `BaseButton`, `Touchables`) stop responding app-wide until the app process is restarted. The most common trigger is unmounting a `GestureDetector` during the wait window. This change clears `isAwaiting` when a handler reaches `STATE_CANCELLED` or `STATE_FAILED`, since such a handler can never be resolved by the one it was waiting for, letting the existing cleanup collect it. `STATE_END` stays pinned, as `makeActive` relies on it to send synthetic events. Going through `onHandlerStateChange` also covers cancel paths that never touch the registry, e.g. `tryActivate` cancelling an awaiting handler via `shouldBeCancelledByFinishedHandler`. Fixes #4401
Tested on the following code ```tsx import React, { useRef, useState } from 'react'; import { Pressable as RNPressable, StyleSheet, Text, View, } from 'react-native'; import { GestureDetector, Pressable, RectButton, useExclusiveGestures, useTapGesture, } from 'react-native-gesture-handler'; // Repro for https://github.com/software-mansion/react-native-gesture-handler/issues/4401 // (Android): cancelling a handler while it is awaiting (Exclusive single tap // waiting for double tap to fail) leaves it in the orchestrator forever. // // Steps: // 1. Single-tap the purple box. 120ms later (inside the double-tap window, // while the single-tap handler is awaiting) the detector unmounts itself. // 2. Try the probe buttons below. According to the issue, ALL RNGH-based // touchables should now be dead app-wide until app restart. function ExclusiveBox({ onGone }: { onGone: () => void }) { const timer = useRef | null>(null); const doubleTap = useTapGesture({ runOnJS: true, numberOfTaps: 2, onActivate: () => console.log('[repro] double tap activated'), }); const singleTap = useTapGesture({ runOnJS: true, requireToFail: doubleTap, onActivate: () => console.log('[repro] single tap activated'), onTouchesUp: () => { // Unmount while the single-tap handler is awaiting double-tap failure if (timer.current == null) { timer.current = setTimeout(() => { console.log('[repro] unmounting detector while awaiting'); onGone(); }, 120); } }, }); const exclusive = useExclusiveGestures(doubleTap, singleTap); return ( SINGLE-TAP ME{'\n'}(unmounts in 120ms) ); } export default function EmptyExample() { const [mounted, setMounted] = useState(true); const [detectorTaps, setDetectorTaps] = useState(0); const [pressableTaps, setPressableTaps] = useState(0); const [rectTaps, setRectTaps] = useState(0); const [rnTaps, setRnTaps] = useState(0); const probeTap = useTapGesture({ runOnJS: true, onActivate: () => { console.log('[probe] GestureDetector tap'); setDetectorTaps((n) => n + 1); }, }); return ( {mounted ? ( setMounted(false)} /> ) : ( setMounted(true)}> DETECTOR GONE — tap to remount )} Probe detector: {detectorTaps} { console.log('[probe] RNGH Pressable'); setPressableTaps((n) => n + 1); }}> RNGH Pressable: {pressableTaps} { console.log('[probe] RectButton'); setRectTaps((n) => n + 1); }}> RectButton: {rectTaps} { console.log('[probe] RN core Pressable'); setRnTaps((n) => n + 1); }}> RN core Pressable: {rnTaps} ); } const styles = StyleSheet.create({ container: { flex: 1, justifyContent: 'center', alignItems: 'center', gap: 16, padding: 24, }, box: { width: 260, height: 110, borderRadius: 12, justifyContent: 'center', alignItems: 'center', }, probe: { width: 260, height: 56, borderRadius: 12, justifyContent: 'center', alignItems: 'center', }, boxLabel: { color: 'white', fontWeight: 'bold', textAlign: 'center', }, }); ```
--- .../gesturehandler/core/GestureHandlerOrchestrator.kt | 7 +++++++ 1 file changed, 7 insertions(+) diff --git a/packages/react-native-gesture-handler/android/src/main/java/com/swmansion/gesturehandler/core/GestureHandlerOrchestrator.kt b/packages/react-native-gesture-handler/android/src/main/java/com/swmansion/gesturehandler/core/GestureHandlerOrchestrator.kt index f694878ebe..9abedc751a 100644 --- a/packages/react-native-gesture-handler/android/src/main/java/com/swmansion/gesturehandler/core/GestureHandlerOrchestrator.kt +++ b/packages/react-native-gesture-handler/android/src/main/java/com/swmansion/gesturehandler/core/GestureHandlerOrchestrator.kt @@ -156,6 +156,13 @@ class GestureHandlerOrchestrator( /*package*/ fun onHandlerStateChange(handler: GestureHandler, newState: Int, prevState: Int) { handlingChangeSemaphore += 1 + + if (handler.isAwaiting && + (newState == GestureHandler.STATE_CANCELLED || newState == GestureHandler.STATE_FAILED) + ) { + handler.isAwaiting = false + } + if (isFinished(newState)) { // We have to loop through copy in order to avoid modifying collection // while iterating over its elements From dcdb39e7dcadf9ad92f1bfe985bc270780960c22 Mon Sep 17 00:00:00 2001 From: Jakub Piasecki Date: Fri, 7 Aug 2026 14:54:20 +0200 Subject: [PATCH 30/48] [General] Align `pointerType` across native and JS (#4403) MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit The JS `PointerType` enum is `TOUCH, STYLUS, MOUSE, KEY, OTHER`, but the native constants stopped at `OTHER = 3` — the value JS reads as `KEY`. Native pointer types travel to JS as plain ints with no translation layer (`gestureHandlerCommon.ts` types them as `PointerType`), so every native "other pointer" surfaced as `PointerType.KEY`, and `PointerType.OTHER` was unreachable from native. It is reachable on Android through `TOOL_TYPE_ERASER` / `TOOL_TYPE_UNKNOWN`, and on Apple through tvOS focus-driven hover and any touch that is neither direct, pencil, nor indirect pointer. Declares `KEY` on both platforms so `OTHER` lands on 4. Native never emits `KEY` — it is produced only by the web `KeyboardEventManager`. With the values consistent, `ButtonEvent.pointerType` is narrowed from `number` to `PointerType`. It only mirrored the codegen spec's `Int32`; the spec keeps its own self-contained copy, so codegen is unaffected. Numeric enums and `number` are mutually assignable, so this breaks no consumer — which is also why the `buttonEventTest` drift guard still passes. That guard can no longer tell a deliberate refinement of this field from real spec drift, but it still catches added, removed and retyped fields. - Android: `:react-native-gesture-handler:compileDebugKotlin` and `:app:assembleDebug` in `apps/basic-example/android` both succeed - Apple: enum values pinned by a compiled assertion (`Touch` 0 … `Key` 3, `OtherPointer` 4); no `switch` over the enum exists, so no `-Wswitch` fallout - `yarn ts-check` clean (both passes), `yarn test` 115/115, `yarn lint:js` 0 errors - The iOS app build was not run — `pod install` fails on this machine for reasons unrelated to the change (rvm `libruby.2.7.dylib` mismatch) --- .../java/com/swmansion/gesturehandler/core/GestureHandler.kt | 3 ++- .../apple/RNGestureHandlerPointerType.h | 1 + 2 files changed, 3 insertions(+), 1 deletion(-) diff --git a/packages/react-native-gesture-handler/android/src/main/java/com/swmansion/gesturehandler/core/GestureHandler.kt b/packages/react-native-gesture-handler/android/src/main/java/com/swmansion/gesturehandler/core/GestureHandler.kt index 5828c60e33..a0caa5ae99 100644 --- a/packages/react-native-gesture-handler/android/src/main/java/com/swmansion/gesturehandler/core/GestureHandler.kt +++ b/packages/react-native-gesture-handler/android/src/main/java/com/swmansion/gesturehandler/core/GestureHandler.kt @@ -982,7 +982,8 @@ open class GestureHandler { const val POINTER_TYPE_TOUCH = 0 const val POINTER_TYPE_STYLUS = 1 const val POINTER_TYPE_MOUSE = 2 - const val POINTER_TYPE_OTHER = 3 + const val POINTER_TYPE_KEY = 3 + const val POINTER_TYPE_OTHER = 4 private const val MAX_POINTERS_COUNT = 12 private lateinit var pointerProps: Array private lateinit var pointerCoords: Array diff --git a/packages/react-native-gesture-handler/apple/RNGestureHandlerPointerType.h b/packages/react-native-gesture-handler/apple/RNGestureHandlerPointerType.h index 90179d2613..e41df538fb 100644 --- a/packages/react-native-gesture-handler/apple/RNGestureHandlerPointerType.h +++ b/packages/react-native-gesture-handler/apple/RNGestureHandlerPointerType.h @@ -4,5 +4,6 @@ typedef NS_ENUM(NSInteger, RNGestureHandlerPointerType) { RNGestureHandlerTouch = 0, RNGestureHandlerStylus, RNGestureHandlerMouse, + RNGestureHandlerKey, RNGestureHandlerOtherPointer, }; From ea76dca416826ea05445f053090b04e37f895bef Mon Sep 17 00:00:00 2001 From: Hur Ali Date: Thu, 13 Aug 2026 11:06:34 +0500 Subject: [PATCH 31/48] feat: Adopt AGP v9 (#4263) MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit ## Description This PR is rasied following the second phase of the RFC around AGP v9 adoption: RFC: https://github.com/react-native-community/discussions-and-proposals/pull/1006 The gist of this PR is to make Gesture Handler AGP v9 compliant with backward compatibility. The main scope of changes is the `react-native-gesture-handler/android/build.gradle`. The rest of the changes can be considered temporary. The rest changes include: - Making Basic Example App AGP9 compliant - Upgrade `Gradle` to `v9.4.1` - Use `proguard-android-optimize` proguard file - Enable opt outs in the `gradle.properties` Ideally, we should not enable the opt outs and leverage the AGP 9 built-in kotlin and newDSL. However, if we do not do it, then other libraries which are not yet AGP v9 compliant starts to fail. Hence, we need to keep the opt outs enabled for a while. For the context, react-native starting from 0.87.x will ship with AGP v9 and opt outs enabled by default for the new apps, which is the first phase of AGP v9 adoption. With the second phase when the libraries starts adoption AGP v9, we can eventually remove the opt outs from Basic Example. ## Test plan To test this PR, we need to do a few steps: - Set the `agp` version to `9.2.1` and `kotlin` to `2.2.0` in `react-native-gradle-plugin` ```diff --- a/node_modules/@react-native/gradle-plugin/gradle/libs.versions.toml +++ b/node_modules/@react-native/gradle-plugin/gradle/libs.versions.toml @@ -1,10 +1,10 @@ [versions] -agp = "8.12.0" +agp = "9.2.1" gson = "2.8.9" -kotlin = "2.1.20" +kotlin = "2.2.0" assertj = "3.25.1" ``` - Comment out the following functions in `react-native-gradle-plugin` ```diff --- a/node_modules/@react-native/gradle-plugin/react-native-gradle-plugin/ReactPlugin.kt +++ b/node_modules/@react-native/gradle-plugin/react-native-gradle-plugin/ReactPlugin.kt @@ -1,10 +1,10 @@ configureBuildTypesForApp(project) } // Library Only Configuration - configureBuildConfigFieldsForLibraries(project) - configureNamespaceForLibraries(project) + // configureBuildConfigFieldsForLibraries(project) + // configureNamespaceForLibraries(project) project.pluginManager.withPlugin("com.android.library") { ``` This step is only required because we have RN version on 0.85.2 and `AGP` + `kotlin` bump will be shipped with 0.87.x.
Verified by locally patching Reanimated and Worklet, making those AGP v9 compliant and removing the opt outs to test the changes in the PR https://github.com/user-attachments/assets/7f4f571a-6bef-4694-9b32-330571c0c734
Verified these changes work with AGP 8 https://github.com/user-attachments/assets/116a0b2e-23f5-499c-8a64-b99f927165c4
--------- Co-authored-by: Michał --- .../android/build.gradle | 27 ++++++++++++++----- 1 file changed, 21 insertions(+), 6 deletions(-) diff --git a/packages/react-native-gesture-handler/android/build.gradle b/packages/react-native-gesture-handler/android/build.gradle index 7361eec0f1..05818a67fb 100644 --- a/packages/react-native-gesture-handler/android/build.gradle +++ b/packages/react-native-gesture-handler/android/build.gradle @@ -63,7 +63,22 @@ if (isNewArchitectureEnabled()) { } apply plugin: 'com.android.library' -apply plugin: 'kotlin-android' + +def shouldEnableAgpFallback() { + def agpMajorVersion = com.android.Version.ANDROID_GRADLE_PLUGIN_VERSION.tokenize('.')[0].toInteger() + if (agpMajorVersion <= 8) { + return true + } + + def propertyVal = providers.gradleProperty("android.builtInKotlin").orNull + def isBuiltInKotlinEnabled = propertyVal != null ? propertyVal.toBoolean() : true + + return !isBuiltInKotlinEnabled +} + +if (shouldEnableAgpFallback()) { + apply plugin: 'kotlin-android' +} if (project == rootProject) { apply from: "spotless.gradle" @@ -191,17 +206,17 @@ android { } sourceSets.main { - java { + kotlin { if (shouldUseCommonInterfaceFromReanimated()) { - srcDirs += 'reanimated/src/main/java' + directories.add('reanimated/src/main/java') } else { - srcDirs += 'noreanimated/src/main/java' + directories.add('noreanimated/src/main/java') } if (shouldUseCommonInterfaceFromRNSVG()) { - srcDirs += 'svg/src/main/java' + directories.add('svg/src/main/java') } else { - srcDirs += 'nosvg/src/main/java' + directories.add('nosvg/src/main/java') } if (isNewArchitectureEnabled()) { From cca199ed124b411f6f20a4f932a93dd16966f5d4 Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?Micha=C5=82=20Bert?= <63123542+m-bert@users.noreply.github.com> Date: Thu, 13 Aug 2026 11:14:18 +0200 Subject: [PATCH 32/48] Clear pending timers on unmount in StatefulPressable (#4413) MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit The stateful Pressable engine keeps four pending timers - `longPressTimeoutRef`, `pressDelayTimeoutRef`, `hoverInTimeout` and `hoverOutTimeout` - but never clears them on unmount. If the component unmounts while one is pending, the scheduled callback still fires (`onPressIn` / `onLongPress` / `onHoverIn` / `onHoverOut`, plus a `setState`), acting on a torn-down component. This adds a cleanup effect that clears all four on unmount, matching the cleanup the Touchable-based engine already has. Follow-up to #4411, flagged by CodeRabbit. Existing v3 suite passes (`yarn test` in `packages/react-native-gesture-handler`). No behavior change while mounted — the effect only cancels timers that would otherwise fire after unmount. --- .../src/components/Pressable/Pressable.tsx | 18 ++++++++++++++++++ 1 file changed, 18 insertions(+) diff --git a/packages/react-native-gesture-handler/src/components/Pressable/Pressable.tsx b/packages/react-native-gesture-handler/src/components/Pressable/Pressable.tsx index 10bd906227..a6b0509068 100644 --- a/packages/react-native-gesture-handler/src/components/Pressable/Pressable.tsx +++ b/packages/react-native-gesture-handler/src/components/Pressable/Pressable.tsx @@ -218,6 +218,24 @@ const Pressable = (props: PressableProps) => { const hoverInTimeout = useRef(null); const hoverOutTimeout = useRef(null); + useEffect( + () => () => { + if (longPressTimeoutRef.current) { + clearTimeout(longPressTimeoutRef.current); + } + if (pressDelayTimeoutRef.current) { + clearTimeout(pressDelayTimeoutRef.current); + } + if (hoverInTimeout.current) { + clearTimeout(hoverInTimeout.current); + } + if (hoverOutTimeout.current) { + clearTimeout(hoverOutTimeout.current); + } + }, + [] + ); + const hoverGesture = useMemo( () => Gesture.Hover() From 1494f54e1a9d2efab60d75fac14ef27ab4d98234 Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?Micha=C5=82=20Bert?= <63123542+m-bert@users.noreply.github.com> Date: Thu, 13 Aug 2026 11:31:09 +0200 Subject: [PATCH 33/48] Derive `Pressable` pressed state from `testOnly_pressed` (#4414) MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit `testOnly_pressed` forces a Pressable's pressed state (for snapshots/tests), but both v3 engines only seeded it into the initial `useState(testOnly_pressed ?? false)`. After mount, changing the prop no longer updated the functional `style`/`children`, so they showed a stale pressed state. Both engines now derive the displayed state from `testOnly_pressed ?? ` at the style/children call sites — a prop change is reflected, while interactive presses still work when the prop is unset. This matches RN's Pressable, which runs the style/children functions with the forced pressed state on every render. Follow-up to #4411. CodeRabbit flagged the stateful engine; the Touchable-based engine had the identical issue. Existing v3 suite passes (`yarn test` in `packages/react-native-gesture-handler`). The change only affects the value passed to functional `style`/`children` when `testOnly_pressed` is set. --- .../src/components/Pressable/Pressable.tsx | 11 ++++++++--- 1 file changed, 8 insertions(+), 3 deletions(-) diff --git a/packages/react-native-gesture-handler/src/components/Pressable/Pressable.tsx b/packages/react-native-gesture-handler/src/components/Pressable/Pressable.tsx index a6b0509068..2bc1c606bb 100644 --- a/packages/react-native-gesture-handler/src/components/Pressable/Pressable.tsx +++ b/packages/react-native-gesture-handler/src/components/Pressable/Pressable.tsx @@ -79,7 +79,7 @@ const Pressable = (props: PressableProps) => { blocksExternalGesture, }; - const [pressedState, setPressedState] = useState(testOnly_pressed ?? false); + const [pressedState, setPressedState] = useState(false); const longPressTimeoutRef = useRef(null); const pressDelayTimeoutRef = useRef(null); @@ -372,12 +372,17 @@ const Pressable = (props: PressableProps) => { const pointerStyle: StyleProp = Platform.OS === 'web' ? { cursor: 'pointer' } : {}; + // `testOnly_pressed` forces the pressed state for snapshots/tests. Derive the + // displayed value from it each render, keeping the interactive `pressedState` + // independent (seeded to false) so clearing the prop doesn't leave it stuck. + const displayPressed = testOnly_pressed ?? pressedState; + const styleProp = - typeof style === 'function' ? style({ pressed: pressedState }) : style; + typeof style === 'function' ? style({ pressed: displayPressed }) : style; const childrenProp = typeof children === 'function' - ? children({ pressed: pressedState }) + ? children({ pressed: displayPressed }) : children; const rippleColor = useMemo(() => { From d33e6f58c7d73cf131fe591bd1d196251418afa0 Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?Micha=C5=82=20Bert?= <63123542+m-bert@users.noreply.github.com> Date: Fri, 14 Aug 2026 11:12:56 +0200 Subject: [PATCH 34/48] Update `Pressable` props (#4421) MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Follow-up to #4416. None of the `Pressable` engines forwarded hover handlers as `testOnly_*` props, so `fireEvent(element, 'hoverIn')` from React Native Testing Library had no way to reach `onHoverIn`/`onHoverOut`. RNTL resolves `testOnly_on{EventName}` generically for any event, so exposing the props is all that's needed. This adds `testOnly_onHoverIn`/`testOnly_onHoverOut` to the button props and forwards them, guarded by `isTestEnv()`, from all three engines: legacy `Pressable`, `StatefulPressable` and `PressableWithTouchable`. Also widens the relation props (`simultaneousWith`/`requireToFail`/`block`) from `AnyGesture` to `AnyGesture | AnyGesture[]`. The JSDoc already promises a gesture object or an array of gesture objects and the runtime handles arrays in both directions (`relationUtils` flattens them into handler tags and pushes the symmetric relation onto each array element), only the prop type was narrowed. Added tests in `src/__tests__/mocks.test.tsx` asserting the hover props are wired on the button for both v3 engines — relation-free and routed to `StatefulPressable` via `simultaneousWith={[]}` (which the type widening makes legal). Both fail without the engine changes. In `packages/react-native-gesture-handler`: `yarn test`, `yarn ts-check` and `yarn lint:js` pass. --- .../src/components/GestureButtonsProps.ts | 14 ++++++++++++++ .../src/components/Pressable/Pressable.tsx | 4 +++- 2 files changed, 17 insertions(+), 1 deletion(-) diff --git a/packages/react-native-gesture-handler/src/components/GestureButtonsProps.ts b/packages/react-native-gesture-handler/src/components/GestureButtonsProps.ts index a49104aac1..5bc4e4b635 100644 --- a/packages/react-native-gesture-handler/src/components/GestureButtonsProps.ts +++ b/packages/react-native-gesture-handler/src/components/GestureButtonsProps.ts @@ -89,6 +89,20 @@ export interface RawButtonProps */ // eslint-disable-next-line @typescript-eslint/ban-types testOnly_onLongPress?: Function | null | undefined; + + /** + * Used for testing-library compatibility, not passed to the native component. + * @deprecated test-only props are deprecated and will be removed in the future. + */ + // eslint-disable-next-line @typescript-eslint/ban-types + testOnly_onHoverIn?: Function | null | undefined; + + /** + * Used for testing-library compatibility, not passed to the native component. + * @deprecated test-only props are deprecated and will be removed in the future. + */ + // eslint-disable-next-line @typescript-eslint/ban-types + testOnly_onHoverOut?: Function | null | undefined; } interface ButtonWithRefProps { innerRef?: React.ForwardedRef> | undefined; diff --git a/packages/react-native-gesture-handler/src/components/Pressable/Pressable.tsx b/packages/react-native-gesture-handler/src/components/Pressable/Pressable.tsx index 2bc1c606bb..10c3aa484b 100644 --- a/packages/react-native-gesture-handler/src/components/Pressable/Pressable.tsx +++ b/packages/react-native-gesture-handler/src/components/Pressable/Pressable.tsx @@ -421,7 +421,9 @@ const Pressable = (props: PressableProps) => { testOnly_onPress={IS_TEST_ENV ? onPress : undefined} testOnly_onPressIn={IS_TEST_ENV ? onPressIn : undefined} testOnly_onPressOut={IS_TEST_ENV ? onPressOut : undefined} - testOnly_onLongPress={IS_TEST_ENV ? onLongPress : undefined}> + testOnly_onLongPress={IS_TEST_ENV ? onLongPress : undefined} + testOnly_onHoverIn={IS_TEST_ENV ? onHoverIn : undefined} + testOnly_onHoverOut={IS_TEST_ENV ? onHoverOut : undefined}> {childrenProp} {__DEV__ ? ( From 365b333006561c689f9ff2a09ce711fcafbdc806 Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?Micha=C5=82=20Bert?= <63123542+m-bert@users.noreply.github.com> Date: Wed, 19 Aug 2026 12:52:03 +0200 Subject: [PATCH 35/48] Forward `borderless` and `foreground` from `android_ripple` (#4442) Follow-up to #4411. `StatefulPressable` and the legacy `Pressable` only forwarded `color` and `radius` from `android_ripple` to the button, so `borderless` and `foreground` silently did nothing on those engines - the ripple stayed bounded and drew under the children. `PressableWithTouchable` already passed all four fields, which made the behavior depend on whether a relation prop (`simultaneousWith`/`requireToFail`/`block`) was present. Both engines now pass the whole config through. The button props for the two flags already existed, only the Pressable side dropped them.
Tested on the following code: ```tsx import React from 'react'; import { Pressable as RNPressable, ScrollView, StyleSheet, Text, View, } from 'react-native'; import { LegacyPressable, Pressable } from 'react-native-gesture-handler'; import type { PressableProps } from 'react-native-gesture-handler'; // Android-only check for `android_ripple.borderless` / `.foreground`, which the // StatefulPressable and legacy engines used to drop (only color + radius were // forwarded to the button). // // borderless: the ripple is a circle that spills outside the box. // foreground: the ripple draws over the opaque child instead of under it. // // Every column must look the same. Nothing to see on iOS - no native ripple there. const RIPPLE_COLOR = '#1565c0'; const VARIANTS = [ { label: 'baseline\n{ color }', ripple: { color: RIPPLE_COLOR }, covered: false, }, { label: 'borderless\n{ borderless: true }', ripple: { color: RIPPLE_COLOR, borderless: true }, covered: false, }, { // Control for the row below: a background ripple hides under the child. label: 'covered, control\n{ color }\nno ripple expected', ripple: { color: RIPPLE_COLOR }, covered: true, }, { label: 'foreground\n{ foreground: true }\nripple over the child', ripple: { color: RIPPLE_COLOR, foreground: true }, covered: true, }, { label: 'both + radius\n{ borderless, foreground, radius: 70 }', ripple: { color: RIPPLE_COLOR, borderless: true, foreground: true, radius: 70, }, covered: true, }, ] as const; // A relation prop is what routes the public `Pressable` to the Stateful engine. function StatefulPressable(props: PressableProps) { return ; } const ENGINES = [ { label: 'v3\nTouchable', Component: Pressable }, { label: 'v3\nStateful', Component: StatefulPressable }, { label: 'legacy\nRNGH', Component: LegacyPressable }, { label: 'RN\ncontrol', Component: RNPressable }, ] as const; export default function EmptyExample() { return ( {ENGINES.map((engine) => ( {engine.label} ))} {VARIANTS.map((variant) => ( {variant.label} {ENGINES.map(({ label, Component }) => ( {variant.covered ? : null} ))} ))} ); } const styles = StyleSheet.create({ container: { padding: 12, paddingTop: 40, gap: 20, }, row: { flexDirection: 'row', alignItems: 'center', }, variantLabel: { width: 100, }, variantText: { fontSize: 11, fontFamily: 'monospace', color: '#37474f', }, engineLabel: { flex: 1, fontSize: 11, textAlign: 'center', color: '#607d8b', }, cell: { flex: 1, alignItems: 'center', }, button: { width: 54, height: 54, borderRadius: 6, backgroundColor: '#eceff1', }, cover: { flex: 1, backgroundColor: '#cfd8dc', }, }); ```
--- .../src/components/Pressable/Pressable.tsx | 2 ++ 1 file changed, 2 insertions(+) diff --git a/packages/react-native-gesture-handler/src/components/Pressable/Pressable.tsx b/packages/react-native-gesture-handler/src/components/Pressable/Pressable.tsx index 10c3aa484b..5fc66f5bdb 100644 --- a/packages/react-native-gesture-handler/src/components/Pressable/Pressable.tsx +++ b/packages/react-native-gesture-handler/src/components/Pressable/Pressable.tsx @@ -417,6 +417,8 @@ const Pressable = (props: PressableProps) => { touchSoundDisabled={android_disableSound ?? undefined} rippleColor={rippleColor} rippleRadius={android_ripple?.radius ?? undefined} + borderless={android_ripple?.borderless ?? undefined} + foreground={android_ripple?.foreground ?? undefined} style={[pointerStyle, styleProp]} testOnly_onPress={IS_TEST_ENV ? onPress : undefined} testOnly_onPressIn={IS_TEST_ENV ? onPressIn : undefined} From 92d422bbd58d0591325eddc9e31e32c8ec71de93 Mon Sep 17 00:00:00 2001 From: Ngoc Le Date: Mon, 24 Aug 2026 22:04:01 +0700 Subject: [PATCH 36/48] Keep ReanimatedSwipeable native handlers stable when event callbacks change (#4466) MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Fixes #3307 Passing inline `onSwipeableOpen` / `onSwipeableClose` (or the other event props) to `ReanimatedSwipeable` made list scrolling stutter, even when the callbacks were empty. Those functions sat in the worklet/`useCallback` dependency chain, so a new identity on every parent render rebuilt the pan and tap gesture configs and reconfigured the native handlers. This change: - keeps the latest user callbacks behind stable wrappers (`useEventCallback`) - memoizes the tap/pan configs so native handlers are only updated when gesture settings actually change - still invokes the most recent callback after a callback-only rerender `ReanimatedDrawerLayout` has a similar pattern, but it is typically a single instance per screen rather than a list row, so it is left untouched here. - [x] `yarn test src/__tests__/reanimatedSwipeableCallbacks.test.tsx` — native `setGestureHandlerConfig` is not called again when only event callback identities change - [x] same file — `close()` after a callback-only rerender invokes the latest `onSwipeableWillClose` - [x] sabotage: bypassing `useEventCallback` makes the identity test fail (`2` → `4` `setConfig` calls) - [x] `yarn test` — 19 suites / 158 tests pass - [x] `yarn lint:js` (no new errors) and `yarn ts-check` - [x] Please confirm scrolling a `FlatList`/`FlashList` of `ReanimatedSwipeable` rows with inline `onSwipeableOpen={() => {}}` no longer drops JS FPS --- .../ReanimatedSwipeable.tsx | 39 ++++++++++++++++--- 1 file changed, 33 insertions(+), 6 deletions(-) diff --git a/packages/react-native-gesture-handler/src/components/ReanimatedSwipeable/ReanimatedSwipeable.tsx b/packages/react-native-gesture-handler/src/components/ReanimatedSwipeable/ReanimatedSwipeable.tsx index 0ea07c0d41..4d28b03b4a 100644 --- a/packages/react-native-gesture-handler/src/components/ReanimatedSwipeable/ReanimatedSwipeable.tsx +++ b/packages/react-native-gesture-handler/src/components/ReanimatedSwipeable/ReanimatedSwipeable.tsx @@ -4,6 +4,7 @@ import { useImperativeHandle, ForwardedRef, useState, + useRef, } from 'react'; import { LayoutChangeEvent, View, I18nManager, StyleSheet } from 'react-native'; import Animated, { @@ -42,6 +43,21 @@ const DEFAULT_OVERSHOOT_FRICTION = 1; const DEFAULT_DRAG_OFFSET = 10; const DEFAULT_ENABLE_TRACKING_TWO_FINGER_GESTURE = false; +function useEventCallback( + callback: ((...args: Args) => void) | undefined +): ((...args: Args) => void) | undefined { + const callbackRef = useRef(callback); + callbackRef.current = callback; + + const stableCallback = useCallback((...args: Args) => { + callbackRef.current?.(...args); + }, []); + + // Keep a stable wrapper only while a user callback exists, so the existing + // truthiness checks can still skip `runOnJS` when the prop is absent. + return callback ? stableCallback : undefined; +} + const Swipeable = (props: SwipeableProps) => { const { ref, @@ -60,12 +76,12 @@ const Swipeable = (props: SwipeableProps) => { dragOffsetFromRightEdge = DEFAULT_DRAG_OFFSET, friction = DEFAULT_FRICTION, overshootFriction = DEFAULT_OVERSHOOT_FRICTION, - onSwipeableOpenStartDrag, - onSwipeableCloseStartDrag, - onSwipeableWillOpen, - onSwipeableWillClose, - onSwipeableOpen, - onSwipeableClose, + onSwipeableOpenStartDrag: onSwipeableOpenStartDragProp, + onSwipeableCloseStartDrag: onSwipeableCloseStartDragProp, + onSwipeableWillOpen: onSwipeableWillOpenProp, + onSwipeableWillClose: onSwipeableWillCloseProp, + onSwipeableOpen: onSwipeableOpenProp, + onSwipeableClose: onSwipeableCloseProp, renderLeftActions, renderRightActions, simultaneousWithExternalGesture, @@ -88,6 +104,17 @@ const Swipeable = (props: SwipeableProps) => { ] ); + const onSwipeableOpenStartDrag = useEventCallback( + onSwipeableOpenStartDragProp + ); + const onSwipeableCloseStartDrag = useEventCallback( + onSwipeableCloseStartDragProp + ); + const onSwipeableWillOpen = useEventCallback(onSwipeableWillOpenProp); + const onSwipeableWillClose = useEventCallback(onSwipeableWillCloseProp); + const onSwipeableOpen = useEventCallback(onSwipeableOpenProp); + const onSwipeableClose = useEventCallback(onSwipeableCloseProp); + const [shouldEnableTap, setShouldEnableTap] = useState(false); const rowState = useSharedValue(0); From 484f7c9aeaf42ddc21748312bf9d8e999363f9df Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?Micha=C5=82=20Bert?= <63123542+m-bert@users.noreply.github.com> Date: Wed, 26 Aug 2026 11:31:05 +0200 Subject: [PATCH 37/48] Don't pass dependencies to Reanimated hooks on native (#4472) Since Reanimated 4.6 (software-mansion/react-native-reanimated#10009) the native implementations of `useAnimatedStyle`, `useDerivedValue` and other hooks log a dev warning whenever a dependencies argument is passed, since dependencies are only relevant on web: ``` WARN [Reanimated] dependencies should only be used in web implementation. ``` We pass dependencies in two places: - `ReanimatedSwipeable` passes `[appliedTranslation, rowState]` to `useAnimatedStyle`. The dependencies are still needed on web, where bundlers resolve the compiled `lib` output that isn't processed by the Reanimated babel plugin, so they are now passed only when `Platform.OS === 'web'`. - `ReanimatedDrawerLayout` passed an empty array to `useDerivedValue`. An empty array does nothing on any platform (the web fallback only reads non-empty dependencies), so it's simply removed. - Open the Swipeable and Drawer examples in the example app on native and check that the warning is no longer logged. - Check that Swipeable still animates correctly in the example app running on web. --- .../src/components/ReanimatedDrawerLayout.tsx | 2 +- .../ReanimatedSwipeable/ReanimatedSwipeable.tsx | 10 ++++++++-- 2 files changed, 9 insertions(+), 3 deletions(-) diff --git a/packages/react-native-gesture-handler/src/components/ReanimatedDrawerLayout.tsx b/packages/react-native-gesture-handler/src/components/ReanimatedDrawerLayout.tsx index 58710553b0..799bee34b4 100644 --- a/packages/react-native-gesture-handler/src/components/ReanimatedDrawerLayout.tsx +++ b/packages/react-native-gesture-handler/src/components/ReanimatedDrawerLayout.tsx @@ -322,7 +322,7 @@ const DrawerLayout = forwardRef( useDerivedValue(() => { onDrawerSlide && runOnJS(onDrawerSlide)(openValue.value); - }, []); + }); const isDrawerOpen = useSharedValue(false); diff --git a/packages/react-native-gesture-handler/src/components/ReanimatedSwipeable/ReanimatedSwipeable.tsx b/packages/react-native-gesture-handler/src/components/ReanimatedSwipeable/ReanimatedSwipeable.tsx index 4d28b03b4a..928862cb10 100644 --- a/packages/react-native-gesture-handler/src/components/ReanimatedSwipeable/ReanimatedSwipeable.tsx +++ b/packages/react-native-gesture-handler/src/components/ReanimatedSwipeable/ReanimatedSwipeable.tsx @@ -6,7 +6,13 @@ import { useState, useRef, } from 'react'; -import { LayoutChangeEvent, View, I18nManager, StyleSheet } from 'react-native'; +import { + LayoutChangeEvent, + View, + I18nManager, + Platform, + StyleSheet, +} from 'react-native'; import Animated, { useSharedValue, interpolate, @@ -603,7 +609,7 @@ const Swipeable = (props: SwipeableProps) => { transform: [{ translateX: appliedTranslation.value }], pointerEvents: rowState.value === 0 ? 'auto' : 'box-only', }), - [appliedTranslation, rowState] + Platform.OS === 'web' ? [appliedTranslation, rowState] : undefined ); const swipeableComponent = ( From 0d90a42079f4c7c3fc4d3e6969030344b3390d60 Mon Sep 17 00:00:00 2001 From: Ngoc Le Date: Thu, 27 Aug 2026 17:26:18 +0700 Subject: [PATCH 38/48] Fix ReanimatedDrawerLayout animation speed after rerender (#4470) MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Fixes #4469. `ReanimatedDrawerLayout` memoized `animateDrawer` without `animationSpeedProp`, so changing the prop did not affect later programmatic `openDrawer()` or `closeDrawer()` calls. The callback now tracks the prop and the imperative methods receive the latest default spring speed after a rerender. - `yarn workspace react-native-gesture-handler test --runInBand` — 159 tests passed - `yarn workspace react-native-gesture-handler ts-check` - `yarn workspace react-native-gesture-handler lint-js` — no errors (existing warnings remain) - `yarn workspace react-native-gesture-handler build` --- .../src/components/ReanimatedDrawerLayout.tsx | 1 + 1 file changed, 1 insertion(+) diff --git a/packages/react-native-gesture-handler/src/components/ReanimatedDrawerLayout.tsx b/packages/react-native-gesture-handler/src/components/ReanimatedDrawerLayout.tsx index 799bee34b4..2ff995cabe 100644 --- a/packages/react-native-gesture-handler/src/components/ReanimatedDrawerLayout.tsx +++ b/packages/react-native-gesture-handler/src/components/ReanimatedDrawerLayout.tsx @@ -422,6 +422,7 @@ const DrawerLayout = forwardRef( ); }, [ + animationSpeedProp, openValue, emitStateChanged, isDrawerOpen, From 81fb95f88efa11d6722aaf1ecbad065e95ddd565 Mon Sep 17 00:00:00 2001 From: Bao Nguyen <39545125+giaBaoJS@users.noreply.github.com> Date: Fri, 4 Sep 2026 15:58:38 +0700 Subject: [PATCH 39/48] Reset `numberOfPointers` in `LongPressGestureHandler` (#4479) `LongPressGestureHandler` accepts `numberOfPointers` but never resets it, so the value survives a config update that no longer sets it. `setConfig` rebuilds a handler's whole config as `resetConfig()` followed by `updateConfig()`: * web: `src/web/handlers/GestureHandler.ts:790` * Apple: `apple/RNGestureHandler.mm:131` * Android: `android/src/main/java/com/swmansion/gesturehandler/core/GestureHandler.kt:911` `updateConfig` only assigns properties that are present in the incoming config, so every property it can write has to be cleared by `resetConfig`. `numberOfPointers` was written but never cleared: * `src/web/handlers/LongPressGestureHandler.ts:127` writes it, `:76` did not reset it * `android/.../core/LongPressGestureHandler.kt:197` writes it, `:34` did not reset it * `apple/Handlers/RNLongPressHandler.m:255` writes it, `:232` did not reset it Every sibling handler already resets its pointer count, which is what made this stand out: `Tap` resets `minNumberOfPointers`, `Fling` resets `numberOfPointersRequired`, `Pan` resets `minPointers`/`maxPointers`. `GestureDetector` re-sends the full config on every update (`src/handlers/gestures/GestureDetector/updateHandlers.ts:68`), and the config only contains `numberOfPointers` when the gesture actually sets it. So a long press that stops requesting a multi pointer press keeps the stale requirement: ```jsx const longPress = useLongPressGesture( twoFingerMode ? { numberOfPointers: 2 } : {} ); ``` After `twoFingerMode` flips back to `false`, the handler still requires 2 pointers. On web `tryActivate` returns early because `trackedPointersCount !== numberOfPointers` (`src/web/handlers/LongPressGestureHandler.ts:163`), so a normal one finger long press never activates again. The same holds for the v2 `Gesture.LongPress().numberOfPointers(2)` builder and the v1 `` prop. The fix resets the value on all three platforms, using a named default in the two places that had a bare literal. Added `src/web/handlers/__tests__/LongPressGestureHandler.test.ts`, following the existing `GestureHandler.test.ts` / `webNativeViewGestureHandler.test.ts` pattern. One test drives the regression (config with `numberOfPointers: 2`, then a config without it, then a single pointer press), and one control test confirms `numberOfPointers` still applies while it is in the config. Counterfactual, run in this checkout. With the fix: ``` $ yarn jest src/web/handlers/__tests__/LongPressGestureHandler.test.ts PASS src/web/handlers/__tests__/LongPressGestureHandler.test.ts LongPressGestureHandler config reset v a config without numberOfPointers restores the single pointer default (2 ms) v numberOfPointers still applies while it stays in the config ``` Then reverting only `src/web/handlers/LongPressGestureHandler.ts` and keeping the test: ``` $ git show HEAD:packages/.../src/web/handlers/LongPressGestureHandler.ts > packages/.../src/web/handlers/LongPressGestureHandler.ts $ yarn jest src/web/handlers/__tests__/LongPressGestureHandler.test.ts x a config without numberOfPointers restores the single pointer default expect(received).toBe(expected) // Object.is equality Expected: 4 Received: 2 > 74 | expect(handler.state).toBe(State.ACTIVE); Tests: 1 failed, 1 passed, 2 total ``` `4` is `State.ACTIVE`, `2` is `State.BEGAN`: the handler stayed in `BEGAN` because it was still waiting for a second pointer. Restoring the file turns it green again. The control test passes in both directions, so the failure is specific to the reset. Checks in this checkout: * `yarn workspace react-native-gesture-handler test` -> 20 suites, 163 tests passing (162 before this change) * `yarn workspace react-native-gesture-handler ts-check` -> clean * `yarn eslint --ext '.js,.ts,.tsx' src/` -> 0 errors, 0 warnings on the touched files * `yarn prettier --check './src/**/*.{js,jsx,ts,tsx}'` -> all matched files use Prettier code style * `./android/gradlew -p android spotlessCheck -q` -> exit 0 * `clang-format --style=file` on `RNLongPressHandler.m` -> no diff on the changed lines The Android and Apple changes mirror the web one and are not covered by the Jest suite; I did not run them on a device. --- .../swmansion/gesturehandler/core/LongPressGestureHandler.kt | 4 +++- .../apple/Handlers/RNLongPressHandler.m | 4 ++++ .../src/web/handlers/LongPressGestureHandler.ts | 4 +++- 3 files changed, 10 insertions(+), 2 deletions(-) diff --git a/packages/react-native-gesture-handler/android/src/main/java/com/swmansion/gesturehandler/core/LongPressGestureHandler.kt b/packages/react-native-gesture-handler/android/src/main/java/com/swmansion/gesturehandler/core/LongPressGestureHandler.kt index 2abec746ea..614e97dfd9 100644 --- a/packages/react-native-gesture-handler/android/src/main/java/com/swmansion/gesturehandler/core/LongPressGestureHandler.kt +++ b/packages/react-native-gesture-handler/android/src/main/java/com/swmansion/gesturehandler/core/LongPressGestureHandler.kt @@ -29,13 +29,14 @@ class LongPressGestureHandler(context: Context) : GestureHandler() { val systemDefaultMaxDist = DEFAULT_MAX_DIST_DP * context.resources.displayMetrics.density defaultMaxDist = systemDefaultMaxDist maxDist = defaultMaxDist - numberOfPointersRequired = 1 + numberOfPointersRequired = DEFAULT_NUMBER_OF_POINTERS_REQUIRED } override fun resetConfig() { super.resetConfig() minDurationMs = DEFAULT_MIN_DURATION_MS maxDist = defaultMaxDist + numberOfPointersRequired = DEFAULT_NUMBER_OF_POINTERS_REQUIRED shouldCancelWhenOutside = DEFAULT_SHOULD_CANCEL_WHEN_OUTSIDE } @@ -208,5 +209,6 @@ class LongPressGestureHandler(context: Context) : GestureHandler() { private const val DEFAULT_SHOULD_CANCEL_WHEN_OUTSIDE = true private const val DEFAULT_MIN_DURATION_MS: Long = 500 private const val DEFAULT_MAX_DIST_DP = 10f + private const val DEFAULT_NUMBER_OF_POINTERS_REQUIRED = 1 } } diff --git a/packages/react-native-gesture-handler/apple/Handlers/RNLongPressHandler.m b/packages/react-native-gesture-handler/apple/Handlers/RNLongPressHandler.m index 4579ff73e3..9790108cb3 100644 --- a/packages/react-native-gesture-handler/apple/Handlers/RNLongPressHandler.m +++ b/packages/react-native-gesture-handler/apple/Handlers/RNLongPressHandler.m @@ -225,6 +225,10 @@ - (void)resetConfig recognizer.minimumPressDuration = 0.5; recognizer.allowableMovement = 10; + +#if !TARGET_OS_TV + recognizer.numberOfTouchesRequired = 1; +#endif } - (void)configure:(NSDictionary *)config diff --git a/packages/react-native-gesture-handler/src/web/handlers/LongPressGestureHandler.ts b/packages/react-native-gesture-handler/src/web/handlers/LongPressGestureHandler.ts index 3bf4da8b5d..5a164e78aa 100644 --- a/packages/react-native-gesture-handler/src/web/handlers/LongPressGestureHandler.ts +++ b/packages/react-native-gesture-handler/src/web/handlers/LongPressGestureHandler.ts @@ -5,6 +5,7 @@ import GestureHandler from './GestureHandler'; const DEFAULT_MIN_DURATION_MS = 500; const DEFAULT_MAX_DIST_DP = 10; +const DEFAULT_NUMBER_OF_POINTERS = 1; const SCALING_FACTOR = 10; export default class LongPressGestureHandler extends GestureHandler { @@ -12,7 +13,7 @@ export default class LongPressGestureHandler extends GestureHandler { private defaultMaxDistSq = DEFAULT_MAX_DIST_DP * SCALING_FACTOR; private maxDistSq = this.defaultMaxDistSq; - private numberOfPointers = 1; + private numberOfPointers = DEFAULT_NUMBER_OF_POINTERS; private startX = 0; private startY = 0; @@ -56,6 +57,7 @@ export default class LongPressGestureHandler extends GestureHandler { super.resetConfig(); this.minDurationMs = DEFAULT_MIN_DURATION_MS; this.maxDistSq = this.defaultMaxDistSq; + this.numberOfPointers = DEFAULT_NUMBER_OF_POINTERS; } protected onStateChange(_newState: State, _oldState: State): void { From 6494ef0dae61127520657e0ae1babd4303ef483b Mon Sep 17 00:00:00 2001 From: Bao Nguyen <39545125+giaBaoJS@users.noreply.github.com> Date: Fri, 4 Sep 2026 15:59:48 +0700 Subject: [PATCH 40/48] Reset `enableTrackpadTwoFingerGesture` in web `PanGestureHandler` (#4480) On web, `PanGestureHandler.updateGestureConfig` writes `enableTrackpadTwoFingerGesture`, but `resetConfig` never restores it. `setGestureConfig` is a full config replace (`resetConfig` then `updateGestureConfig`), and it is what `updateHandlers` / `useGesture` call whenever the gesture config changes. So once a pan gesture has been configured with `enableTrackpadTwoFingerGesture: true`, a later config that no longer carries the prop keeps two-finger trackpad panning enabled, and a wheel event from a trackpad still activates the gesture. Every other field the web `PanGestureHandler` reads from the config is restored in `resetConfig`; this one was missed. iOS is already correct: `RNPanHandler`'s `resetConfig` sets `recognizer.allowedScrollTypesMask = 0`, which is the same property. Android does not support the prop, so this is web only. Added `src/web/handlers/__tests__/PanGestureHandler.test.ts` with two cases: - configure the handler with `enableTrackpadTwoFingerGesture: true`, then apply a config without it, and feed a touchpad wheel event: the handler must stay `UNDETERMINED`. - configure it with `enableTrackpadTwoFingerGesture: true` and feed the same event: the handler must reach `ACTIVE`, so the flag itself keeps working. Without the one-line change in `resetConfig`, the first test fails with `Expected: 0 / Received: 4` (the gesture activates from stale config). The second one passes both ways. `yarn test`, `yarn lint-js` and `yarn ts-check` are green in `packages/react-native-gesture-handler`. --- .../src/web/handlers/PanGestureHandler.ts | 1 + 1 file changed, 1 insertion(+) diff --git a/packages/react-native-gesture-handler/src/web/handlers/PanGestureHandler.ts b/packages/react-native-gesture-handler/src/web/handlers/PanGestureHandler.ts index 8c7debac9e..d37bd85f79 100644 --- a/packages/react-native-gesture-handler/src/web/handlers/PanGestureHandler.ts +++ b/packages/react-native-gesture-handler/src/web/handlers/PanGestureHandler.ts @@ -190,6 +190,7 @@ export default class PanGestureHandler extends GestureHandler { this.maxPointers = DEFAULT_MAX_POINTERS; this.activateAfterLongPress = 0; + this.enableTrackpadTwoFingerGesture = false; } protected transformNativeEvent() { From 533ca408d3bd1e63955d476fd7dad8eaad37f3e4 Mon Sep 17 00:00:00 2001 From: Jakub Piasecki Date: Tue, 8 Sep 2026 08:40:08 +0200 Subject: [PATCH 41/48] [Android] Don't let an awaiting parent handler cancel the child it is waiting for (#4476) ## Description Fixes #3326 Replaces `state == ACTIVE` check with `handler.isActive` to correctly account for handlers that have technically met activation criteria, but are awaiting for the failure of another handler. ## Test plan
Tested on updated repro from issue ```jsx import React, { useState } from 'react'; import { Pressable, ScrollView, StyleSheet, Text, View } from 'react-native'; import type { LegacyPanGesture, NativeGesture, PanGesture, } from 'react-native-gesture-handler'; import { Gesture, GestureDetector, useNativeGesture, usePanGesture, } from 'react-native-gesture-handler'; import type { PagerViewOnPageSelectedEvent } from 'react-native-pager-view'; import PagerView from 'react-native-pager-view'; import Animated, { useAnimatedStyle, useSharedValue, } from 'react-native-reanimated'; // Reproduction of https://github.com/software-mansion/react-native-gesture-handler/issues/3326 // Expected: dragging the yellow ScrollView never begins/activates the drawer pan. type AnyPanGesture = PanGesture | LegacyPanGesture; type AnyNativeGesture = NativeGesture | ReturnType; type SetStatus = React.Dispatch>; type Mode = | 'requireToFail-parent' | 'block-parent' | 'block-child' | 'v2' | 'noPager' | 'noPaging' | 'nestedPan'; const MODES: Mode[] = [ 'requireToFail-parent', 'block-parent', 'block-child', 'v2', 'noPager', 'noPaging', 'nestedPan', ]; export default function EmptyExample() { const [mode, setMode] = useState('requireToFail-parent'); return ( {MODES.map((m) => ( setMode(m)} style={[styles.modeButton, m === mode && styles.modeButtonActive]}> {m} ))} {mode === 'requireToFail-parent' && } {mode === 'block-parent' && } {mode === 'block-child' && } {mode === 'v2' && } {mode === 'noPager' && } {mode === 'noPaging' && } {mode === 'nestedPan' && } ); } function useDrawerPan( innerNative: NativeGesture | undefined, swipeEnabled: boolean, setStatus: SetStatus ) { const val = useSharedValue(0); const pan = usePanGesture({ requireToFail: innerNative, activeOffsetX: swipeEnabled ? 5 : undefined, failOffsetX: swipeEnabled ? -1 : [0, 0], failOffsetY: swipeEnabled ? undefined : [0, 0], runOnJS: true, onBegin: () => { setStatus('pan: begin'); val.set(0); }, onActivate: () => { setStatus((s) => `${s} > ACTIVE`); }, onUpdate: (e) => { val.set(e.translationX); }, onDeactivate: () => { val.set(0); }, onFinalize: (e) => { setStatus((s) => `${s} > finalized (canceled: ${e.canceled})`); val.set(0); }, }); const style = useAnimatedStyle(() => ({ flex: 1, transform: [{ translateX: val.value }], })); return { pan, style }; } function RequireToFailInParent() { const [status, setStatus] = useState('pan: idle'); const [swipeEnabled, setSwipeEnabled] = useState(true); const innerNative = useNativeGesture({}); const { pan, style } = useDrawerPan(innerNative, swipeEnabled, setStatus); const pagerNative = useNativeGesture({ requireToFail: pan }); return ( index === 0 ? ( ) : ( Page {index + 1} ) } /> ); } function BlockInParent() { const [status, setStatus] = useState('pan: idle'); const [swipeEnabled, setSwipeEnabled] = useState(true); const { pan, style } = useDrawerPan(undefined, swipeEnabled, setStatus); const innerNative = useNativeGesture({ block: pan }); const pagerNative = useNativeGesture({ requireToFail: pan }); return ( index === 0 ? ( ) : ( Page {index + 1} ) } /> ); } function BlockInChild({ pager = true, paging = true, }: { pager?: boolean; paging?: boolean; }) { const [status, setStatus] = useState('pan: idle'); const [swipeEnabled, setSwipeEnabled] = useState(true); const { pan, style } = useDrawerPan(undefined, swipeEnabled, setStatus); const pagerNative = useNativeGesture({ requireToFail: pan }); return ( ( )} /> ); } // Scenario from PR #3095: a pan nested in a ScrollView must not activate while the ScrollView scrolls. function NestedPanInScrollView() { const [status, setStatus] = useState('pan: idle'); const [scrollY, setScrollY] = useState(0); const native = useNativeGesture({}); const pan = usePanGesture({ runOnJS: true, onBegin: () => setStatus('pan: begin'), onActivate: () => setStatus((s) => `${s} > ACTIVE`), onFinalize: (e) => setStatus((s) => `${s} > finalized (canceled: ${e.canceled})`), }); return ( {status} scrollY: {Math.round(scrollY)} setScrollY(e.nativeEvent.contentOffset.y)}> ); } function V2BlockInChild() { const [status, setStatus] = useState('pan: idle'); const [swipeEnabled, setSwipeEnabled] = useState(true); const val = useSharedValue(0); let pan = Gesture.Pan() .runOnJS(true) .onBegin(() => { setStatus('pan: begin'); val.set(0); }) .onStart(() => { setStatus((s) => `${s} > ACTIVE`); }) .onUpdate((e) => { val.set(e.translationX); }) .onEnd(() => { val.set(0); }) .onFinalize((_e, success) => { setStatus((s) => `${s} > finalized (canceled: ${!success})`); val.set(0); }); pan = swipeEnabled ? pan.failOffsetX(-1).activeOffsetX(5) : pan.failOffsetX([0, 0]).failOffsetY([0, 0]); const style = useAnimatedStyle(() => ({ flex: 1, transform: [{ translateX: val.value }], })); const pagerNative = Gesture.Native().requireExternalGestureToFail(pan); return ( } /> ); } function V2InnerScrollView({ pan }: { pan: LegacyPanGesture }) { const innerNative = Gesture.Native().blocksExternalGesture(pan); return ; } function Drawer({ pan, style, status, children, }: { pan: AnyPanGesture; style: ReturnType; status: string; children: React.ReactNode; }) { return ( {status} {children} ); } function Pager({ renderPage, native, setSwipeEnabled, }: { renderPage: (index: number) => React.ReactNode; native: AnyNativeGesture | undefined; setSwipeEnabled: (enabled: boolean) => void; }) { const [page, setPage] = useState(0); if (!native) { return {renderPage(0)}; } return ( { setSwipeEnabled(e.nativeEvent.position === 0); setPage(e.nativeEvent.position); }}> page: {page} {renderPage(0)} {renderPage(1)} {renderPage(2)} ); } function InnerScrollViewBlockingPan({ pan, paging, }: { pan: PanGesture; paging: boolean; }) { const innerNative = useNativeGesture({ block: pan }); return ; } function InnerScrollView({ innerNative, paging = true, }: { innerNative: AnyNativeGesture; paging?: boolean; }) { const [scrollX, setScrollX] = useState(0); return ( scrollX: {Math.round(scrollX)} setScrollX(e.nativeEvent.contentOffset.x)} scrollEventThrottle={16} style={styles.scroll}> 1 2 3 4 5 6 7 8 9 10 11 12 13 14 15 16 17 18 19 20 21 22 23 24 25 26 27 28 29 30 31 32 33 34 35 36 37 38 39 40 ); } const styles = StyleSheet.create({ container: { flex: 1, }, modes: { flexDirection: 'row', flexWrap: 'wrap', gap: 4, padding: 4, }, modeButton: { width: '32%', padding: 8, backgroundColor: '#ddd', borderRadius: 6, }, modeButtonActive: { backgroundColor: '#8f8', }, modeText: { fontSize: 11, textAlign: 'center', }, status: { padding: 8, fontSize: 18, textAlign: 'center', }, pager: { flex: 1, backgroundColor: 'green', }, scrollContainer: { paddingTop: 150, alignItems: 'center', }, scroll: { width: 300, height: 200, backgroundColor: 'yellow', }, scrollText: { width: 1000, }, spacer: { height: 400, }, nestedBox: { width: 150, height: 150, alignSelf: 'center', backgroundColor: 'yellow', }, }); ```
--- .../swmansion/gesturehandler/core/GestureHandlerOrchestrator.kt | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/packages/react-native-gesture-handler/android/src/main/java/com/swmansion/gesturehandler/core/GestureHandlerOrchestrator.kt b/packages/react-native-gesture-handler/android/src/main/java/com/swmansion/gesturehandler/core/GestureHandlerOrchestrator.kt index 9abedc751a..7d9cb45bf2 100644 --- a/packages/react-native-gesture-handler/android/src/main/java/com/swmansion/gesturehandler/core/GestureHandlerOrchestrator.kt +++ b/packages/react-native-gesture-handler/android/src/main/java/com/swmansion/gesturehandler/core/GestureHandlerOrchestrator.kt @@ -120,7 +120,7 @@ class GestureHandlerOrchestrator( private fun shouldBeCancelledByActiveHandler(handler: GestureHandler) = gestureHandlers.any { handler.hasCommonPointers(it) && - it.state == GestureHandler.STATE_ACTIVE && + it.isActive && !canRunSimultaneously(handler, it) && handler.isDescendantOf(it) } From 1a092b8249214c78724c9898d28293390ea2af52 Mon Sep 17 00:00:00 2001 From: Jakub Piasecki Date: Tue, 8 Sep 2026 08:40:35 +0200 Subject: [PATCH 42/48] [Web] Reset NativeViewGestureHandler config on a full config replace (#4486) The web NativeViewGestureHandler did not override resetConfig, so its props kept old values after setGestureConfig dropped them. It also forced shouldCancelWhenOutside to true in init, overriding an explicit false. The defaults now live in resetConfig, matching Android. Added tests in `webNativeViewGestureHandler.test.ts`. --- .../src/web/handlers/NativeViewGestureHandler.ts | 10 ++++++++-- 1 file changed, 8 insertions(+), 2 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 8c6732123f..c05cdcfa3f 100644 --- a/packages/react-native-gesture-handler/src/web/handlers/NativeViewGestureHandler.ts +++ b/packages/react-native-gesture-handler/src/web/handlers/NativeViewGestureHandler.ts @@ -20,8 +20,6 @@ export default class NativeViewGestureHandler extends GestureHandler { public init(ref: number, propsRef: React.RefObject): void { super.init(ref, propsRef); - this.shouldCancelWhenOutside = true; - if (Platform.OS !== 'web') { return; } @@ -46,6 +44,14 @@ export default class NativeViewGestureHandler extends GestureHandler { this.restoreViewStyles(view); } + protected override resetConfig(): void { + super.resetConfig(); + + this.shouldCancelWhenOutside = true; + this.shouldActivateOnStart = false; + this.disallowInterruption = false; + } + private restoreViewStyles(view: HTMLElement) { if (!view) { return; From 48f354b4da8f8674d7c3c0cb1720f39056cea7ab Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?Micha=C5=82=20Bert?= <63123542+m-bert@users.noreply.github.com> Date: Tue, 8 Sep 2026 09:47:04 +0200 Subject: [PATCH 43/48] Resolve `DrawerLayoutAndroid` lazily to avoid RN deprecation warning on import (#4493) Since React Native 0.87 the `DrawerLayoutAndroid` export is a getter that logs `DrawerLayoutAndroid is deprecated and will be removed in a future release` through `warnOnce` on first access. `GestureComponents.tsx` imported it at module level to build `LegacyDrawerLayoutAndroid`, so every app that imports gesture-handler saw the warning at startup, even if it never rendered a drawer. `LegacyDrawerLayoutAndroid` now wraps a small component that reads `DrawerLayoutAndroid` from `react-native` on first render and caches it in a module-level variable, so the warning appears only for apps that actually render the deprecated wrapper. The read uses `require` rather than `import * as RN`, because Metro's `experimentalImportSupport` (enabled by default in Expo) copies every export eagerly and would trigger all of RN's deprecation getters at once. Fixes #4491 - `yarn ts-check`, `yarn test` and `yarn lint:js` in the package - `yarn ts-check` in `apps/basic-example` - `basic-example` on Android: no deprecation warning at startup, one warning when opening the `Drawer Layout` screen; open/close via edge swipe and via ref buttons, drawer callbacks and `RectButton` presses work --- .../src/components/GestureComponents.tsx | 35 ++++++++++++++++--- 1 file changed, 31 insertions(+), 4 deletions(-) diff --git a/packages/react-native-gesture-handler/src/components/GestureComponents.tsx b/packages/react-native-gesture-handler/src/components/GestureComponents.tsx index dae8fb3690..2f1552a36d 100644 --- a/packages/react-native-gesture-handler/src/components/GestureComponents.tsx +++ b/packages/react-native-gesture-handler/src/components/GestureComponents.tsx @@ -5,6 +5,8 @@ import { RefAttributes, ReactElement, } from 'react'; +// Type-only: the value export is a deprecation-warning getter on RN 0.87+ (see below). +import type { DrawerLayoutAndroid as RNDrawerLayoutAndroid } from 'react-native'; import { ScrollView as RNScrollView, ScrollViewProps as RNScrollViewProps, @@ -12,7 +14,6 @@ import { SwitchProps as RNSwitchProps, TextInput as RNTextInput, TextInputProps as RNTextInputProps, - DrawerLayoutAndroid as RNDrawerLayoutAndroid, DrawerLayoutAndroidProps as RNDrawerLayoutAndroidProps, FlatList as RNFlatList, FlatListProps as RNFlatListProps, @@ -85,15 +86,41 @@ export const TextInput = createNativeWrapper(RNTextInput); // eslint-disable-next-line @typescript-eslint/no-redeclare export type TextInput = typeof TextInput & RNTextInput; +// RN's `DrawerLayoutAndroid` export is a getter that logs a deprecation +// warning on access, so resolve it on first render instead of module load. +// `require` is used on purpose: `import * as RN` would read every export +// eagerly under Metro's `experimentalImportSupport`. +let DrawerLayoutAndroidImpl: typeof RNDrawerLayoutAndroid | undefined; + +const LazyDrawerLayoutAndroid = ( + props: PropsWithChildren & { + ref?: React.Ref | null>; + } +) => { + if (!DrawerLayoutAndroidImpl) { + // eslint-disable-next-line @typescript-eslint/no-var-requires + const { DrawerLayoutAndroid } = require('react-native') as { + DrawerLayoutAndroid: typeof RNDrawerLayoutAndroid; + }; + DrawerLayoutAndroidImpl = DrawerLayoutAndroid; + } + return ; +}; +LazyDrawerLayoutAndroid.displayName = 'DrawerLayoutAndroid'; + export const DrawerLayoutAndroid: React.ComponentType< - PropsWithChildren & NativeViewGestureHandlerProps + PropsWithChildren & + NativeViewGestureHandlerProps & { + // eslint-disable-next-line @typescript-eslint/no-explicit-any + ref?: React.Ref | null>; + } > = createNativeWrapper>( - RNDrawerLayoutAndroid, + LazyDrawerLayoutAndroid, { disallowInterruption: true } ); // eslint-disable-next-line @typescript-eslint/no-redeclare export type DrawerLayoutAndroid = typeof DrawerLayoutAndroid & - RNDrawerLayoutAndroid; + React.ComponentRef; export const FlatList = React.forwardRef((props, ref) => { const refreshControlGestureRef = React.useRef(null); From 25d36d96bbd74ab06938524a7a904ae1cba7124e Mon Sep 17 00:00:00 2001 From: Bao Nguyen <39545125+giaBaoJS@users.noreply.github.com> Date: Tue, 8 Sep 2026 16:01:54 +0700 Subject: [PATCH 44/48] Reset accumulated wheel delta in web `WheelEventManager` (#4484) `WheelEventManager` has no pointer to follow, so it synthesizes coordinates by accumulating `deltaX`/`deltaY` on top of each wheel event's client coordinates. Its `resetManager` override calls only `super.resetManager()` and never clears `wheelDelta`, unlike `PointerEventManager`, which clears its own bookkeeping there. `resetManager` runs on every handler reset (`GestureHandler.reset` -> `delegate.reset` -> `manager.resetManager`), which the orchestrator triggers once a gesture reaches `END`. So when a trackpad pan ends, the whole scroll distance of that gesture stays in `wheelDelta`. To reproduce, put a `Pan` gesture with `enableTrackpadTwoFingerGesture` on a view, then do two two-finger trackpad pans in a row without moving the cursor in between. The second gesture reports `absoluteX`/`absoluteY` offset by the first gesture's total scroll. A two-finger scroll does not move the cursor, so the `pointermove` listener that clears the delta does not necessarily fire, and the offset keeps growing with each gesture. Fix is to clear `wheelDelta` in `resetManager`, matching what `PointerEventManager` already does. Added `src/web/tools/__tests__/WheelEventManager.test.ts` with two cases: - deltas still accumulate across wheel events within one gesture (`y` is 130 after deltas of 100 and 30), so the accumulation behaviour is not lost. - after `resetManager`, the next wheel event reports only its own delta (`y` is 30, not 130). The second test fails on `main` with `Expected: 30, Received: 130` and passes with the fix. `yarn jest src/web`, `yarn ts-check` and `eslint`/`prettier` on the touched files all pass. --- .../src/web/handlers/PanGestureHandler.ts | 9 ++++----- .../src/web/tools/WheelEventManager.ts | 1 + 2 files changed, 5 insertions(+), 5 deletions(-) diff --git a/packages/react-native-gesture-handler/src/web/handlers/PanGestureHandler.ts b/packages/react-native-gesture-handler/src/web/handlers/PanGestureHandler.ts index d37bd85f79..da38edac04 100644 --- a/packages/react-native-gesture-handler/src/web/handlers/PanGestureHandler.ts +++ b/packages/react-native-gesture-handler/src/web/handlers/PanGestureHandler.ts @@ -354,14 +354,13 @@ export default class PanGestureHandler extends GestureHandler { } } - private scheduleWheelEnd(event: AdaptedEvent) { + private scheduleWheelEnd() { clearTimeout(this.endWheelTimeout); this.endWheelTimeout = setTimeout(() => { if (this.state === State.ACTIVE) { this.end(); - this.tracker.removeFromTracker(event.pointerId); - this.state = State.UNDETERMINED; + this.reset(); } this.wheelDevice = WheelDevice.UNDETERMINED; @@ -383,7 +382,7 @@ export default class PanGestureHandler extends GestureHandler { : WheelDevice.MOUSE; if (this.wheelDevice === WheelDevice.MOUSE) { - this.scheduleWheelEnd(event); + this.scheduleWheelEnd(); return; } @@ -403,7 +402,7 @@ export default class PanGestureHandler extends GestureHandler { this.updateVelocity(event.pointerId); this.tryToSendMoveEvent(false, event); - this.scheduleWheelEnd(event); + this.scheduleWheelEnd(); } private shouldActivate(): boolean { diff --git a/packages/react-native-gesture-handler/src/web/tools/WheelEventManager.ts b/packages/react-native-gesture-handler/src/web/tools/WheelEventManager.ts index fce1ec31c6..627cbaac09 100644 --- a/packages/react-native-gesture-handler/src/web/tools/WheelEventManager.ts +++ b/packages/react-native-gesture-handler/src/web/tools/WheelEventManager.ts @@ -44,5 +44,6 @@ export default class WheelEventManager extends EventManager { public resetManager(): void { super.resetManager(); + this.wheelDelta = { x: 0, y: 0 }; } } From ef9ade7ac8da1e7c109ee55b6417afbbc248b43d Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?Micha=C5=82=20Bert?= <63123542+m-bert@users.noreply.github.com> Date: Tue, 8 Sep 2026 11:14:37 +0200 Subject: [PATCH 45/48] [Android] Stop nested scroll when a native handler's gesture ends (#4492) ## Description `ScrollView` and `FlatList` from Gesture Handler never fire `onRefresh` when given React Native's `RefreshControl` on Android. The spinner follows the pull but stays parked on release, and the refresh only triggers on the next touch anywhere on the screen. With a `refreshControl` RN wraps the scroll view in a `SwipeRefreshLayout` and enables nested scrolling on it. The pull reaches the layout through the nested-scroll API, and the layout finishes it (fires refresh or snaps back) only in `onStopNestedScroll`. Android calls `stopNestedScroll()` from `View.dispatchTouchEvent` at the end of a gesture, but once `NativeViewGestureHandler` activates it delivers touches straight to the view's `onTouchEvent`, so that cleanup never runs. The nested scroll stays open and the layout is released one touch late, by the CANCEL our root view dispatches on the next DOWN. Gesture Handler's own `RefreshControl` avoids this because its handler drives the `SwipeRefreshLayout` in touch-drag mode, which finishes the spinner in `onTouchEvent` without relying on the nested-scroll cleanup. The handler now calls `stopNestedScroll()` on the view after feeding it the final UP or the synthetic CANCEL, mirroring `View.dispatchTouchEvent`. It only does so while active, since below that the view still receives the events through regular dispatch, and only for views whose hook opts in through the new `shouldStopNestedScroll()`. `ScrollViewHook` opts in. Fixes #4485 ## Test plan - Pulled to refresh repeatedly on GH `ScrollView` + RN `RefreshControl`, GH `FlatList` + RN `RefreshControl`, and `Gesture.Native()` around an RN `ScrollView` + RN `RefreshControl`. `onRefresh` fires on every pull and the spinner retracts. - GH `ScrollView` + GH `RefreshControl` and RN `ScrollView` + RN `RefreshControl` unchanged. - Nested GH `ScrollView` inside GH `ScrollView`, with and without an RN `RefreshControl` on the outer: scroll handover, fling, and pull-to-refresh from inside the inner list work the same as with RN scroll views.
Repro ```tsx import React, { useCallback, useState } from 'react'; import { RefreshControl, Text, View } from 'react-native'; import { ScrollView } from 'react-native-gesture-handler'; export default function App() { const [refreshing, setRefreshing] = useState(false); const onRefresh = useCallback(() => { setRefreshing(true); setTimeout(() => setRefreshing(false), 1000); }, []); return ( }> {Array.from({ length: 40 }, (_, i) => ( Row {i} ))} ); } ```
--- .../core/NativeViewGestureHandler.kt | 23 +++++++++++++++++++ 1 file changed, 23 insertions(+) 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 8329224455..27b208d8fe 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 @@ -109,6 +109,9 @@ class NativeViewGestureHandler : GestureHandler() { cancel() } else { hook.sendTouchEvent(view, event) + if (shouldStopNestedScroll()) { + view.stopNestedScroll() + } if ((state == STATE_UNDETERMINED || state == STATE_BEGAN) && hook.canActivate(view)) { activate() @@ -156,9 +159,19 @@ class NativeViewGestureHandler : GestureHandler() { action = MotionEvent.ACTION_CANCEL } hook.sendTouchEvent(view, event) + if (shouldStopNestedScroll()) { + view?.stopNestedScroll() + } event.recycle() } + // Once the handler is active, it delivers touches straight to the view's `onTouchEvent`. Normally + // touches arrive through `View.dispatchTouchEvent`, which also ends the nested scroll when the finger + // goes up. Because we skip it, the nested scroll stays open and the parent never finds out that the + // gesture is over - e.g. SwipeRefreshLayout never fires refresh (#4485). While the handler is not + // active, the view still receives touches the regular way, so Android takes care of it. + private fun shouldStopNestedScroll() = state == STATE_ACTIVE && hook.shouldStopNestedScroll() + override fun onCancel() = dispatchCancelEventToView() override fun onFail() = dispatchCancelEventToView() @@ -211,6 +224,12 @@ class NativeViewGestureHandler : GestureHandler() { */ fun canBegin(event: MotionEvent) = true + /** + * Whether the view's nested scroll should be stopped when the active gesture ends. Touches + * are fed through `onTouchEvent`, so `View.dispatchTouchEvent` never gets to do it. + */ + fun shouldStopNestedScroll() = false + /** * Checks whether handler can activate. Used by TextViewHook. */ @@ -336,6 +355,10 @@ class NativeViewGestureHandler : GestureHandler() { private class ScrollViewHook : NativeViewGestureHandlerHook { override fun shouldCancelRootViewGestureHandlerIfNecessary() = true + + // ScrollView starts a nested scroll on DOWN but never stops it itself. Without this the + // parent's `onStopNestedScroll` never runs, e.g. SwipeRefreshLayout never triggers refresh. + override fun shouldStopNestedScroll() = true } private class ReactViewGroupHook : NativeViewGestureHandlerHook { From 3d6ca416a27fa6788c1898b0dd2b5e9489bac866 Mon Sep 17 00:00:00 2001 From: Wojciech Rok <58606210+tshmieldev@users.noreply.github.com> Date: Wed, 5 Aug 2026 15:17:50 +0200 Subject: [PATCH 46/48] Chore: ref forwarding to child in wrap v2 (#4385) --- .../src/__tests__/legacyWrapRef.test.tsx | 156 ++++++++++++++++++ .../src/getShadowNodeFromRef.ts | 15 +- .../gestures/GestureDetector/Wrap.tsx | 91 ++++++++-- .../gestures/GestureDetector/index.tsx | 5 +- .../GestureDetector/useDetectorUpdater.ts | 5 +- .../GestureDetector/useViewRefHandler.ts | 7 +- .../src/hostInstance.ts | 57 +++++++ 7 files changed, 315 insertions(+), 21 deletions(-) create mode 100644 packages/react-native-gesture-handler/src/__tests__/legacyWrapRef.test.tsx create mode 100644 packages/react-native-gesture-handler/src/hostInstance.ts diff --git a/packages/react-native-gesture-handler/src/__tests__/legacyWrapRef.test.tsx b/packages/react-native-gesture-handler/src/__tests__/legacyWrapRef.test.tsx new file mode 100644 index 0000000000..9bbd74484d --- /dev/null +++ b/packages/react-native-gesture-handler/src/__tests__/legacyWrapRef.test.tsx @@ -0,0 +1,156 @@ +import { cleanup, render } from '@testing-library/react-native'; +import React from 'react'; +import { findNodeHandle, View } from 'react-native'; + +import { Gesture, GestureDetector, GestureHandlerRootView } from '../index'; +import RNGestureHandlerModule from '../RNGestureHandlerModule'; + +jest.mock('react-native/Libraries/ReactNative/RendererProxy', () => ({ + findNodeHandle: jest.fn(), +})); + +const VIEW_TAG = 123; + +function ChildIgnoringRef(props: { children?: React.ReactNode }) { + return {props.children}; +} + +class ChildWithHostInstance extends React.Component<{ + children?: React.ReactNode; +}> { + // eslint-disable-next-line @eslint-react/no-unused-class-component-members + public __internalInstanceHandle = {}; + + override render() { + return {this.props.children}; + } +} + +describe('Legacy GestureDetector ref forwarding', () => { + let attachSpy: jest.SpyInstance; + + beforeEach(() => { + cleanup(); + jest.clearAllMocks(); + (findNodeHandle as jest.Mock).mockReturnValue(VIEW_TAG); + attachSpy = jest.spyOn(RNGestureHandlerModule, 'attachGestureHandler'); + }); + + afterEach(() => { + attachSpy.mockRestore(); + }); + + test('resolves the tag from the child host instance when its ref resolves', () => { + render( + + + + + + ); + + const resolvedRefs = (findNodeHandle as jest.Mock).mock.calls.map( + (call) => call[0] + ); + + expect(resolvedRefs.length).toBeGreaterThan(0); + expect( + resolvedRefs.every( + (ref) => ref instanceof ChildWithHostInstance && ref !== null + ) + ).toBe(true); + expect(attachSpy).toHaveBeenCalledWith( + expect.any(Number), + VIEW_TAG, + expect.any(Number) + ); + }); + + test('attaches gestures when the child ignores its ref', () => { + render( + + + + + + ); + + const resolvedRefs = (findNodeHandle as jest.Mock).mock.calls.map( + (call) => call[0] + ); + + expect(resolvedRefs.length).toBeGreaterThan(0); + expect( + resolvedRefs.every((ref) => ref instanceof ChildWithHostInstance) + ).toBe(false); + expect(attachSpy).toHaveBeenCalledWith( + expect.any(Number), + VIEW_TAG, + expect.any(Number) + ); + }); + + test('does not clobber a ref the child already has', () => { + const childRef = jest.fn(); + + render( + + + + + + ); + + expect(childRef).toHaveBeenCalled(); + expect(childRef.mock.calls[0][0]).not.toBeNull(); + }); + + test('moves the child instance when its ref is replaced', () => { + const gesture = Gesture.Tap(); + const firstRef = jest.fn(); + const secondRef = jest.fn(); + + function App({ useSecondRef }: { useSecondRef: boolean }) { + return ( + + + + + + ); + } + + const { rerender } = render(); + + const childInstance = firstRef.mock.calls[0][0]; + expect(childInstance).not.toBeNull(); + firstRef.mockClear(); + + rerender(); + + expect(firstRef).toHaveBeenCalledWith(null); + expect(secondRef).toHaveBeenCalledWith(childInstance); + }); + + test('does not reattach gestures on re-render', () => { + const gesture = Gesture.Tap(); + + function App() { + return ( + + + + + + ); + } + + const { rerender } = render(); + expect(attachSpy).toHaveBeenCalledTimes(1); + + rerender(); + rerender(); + + expect(attachSpy).toHaveBeenCalledTimes(1); + }); +}); diff --git a/packages/react-native-gesture-handler/src/getShadowNodeFromRef.ts b/packages/react-native-gesture-handler/src/getShadowNodeFromRef.ts index f46d501a9b..1c13833094 100644 --- a/packages/react-native-gesture-handler/src/getShadowNodeFromRef.ts +++ b/packages/react-native-gesture-handler/src/getShadowNodeFromRef.ts @@ -1,3 +1,5 @@ +import { isHostInstance } from './hostInstance'; + // Used by GestureDetector (unsupported on web at the moment) to check whether the // attached view may get flattened on Fabric. This implementation causes errors // on web due to the static resolution of `require` statements by webpack breaking @@ -8,8 +10,10 @@ let getInternalInstanceHandleFromPublicInstance: (ref: unknown) => { }; export function getShadowNodeFromRef(ref: unknown) { + const isAlreadyHostInstance = isHostInstance(ref); + // Load findHostInstance_DEPRECATED lazily because it may not be available before render - if (findHostInstance_DEPRECATED === undefined) { + if (!isAlreadyHostInstance && findHostInstance_DEPRECATED === undefined) { try { // eslint-disable-next-line @typescript-eslint/no-var-requires const ReactFabric = require('react-native/Libraries/Renderer/shims/ReactFabric'); @@ -43,8 +47,11 @@ export function getShadowNodeFromRef(ref: unknown) { } } + const hostInstance = isAlreadyHostInstance + ? ref + : findHostInstance_DEPRECATED(ref); + // @ts-ignore Fabric - return getInternalInstanceHandleFromPublicInstance( - findHostInstance_DEPRECATED(ref) - ).stateNode.node; + return getInternalInstanceHandleFromPublicInstance(hostInstance).stateNode + .node; } diff --git a/packages/react-native-gesture-handler/src/handlers/gestures/GestureDetector/Wrap.tsx b/packages/react-native-gesture-handler/src/handlers/gestures/GestureDetector/Wrap.tsx index 06c79267d0..8abb581e25 100644 --- a/packages/react-native-gesture-handler/src/handlers/gestures/GestureDetector/Wrap.tsx +++ b/packages/react-native-gesture-handler/src/handlers/gestures/GestureDetector/Wrap.tsx @@ -1,4 +1,10 @@ import React from 'react'; +import type { WrapRef } from '../../../hostInstance'; +import { + assignRef, + isHostInstance, + preferHostInstance, +} from '../../../hostInstance'; import { Reanimated } from '../reanimatedWrapper'; import { tagMessage } from '../../../utils'; @@ -7,20 +13,69 @@ export class Wrap extends React.Component<{ // Implicit `children` prop has been removed in @types/react^18.0.0 children?: React.ReactNode; }> { - render() { + private childInstance: unknown = null; + private hostInstance: unknown = null; + private childRef: WrapRef = undefined; + private attachedChildRef: WrapRef = undefined; + private childRefCleanup: (() => void) | undefined = undefined; + + // eslint-disable-next-line @eslint-react/no-unused-class-component-members + public getHostInstance() { + return this.hostInstance; + } + + private detachChildRef() { + if (this.childRefCleanup !== undefined) { + this.childRefCleanup(); + } else if (this.attachedChildRef) { + assignRef(this.attachedChildRef, null); + } + + this.childRefCleanup = undefined; + this.attachedChildRef = undefined; + } + + private attachChildRef(instance: unknown) { + this.attachedChildRef = this.childRef; + + this.childRefCleanup = assignRef(this.attachedChildRef, instance); + } + + private handleChildRef = (instance: unknown) => { + this.childInstance = instance; + + const resolved = preferHostInstance(instance); + this.hostInstance = isHostInstance(resolved) ? resolved : null; + + this.detachChildRef(); + + if (instance !== null && instance !== undefined) { + this.attachChildRef(instance); + } + }; + + override componentDidUpdate() { + if ( + this.childRef === this.attachedChildRef || + this.childInstance === null + ) { + return; + } + + this.detachChildRef(); + this.attachChildRef(this.childInstance); + } + + override render() { + // I don't think that fighting with types over such a simple function is worth it + // The only thing it does is add 'collapsable: false' to the child component + // to make sure it is in the native view hierarchy so the detector can find + // correct viewTag to attach to. + // eslint-disable-next-line @typescript-eslint/no-explicit-any + let child: any; + try { - // I don't think that fighting with types over such a simple function is worth it - // The only thing it does is add 'collapsable: false' to the child component - // to make sure it is in the native view hierarchy so the detector can find - // correct viewTag to attach to. - // eslint-disable-next-line @typescript-eslint/no-explicit-any - const child: any = React.Children.only(this.props.children); - return React.cloneElement( - child, - { collapsable: false }, - // eslint-disable-next-line @typescript-eslint/no-unsafe-member-access - child.props.children - ); + child = React.Children.only(this.props.children); } catch (e) { throw new Error( tagMessage( @@ -28,6 +83,16 @@ export class Wrap extends React.Component<{ ) ); } + + // eslint-disable-next-line @typescript-eslint/no-unsafe-member-access + this.childRef = child.props.ref as WrapRef; + + return React.cloneElement( + child, + { collapsable: false, ref: this.handleChildRef }, + // eslint-disable-next-line @typescript-eslint/no-unsafe-member-access + child.props.children + ); } } diff --git a/packages/react-native-gesture-handler/src/handlers/gestures/GestureDetector/index.tsx b/packages/react-native-gesture-handler/src/handlers/gestures/GestureDetector/index.tsx index 2df46feffb..0ad930fe04 100644 --- a/packages/react-native-gesture-handler/src/handlers/gestures/GestureDetector/index.tsx +++ b/packages/react-native-gesture-handler/src/handlers/gestures/GestureDetector/index.tsx @@ -6,6 +6,7 @@ import { GestureType } from '../gesture'; import { UserSelect, TouchAction } from '../../gestureHandlerCommon'; import { ComposedGesture } from '../gestureComposition'; import { isTestEnv } from '../../../utils'; +import { resolveHostInstance } from '../../../hostInstance'; import GestureHandlerRootViewContext from '../../../GestureHandlerRootViewContext'; import { AttachedGestureState, GestureDetectorState } from './types'; @@ -149,7 +150,9 @@ export const GestureDetector = (props: GestureDetectorProps) => { useAnimatedGesture(preparedGesture, needsToRebuildReanimatedEvent); useIsomorphicLayoutEffect(() => { - const viewTag = findNodeHandle(state.viewRef) as number; + const viewTag = findNodeHandle( + resolveHostInstance(state.viewRef) + ) as number; preparedGesture.isMounted = true; attachHandlers({ diff --git a/packages/react-native-gesture-handler/src/handlers/gestures/GestureDetector/useDetectorUpdater.ts b/packages/react-native-gesture-handler/src/handlers/gestures/GestureDetector/useDetectorUpdater.ts index e3113a27b0..f77c22a8cb 100644 --- a/packages/react-native-gesture-handler/src/handlers/gestures/GestureDetector/useDetectorUpdater.ts +++ b/packages/react-native-gesture-handler/src/handlers/gestures/GestureDetector/useDetectorUpdater.ts @@ -2,6 +2,7 @@ import React, { useCallback } from 'react'; import { GestureType } from '../gesture'; import { ComposedGesture } from '../gestureComposition'; +import { resolveHostInstance } from '../../../hostInstance'; import { AttachedGestureState, GestureDetectorState, @@ -29,7 +30,9 @@ export function useDetectorUpdater( // skipConfigUpdate is used to prevent unnecessary updates when only checking if the view has changed (skipConfigUpdate?: boolean) => { // If the underlying view has changed we need to reattach handlers to the new view - const viewTag = findNodeHandle(state.viewRef) as number; + const viewTag = findNodeHandle( + resolveHostInstance(state.viewRef) + ) as number; const didUnderlyingViewChange = viewTag !== state.previousViewTag; if ( diff --git a/packages/react-native-gesture-handler/src/handlers/gestures/GestureDetector/useViewRefHandler.ts b/packages/react-native-gesture-handler/src/handlers/gestures/GestureDetector/useViewRefHandler.ts index 10679000c6..2d7c3defa8 100644 --- a/packages/react-native-gesture-handler/src/handlers/gestures/GestureDetector/useViewRefHandler.ts +++ b/packages/react-native-gesture-handler/src/handlers/gestures/GestureDetector/useViewRefHandler.ts @@ -4,6 +4,7 @@ import { getShadowNodeFromRef } from '../../../getShadowNodeFromRef'; import { GestureDetectorState } from './types'; import React, { useCallback } from 'react'; import findNodeHandle from '../../../findNodeHandle'; +import { resolveHostInstance } from '../../../hostInstance'; declare const global: { isViewFlatteningDisabled: (node: unknown) => boolean | null; // JSI function @@ -26,7 +27,9 @@ export function useViewRefHandler( // if it's the first render, also set the previousViewTag to prevent reattaching gestures when not needed if (state.previousViewTag === -1) { - state.previousViewTag = findNodeHandle(state.viewRef) as number; + state.previousViewTag = findNodeHandle( + resolveHostInstance(state.viewRef) + ) as number; } // Pass true as `skipConfigUpdate`. Here we only want to trigger the eventual reattaching of handlers @@ -36,7 +39,7 @@ export function useViewRefHandler( } if (__DEV__ && isFabric() && global.isViewFlatteningDisabled) { - const node = getShadowNodeFromRef(ref); + const node = getShadowNodeFromRef(resolveHostInstance(ref)); if (global.isViewFlatteningDisabled(node) === false) { console.error( tagMessage( diff --git a/packages/react-native-gesture-handler/src/hostInstance.ts b/packages/react-native-gesture-handler/src/hostInstance.ts new file mode 100644 index 0000000000..dfcdb58471 --- /dev/null +++ b/packages/react-native-gesture-handler/src/hostInstance.ts @@ -0,0 +1,57 @@ +import type { Ref } from 'react'; + +export type WrapRef = Ref | undefined; + +export function assignRef( + ref: WrapRef, + instance: unknown +): (() => void) | undefined { + if (typeof ref === 'function') { + const cleanup = ref(instance as never); + return typeof cleanup === 'function' ? cleanup : undefined; + } + + if (ref) { + ref.current = instance; + } + + return undefined; +} + +export function isHostInstance(instance: unknown) { + return ( + (instance as { __internalInstanceHandle?: unknown } | null | undefined) + ?.__internalInstanceHandle !== undefined + ); +} + +export function preferHostInstance(instance: unknown) { + if (instance === null || instance === undefined || isHostInstance(instance)) { + return instance; + } + + const nativeRef = ( + instance as { getNativeScrollRef?: () => unknown } + ).getNativeScrollRef?.(); + + return isHostInstance(nativeRef) ? nativeRef : instance; +} + +export interface HostInstanceProvider { + getHostInstance: () => unknown; +} + +function providesHostInstance(ref: unknown): ref is HostInstanceProvider { + return ( + typeof (ref as HostInstanceProvider | null | undefined)?.getHostInstance === + 'function' + ); +} + +export function resolveHostInstance(ref: T): T { + if (!providesHostInstance(ref)) { + return ref; + } + + return (ref.getHostInstance() ?? ref) as T; +} From 6da0b2376b659ae8c65d2f601297c93b78eef2ad Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?Micha=C5=82?= Date: Wed, 9 Sep 2026 09:32:26 +0200 Subject: [PATCH 47/48] Reset config before update --- .../src/web/handlers/NativeViewGestureHandler.ts | 3 +++ 1 file changed, 3 insertions(+) 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 c05cdcfa3f..b515fc820a 100644 --- a/packages/react-native-gesture-handler/src/web/handlers/NativeViewGestureHandler.ts +++ b/packages/react-native-gesture-handler/src/web/handlers/NativeViewGestureHandler.ts @@ -31,6 +31,9 @@ export default class NativeViewGestureHandler extends GestureHandler { } public updateGestureConfig({ enabled = true, ...props }: Config): void { + // Config updates are full replaces, so restore the defaults first - the + // module never calls `resetConfig` on its own. + this.resetConfig(); super.updateGestureConfig({ enabled: enabled, ...props }); if (this.config.shouldActivateOnStart !== undefined) { From d21cb85bf9595f3c3855e4cddc16143bc42bdd97 Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?Micha=C5=82?= Date: Wed, 9 Sep 2026 09:39:50 +0200 Subject: [PATCH 48/48] Fix docs CI --- .../docs-gesture-handler/docs/gesture-handlers/pan-gh.md | 6 +++--- packages/docs-gesture-handler/docs/gestures/pan-gesture.md | 6 +++--- 2 files changed, 6 insertions(+), 6 deletions(-) diff --git a/packages/docs-gesture-handler/docs/gesture-handlers/pan-gh.md b/packages/docs-gesture-handler/docs/gesture-handlers/pan-gh.md index 923e39edbc..29aa28349f 100644 --- a/packages/docs-gesture-handler/docs/gesture-handlers/pan-gh.md +++ b/packages/docs-gesture-handler/docs/gesture-handlers/pan-gh.md @@ -54,15 +54,15 @@ Minimum distance the finger (or multiple finger) need to travel before the handl ### `minVelocity` -Minimum speed the pointer has to reach in order for the handler to [activate](/docs/2.x/under-the-hood/state#active). Expressed in points per second. +Minimum speed the pointer has to reach in order for the handler to [activate](/docs/under-the-hood/state#active). Expressed in points per second. ### `minVelocityX` -Minimum speed along X axis the pointer has to reach in order for the handler to [activate](/docs/2.x/under-the-hood/state#active). Expressed in points per second. +Minimum speed along X axis the pointer has to reach in order for the handler to [activate](/docs/under-the-hood/state#active). Expressed in points per second. ### `minVelocityY` -Minimum speed along Y axis the pointer has to reach in order for the handler to [activate](/docs/2.x/under-the-hood/state#active). Expressed in points per second. +Minimum speed along Y axis the pointer has to reach in order for the handler to [activate](/docs/under-the-hood/state#active). Expressed in points per second. ### `minPointers` diff --git a/packages/docs-gesture-handler/docs/gestures/pan-gesture.md b/packages/docs-gesture-handler/docs/gestures/pan-gesture.md index 456c7f5fe2..3a03b733b8 100644 --- a/packages/docs-gesture-handler/docs/gestures/pan-gesture.md +++ b/packages/docs-gesture-handler/docs/gestures/pan-gesture.md @@ -132,15 +132,15 @@ Minimum distance the finger (or multiple finger) need to travel before the gestu ### `minVelocity(value: number)` -Minimum speed the pointer has to reach in order for the gesture to [activate](/docs/2.x/fundamentals/states-events#active). Expressed in points per second. +Minimum speed the pointer has to reach in order for the gesture to [activate](/docs/fundamentals/states-events#active). Expressed in points per second. ### `minVelocityX(value: number)` -Minimum speed along X axis the pointer has to reach in order for the gesture to [activate](/docs/2.x/fundamentals/states-events#active). Expressed in points per second. +Minimum speed along X axis the pointer has to reach in order for the gesture to [activate](/docs/fundamentals/states-events#active). Expressed in points per second. ### `minVelocityY(value: number)` -Minimum speed along Y axis the pointer has to reach in order for the gesture to [activate](/docs/2.x/fundamentals/states-events#active). Expressed in points per second. +Minimum speed along Y axis the pointer has to reach in order for the gesture to [activate](/docs/fundamentals/states-events#active). Expressed in points per second. ### `minPointers(value: number)`