[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).
This commit is contained in:
Nicolas Gallagher
2020-10-29 15:51:03 -07:00
parent d2e6c29e25
commit 07e578edb8
6 changed files with 335 additions and 21 deletions
@@ -64,3 +64,9 @@ Called when the pointer is released, but not if cancelled (e.g. by a scroll that
<Stories.feedbackEvents /> <Stories.feedbackEvents />
</Story> </Story>
</Preview> </Preview>
<Preview withSource='none'>
<Story name="panAndPress">
<Stories.panAndPress />
</Story>
</Preview>
@@ -56,13 +56,12 @@ export default function FeedbackEvents() {
}} }}
> >
<Pressable <Pressable
accessibilityRole="none" accessibilityRole="button"
onLongPress={handlePress('longPress - inner')} onLongPress={handlePress('longPress - inner')}
onPress={handlePress('press - inner')} onPress={handlePress('press - inner')}
onPressIn={handlePress('pressIn - inner')} onPressIn={handlePress('pressIn - inner')}
onPressOut={handlePress('pressOut - inner')} onPressOut={handlePress('pressOut - inner')}
style={({ hovered, pressed, focused }) => { style={({ hovered, pressed, focused }) => {
console.log(focused);
let backgroundColor = 'white'; let backgroundColor = 'white';
if (hovered) { if (hovered) {
backgroundColor = 'lightgray'; backgroundColor = 'lightgray';
@@ -0,0 +1,100 @@
import React, { useRef, useState } from 'react';
import { Animated, View, StyleSheet, PanResponder, Text, TouchableOpacity } from 'react-native';
const App = () => {
const pan = useRef(new Animated.ValueXY()).current;
const [x, setX] = useState(0);
const panResponder = useRef(null);
if (panResponder.current == null) {
panResponder.current = PanResponder.create({
onMoveShouldSetPanResponder: () => true,
onPanResponderGrant: e => {
console.log('pan grant');
pan.setOffset({
x: pan.x._value,
y: pan.y._value
});
},
onPanResponderMove: Animated.event([null, { dx: pan.x, dy: pan.y }]),
onPanResponderRelease: () => {
console.log('pan release');
pan.flattenOffset();
},
onPanResponderTerminate() {
console.log('pan terminate');
pan.flattenOffset();
}
});
}
return (
<View style={styles.container}>
<Text style={styles.titleText}>Pressed: {x}</Text>
<Animated.View
style={{
transform: [{ translateX: pan.x }, { translateY: pan.y }]
}}
{...panResponder.current.panHandlers}
>
<View style={styles.box}>
<TouchableOpacity onPress={() => setX(x + 1)} style={styles.outerTouchable}>
<TouchableOpacity
onPress={() => {
console.log('press inner');
}}
style={styles.innerTouchable}
/>
<TouchableOpacity disabled style={styles.innerTouchable} />
<TouchableOpacity
accessibilityRole="button"
disabled
style={[styles.innerTouchable, styles.disabledButton]}
/>
</TouchableOpacity>
</View>
</Animated.View>
</View>
);
};
const styles = StyleSheet.create({
container: {
flex: 1,
alignItems: 'center',
justifyContent: 'center',
userSelect: 'none'
},
titleText: {
fontSize: 14,
lineHeight: 24,
fontWeight: 'bold'
},
box: {
height: 200,
width: 150,
backgroundColor: 'lightblue',
borderRadius: 5
},
outerTouchable: {
height: 150,
width: 100,
margin: 25,
backgroundColor: 'blue',
borderRadius: 5,
justifyContent: 'center'
},
innerTouchable: {
height: 20,
flex: 1,
marginVertical: 10,
marginHorizontal: 20,
backgroundColor: 'green',
borderRadius: 5
},
disabledButton: {
backgroundColor: 'red'
}
});
export default App;
@@ -1,3 +1,4 @@
export { default as delayEvents } from './DelayEvents'; export { default as delayEvents } from './DelayEvents';
export { default as disabled } from './Disabled'; export { default as disabled } from './Disabled';
export { default as feedbackEvents } from './FeedbackEvents'; export { default as feedbackEvents } from './FeedbackEvents';
export { default as panAndPress } from './PanAndPress';
@@ -374,12 +374,14 @@ function eventListener(domEvent: any) {
// Start // Start
if (isStartEvent) { if (isStartEvent) {
if (onResponderStart != null) { if (onResponderStart != null) {
responderEvent.dispatchConfig.registrationName = 'onResponderStart';
onResponderStart(responderEvent); onResponderStart(responderEvent);
} }
} }
// Move // Move
else if (isMoveEvent) { else if (isMoveEvent) {
if (onResponderMove != null) { if (onResponderMove != null) {
responderEvent.dispatchConfig.registrationName = 'onResponderMove';
onResponderMove(responderEvent); onResponderMove(responderEvent);
} }
} else { } else {
@@ -404,12 +406,14 @@ function eventListener(domEvent: any) {
// End // End
if (isEndEvent) { if (isEndEvent) {
if (onResponderEnd != null) { if (onResponderEnd != null) {
responderEvent.dispatchConfig.registrationName = 'onResponderEnd';
onResponderEnd(responderEvent); onResponderEnd(responderEvent);
} }
} }
// Release // Release
if (isReleaseEvent) { if (isReleaseEvent) {
if (onResponderRelease != null) { if (onResponderRelease != null) {
responderEvent.dispatchConfig.registrationName = 'onResponderRelease';
onResponderRelease(responderEvent); onResponderRelease(responderEvent);
} }
changeCurrentResponder(emptyResponder); changeCurrentResponder(emptyResponder);
@@ -424,18 +428,20 @@ function eventListener(domEvent: any) {
eventType === 'scroll' || eventType === 'scroll' ||
eventType === 'selectionchange' eventType === 'selectionchange'
) { ) {
if ( // Only call this function is it wasn't already called during negotiation.
wasNegotiated || if (wasNegotiated) {
// Only call this function is it wasn't already called during negotiation.
(onResponderTerminationRequest != null &&
onResponderTerminationRequest(responderEvent) === false)
) {
shouldTerminate = false; shouldTerminate = false;
} else if (onResponderTerminationRequest != null) {
responderEvent.dispatchConfig.registrationName = 'onResponderTerminationRequest';
if (onResponderTerminationRequest(responderEvent) === false) {
shouldTerminate = false;
}
} }
} }
if (shouldTerminate) { if (shouldTerminate) {
if (onResponderTerminate != null) { if (onResponderTerminate != null) {
responderEvent.dispatchConfig.registrationName = 'onResponderTerminate';
onResponderTerminate(responderEvent); onResponderTerminate(responderEvent);
} }
changeCurrentResponder(emptyResponder); changeCurrentResponder(emptyResponder);
@@ -466,8 +472,11 @@ function findWantsResponder(eventPaths, domEvent, responderEvent) {
const config = getResponderConfig(id); const config = getResponderConfig(id);
const shouldSetCallback = config[callbackName]; const shouldSetCallback = config[callbackName];
if (shouldSetCallback != null) { if (shouldSetCallback != null) {
responderEvent.currentTarget = node;
if (shouldSetCallback(responderEvent) === true) { if (shouldSetCallback(responderEvent) === true) {
return { id, node, idPath }; // Start the path from the potential responder
const prunedIdPath = idPath.slice(idPath.indexOf(id));
return { id, node, idPath: prunedIdPath };
} }
} }
}; };
@@ -521,6 +530,7 @@ function attemptTransfer(responderEvent: ResponderEvent, wantsResponder: ActiveR
responderEvent.bubbles = false; responderEvent.bubbles = false;
responderEvent.cancelable = false; responderEvent.cancelable = false;
responderEvent.currentTarget = node; responderEvent.currentTarget = node;
// Set responder // Set responder
if (currentId == null) { if (currentId == null) {
if (onResponderGrant != null) { if (onResponderGrant != null) {
@@ -533,22 +543,35 @@ function attemptTransfer(responderEvent: ResponderEvent, wantsResponder: ActiveR
// Negotiate with current responder // Negotiate with current responder
else { else {
const { onResponderTerminate, onResponderTerminationRequest } = getResponderConfig(currentId); const { onResponderTerminate, onResponderTerminationRequest } = getResponderConfig(currentId);
const allowTransfer =
onResponderTerminationRequest != null && onResponderTerminationRequest(responderEvent); let allowTransfer = true;
if (onResponderTerminationRequest != null) {
responderEvent.currentTarget = currentNode;
responderEvent.dispatchConfig.registrationName = 'onResponderTerminationRequest';
if (onResponderTerminationRequest(responderEvent) === false) {
allowTransfer = false;
}
}
if (allowTransfer) { if (allowTransfer) {
// Terminate existing responder // Terminate existing responder
if (onResponderTerminate != null) { if (onResponderTerminate != null) {
responderEvent.currentTarget = currentNode; responderEvent.currentTarget = currentNode;
responderEvent.dispatchConfig.registrationName = 'onResponderTerminate';
onResponderTerminate(responderEvent); onResponderTerminate(responderEvent);
} }
// Grant next responder // Grant next responder
if (onResponderGrant != null) { if (onResponderGrant != null) {
responderEvent.currentTarget = node;
responderEvent.dispatchConfig.registrationName = 'onResponderGrant';
onResponderGrant(responderEvent); onResponderGrant(responderEvent);
} }
changeCurrentResponder(wantsResponder); changeCurrentResponder(wantsResponder);
} else { } else {
// Reject responder request // Reject responder request
if (onResponderReject != null) { if (onResponderReject != null) {
responderEvent.currentTarget = node;
responderEvent.dispatchConfig.registrationName = 'onResponderReject';
onResponderReject(responderEvent); onResponderReject(responderEvent);
} }
} }
@@ -205,10 +205,9 @@ describe('useResponderEvents', () => {
}); });
testWithPointerType('start grants responder to grandParent', pointerType => { testWithPointerType('start grants responder to grandParent', pointerType => {
let grantCurrentTarget, shouldSetCurrentTarget; let grantCurrentTarget;
const grandParentCallbacks = { const grandParentCallbacks = {
onStartShouldSetResponderCapture: jest.fn(e => { onStartShouldSetResponderCapture: jest.fn(e => {
shouldSetCurrentTarget = e.currentTarget;
return true; return true;
}), }),
onResponderGrant: jest.fn(e => { onResponderGrant: jest.fn(e => {
@@ -246,7 +245,6 @@ describe('useResponderEvents', () => {
}); });
// responder set (capture phase) // responder set (capture phase)
expect(grandParentCallbacks.onStartShouldSetResponderCapture).toBeCalledTimes(1); expect(grandParentCallbacks.onStartShouldSetResponderCapture).toBeCalledTimes(1);
expect(shouldSetCurrentTarget).toBe(null);
expect(parentCallbacks.onStartShouldSetResponderCapture).not.toBeCalled(); expect(parentCallbacks.onStartShouldSetResponderCapture).not.toBeCalled();
expect(targetCallbacks.onStartShouldSetResponderCapture).not.toBeCalled(); expect(targetCallbacks.onStartShouldSetResponderCapture).not.toBeCalled();
// responder grant // responder grant
@@ -1637,11 +1635,185 @@ describe('useResponderEvents', () => {
}); });
/** /**
* When there is an active responder, negotiation captures to and bubbles from * When there is an active responder, negotiation of the active pointer captures to
* the ancestor registered with the system. The responder is transferred and * and bubbles from the closest common ancestor registered with the system. The
* the relevant termination events are called. * responder is transferred and maintained for subsequent events of the same type.
*/ */
test('negotiates from first registered ancestor of responder and transfers', () => { test('negotiates single-touch from first registered ancestor of responder and transfers', () => {
const pointerType = 'touch';
const eventLog = [];
const grandParentCallbacks = {
onStartShouldSetResponderCapture() {
eventLog.push('grandParent: onStartShouldSetResponderCapture');
return false;
},
onStartShouldSetResponder() {
eventLog.push('grandParent: onStartShouldSetResponder');
return false;
},
onMoveShouldSetResponderCapture() {
eventLog.push('grandParent: onMoveShouldSetResponderCapture');
return false;
},
onMoveShouldSetResponder() {
eventLog.push('grandParent: onMoveShouldSetResponder');
return true;
},
onResponderGrant() {
eventLog.push('grandParent: onResponderGrant');
},
onResponderStart() {
eventLog.push('grandParent: onResponderStart');
},
onResponderMove() {
eventLog.push('grandParent: onResponderMove');
},
onResponderEnd() {
eventLog.push('grandParent: onResponderEnd');
},
onResponderRelease() {
eventLog.push('grandParent: onResponderRelease');
},
onResponderTerminate() {
eventLog.push('grandParent: onResponderTerminate');
},
onResponderTerminationRequest() {
eventLog.push('grandParent: onResponderTerminationRequest');
return true;
}
};
const parentCallbacks = {
onStartShouldSetResponderCapture() {
eventLog.push('parent: onStartShouldSetResponderCapture');
return false;
},
onStartShouldSetResponder() {
eventLog.push('parent: onStartShouldSetResponder');
return false;
},
onMoveShouldSetResponderCapture() {
eventLog.push('parent: onMoveShouldSetResponderCapture');
return false;
},
onMoveShouldSetResponder() {
eventLog.push('parent: onMoveShouldSetResponder');
return false;
},
onResponderGrant() {
eventLog.push('parent: onResponderGrant');
},
onResponderStart() {
eventLog.push('parent: onResponderStart');
},
onResponderMove() {
eventLog.push('parent: onResponderMove');
},
onResponderEnd() {
eventLog.push('parent: onResponderEnd');
},
onResponderRelease() {
eventLog.push('parent: onResponderRelease');
},
onResponderTerminate() {
eventLog.push('parent: onResponderTerminate');
},
onResponderTerminationRequest() {
eventLog.push('parent: onResponderTerminationRequest');
return true;
}
};
const targetCallbacks = {
onStartShouldSetResponderCapture() {
eventLog.push('target: onStartShouldSetResponderCapture');
return false;
},
onStartShouldSetResponder() {
eventLog.push('target: onStartShouldSetResponder');
return true;
},
onMoveShouldSetResponderCapture() {
eventLog.push('target: onMoveShouldSetResponderCapture');
return false;
},
onMoveShouldSetResponder() {
eventLog.push('target: onMoveShouldSetResponder');
return false;
},
onResponderGrant() {
eventLog.push('target: onResponderGrant');
},
onResponderStart() {
eventLog.push('target: onResponderStart');
},
onResponderMove() {
eventLog.push('target: onResponderMove');
},
onResponderEnd() {
eventLog.push('target: onResponderEnd');
},
onResponderRelease() {
eventLog.push('target: onResponderRelease');
},
onResponderTerminate() {
eventLog.push('target: onResponderTerminate');
},
onResponderTerminationRequest() {
eventLog.push('target: onResponderTerminationRequest');
return true;
}
};
const Component = () => {
useResponderEvents(grandParentRef, grandParentCallbacks);
useResponderEvents(parentRef, parentCallbacks);
useResponderEvents(targetRef, targetCallbacks);
return (
<div ref={grandParentRef}>
<div ref={parentRef}>
<div ref={targetRef} />
</div>
</div>
);
};
// render
act(() => {
render(<Component />);
});
const target = createEventTarget(targetRef.current);
// gesture start
act(() => {
target.pointerdown({ pointerType, pointerId: 1 });
target.pointermove({ pointerType, pointerId: 1 });
target.pointermove({ pointerType, pointerId: 1 });
});
expect(eventLog).toEqual([
'grandParent: onStartShouldSetResponderCapture',
'parent: onStartShouldSetResponderCapture',
'target: onStartShouldSetResponderCapture',
'target: onStartShouldSetResponder',
'target: onResponderGrant',
'target: onResponderStart',
'grandParent: onMoveShouldSetResponderCapture',
'parent: onMoveShouldSetResponderCapture',
'parent: onMoveShouldSetResponder',
'grandParent: onMoveShouldSetResponder',
'target: onResponderTerminationRequest',
'target: onResponderTerminate',
'grandParent: onResponderGrant',
'grandParent: onResponderMove',
// Continues calling 'move' rather than entering into negotiation again
'grandParent: onResponderMove'
]);
});
/**
* When there is an active responder, negotiation of a second pointer captures to
* and bubbles from the closest common ancestor registered with the system. The
* responder is transferred andvthe relevant termination events are called.
*/
test('negotiates multi-touch from first registered ancestor of responder and transfers', () => {
const pointerType = 'touch'; const pointerType = 'touch';
let eventLog = []; let eventLog = [];
const grandParentCallbacks = { const grandParentCallbacks = {
@@ -1667,6 +1839,9 @@ describe('useResponderEvents', () => {
onResponderStart() { onResponderStart() {
eventLog.push('grandParent: onResponderStart'); eventLog.push('grandParent: onResponderStart');
}, },
onResponderMove() {
eventLog.push('grandParent: onResponderMove');
},
onResponderEnd() { onResponderEnd() {
eventLog.push('grandParent: onResponderEnd'); eventLog.push('grandParent: onResponderEnd');
}, },
@@ -1704,6 +1879,9 @@ describe('useResponderEvents', () => {
onResponderStart() { onResponderStart() {
eventLog.push('parent: onResponderStart'); eventLog.push('parent: onResponderStart');
}, },
onResponderMove() {
eventLog.push('parent: onResponderMove');
},
onResponderEnd() { onResponderEnd() {
eventLog.push('parent: onResponderEnd'); eventLog.push('parent: onResponderEnd');
}, },
@@ -1741,6 +1919,9 @@ describe('useResponderEvents', () => {
onResponderStart() { onResponderStart() {
eventLog.push('target: onResponderStart'); eventLog.push('target: onResponderStart');
}, },
onResponderMove() {
eventLog.push('target: onResponderMove');
},
onResponderEnd() { onResponderEnd() {
eventLog.push('target: onResponderEnd'); eventLog.push('target: onResponderEnd');
}, },
@@ -1816,21 +1997,25 @@ describe('useResponderEvents', () => {
'parent: onMoveShouldSetResponder', 'parent: onMoveShouldSetResponder',
'target: onResponderTerminationRequest', 'target: onResponderTerminationRequest',
'target: onResponderTerminate', 'target: onResponderTerminate',
'parent: onResponderGrant' 'parent: onResponderGrant',
'parent: onResponderMove'
]); ]);
eventLog = []; eventLog = [];
// second move gesture // second move gesture
act(() => { act(() => {
target.pointermove({ pointerType, pointerId: 1 }); target.pointermove({ pointerType, pointerId: 1 });
target.pointermove({ pointerType, pointerId: 2 });
}); });
// parent becomes responder, parent terminates // grand parent becomes responder, parent terminates
expect(getResponderNode()).toBe(grandParentRef.current); expect(getResponderNode()).toBe(grandParentRef.current);
expect(eventLog).toEqual([ expect(eventLog).toEqual([
'grandParent: onMoveShouldSetResponderCapture', 'grandParent: onMoveShouldSetResponderCapture',
'grandParent: onMoveShouldSetResponder', 'grandParent: onMoveShouldSetResponder',
'parent: onResponderTerminationRequest', 'parent: onResponderTerminationRequest',
'parent: onResponderTerminate', 'parent: onResponderTerminate',
'grandParent: onResponderGrant' 'grandParent: onResponderGrant',
'grandParent: onResponderMove',
'grandParent: onResponderMove'
]); ]);
eventLog = []; eventLog = [];
// end gestures // end gestures