Skip to content

Commit 1e4378b

Browse files
authored
[iOS] Fix gesture callback guarantees when view is detached mid-gesture (#4321)
## Description On iOS, changing `zIndex` during an active gesture makes Fabric remount the view subtree, which cancels the in-flight touch. On iOS 26 the recognizer is reset to `Possible` and the pointer tracker processes the cancellation before the recognizer's cancel action fires — `reset` cleared `_lastState` too early, so the `CANCELLED` event reached JS as `UNDETERMINED` → `CANCELLED` instead of `ACTIVE` → `CANCELLED`. As a result `onDeactivate` never fired, and a stray `BEGAN` event was emitted afterwards from the reset path, breaking the documented `onBegin`/`onFinalize` and `onActivate`/`onDeactivate` guarantees. Fixes #4320 ## Test plan <details> <summary>Tested on existing examples and repro from issue: </summary> ```tsx import { useEffect, useState } from 'react'; import { Pressable, Text, View } from 'react-native'; import { GestureDetector, GestureHandlerRootView, usePanGesture, } from 'react-native-gesture-handler'; import Animated, { cancelAnimation, SharedValue, useAnimatedStyle, useFrameCallback, useSharedValue, withSpring, } from 'react-native-reanimated'; import { scheduleOnRN } from 'react-native-worklets'; type BoxId = 'A' | 'B'; type EventCounts = Record<BoxId, Record<string, number>>; const initialCounts: EventCounts = { A: {}, B: {}, }; function countEvent( setCounts: (updater: (prev: EventCounts) => EventCounts) => void, boxId: BoxId, eventName: string ) { setCounts((prev) => ({ ...prev, [boxId]: { ...prev[boxId], [eventName]: (prev[boxId][eventName] ?? 0) + 1, }, })); } function ReproBox({ boxId, color, top, left, frontBox, record, }: { boxId: BoxId; color: string; top: number; left: number; frontBox: SharedValue<0 | 1>; record: (boxId: BoxId, eventName: string) => void; }) { const x = useSharedValue(0); const y = useSharedValue(0); const startX = useSharedValue(0); const startY = useSharedValue(0); const scale = useSharedValue(1); const activeTouches = useSharedValue(0); const mark = (eventName: string) => { 'worklet'; scheduleOnRN(record, boxId, eventName); }; const resetVisualState = () => { 'worklet'; activeTouches.set(0); cancelAnimation(scale); scale.set(withSpring(1)); }; const finish = (eventName: string) => { 'worklet'; mark(eventName); resetVisualState(); }; const gesture = usePanGesture({ minDistance: 0, minVelocity: 0, onBegin: (event) => { mark('onBegin'); activeTouches.set(event.numberOfPointers); startX.set(x.get()); startY.set(y.get()); cancelAnimation(scale); scale.set(withSpring(1.15)); }, onActivate: () => { mark('onActivate'); }, onUpdate: (event) => { x.set(startX.get() + event.translationX); y.set(startY.get() + event.translationY); }, onTouchesDown: (event) => { mark('onTouchesDown'); activeTouches.set(event.allTouches.length); }, onTouchesMove: (event) => { activeTouches.set(event.allTouches.length); }, onTouchesUp: (event) => { mark('onTouchesUp'); activeTouches.set(event.allTouches.length); if (event.allTouches.length === 0) { resetVisualState(); } }, onTouchesCancel: () => { finish('onTouchesCancel'); }, onDeactivate: () => { finish('onDeactivate'); }, onFinalize: () => { finish('onFinalize'); }, }); const animatedStyle = useAnimatedStyle(() => { const isDragging = activeTouches.get() > 0; const isFront = frontBox.get() === (boxId === 'A' ? 0 : 1); const zIndex = isFront ? 30 : 10; return { zIndex, elevation: zIndex, transform: [ { translateX: x.get() }, { translateY: y.get() }, { scale: isDragging ? scale.get() : 1 }, ], }; }); return ( <GestureDetector gesture={gesture}> <Animated.View style={[ { position: 'absolute', top, left, width: 128, height: 128, borderRadius: 10, backgroundColor: color, alignItems: 'center', justifyContent: 'center', borderWidth: 3, borderColor: '#111827', shadowColor: '#111827', shadowOffset: { width: 0, height: 6 }, shadowOpacity: 0.18, shadowRadius: 10, }, animatedStyle, ]}> <Text style={{ color: '#ffffff', fontSize: 34, fontWeight: '800' }}> {boxId} </Text> <Text style={{ color: '#ffffff', fontSize: 13, fontWeight: '700' }}> z swaps on UI </Text> </Animated.View> </GestureDetector> ); } function EventColumn({ boxId, counts, }: { boxId: BoxId; counts: Record<string, number>; }) { const touchEndCount = (counts.onTouchesUp ?? 0) + (counts.onTouchesCancel ?? 0); return ( <View style={{ flex: 1, gap: 5 }}> <Text style={{ color: '#111827', fontSize: 17, fontWeight: '800' }}> Box {boxId} </Text> <Text style={{ color: '#374151', fontSize: 12 }}> Begin / Finalize: {counts.onBegin ?? 0} / {counts.onFinalize ?? 0} </Text> <Text style={{ color: '#374151', fontSize: 12 }}> Activate / Deactivate: {counts.onActivate ?? 0} /{' '} {counts.onDeactivate ?? 0} </Text> <Text style={{ color: '#374151', fontSize: 12 }}> TouchesDown / Touch end: {counts.onTouchesDown ?? 0} / {touchEndCount} </Text> <Text style={{ color: '#374151', fontSize: 12 }}> TouchesUp: {counts.onTouchesUp ?? 0} </Text> <Text style={{ color: '#374151', fontSize: 12 }}> TouchesCancel: {counts.onTouchesCancel ?? 0} </Text> </View> ); } function ControlButton({ label, color, onPress, }: { label: string; color: string; onPress: () => void; }) { return ( <Pressable onPress={onPress} style={({ pressed }) => ({ minHeight: 42, borderRadius: 8, paddingHorizontal: 14, paddingVertical: 10, backgroundColor: color, opacity: pressed ? 0.72 : 1, alignItems: 'center', justifyContent: 'center', })}> <Text style={{ color: '#ffffff', fontSize: 14, fontWeight: '800' }}> {label} </Text> </Pressable> ); } export default function App() { const [autoFlip, setAutoFlip] = useState(true); const [counts, setCounts] = useState<EventCounts>(initialCounts); const frontBox = useSharedValue<0 | 1>(0); const flipElapsedMs = useSharedValue(0); const frameCallback = useFrameCallback((frameInfo) => { const deltaMs = frameInfo.timeSincePreviousFrame ?? 0; flipElapsedMs.set(flipElapsedMs.get() + deltaMs); if (flipElapsedMs.get() < 900) { return; } flipElapsedMs.set(flipElapsedMs.get() % 900); frontBox.set(frontBox.get() === 0 ? 1 : 0); }, true); useEffect(() => { frameCallback.setActive(autoFlip); }, [autoFlip, frameCallback]); const record = (boxId: BoxId, eventName: string) => { countEvent(setCounts, boxId, eventName); }; const resetCounts = () => { setCounts(initialCounts); }; const flipNow = () => { flipElapsedMs.set(0); frontBox.set(frontBox.get() === 0 ? 1 : 0); }; return ( <GestureHandlerRootView style={{ flex: 1 }}> <View style={{ flex: 1, backgroundColor: '#f6f1e8', paddingTop: 58 }}> <View style={{ paddingHorizontal: 18, gap: 12 }}> <Text style={{ color: '#111827', fontSize: 25, fontWeight: '900' }}> RNGH zIndex Swap Repro </Text> <Text style={{ color: '#374151', fontSize: 14, lineHeight: 20 }}> Drag one box, then start dragging the other while the timer flips which native view has the higher zIndex/elevation. Watch whether the first box receives matching gesture lifecycle callbacks and matching touch lifecycle callbacks. </Text> <View style={{ flexDirection: 'row', flexWrap: 'wrap', gap: 10 }}> <ControlButton label={`Auto flip ${autoFlip ? 'on' : 'off'}`} color={autoFlip ? '#2f6f4e' : '#8a6a1f'} onPress={() => setAutoFlip((prev) => !prev)} /> <ControlButton label="Flip now" color="#365a96" onPress={flipNow} /> <ControlButton label="Reset counts" color="#5f6368" onPress={resetCounts} /> </View> <Text style={{ color: '#111827', fontSize: 15, fontWeight: '800' }}> zIndex/elevation swaps on the UI thread every 900ms. </Text> </View> <View style={{ flex: 1, margin: 18, gap: 16 }}> <View style={{ height: 320, borderRadius: 12, borderWidth: 1, borderColor: '#c5bcae', backgroundColor: '#fffaf2', overflow: 'visible', }}> <ReproBox boxId="A" color="#c44949" top={78} left={66} frontBox={frontBox} record={record} /> <ReproBox boxId="B" color="#3569b7" top={126} left={142} frontBox={frontBox} record={record} /> </View> <View style={{ flexDirection: 'row', gap: 16, padding: 14, borderRadius: 12, borderWidth: 1, borderColor: '#c5bcae', backgroundColor: '#fffaf2', }}> <EventColumn boxId="A" counts={counts.A} /> <EventColumn boxId="B" counts={counts.B} /> </View> </View> </View> </GestureHandlerRootView> ); } ``` </details>
1 parent 9c61359 commit 1e4378b

1 file changed

Lines changed: 56 additions & 13 deletions

File tree

packages/react-native-gesture-handler/apple/RNGestureHandler.mm

Lines changed: 56 additions & 13 deletions
Original file line numberDiff line numberDiff line change
@@ -312,6 +312,24 @@ - (void)bindToView:(RNGHUIView *)view
312312

313313
- (void)unbindFromView
314314
{
315+
// If the gesture is still in flight - e.g. the view is being unmounted mid-gesture - deliver
316+
// the final event now, while the recognizer is still attached and the target view is known.
317+
// Otherwise the onBegin/onFinalize and onActivate/onDeactivate guarantees would be broken
318+
// and `_lastState` would never be cleared by `reset`.
319+
if (self.recognizer.view != nil &&
320+
(_lastState == RNGestureHandlerStateBegan || _lastState == RNGestureHandlerStateActive)) {
321+
if ([self eventTagForRecognizer:self.recognizer] != nil) {
322+
[self handleGesture:self.recognizer
323+
inState:_lastState == RNGestureHandlerStateActive ? RNGestureHandlerStateCancelled
324+
: RNGestureHandlerStateFailed];
325+
} else {
326+
// The event has no tag to be dispatched with, so it cannot be delivered on any path - reset
327+
// the bookkeeping so the handler doesn't stay in-flight forever.
328+
_lastState = RNGestureHandlerStateUndetermined;
329+
_state = RNGestureHandlerStateBegan;
330+
}
331+
}
332+
315333
[self.recognizer.view removeGestureRecognizer:self.recognizer];
316334
self.recognizer.delegate = nil;
317335

@@ -401,9 +419,21 @@ - (void)handleGesture:(UIGestureRecognizer *)recognizer
401419
//
402420
// While this solution is not great, we decided to check whether sending events was triggered from `reset` method.
403421
// This way we can detect double Began mapping by checking previous sent state and current state of recognizer.
404-
if (fromReset && _lastState == RNGestureHandlerStateBegan &&
405-
self.recognizer.state == UIGestureRecognizerStatePossible) {
406-
_state = RNGestureHandlerStateFailed;
422+
//
423+
// The same applies to gestures interrupted mid-flight, e.g. when the view is unmounted during an active
424+
// gesture the recognizer may be reset without its cancel action ever firing.
425+
// If the last sent state is not final, synthesize the missing final event so that the
426+
// `onBegin`/`onFinalize` and `onActivate`/`onDeactivate` guarantees hold.
427+
if (fromReset && self.recognizer.state == UIGestureRecognizerStatePossible) {
428+
if (_lastState == RNGestureHandlerStateBegan) {
429+
_state = RNGestureHandlerStateFailed;
430+
} else if (_lastState == RNGestureHandlerStateActive) {
431+
_state = RNGestureHandlerStateCancelled;
432+
} else {
433+
// The final event was already delivered; mapping Possible to Began here would emit a stray
434+
// BEGAN event after the gesture has finished.
435+
return;
436+
}
407437
}
408438

409439
[self handleGesture:recognizer inState:_state fromManualStateChange:fromManualStateChange];
@@ -414,6 +444,21 @@ - (void)handleGesture:(UIGestureRecognizer *)recognizer inState:(RNGestureHandle
414444
[self handleGesture:recognizer inState:state fromManualStateChange:NO];
415445
}
416446

447+
- (nullable NSNumber *)eventTagForRecognizer:(UIGestureRecognizer *)recognizer
448+
{
449+
NSNumber *tag = [self chooseViewForInteraction:recognizer].reactTag;
450+
451+
if (tag == nil && _actionType == RNGestureHandlerActionTypeNativeDetector) {
452+
tag = @(recognizer.view.tag);
453+
}
454+
455+
if (_virtualViewTag != nil && _actionType == RNGestureHandlerActionTypeVirtualDetector) {
456+
tag = _virtualViewTag;
457+
}
458+
459+
return tag;
460+
}
461+
417462
- (void)handleGesture:(UIGestureRecognizer *)recognizer
418463
inState:(RNGestureHandlerState)state
419464
fromManualStateChange:(BOOL)fromManualStateChange
@@ -426,15 +471,7 @@ - (void)handleGesture:(UIGestureRecognizer *)recognizer
426471
return;
427472
}
428473

429-
NSNumber *tag = [self chooseViewForInteraction:recognizer].reactTag;
430-
431-
if (tag == nil && _actionType == RNGestureHandlerActionTypeNativeDetector) {
432-
tag = @(recognizer.view.tag);
433-
}
434-
435-
if (_virtualViewTag != nil && _actionType == RNGestureHandlerActionTypeVirtualDetector) {
436-
tag = _virtualViewTag;
437-
}
474+
NSNumber *tag = [self eventTagForRecognizer:recognizer];
438475

439476
react_native_assert(tag != nil && "Tag should be defined when dispatching an event");
440477

@@ -786,7 +823,13 @@ - (void)reset
786823
// might be called after some pointers are down, and after state manipulation by the user.
787824
// Pointer tracker calls this method when it resets, and in that case it no longer tracks
788825
// any pointers, thus entering this if
789-
if (!_needsPointerData || _pointerTracker.trackedPointersCount == 0) {
826+
//
827+
// Also do not clear _lastState while the gesture is in flight (BEGAN/ACTIVE) - the final
828+
// state-change event hasn't been dispatched yet. When the view is removed mid-gesture,
829+
// the pointer tracker resets before the recognizer's cancel action fires; clearing _lastState
830+
// here would corrupt the prevState of the outgoing CANCELLED event and break the onActivate/onDeactivate guarantee.
831+
if ((!_needsPointerData || _pointerTracker.trackedPointersCount == 0) && _lastState != RNGestureHandlerStateBegan &&
832+
_lastState != RNGestureHandlerStateActive) {
790833
_lastState = RNGestureHandlerStateUndetermined;
791834
_state = RNGestureHandlerStateBegan;
792835
}

0 commit comments

Comments
 (0)