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
@@ -0,0 +1,195 @@
import { render } from '@testing-library/react-native';
import React from 'react';
import { Text } from 'react-native';
import { scheduleOnRN } from 'react-native-worklets';

import GestureHandlerRootView from '../components/GestureHandlerRootView';
import type {
SwipeableMethods,
SwipeableProps,
} from '../components/ReanimatedSwipeable';
import ReanimatedSwipeable from '../components/ReanimatedSwipeable';
import RNGestureHandlerModule from '../RNGestureHandlerModule';

jest.mock('react-native-reanimated', () => {
// eslint-disable-next-line @typescript-eslint/no-unsafe-assignment
const ReactNative = jest.requireActual('react-native');
// eslint-disable-next-line @typescript-eslint/no-unsafe-assignment
const ReactActual = jest.requireActual('react');
// eslint-disable-next-line @typescript-eslint/no-unsafe-assignment, @typescript-eslint/no-unsafe-member-access
const AnimatedView = ReactNative.View;

return {
__esModule: true,
default: {
View: AnimatedView,
createAnimatedComponent: (component: unknown) => component,
},
View: AnimatedView,
createAnimatedComponent: (component: unknown) => component,
interpolate: (value: number) => value,
isSharedValue: () => false,
measure: () => null,
ReduceMotion: { System: 'system' },
// eslint-disable-next-line @typescript-eslint/no-unsafe-return, @typescript-eslint/no-unsafe-call, @typescript-eslint/no-unsafe-member-access
useAnimatedRef: () => ReactActual.useRef(null),
useAnimatedStyle: () => ({}),
useSharedValue: (init: unknown) => {
// eslint-disable-next-line @typescript-eslint/no-unsafe-assignment, @typescript-eslint/no-unsafe-call, @typescript-eslint/no-unsafe-member-access
const ref = ReactActual.useRef({ value: init });
// eslint-disable-next-line @typescript-eslint/no-unsafe-return, @typescript-eslint/no-unsafe-member-access
return ref.current;
},
withSpring: (to: unknown) => to,
};
});

jest.mock('react-native-worklets', () => {
const WorkletsMock = jest.requireActual<
Record<string, unknown> & { scheduleOnRN: typeof scheduleOnRN }
>('react-native-worklets/src/mock');

return {
...WorkletsMock,
scheduleOnRN: jest.fn(WorkletsMock.scheduleOnRN),
};
});

async function flushNativeOps() {
await new Promise<void>((resolve) => {
setTimeout(resolve, 0);
});
await new Promise<void>((resolve) => {
queueMicrotask(resolve);
});
await new Promise<void>((resolve) => {
setImmediate(resolve);
});
}

function SwipeableRow({
swipeableRef,
onSwipeableOpen,
onSwipeableClose,
onSwipeableWillOpen,
onSwipeableWillClose,
onSwipeableOpenStartDrag,
onSwipeableCloseStartDrag,
}: {
swipeableRef?: React.Ref<SwipeableMethods>;
onSwipeableOpen: NonNullable<SwipeableProps['onSwipeableOpen']>;
onSwipeableClose: NonNullable<SwipeableProps['onSwipeableClose']>;
onSwipeableWillOpen: NonNullable<SwipeableProps['onSwipeableWillOpen']>;
onSwipeableWillClose: NonNullable<SwipeableProps['onSwipeableWillClose']>;
onSwipeableOpenStartDrag: NonNullable<
SwipeableProps['onSwipeableOpenStartDrag']
>;
onSwipeableCloseStartDrag: NonNullable<
SwipeableProps['onSwipeableCloseStartDrag']
>;
}) {
const fallbackRef = React.useRef<SwipeableMethods>(null);

return (
<GestureHandlerRootView>
<ReanimatedSwipeable
ref={swipeableRef ?? fallbackRef}
renderRightActions={() => <Text>Delete</Text>}
onSwipeableOpen={onSwipeableOpen}
onSwipeableClose={onSwipeableClose}
onSwipeableWillOpen={onSwipeableWillOpen}
onSwipeableWillClose={onSwipeableWillClose}
onSwipeableOpenStartDrag={onSwipeableOpenStartDrag}
onSwipeableCloseStartDrag={onSwipeableCloseStartDrag}>
<Text>Row</Text>
</ReanimatedSwipeable>
</GestureHandlerRootView>
);
}

function inlineCallbacks() {
return {
onSwipeableOpen: () => undefined,
onSwipeableClose: () => undefined,
onSwipeableWillOpen: () => undefined,
onSwipeableWillClose: () => undefined,
onSwipeableOpenStartDrag: () => undefined,
onSwipeableCloseStartDrag: () => undefined,
};
}

describe('ReanimatedSwipeable callback identity', () => {
let setConfigSpy: jest.SpyInstance;

beforeEach(() => {
jest.clearAllMocks();
setConfigSpy = jest.spyOn(
RNGestureHandlerModule,
'setGestureHandlerConfig'
);
jest.mocked(scheduleOnRN).mockClear();
});

afterEach(() => {
setConfigSpy.mockRestore();
});

test('does not reconfigure native handlers when only event callback identities change', async () => {
const { rerender } = render(<SwipeableRow {...inlineCallbacks()} />);
await flushNativeOps();

const callsAfterMount = setConfigSpy.mock.calls.length;
expect(callsAfterMount).toBeGreaterThan(0);

rerender(<SwipeableRow {...inlineCallbacks()} />);
await flushNativeOps();

expect(setConfigSpy.mock.calls.length).toBe(callsAfterMount);
});

test('invokes the latest event callbacks after a callback-only rerender', async () => {
const first = {
...inlineCallbacks(),
onSwipeableWillClose: jest.fn(),
};
const second = {
...inlineCallbacks(),
onSwipeableWillClose: jest.fn(),
};
const swipeableRef = React.createRef<SwipeableMethods>();

const { rerender } = render(
<SwipeableRow {...first} swipeableRef={swipeableRef} />
);

swipeableRef.current?.close();
await flushNativeOps();
expect(first.onSwipeableWillClose).toHaveBeenCalledTimes(1);
expect(second.onSwipeableWillClose).not.toHaveBeenCalled();

rerender(<SwipeableRow {...second} swipeableRef={swipeableRef} />);

swipeableRef.current?.close();
await flushNativeOps();
expect(first.onSwipeableWillClose).toHaveBeenCalledTimes(1);
expect(second.onSwipeableWillClose).toHaveBeenCalledTimes(1);
});

test('does not schedule JS work when event callbacks are absent', async () => {
const swipeableRef = React.createRef<SwipeableMethods>();

render(
<GestureHandlerRootView>
<ReanimatedSwipeable
ref={swipeableRef}
renderRightActions={() => <Text>Delete</Text>}>
<Text>Row</Text>
</ReanimatedSwipeable>
</GestureHandlerRootView>
);

swipeableRef.current?.close();
await flushNativeOps();
expect(scheduleOnRN).not.toHaveBeenCalled();
});
});
Original file line number Diff line number Diff line change
Expand Up @@ -4,6 +4,7 @@ import React, {
useEffect,
useImperativeHandle,
useMemo,
useRef,
} from 'react';
import type { LayoutChangeEvent } from 'react-native';
import { I18nManager, StyleSheet, View } from 'react-native';
Expand Down Expand Up @@ -45,6 +46,21 @@ const DEFAULT_OVERSHOOT_FRICTION = 1;
const DEFAULT_DRAG_OFFSET = 10;
const DEFAULT_ENABLE_TRACKING_TWO_FINGER_GESTURE = false;

function useEventCallback<Args extends unknown[]>(
callback: ((...args: Args) => void) | undefined
): ((...args: Args) => void) | undefined {
const callbackRef = useRef(callback);
callbackRef.current = callback;

const stableCallback = useCallback((...args: Args) => {
callbackRef.current?.(...args);
}, []);
Comment thread
coderabbitai[bot] marked this conversation as resolved.

// Keep a stable wrapper only while a user callback exists, so the existing
// truthiness checks can still skip `scheduleOnRN` when the prop is absent.
return callback ? stableCallback : undefined;
}

const Swipeable = (props: SwipeableProps) => {
const {
ref,
Expand All @@ -63,12 +79,12 @@ const Swipeable = (props: SwipeableProps) => {
dragOffsetFromRight = -DEFAULT_DRAG_OFFSET,
friction = DEFAULT_FRICTION,
overshootFriction = DEFAULT_OVERSHOOT_FRICTION,
onSwipeableOpenStartDrag,
onSwipeableCloseStartDrag,
onSwipeableWillOpen,
onSwipeableWillClose,
onSwipeableOpen,
onSwipeableClose,
onSwipeableOpenStartDrag: onSwipeableOpenStartDragProp,
onSwipeableCloseStartDrag: onSwipeableCloseStartDragProp,
onSwipeableWillOpen: onSwipeableWillOpenProp,
onSwipeableWillClose: onSwipeableWillCloseProp,
onSwipeableOpen: onSwipeableOpenProp,
onSwipeableClose: onSwipeableCloseProp,
renderLeftActions,
renderRightActions,
simultaneousWith,
Expand All @@ -78,6 +94,17 @@ const Swipeable = (props: SwipeableProps) => {
...remainingProps
} = props;

const onSwipeableOpenStartDrag = useEventCallback(
onSwipeableOpenStartDragProp
);
const onSwipeableCloseStartDrag = useEventCallback(
onSwipeableCloseStartDragProp
);
const onSwipeableWillOpen = useEventCallback(onSwipeableWillOpenProp);
const onSwipeableWillClose = useEventCallback(onSwipeableWillCloseProp);
const onSwipeableOpen = useEventCallback(onSwipeableOpenProp);
const onSwipeableClose = useEventCallback(onSwipeableCloseProp);

if (__DEV__) {
const checkValue = (value: SharedValueOrT<number>) => {
'worklet';
Expand Down Expand Up @@ -512,30 +539,13 @@ const Swipeable = (props: SwipeableProps) => {

const dragStarted = useSharedValue<boolean>(false);

const tapGesture = useTapGesture({
shouldCancelWhenOutside: true,
enabled: shouldEnableTap,
simultaneousWith,
requireToFail,
block,
onActivate: () => {
'worklet';
if (rowState.value !== 0) {
close();
}
},
});
const handleFinalize = useCallback(() => {
'worklet';
dragStarted.value = false;
}, [dragStarted]);

const panGesture = usePanGesture({
enabled: enabled ?? true,
enableTrackpadTwoFingerGesture: enableTrackpadTwoFingerGesture,
activeOffsetX: [dragOffsetFromRight, dragOffsetFromLeft],
simultaneousWith,
requireToFail,
block,
hitSlop: hitSlop,
onActivate: updateElementWidths,
onUpdate: (event: PanGestureActiveEvent) => {
const handleUpdate = useCallback(
(event: PanGestureActiveEvent) => {
'worklet';
userDrag.value = event.translationX;

Expand All @@ -559,15 +569,69 @@ const Swipeable = (props: SwipeableProps) => {

updateAnimatedEvent();
},
onDeactivate: (event: PanGestureActiveEvent) => {
'worklet';
handleRelease(event);
},
onFinalize: () => {
'worklet';
dragStarted.value = false;
},
});
[
dragStarted,
onSwipeableCloseStartDrag,
onSwipeableOpenStartDrag,
rowState,
updateAnimatedEvent,
userDrag,
]
);

const tapConfig = useMemo(
() => ({
shouldCancelWhenOutside: true,
enabled: shouldEnableTap,
simultaneousWith,
requireToFail,
block,
onActivate: () => {
'worklet';
if (rowState.value !== 0) {
close();
}
},
}),
[block, close, requireToFail, rowState, shouldEnableTap, simultaneousWith]
);

const tapGesture = useTapGesture(tapConfig);

const panConfig = useMemo(
() => ({
enabled: enabled ?? true,
enableTrackpadTwoFingerGesture: enableTrackpadTwoFingerGesture,
activeOffsetX: [dragOffsetFromRight, dragOffsetFromLeft] as [
typeof dragOffsetFromRight,
typeof dragOffsetFromLeft,
],
simultaneousWith,
requireToFail,
block,
hitSlop: hitSlop,
onActivate: updateElementWidths,
onUpdate: handleUpdate,
onDeactivate: handleRelease,
onFinalize: handleFinalize,
}),
[
block,
dragOffsetFromLeft,
dragOffsetFromRight,
enableTrackpadTwoFingerGesture,
enabled,
handleFinalize,
handleRelease,
handleUpdate,
hitSlop,
requireToFail,
simultaneousWith,
updateElementWidths,
]
);

const panGesture = usePanGesture(panConfig);

useImperativeHandle(ref, () => swipeableMethods, [swipeableMethods]);

Expand Down
Loading