Skip to content

Delay dropping native gesture handler on StrictMode#4174

Open
coado wants to merge 3 commits into
mainfrom
@coado/strict-mode-issue
Open

Delay dropping native gesture handler on StrictMode#4174
coado wants to merge 3 commits into
mainfrom
@coado/strict-mode-issue

Conversation

@coado
Copy link
Copy Markdown
Contributor

@coado coado commented May 13, 2026

Description

In the strict mode React does a simulated unmount/remount of effects (without fully unmounting/remounting the component instance). This causes dropping the native gesture handler and rerunning the useEffect with the same handlerTag that was already dropped.

The solution is to delay dropping the gesture handler with ghQueueMicrotask and increment a helper token on each effect setup and cleanup. During StrictMode’s simulated unmount/remount, the replayed setup increments the token before the delayed drop runs. The delayed callback then sees that its captured token no longer matches the current token and skips the drop.

On a real unmount, no setup runs afterward for that hook instance, so the token remains unchanged and the delayed drop proceeds.

Fixes #4172

Test plan

I've used the repro from the #4172

Copilot AI review requested due to automatic review settings May 13, 2026 14:01
Copy link
Copy Markdown
Contributor

Copilot AI left a comment

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Pull request overview

This PR updates the v3 useGesture hook to avoid dropping native gesture handlers during React StrictMode’s simulated effect unmount/remount cycle.

Changes:

  • Adds a cleanup token ref to distinguish stale delayed drops from current hook state.
  • Delays dropGestureHandler with ghQueueMicrotask so StrictMode replay can cancel the drop.
  • Keeps native handler cleanup behavior inside the existing effect lifecycle.

💡 Add Copilot custom instructions for smarter, more guided reviews. Learn how to get started.

Comment thread packages/react-native-gesture-handler/src/v3/hooks/useGesture.ts
Comment thread packages/react-native-gesture-handler/src/v3/hooks/useGesture.ts
@coado coado marked this pull request as draft May 13, 2026 14:29
@coado
Copy link
Copy Markdown
Contributor Author

coado commented May 13, 2026

@copilot review please

@coado coado marked this pull request as ready for review May 13, 2026 15:48
Copy link
Copy Markdown
Contributor

Copilot AI commented May 13, 2026

@copilot review please

Re-reviewed current branch at 6add5a8: focused API v3 tests pass (api_v3.test.tsx), and the StrictMode/unmount regression coverage plus same-handler/type replay guard are in place as expected.

Warning

Firewall rules blocked me from connecting to one or more addresses (expand for details)

I tried to connect to the following addresses, but was blocked by firewall rules:

  • cdp.expo.dev
    • Triggering command: /opt/hostedtoolcache/node/24.14.1/x64/bin/node /opt/hostedtoolcache/node/24.14.1/x64/bin/node /home/REDACTED/work/react-native-gesture-handler/react-native-gesture-handler/apps/expo-example/node_modules/@expo/cli/build/src/utils/telemetry/clients/flushFetchDetached.js /tmp/05d0ab6fc68b6e884e912b7c1e4d3add/expo-telemetry.json (dns block)

If you need me to access, download, or install something from one of these locations, you can either:

Copy link
Copy Markdown
Contributor

Copilot AI left a comment

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Pull request overview

Copilot reviewed 2 out of 2 changed files in this pull request and generated 2 comments.

Comment on lines +99 to +101
// React StrictMode replays effects without recreating the hook instance.
// Delay dropping the native handler so the replayed mount can cancel it.
dropGestureHandlerTokenRef.current += 1;
});
}

afterEach(async () => {
Copy link
Copy Markdown
Member

@j-piasecki j-piasecki left a comment

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

I don't think this is the way to go - it's a workaround for strict mode, while it doesn't solve the underlying issue. When the hook is rendered using concurrent features it will either try to create the same gesture twice or end up with no gesture on the native side.

I think the correct approach should be moving management of the native gesture fully into the react lifecycle, so it's always created/deteled in an effect. One issue is that creation is synchronous, while deletion is not (and I don't think it can be - ui operations should run on main queue, and at this point dropping gesture likely means detaching it from a view), so the tag most likely needs to be advanced. Something along the lines of

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 6548575b2..092880dc0 100644
--- a/packages/react-native-gesture-handler/src/v3/hooks/useGesture.ts
+++ b/packages/react-native-gesture-handler/src/v3/hooks/useGesture.ts
@@ -1,4 +1,4 @@
-import { useEffect, useMemo, useRef } from 'react';
+import { useEffect, useLayoutEffect, useMemo, useRef, useState } from 'react';
 
 import { getNextHandlerTag } from '../../handlers/getNextHandlerTag';
 import {
@@ -29,7 +29,8 @@ export function useGesture<
   type: SingleGestureName,
   config: BaseGestureConfig<TConfig, THandlerData, TExtendedHandlerData>
 ): SingleGesture<TConfig, THandlerData, TExtendedHandlerData> {
-  const handlerTag = useMemo(() => getNextHandlerTag(), []);
+  const [handlerTag, setHandlerTag] = useState(getNextHandlerTag);
+  const createdHandlerTag = useRef<number | undefined>(handlerTag);
   const disableReanimated = useMemo(() => config.disableReanimated, []);
 
   if (config.disableReanimated !== disableReanimated) {
@@ -61,15 +62,6 @@ export function useGesture<
     [handlerTag, config.simultaneousWith, config.requireToFail, config.block]
   );
 
-  const currentGestureRef = useRef({ type: '', handlerTag: -1 });
-  if (
-    currentGestureRef.current.handlerTag !== handlerTag ||
-    currentGestureRef.current.type !== (type as string)
-  ) {
-    currentGestureRef.current = { type, handlerTag };
-    NativeProxy.createGestureHandler(type, handlerTag, {});
-  }
-
   const gesture = useMemo(
     () => ({
       handlerTag,
@@ -93,14 +85,23 @@ export function useGesture<
     ]
   );
 
-  useEffect(() => {
-    return () => {
-      if (currentGestureRef.current.handlerTag === handlerTag) {
-        currentGestureRef.current = { type: '', handlerTag: -1 };
-      }
+  useLayoutEffect(() => {
+    if (createdHandlerTag.current !== handlerTag) {
+      const nextHandlerTag = getNextHandlerTag();
+      setHandlerTag(nextHandlerTag);
+      createdHandlerTag.current = nextHandlerTag;
+      return;
+    }
 
+    NativeProxy.createGestureHandler(type, handlerTag, {});
+    console.log('Created gesture handler with tag', handlerTag, 'and type', type);
+
+    return () => {
+      console.log('Dropping gesture handler with tag', handlerTag, 'and type', type);
       NativeProxy.dropGestureHandler(handlerTag);
       scheduleFlushOperations();
+
+      createdHandlerTag.current = undefined;
     };
   }, [type, handlerTag]);

Which works on iOS, doesn't on web, and I don't have an android build on hand to check.

Can you look more in this direction?

Sign up for free to join this conversation on GitHub. Already have an account? Sign in to comment

Labels

None yet

Projects

None yet

Development

Successfully merging this pull request may close these issues.

[v3] App crashes when using React.StrictMode

5 participants