Repository navigation
[macOS] Fix manualActivation never blocking gesture activation - #4389
Conversation
|
No actionable comments were generated in the recent review. 🎉 ℹ️ Recent review info⚙️ Run configurationConfiguration used: Repository UI Review profile: CHILL Plan: Pro Plus Run ID: 📒 Files selected for processing (1)
🚧 Files skipped from review as they are similar to previous changes (1)
📝 WalkthroughSummary by CodeRabbit
WalkthroughChangesmacOS manual activation recognizer
Possibly related PRs
🚥 Pre-merge checks | ✅ 3✅ Passed checks (3 passed)
Thanks for using CodeRabbit! It's free for OSS, and your support helps us grow. If you like it, consider giving us a shout-out. Comment |
There was a problem hiding this comment.
Pull request overview
Fixes macOS-specific manual activation behavior so manualActivation: true actually blocks a handler from auto-activating until JS explicitly calls activate(), aligning AppKit behavior more closely with iOS expectations.
Changes:
- Ensure the activation blocker establishes the required-to-fail relationship on macOS via the AppKit delegate callback.
- Explicitly fail the handler recognizer when the blocker recognizes (to prevent buffered/withheld recognition from being flushed on release).
- Correct macOS pointer-count bookkeeping and reset behavior to avoid stale
_activePointersacross disable/enable cycles.
💡 Add Copilot custom instructions for smarter, more guided reviews. Learn how to get started.
## 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 <details> <summary>Tested on the following code:</summary> ```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 ( <View style={styles.container}> <Text style={styles.label}>A: manualActivation, never activated — click & drag, release</Text> <GestureDetector gesture={neverActivatePan}> <View style={[styles.box, { backgroundColor: 'darkorange' }]} /> </GestureDetector> <Text style={styles.label}>B: manualActivation, self-activates on touch move — drag</Text> <GestureDetector gesture={selfActivatePan}> <View style={[styles.box, { backgroundColor: 'mediumpurple' }]} /> </GestureDetector> </View> ); } 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 }, }); ``` </details>
j-piasecki
left a comment
There was a problem hiding this comment.
Are the claude-looking comments really needed?
50/50 I'd say (c96efd4) |
…867) Bumps \[react-native-gesture-handler\](https://github.com/software-mansion/react-native-gesture-handler) from 2.32.0 to 3.2.1. Release notes _Sourced from [react-native-gesture-handler's releases](https://github.com/software-mansion/react-native-gesture-handler/releases)._ > v3.2.1 > ------ > > 🐛 Bug fixes > ------------ > > * Forward press handlers as `testOnly_*` in `PressableWithTouchable` by [`@huextrat`](https://github.com/huextrat) in [software-mansion/react-native-gesture-handler#4416](https://redirect.github.com/software-mansion/react-native-gesture-handler/pull/4416) > > 🔢 Miscellaneous > ---------------- > > * Update `Pressable` props by [`@m-bert`](https://github.com/m-bert) in [software-mansion/react-native-gesture-handler#4421](https://redirect.github.com/software-mansion/react-native-gesture-handler/pull/4421) > > **Full Changelog**: [https://github.com/software-mansion/react-native-gesture-handler/compare/v3.2.0...v3.2.1](https://github.com/software-mansion/react-native-gesture-handler/compare/v3.2.0...v3.2.1) > > v3.2.0 > ------ > > ❗ Important changes > ------------------- > > * feat: Adopt AGP v9 by [`@hurali97`](https://github.com/hurali97) in [software-mansion/react-native-gesture-handler#4263](https://redirect.github.com/software-mansion/react-native-gesture-handler/pull/4263) > * Implement `Pressable` based on `Touchable` by [`@m-bert`](https://github.com/m-bert) in [software-mansion/react-native-gesture-handler#4411](https://redirect.github.com/software-mansion/react-native-gesture-handler/pull/4411) > * \[Android\] Add hover callbacks to Touchable by [`@j-piasecki`](https://github.com/j-piasecki) in [software-mansion/react-native-gesture-handler#4396](https://redirect.github.com/software-mansion/react-native-gesture-handler/pull/4396) > * \[iOS\] Add hover callbacks to Touchable by [`@j-piasecki`](https://github.com/j-piasecki) in [software-mansion/react-native-gesture-handler#4397](https://redirect.github.com/software-mansion/react-native-gesture-handler/pull/4397) > * \[Web\] Add hover callbacks to Touchable by [`@j-piasecki`](https://github.com/j-piasecki) in [software-mansion/react-native-gesture-handler#4398](https://redirect.github.com/software-mansion/react-native-gesture-handler/pull/4398) > * \[Web\] Refactor `Touchable` not to rely on `GestureDetector` by [`@j-piasecki`](https://github.com/j-piasecki) in [software-mansion/react-native-gesture-handler#4344](https://redirect.github.com/software-mansion/react-native-gesture-handler/pull/4344) > * \[iOS\] Refactor `Touchable` not to rely on `GestureDetector` by [`@j-piasecki`](https://github.com/j-piasecki) in [software-mansion/react-native-gesture-handler#4343](https://redirect.github.com/software-mansion/react-native-gesture-handler/pull/4343) > * \[Android\] Refactor `Touchable` not to rely on `GestureDetector` by [`@j-piasecki`](https://github.com/j-piasecki) in [software-mansion/react-native-gesture-handler#4342](https://redirect.github.com/software-mansion/react-native-gesture-handler/pull/4342) > * Fix fatal crash `Cannot read property 'translationX' of undefined` when a touch event is serialized without `allTouches` by [`@huextrat`](https://github.com/huextrat) in [software-mansion/react-native-gesture-handler#4316](https://redirect.github.com/software-mansion/react-native-gesture-handler/pull/4316) > > 👍 Improvements > --------------- > > * \[Android\] Skip the underlay drawable when it can never be visible by [`@j-piasecki`](https://github.com/j-piasecki) in [software-mansion/react-native-gesture-handler#4359](https://redirect.github.com/software-mansion/react-native-gesture-handler/pull/4359) > * \[Android\] Apply the button's managed handler config once per prop transaction by [`@j-piasecki`](https://github.com/j-piasecki) in [software-mansion/react-native-gesture-handler#4357](https://redirect.github.com/software-mansion/react-native-gesture-handler/pull/4357) > * \[Android\] Configure the button's handler directly instead of through a `ReadableMap` by [`@j-piasecki`](https://github.com/j-piasecki) in [software-mansion/react-native-gesture-handler#4358](https://redirect.github.com/software-mansion/react-native-gesture-handler/pull/4358) > > 🐛 Bug fixes > ------------ > > * \[Android\] Guard update events to only be dispatched in ACTIVE state by [`@j-piasecki`](https://github.com/j-piasecki) in [software-mansion/react-native-gesture-handler#4332](https://redirect.github.com/software-mansion/react-native-gesture-handler/pull/4332) > * Pass empty callbacks to UI when `runOnJS` is `true` by [`@m-bert`](https://github.com/m-bert) in [software-mansion/react-native-gesture-handler#4326](https://redirect.github.com/software-mansion/react-native-gesture-handler/pull/4326) > * \[Web\] Fix incorrectly calculated `timeDelta` by [`@m-bert`](https://github.com/m-bert) in [software-mansion/react-native-gesture-handler#4329](https://redirect.github.com/software-mansion/react-native-gesture-handler/pull/4329) > * \[Web\] Fix incorrect `Tap` offset by [`@m-bert`](https://github.com/m-bert) in [software-mansion/react-native-gesture-handler#4330](https://redirect.github.com/software-mansion/react-native-gesture-handler/pull/4330) > * Fix `minVelocity` props behavior by [`@m-bert`](https://github.com/m-bert) in [software-mansion/react-native-gesture-handler#4327](https://redirect.github.com/software-mansion/react-native-gesture-handler/pull/4327) > * \[Android\] Fix `minDistance` being reset by partial config updates by [`@m-bert`](https://github.com/m-bert) in [software-mansion/react-native-gesture-handler#4347](https://redirect.github.com/software-mansion/react-native-gesture-handler/pull/4347) > * Move Interceptor on `ScrollView`, not its content by [`@m-bert`](https://github.com/m-bert) in [software-mansion/react-native-gesture-handler#4331](https://redirect.github.com/software-mansion/react-native-gesture-handler/pull/4331) > * \[iOS\] Re-sync layer opacity and transform from retained props when recycling buttons by [`@m-bert`](https://github.com/m-bert) in [software-mansion/react-native-gesture-handler#4360](https://redirect.github.com/software-mansion/react-native-gesture-handler/pull/4360) > * \[Android\] Properly handle `requestDisallowInterceptTouchEvent` for v3 by [`@j-piasecki`](https://github.com/j-piasecki) in [software-mansion/react-native-gesture-handler#4367](https://redirect.github.com/software-mansion/react-native-gesture-handler/pull/4367) > * Fix `Touchable` not respecting `keyboardShouldPersistTaps="handled"` by [`@m-bert`](https://github.com/m-bert) in [software-mansion/react-native-gesture-handler#4372](https://redirect.github.com/software-mansion/react-native-gesture-handler/pull/4372) > * \[macOS\] Fix touch events never being delivered by [`@m-bert`](https://github.com/m-bert) in [software-mansion/react-native-gesture-handler#4390](https://redirect.github.com/software-mansion/react-native-gesture-handler/pull/4390) > * fix: crash when mount listener fires after GestureDetector unmount by [`@kosmydel`](https://github.com/kosmydel) in [software-mansion/react-native-gesture-handler#4268](https://redirect.github.com/software-mansion/react-native-gesture-handler/pull/4268) > * \[macOS\] Fix `Pan` activation criteria being ignored by [`@m-bert`](https://github.com/m-bert) in [software-mansion/react-native-gesture-handler#4387](https://redirect.github.com/software-mansion/react-native-gesture-handler/pull/4387) > * \[macOS\] Fix `manualActivation` never blocking gesture activation by [`@m-bert`](https://github.com/m-bert) in [software-mansion/react-native-gesture-handler#4389](https://redirect.github.com/software-mansion/react-native-gesture-handler/pull/4389) > * \[iOS\] Fix touch events never being delivered to `VirtualDetector` handlers by [`@m-bert`](https://github.com/m-bert) in [software-mansion/react-native-gesture-handler#4392](https://redirect.github.com/software-mansion/react-native-gesture-handler/pull/4392) > * \[iOS\] Fix gestures attached via `VirtualGestureDetector` never recognizing continuous gestures by [`@m-bert`](https://github.com/m-bert) in [software-mansion/react-native-gesture-handler#4393](https://redirect.github.com/software-mansion/react-native-gesture-handler/pull/4393) > * \[macOS\] Fix `Fling` not sending touch events and begin/end states consistently by [`@m-bert`](https://github.com/m-bert) in [software-mansion/react-native-gesture-handler#4395](https://redirect.github.com/software-mansion/react-native-gesture-handler/pull/4395) > * \[Android\] Fix handlers cancelled while awaiting leaking in the orchestrator by [`@m-bert`](https://github.com/m-bert) in [software-mansion/react-native-gesture-handler#4402](https://redirect.github.com/software-mansion/react-native-gesture-handler/pull/4402) ... (truncated) Commits * [`62f0f7d`](software-mansion/react-native-gesture-handler@62f0f7d) Release v3.2.1 * [`4716425`](software-mansion/react-native-gesture-handler@4716425) Merge branch '3.2-stable' of github.com:software-mansion/react-native-gesture... * [`f0ae48c`](software-mansion/react-native-gesture-handler@f0ae48c) Update `Pressable` props ([#4421](https://redirect.github.com/software-mansion/react-native-gesture-handler/issues/4421)) * [`5f0f0d8`](software-mansion/react-native-gesture-handler@5f0f0d8) Forward press handlers as `testOnly_*` in `PressableWithTouchable` ([#4416](https://redirect.github.com/software-mansion/react-native-gesture-handler/issues/4416)) * [`0a91db7`](software-mansion/react-native-gesture-handler@0a91db7) Release v3.2.0 * [`44046a6`](software-mansion/react-native-gesture-handler@44046a6) \[Android\] Resolve the button event dispatcher by react tag ([#4415](https://redirect.github.com/software-mansion/react-native-gesture-handler/issues/4415)) * [`2469c1d`](software-mansion/react-native-gesture-handler@2469c1d) Derive `Pressable` pressed state from `testOnly_pressed` ([#4414](https://redirect.github.com/software-mansion/react-native-gesture-handler/issues/4414)) * [`3f1bf74`](software-mansion/react-native-gesture-handler@3f1bf74) Clear pending timers on unmount in StatefulPressable ([#4413](https://redirect.github.com/software-mansion/react-native-gesture-handler/issues/4413)) * [`50ae6a1`](software-mansion/react-native-gesture-handler@50ae6a1) \[General\] Default GestureDetector moduleId to -1 ([#4412](https://redirect.github.com/software-mansion/react-native-gesture-handler/issues/4412)) * [`8b661c9`](software-mansion/react-native-gesture-handler@8b661c9) Implement `Pressable` based on `Touchable` ([#4411](https://redirect.github.com/software-mansion/react-native-gesture-handler/issues/4411)) * Additional commits viewable in [compare view](software-mansion/react-native-gesture-handler@v2.32.0...v3.2.1)
## 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 <details> <summary>Tested on the following code:</summary> ```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 ( <View style={styles.container}> <Text style={styles.label}>A: manualActivation, never activated — click & drag, release</Text> <GestureDetector gesture={neverActivatePan}> <View style={[styles.box, { backgroundColor: 'darkorange' }]} /> </GestureDetector> <Text style={styles.label}>B: manualActivation, self-activates on touch move — drag</Text> <GestureDetector gesture={selfActivatePan}> <View style={[styles.box, { backgroundColor: 'mediumpurple' }]} /> </GestureDetector> </View> ); } 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 }, }); ``` </details>
## 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 <details> <summary>Tested on the following code:</summary> ```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 ( <View style={styles.container}> <Text style={styles.label}>A: manualActivation, never activated — click & drag, release</Text> <GestureDetector gesture={neverActivatePan}> <View style={[styles.box, { backgroundColor: 'darkorange' }]} /> </GestureDetector> <Text style={styles.label}>B: manualActivation, self-activates 300 ms after press</Text> <GestureDetector gesture={selfActivatePan}> <View style={[styles.box, { backgroundColor: 'mediumpurple' }]} /> </GestureDetector> </View> ); } 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 }, }); ``` </details>
Description
manualActivation: truehas 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 inRNManualActivationRecognizer, all macOS-specific:The failure requirement was never established. The blocking mechanism relies on
shouldBeRequiredToFailByGestureRecognizer:, which is aUIGestureRecognizersubclass hook (UIGestureRecognizerSubclass.h).NSGestureRecognizerhas 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 ownNSGestureRecognizerDelegate, the two-arg delegate callback now forwards to the shared logic.AppKit does not reliably deny the dependent recognizer when the blocker recognizes. On iOS, the blocker completing (
Began→Endedin 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/Endedwith 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.The release-without-activation cleanup was dead code. The original macOS port (macOS Support #2588) crossed the
touchesBegan/touchesEndedbodies:mouseDownincremented_activePointersand then checkedif (_activePointers == 0)— never true after an increment — whilemouseUponly decremented. The zero-check now lives inmouseUp, mirroringtouchesEndedon iOS.Stale pointer count after a JS
activate().stopActivationBlockerdisables the blocker, so it misses the subsequent mouse-up; iOS recovers intouchesCancelled, which has no AppKit equivalent. The count would stay at 1 and the cleanup in (3) would never fire again.resetnow zeroes_activePointers.Test plan
Tested on the following code: