Skip to content

[macOS] Fix manualActivation never blocking gesture activation - #4389

Merged
m-bert merged 2 commits into
mainfrom
@mbert/macos-manual-activation-release
Aug 6, 2026
Merged

m-bert merged 2 commits into
mainfrom
@mbert/macos-manual-activation-release

Conversation

@m-bert

@m-bert m-bert commented Aug 5, 2026

Copy link
Copy Markdown
Collaborator

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 (macOS Support #2588) crossed the touchesBegan/touchesEnded bodies: mouseDown incremented _activePointers and then checked if (_activePointers == 0) — never true after an increment — while mouseUp only decremented. The zero-check now lives in mouseUp, mirroring touchesEnded on iOS.

  4. Stale pointer count after a JS activate(). stopActivationBlocker disables the blocker, so it misses the subsequent mouse-up; iOS recovers in touchesCancelled, which has no AppKit equivalent. The count would stay at 1 and the cleanup in (3) would never fire again. reset now zeroes _activePointers.

Test plan

Tested on the following code:
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 },
});

Copilot AI review requested due to automatic review settings August 5, 2026 16:54
@coderabbitai

coderabbitai Bot commented Aug 5, 2026 •

Copy link
Copy Markdown

Review Change Stack

No actionable comments were generated in the recent review. 🎉

ℹ️ Recent review info
⚙️ Run configuration

Configuration used: Repository UI

Review profile: CHILL

Plan: Pro Plus

Run ID: 8c232aea-131a-458d-9aba-db8b8ccdc66d

📥 Commits

Reviewing files that changed from the base of the PR and between 9a4f06d and c96efd4.

📒 Files selected for processing (1)
  • packages/react-native-gesture-handler/apple/RNManualActivationRecognizer.m
🚧 Files skipped from review as they are similar to previous changes (1)
  • packages/react-native-gesture-handler/apple/RNManualActivationRecognizer.m

📝 Walkthrough

Summary by CodeRabbit

  • Bug Fixes
    • Improved macOS gesture handling by correctly failing dependent gestures before completion.
    • Reset pointer tracking state reliably when gestures are reset.
    • Aligned macOS gesture behavior with iOS for failure dependencies.

Walkthrough

Changes

macOS manual activation recognizer

Layer / File(s) Summary
Recognizer lifecycle and failure coordination
packages/react-native-gesture-handler/apple/RNManualActivationRecognizer.m
macOS gesture completion now fails the dependent recognizer before ending. reset clears _activePointers. The macOS delegate forwards failure-requirement checks to the shared implementation. Mouse tracking behavior remains unchanged.

Possibly related PRs

🚥 Pre-merge checks | ✅ 3
✅ Passed checks (3 passed)
Check name Status Explanation
Title check ✅ Passed The title clearly identifies the macOS-specific fix for manualActivation handlers that activate without explicit activation.
Linked Issues check ✅ Passed Check skipped because no linked issues were found for this pull request.
Out of Scope Changes check ✅ Passed Check skipped because no linked issues were found for this pull request.

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.

❤️ Share

Comment @coderabbitai help to get the list of available commands.

Copilot AI left a comment

Copy link
Copy Markdown
Contributor

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

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 _activePointers across disable/enable cycles.

💡 Add Copilot custom instructions for smarter, more guided reviews. Learn how to get started.

@m-bert
m-bert requested a review from j-piasecki August 5, 2026 17:23
m-bert added a commit that referenced this pull request Aug 6, 2026
## 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 j-piasecki left a comment

Copy link
Copy Markdown
Member

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Are the claude-looking comments really needed?

@m-bert

m-bert commented Aug 6, 2026

Copy link
Copy Markdown
Collaborator Author

Are the claude-looking comments really needed?

50/50 I'd say (c96efd4)

@m-bert
m-bert merged commit 10235ad into main Aug 6, 2026
4 checks passed
@m-bert
m-bert deleted the @mbert/macos-manual-activation-release branch August 6, 2026 10:52
This was referenced Aug 19, 2026
RonenMars added a commit to RonenMars/threadbase-mobile that referenced this pull request Aug 26, 2026
…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)
m-bert added a commit that referenced this pull request Sep 11, 2026
## 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>
m-bert added a commit that referenced this pull request Sep 11, 2026
## 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>
Sign up for free to join this conversation on GitHub. Already have an account? Sign in to comment

Labels

None yet

Projects

None yet

Development

Successfully merging this pull request may close these issues.

3 participants