From 720a9b33d6a0a5e4547975011364d8c6fdbe3196 Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?Micha=C5=82?= Date: Thu, 15 Oct 2026 10:45:24 +0200 Subject: [PATCH 01/15] Skip hidden and transparent buttons in hit testing --- .../apple/RNGestureHandlerButtonComponentView.mm | 6 ++++++ 1 file changed, 6 insertions(+) diff --git a/packages/react-native-gesture-handler/apple/RNGestureHandlerButtonComponentView.mm b/packages/react-native-gesture-handler/apple/RNGestureHandlerButtonComponentView.mm index fda94c4bef..471e6730e9 100644 --- a/packages/react-native-gesture-handler/apple/RNGestureHandlerButtonComponentView.mm +++ b/packages/react-native-gesture-handler/apple/RNGestureHandlerButtonComponentView.mm @@ -259,8 +259,14 @@ - (void)updateProps:(const Props::Shared &)props oldProps:(const Props::Shared & // This is necessary because RCTViewComponentView's hitTest might handle pointerEvents // from ViewProps and prevent touches from reaching _buttonView (which is the contentView). // Since _buttonView has its own pointerEvents handling, we always forward to it. +// Keep the visibility checks RCTViewComponentView applies, so a hidden or +// (nearly) transparent button is skipped like any other RN view. - (UIView *)hitTest:(CGPoint)point withEvent:(UIEvent *)event { + if (!self.userInteractionEnabled || self.hidden || self.alpha < 0.01) { + return nil; + } + if (![self pointInside:point withEvent:event]) { return nil; } From aad1f45459eec4402629cb6b9827bb22c1ee8a6b Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?Micha=C5=82?= Date: Thu, 15 Oct 2026 10:49:45 +0200 Subject: [PATCH 02/15] Align the alpha traversal gate with iOS --- .../gesturehandler/react/RNGestureHandlerRootHelper.kt | 3 ++- 1 file changed, 2 insertions(+), 1 deletion(-) 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 edbdff9b19..a155595d4b 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 @@ -155,7 +155,8 @@ class RNGestureHandlerRootHelper(private val context: ReactContext, wrappedView: } companion object { - private const val MIN_ALPHA_FOR_TOUCH = 0.1f + // Same threshold as UIKit / RCTViewComponentView hit testing on iOS. + private const val MIN_ALPHA_FOR_TOUCH = 0.01f private fun findRootViewTag(viewGroup: ViewGroup): ViewGroup { UiThreadUtil.assertOnUiThread() var parent: ViewParent? = viewGroup From 96f5112905dac729506ea5365d6c3b1df2ff5810 Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?Micha=C5=82?= Date: Thu, 15 Oct 2026 10:54:07 +0200 Subject: [PATCH 03/15] [iOS] Fix `manualActivation` being ignored after a config update mid-gesture (#4520) On iOS a handler with `manualActivation: true` activates on its own if its config is updated mid-gesture. Any re-render during the gesture triggers that, since the config is re-sent on every render. `setConfig:` runs `resetConfig` before `updateConfig:`, and `resetConfig` went through the `manualActivation` setter, which removed the `RNManualActivationRecognizer` from the view. `UIKit` cancels a recognizer removed mid-touch, so the failure requirement it imposed on the handler was lost and the new blocker created by `updateConfig:` never saw the touch. `resetConfig` now only clears the flag and the blocker is reconciled once after the config is applied, only when its presence no longer matches the flag. Android and web already reset values only.
Repro ```tsx import React, { useRef, useState } from 'react'; import { StyleSheet, Text, View } from 'react-native'; import { GestureDetector, GestureStateManager, usePanGesture, } from 'react-native-gesture-handler'; const ACTIVATION_THRESHOLD = 20; type Phase = 'idle' | 'began' | 'active'; const color = (phase: Phase) => phase === 'active' ? '#21a37c' : phase === 'began' ? '#f6b914' : '#9b59b6'; export default function EmptyExample() { const [phaseA, setPhaseA] = useState('idle'); const [phaseB, setPhaseB] = useState('idle'); const neverActivatePan = usePanGesture({ manualActivation: true, runOnJS: true, onBegin: () => { console.log('[A never-activate] onBegin'); setPhaseA('began'); }, onActivate: () => { console.log('[A never-activate] onActivate (SHOULD NOT HAPPEN)'); setPhaseA('active'); }, onFinalize: (e) => { console.log(`[A never-activate] onFinalize canceled=${e.canceled}`); setPhaseA('idle'); }, }); const start = useRef({ x: 0, y: 0 }); const requested = useRef(false); const selfActivatePan = usePanGesture({ manualActivation: true, runOnJS: true, onTouchesDown: (e) => { const t = e.allTouches[0]; start.current = { x: t.x, y: t.y }; requested.current = false; }, onTouchesMove: (e) => { if (requested.current) { return; } const t = e.allTouches[0]; const dx = t.x - start.current.x; const dy = t.y - start.current.y; if (Math.hypot(dx, dy) > ACTIVATION_THRESHOLD) { requested.current = true; GestureStateManager.activate(e.handlerTag); } }, onBegin: () => { console.log('[B self-activate] onBegin'); setPhaseB('began'); }, onActivate: () => { console.log('[B self-activate] onActivate'); setPhaseB('active'); }, onFinalize: (e) => { console.log(`[B self-activate] onFinalize canceled=${e.canceled}`); setPhaseB('idle'); }, }); return ( A: manualActivation, never activated B: manualActivation, activate() after {ACTIVATION_THRESHOLD}px Purple = idle, orange = BEGAN, green = ACTIVE. Drag each box. A must never turn green. ); } const styles = StyleSheet.create({ container: { flex: 1, justifyContent: 'center', alignItems: 'center', gap: 8, }, label: { marginTop: 16, fontSize: 15, opacity: 0.6 }, hint: { marginTop: 24, fontSize: 13, opacity: 0.5, textAlign: 'center' }, box: { width: 150, height: 150, borderRadius: 12 }, }); ```
--- .../apple/RNGestureHandler.mm | 18 +++++++++++++++--- 1 file changed, 15 insertions(+), 3 deletions(-) diff --git a/packages/react-native-gesture-handler/apple/RNGestureHandler.mm b/packages/react-native-gesture-handler/apple/RNGestureHandler.mm index ea38a8334b..58c3fccd99 100644 --- a/packages/react-native-gesture-handler/apple/RNGestureHandler.mm +++ b/packages/react-native-gesture-handler/apple/RNGestureHandler.mm @@ -105,7 +105,7 @@ - (instancetype)initWithTag:(NSNumber *)tag - (void)resetConfig { self.enabled = YES; - self.manualActivation = NO; + _manualActivation = NO; _shouldCancelWhenOutside = NO; _handlersToWaitFor = nil; _simultaneousHandlers = nil; @@ -176,6 +176,8 @@ - (void)configure:(NSDictionary *)config RCTLogError(@"Cannot have all of top, bottom and height defined"); } } + + [self syncManualActivationRecognizer]; } - (void)setEnabled:(BOOL)enabled @@ -480,14 +482,24 @@ - (void)stopActivationBlocker - (void)setManualActivation:(BOOL)manualActivation { _manualActivation = manualActivation; + [self syncManualActivationRecognizer]; +} + +- (void)syncManualActivationRecognizer +{ + BOOL hasRecognizer = _manualActivationRecognizer != nil; - if (manualActivation) { + if (hasRecognizer == _manualActivation) { + return; + } + + if (_manualActivation) { _manualActivationRecognizer = [[RNManualActivationRecognizer alloc] initWithGestureHandler:self]; if (_recognizer.view != nil) { [_recognizer.view addGestureRecognizer:_manualActivationRecognizer]; } - } else if (_manualActivationRecognizer != nil) { + } else { [_manualActivationRecognizer.view removeGestureRecognizer:_manualActivationRecognizer]; _manualActivationRecognizer = nil; } From f0004f01ad1076c268ae39b866f43f52f7980000 Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?Micha=C5=82?= Date: Thu, 15 Oct 2026 10:57:22 +0200 Subject: [PATCH 04/15] [Web] Introduce key pointer in KeyboardEventManager (#4519) On web `KeyboardEventManager` turns `Enter` and `Space` into a synthetic pointer. It tracked the press with a single `isPressed` flag that was set on every activation keydown, so browser auto-repeat dispatched a pointer down roughly every 33 ms while a key was held. A held `Enter` on a `Tap` kept restarting the `maxDuration` timer, so the tap never failed and activated on release. The flag also survived `resetManager()`, so a handler reset mid-press could later receive a pointer up with no matching pointer down. The manager now stores the key that started the press in `keyPointer`. Repeated keydowns and other activation keys are ignored while a press is active, only the release of that key ends it, and Tab still cancels it. `resetManager()` clears the key, so after the orchestrator resets a handler the still-held key is ignored until it is physically pressed again. The activation and cancelation key lists became sets.
Tested on the following code: ```tsx import React, { useCallback, useRef, useState } from 'react'; import { Pressable, StyleSheet, Text, View } from 'react-native'; import { GestureDetector, useTapGesture } from 'react-native-gesture-handler'; import { COLORS, commonStyles } from '../../../common'; // Web: KeyboardEventManager turns Enter/Space into a synthetic pointer, but // every auto-repeated `keydown` dispatches another pointer down. A held Enter // keeps restarting Tap's maxDuration timer, so the tap never fails and // activates on release. PR #4518 discussion. // // Scenario A: focus the box (click it or Tab to it), hold Enter ~2s, release. // Broken: touches down keeps climbing, onActivate fires on release. // Expected: 1 touches down, onBegin, onFinalize ~500ms later, nothing on release. // // Scenario B: hold Enter, tap Space (press + release), keep holding Enter. // Expected: Space does nothing. The press belongs to Enter. // // Scenario C: hold Enter, press Tab (cancel), keep holding Enter. // Expected: onFinalize once, no new onBegin from the still-held Enter. const MAX_DURATION_MS = 500; type Counters = { touchesDown: number; touchesUp: number; begin: number; activate: number; finalize: number; }; const EMPTY: Counters = { touchesDown: 0, touchesUp: 0, begin: 0, activate: 0, finalize: 0, }; export default function KeyboardHoldExample() { const [counters, setCounters] = useState(EMPTY); const [log, setLog] = useState([]); const startTime = useRef(null); const record = useCallback((key: keyof Counters, label: string) => { const now = Date.now(); if (startTime.current === null) { startTime.current = now; } const elapsed = now - startTime.current; setCounters((c) => ({ ...c, [key]: c[key] + 1 })); setLog((l) => [`+${elapsed}ms ${label}`, ...l].slice(0, 30)); }, []); const reset = useCallback(() => { startTime.current = null; setCounters(EMPTY); setLog([]); }, []); const tap = useTapGesture({ maxDuration: MAX_DURATION_MS, runOnJS: true, onTouchesDown: () => record('touchesDown', 'onTouchesDown'), onTouchesUp: () => record('touchesUp', 'onTouchesUp'), onBegin: () => record('begin', 'onBegin'), onActivate: () => record('activate', 'onActivate <-- tap fired'), onFinalize: (e) => record('finalize', `onFinalize (${e.canceled ? 'canceled' : 'ended'})`), }); return ( Keyboard hold (web) Focus the box, hold Enter for ~2s, release. Expected: one touches down, onBegin, onFinalize after {MAX_DURATION_MS}ms, nothing on release. counters.finalize && styles.boxPressed, ]} /> Reset {log.map((line, i) => ( {line} ))} ); } function Counter({ label, value }: { label: string; value: number }) { return ( {label}: {value} ); } const styles = StyleSheet.create({ container: { flex: 1, alignItems: 'center', padding: 16, gap: 12, }, hint: { textAlign: 'center', maxWidth: 420, color: '#555', }, box: { backgroundColor: COLORS.NAVY, }, boxPressed: { backgroundColor: COLORS.KINDA_BLUE, }, counters: { alignItems: 'flex-start', gap: 2, }, counter: { fontFamily: 'monospace', }, counterValue: { fontWeight: 'bold', }, reset: { paddingHorizontal: 16, paddingVertical: 8, borderRadius: 6, backgroundColor: '#ddd', }, resetText: { fontWeight: '600', }, log: { alignSelf: 'stretch', gap: 1, }, logLine: { fontFamily: 'monospace', fontSize: 12, }, }); ```
--- .../src/web/tools/KeyboardEventManager.ts | 38 ++++++++++--------- 1 file changed, 20 insertions(+), 18 deletions(-) diff --git a/packages/react-native-gesture-handler/src/web/tools/KeyboardEventManager.ts b/packages/react-native-gesture-handler/src/web/tools/KeyboardEventManager.ts index ba7cf4cc78..4d0362ae88 100644 --- a/packages/react-native-gesture-handler/src/web/tools/KeyboardEventManager.ts +++ b/packages/react-native-gesture-handler/src/web/tools/KeyboardEventManager.ts @@ -3,9 +3,10 @@ import EventManager from './EventManager'; import { PointerType } from '../../PointerType'; export default class KeyboardEventManager extends EventManager { - private static activationKeys = ['Enter', ' ']; - private static cancelationKeys = ['Tab']; - private isPressed = false; + private static activationKeys = new Set(['Enter', ' ']); + private static cancelationKeys = new Set(['Tab']); + private keyPointer: string | null = null; + private static registeredStaticListeners = false; private static instances: Set = new Set(); @@ -13,8 +14,7 @@ export default class KeyboardEventManager extends EventManager { // We need a global listener, as in some cases, keyUp event gets stop-propagated. // Then, if we used only component-level listeners the gesture would never end, // causing other gestues to fail. - - if (this.activationKeys.indexOf(event.key) === -1) { + if (!this.activationKeys.has(event.key)) { return; } @@ -24,30 +24,30 @@ export default class KeyboardEventManager extends EventManager { }; private keyDownCallback = (event: KeyboardEvent): void => { - if ( - KeyboardEventManager.cancelationKeys.indexOf(event.key) !== -1 && - this.isPressed - ) { - this.dispatchEvent(event, EventTypes.CANCEL); + if (this.keyPointer !== null) { + if (KeyboardEventManager.cancelationKeys.has(event.key)) { + this.dispatchEvent(event, EventTypes.CANCEL); + this.keyPointer = null; + } + return; } - if (KeyboardEventManager.activationKeys.indexOf(event.key) === -1) { + if (!KeyboardEventManager.activationKeys.has(event.key) || event.repeat) { return; } + this.keyPointer = event.key; this.dispatchEvent(event, EventTypes.DOWN); }; private onKeyUp = (event: KeyboardEvent): void => { - if ( - KeyboardEventManager.activationKeys.indexOf(event.key) === -1 || - !this.isPressed - ) { + if (this.keyPointer !== event.key) { return; } this.dispatchEvent(event, EventTypes.UP); + this.keyPointer = null; }; private dispatchEvent(event: KeyboardEvent, eventType: EventTypes) { @@ -59,15 +59,12 @@ export default class KeyboardEventManager extends EventManager { switch (eventType) { case EventTypes.UP: - this.isPressed = false; this.onPointerUp(adaptedEvent); break; case EventTypes.DOWN: - this.isPressed = true; this.onPointerDown(adaptedEvent); break; case EventTypes.CANCEL: - this.isPressed = false; this.onPointerCancel(adaptedEvent); break; } @@ -103,6 +100,11 @@ export default class KeyboardEventManager extends EventManager { } } + public override resetManager(): void { + super.resetManager(); + this.keyPointer = null; + } + protected mapEvent( event: KeyboardEvent, eventType: EventTypes From 774b45979e8c0ea2c954cb68d4ec84b24523cac7 Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?Micha=C5=82?= Date: Thu, 15 Oct 2026 11:06:05 +0200 Subject: [PATCH 05/15] Scan mount relations directly instead of rebuilding tag arrays (#4546) MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Fixes #4540 `shouldUpdateDetector` resolved every relation entry through `transformIntoHandlerTags` on each gesture mount. That call runs `toArray`, `map` and `filter`, so each mount allocated three arrays per relation, for three relations, for every attached gesture of every mounted detector. The issue reports ~800ms of JS thread blocking when opening a sheet with ~50 pressables on a Pixel 7 Pro, with several hundred detectors mounted app-wide. Most of that work could never match. Only refs can start pointing at a different handler after the detector attached, because `current` is filled in when the gesture they point at mounts. Gestures and numeric tags already carry their tag by then — and `transformIntoHandlerTags` mapped them to `-1` and filtered them out anyway. This walks the relation array directly and compares `current.handlerTag`, skipping entries that cannot resolve late. No allocations, and the impossible comparisons are gone. It also fixes late-mounted relations on web, the secondary issue in the report: the web branch of `transformIntoHandlerTags` returns handler objects, which the caller compared against a numeric `handlerTag`, so the check was always false and those relations never re-attached. Comparing tags works on both platforms. Behaviour for gesture objects and numeric tags is unchanged — they were already filtered out before reaching the comparison. New `src/__tests__/useMountReactions.test.ts`, run with `Platform.OS` mocked to `web` so it covers the branch that was broken: - a ref in `blocksHandlers`, `requireToFail` or `simultaneousWith` resolving on mount updates the detector — these three fail without the change, since the web path never matched - an unrelated gesture mounting does not update the detector - an unresolved ref (`current: null`) does not update the detector - gesture objects and raw tags do not update the detector, pinning the unchanged behaviour - an already-unmounted detector is not updated Verified in `packages/react-native-gesture-handler`: `yarn jest` 185 passing across 22 suites, `yarn ts-check` clean, `yarn lint:js` clean on the changed files. Not verified on a device or simulator — this environment has neither, so the Android and iOS builds and the example apps were not exercised. --- .../GestureDetector/useMountReactions.ts | 16 +++++++++++++--- 1 file changed, 13 insertions(+), 3 deletions(-) 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 ff4628f24f..9a9a6b4b8d 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 @@ -1,4 +1,3 @@ -import { transformIntoHandlerTags } from '../../utils'; import { MountRegistry } from '../../../mountRegistry'; import { AttachedGestureState } from './types'; import { useEffect } from 'react'; @@ -12,8 +11,19 @@ function shouldUpdateDetector( return false; } - for (const tag of transformIntoHandlerTags(relation)) { - if (tag === gesture.handlerTag) { + for (const entry of relation) { + // Only refs can start pointing at a different handler after the detector + // attached, because `current` is filled in when the gesture they point at + // mounts. Gestures and numeric tags already carry their tag by then, so a + // mount can never change what they resolve to and scanning them is wasted + // work on every mount of every detector. + if (entry === null || typeof entry !== 'object' || !('current' in entry)) { + continue; + } + + const current = entry.current as { handlerTag?: number } | null | undefined; + + if (current?.handlerTag === gesture.handlerTag) { return true; } } From 7adbee77df2e7145b0df6f1f396e31aff660b513 Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?Micha=C5=82?= Date: Thu, 15 Oct 2026 11:07:45 +0200 Subject: [PATCH 06/15] [Web] Fix view relative coordinates of trackpad pan events (#4531) `WheelEventManager` synthesizes a moving pointer out of a stationary cursor by accumulating wheel deltas, but only the page coordinates use the accumulator: ```js this.wheelDelta.x += event.deltaX; // wheelCallback x: event.clientX + this.wheelDelta.x, // accumulated offsetX: event.offsetX - event.deltaX, // one event, opposite sign ``` Repro: a Pan with `enableTrackpadTwoFingerGesture` on web, three trackpad frames of `deltaY: 10` from `clientY: 0`, `offsetY: 0`. `absoluteY` reads 10, 20, 30 while `y` reads -10, -10, -10. `y` points the wrong way and never advances for the whole gesture. `translationY`, `velocityY` and `absoluteY` are fine, they come off the page coordinates. `x`/`y` and `absoluteX`/`absoluteY` are the same point in two spaces, so they can only differ by the view's page offset. Every other producer keeps that invariant: `PointerEventManager.ts:230` derives `offsetX` from the same `clientX` it writes to `x`, `KeyboardEventManager.ts:120-129` writes the view centre into both, `ScrollEventManager.ts:26-29` writes one value twice, and `GestureHandlerButton.web.tsx:51-53` computes `x` from `absoluteX`. `WheelEventManager.ts:35-36` is the only place where the two move apart. The fix, as suggested in review, derives the view relative position from the accumulated page position the same way `PointerEventManager` does: `(x - rect.left) / scaleX`, with `getEffectiveBoundingRect` and `calculateViewScale` on the handler's view. This also stops relying on `event.offsetX`, which is relative to `event.target` rather than to the handler's view, and is in the target's untransformed space. With the v3 detector the view is the `display: contents` wrapper, so the target is always one of its descendants. The cost is one `getBoundingClientRect` per wheel event, the same read `PointerEventManager` does per pointer move. `onWheel` is overridden only by `PanGestureHandler`, and the wheel `offsetX`/`offsetY` reaches exactly one consumer, `PointerTracker.relativeCoords` -> `GestureHandler.transformNativeEvent()`. `yarn workspace react-native-gesture-handler test` - 182 passed, was 178. Merged onto current `main` it is 189, was 185. The `PanGestureHandler trackpad coordinates` cases drive the real `WheelEventManager` off a fake view with a mocked `getBoundingClientRect` and computed style, and assert `x`/`y` against `absoluteX`/`absoluteY` for: - a view at the page origin, - a view at (40, 60), - a wheel over a child of that view, where `offsetX`/`offsetY` are relative to the child, - a view scaled 2x by its transform. On main all four fail, the first one reports `x: -5, y: -10` against `absoluteX: 15, absoluteY: 30`. The previous revision of this PR (`event.offsetX` plus the accumulated delta) passed the first two but reported `x: 25, y: 40` instead of `55, 70` for the child and `x: 20, y: 40` instead of `12.5, 25` for the scaled view. `ts-check`, `lint-js` and `circular-dependency-check` are clean. --- .../src/web/tools/WheelEventManager.ts | 15 +++++++++++---- 1 file changed, 11 insertions(+), 4 deletions(-) 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 627cbaac09..350c84e352 100644 --- a/packages/react-native-gesture-handler/src/web/tools/WheelEventManager.ts +++ b/packages/react-native-gesture-handler/src/web/tools/WheelEventManager.ts @@ -1,6 +1,7 @@ import EventManager from './EventManager'; import { AdaptedEvent, EventTypes } from '../interfaces'; import { PointerType } from '../../PointerType'; +import { calculateViewScale } from '../utils'; export default class WheelEventManager extends EventManager { private wheelDelta = { x: 0, y: 0 }; @@ -28,11 +29,17 @@ export default class WheelEventManager extends EventManager { } protected mapEvent(event: WheelEvent): AdaptedEvent { + const rect = this.view.getBoundingClientRect(); + const { scaleX, scaleY } = calculateViewScale(this.view); + + const x = event.clientX + this.wheelDelta.x; + const y = event.clientY + this.wheelDelta.y; + return { - x: event.clientX + this.wheelDelta.x, - y: event.clientY + this.wheelDelta.y, - offsetX: event.offsetX - event.deltaX, - offsetY: event.offsetY - event.deltaY, + x, + y, + offsetX: (x - rect.left) / scaleX, + offsetY: (y - rect.top) / scaleY, pointerId: -1, eventType: EventTypes.MOVE, pointerType: PointerType.OTHER, From 05e06cad1e6be8f7a6f4109f81a9626123ae7597 Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?Micha=C5=82?= Date: Thu, 15 Oct 2026 12:29:01 +0200 Subject: [PATCH 07/15] [Web] Register the wheel listener as passive and only for Pan (#4583) Adds `{ passive: true }` option to the `wheel` event listener as chrome logs violation when no `preventDefault` is called Tested on the "Ywo finger trackpad pan" example --- .../src/web/tools/GestureHandlerWebDelegate.ts | 7 ++++++- .../src/web/tools/WheelEventManager.ts | 4 +++- 2 files changed, 9 insertions(+), 2 deletions(-) diff --git a/packages/react-native-gesture-handler/src/web/tools/GestureHandlerWebDelegate.ts b/packages/react-native-gesture-handler/src/web/tools/GestureHandlerWebDelegate.ts index c545cef537..9f22c07cda 100644 --- a/packages/react-native-gesture-handler/src/web/tools/GestureHandlerWebDelegate.ts +++ b/packages/react-native-gesture-handler/src/web/tools/GestureHandlerWebDelegate.ts @@ -12,6 +12,7 @@ import { Config } from '../interfaces'; import { MouseButton } from '../../handlers/gestureHandlerCommon'; import KeyboardEventManager from './KeyboardEventManager'; import WheelEventManager from './WheelEventManager'; +import PanGestureHandler from '../handlers/PanGestureHandler'; interface DefaultViewStyles { userSelect: string; @@ -49,6 +50,7 @@ export class GestureHandlerWebDelegate }; const config = handler.config; + const shouldSendWheelEvents = handler instanceof PanGestureHandler; this.setUserSelect(config.enabled); this.setTouchAction(config.enabled); @@ -56,7 +58,10 @@ export class GestureHandlerWebDelegate this.eventManagers.push(new PointerEventManager(this.view)); this.eventManagers.push(new KeyboardEventManager(this.view)); - this.eventManagers.push(new WheelEventManager(this.view)); + + if (shouldSendWheelEvents) { + this.eventManagers.push(new WheelEventManager(this.view)); + } this.eventManagers.forEach((manager) => this.gestureHandler.attachEventManager(manager) 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 350c84e352..bf64dde55f 100644 --- a/packages/react-native-gesture-handler/src/web/tools/WheelEventManager.ts +++ b/packages/react-native-gesture-handler/src/web/tools/WheelEventManager.ts @@ -20,7 +20,9 @@ export default class WheelEventManager extends EventManager { public registerListeners(): void { this.view.addEventListener('pointermove', this.resetDelta); - this.view.addEventListener('wheel', this.wheelCallback); + this.view.addEventListener('wheel', this.wheelCallback, { + passive: true, + }); } public unregisterListeners(): void { From e53f9260c92c5f42286b48fb9c50a027c8bebf46 Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?Micha=C5=82?= Date: Thu, 15 Oct 2026 12:31:49 +0200 Subject: [PATCH 08/15] [Android] Fix NPE when a touch cancel callback finishes the handler mid-transition (#4584) `moveToState` dispatches the touch cancel event (`cancelPointers`) before it assigns the new state. The event is delivered synchronously, so a callback can finish the handler in the meantime, e.g. `onTouchesCancel` calling `GestureStateManager.fail`. The nested transition goes through the orchestrator, which resets the handler (`orchestrator = null`). The outer transition then continues on the reset handler and dereferences `orchestrator!!`. If the host detector is still attached it instead re-prepares the handler into a recorded one with `view == null` and state `UNDETERMINED`. The posted cancel from `RNGestureHandlerRegistry.detachHandlerInternal` is the usual entry point: it runs outside touch handling, so the cleanup is not deferred. The handler must still track pointers when it runs, which happens when its detector gets detached natively while the finger is down (e.g. `removeClippedSubviews` clipping it out of a scroll view) and no touch move arrives before the runnable. After `cancelPointers` the transition now returns early if the handler is already finished. `isFinished` is moved from the orchestrator to `GestureHandler` so both can use it. Web assigns the state before `cancelTouches`, iOS sets the recognizer state before cancelling pointers, so only Android is affected.
Tested on the following code: ```tsx import { useRef, useState } from 'react'; import { ScrollView, StyleSheet, Text, View } from 'react-native'; import { GestureDetector, GestureHandlerRootView, GestureStateManager, useManualGesture, } from 'react-native-gesture-handler'; import { runOnJS } from 'react-native-reanimated'; const log = (...args: unknown[]) => console.log('[4567]', ...args); export default function Issue4567() { const scrollRef = useRef(null); const [runs, setRuns] = useState(0); const onDown = () => { setTimeout(() => { log('scrolling box out of the clip rect'); scrollRef.current?.scrollTo({ y: 2000, animated: false }); setTimeout(() => { scrollRef.current?.scrollTo({ y: 0, animated: false }); setRuns((r) => r + 1); }, 1500); }, 700); }; const gesture = useManualGesture({ onTouchesDown: (e) => { 'worklet'; runOnJS(log)('touches down', e.handlerTag); runOnJS(onDown)(); GestureStateManager.activate(e.handlerTag); }, onTouchesCancel: (e) => { 'worklet'; runOnJS(log)('touches cancel', e.handlerTag, e.state); // sortables: onTouchesCancelled -> control.fail() GestureStateManager.fail(e.handlerTag); runOnJS(log)('fail() returned'); }, onTouchesUp: (e) => { 'worklet'; runOnJS(log)('touches up', e.handlerTag); GestureStateManager.deactivate(e.handlerTag); }, onFinalize: (e) => { 'worklet'; runOnJS(log)('onFinalize state', e.state); }, }); return ( Hold the box still for over 700 ms. It gets clipped out of the ScrollView mid-hold. Runs: {runs} press and hold (still) ); } const styles = StyleSheet.create({ root: { flex: 1 }, info: { padding: 16, textAlign: 'center' }, scroll: { flex: 1 }, content: { alignItems: 'center', paddingTop: 40 }, box: { width: 220, height: 200, borderRadius: 16, backgroundColor: '#5b7cfa', alignItems: 'center', justifyContent: 'center', }, spacer: { height: 4000 }, label: { color: 'white', fontWeight: '600' }, }); ```
--- .../swmansion/gesturehandler/core/GestureHandler.kt | 12 +++++++++--- .../core/GestureHandlerOrchestrator.kt | 12 ++++-------- 2 files changed, 13 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 a0caa5ae99..24237ed304 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 @@ -568,10 +568,14 @@ open class GestureHandler { } // if there are tracked pointers and the gesture is about to end, send event cancelling all pointers - if (trackedPointersCount > 0 && - (newState == STATE_END || newState == STATE_CANCELLED || newState == STATE_FAILED) - ) { + if (trackedPointersCount > 0 && isFinished(newState)) { cancelPointers() + + // `cancelPointers` dispatches the event synchronously and its callback may change the state + // (e.g. via `GestureStateManager.fail`), in which case the transition is already complete. + if (isFinished(state)) { + return + } } val oldState = state @@ -951,6 +955,8 @@ open class GestureHandler { } companion object { + fun isFinished(state: Int) = state == STATE_CANCELLED || state == STATE_FAILED || state == STATE_END + private const val DEFAULT_NEEDS_POINTER_DATA = false private const val DEFAULT_MANUAL_ACTIVATION = false private const val DEFAULT_SHOULD_CANCEL_WHEN_OUTSIDE = false 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 7d9cb45bf2..5fc9252ec5 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 @@ -97,7 +97,7 @@ class GestureHandlerOrchestrator( private fun cleanupFinishedHandlers() { for (handler in gestureHandlers.asReversed()) { - if (isFinished(handler.state) && !handler.isAwaiting) { + if (GestureHandler.isFinished(handler.state) && !handler.isAwaiting) { handler.reset() handler.apply { isActive = false @@ -107,13 +107,13 @@ class GestureHandlerOrchestrator( } } - gestureHandlers.removeAll { isFinished(it.state) && !it.isAwaiting } + gestureHandlers.removeAll { GestureHandler.isFinished(it.state) && !it.isAwaiting } finishedHandlersCleanupScheduled = false } private fun hasOtherHandlerToWaitFor(handler: GestureHandler) = - gestureHandlers.any { !isFinished(it.state) && shouldHandlerWaitForOther(handler, it) } + gestureHandlers.any { !GestureHandler.isFinished(it.state) && shouldHandlerWaitForOther(handler, it) } private fun shouldBeCancelledByFinishedHandler(handler: GestureHandler) = gestureHandlers.any { shouldHandlerWaitForOther(handler, it) && it.state == GestureHandler.STATE_END } @@ -163,7 +163,7 @@ class GestureHandlerOrchestrator( handler.isAwaiting = false } - if (isFinished(newState)) { + if (GestureHandler.isFinished(newState)) { // We have to loop through copy in order to avoid modifying collection // while iterating over its elements val currentlyAwaitingHandlers = awaitingHandlers.toList() @@ -927,9 +927,5 @@ class GestureHandlerOrchestrator( true } } - - private fun isFinished(state: Int) = state == GestureHandler.STATE_CANCELLED || - state == GestureHandler.STATE_FAILED || - state == GestureHandler.STATE_END } } From c424f8085d6cac1e92da8bb91b8561b26412bf48 Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?Micha=C5=82?= Date: Thu, 15 Oct 2026 12:34:19 +0200 Subject: [PATCH 09/15] [General] Link late-mounted gesture objects and skip mount reactions without external relations (#4562) Follow-up to #4546. A relation that holds a gesture object (`simultaneousWithExternalGesture(gesture)`) to a gesture mounted by a different detector in a later commit never links on native. When the outer detector attaches, the object still has tag -1, and the mount listener only matches ref entries, so nothing re-sends the config once the object gets its tag. The relation stays dead until the outer detector happens to re-render, which hides the problem. The listener now also matches gesture objects by identity: the mount event carries the very object stored in the relation. Composition fills relations with sibling gesture objects, so without a guard every composed detector would update itself on mount. The listener skips mounts of the detector's own gestures, which requires assigning `attachedGestures` before the mount events fire in `attachHandlers`. Most detectors have nothing that can resolve late, yet every one of them still walks its relations on every mount of every other gesture. `AttachedGestureState` gets a `hasExternalRelations` flag, computed in the attach and update microtasks. It is true only when some relation entry is a ref or a gesture object owned by another detector; numeric tags and composition siblings never resolve late. Detectors with the flag unset return from the listener immediately. On a screen with 300 `LegacyPressable`s, mounting 50 more spent 38 ms in mount listeners before and 5.5 ms after.
Tested on the following code: ```tsx import React, { useLayoutEffect, useRef, useState, useSyncExternalStore, } from 'react'; import { Button, StyleSheet, Text, View } from 'react-native'; import type { GestureType } from 'react-native-gesture-handler'; import { Gesture, GestureDetector, LegacyPressable, } from 'react-native-gesture-handler'; // #4540 follow-up demo (v2 detector only, v3 has no MountRegistry). // // Late relations: the OUTER pan declares `simultaneousWithExternalGesture` to // an INNER pan whose detector mounts later (toggle). Without the link the inner // (deeper) pan wins and the outer counter never moves; with it both update. // Row 1: relation holds the inner Gesture OBJECT (fixed by the follow-up). // Row 2: relation holds a REF filled by `withRef` (fixed by PR #4546). // // Perf: N composed background detectors (LegacyPressable = Simultaneous of 3) // plus "mount 50 more", timing the commit of the newly mounted batch. // Tiny external store so the outer detectors never re-render: a v2 detector // re-sends its config on every re-render, which would repair the relation in // the same commit the inner gesture mounts and hide the bug. type Counters = { outerObj: number; innerObj: number; outerRef: number; innerRef: number; }; const store = { mounted: false, counters: { outerObj: 0, innerObj: 0, outerRef: 0, innerRef: 0, } as Counters, listeners: new Set<() => void>(), subscribe: (listener: () => void) => { store.listeners.add(listener); return () => store.listeners.delete(listener); }, emit: () => { store.listeners.forEach((l) => l()); }, toggle: () => { store.mounted = !store.mounted; store.emit(); }, bump: (key: keyof Counters) => { store.counters = { ...store.counters, [key]: store.counters[key] + 1 }; store.emit(); }, }; // Row 1: gesture object relation. const innerPanObj = Gesture.Pan() .runOnJS(true) .onUpdate(() => store.bump('innerObj')); const outerPanObj = Gesture.Pan() .runOnJS(true) .simultaneousWithExternalGesture(innerPanObj) .onUpdate(() => store.bump('outerObj')); // Row 2: ref relation. const innerRefHolder: React.MutableRefObject = { current: undefined, }; const innerPanRef = Gesture.Pan() .runOnJS(true) .withRef(innerRefHolder) .onUpdate(() => store.bump('innerRef')); const outerPanRef = Gesture.Pan() .runOnJS(true) .simultaneousWithExternalGesture(innerRefHolder) .onUpdate(() => store.bump('outerRef')); function InnerSlot({ inner }: { inner: GestureType }) { const mounted = useSyncExternalStore(store.subscribe, () => store.mounted); return mounted ? ( ) : ( ); } function CounterLabel({ prefix, outerKey, innerKey, }: { prefix: string; outerKey: keyof Counters; innerKey: keyof Counters; }) { const counters = useSyncExternalStore(store.subscribe, () => store.counters); return ( {`${prefix} outer ${counters[outerKey]} / inner ${counters[innerKey]}`} ); } function ToggleButton() { const mounted = useSyncExternalStore(store.subscribe, () => store.mounted); return (
--- .../GestureDetector/attachHandlers.ts | 13 +++++-- .../gestures/GestureDetector/index.tsx | 4 +++ .../gestures/GestureDetector/types.ts | 3 ++ .../GestureDetector/updateHandlers.ts | 8 +++++ .../GestureDetector/useMountReactions.ts | 27 ++++++++++---- .../gestures/GestureDetector/utils.ts | 36 +++++++++++++++++++ 6 files changed, 83 insertions(+), 8 deletions(-) diff --git a/packages/react-native-gesture-handler/src/handlers/gestures/GestureDetector/attachHandlers.ts b/packages/react-native-gesture-handler/src/handlers/gestures/GestureDetector/attachHandlers.ts index 4af2b57031..aef56ed329 100644 --- a/packages/react-native-gesture-handler/src/handlers/gestures/GestureDetector/attachHandlers.ts +++ b/packages/react-native-gesture-handler/src/handlers/gestures/GestureDetector/attachHandlers.ts @@ -13,6 +13,7 @@ import { extractGestureRelations, checkGestureCallbacksForWorklets, ALLOWED_PROPS, + hasExternalRelations, } from './utils'; import { MountRegistry } from '../../../mountRegistry'; @@ -53,6 +54,9 @@ export function attachHandlers({ registerHandler(handler.handlerTag, handler, handler.config.testId); } + // React to every mount until the microtask below classifies the new gesture set + preparedGesture.hasExternalRelations = true; + // Use queueMicrotask to extract handlerTags, because all refs should be initialized // when it's ran ghQueueMicrotask(() => { @@ -70,9 +74,16 @@ export function attachHandlers({ ); } + preparedGesture.hasExternalRelations = + hasExternalRelations(gesturesToAttach); + scheduleFlushOperations(); }); + // Set before the mount events below fire, so this detector's own mount + // listener can tell its gestures apart from external ones + preparedGesture.attachedGestures = gesturesToAttach; + for (const gesture of gesturesToAttach) { const actionType = gesture.shouldUseReanimated ? ActionType.REANIMATED_WORKLET @@ -98,8 +109,6 @@ export function attachHandlers({ MountRegistry.gestureWillMount(gesture); } - preparedGesture.attachedGestures = gesturesToAttach; - if (preparedGesture.animatedHandlers) { const isAnimatedGesture = (g: GestureType) => g.shouldUseReanimated; 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 0ad930fe04..e39e930392 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 @@ -127,6 +127,10 @@ export const GestureDetector = (props: GestureDetectorProps) => { animatedHandlers: null, shouldUseReanimated: shouldUseReanimated, isMounted: false, + // The real value is computed in the attach microtask, which runs after + // this commit's effects. Until then react to every mount, so a relation + // to a gesture mounting in the same commit is never skipped. + hasExternalRelations: true, }).current; const updateAttachedGestures = useDetectorUpdater( diff --git a/packages/react-native-gesture-handler/src/handlers/gestures/GestureDetector/types.ts b/packages/react-native-gesture-handler/src/handlers/gestures/GestureDetector/types.ts index 398e55e870..9baafc11d0 100644 --- a/packages/react-native-gesture-handler/src/handlers/gestures/GestureDetector/types.ts +++ b/packages/react-native-gesture-handler/src/handlers/gestures/GestureDetector/types.ts @@ -15,6 +15,9 @@ export interface AttachedGestureState { shouldUseReanimated: boolean; // Whether the GestureDetector is mounted isMounted: boolean; + // Whether any attached gesture has a relation to a gesture outside this + // detector (a ref or a gesture object), which may resolve on a later mount + hasExternalRelations: boolean; } export interface GestureDetectorState { diff --git a/packages/react-native-gesture-handler/src/handlers/gestures/GestureDetector/updateHandlers.ts b/packages/react-native-gesture-handler/src/handlers/gestures/GestureDetector/updateHandlers.ts index 22906ebb89..9d7fdd68b4 100644 --- a/packages/react-native-gesture-handler/src/handlers/gestures/GestureDetector/updateHandlers.ts +++ b/packages/react-native-gesture-handler/src/handlers/gestures/GestureDetector/updateHandlers.ts @@ -9,6 +9,7 @@ import { extractGestureRelations, checkGestureCallbacksForWorklets, ALLOWED_PROPS, + hasExternalRelations, } from './utils'; export function updateHandlers( @@ -33,6 +34,9 @@ export function updateHandlers( // Store attached gestures to avoid crash when gestures changed after queueing micro task const attachedGestures = preparedGesture.attachedGestures; + // React to every mount until the microtask below classifies the new gesture set + preparedGesture.hasExternalRelations = true; + // Use queueMicrotask to extract handlerTags, because when it's ran, all refs should be updated // and handlerTags in BaseGesture references should be updated in the loop above (we need to wait // in case of external relations) @@ -77,6 +81,10 @@ export function updateHandlers( registerHandler(handler.handlerTag, handler, handler.config.testId); } + // Classify the new instances: their configs reference the new siblings, + // which the old attached objects would mistake for external gestures + preparedGesture.hasExternalRelations = hasExternalRelations(newGestures); + if (preparedGesture.animatedHandlers && shouldUpdateSharedValueIfUsed) { const newHandlersValue = attachedGestures .filter((g) => g.shouldUseReanimated) // Ignore gestures that shouldn't run on UI 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 9a9a6b4b8d..ceb78b844f 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 @@ -1,22 +1,26 @@ +import type { GestureMountListener } from '../../../mountRegistry'; import { MountRegistry } from '../../../mountRegistry'; import { AttachedGestureState } from './types'; import { useEffect } from 'react'; import { GestureRef } from '../gesture'; +type MountedGesture = Parameters[0]; + function shouldUpdateDetector( relation: GestureRef[] | undefined, - gesture: { handlerTag: number } + gesture: MountedGesture ) { if (relation === undefined) { return false; } for (const entry of relation) { - // Only refs can start pointing at a different handler after the detector - // attached, because `current` is filled in when the gesture they point at - // mounts. Gestures and numeric tags already carry their tag by then, so a - // mount can never change what they resolve to and scanning them is wasted - // work on every mount of every detector. + // A gesture object gets its tag in `initialize`, when its own detector + // attaches. The mount event carries that very object, so identity is enough. + if (entry === gesture) { + return true; + } + if (entry === null || typeof entry !== 'object' || !('current' in entry)) { continue; } @@ -43,6 +47,17 @@ export function useMountReactions( return; } + // Nothing here can resolve late, skip the scan (most detectors) + if (!state.hasExternalRelations) { + return; + } + + // Own gestures are resolved by the attach microtask, and on a reattach + // they would match the sibling entries added by composition + if (state.attachedGestures.includes(gesture as never)) { + 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. diff --git a/packages/react-native-gesture-handler/src/handlers/gestures/GestureDetector/utils.ts b/packages/react-native-gesture-handler/src/handlers/gestures/GestureDetector/utils.ts index f09f4ec916..30f23817aa 100644 --- a/packages/react-native-gesture-handler/src/handlers/gestures/GestureDetector/utils.ts +++ b/packages/react-native-gesture-handler/src/handlers/gestures/GestureDetector/utils.ts @@ -64,6 +64,42 @@ function extractValidHandlerTags(interactionGroup: GestureRef[] | undefined) { ); } +// Relations that may resolve to a different handler after this detector attached: +// refs (filled when the target mounts) and gesture objects attached by another +// detector (they get a tag in `initialize`). Siblings added by composition are +// attached together with the gesture, so they never resolve late. +export function hasExternalRelations(gestures: GestureType[]) { + const ownGestures = new Set(gestures); + + for (const gesture of gestures) { + const relations = [ + gesture.config.blocksHandlers, + gesture.config.requireToFail, + gesture.config.simultaneousWith, + ]; + + for (const relation of relations) { + if (relation === undefined) { + continue; + } + + for (const entry of relation) { + if (typeof entry === 'number') { + continue; + } + + // Anything but our own gesture object is external: a ref, or a gesture + // attached by another detector + if (!(entry instanceof BaseGesture) || !ownGestures.has(entry)) { + return true; + } + } + } + } + + return false; +} + export function extractGestureRelations(gesture: GestureType) { gesture.config.requireToFail = extractValidHandlerTags( gesture.config.requireToFail From c3b7245f4668bc72de5d172e427cc71e3c4fedf2 Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?Micha=C5=82?= Date: Thu, 15 Oct 2026 12:44:07 +0200 Subject: [PATCH 10/15] fix(android): respect pointer events on gesture root views (#4565) MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit On Android, `GestureHandlerRootView` accepts `pointerEvents` through `ViewProps`, but its generated manager delegate never forwards that prop. A nested root with `box-none` intercepts taps in its empty area; `none` still allows child Pressables and v3 gestures to activate. Declare the prop explicitly in the native spec and forward it to `ReactViewGroup.pointerEvents`, following the existing detector/button managers. Add a basic-example screen comparing a nested gesture root with a plain View across all pointer-event modes. This addresses the gesture-root blocker described in heroui-inc/heroui-native#490. The separate HeroUI wrapper behavior is outside this change. - Built the unmodified and patched Android example from source, React Native 0.87.0 / Fabric / Hermes, arm64 Android 12L emulator. - Before: `box-none` blocks the background where a plain View allows it; `none` still fires the child Pressable and `useTapGesture` callbacks. - After: tested `auto`, `none`, `box-only`, `box-none`, and removing the prop on the same mounted view. Background, child Pressable, and v3 gesture counters match the expected behavior. Also verified the new screen through the example navigator. - Library Jest: 185 tests passed. Monorepo TypeScript checks, changed-file ESLint/Prettier, and Android Spotless checks passed. To reproduce: launch `apps/basic-example`, advance to **Root Pointer Events**, and tap **Empty side**, **Child press**, and **Child gesture**. Switch modes without remounting; **Use View** provides the native View control. --------- Co-authored-by: Michał --- .../react/RNGestureHandlerRootViewManager.kt | 6 ++++++ .../RNGestureHandlerRootViewNativeComponent.ts | 13 +++++++++++-- 2 files changed, 17 insertions(+), 2 deletions(-) diff --git a/packages/react-native-gesture-handler/android/src/main/java/com/swmansion/gesturehandler/react/RNGestureHandlerRootViewManager.kt b/packages/react-native-gesture-handler/android/src/main/java/com/swmansion/gesturehandler/react/RNGestureHandlerRootViewManager.kt index f209a7c407..8f5de396da 100644 --- a/packages/react-native-gesture-handler/android/src/main/java/com/swmansion/gesturehandler/react/RNGestureHandlerRootViewManager.kt +++ b/packages/react-native-gesture-handler/android/src/main/java/com/swmansion/gesturehandler/react/RNGestureHandlerRootViewManager.kt @@ -1,6 +1,7 @@ package com.swmansion.gesturehandler.react import com.facebook.react.module.annotations.ReactModule +import com.facebook.react.uimanager.PointerEvents.Companion.parsePointerEvents import com.facebook.react.uimanager.ThemedReactContext import com.facebook.react.uimanager.ViewGroupManager import com.facebook.react.uimanager.ViewManagerDelegate @@ -33,6 +34,11 @@ class RNGestureHandlerRootViewManager : view.tearDown() } + @ReactProp(name = "pointerEvents") + override fun setPointerEvents(view: RNGestureHandlerRootView, pointerEventsStr: String?) { + view.pointerEvents = parsePointerEvents(pointerEventsStr) + } + @ReactProp(name = "unstable_forceActive") override fun setUnstable_forceActive(view: RNGestureHandlerRootView, active: Boolean) { view.setUnstableForceActive(active) diff --git a/packages/react-native-gesture-handler/src/specs/RNGestureHandlerRootViewNativeComponent.ts b/packages/react-native-gesture-handler/src/specs/RNGestureHandlerRootViewNativeComponent.ts index fdfd2e220d..a3f99df953 100644 --- a/packages/react-native-gesture-handler/src/specs/RNGestureHandlerRootViewNativeComponent.ts +++ b/packages/react-native-gesture-handler/src/specs/RNGestureHandlerRootViewNativeComponent.ts @@ -1,10 +1,19 @@ import { codegenNativeComponent } from 'react-native'; -import type { HostComponent, ViewProps } from 'react-native'; +import type { CodegenTypes, HostComponent, ViewProps } from 'react-native'; export interface RootViewNativeProps extends ViewProps { unstable_forceActive?: boolean; } -export default codegenNativeComponent( +// @ts-expect-error WithDefault adds null for codegen, unlike ViewProps.pointerEvents. +interface NativeProps extends ViewProps { + pointerEvents?: CodegenTypes.WithDefault< + 'box-none' | 'none' | 'box-only' | 'auto', + 'auto' + >; + unstable_forceActive?: boolean; +} + +export default codegenNativeComponent( 'RNGestureHandlerRootView' ) as HostComponent; From 6f72f2e3d2984745701d4670a1e7b14e507fae72 Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?Micha=C5=82?= Date: Thu, 15 Oct 2026 12:48:08 +0200 Subject: [PATCH 11/15] Expose animatable ref from legacy `GestureDetector` `Wrap` (#4554) MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit calling `findNodeHandle`, which removed the `findNodeHandle is deprecated in StrictMode` warning. A related warning remains when a gesture uses Reanimated: ``` findHostInstance_DEPRECATED is deprecated in StrictMode. findHostInstance_DEPRECATED was passed an instance of Wrap which is inside StrictMode. ``` In that case the detector renders `AnimatedWrap = Reanimated.createAnimatedComponent(Wrap)`. Reanimated's `findHostInstance` passes `_componentRef` to `findHostInstance_DEPRECATED` unless it is already a native ref, and it only unwraps a component into such a ref when the component implements `getAnimatableRef()`. `Wrap` already tracks its child's host instance and exposes it through `getHostInstance()`, but not `getAnimatableRef()`. This PR adds `getAnimatableRef()` to `Wrap`, implemented with the existing `resolveHostInstance` helper. It returns the child's host instance, or the `Wrap` instance itself when none is known, which matches today's behaviour. Reanimated then resolves the same view without the deprecated call. We hit this in an app wrapped in `` that uses the React Navigation drawer (`react-native-drawer-layout`) and `ReanimatedSwipeable`, which both use Reanimated-driven gestures. We applied the same change as a local patch on 2.33.0 (RN 0.86.3, Reanimated 4.5.1): the warning disappeared, and the drawer swipe and swipe-to-delete behaved as before. A backport to the 2.x line would be welcome if that fits your process. - Added two cases to `src/__tests__/legacyWrapRef.test.tsx`: - `getAnimatableRef()` returns the child's host instance. - It falls back to the `Wrap` instance when the child ignores its ref. Both fail without the change and pass with it. - `yarn test` in `packages/react-native-gesture-handler`: 21 suites, 180 tests pass. - `yarn ts-check` and `yarn lint:js` pass, with no new warnings in the touched files. - Manual check with the equivalent patch on 2.33.0 in an iOS app under ``: - No `findHostInstance_DEPRECATED` warning. - Drawer open/close by edge swipe works. - `ReanimatedSwipeable` swipe-to-delete works. - Removing the patch brings the warning back. 🤖 Generated with [Claude Code](https://claude.com/claude-code) --------- Co-authored-by: Claude Opus 5.5 Co-authored-by: Michał --- .../src/__tests__/legacyWrapRef.test.tsx | 61 +++++++++++++++++++ .../gestures/GestureDetector/Wrap.tsx | 18 +++++- .../handlers/gestures/reanimatedWrapper.ts | 23 +++++++ 3 files changed, 101 insertions(+), 1 deletion(-) diff --git a/packages/react-native-gesture-handler/src/__tests__/legacyWrapRef.test.tsx b/packages/react-native-gesture-handler/src/__tests__/legacyWrapRef.test.tsx index 9bbd74484d..c6a60e5ac3 100644 --- a/packages/react-native-gesture-handler/src/__tests__/legacyWrapRef.test.tsx +++ b/packages/react-native-gesture-handler/src/__tests__/legacyWrapRef.test.tsx @@ -2,6 +2,8 @@ import { cleanup, render } from '@testing-library/react-native'; import React from 'react'; import { findNodeHandle, View } from 'react-native'; +import { Wrap } from '../handlers/gestures/GestureDetector/Wrap'; +import * as reanimatedWrapper from '../handlers/gestures/reanimatedWrapper'; import { Gesture, GestureDetector, GestureHandlerRootView } from '../index'; import RNGestureHandlerModule from '../RNGestureHandlerModule'; @@ -132,6 +134,65 @@ describe('Legacy GestureDetector ref forwarding', () => { expect(secondRef).toHaveBeenCalledWith(childInstance); }); + describe('animatable ref for Reanimated', () => { + let refreshSpy: jest.SpyInstance; + + beforeEach(() => { + refreshSpy = jest.spyOn( + reanimatedWrapper, + 'reanimatedRefreshesAnimatableRef' + ); + }); + + afterEach(() => { + refreshSpy.mockRestore(); + }); + + test('exposes the child host instance when Reanimated re-resolves it on update', () => { + refreshSpy.mockReturnValue(true); + const wrapRef = React.createRef(); + const childRef = React.createRef(); + + render( + + + + ); + + expect(childRef.current).not.toBeNull(); + expect(wrapRef.current?.getAnimatableRef()).toBe(childRef.current); + }); + + test('falls back to itself without a host instance', () => { + refreshSpy.mockReturnValue(true); + const wrapRef = React.createRef(); + + render( + + + + ); + + expect(wrapRef.current).not.toBeNull(); + expect(wrapRef.current?.getAnimatableRef()).toBe(wrapRef.current); + }); + + test('returns itself when Reanimated caches the animatable ref', () => { + refreshSpy.mockReturnValue(false); + const wrapRef = React.createRef(); + const childRef = React.createRef(); + + render( + + + + ); + + expect(childRef.current).not.toBeNull(); + expect(wrapRef.current?.getAnimatableRef()).toBe(wrapRef.current); + }); + }); + test('does not reattach gestures on re-render', () => { const gesture = Gesture.Tap(); 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 8abb581e25..29a347fab2 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 @@ -4,8 +4,12 @@ import { assignRef, isHostInstance, preferHostInstance, + resolveHostInstance, } from '../../../hostInstance'; -import { Reanimated } from '../reanimatedWrapper'; +import { + Reanimated, + reanimatedRefreshesAnimatableRef, +} from '../reanimatedWrapper'; import { tagMessage } from '../../../utils'; export class Wrap extends React.Component<{ @@ -24,6 +28,18 @@ export class Wrap extends React.Component<{ return this.hostInstance; } + // Used by Reanimated's `createAnimatedComponent` (`AnimatedWrap`). Without it, + // Reanimated calls `findHostInstance_DEPRECATED` on this class instance, which + // warns under StrictMode. Reanimated caches the result, so the child's host + // instance is only safe to hand out when it re-resolves the ref on update; + // otherwise return the instance itself, which keeps the deprecated lookup. + // eslint-disable-next-line @eslint-react/no-unused-class-component-members + public getAnimatableRef() { + return reanimatedRefreshesAnimatableRef() + ? resolveHostInstance(this) + : this; + } + private detachChildRef() { if (this.childRefCleanup !== undefined) { this.childRefCleanup(); diff --git a/packages/react-native-gesture-handler/src/handlers/gestures/reanimatedWrapper.ts b/packages/react-native-gesture-handler/src/handlers/gestures/reanimatedWrapper.ts index d4e00e9277..71e581365a 100644 --- a/packages/react-native-gesture-handler/src/handlers/gestures/reanimatedWrapper.ts +++ b/packages/react-native-gesture-handler/src/handlers/gestures/reanimatedWrapper.ts @@ -54,4 +54,27 @@ if (Reanimated !== undefined && !Reanimated.setGestureState) { }; } +// Reanimated resolves `getAnimatableRef()` once, when its ref to the wrapped +// component attaches. Only versions that re-resolve it on update (their +// `AnimatedComponent` has `_refreshComponentRef`) can be handed a child's host +// instance that may change, as the legacy `Wrap` does. The class is not +// exported, so probe it by subpath, as the v3 detector does for +// `NativeEventsManager`. +let refreshesAnimatableRef = false; +try { + // eslint-disable-next-line @typescript-eslint/no-unsafe-assignment + const AnimatedComponent = + // eslint-disable-next-line @typescript-eslint/no-var-requires, @typescript-eslint/no-unsafe-member-access + require('react-native-reanimated/src/createAnimatedComponent/AnimatedComponent').default; + refreshesAnimatableRef = + // eslint-disable-next-line @typescript-eslint/no-unsafe-member-access + typeof AnimatedComponent?.prototype?._refreshComponentRef === 'function'; +} catch { + // Reanimated is missing or older; keep handing Reanimated the Wrap itself. +} + +export function reanimatedRefreshesAnimatableRef() { + return refreshesAnimatableRef; +} + export { Reanimated }; From 4ab9fb529d51e25f8eccc63ff3857df80043db96 Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?Micha=C5=82?= Date: Thu, 15 Oct 2026 12:49:17 +0200 Subject: [PATCH 12/15] [Android] Clear the pending HOVER_EXIT finish when the hover handler is reset (#4593) HoverGestureHandler posts finishRunnable with a 4 ms delay on HOVER_EXIT, so a touch-down (which synthesizes HOVER_EXIT right before ACTION_DOWN) can cancel it and the hover survives the press. The runnable was only ever cleared on ACTION_DOWN. If the handler finished by another route and got reset before the runnable fired, the runnable stayed armed and ended the next hover gesture that was recorded on the same handler. This shows up when the UI thread stalls while the pointer leaves the window and comes back: the queued HOVER_EXIT, HOVER_ENTER (outside the view) and HOVER_MOVE (inside the view) flush together, the handler finishes, resets and re-activates within the 4 ms window, and the stale runnable then ends the fresh gesture. The view loses its hover state while the pointer is still resting on it, until the next move. onReset now clears the handler's pending callbacks, the same way TapGestureHandler does. Hover a view with a stylus or mouse on Android, stall the UI thread for ~2 s (e.g. a busy-wait worklet scheduled on the UI runtime), and during the stall move the pointer out of the window, back in outside the view, then onto the view and hold still. Before: the view flickers to its non-hovered state and stays there after the stall. After: it stays hovered.
Tested on the following code ```tsx import React, { useCallback, useEffect, useState } from 'react'; import { StyleSheet, Switch, Text, View } from 'react-native'; import { GestureDetector, useHoverGesture } from 'react-native-gesture-handler'; import Animated, { Easing, useAnimatedStyle, useSharedValue, withRepeat, withTiming, } from 'react-native-reanimated'; import { scheduleOnRN, scheduleOnUI } from 'react-native-worklets'; // Repro: Android HoverGestureHandler.onReset doesn't clear the 4 ms finishRunnable // posted on HOVER_EXIT. Steps (mouse on Android): // 1. enable the periodic stall, hover the box // 2. while the spinner is frozen: move into the status bar and back onto the box, stop there // 3. after the stall the box should stay highlighted - with the bug it goes dark // and the log shows `finalize` a few ms after `activate` (marked with <<<). const STALL_MS = 2000; const PERIOD_MS = 6000; type Entry = { t: number; name: string }; function stallUiThread(ms: number) { 'worklet'; const end = Date.now() + ms; while (Date.now() < end) { // busy-wait on the UI thread so input events queue up } } export default function EmptyExample() { const active = useSharedValue(0); const spin = useSharedValue(0); const [log, setLog] = useState([]); const [cursor, setCursor] = useState('-'); const [stallEnabled, setStallEnabled] = useState(false); const [countdown, setCountdown] = useState(0); const addLog = useCallback((name: string, t: number) => { setLog((prev) => [...prev.slice(-13), { t, name }]); }, []); useEffect(() => { spin.value = withRepeat( withTiming(360, { duration: 1500, easing: Easing.linear }), -1, false ); }, [spin]); useEffect(() => { if (!stallEnabled) { setCountdown(0); return; } let next = Date.now() + PERIOD_MS; const id = setInterval(() => { const left = next - Date.now(); if (left <= 0) { next = Date.now() + PERIOD_MS; addLog('stall', Date.now()); scheduleOnUI(stallUiThread, STALL_MS); } setCountdown(Math.max(0, Math.ceil(left / 1000))); }, 250); return () => clearInterval(id); }, [stallEnabled, addLog]); const boxHover = useHoverGesture({ onBegin: () => { scheduleOnRN(addLog, 'begin', Date.now()); }, onActivate: () => { active.value = 1; scheduleOnRN(addLog, 'activate', Date.now()); }, onDeactivate: () => { scheduleOnRN(addLog, 'deactivate', Date.now()); }, onFinalize: () => { active.value = 0; scheduleOnRN(addLog, 'finalize', Date.now()); }, }); const cursorHover = useHoverGesture({ onUpdate: (e) => { scheduleOnRN( setCursor, `${Math.round(e.absoluteX)}, ${Math.round(e.absoluteY)}` ); }, }); const boxStyle = useAnimatedStyle(() => ({ backgroundColor: active.value ? '#2e7d32' : '#263238', })); const spinStyle = useAnimatedStyle(() => ({ transform: [{ rotate: `${spin.value}deg` }], })); return ( {stallEnabled ? countdown > 0 ? `UI stall in ${countdown}s` : 'UI STALLED' : 'Periodic 2s UI stall'} cursor (dp): {cursor} {log.map((e, i) => { const prev = log[i - 1]; const dt = prev ? e.t - prev.t : 0; const stale = e.name === 'finalize' && prev?.name === 'activate' && dt < 20; return ( {`+${dt} ms ${e.name}${stale ? ' <<< stale finish' : ''}`} ); })} ); } const styles = StyleSheet.create({ container: { flex: 1, alignItems: 'center', paddingTop: 24, }, row: { flexDirection: 'row', alignItems: 'center', gap: 12, }, text: { fontSize: 16, }, spinner: { width: 20, height: 20, backgroundColor: '#ef6c00', }, box: { width: 200, height: 200, marginVertical: 24, borderRadius: 12, }, log: { alignSelf: 'stretch', paddingHorizontal: 16, }, logLine: { fontFamily: 'monospace', fontSize: 13, }, }); ```
--- .../com/swmansion/gesturehandler/core/HoverGestureHandler.kt | 1 + 1 file changed, 1 insertion(+) diff --git a/packages/react-native-gesture-handler/android/src/main/java/com/swmansion/gesturehandler/core/HoverGestureHandler.kt b/packages/react-native-gesture-handler/android/src/main/java/com/swmansion/gesturehandler/core/HoverGestureHandler.kt index 8a6b6ec75e..83012af472 100644 --- a/packages/react-native-gesture-handler/android/src/main/java/com/swmansion/gesturehandler/core/HoverGestureHandler.kt +++ b/packages/react-native-gesture-handler/android/src/main/java/com/swmansion/gesturehandler/core/HoverGestureHandler.kt @@ -124,6 +124,7 @@ class HoverGestureHandler : GestureHandler() { override fun onReset() { super.onReset() stylusData = StylusData() + handler?.removeCallbacksAndMessages(null) } private fun finish() { From 558bcaa3a68a6bd1bca7db2467287a1bfa96d919 Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?Micha=C5=82?= Date: Thu, 15 Oct 2026 12:50:14 +0200 Subject: [PATCH 13/15] [Android] Stop nested scroll when the root starts intercepting (#4592) When a gesture activates during a pull-to-refresh on an RN `FlatList` with an RN `RefreshControl`, the spinner parks half-pulled and `onRefresh` fires only on the next touch. Reported with `ReanimatedSwipeable` rows in #4563. RN drives the pull through nested scrolling and `SwipeRefreshLayout` finishes it only in `onStopNestedScroll`, which Android triggers from `View.dispatchTouchEvent` by calling `stopNestedScroll()` after the final UP or CANCEL. When the root view starts intercepting, `cancelTouchesInInterceptedViews` feeds `ACTION_CANCEL` straight to `onTouchEvent`, so that cleanup never runs. Same omission #4492 fixed on the `NativeViewGestureHandler` path. The sweep now calls `stopNestedScroll()` after `onTouchEvent`, as [`View.dispatchTouchEvent` does](https://cs.android.com/android/platform/superproject/main/+/main:frameworks/base/core/java/android/view/View.java;l=16587). It is a no-op without an open nested scroll. An interrupted pull now snaps back, or fires `onRefresh` if it was past the threshold. - Before #4582 (main at 993bbe74a): RN `FlatList` + RN `RefreshControl` with `ReanimatedSwipeable` rows and with bare v3 pan rows, drag a row down then sideways. Short pull snaps back and the row still swipes, long pull fires `onRefresh` immediately. Previously the spinner parked until the next touch. - On current main: single-finger drag leaves the pull to the scroll view (gesture cancelled by #4582). Hold a pull with one finger and swipe another row with a second finger: spinner snaps back or refreshes at once instead of parking. - GH `FlatList` + RN `RefreshControl`, GH `FlatList` + GH `RefreshControl`, plain pulls and plain row swipes unchanged. iOS unaffected.
Repro ```tsx import { useCallback, useState } from 'react'; import { FlatList, RefreshControl, StyleSheet, Text, View } from 'react-native'; import { GestureDetector, GestureHandlerRootView, usePanGesture } from 'react-native-gesture-handler'; const DATA = Array.from({ length: 30 }, (_, i) => `Row ${i}`); function Row({ item }: { item: string }) { const pan = usePanGesture({ activeOffsetX: [-10, 10] }); return ( {item} ); } export default function App() { const [refreshing, setRefreshing] = useState(false); const onRefresh = useCallback(() => { setRefreshing(true); setTimeout(() => setRefreshing(false), 1000); }, []); return ( item} refreshControl={} renderItem={({ item }) => } /> ); } const styles = StyleSheet.create({ container: { flex: 1 }, row: { padding: 20, backgroundColor: 'white', borderBottomWidth: 1, borderColor: '#eee' }, }); ```
--- .../swmansion/gesturehandler/core/GestureHandlerOrchestrator.kt | 1 + 1 file changed, 1 insertion(+) 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 5fc9252ec5..3005920481 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 @@ -678,6 +678,7 @@ class GestureHandlerOrchestrator( continue } view.onTouchEvent(cancelEvent) + view.stopNestedScroll() } cancelEvent.recycle() From 5ed131a579c37bf7a550b6c49ab5c07b633df68a Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?Micha=C5=82?= Date: Thu, 15 Oct 2026 13:28:18 +0200 Subject: [PATCH 14/15] Fix gesture relations after `freeze` (#4244) MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit their relations array. However, this approach introduced a bug where relations wouldn't be applied after freezing (like `freezeOnBlur` from `react-native-screens`). This PR addresses this issue by fixing incorrectly applied relations without re-introducing memory-leaks. To do so, we create snapshot of relations at first render. It is later used to prepare relations during next renders. Fixes #4238
Tested on the following reproduction ```tsx import { createBottomTabNavigator } from '@react-navigation/bottom-tabs'; import { NavigationContainer } from '@react-navigation/native'; import { createNativeStackNavigator } from '@react-navigation/native-stack'; import React from 'react'; import { Pressable, StyleSheet, Text, View } from 'react-native'; import { Gesture, GestureDetector, GestureHandlerRootView, } from 'react-native-gesture-handler'; import { enableFreeze } from 'react-native-screens'; enableFreeze(true); type RootStackParamList = { Tabs: undefined; }; type TabParamList = { TabOne: undefined; TabTwo: undefined; Leak: undefined; }; const Stack = createNativeStackNavigator(); const Tab = createBottomTabNavigator(); function TabOneScreen() { const outerTap = Gesture.Tap().onStart(() => void console.log('outer tap')); const innerTap = Gesture.Tap() .onStart(() => void console.log('inner tap')) .simultaneousWithExternalGesture(outerTap); return ( ); } // Demonstrates that when the composition's partner gesture changes across // renders, the stable gesture's resolved relation follows the CURRENT partner // and never accumulates the old one. `stableTap` is a single instance reused // across renders (memoized); only the partner it is composed with changes. function TabTwoScreen() { const [useB, switchPartner] = React.useReducer((value) => !value, false); const stableTap = React.useMemo(() => Gesture.Tap(), []); const partnerA = React.useMemo( () => Gesture.Tap().onStart(() => void console.log('▶ partner A')), [] ); const partnerB = React.useMemo( () => Gesture.Tap().onStart(() => void console.log('▶ partner B')), [] ); const activePartner = useB ? partnerB : partnerA; // Re-bind the log each render so it reports the partner that is active now. // We identify each stored relation by object identity (which partner gesture // it points at), because the GestureDetector recycles native handler slots by // position - so the numeric `tag` stays the same across a switch even though // the partner reference correctly changes from A to B. The key things to watch // are that `partner` follows the active one and the array length stays 1 // (no stale accumulation). stableTap.onStart(() => { const resolved = (stableTap.config.simultaneousWith ?? []).map((ref) => ({ partner: ref === partnerA ? 'A' : ref === partnerB ? 'B' : '?', tag: (ref as { handlerTag?: number }).handlerTag, })); console.log( `▶ stable tap | active=${useB ? 'B' : 'A'} resolved=${JSON.stringify( resolved )}` ); }); // Recreated every render because it depends on state — this is exactly the // case where the partner array passed into the composition changes. const composed = Gesture.Simultaneous(stableTap, activePartner); return ( Tap me partner: {useB ? 'B' : 'A'} Switch partner ); } // Reproduces the scenario from the #3763 memory-leak fix: a stable (memoized) // gesture composed with a gesture that is recreated on every render and captures // a large allocation. If relations accumulated, `stable.config.simultaneousWith` // would grow by one on every render and pin every transient gesture (and its // `bigMemory`) in memory forever. With the fix it stays at exactly 1, so each // previous transient gesture becomes collectible. function LeakScreen() { const [renders, rerender] = React.useReducer((value) => value + 1, 0); // Stable instance — survives every render. This is the gesture that used to // accumulate references to each render's transient partner. const stable = React.useMemo(() => Gesture.Pan(), []); // Fresh big allocation + fresh transient gesture on every render. The gesture // captures `bigMemory`, so anything retaining the gesture also retains it. const bigMemory = new Array(500_000).fill(renders); const transient = Gesture.Pan().onStart(() => void bigMemory.length); const composed = Gesture.Simultaneous(stable, transient); // Direct proxy for the leak: how many partner references `stable` is holding. // Stays 1 with the fix; grew by one per render before it. const relationsHeld = stable.config.simultaneousWith?.length ?? 0; console.log(`leak check | renders=${renders} relationsHeld=${relationsHeld}`); // Drives many *separate* renders (and therefore many prepares) so the count // is easy to watch. Plain dispatch-in-a-loop would be batched into one render. const stress = React.useCallback(() => { let count = 0; const id = setInterval(() => { rerender(); count += 1; if (count >= 100) { clearInterval(id); } }, 16); }, []); return ( renders: {renders} relations held: {relationsHeld} Re-render once Re-render x100 ); } function TabsScreen() { return ( ); } const App = () => { return ( ); }; export default App; const styles = StyleSheet.create({ root: { flex: 1, }, screen1: { flex: 1, backgroundColor: '#f9f', }, screen2: { flex: 1, alignItems: 'center', justifyContent: 'center', gap: 12, }, box: { width: 200, height: 200, backgroundColor: 'red', alignItems: 'center', justifyContent: 'center', gap: 8, borderRadius: 12, }, boxLabel: { color: 'white', fontWeight: '600', }, button: { paddingVertical: 12, paddingHorizontal: 20, backgroundColor: '#333', borderRadius: 8, }, buttonLabel: { color: 'white', fontWeight: '600', }, }); ```
--- .../handlers/gestures/GestureDetector/utils.ts | 16 ++++++---------- .../src/handlers/gestures/gesture.ts | 11 +++++++++++ .../src/handlers/gestures/gestureComposition.ts | 17 +++++++++++++++-- 3 files changed, 32 insertions(+), 12 deletions(-) diff --git a/packages/react-native-gesture-handler/src/handlers/gestures/GestureDetector/utils.ts b/packages/react-native-gesture-handler/src/handlers/gestures/GestureDetector/utils.ts index 30f23817aa..e73e391bcd 100644 --- a/packages/react-native-gesture-handler/src/handlers/gestures/GestureDetector/utils.ts +++ b/packages/react-native-gesture-handler/src/handlers/gestures/GestureDetector/utils.ts @@ -101,20 +101,16 @@ export function hasExternalRelations(gestures: GestureType[]) { } export function extractGestureRelations(gesture: GestureType) { - gesture.config.requireToFail = extractValidHandlerTags( - gesture.config.requireToFail - ); - gesture.config.simultaneousWith = extractValidHandlerTags( + const requireToFail = extractValidHandlerTags(gesture.config.requireToFail); + const simultaneousWith = extractValidHandlerTags( gesture.config.simultaneousWith ); - gesture.config.blocksHandlers = extractValidHandlerTags( - gesture.config.blocksHandlers - ); + const blocksHandlers = extractValidHandlerTags(gesture.config.blocksHandlers); return { - waitFor: gesture.config.requireToFail, - simultaneousHandlers: gesture.config.simultaneousWith, - blocksHandlers: gesture.config.blocksHandlers, + waitFor: requireToFail, + simultaneousHandlers: simultaneousWith, + blocksHandlers: blocksHandlers, }; } diff --git a/packages/react-native-gesture-handler/src/handlers/gestures/gesture.ts b/packages/react-native-gesture-handler/src/handlers/gestures/gesture.ts index d5e4fe527e..44edea8000 100644 --- a/packages/react-native-gesture-handler/src/handlers/gestures/gesture.ts +++ b/packages/react-native-gesture-handler/src/handlers/gestures/gesture.ts @@ -131,6 +131,17 @@ export abstract class BaseGesture< public handlerTag = -1; public handlerName = ''; public config: BaseGestureConfig = {}; + // Snapshot of the relations defined directly on this gesture (e.g. via + // `simultaneousWithExternalGesture`), captured before any composition extends + // them. Composition rebuilds the relation config from this snapshot on every + // `prepare`, so repeated renders don't accumulate references to gestures from + // previous renders (memory leak, see #3763), while keeping the original + // references so relations stay re-resolvable after a remount, such as a + // `react-freeze` unfreeze (see #4238). + public relationsSnapshot?: { + simultaneousWith: GestureRef[] | undefined; + requireToFail: GestureRef[] | undefined; + }; public handlers: HandlerCallbacks = { gestureId: -1, handlerTag: -1, diff --git a/packages/react-native-gesture-handler/src/handlers/gestures/gestureComposition.ts b/packages/react-native-gesture-handler/src/handlers/gestures/gestureComposition.ts index ff1096c47b..a0696ccb63 100644 --- a/packages/react-native-gesture-handler/src/handlers/gestures/gestureComposition.ts +++ b/packages/react-native-gesture-handler/src/handlers/gestures/gestureComposition.ts @@ -27,16 +27,29 @@ export class ComposedGesture extends Gesture { requireGesturesToFail: GestureType[] ) { if (gesture instanceof BaseGesture) { + // Capture the relations defined directly on the gesture before composition + // extends them, then always rebuild from that snapshot. Otherwise, when the + // gesture is stable (e.g. wrapped in `useMemo`) but the composition is + // recreated on every render, the relations would keep accumulating + // references to gestures from previous renders, leaking memory (see #3763). + // We keep the original references (instead of collapsing them to handler + // tags) so relations can still be re-resolved after a remount, such as a + // `react-freeze` unfreeze (see #4238). + gesture.relationsSnapshot ??= { + simultaneousWith: gesture.config.simultaneousWith, + requireToFail: gesture.config.requireToFail, + }; + const newConfig = { ...gesture.config }; // No need to extend `blocksHandlers` here, because it's not changed in composition. // The same effect is achieved by reversing the order of 2 gestures in `Exclusive` newConfig.simultaneousWith = extendRelation( - newConfig.simultaneousWith, + gesture.relationsSnapshot.simultaneousWith, simultaneousGestures ); newConfig.requireToFail = extendRelation( - newConfig.requireToFail, + gesture.relationsSnapshot.requireToFail, requireGesturesToFail ); From 964313b17018ed79b50ecc8316f2d7df697ea5a2 Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?Micha=C5=82?= Date: Thu, 15 Oct 2026 16:16:41 +0200 Subject: [PATCH 15/15] Update java files --- .../viewmanagers/RNGestureHandlerRootViewManagerDelegate.java | 3 +++ .../viewmanagers/RNGestureHandlerRootViewManagerInterface.java | 2 ++ 2 files changed, 5 insertions(+) diff --git a/packages/react-native-gesture-handler/android/paper/src/main/java/com/facebook/react/viewmanagers/RNGestureHandlerRootViewManagerDelegate.java b/packages/react-native-gesture-handler/android/paper/src/main/java/com/facebook/react/viewmanagers/RNGestureHandlerRootViewManagerDelegate.java index 69624e0ea5..cae9973aa9 100644 --- a/packages/react-native-gesture-handler/android/paper/src/main/java/com/facebook/react/viewmanagers/RNGestureHandlerRootViewManagerDelegate.java +++ b/packages/react-native-gesture-handler/android/paper/src/main/java/com/facebook/react/viewmanagers/RNGestureHandlerRootViewManagerDelegate.java @@ -23,6 +23,9 @@ public RNGestureHandlerRootViewManagerDelegate(U viewManager) { @Override public void setProperty(T view, String propName, @Nullable Object value) { switch (propName) { + case "pointerEvents": + mViewManager.setPointerEvents(view, (String) value); + break; case "unstable_forceActive": mViewManager.setUnstable_forceActive(view, value == null ? false : (boolean) value); break; diff --git a/packages/react-native-gesture-handler/android/paper/src/main/java/com/facebook/react/viewmanagers/RNGestureHandlerRootViewManagerInterface.java b/packages/react-native-gesture-handler/android/paper/src/main/java/com/facebook/react/viewmanagers/RNGestureHandlerRootViewManagerInterface.java index 9864e0fc48..27a653852f 100644 --- a/packages/react-native-gesture-handler/android/paper/src/main/java/com/facebook/react/viewmanagers/RNGestureHandlerRootViewManagerInterface.java +++ b/packages/react-native-gesture-handler/android/paper/src/main/java/com/facebook/react/viewmanagers/RNGestureHandlerRootViewManagerInterface.java @@ -10,8 +10,10 @@ package com.facebook.react.viewmanagers; import android.view.View; +import androidx.annotation.Nullable; import com.facebook.react.uimanager.ViewManagerWithGeneratedInterface; public interface RNGestureHandlerRootViewManagerInterface extends ViewManagerWithGeneratedInterface { + void setPointerEvents(T view, @Nullable String value); void setUnstable_forceActive(T view, boolean value); }