Commit 1e4378b
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
Lines changed: 56 additions & 13 deletions
| Original file line number | Diff line number | Diff line change | |
|---|---|---|---|
| |||
312 | 312 | | |
313 | 313 | | |
314 | 314 | | |
| 315 | + | |
| 316 | + | |
| 317 | + | |
| 318 | + | |
| 319 | + | |
| 320 | + | |
| 321 | + | |
| 322 | + | |
| 323 | + | |
| 324 | + | |
| 325 | + | |
| 326 | + | |
| 327 | + | |
| 328 | + | |
| 329 | + | |
| 330 | + | |
| 331 | + | |
| 332 | + | |
315 | 333 | | |
316 | 334 | | |
317 | 335 | | |
| |||
401 | 419 | | |
402 | 420 | | |
403 | 421 | | |
404 | | - | |
405 | | - | |
406 | | - | |
| 422 | + | |
| 423 | + | |
| 424 | + | |
| 425 | + | |
| 426 | + | |
| 427 | + | |
| 428 | + | |
| 429 | + | |
| 430 | + | |
| 431 | + | |
| 432 | + | |
| 433 | + | |
| 434 | + | |
| 435 | + | |
| 436 | + | |
407 | 437 | | |
408 | 438 | | |
409 | 439 | | |
| |||
414 | 444 | | |
415 | 445 | | |
416 | 446 | | |
| 447 | + | |
| 448 | + | |
| 449 | + | |
| 450 | + | |
| 451 | + | |
| 452 | + | |
| 453 | + | |
| 454 | + | |
| 455 | + | |
| 456 | + | |
| 457 | + | |
| 458 | + | |
| 459 | + | |
| 460 | + | |
| 461 | + | |
417 | 462 | | |
418 | 463 | | |
419 | 464 | | |
| |||
426 | 471 | | |
427 | 472 | | |
428 | 473 | | |
429 | | - | |
430 | | - | |
431 | | - | |
432 | | - | |
433 | | - | |
434 | | - | |
435 | | - | |
436 | | - | |
437 | | - | |
| 474 | + | |
438 | 475 | | |
439 | 476 | | |
440 | 477 | | |
| |||
786 | 823 | | |
787 | 824 | | |
788 | 825 | | |
789 | | - | |
| 826 | + | |
| 827 | + | |
| 828 | + | |
| 829 | + | |
| 830 | + | |
| 831 | + | |
| 832 | + | |
790 | 833 | | |
791 | 834 | | |
792 | 835 | | |
| |||
0 commit comments