[iOS] Detach handlers when the detector view is recycled (#4440)
## Description
`RNGestureHandlerDetector` detaches its handlers and cancels registry
observations only in `willMoveToWindow:` when the new window is `nil`.
`UIKit` sends that callback only when the view's window actually
changes, so a detector that is unmounted while its ancestor is already
detached from the window (e.g. an inactive native-stack screen) never
receives it. The view then enters Fabric's recycle pool still carrying
the recognizers of live handlers and their `hostDetectorView` bindings -
`prepareForRecycle` only reset the bookkeeping sets, and the base
`RCTViewComponentView` implementation doesn't remove gesture
recognizers.
When such a view is reused for a different `GestureDetector`, the stale
handler's events are emitted through the new detector's event emitter.
If the new detector is a plain one, this throws
```
Expected onGestureHandlerReanimatedEvent listener to be a function, instead got a value of 'object' type
```
on every gesture frame (the visible half of #4428, see also #4429 which
addresses the invalid prop itself). If the new detector is a Reanimated
one, the foreign events are silently misrouted instead.
This PR moves the cleanup into `detachAndCleanupHandlers` and calls it
from both `willMoveToWindow:` and `prepareForRecycle`. The method is
idempotent and skips views that were never configured (`moduleId ==
-1`), so the common path where `willMoveToWindow:` already ran is a
no-op. This also makes iOS consistent with Android, where
`onDropViewInstance` already calls `detachAllHandlers()` on unmount
regardless of window state, which is why Android is not affected.
## Test plan
- Ran the repro above on the iPhone 17 Pro simulator (iOS 26.4,
expo-example, Fabric): before the change the error is thrown on every
gesture frame, after the change the flow is clean in repeated runs.
- Checked the regular paths on the same build: Fling and Tap examples,
screen push/pop (the `willMoveToWindow:` detach/reattach cycle),
Pressable rows and ScrollView on the examples list.
<details>
<summary>Tested on the following code:</summary>
```tsx
import React, { useEffect, useRef, useState } from 'react';
import { Button, StyleSheet, Text, View } from 'react-native';
import {
NavigationContainer,
NavigationIndependentTree,
useNavigation,
} from '@react-navigation/native';
import { createNativeStackNavigator } from '@react-navigation/native-stack';
import {
GestureDetector,
usePanGesture,
useTapGesture,
} from 'react-native-gesture-handler';
// Repro for the visible half of #4428: a handler emitting through a detector
// that is not its own. A worklet pan detector is mounted and unmounted while
// its screen is detached from the window (inactive native-stack screen), so
// RNGestureHandlerDetector's willMoveToWindow:nil cleanup never runs. The
// detector view goes to Fabric's recycle pool still carrying the pan
// recognizer and hostDetectorView binding. A plain-gesture detector mounted
// afterwards recycles that view; panning on it should emit
// onGestureHandlerReanimatedEvent into the plain HostGestureDetector, whose
// prop is Reanimated's handler object -> "Expected onGestureHandlerReanimatedEvent
// listener to be a function" on every frame.
const Stack = createNativeStackNavigator();
function ReproScreen() {
const navigation = useNavigation<any>();
const [phase, setPhase] = useState('idle');
const [showPan, setShowPan] = useState(false);
const [showTarget, setShowTarget] = useState(false);
const timers = useRef<ReturnType<typeof setTimeout>[]>([]);
// Worklet callback -> shouldUseReanimatedDetector=true,
// dispatchesReanimatedEvents=true on the native handler.
const pan = usePanGesture({
onUpdate: (e) => {
'worklet';
console.log('pan onUpdate (worklet)', e.translationX);
},
});
// No callbacks: any callback here gets auto-workletized by babel (even when
// passed by reference), which would flip this to ReanimatedNativeDetector.
// Callback-less tap keeps the plain HostGestureDetector with the object prop,
// same as the issue's useNativeGesture() case.
const tap = useTapGesture({});
useEffect(() => {
return () => timers.current.forEach(clearTimeout);
}, []);
const at = (ms: number, fn: () => void) => {
timers.current.push(setTimeout(fn, ms));
};
const start = () => {
setShowPan(false);
setShowTarget(false);
setPhase('pushed cover screen');
navigation.navigate('Cover');
at(800, () => {
setPhase('pan detector mounted (detached)');
setShowPan(true);
});
at(1600, () => {
setPhase('pan detector unmounted (detached) -> dirty pool');
setShowPan(false);
});
at(2400, () => {
setPhase('popped back');
navigation.goBack();
});
at(3200, () => {
setPhase('target mounted - PAN ON THE BLUE BOX');
setShowTarget(true);
});
};
return (
<View style={styles.container}>
<Button title="Start repro" onPress={start} />
<Text style={styles.status}>{phase}</Text>
{showPan && (
<GestureDetector gesture={pan}>
<View style={[styles.box, styles.red]} />
</GestureDetector>
)}
{showTarget && (
<GestureDetector gesture={tap}>
<View style={[styles.box, styles.blue]} />
</GestureDetector>
)}
</View>
);
}
function CoverScreen() {
return (
<View style={styles.container}>
<Text style={styles.status}>
Cover screen - the repro screen is now detached from the window.
</Text>
</View>
);
}
export default function EmptyExample() {
return (
<NavigationIndependentTree>
<NavigationContainer>
<Stack.Navigator>
<Stack.Screen name="Repro" component={ReproScreen} />
<Stack.Screen name="Cover" component={CoverScreen} />
</Stack.Navigator>
</NavigationContainer>
</NavigationIndependentTree>
);
}
const styles = StyleSheet.create({
container: {
flex: 1,
alignItems: 'center',
gap: 20,
paddingTop: 40,
},
status: {
fontSize: 16,
paddingHorizontal: 20,
textAlign: 'center',
},
box: {
width: 220,
height: 220,
borderRadius: 12,
},
red: {
backgroundColor: 'crimson',
},
blue: {
backgroundColor: 'steelblue',
},
});
```
</details> M
Michał Bert committed
00acc10acb7828f96959432147e9ed1a1a781ebe
Parent: d43e02f
Committed by GitHub <noreply@github.com>
on 8/19/2026, 10:51:47 AM