From f843a94b92ea8dd5f627b7da900f9cfbbdac6568 Mon Sep 17 00:00:00 2001 From: Ngoc Le Date: Sat, 22 Aug 2026 19:12:42 +0700 Subject: [PATCH 1/3] Keep ReanimatedSwipeable native handlers stable when event callbacks change Inline onSwipeableOpen / onSwipeableClose (and the other event props) sat in the worklet dependency chain, so a new function identity on each parent render reconfigured the pan and tap handlers. That matches the list-scroll stutter reported when those props are passed inline. Keep the latest user callbacks behind stable wrappers, memoize the gesture configs, and add a regression test that the native config is not rewritten on a callback-only rerender. Fixes #3307 --- .../reanimatedSwipeableCallbacks.test.tsx | 163 ++++++++++++++++++ .../ReanimatedSwipeable.tsx | 136 +++++++++++---- 2 files changed, 261 insertions(+), 38 deletions(-) create mode 100644 packages/react-native-gesture-handler/src/__tests__/reanimatedSwipeableCallbacks.test.tsx 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..7b181b6c8e --- /dev/null +++ b/packages/react-native-gesture-handler/src/__tests__/reanimatedSwipeableCallbacks.test.tsx @@ -0,0 +1,163 @@ +import { render } from '@testing-library/react-native'; +import React from 'react'; +import { Text } from 'react-native'; + +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', () => ({ + isWorkletRuntime: () => false, + scheduleOnRN: (fn: (...args: unknown[]) => void, ...args: unknown[]) => + fn(...args), + scheduleOnUI: (fn: () => void) => fn(), +})); + +async function flushNativeOps() { + 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' + ); + }); + + 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', () => { + const first = { + ...inlineCallbacks(), + onSwipeableWillClose: jest.fn(), + }; + const second = { + ...inlineCallbacks(), + onSwipeableWillClose: jest.fn(), + }; + const swipeableRef = React.createRef(); + + const { rerender } = render( + + ); + + swipeableRef.current?.close(); + expect(first.onSwipeableWillClose).toHaveBeenCalledTimes(1); + expect(second.onSwipeableWillClose).not.toHaveBeenCalled(); + + rerender(); + + swipeableRef.current?.close(); + expect(first.onSwipeableWillClose).toHaveBeenCalledTimes(1); + expect(second.onSwipeableWillClose).toHaveBeenCalledTimes(1); + }); +}); 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..ec37fee54e 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,17 @@ 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 +) { + const callbackRef = useRef(callback); + callbackRef.current = callback; + + return useCallback((...args: Args) => { + callbackRef.current?.(...args); + }, []); +} + const Swipeable = (props: SwipeableProps) => { const { ref, @@ -63,12 +75,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 +90,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 +535,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 +565,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]); From fdf5bea418db9ccf63632efad7f0c63b026ebb36 Mon Sep 17 00:00:00 2001 From: Ngoc Le Date: Sat, 22 Aug 2026 19:26:09 +0700 Subject: [PATCH 2/3] Skip scheduleOnRN when ReanimatedSwipeable event callbacks are absent useEventCallback always returned a wrapper, so the existing truthiness guards scheduled a no-op onto the JS queue on every open/close/drag-start. Return undefined while the user prop is missing; the wrapper stays stable only while a callback exists. --- .../reanimatedSwipeableCallbacks.test.tsx | 26 ++++++++++++++++--- .../ReanimatedSwipeable.tsx | 8 ++++-- 2 files changed, 29 insertions(+), 5 deletions(-) diff --git a/packages/react-native-gesture-handler/src/__tests__/reanimatedSwipeableCallbacks.test.tsx b/packages/react-native-gesture-handler/src/__tests__/reanimatedSwipeableCallbacks.test.tsx index 7b181b6c8e..cad43d38e6 100644 --- a/packages/react-native-gesture-handler/src/__tests__/reanimatedSwipeableCallbacks.test.tsx +++ b/packages/react-native-gesture-handler/src/__tests__/reanimatedSwipeableCallbacks.test.tsx @@ -1,6 +1,7 @@ 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 { @@ -45,9 +46,10 @@ jest.mock('react-native-reanimated', () => { jest.mock('react-native-worklets', () => ({ isWorkletRuntime: () => false, - scheduleOnRN: (fn: (...args: unknown[]) => void, ...args: unknown[]) => - fn(...args), - scheduleOnUI: (fn: () => void) => fn(), + scheduleOnRN: jest.fn( + (fn: (...args: unknown[]) => void, ...args: unknown[]) => fn(...args) + ), + scheduleOnUI: jest.fn((fn: () => void) => fn()), })); async function flushNativeOps() { @@ -116,6 +118,7 @@ describe('ReanimatedSwipeable callback identity', () => { RNGestureHandlerModule, 'setGestureHandlerConfig' ); + jest.mocked(scheduleOnRN).mockClear(); }); afterEach(() => { @@ -160,4 +163,21 @@ describe('ReanimatedSwipeable callback identity', () => { expect(first.onSwipeableWillClose).toHaveBeenCalledTimes(1); expect(second.onSwipeableWillClose).toHaveBeenCalledTimes(1); }); + + test('does not schedule JS work when event callbacks are absent', () => { + const swipeableRef = React.createRef(); + + render( + + Delete}> + Row + + + ); + + swipeableRef.current?.close(); + 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 ec37fee54e..546e9f7e04 100644 --- a/packages/react-native-gesture-handler/src/components/ReanimatedSwipeable/ReanimatedSwipeable.tsx +++ b/packages/react-native-gesture-handler/src/components/ReanimatedSwipeable/ReanimatedSwipeable.tsx @@ -48,13 +48,17 @@ 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; - return useCallback((...args: Args) => { + 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) => { From 68420ee5065aba0bbae318b50f589122bd7bcf53 Mon Sep 17 00:00:00 2001 From: Ngoc Le Date: Mon, 24 Aug 2026 19:13:08 +0700 Subject: [PATCH 3/3] test: use official Worklets mock --- .../reanimatedSwipeableCallbacks.test.tsx | 30 +++++++++++++------ 1 file changed, 21 insertions(+), 9 deletions(-) diff --git a/packages/react-native-gesture-handler/src/__tests__/reanimatedSwipeableCallbacks.test.tsx b/packages/react-native-gesture-handler/src/__tests__/reanimatedSwipeableCallbacks.test.tsx index cad43d38e6..77678c8d64 100644 --- a/packages/react-native-gesture-handler/src/__tests__/reanimatedSwipeableCallbacks.test.tsx +++ b/packages/react-native-gesture-handler/src/__tests__/reanimatedSwipeableCallbacks.test.tsx @@ -44,15 +44,24 @@ jest.mock('react-native-reanimated', () => { }; }); -jest.mock('react-native-worklets', () => ({ - isWorkletRuntime: () => false, - scheduleOnRN: jest.fn( - (fn: (...args: unknown[]) => void, ...args: unknown[]) => fn(...args) - ), - scheduleOnUI: jest.fn((fn: () => void) => fn()), -})); +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); }); @@ -138,7 +147,7 @@ describe('ReanimatedSwipeable callback identity', () => { expect(setConfigSpy.mock.calls.length).toBe(callsAfterMount); }); - test('invokes the latest event callbacks after a callback-only rerender', () => { + test('invokes the latest event callbacks after a callback-only rerender', async () => { const first = { ...inlineCallbacks(), onSwipeableWillClose: jest.fn(), @@ -154,17 +163,19 @@ describe('ReanimatedSwipeable callback identity', () => { ); 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', () => { + test('does not schedule JS work when event callbacks are absent', async () => { const swipeableRef = React.createRef(); render( @@ -178,6 +189,7 @@ describe('ReanimatedSwipeable callback identity', () => { ); swipeableRef.current?.close(); + await flushNativeOps(); expect(scheduleOnRN).not.toHaveBeenCalled(); }); });