Delay dropping native gesture handler on StrictMode#4174
Conversation
There was a problem hiding this comment.
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
dropGestureHandlerwithghQueueMicrotaskso 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.
|
@copilot review please |
Re-reviewed current branch at 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:
If you need me to access, download, or install something from one of these locations, you can either:
|
| // 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 () => { |
j-piasecki
left a comment
There was a problem hiding this comment.
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?
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
handlerTagthat was already dropped.The solution is to delay dropping the gesture handler with
ghQueueMicrotaskand 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