diff --git a/packages/react-native-gesture-handler/android/src/main/java/com/swmansion/gesturehandler/react/events/RNGestureHandlerTouchEvent.kt b/packages/react-native-gesture-handler/android/src/main/java/com/swmansion/gesturehandler/react/events/RNGestureHandlerTouchEvent.kt index d225b51111..8e42b68e3f 100644 --- a/packages/react-native-gesture-handler/android/src/main/java/com/swmansion/gesturehandler/react/events/RNGestureHandlerTouchEvent.kt +++ b/packages/react-native-gesture-handler/android/src/main/java/com/swmansion/gesturehandler/react/events/RNGestureHandlerTouchEvent.kt @@ -69,13 +69,14 @@ class RNGestureHandlerTouchEvent private constructor() : Event { expect(onBegin).toHaveBeenCalledTimes(1); expect(onStart).toHaveBeenCalledTimes(1); }); + + test('Pan gesture drops malformed event without crashing', () => { + // On Android a touch event may be serialized without the `allTouches` key + // when its payload is lost in a race (e.g. rapid taps cancelling the + // gesture). Such an event has no `oldState`, no `allTouches` and no + // `handlerData`, so it used to be misclassified as an update event and + // crash the pan change calculator with + // "Cannot read property 'translationX' of undefined". + const onUpdate = jest.fn(); + const onTouchesUp = jest.fn(); + + const panGesture = renderHook(() => + usePanGesture({ + disableReanimated: true, + onUpdate: (e) => onUpdate(e), + onTouchesUp: (e) => onTouchesUp(e), + }) + ).result.current; + + const { jsEventHandler } = panGesture.detectorCallbacks; + + const malformedTouchEvent = { + handlerTag: panGesture.handlerTag, + state: State.ACTIVE, + eventType: 3, // TouchEventType.TOUCHES_UP + numberOfTouches: 0, + changedTouches: [{ id: 0, x: 0, y: 0, absoluteX: 0, absoluteY: 0 }], + // no `allTouches`, no `oldState`, no `handlerData` + }; + + expect(() => { + // eslint-disable-next-line @typescript-eslint/no-explicit-any + jsEventHandler?.(malformedTouchEvent as any); + }).not.toThrow(); + + expect(onUpdate).not.toHaveBeenCalled(); + expect(onTouchesUp).not.toHaveBeenCalled(); + }); + + test('Pan gesture handles valid update events after a malformed one', () => { + const onUpdate = jest.fn(); + + const panGesture = renderHook(() => + usePanGesture({ + disableReanimated: true, + onUpdate: (e) => onUpdate(e), + }) + ).result.current; + + const { jsEventHandler } = panGesture.detectorCallbacks; + + jsEventHandler?.({ + handlerTag: panGesture.handlerTag, + state: State.ACTIVE, + eventType: 3, + numberOfTouches: 0, + changedTouches: [], + // eslint-disable-next-line @typescript-eslint/no-explicit-any + } as any); + + jsEventHandler?.({ + handlerTag: panGesture.handlerTag, + state: State.ACTIVE, + handlerData: { + translationX: 10, + translationY: 5, + x: 10, + y: 5, + absoluteX: 10, + absoluteY: 5, + velocityX: 0, + velocityY: 0, + stylusData: undefined, + // eslint-disable-next-line @typescript-eslint/no-explicit-any + } as any, + }); + + expect(onUpdate).toHaveBeenCalledTimes(1); + expect(onUpdate).toHaveBeenCalledWith( + expect.objectContaining({ changeX: 10, changeY: 5 }) + ); + }); }); describe('[API v3] Components', () => { diff --git a/packages/react-native-gesture-handler/src/__tests__/utils.test.tsx b/packages/react-native-gesture-handler/src/__tests__/utils.test.tsx index 01b4b1e793..67cab6030f 100644 --- a/packages/react-native-gesture-handler/src/__tests__/utils.test.tsx +++ b/packages/react-native-gesture-handler/src/__tests__/utils.test.tsx @@ -1,4 +1,6 @@ import { withPrevAndCurrent } from '../utils'; +import { getChangeEventCalculator } from '../v3/hooks/utils/eventUtils'; +import type { GestureUpdateEventWithHandlerData } from '../v3/types'; describe('withPrevAndCurrent', () => { test('returns transformed elements', () => { @@ -14,3 +16,46 @@ describe('withPrevAndCurrent', () => { expect(withPrevAndCurrent([], concat)).toEqual([]); }); }); + +describe('getChangeEventCalculator', () => { + type TestHandlerData = { translationX: number; changeX?: number }; + + const diffCalculator = ( + current: TestHandlerData, + previous: TestHandlerData | null + ) => ({ + changeX: previous + ? current.translationX - previous.translationX + : current.translationX, + }); + + const calculator = getChangeEventCalculator(diffCalculator); + + const makeEvent = (handlerData?: TestHandlerData) => + ({ + handlerTag: 1, + state: 4, + handlerData, + }) as GestureUpdateEventWithHandlerData; + + test('computes change payload for well-formed events', () => { + const first = calculator(makeEvent({ translationX: 10 })); + expect(first.handlerData).toEqual({ translationX: 10, changeX: 10 }); + + const second = calculator( + makeEvent({ translationX: 25 }), + makeEvent({ translationX: 10 }) + ); + expect(second.handlerData).toEqual({ translationX: 25, changeX: 15 }); + }); + + test('returns event untouched when handlerData is missing', () => { + // Regression: a malformed event without `handlerData` used to crash the + // diff calculator with "Cannot read property 'translationX' of undefined". + const malformed = makeEvent(undefined); + + expect(() => calculator(malformed)).not.toThrow(); + expect(calculator(malformed)).toBe(malformed); + expect(malformed.handlerData).toBeUndefined(); + }); +}); diff --git a/packages/react-native-gesture-handler/src/v3/hooks/callbacks/eventHandler.ts b/packages/react-native-gesture-handler/src/v3/hooks/callbacks/eventHandler.ts index f1c36f5202..b232aa64bc 100644 --- a/packages/react-native-gesture-handler/src/v3/hooks/callbacks/eventHandler.ts +++ b/packages/react-native-gesture-handler/src/v3/hooks/callbacks/eventHandler.ts @@ -150,6 +150,15 @@ export function eventHandler< return; } + // At this point the event should be an update event carrying `handlerData`. + // If it isn't, it's a malformed event (e.g. a touch event that lost its + // `allTouches` payload in a race on the native side) - drop it instead of + // running the change calculator on undefined data, which would crash the UI + // thread. + if (eventWithData.handlerData === undefined) { + return; + } + if (!dispatchesAnimatedEvents) { handleUpdateEvent( eventWithData, diff --git a/packages/react-native-gesture-handler/src/v3/hooks/utils/eventUtils.ts b/packages/react-native-gesture-handler/src/v3/hooks/utils/eventUtils.ts index 199811cf60..b67f794cf4 100644 --- a/packages/react-native-gesture-handler/src/v3/hooks/utils/eventUtils.ts +++ b/packages/react-native-gesture-handler/src/v3/hooks/utils/eventUtils.ts @@ -118,6 +118,15 @@ export function getChangeEventCalculator( ) => { 'worklet'; const currentEventData = current.handlerData; + + // Events missing `handlerData` cannot have their change payload computed. + // This shouldn't happen for well-formed update events, but a malformed + // event (e.g. a touch event that lost its payloads in a race on the native + // side) must not crash the diff calculator on the UI thread. + if (currentEventData === undefined) { + return current; + } + const previousEventData = previous ? previous.handlerData : null; const changePayload = diffCalculator(currentEventData, previousEventData);