Skip to content

Commit 6fdee58

Browse files
committed
Fix crash when v3 update handler receives event without handlerData
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.
1 parent fb1089b commit 6fdee58

4 files changed

Lines changed: 145 additions & 0 deletions

File tree

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: 9 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -150,6 +150,15 @@ export function eventHandler<
150150
return;
151151
}
152152

153+
// At this point the event should be an update event carrying `handlerData`.
154+
// If it isn't, it's a malformed event (e.g. a touch event that lost its
155+
// `allTouches` payload in a race on the native side) - drop it instead of
156+
// running the change calculator on undefined data, which would crash the UI
157+
// thread.
158+
if (eventWithData.handlerData === undefined) {
159+
return;
160+
}
161+
153162
if (!dispatchesAnimatedEvents) {
154163
handleUpdateEvent(
155164
eventWithData,

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

Lines changed: 9 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -118,6 +118,15 @@ export function getChangeEventCalculator<TExtendedHandlerData>(
118118
) => {
119119
'worklet';
120120
const currentEventData = current.handlerData;
121+
122+
// Events missing `handlerData` cannot have their change payload computed.
123+
// This shouldn't happen for well-formed update events, but a malformed
124+
// event (e.g. a touch event that lost its payloads in a race on the native
125+
// side) must not crash the diff calculator on the UI thread.
126+
if (currentEventData === undefined) {
127+
return current;
128+
}
129+
121130
const previousEventData = previous ? previous.handlerData : null;
122131

123132
const changePayload = diffCalculator(currentEventData, previousEventData);

0 commit comments

Comments
 (0)