diff --git a/packages/react-native-gesture-handler/src/__tests__/reanimatedSwipeableCallbacks.test.tsx b/packages/react-native-gesture-handler/src/__tests__/reanimatedSwipeableCallbacks.test.tsx new file mode 100644 index 0000000000..77678c8d64 --- /dev/null +++ b/packages/react-native-gesture-handler/src/__tests__/reanimatedSwipeableCallbacks.test.tsx @@ -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 & { scheduleOnRN: typeof scheduleOnRN } + >('react-native-worklets/src/mock'); + + return { + ...WorkletsMock, + scheduleOnRN: jest.fn(WorkletsMock.scheduleOnRN), + }; +}); + +async function flushNativeOps() { + await new Promise((resolve) => { + setTimeout(resolve, 0); + }); + await new Promise((resolve) => { + queueMicrotask(resolve); + }); + await new Promise((resolve) => { + setImmediate(resolve); + }); +} + +function SwipeableRow({ + swipeableRef, + onSwipeableOpen, + onSwipeableClose, + onSwipeableWillOpen, + onSwipeableWillClose, + onSwipeableOpenStartDrag, + onSwipeableCloseStartDrag, +}: { + swipeableRef?: React.Ref; + onSwipeableOpen: NonNullable; + onSwipeableClose: NonNullable; + onSwipeableWillOpen: NonNullable; + onSwipeableWillClose: NonNullable; + onSwipeableOpenStartDrag: NonNullable< + SwipeableProps['onSwipeableOpenStartDrag'] + >; + onSwipeableCloseStartDrag: NonNullable< + SwipeableProps['onSwipeableCloseStartDrag'] + >; +}) { + const fallbackRef = React.useRef(null); + + return ( + + Delete} + onSwipeableOpen={onSwipeableOpen} + onSwipeableClose={onSwipeableClose} + onSwipeableWillOpen={onSwipeableWillOpen} + onSwipeableWillClose={onSwipeableWillClose} + onSwipeableOpenStartDrag={onSwipeableOpenStartDrag} + onSwipeableCloseStartDrag={onSwipeableCloseStartDrag}> + Row + + + ); +} + +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(); + await flushNativeOps(); + + const callsAfterMount = setConfigSpy.mock.calls.length; + expect(callsAfterMount).toBeGreaterThan(0); + + rerender(); + 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(); + + const { rerender } = render( + + ); + + swipeableRef.current?.close(); + await flushNativeOps(); + expect(first.onSwipeableWillClose).toHaveBeenCalledTimes(1); + expect(second.onSwipeableWillClose).not.toHaveBeenCalled(); + + rerender(); + + 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(); + + render( + + Delete}> + Row + + + ); + + swipeableRef.current?.close(); + await flushNativeOps(); + expect(scheduleOnRN).not.toHaveBeenCalled(); + }); +}); diff --git a/packages/react-native-gesture-handler/src/components/ReanimatedSwipeable/ReanimatedSwipeable.tsx b/packages/react-native-gesture-handler/src/components/ReanimatedSwipeable/ReanimatedSwipeable.tsx index 4881db1dc7..546e9f7e04 100644 --- a/packages/react-native-gesture-handler/src/components/ReanimatedSwipeable/ReanimatedSwipeable.tsx +++ b/packages/react-native-gesture-handler/src/components/ReanimatedSwipeable/ReanimatedSwipeable.tsx @@ -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'; @@ -45,6 +46,21 @@ const DEFAULT_OVERSHOOT_FRICTION = 1; const DEFAULT_DRAG_OFFSET = 10; const DEFAULT_ENABLE_TRACKING_TWO_FINGER_GESTURE = false; +function useEventCallback( + callback: ((...args: Args) => void) | undefined +): ((...args: Args) => void) | undefined { + const callbackRef = useRef(callback); + callbackRef.current = callback; + + const stableCallback = useCallback((...args: Args) => { + callbackRef.current?.(...args); + }, []); + + // 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, @@ -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, @@ -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) => { 'worklet'; @@ -512,30 +539,13 @@ const Swipeable = (props: SwipeableProps) => { const dragStarted = useSharedValue(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; @@ -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]);