Skip to content

Fix fatal crash Cannot read property 'translationX' of undefined when a touch event is serialized without allTouches#4316

Merged
m-bert merged 4 commits into
software-mansion:mainfrom
huextrat:fix/fix-touch-event-missing-alltouches-crash
Jul 24, 2026
Merged

Fix fatal crash Cannot read property 'translationX' of undefined when a touch event is serialized without allTouches#4316
m-bert merged 4 commits into
software-mansion:mainfrom
huextrat:fix/fix-touch-event-missing-alltouches-crash

Conversation

@huextrat

Copy link
Copy Markdown
Contributor

Note

This PR was written with AI assistance (Claude), based on a production crash investigated from Sentry.

Description

Fixes a fatal, unhandled production crash in apps using the v3 API (usePanGesture + GestureDetector) with Reanimated on Android (Fabric / New Architecture):

TypeError: Cannot read property 'translationX' of undefined
    at diffCalculator
    at getChangeEventCalculator
    at handleUpdateEvent
    at eventHandler

Since the error is thrown inside a worklet running on the UI thread, it results in a native CppException caught by the UncaughtExceptionHandler — a hard crash, not a JS redbox.

Observed trigger: very fast repeated taps on a screen with a GestureDetector + pan gesture (reproduced in production on a low-end Android 15 device, but the underlying race is not device-specific).

Root cause

The failure is a chain across the Android event serialization and the v3 event classification:

  1. Kotlin — GestureHandler.kt: dispatchTouchEvent() only guards on changedTouchesPayload != null. In dispatchTouchUpEvent(), extractAllPointersData() runs before the changed pointer is re-added to trackedPointers. If the tracked pointers were already cleared at that point (e.g. cancelPointers() fired by a rapid succession of taps racing with the touch-up dispatch), allTouchesPayload is null while changedTouchesPayload is still populated — so the event is dispatched anyway.

  2. Kotlin — RNGestureHandlerTouchEvent.kt: the serializer omitted the key entirely when the payload was null:

    handler.consumeAllTouchesPayload()?.let { putArray("allTouches", it) }

    → a touch event can reach JS without the allTouches key. Note that iOS always serializes allTouches/changedTouches as (possibly empty) arrays (RNGestureHandlerManager.mm initializes .allTouches = {}), so this was also a platform inconsistency.

  3. TS — src/v3/hooks/utils/eventUtils.ts: touch events are discriminated by key presence:

    export function isTouchEvent(...) { 'worklet'; return 'allTouches' in event; }

    Key absent → the touch event is misclassified as an update event.

  4. TS — src/v3/hooks/callbacks/eventHandler.ts: the event is routed to handleUpdateEvent()getChangeEventCalculator() reads current.handlerData (undefined for a touch event) and passes it to the gesture's diffCalculator, which reads current.translationXTypeError in a UI-thread worklet → fatal crash.

Fix

JS (defensive, worklet-safe, no behavior change for valid events):

  • eventHandler() now drops events that are neither state-change events, nor touch events, nor carry handlerData, instead of treating them as update events. (handlerData is present on all well-formed update events on every platform: Android createNativeEventData, web GestureHandler.ts, and jestUtils.)
  • getChangeEventCalculator() returns the event unchanged when handlerData is undefined instead of calling the diff calculator with undefined data.

Android (root cause):

  • RNGestureHandlerTouchEvent.createEventData() always serializes allTouches and changedTouches, falling back to empty arrays instead of omitting the keys — matching the iOS implementation.

Test plan

  • New regression test in src/__tests__/api_v3.test.tsx: fires a malformed touch event (no allTouches, no oldState, no handlerData) through a pan gesture's jsEventHandler. Without the JS fix it reproduces the exact production error (Cannot read properties of undefined (reading 'translationX') through diffCalculator); with the fix the event is dropped without invoking any callback, and subsequent valid update events still compute changeX/changeY correctly.
  • New unit tests for getChangeEventCalculator in src/__tests__/utils.test.tsx: change payload computed for well-formed events; event returned untouched when handlerData is missing.
  • Full Jest suite passes (80/80), yarn ts-check clean, yarn lint:js clean.
  • Android: spotlessCheck passes, library compiles via apps/basic-example (:react-native-gesture-handler:compileDebugKotlin — BUILD SUCCESSFUL).

huextrat added 2 commits July 15, 2026 15:31
On Android, a touch event can be serialized without the `allTouches` key
when its payload is lost in a race (e.g. rapid taps cancelling the
gesture while a touch-up is being dispatched). `isTouchEvent` relies on
`'allTouches' in event`, so such an event was misclassified as an update
event and passed to the change event calculator, which crashed reading
gesture-specific data (`Cannot read property 'translationX' of
undefined`) inside a UI-thread worklet - a fatal error in production.

- `eventHandler` now drops events that are neither state-change nor
  touch events and carry no `handlerData` instead of treating them as
  update events.
- `getChangeEventCalculator` returns the event unchanged when
  `handlerData` is missing instead of calling the diff calculator with
  undefined.

Valid events are unaffected; both guards are worklet-safe.
Copilot AI review requested due to automatic review settings July 15, 2026 13:38

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

This PR fixes a hard crash in the v3 gesture event pipeline caused by a malformed Android touch event being serialized without the allTouches key, which led to misclassification as an update event and a UI-thread worklet exception.

Changes:

  • Make the v3 JS event router defensively drop events that are neither state-change nor touch events and that also lack handlerData, preventing the diff calculator from being called with undefined.
  • Make the v3 change calculator return the event unchanged when handlerData is missing (worklet-safe guard).
  • Fix Android touch event serialization to always include changedTouches and allTouches keys (empty arrays when payloads are unavailable), matching iOS behavior; add Jest regression/unit tests.

Reviewed changes

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

Show a summary per file
File Description
packages/react-native-gesture-handler/src/v3/hooks/utils/eventUtils.ts Adds a worklet-safe guard in getChangeEventCalculator and relies on allTouches key presence for touch-event discrimination.
packages/react-native-gesture-handler/src/v3/hooks/callbacks/eventHandler.ts Drops malformed non-touch/non-state-change events with missing handlerData instead of routing them to update handling.
packages/react-native-gesture-handler/src/tests/utils.test.tsx Adds unit tests covering normal change-payload computation and the missing-handlerData regression case.
packages/react-native-gesture-handler/src/tests/api_v3.test.tsx Adds an integration-style regression test ensuring malformed touch events don’t crash and don’t break subsequent valid updates.
packages/react-native-gesture-handler/android/src/main/java/com/swmansion/gesturehandler/react/events/RNGestureHandlerTouchEvent.kt Ensures changedTouches and allTouches are always serialized (empty arrays as fallback) to avoid JS-side misclassification.

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

@m-bert m-bert left a comment

Copy link
Copy Markdown
Collaborator

Choose a reason for hiding this comment

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

Hi @huextrat! Thank you for this PR! I took the liberty of slightly modifying it - merging main and removing some comments which were not necessary. Hope you don't mind!

@huextrat

Copy link
Copy Markdown
Contributor Author

Hi @huextrat! Thank you for this PR! I took the liberty of slightly modifying it - merging main and removing some comments which were not necessary. Hope you don't mind!

Not at all, thanks for cleaning it up! You know the codebase best. Happy to help anytime.

@m-bert
m-bert merged commit 13d0070 into software-mansion:main Jul 24, 2026
6 checks passed
m-bert added a commit that referenced this pull request Jul 24, 2026
## 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
`onBegin` — `needsPointerData` 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 #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

<details>
<summary>Tested on the following code:</summary>

```tsx
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,
  },
});
```

</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