diff --git a/packages/react-native-gesture-handler/src/__tests__/api_v3.test.tsx b/packages/react-native-gesture-handler/src/__tests__/api_v3.test.tsx index aba80c8a8f..f9aca8598a 100644 --- a/packages/react-native-gesture-handler/src/__tests__/api_v3.test.tsx +++ b/packages/react-native-gesture-handler/src/__tests__/api_v3.test.tsx @@ -1,13 +1,28 @@ import { render, renderHook } from '@testing-library/react-native'; -import { act } from 'react'; +import React, { act } from 'react'; import GestureHandlerRootView from '../components/GestureHandlerRootView'; import { fireGestureHandler, getByGestureTestId } from '../jestUtils'; +import RNGestureHandlerModule from '../RNGestureHandlerModule'; import { State } from '../State'; import { RectButton, Touchable } from '../v3/components'; import { usePanGesture } from '../v3/hooks/gestures'; import type { SingleGesture } from '../v3/types'; +async function flushQueuedOperations() { + await act(async () => { + await new Promise((resolve) => { + setImmediate(() => resolve()); + }); + }); +} + +afterEach(async () => { + await flushQueuedOperations(); + await flushQueuedOperations(); + jest.restoreAllMocks(); +}); + describe('[API v3] Hooks', () => { test('Pan gesture', () => { const onBegin = jest.fn(); @@ -31,6 +46,32 @@ describe('[API v3] Hooks', () => { expect(onBegin).toHaveBeenCalledTimes(1); expect(onStart).toHaveBeenCalledTimes(1); }); + + test('does not drop native handler during StrictMode effect replay', async () => { + const dropGestureHandlerSpy = jest.spyOn( + RNGestureHandlerModule, + 'dropGestureHandler' + ); + + const StrictModeWrapper = ({ children }: React.PropsWithChildren) => ( + {children} + ); + + const { unmount } = renderHook( + () => usePanGesture({ disableReanimated: true }), + { wrapper: StrictModeWrapper } + ); + + await flushQueuedOperations(); + + expect(dropGestureHandlerSpy).not.toHaveBeenCalled(); + + unmount(); + await flushQueuedOperations(); + await flushQueuedOperations(); + + expect(dropGestureHandlerSpy).toHaveBeenCalledTimes(1); + }); }); describe('[API v3] Components', () => { diff --git a/packages/react-native-gesture-handler/src/v3/hooks/useGesture.ts b/packages/react-native-gesture-handler/src/v3/hooks/useGesture.ts index 6548575b25..9dcf387c6f 100644 --- a/packages/react-native-gesture-handler/src/v3/hooks/useGesture.ts +++ b/packages/react-native-gesture-handler/src/v3/hooks/useGesture.ts @@ -1,5 +1,6 @@ import { useEffect, useMemo, useRef } from 'react'; +import { ghQueueMicrotask } from '../../ghQueueMicrotask'; import { getNextHandlerTag } from '../../handlers/getNextHandlerTag'; import { registerGesture, @@ -62,6 +63,7 @@ export function useGesture< ); const currentGestureRef = useRef({ type: '', handlerTag: -1 }); + const dropGestureHandlerTokenRef = useRef(0); if ( currentGestureRef.current.handlerTag !== handlerTag || currentGestureRef.current.type !== (type as string) @@ -94,13 +96,30 @@ export function useGesture< ); useEffect(() => { + // React StrictMode replays effects without recreating the hook instance. + // Delay dropping the native handler so the replayed mount can cancel it. + dropGestureHandlerTokenRef.current += 1; + return () => { - if (currentGestureRef.current.handlerTag === handlerTag) { - currentGestureRef.current = { type: '', handlerTag: -1 }; - } + const dropGestureHandlerToken = ++dropGestureHandlerTokenRef.current; + + ghQueueMicrotask(() => { + const wasSameHandlerRecreated = + dropGestureHandlerTokenRef.current !== dropGestureHandlerToken && + currentGestureRef.current.handlerTag === handlerTag && + currentGestureRef.current.type === type; + + if (wasSameHandlerRecreated) { + return; + } + + if (currentGestureRef.current.handlerTag === handlerTag) { + currentGestureRef.current = { type: '', handlerTag: -1 }; + } - NativeProxy.dropGestureHandler(handlerTag); - scheduleFlushOperations(); + NativeProxy.dropGestureHandler(handlerTag); + scheduleFlushOperations(); + }); }; }, [type, handlerTag]);