Skip to content
Open
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
Original file line number Diff line number Diff line change
Expand Up @@ -69,13 +69,14 @@ class RNGestureHandlerTouchEvent private constructor() : Event<RNGestureHandlerT
putInt("eventType", handler.touchEventType)
putInt("pointerType", handler.pointerType)

handler.consumeChangedTouchesPayload()?.let {
putArray("changedTouches", it)
}
// Both keys have to be present in every touch event, otherwise the JS side may
// misclassify it as an update event and crash trying to read gesture-specific data.
// The payloads may be null when the tracked pointers were cleared before the event
// was serialized (e.g. the gesture was cancelled by a rapid succession of taps) or
// when the payload was already consumed - fall back to empty arrays, matching iOS.
putArray("changedTouches", handler.consumeChangedTouchesPayload() ?: Arguments.createArray())

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

if (handler.isAwaiting && handler.state == GestureHandler.STATE_ACTIVE) {
putInt("state", GestureHandler.STATE_BEGAN)
Expand Down
Original file line number Diff line number Diff line change
Expand Up @@ -28,8 +28,8 @@
const panGesture = renderHook(() =>
usePanGesture({
disableReanimated: true,
onBegin: (e) => onBegin(e),

Check warning on line 31 in packages/react-native-gesture-handler/src/__tests__/api_v3.test.tsx

View workflow job for this annotation

GitHub Actions / check

Unsafe return of an `any` typed value
onActivate: (e) => onStart(e),

Check warning on line 32 in packages/react-native-gesture-handler/src/__tests__/api_v3.test.tsx

View workflow job for this annotation

GitHub Actions / check

Unsafe return of an `any` typed value
})
).result.current;

Expand All @@ -43,6 +43,88 @@
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),

Check warning on line 60 in packages/react-native-gesture-handler/src/__tests__/api_v3.test.tsx

View workflow job for this annotation

GitHub Actions / check

Unsafe return of an `any` typed value
onTouchesUp: (e) => onTouchesUp(e),

Check warning on line 61 in packages/react-native-gesture-handler/src/__tests__/api_v3.test.tsx

View workflow job for this annotation

GitHub Actions / check

Unsafe return of an `any` typed value
})
).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);

Check warning on line 78 in packages/react-native-gesture-handler/src/__tests__/api_v3.test.tsx

View workflow job for this annotation

GitHub Actions / check

Unsafe argument of type `any` assigned to a parameter of type `GestureHandlerEventWithHandlerData<PanHandlerData, PanExtendedHandlerData>`
}).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),

Check warning on line 91 in packages/react-native-gesture-handler/src/__tests__/api_v3.test.tsx

View workflow job for this annotation

GitHub Actions / check

Unsafe return of an `any` typed value
})
).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', () => {
Expand Down
45 changes: 45 additions & 0 deletions packages/react-native-gesture-handler/src/__tests__/utils.test.tsx
Original file line number Diff line number Diff line change
@@ -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', () => {
Expand All @@ -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<TestHandlerData>;

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();
});
});
Original file line number Diff line number Diff line change
Expand Up @@ -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,
Expand Down
Original file line number Diff line number Diff line change
Expand Up @@ -118,6 +118,15 @@ export function getChangeEventCalculator<TExtendedHandlerData>(
) => {
'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);
Expand Down
Loading