Skip to content

Don't dispatch orphaned touch events#4341

Merged
m-bert merged 2 commits into
mainfrom
@mbert/fix-orphaned-touch-events
Jul 24, 2026
Merged

Don't dispatch orphaned touch events#4341
m-bert merged 2 commits into
mainfrom
@mbert/fix-orphaned-touch-events

Conversation

@m-bert

@m-bert m-bert commented Jul 24, 2026

Copy link
Copy Markdown
Collaborator

Description

Follow-up to #4316, fixing the root cause of the malformed touch events on the native side.

GestureHandler on Android keeps two parallel pointer structures with different lifecycles:

  • trackedPointerIDs — which pointers belong to the gesture. Always maintained (startTrackingPointer runs unconditionally) and consulted by wantsEvent.
  • trackedPointers — per-pointer position data used to build touch events. Only maintained while needsPointerData is true, i.e. while touch callbacks are attached.

When touch callbacks are attached mid-gesture — e.g. a callback attached conditionally, where the attaching re-render is triggered from onBeginneedsPointerData flips from false to true while a pointer is already down. That pointer's DOWN was never recorded into trackedPointers, but the UP still passes wantsEvent, so dispatchTouchUpEvent():

  1. built allTouches from an empty map — the null payload that used to reach JS without the allTouches key and crash the v3 update pipeline (fixed defensively in Fix fatal crash Cannot read property 'translationX' of undefined when a touch event is serialized without allTouches #4316),
  2. reported a pointer in changedTouches that the JS side has never seen go down,
  3. decremented trackedPointersCount that was never incremented for that pointer, serializing numberOfTouches: -1 and corrupting the counter that moveToState uses to decide whether to dispatch TOUCH_CANCEL (with multi-touch this can suppress a legitimate cancel event).

iOS had the same asymmetry with a different failure mode: RNGestureHandlerPointerTracker guards the counter (unregisterTouch returns -1, no underflow), but still dispatched the orphaned event with id: -1 in changedTouches. Same for moves of an unregistered touch in touchesMoved.

Web is not affected: its PointerTracker is populated unconditionally and only sending is gated on needsPointerData, so mid-gesture flips always produce well-formed events there.

Test plan

Tested on the following code:
import React, { useState } from 'react';
import { StyleSheet, Text, View } from 'react-native';
import { GestureDetector, usePanGesture } from 'react-native-gesture-handler';

// Repro / verification screen for the orphaned-touch-event fix (follow-up to
// #4316).
//
// Purple box ("flip"): touch callbacks are attached conditionally, so
// `needsPointerData` flips false -> true mid-gesture (the onBegin re-render
// attaches onTouchesUp). Press, hold ~300ms, release:
// - without the fix: an orphaned onTouchesUp fires for a pointer that never
//   reported a down (Android: numberOfTouches -1, iOS: changed touch id -1;
//   on pre-#4316 builds this crashed the pan change calculator instead)
// - with the fix: the orphaned up event is skipped - counter stays 0
//
// Green box ("static"): touch callbacks attached from the start - the normal
// path. Each tap must count down+up (counter += 2) with and without the fix.
export default function OrphanedTouchRepro() {
  const [flipTouches, setFlipTouches] = useState(0);
  const [staticTouches, setStaticTouches] = useState(0);
  const [lastOrphan, setLastOrphan] = useState('none');
  const [pressed, setPressed] = useState(false);

  const flipPan = usePanGesture({
    onBegin: () => setPressed(true),
    onFinalize: () => setPressed(false),
    onUpdate: () => {},
    runOnJS: true,
    onTouchesUp: pressed
      ? (e) => {
          setFlipTouches((c) => c + 1);
          setLastOrphan(
            `numberOfTouches: ${e.numberOfTouches}, ` +
              `changed id: ${e.changedTouches[0]?.id ?? 'none'}`
          );
        }
      : undefined,
  });

  const staticPan = usePanGesture({
    onTouchesDown: () => setStaticTouches((c) => c + 1),
    onTouchesUp: () => setStaticTouches((c) => c + 1),
    onUpdate: () => {},
    runOnJS: true,
  });

  return (
    <View style={styles.container}>
      <Text style={styles.counter}>
        flip: {flipTouches} static: {staticTouches}
      </Text>
      <Text style={styles.orphanInfo}>last orphaned up: {lastOrphan}</Text>
      <GestureDetector gesture={flipPan}>
        <View style={[styles.box, styles.flipBox]} testID="flipBox">
          <Text style={styles.boxLabel}>flip: hold ~300ms (expect 0)</Text>
        </View>
      </GestureDetector>
      <GestureDetector gesture={staticPan}>
        <View style={[styles.box, styles.staticBox]} testID="staticBox">
          <Text style={styles.boxLabel}>static (expect +2 per tap)</Text>
        </View>
      </GestureDetector>
    </View>
  );
}

const styles = StyleSheet.create({
  container: {
    flex: 1,
    justifyContent: 'center',
    alignItems: 'center',
  },
  counter: {
    fontSize: 16,
    marginBottom: 8,
  },
  orphanInfo: {
    fontSize: 13,
    opacity: 0.6,
    marginBottom: 24,
  },
  box: {
    width: 260,
    height: 150,
    borderRadius: 16,
    justifyContent: 'center',
    alignItems: 'center',
    marginVertical: 12,
  },
  flipBox: {
    backgroundColor: 'mediumpurple',
  },
  staticBox: {
    backgroundColor: 'mediumseagreen',
  },
  boxLabel: {
    color: 'white',
    fontSize: 16,
  },
});

Copilot AI review requested due to automatic review settings July 24, 2026 10:57

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 the root cause of malformed “orphaned” touch events when touch callbacks become attached mid-gesture, by ensuring native platforms don’t dispatch touch-up/move payloads for pointers that were never registered into the per-pointer data structures.

Changes:

  • iOS/macOS: Skip emitting touch events for unregistered touches in touchesBegan/touchesMoved/touchesEnded, and build changedTouches only from successfully registered pointers.
  • Android: Skip dispatchTouchUpEvent when the lifted pointer has no tracked pointer data, preventing orphaned UP events and counter underflow/corruption.

Reviewed changes

Copilot reviewed 2 out of 2 changed files in this pull request and generated no comments.

File Description
packages/react-native-gesture-handler/apple/RNGestureHandlerPointerTracker.mm Filters out unregistered touches from changedPointersData and avoids dispatching orphaned move/up/down touch events.
packages/react-native-gesture-handler/android/src/main/java/com/swmansion/gesturehandler/core/GestureHandler.kt Adds an early return to prevent dispatching orphaned UP events when the pointer was never tracked in trackedPointers.

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

@m-bert
m-bert merged commit 6b0ca7b into main Jul 24, 2026
7 of 9 checks passed
@m-bert
m-bert deleted the @mbert/fix-orphaned-touch-events branch July 24, 2026 14:19
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