From 39a8dfffd2cec4c21eb8877791f44650bfb3e123 Mon Sep 17 00:00:00 2001 From: Dawid Malecki Date: Wed, 13 May 2026 15:45:04 +0200 Subject: [PATCH 1/3] delay dropping native gesture handler --- .../src/v3/hooks/useGesture.ts | 25 +++++++++++++++---- 1 file changed, 20 insertions(+), 5 deletions(-) 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..d428e93816 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,26 @@ export function useGesture< ); useEffect(() => { + // React StrictMode replays effects without re-rendering, while the native + // detector can keep the same handler tag attached during that replay. + // 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(() => { + if (dropGestureHandlerTokenRef.current !== dropGestureHandlerToken) { + return; + } + + if (currentGestureRef.current.handlerTag === handlerTag) { + currentGestureRef.current = { type: '', handlerTag: -1 }; + } - NativeProxy.dropGestureHandler(handlerTag); - scheduleFlushOperations(); + NativeProxy.dropGestureHandler(handlerTag); + scheduleFlushOperations(); + }); }; }, [type, handlerTag]); From 27b359556bd032800829e6042775580cc254e53b Mon Sep 17 00:00:00 2001 From: Dawid Malecki Date: Wed, 13 May 2026 16:23:44 +0200 Subject: [PATCH 2/3] recreate only when the same handler was recreated --- .../src/v3/hooks/useGesture.ts | 7 ++++++- 1 file changed, 6 insertions(+), 1 deletion(-) 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 d428e93816..210b4ba003 100644 --- a/packages/react-native-gesture-handler/src/v3/hooks/useGesture.ts +++ b/packages/react-native-gesture-handler/src/v3/hooks/useGesture.ts @@ -105,7 +105,12 @@ export function useGesture< const dropGestureHandlerToken = ++dropGestureHandlerTokenRef.current; ghQueueMicrotask(() => { - if (dropGestureHandlerTokenRef.current !== dropGestureHandlerToken) { + const wasSameHandlerRecreated = + dropGestureHandlerTokenRef.current !== dropGestureHandlerToken && + currentGestureRef.current.handlerTag === handlerTag && + currentGestureRef.current.type === type; + + if (wasSameHandlerRecreated) { return; } From 6add5a8a1ae747e45a160895f0c0ffb7372e7521 Mon Sep 17 00:00:00 2001 From: Dawid Malecki Date: Wed, 13 May 2026 17:44:46 +0200 Subject: [PATCH 3/3] add tests --- .../src/__tests__/api_v3.test.tsx | 43 ++++++++++++++++++- .../src/v3/hooks/useGesture.ts | 3 +- 2 files changed, 43 insertions(+), 3 deletions(-) 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 210b4ba003..9dcf387c6f 100644 --- a/packages/react-native-gesture-handler/src/v3/hooks/useGesture.ts +++ b/packages/react-native-gesture-handler/src/v3/hooks/useGesture.ts @@ -96,8 +96,7 @@ export function useGesture< ); useEffect(() => { - // React StrictMode replays effects without re-rendering, while the native - // detector can keep the same handler tag attached during that replay. + // React StrictMode replays effects without recreating the hook instance. // Delay dropping the native handler so the replayed mount can cancel it. dropGestureHandlerTokenRef.current += 1;