Skip to content

Commit 13d0070

Browse files
huextratm-bert
andauthored
Fix fatal crash Cannot read property 'translationX' of undefined when a touch event is serialized without allTouches (#4316)
> [!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`: ```kotlin 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: ```ts 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.translationX` → `TypeError` 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). --------- Co-authored-by: Michał <michal.bert@swmansion.com>
1 parent f7394a5 commit 13d0070

5 files changed

Lines changed: 140 additions & 7 deletions

File tree

packages/react-native-gesture-handler/android/src/main/java/com/swmansion/gesturehandler/react/events/RNGestureHandlerTouchEvent.kt

Lines changed: 2 additions & 7 deletions
Original file line numberDiff line numberDiff line change
@@ -69,13 +69,8 @@ class RNGestureHandlerTouchEvent private constructor() : Event<RNGestureHandlerT
6969
putInt("eventType", handler.touchEventType)
7070
putInt("pointerType", handler.pointerType)
7171

72-
handler.consumeChangedTouchesPayload()?.let {
73-
putArray("changedTouches", it)
74-
}
75-
76-
handler.consumeAllTouchesPayload()?.let {
77-
putArray("allTouches", it)
78-
}
72+
putArray("changedTouches", handler.consumeChangedTouchesPayload() ?: Arguments.createArray())
73+
putArray("allTouches", handler.consumeAllTouchesPayload() ?: Arguments.createArray())
7974

8075
if (handler.isAwaiting && handler.state == GestureHandler.STATE_ACTIVE) {
8176
putInt("state", GestureHandler.STATE_BEGAN)

packages/react-native-gesture-handler/src/__tests__/api_v3.test.tsx

Lines changed: 82 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -43,6 +43,88 @@ describe('[API v3] Hooks', () => {
4343
expect(onBegin).toHaveBeenCalledTimes(1);
4444
expect(onStart).toHaveBeenCalledTimes(1);
4545
});
46+
47+
test('Pan gesture drops malformed event without crashing', () => {
48+
// On Android a touch event may be serialized without the `allTouches` key
49+
// when its payload is lost in a race (e.g. rapid taps cancelling the
50+
// gesture). Such an event has no `oldState`, no `allTouches` and no
51+
// `handlerData`, so it used to be misclassified as an update event and
52+
// crash the pan change calculator with
53+
// "Cannot read property 'translationX' of undefined".
54+
const onUpdate = jest.fn();
55+
const onTouchesUp = jest.fn();
56+
57+
const panGesture = renderHook(() =>
58+
usePanGesture({
59+
disableReanimated: true,
60+
onUpdate: (e) => onUpdate(e),
61+
onTouchesUp: (e) => onTouchesUp(e),
62+
})
63+
).result.current;
64+
65+
const { jsEventHandler } = panGesture.detectorCallbacks;
66+
67+
const malformedTouchEvent = {
68+
handlerTag: panGesture.handlerTag,
69+
state: State.ACTIVE,
70+
eventType: 3, // TouchEventType.TOUCHES_UP
71+
numberOfTouches: 0,
72+
changedTouches: [{ id: 0, x: 0, y: 0, absoluteX: 0, absoluteY: 0 }],
73+
// no `allTouches`, no `oldState`, no `handlerData`
74+
};
75+
76+
expect(() => {
77+
// eslint-disable-next-line @typescript-eslint/no-explicit-any
78+
jsEventHandler?.(malformedTouchEvent as any);
79+
}).not.toThrow();
80+
81+
expect(onUpdate).not.toHaveBeenCalled();
82+
expect(onTouchesUp).not.toHaveBeenCalled();
83+
});
84+
85+
test('Pan gesture handles valid update events after a malformed one', () => {
86+
const onUpdate = jest.fn();
87+
88+
const panGesture = renderHook(() =>
89+
usePanGesture({
90+
disableReanimated: true,
91+
onUpdate: (e) => onUpdate(e),
92+
})
93+
).result.current;
94+
95+
const { jsEventHandler } = panGesture.detectorCallbacks;
96+
97+
jsEventHandler?.({
98+
handlerTag: panGesture.handlerTag,
99+
state: State.ACTIVE,
100+
eventType: 3,
101+
numberOfTouches: 0,
102+
changedTouches: [],
103+
// eslint-disable-next-line @typescript-eslint/no-explicit-any
104+
} as any);
105+
106+
jsEventHandler?.({
107+
handlerTag: panGesture.handlerTag,
108+
state: State.ACTIVE,
109+
handlerData: {
110+
translationX: 10,
111+
translationY: 5,
112+
x: 10,
113+
y: 5,
114+
absoluteX: 10,
115+
absoluteY: 5,
116+
velocityX: 0,
117+
velocityY: 0,
118+
stylusData: undefined,
119+
// eslint-disable-next-line @typescript-eslint/no-explicit-any
120+
} as any,
121+
});
122+
123+
expect(onUpdate).toHaveBeenCalledTimes(1);
124+
expect(onUpdate).toHaveBeenCalledWith(
125+
expect.objectContaining({ changeX: 10, changeY: 5 })
126+
);
127+
});
46128
});
47129

48130
describe('[API v3] Components', () => {

packages/react-native-gesture-handler/src/__tests__/utils.test.tsx

Lines changed: 45 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -1,4 +1,6 @@
11
import { withPrevAndCurrent } from '../utils';
2+
import { getChangeEventCalculator } from '../v3/hooks/utils/eventUtils';
3+
import type { GestureUpdateEventWithHandlerData } from '../v3/types';
24

35
describe('withPrevAndCurrent', () => {
46
test('returns transformed elements', () => {
@@ -14,3 +16,46 @@ describe('withPrevAndCurrent', () => {
1416
expect(withPrevAndCurrent([], concat)).toEqual([]);
1517
});
1618
});
19+
20+
describe('getChangeEventCalculator', () => {
21+
type TestHandlerData = { translationX: number; changeX?: number };
22+
23+
const diffCalculator = (
24+
current: TestHandlerData,
25+
previous: TestHandlerData | null
26+
) => ({
27+
changeX: previous
28+
? current.translationX - previous.translationX
29+
: current.translationX,
30+
});
31+
32+
const calculator = getChangeEventCalculator(diffCalculator);
33+
34+
const makeEvent = (handlerData?: TestHandlerData) =>
35+
({
36+
handlerTag: 1,
37+
state: 4,
38+
handlerData,
39+
}) as GestureUpdateEventWithHandlerData<TestHandlerData>;
40+
41+
test('computes change payload for well-formed events', () => {
42+
const first = calculator(makeEvent({ translationX: 10 }));
43+
expect(first.handlerData).toEqual({ translationX: 10, changeX: 10 });
44+
45+
const second = calculator(
46+
makeEvent({ translationX: 25 }),
47+
makeEvent({ translationX: 10 })
48+
);
49+
expect(second.handlerData).toEqual({ translationX: 25, changeX: 15 });
50+
});
51+
52+
test('returns event untouched when handlerData is missing', () => {
53+
// Regression: a malformed event without `handlerData` used to crash the
54+
// diff calculator with "Cannot read property 'translationX' of undefined".
55+
const malformed = makeEvent(undefined);
56+
57+
expect(() => calculator(malformed)).not.toThrow();
58+
expect(calculator(malformed)).toBe(malformed);
59+
expect(malformed.handlerData).toBeUndefined();
60+
});
61+
});

packages/react-native-gesture-handler/src/v3/hooks/callbacks/eventHandler.ts

Lines changed: 5 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -150,6 +150,11 @@ export function eventHandler<
150150
return;
151151
}
152152

153+
// Guard against malformed events
154+
if (eventWithData.handlerData === undefined) {
155+
return;
156+
}
157+
153158
if (!dispatchesAnimatedEvents) {
154159
handleUpdateEvent(
155160
eventWithData,

packages/react-native-gesture-handler/src/v3/hooks/utils/eventUtils.ts

Lines changed: 6 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -118,6 +118,12 @@ export function getChangeEventCalculator<TExtendedHandlerData>(
118118
) => {
119119
'worklet';
120120
const currentEventData = current.handlerData;
121+
122+
// Guard against malformed events
123+
if (currentEventData === undefined) {
124+
return current;
125+
}
126+
121127
const previousEventData = previous ? previous.handlerData : null;
122128

123129
const changePayload = diffCalculator(currentEventData, previousEventData);

0 commit comments

Comments
 (0)