[macOS] Fix `manualActivation` never blocking gesture activation (#4389)
## 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> M
Michał Bert committed
10235ad6011b4c5ab2ceb8bdd1e2ef5723f21151
Parent: 6819385
Committed by GitHub <noreply@github.com>
on 8/6/2026, 10:52:04 AM