Skip to content

Commit 07e578e

Browse files
committed
[fix] ResponderSystem negotiation logic
This fixes a bug in the negotiation logic that caused a cycle of terminate->grant events to be sent to the current responder during a pointer move. The root cause was using an incorrect event path in the calculation of the lowest common ancestor's index. The fix is to ensure that the event path stored with the current responder is pruned to begin with the node that is the current responder (rather than any child responders it may have contained).
1 parent d2e6c29 commit 07e578e

File tree

6 files changed

+335
-21
lines changed

6 files changed

+335
-21
lines changed

packages/docs/src/components/Pressable/Pressable.stories.mdx

Lines changed: 6 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -64,3 +64,9 @@ Called when the pointer is released, but not if cancelled (e.g. by a scroll that
6464
<Stories.feedbackEvents />
6565
</Story>
6666
</Preview>
67+
68+
<Preview withSource='none'>
69+
<Story name="panAndPress">
70+
<Stories.panAndPress />
71+
</Story>
72+
</Preview>

packages/docs/src/components/Pressable/examples/FeedbackEvents.js

Lines changed: 1 addition & 2 deletions
Original file line numberDiff line numberDiff line change
@@ -56,13 +56,12 @@ export default function FeedbackEvents() {
5656
}}
5757
>
5858
<Pressable
59-
accessibilityRole="none"
59+
accessibilityRole="button"
6060
onLongPress={handlePress('longPress - inner')}
6161
onPress={handlePress('press - inner')}
6262
onPressIn={handlePress('pressIn - inner')}
6363
onPressOut={handlePress('pressOut - inner')}
6464
style={({ hovered, pressed, focused }) => {
65-
console.log(focused);
6665
let backgroundColor = 'white';
6766
if (hovered) {
6867
backgroundColor = 'lightgray';
Lines changed: 100 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,100 @@
1+
import React, { useRef, useState } from 'react';
2+
import { Animated, View, StyleSheet, PanResponder, Text, TouchableOpacity } from 'react-native';
3+
4+
const App = () => {
5+
const pan = useRef(new Animated.ValueXY()).current;
6+
const [x, setX] = useState(0);
7+
8+
const panResponder = useRef(null);
9+
if (panResponder.current == null) {
10+
panResponder.current = PanResponder.create({
11+
onMoveShouldSetPanResponder: () => true,
12+
onPanResponderGrant: e => {
13+
console.log('pan grant');
14+
pan.setOffset({
15+
x: pan.x._value,
16+
y: pan.y._value
17+
});
18+
},
19+
onPanResponderMove: Animated.event([null, { dx: pan.x, dy: pan.y }]),
20+
onPanResponderRelease: () => {
21+
console.log('pan release');
22+
pan.flattenOffset();
23+
},
24+
onPanResponderTerminate() {
25+
console.log('pan terminate');
26+
pan.flattenOffset();
27+
}
28+
});
29+
}
30+
31+
return (
32+
<View style={styles.container}>
33+
<Text style={styles.titleText}>Pressed: {x}</Text>
34+
<Animated.View
35+
style={{
36+
transform: [{ translateX: pan.x }, { translateY: pan.y }]
37+
}}
38+
{...panResponder.current.panHandlers}
39+
>
40+
<View style={styles.box}>
41+
<TouchableOpacity onPress={() => setX(x + 1)} style={styles.outerTouchable}>
42+
<TouchableOpacity
43+
onPress={() => {
44+
console.log('press inner');
45+
}}
46+
style={styles.innerTouchable}
47+
/>
48+
<TouchableOpacity disabled style={styles.innerTouchable} />
49+
<TouchableOpacity
50+
accessibilityRole="button"
51+
disabled
52+
style={[styles.innerTouchable, styles.disabledButton]}
53+
/>
54+
</TouchableOpacity>
55+
</View>
56+
</Animated.View>
57+
</View>
58+
);
59+
};
60+
61+
const styles = StyleSheet.create({
62+
container: {
63+
flex: 1,
64+
alignItems: 'center',
65+
justifyContent: 'center',
66+
userSelect: 'none'
67+
},
68+
titleText: {
69+
fontSize: 14,
70+
lineHeight: 24,
71+
fontWeight: 'bold'
72+
},
73+
box: {
74+
height: 200,
75+
width: 150,
76+
backgroundColor: 'lightblue',
77+
borderRadius: 5
78+
},
79+
outerTouchable: {
80+
height: 150,
81+
width: 100,
82+
margin: 25,
83+
backgroundColor: 'blue',
84+
borderRadius: 5,
85+
justifyContent: 'center'
86+
},
87+
innerTouchable: {
88+
height: 20,
89+
flex: 1,
90+
marginVertical: 10,
91+
marginHorizontal: 20,
92+
backgroundColor: 'green',
93+
borderRadius: 5
94+
},
95+
disabledButton: {
96+
backgroundColor: 'red'
97+
}
98+
});
99+
100+
export default App;
Lines changed: 1 addition & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -1,3 +1,4 @@
11
export { default as delayEvents } from './DelayEvents';
22
export { default as disabled } from './Disabled';
33
export { default as feedbackEvents } from './FeedbackEvents';
4+
export { default as panAndPress } from './PanAndPress';

packages/react-native-web/src/modules/useResponderEvents/ResponderSystem.js

Lines changed: 32 additions & 9 deletions
Original file line numberDiff line numberDiff line change
@@ -374,12 +374,14 @@ function eventListener(domEvent: any) {
374374
// Start
375375
if (isStartEvent) {
376376
if (onResponderStart != null) {
377+
responderEvent.dispatchConfig.registrationName = 'onResponderStart';
377378
onResponderStart(responderEvent);
378379
}
379380
}
380381
// Move
381382
else if (isMoveEvent) {
382383
if (onResponderMove != null) {
384+
responderEvent.dispatchConfig.registrationName = 'onResponderMove';
383385
onResponderMove(responderEvent);
384386
}
385387
} else {
@@ -404,12 +406,14 @@ function eventListener(domEvent: any) {
404406
// End
405407
if (isEndEvent) {
406408
if (onResponderEnd != null) {
409+
responderEvent.dispatchConfig.registrationName = 'onResponderEnd';
407410
onResponderEnd(responderEvent);
408411
}
409412
}
410413
// Release
411414
if (isReleaseEvent) {
412415
if (onResponderRelease != null) {
416+
responderEvent.dispatchConfig.registrationName = 'onResponderRelease';
413417
onResponderRelease(responderEvent);
414418
}
415419
changeCurrentResponder(emptyResponder);
@@ -424,18 +428,20 @@ function eventListener(domEvent: any) {
424428
eventType === 'scroll' ||
425429
eventType === 'selectionchange'
426430
) {
427-
if (
428-
wasNegotiated ||
429-
// Only call this function is it wasn't already called during negotiation.
430-
(onResponderTerminationRequest != null &&
431-
onResponderTerminationRequest(responderEvent) === false)
432-
) {
431+
// Only call this function is it wasn't already called during negotiation.
432+
if (wasNegotiated) {
433433
shouldTerminate = false;
434+
} else if (onResponderTerminationRequest != null) {
435+
responderEvent.dispatchConfig.registrationName = 'onResponderTerminationRequest';
436+
if (onResponderTerminationRequest(responderEvent) === false) {
437+
shouldTerminate = false;
438+
}
434439
}
435440
}
436441

437442
if (shouldTerminate) {
438443
if (onResponderTerminate != null) {
444+
responderEvent.dispatchConfig.registrationName = 'onResponderTerminate';
439445
onResponderTerminate(responderEvent);
440446
}
441447
changeCurrentResponder(emptyResponder);
@@ -466,8 +472,11 @@ function findWantsResponder(eventPaths, domEvent, responderEvent) {
466472
const config = getResponderConfig(id);
467473
const shouldSetCallback = config[callbackName];
468474
if (shouldSetCallback != null) {
475+
responderEvent.currentTarget = node;
469476
if (shouldSetCallback(responderEvent) === true) {
470-
return { id, node, idPath };
477+
// Start the path from the potential responder
478+
const prunedIdPath = idPath.slice(idPath.indexOf(id));
479+
return { id, node, idPath: prunedIdPath };
471480
}
472481
}
473482
};
@@ -521,6 +530,7 @@ function attemptTransfer(responderEvent: ResponderEvent, wantsResponder: ActiveR
521530
responderEvent.bubbles = false;
522531
responderEvent.cancelable = false;
523532
responderEvent.currentTarget = node;
533+
524534
// Set responder
525535
if (currentId == null) {
526536
if (onResponderGrant != null) {
@@ -533,22 +543,35 @@ function attemptTransfer(responderEvent: ResponderEvent, wantsResponder: ActiveR
533543
// Negotiate with current responder
534544
else {
535545
const { onResponderTerminate, onResponderTerminationRequest } = getResponderConfig(currentId);
536-
const allowTransfer =
537-
onResponderTerminationRequest != null && onResponderTerminationRequest(responderEvent);
546+
547+
let allowTransfer = true;
548+
if (onResponderTerminationRequest != null) {
549+
responderEvent.currentTarget = currentNode;
550+
responderEvent.dispatchConfig.registrationName = 'onResponderTerminationRequest';
551+
if (onResponderTerminationRequest(responderEvent) === false) {
552+
allowTransfer = false;
553+
}
554+
}
555+
538556
if (allowTransfer) {
539557
// Terminate existing responder
540558
if (onResponderTerminate != null) {
541559
responderEvent.currentTarget = currentNode;
560+
responderEvent.dispatchConfig.registrationName = 'onResponderTerminate';
542561
onResponderTerminate(responderEvent);
543562
}
544563
// Grant next responder
545564
if (onResponderGrant != null) {
565+
responderEvent.currentTarget = node;
566+
responderEvent.dispatchConfig.registrationName = 'onResponderGrant';
546567
onResponderGrant(responderEvent);
547568
}
548569
changeCurrentResponder(wantsResponder);
549570
} else {
550571
// Reject responder request
551572
if (onResponderReject != null) {
573+
responderEvent.currentTarget = node;
574+
responderEvent.dispatchConfig.registrationName = 'onResponderReject';
552575
onResponderReject(responderEvent);
553576
}
554577
}

0 commit comments

Comments
 (0)