SIGN IN SIGN UP

[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