Experimental event API: rework the propagation system for event components (#15462)

This commit is contained in:
Dominic Gannaway
2019-04-23 11:50:48 +01:00
committed by GitHub
parent 587676900f
commit 784ebd8fa9
9 changed files with 342 additions and 434 deletions
+48 -64
View File
@@ -18,6 +18,7 @@ import {
HostComponent,
} from 'shared/ReactWorkTags';
import type {
ReactEventResponder,
ReactEventResponderEventType,
ReactEventComponentInstance,
ReactResponderContext,
@@ -61,13 +62,10 @@ type ResponderTimeout = {|
type ResponderTimer = {|
instance: ReactEventComponentInstance,
func: () => boolean,
func: () => void,
id: Symbol,
|};
const ROOT_PHASE = 0;
const BUBBLE_PHASE = 1;
const CAPTURE_PHASE = 2;
const activeTimeouts: Map<Symbol, ResponderTimeout> = new Map();
const rootEventTypesToEventComponentInstances: Map<
DOMTopLevelEventType | string,
@@ -253,7 +251,7 @@ const eventResponderContext: ReactResponderContext = {
triggerOwnershipListeners();
return false;
},
setTimeout(func: () => boolean, delay): Symbol {
setTimeout(func: () => void, delay): Symbol {
validateResponderContext();
if (currentTimers === null) {
currentTimers = new Map();
@@ -349,16 +347,13 @@ const eventResponderContext: ReactResponderContext = {
function processTimers(timers: Map<Symbol, ResponderTimer>): void {
const timersArr = Array.from(timers.values());
let shouldStopPropagation = false;
currentEventQueue = createEventQueue();
try {
for (let i = 0; i < timersArr.length; i++) {
const {instance, func, id} = timersArr[i];
currentInstance = instance;
try {
if (!shouldStopPropagation) {
shouldStopPropagation = func();
}
func();
} finally {
activeTimeouts.delete(id);
}
@@ -390,7 +385,6 @@ function createResponderEvent(
nativeEvent: AnyNativeEvent,
nativeEventTarget: Element | Document,
eventSystemFlags: EventSystemFlags,
phase: 0 | 1 | 2,
): ReactResponderEvent {
const responderEvent = {
nativeEvent: nativeEvent,
@@ -398,7 +392,6 @@ function createResponderEvent(
type: topLevelType,
passive: (eventSystemFlags & IS_PASSIVE) !== 0,
passiveSupported: (eventSystemFlags & PASSIVE_NOT_SUPPORTED) === 0,
phase,
};
if (__DEV__) {
Object.freeze(responderEvent);
@@ -510,16 +503,7 @@ function getRootEventResponderInstances(
return eventResponderInstances;
}
function triggerEventResponderEventListener(
responderEvent: ReactResponderEvent,
eventComponentInstance: ReactEventComponentInstance,
): boolean {
const {responder, props, state} = eventComponentInstance;
currentInstance = eventComponentInstance;
return responder.onEvent(responderEvent, eventResponderContext, props, state);
}
function traverseAndTriggerEventResponderInstances(
function traverseAndHandleEventResponderInstances(
topLevelType: DOMTopLevelEventType,
targetFiber: null | Fiber,
nativeEvent: AnyNativeEvent,
@@ -535,46 +519,54 @@ function traverseAndTriggerEventResponderInstances(
topLevelType,
targetFiber,
);
const responderEvent = createResponderEvent(
((topLevelType: any): string),
nativeEvent,
((nativeEventTarget: any): Element | Document),
eventSystemFlags,
);
const propagatedEventResponders: Set<ReactEventResponder> = new Set();
let length = targetEventResponderInstances.length;
let i;
let shouldStopPropagation = false;
let responderEvent;
// Captured and bubbled event phases have the notion of local propagation.
// This means that the propgation chain can be stopped part of the the way
// through processing event component instances. The major difference to other
// events systems is that the stopping of propgation is localized to a single
// phase, rather than both phases.
if (length > 0) {
// Capture target phase
responderEvent = createResponderEvent(
((topLevelType: any): string),
nativeEvent,
((nativeEventTarget: any): Element | Document),
eventSystemFlags,
CAPTURE_PHASE,
);
for (i = length; i-- > 0; ) {
const targetEventResponderInstance = targetEventResponderInstances[i];
shouldStopPropagation = triggerEventResponderEventListener(
responderEvent,
targetEventResponderInstance,
);
if (shouldStopPropagation) {
return;
const {responder, props, state} = targetEventResponderInstance;
if (responder.stopLocalPropagation) {
if (propagatedEventResponders.has(responder)) {
continue;
}
propagatedEventResponders.add(responder);
}
const eventListener = responder.onEventCapture;
if (eventListener !== undefined) {
currentInstance = targetEventResponderInstance;
eventListener(responderEvent, eventResponderContext, props, state);
}
}
// We clean propagated event responders between phases.
propagatedEventResponders.clear();
// Bubble target phase
responderEvent = createResponderEvent(
((topLevelType: any): string),
nativeEvent,
((nativeEventTarget: any): Element | Document),
eventSystemFlags,
BUBBLE_PHASE,
);
for (i = 0; i < length; i++) {
const targetEventResponderInstance = targetEventResponderInstances[i];
shouldStopPropagation = triggerEventResponderEventListener(
responderEvent,
targetEventResponderInstance,
);
if (shouldStopPropagation) {
return;
const {responder, props, state} = targetEventResponderInstance;
if (responder.stopLocalPropagation) {
if (propagatedEventResponders.has(responder)) {
continue;
}
propagatedEventResponders.add(responder);
}
const eventListener = responder.onEvent;
if (eventListener !== undefined) {
currentInstance = targetEventResponderInstance;
eventListener(responderEvent, eventResponderContext, props, state);
}
}
}
@@ -584,21 +576,13 @@ function traverseAndTriggerEventResponderInstances(
);
length = rootEventResponderInstances.length;
if (length > 0) {
responderEvent = createResponderEvent(
((topLevelType: any): string),
nativeEvent,
((nativeEventTarget: any): Element | Document),
eventSystemFlags,
ROOT_PHASE,
);
for (i = 0; i < length; i++) {
const targetEventResponderInstance = rootEventResponderInstances[i];
shouldStopPropagation = triggerEventResponderEventListener(
responderEvent,
targetEventResponderInstance,
);
if (shouldStopPropagation) {
return;
const rootEventResponderInstance = rootEventResponderInstances[i];
const {responder, props, state} = rootEventResponderInstance;
const eventListener = responder.onRootEvent;
if (eventListener !== undefined) {
currentInstance = rootEventResponderInstance;
eventListener(responderEvent, eventResponderContext, props, state);
}
}
}
@@ -672,7 +656,7 @@ export function dispatchEventForResponderEventSystem(
if (enableEventAPI) {
currentEventQueue = createEventQueue();
try {
traverseAndTriggerEventResponderInstances(
traverseAndHandleEventResponderInstances(
topLevelType,
targetFiber,
nativeEvent,
@@ -18,15 +18,21 @@ function createReactEventComponent(
targetEventTypes,
createInitialState,
onEvent,
onEventCapture,
onRootEvent,
onUnmount,
onOwnershipChange,
stopLocalPropagation,
) {
const testEventResponder = {
targetEventTypes,
createInitialState,
onEvent,
onEventCapture,
onRootEvent,
onUnmount,
onOwnershipChange,
stopLocalPropagation: stopLocalPropagation || false,
};
return {
@@ -37,18 +43,6 @@ function createReactEventComponent(
};
}
const ROOT_PHASE = 0;
const BUBBLE_PHASE = 1;
const CAPTURE_PHASE = 2;
function phaseToString(phase) {
return phase === ROOT_PHASE
? 'root'
: phase === BUBBLE_PHASE
? 'bubble'
: 'capture';
}
function dispatchEvent(element, type) {
const event = document.createEvent('Event');
event.initEvent(type, true, true);
@@ -88,7 +82,7 @@ describe('DOMEventResponderSystem', () => {
container = null;
});
it('the event responder onEvent() function should fire on click event', () => {
it('the event responder event listeners should fire on click event', () => {
let eventResponderFiredCount = 0;
let eventLog = [];
const buttonRef = React.createRef();
@@ -102,7 +96,16 @@ describe('DOMEventResponderSystem', () => {
name: event.type,
passive: event.passive,
passiveSupported: event.passiveSupported,
phase: event.phase,
phase: 'bubble',
});
},
(event, context, props) => {
eventResponderFiredCount++;
eventLog.push({
name: event.type,
passive: event.passive,
passiveSupported: event.passiveSupported,
phase: 'capture',
});
},
);
@@ -127,13 +130,13 @@ describe('DOMEventResponderSystem', () => {
name: 'click',
passive: false,
passiveSupported: false,
phase: CAPTURE_PHASE,
phase: 'capture',
},
{
name: 'click',
passive: false,
passiveSupported: false,
phase: BUBBLE_PHASE,
phase: 'bubble',
},
]);
@@ -149,7 +152,7 @@ describe('DOMEventResponderSystem', () => {
expect(eventResponderFiredCount).toBe(4);
});
it('the event responder onEvent() function should fire on click event (passive events forced)', () => {
it('the event responder event listeners should fire on click event (passive events forced)', () => {
// JSDOM does not support passive events, so this manually overrides the value to be true
const checkPassiveEvents = require('react-dom/src/events/checkPassiveEvents');
checkPassiveEvents.passiveBrowserEventsSupported = true;
@@ -165,7 +168,15 @@ describe('DOMEventResponderSystem', () => {
name: event.type,
passive: event.passive,
passiveSupported: event.passiveSupported,
phase: event.phase,
phase: 'bubble',
});
},
(event, context, props) => {
eventLog.push({
name: event.type,
passive: event.passive,
passiveSupported: event.passiveSupported,
phase: 'capture',
});
},
);
@@ -187,18 +198,18 @@ describe('DOMEventResponderSystem', () => {
name: 'click',
passive: true,
passiveSupported: true,
phase: CAPTURE_PHASE,
phase: 'capture',
},
{
name: 'click',
passive: true,
passiveSupported: true,
phase: BUBBLE_PHASE,
phase: 'bubble',
},
]);
});
it('nested event responders and their onEvent() function should fire multiple times', () => {
it('nested event responders and their event listeners should fire multiple times', () => {
let eventResponderFiredCount = 0;
let eventLog = [];
const buttonRef = React.createRef();
@@ -212,7 +223,16 @@ describe('DOMEventResponderSystem', () => {
name: event.type,
passive: event.passive,
passiveSupported: event.passiveSupported,
phase: event.phase,
phase: 'bubble',
});
},
(event, context, props) => {
eventResponderFiredCount++;
eventLog.push({
name: event.type,
passive: event.passive,
passiveSupported: event.passiveSupported,
phase: 'capture',
});
},
);
@@ -238,30 +258,30 @@ describe('DOMEventResponderSystem', () => {
name: 'click',
passive: false,
passiveSupported: false,
phase: CAPTURE_PHASE,
phase: 'capture',
},
{
name: 'click',
passive: false,
passiveSupported: false,
phase: CAPTURE_PHASE,
phase: 'capture',
},
{
name: 'click',
passive: false,
passiveSupported: false,
phase: BUBBLE_PHASE,
phase: 'bubble',
},
{
name: 'click',
passive: false,
passiveSupported: false,
phase: BUBBLE_PHASE,
phase: 'bubble',
},
]);
});
it('nested event responders and their onEvent() should fire in the correct order', () => {
it('nested event responders and their event listeners should fire in the correct order', () => {
let eventLog = [];
const buttonRef = React.createRef();
@@ -269,7 +289,10 @@ describe('DOMEventResponderSystem', () => {
['click'],
undefined,
(event, context, props) => {
eventLog.push(`A [${phaseToString(event.phase)}]`);
eventLog.push(`A [bubble]`);
},
(event, context, props) => {
eventLog.push(`A [capture]`);
},
);
@@ -277,7 +300,10 @@ describe('DOMEventResponderSystem', () => {
['click'],
undefined,
(event, context, props) => {
eventLog.push(`B [${phaseToString(event.phase)}]`);
eventLog.push(`B [bubble]`);
},
(event, context, props) => {
eventLog.push(`B [capture]`);
},
);
@@ -303,140 +329,81 @@ describe('DOMEventResponderSystem', () => {
]);
});
it('nested event responders and their onEvent() should fire in the correct order with stopPropagation', () => {
let eventLog;
let stopPropagationOnPhase;
it('nested event responders should fire in the correct order without stopLocalPropagation', () => {
let eventLog = [];
const buttonRef = React.createRef();
const ClickEventComponentA = createReactEventComponent(
const ClickEventComponent = createReactEventComponent(
['click'],
undefined,
(event, context, props) => {
context.addRootEventTypes(event.target.ownerDocument, [
'click',
'pointermove',
]);
eventLog.push(`A [${event.type}, ${phaseToString(event.phase)}]`);
eventLog.push(`${props.name} [bubble]`);
},
);
const ClickEventComponentB = createReactEventComponent(
['click'],
undefined,
(event, context, props) => {
context.addRootEventTypes(event.target.ownerDocument, [
'click',
'pointermove',
]);
eventLog.push(`B [${event.type}, ${phaseToString(event.phase)}]`);
if (event.phase === stopPropagationOnPhase) {
return true;
}
eventLog.push(`${props.name} [capture]`);
},
undefined,
undefined,
undefined,
false,
);
const Test = () => (
<ClickEventComponentA>
<ClickEventComponentB>
<ClickEventComponent name="A">
<ClickEventComponent name="B">
<button ref={buttonRef}>Click me!</button>
</ClickEventComponentB>
</ClickEventComponentA>
</ClickEventComponent>
</ClickEventComponent>
);
function runTestWithPhase(phase) {
eventLog = [];
stopPropagationOnPhase = phase;
ReactDOM.render(<Test />, container);
let buttonElement = buttonRef.current;
dispatchClickEvent(buttonElement);
dispatchEvent(buttonElement, 'pointermove');
}
ReactDOM.render(<Test />, container);
// Clicking the button should trigger the event responder onEvent()
let buttonElement = buttonRef.current;
dispatchClickEvent(buttonElement);
runTestWithPhase(BUBBLE_PHASE);
// Root phase should not be skipped for different event type
expect(eventLog).toEqual([
'A [click, capture]',
'B [click, capture]',
'B [click, bubble]',
'A [pointermove, root]',
'B [pointermove, root]',
]);
runTestWithPhase(CAPTURE_PHASE);
// Root phase should not be skipped for different event type
expect(eventLog).toEqual([
'A [click, capture]',
'B [click, capture]',
'A [pointermove, root]',
'B [pointermove, root]',
'A [capture]',
'B [capture]',
'B [bubble]',
'A [bubble]',
]);
});
it('nested event responders and their onEvent() should fire in the correct order with stopPropagation #2', () => {
let eventLog;
let stopPropagationOnPhase;
it('nested event responders should fire in the correct order with stopLocalPropagation', () => {
let eventLog = [];
const buttonRef = React.createRef();
const ClickEventComponentA = createReactEventComponent(
const ClickEventComponent = createReactEventComponent(
['click'],
undefined,
(event, context, props) => {
context.addRootEventTypes(event.target.ownerDocument, [
'click',
'pointermove',
]);
eventLog.push(`A [${event.type}, ${phaseToString(event.phase)}]`);
if (event.phase === stopPropagationOnPhase) {
return true;
}
eventLog.push(`${props.name} [bubble]`);
},
);
const ClickEventComponentB = createReactEventComponent(
['click'],
undefined,
(event, context, props) => {
context.addRootEventTypes(event.target.ownerDocument, [
'click',
'pointermove',
]);
eventLog.push(`B [${event.type}, ${phaseToString(event.phase)}]`);
eventLog.push(`${props.name} [capture]`);
},
undefined,
undefined,
undefined,
true,
);
const Test = () => (
<ClickEventComponentA>
<ClickEventComponentB>
<ClickEventComponent name="A">
<ClickEventComponent name="B">
<button ref={buttonRef}>Click me!</button>
</ClickEventComponentB>
</ClickEventComponentA>
</ClickEventComponent>
</ClickEventComponent>
);
function runTestWithPhase(phase) {
eventLog = [];
stopPropagationOnPhase = phase;
ReactDOM.render(<Test />, container);
let buttonElement = buttonRef.current;
dispatchClickEvent(buttonElement);
dispatchEvent(buttonElement, 'pointermove');
}
ReactDOM.render(<Test />, container);
runTestWithPhase(BUBBLE_PHASE);
// Root phase should not be skipped for different event type
expect(eventLog).toEqual([
'A [click, capture]',
'B [click, capture]',
'B [click, bubble]',
'A [click, bubble]',
'A [pointermove, root]',
'B [pointermove, root]',
]);
runTestWithPhase(CAPTURE_PHASE);
// Root phase should not be skipped for different event type
expect(eventLog).toEqual([
'A [click, capture]',
'A [pointermove, root]',
'B [pointermove, root]',
]);
// Clicking the button should trigger the event responder onEvent()
let buttonElement = buttonRef.current;
dispatchClickEvent(buttonElement);
expect(eventLog).toEqual(['A [capture]', 'B [bubble]']);
});
it('custom event dispatching for click -> magicClick works', () => {
@@ -451,7 +418,19 @@ describe('DOMEventResponderSystem', () => {
const syntheticEvent = {
target: event.target,
type: 'magicclick',
phase: phaseToString(event.phase),
phase: 'bubble',
};
context.dispatchEvent(syntheticEvent, props.onMagicClick, {
discrete: true,
});
}
},
(event, context, props) => {
if (props.onMagicClick) {
const syntheticEvent = {
target: event.target,
type: 'magicclick',
phase: 'capture',
};
context.dispatchEvent(syntheticEvent, props.onMagicClick, {
discrete: true,
@@ -490,42 +469,47 @@ describe('DOMEventResponderSystem', () => {
let eventLog = [];
const buttonRef = React.createRef();
function handleEvent(event, context, props, phase) {
const pressEvent = {
target: event.target,
type: 'press',
phase,
};
context.dispatchEvent(pressEvent, props.onPress, {discrete: true});
context.setTimeout(() => {
if (props.onLongPress) {
const longPressEvent = {
target: event.target,
type: 'longpress',
phase,
};
context.dispatchEvent(longPressEvent, props.onLongPress, {
discrete: true,
});
}
if (props.onLongPressChange) {
const longPressChangeEvent = {
target: event.target,
type: 'longpresschange',
phase,
};
context.dispatchEvent(longPressChangeEvent, props.onLongPressChange, {
discrete: true,
});
}
}, 500);
}
const LongPressEventComponent = createReactEventComponent(
['click'],
undefined,
(event, context, props) => {
const pressEvent = {
target: event.target,
type: 'press',
phase: phaseToString(event.phase),
};
context.dispatchEvent(pressEvent, props.onPress, {discrete: true});
context.setTimeout(() => {
if (props.onLongPress) {
const longPressEvent = {
target: event.target,
type: 'longpress',
phase: phaseToString(event.phase),
};
context.dispatchEvent(longPressEvent, props.onLongPress, {
discrete: true,
});
}
if (props.onLongPressChange) {
const longPressChangeEvent = {
target: event.target,
type: 'longpresschange',
phase: phaseToString(event.phase),
};
context.dispatchEvent(
longPressChangeEvent,
props.onLongPressChange,
{discrete: true},
);
}
}, 500);
handleEvent(event, context, props, 'bubble');
},
(event, context, props) => {
handleEvent(event, context, props, 'capture');
},
);
@@ -565,6 +549,8 @@ describe('DOMEventResponderSystem', () => {
const EventComponent = createReactEventComponent(
[],
undefined,
undefined,
undefined,
(event, context, props, state) => {},
() => {
onUnmountFired++;
@@ -590,7 +576,9 @@ describe('DOMEventResponderSystem', () => {
() => ({
incrementAmount: 5,
}),
(event, context, props, state) => {},
undefined,
undefined,
undefined,
(context, props, state) => {
counter += state.incrementAmount;
},
@@ -616,11 +604,11 @@ describe('DOMEventResponderSystem', () => {
['click'],
undefined,
(event, context, props, state) => {
if (event.phase === BUBBLE_PHASE) {
ownershipGained = context.requestOwnership();
}
ownershipGained = context.requestOwnership();
},
undefined,
undefined,
undefined,
() => {
onOwnershipChangeFired++;
},
+23 -13
View File
@@ -13,9 +13,12 @@ import type {
} from 'shared/ReactTypes';
import {REACT_EVENT_COMPONENT_TYPE} from 'shared/ReactSymbols';
const CAPTURE_PHASE = 2;
const targetEventTypes = ['pointerdown', 'pointercancel'];
const rootEventTypes = ['pointerup', {name: 'pointermove', passive: false}];
const targetEventTypes = ['pointerdown'];
const rootEventTypes = [
'pointerup',
'pointercancel',
{name: 'pointermove', passive: false},
];
type DragState = {
dragTarget: null | Element | Document,
@@ -30,8 +33,8 @@ type DragState = {
// In the case we don't have PointerEvents (Safari), we listen to touch events
// too
if (typeof window !== 'undefined' && window.PointerEvent === undefined) {
targetEventTypes.push('touchstart', 'touchend', 'mousedown', 'touchcancel');
rootEventTypes.push('mouseup', 'mousemove', {
targetEventTypes.push('touchstart', 'mousedown');
rootEventTypes.push('mouseup', 'mousemove', 'touchend', 'touchcancel', {
name: 'touchmove',
passive: false,
});
@@ -88,18 +91,15 @@ const DragResponder = {
y: 0,
};
},
stopLocalPropagation: true,
onEvent(
event: ReactResponderEvent,
context: ReactResponderContext,
props: Object,
state: DragState,
): boolean {
const {target, phase, type, nativeEvent} = event;
): void {
const {target, type, nativeEvent} = event;
// Drag doesn't handle capture target events at this point
if (phase === CAPTURE_PHASE) {
return false;
}
switch (type) {
case 'touchstart':
case 'mousedown':
@@ -133,11 +133,22 @@ const DragResponder = {
}
break;
}
}
},
onRootEvent(
event: ReactResponderEvent,
context: ReactResponderContext,
props: Object,
state: DragState,
): void {
const {type, nativeEvent} = event;
switch (type) {
case 'touchmove':
case 'mousemove':
case 'pointermove': {
if (event.passive) {
return false;
return;
}
if (state.isPointerDown) {
const obj =
@@ -230,7 +241,6 @@ const DragResponder = {
break;
}
}
return false;
},
};
+3 -13
View File
@@ -14,8 +14,6 @@ import type {
import {REACT_EVENT_COMPONENT_TYPE} from 'shared/ReactSymbols';
import {getEventCurrentTarget} from './utils.js';
const CAPTURE_PHASE = 2;
type FocusProps = {
disabled: boolean,
onBlur: (e: FocusEvent) => void,
@@ -112,10 +110,8 @@ const FocusResponder = {
context: ReactResponderContext,
props: Object,
state: FocusState,
): boolean {
const {type, phase, target} = event;
const shouldStopPropagation =
props.stopPropagation === undefined ? true : props.stopPropagation;
): void {
const {type, target} = event;
if (props.disabled) {
if (state.isFocused) {
@@ -123,12 +119,7 @@ const FocusResponder = {
state.isFocused = false;
state.focusTarget = null;
}
return false;
}
// Focus doesn't handle capture target events at this point
if (phase === CAPTURE_PHASE) {
return false;
return;
}
switch (type) {
@@ -153,7 +144,6 @@ const FocusResponder = {
break;
}
}
return shouldStopPropagation;
},
onUnmount(
context: ReactResponderContext,
+9 -19
View File
@@ -18,8 +18,6 @@ import {
isEventPositionWithinTouchHitTarget,
} from './utils';
const CAPTURE_PHASE = 2;
type HoverProps = {
disabled: boolean,
delayHoverEnd: number,
@@ -141,7 +139,6 @@ function dispatchHoverStartEvents(
state.hoverStartTimeout = context.setTimeout(() => {
state.hoverStartTimeout = null;
activate();
return false;
}, delayHoverStart);
} else {
activate();
@@ -201,7 +198,6 @@ function dispatchHoverEndEvents(
if (delayHoverEnd > 0) {
state.hoverEndTimeout = context.setTimeout(() => {
deactivate();
return false;
}, delayHoverEnd);
} else {
deactivate();
@@ -245,12 +241,13 @@ const HoverResponder = {
ignoreEmulatedMouseEvents: false,
};
},
stopLocalPropagation: true,
onEvent(
event: ReactResponderEvent,
context: ReactResponderContext,
props: HoverProps,
state: HoverState,
): boolean {
): void {
const {type} = event;
if (props.disabled) {
@@ -261,14 +258,8 @@ const HoverResponder = {
if (state.isTouched) {
state.isTouched = false;
}
return false;
return;
}
// Hover doesn't handle capture target events at this point
if (event.phase === CAPTURE_PHASE) {
return false;
}
const pointerType = getEventPointerType(event);
switch (type) {
@@ -280,23 +271,23 @@ const HoverResponder = {
// Prevent hover events for touch
if (state.isTouched || pointerType === 'touch') {
state.isTouched = true;
return false;
return;
}
// Prevent hover events for emulated events
if (isEmulatedMouseEvent(event, state)) {
return false;
return;
}
if (isEventPositionWithinTouchHitTarget(event, context)) {
state.isOverTouchHitTarget = true;
return false;
return;
}
state.hoverTarget = getEventCurrentTarget(event, context);
state.ignoreEmulatedMouseEvents = true;
dispatchHoverStartEvents(event, context, props, state);
}
return false;
return;
}
// MOVE
@@ -331,7 +322,7 @@ const HoverResponder = {
}
}
}
return false;
return;
}
// END
@@ -347,10 +338,9 @@ const HoverResponder = {
if (state.isTouched) {
state.isTouched = false;
}
return false;
return;
}
}
return false;
},
onUnmount(
context: ReactResponderContext,
+64 -59
View File
@@ -19,8 +19,6 @@ import {
isEventPositionWithinTouchHitTarget,
} from './utils';
const CAPTURE_PHASE = 2;
type PressProps = {
disabled: boolean,
delayLongPress: number,
@@ -103,10 +101,11 @@ const rootEventTypes = ['keyup', 'pointerup', 'pointermove', 'scroll'];
// If PointerEvents is not supported (e.g., Safari), also listen to touch and mouse events.
if (typeof window !== 'undefined' && window.PointerEvent === undefined) {
targetEventTypes.push('touchstart', 'touchend', 'touchcancel', 'mousedown');
targetEventTypes.push('touchstart', 'touchcancel', 'mousedown');
rootEventTypes.push(
{name: 'mouseup', passive: false},
'touchmove',
'touchend',
'mousemove',
);
}
@@ -199,8 +198,6 @@ function dispatchPressStartEvents(
props: PressProps,
state: PressState,
): void {
const shouldStopPropagation =
props.stopPropagation === undefined ? true : props.stopPropagation;
state.isPressed = true;
if (state.pressEndTimeout !== null) {
@@ -230,7 +227,6 @@ function dispatchPressStartEvents(
if (props.onLongPressChange) {
dispatchLongPressChangeEvent(context, props, state);
}
return shouldStopPropagation;
}, delayLongPress);
}
};
@@ -245,7 +241,6 @@ function dispatchPressStartEvents(
state.pressStartTimeout = context.setTimeout(() => {
state.pressStartTimeout = null;
dispatch();
return shouldStopPropagation;
}, delayPressStart);
} else {
dispatch();
@@ -258,8 +253,6 @@ function dispatchPressEndEvents(
props: PressProps,
state: PressState,
): void {
const shouldStopPropagation =
props.stopPropagation === undefined ? true : props.stopPropagation;
const wasActivePressStart = state.isActivePressStart;
let activationWasForced = false;
@@ -295,7 +288,6 @@ function dispatchPressEndEvents(
state.pressEndTimeout = context.setTimeout(() => {
state.pressEndTimeout = null;
deactivate(context, props, state);
return shouldStopPropagation;
}, delayPressEnd);
} else {
deactivate(context, props, state);
@@ -381,6 +373,24 @@ function unmountResponder(
}
}
function dispatchCancel(
type: string,
nativeEvent: $PropertyType<ReactResponderEvent, 'nativeEvent'>,
context: ReactResponderContext,
props: PressProps,
state: PressState,
): void {
if (state.isPressed) {
if (type === 'contextmenu' && props.preventDefault !== false) {
(nativeEvent: any).preventDefault();
} else {
state.ignoreEmulatedMouseEvents = false;
dispatchPressEndEvents(context, props, state);
context.removeRootEventTypes(rootEventTypes);
}
}
}
const PressResponder = {
targetEventTypes,
createInitialState(): PressState {
@@ -400,30 +410,23 @@ const PressResponder = {
ignoreEmulatedMouseEvents: false,
};
},
stopLocalPropagation: true,
onEvent(
event: ReactResponderEvent,
context: ReactResponderContext,
props: PressProps,
state: PressState,
): boolean {
const {phase, target, type} = event;
): void {
const {target, type} = event;
if (props.disabled) {
dispatchPressEndEvents(context, props, state);
context.removeRootEventTypes(rootEventTypes);
state.ignoreEmulatedMouseEvents = false;
return false;
return;
}
// Press doesn't handle capture target events at this point
if (phase === CAPTURE_PHASE) {
return false;
}
const nativeEvent: any = event.nativeEvent;
const pointerType = getEventPointerType(event);
const shouldStopPropagation =
props.stopPropagation === undefined ? true : props.stopPropagation;
switch (type) {
// START
@@ -440,7 +443,7 @@ const PressResponder = {
// Ignore unrelated key events
if (pointerType === 'keyboard') {
if (!isValidKeyPress(nativeEvent.key)) {
return shouldStopPropagation;
return;
}
}
@@ -451,13 +454,13 @@ const PressResponder = {
state.ignoreEmulatedMouseEvents ||
isEventPositionWithinTouchHitTarget(event, context)
) {
return shouldStopPropagation;
return;
}
}
// Ignore any device buttons except left-mouse and touch/pen contact
if (nativeEvent.button > 0) {
return shouldStopPropagation;
return;
}
state.pointerType = pointerType;
@@ -465,17 +468,46 @@ const PressResponder = {
state.isPressWithinResponderRegion = true;
dispatchPressStartEvents(context, props, state);
context.addRootEventTypes(target.ownerDocument, rootEventTypes);
return shouldStopPropagation;
} else {
// Prevent spacebar press from scrolling the window
if (isValidKeyPress(nativeEvent.key) && nativeEvent.key === ' ') {
nativeEvent.preventDefault();
return shouldStopPropagation;
}
}
return shouldStopPropagation;
break;
}
// CANCEL
case 'contextmenu': {
dispatchCancel(type, nativeEvent, context, props, state);
break;
}
case 'click': {
if (isAnchorTagElement(target)) {
const {ctrlKey, metaKey, shiftKey} = (nativeEvent: MouseEvent);
// Check "open in new window/tab" and "open context menu" key modifiers
const preventDefault = props.preventDefault;
if (preventDefault !== false && !shiftKey && !metaKey && !ctrlKey) {
nativeEvent.preventDefault();
}
}
break;
}
}
},
onRootEvent(
event: ReactResponderEvent,
context: ReactResponderContext,
props: PressProps,
state: PressState,
): void {
const {target, type} = event;
const nativeEvent: any = event.nativeEvent;
const pointerType = getEventPointerType(event);
switch (type) {
// MOVE
case 'pointermove':
case 'mousemove':
@@ -484,7 +516,7 @@ const PressResponder = {
// Ignore emulated events (pointermove will dispatch touch and mouse events)
// Ignore pointermove events during a keyboard press
if (state.pointerType !== pointerType) {
return shouldStopPropagation;
return;
}
if (state.responderRegion == null) {
@@ -504,9 +536,8 @@ const PressResponder = {
state.isPressWithinResponderRegion = false;
dispatchPressEndEvents(context, props, state);
}
return shouldStopPropagation;
}
return false;
break;
}
// END
@@ -518,7 +549,7 @@ const PressResponder = {
// Ignore unrelated keyboard events
if (pointerType === 'keyboard') {
if (!isValidKeyPress(nativeEvent.key)) {
return false;
return;
}
}
@@ -539,45 +570,19 @@ const PressResponder = {
}
}
context.removeRootEventTypes(rootEventTypes);
return shouldStopPropagation;
} else if (type === 'mouseup' && state.ignoreEmulatedMouseEvents) {
state.ignoreEmulatedMouseEvents = false;
}
return false;
break;
}
// CANCEL
case 'contextmenu':
case 'pointercancel':
case 'scroll':
case 'touchcancel': {
if (state.isPressed) {
if (type === 'contextmenu' && props.preventDefault !== false) {
nativeEvent.preventDefault();
} else {
state.ignoreEmulatedMouseEvents = false;
dispatchPressEndEvents(context, props, state);
context.removeRootEventTypes(rootEventTypes);
}
return shouldStopPropagation;
}
return false;
}
case 'click': {
if (isAnchorTagElement(target)) {
const {ctrlKey, metaKey, shiftKey} = (nativeEvent: MouseEvent);
// Check "open in new window/tab" and "open context menu" key modifiers
const preventDefault = props.preventDefault;
if (preventDefault !== false && !shiftKey && !metaKey && !ctrlKey) {
nativeEvent.preventDefault();
}
return shouldStopPropagation;
}
return false;
dispatchCancel(type, nativeEvent, context, props, state);
}
}
return false;
},
onUnmount(
context: ReactResponderContext,
+25 -15
View File
@@ -13,15 +13,18 @@ import type {
} from 'shared/ReactTypes';
import {REACT_EVENT_COMPONENT_TYPE} from 'shared/ReactSymbols';
const CAPTURE_PHASE = 2;
const targetEventTypes = ['pointerdown', 'pointercancel'];
const rootEventTypes = ['pointerup', {name: 'pointermove', passive: false}];
const targetEventTypes = ['pointerdown'];
const rootEventTypes = [
'pointerup',
'pointercancel',
{name: 'pointermove', passive: false},
];
// In the case we don't have PointerEvents (Safari), we listen to touch events
// too
if (typeof window !== 'undefined' && window.PointerEvent === undefined) {
targetEventTypes.push('touchstart', 'touchend', 'mousedown', 'touchcancel');
rootEventTypes.push('mouseup', 'mousemove', {
targetEventTypes.push('touchstart', 'mousedown');
rootEventTypes.push('mouseup', 'mousemove', 'touchend', 'touchcancel', {
name: 'touchmove',
passive: false,
});
@@ -92,18 +95,15 @@ const SwipeResponder = {
y: 0,
};
},
stopLocalPropagation: true,
onEvent(
event: ReactResponderEvent,
context: ReactResponderContext,
props: Object,
state: SwipeState,
): boolean {
const {target, phase, type, nativeEvent} = event;
): void {
const {target, type, nativeEvent} = event;
// Swipe doesn't handle capture target events at this point
if (phase === CAPTURE_PHASE) {
return false;
}
switch (type) {
case 'touchstart':
case 'mousedown':
@@ -136,11 +136,22 @@ const SwipeResponder = {
}
break;
}
}
},
onRootEvent(
event: ReactResponderEvent,
context: ReactResponderContext,
props: Object,
state: SwipeState,
): void {
const {type, nativeEvent} = event;
switch (type) {
case 'touchmove':
case 'mousemove':
case 'pointermove': {
if (event.passive) {
return false;
return;
}
if (state.isSwiping) {
let obj = null;
@@ -160,7 +171,7 @@ const SwipeResponder = {
state.swipeTarget = null;
state.touchId = null;
context.removeRootEventTypes(rootEventTypes);
return false;
return;
}
const x = (obj: any).screenX;
const y = (obj: any).screenY;
@@ -196,7 +207,7 @@ const SwipeResponder = {
case 'pointerup': {
if (state.isSwiping) {
if (state.x === state.startX && state.y === state.startY) {
return false;
return;
}
if (props.onShouldClaimOwnership) {
context.releaseOwnership();
@@ -240,7 +251,6 @@ const SwipeResponder = {
break;
}
}
return false;
},
};
@@ -1127,15 +1127,10 @@ describe('Event responder: Press', () => {
'pointerdown',
'inner: onPressStart',
'inner: onPressChange',
'outer: onPressStart',
'outer: onPressChange',
'pointerup',
'inner: onPressEnd',
'inner: onPressChange',
'inner: onPress',
'outer: onPressEnd',
'outer: onPressChange',
'outer: onPress',
]);
});
@@ -1214,82 +1209,6 @@ describe('Event responder: Press', () => {
expect(fn).toHaveBeenCalledTimes(2);
});
});
describe('correctly bubble to other event responders when stopPropagation is set to false', () => {
it('for onPress', () => {
const ref = React.createRef();
const fn = jest.fn();
const element = (
<Press onPress={fn}>
<Press onPress={fn} stopPropagation={false}>
<div ref={ref} />
</Press>
</Press>
);
ReactDOM.render(element, container);
ref.current.dispatchEvent(createPointerEvent('pointerdown'));
ref.current.dispatchEvent(createPointerEvent('pointerup'));
expect(fn).toHaveBeenCalledTimes(2);
});
it('for onLongPress', () => {
const ref = React.createRef();
const fn = jest.fn();
const element = (
<Press onLongPress={fn}>
<Press onLongPress={fn} stopPropagation={false}>
<div ref={ref} />
</Press>
</Press>
);
ReactDOM.render(element, container);
ref.current.dispatchEvent(createPointerEvent('pointerdown'));
jest.advanceTimersByTime(DEFAULT_LONG_PRESS_DELAY);
ref.current.dispatchEvent(createPointerEvent('pointerup'));
expect(fn).toHaveBeenCalledTimes(2);
});
it('for onPressStart/onPressEnd', () => {
const ref = React.createRef();
const fn = jest.fn();
const fn2 = jest.fn();
const element = (
<Press onPressStart={fn} onPressEnd={fn2}>
<Press onPressStart={fn} onPressEnd={fn2} stopPropagation={false}>
<div ref={ref} />
</Press>
</Press>
);
ReactDOM.render(element, container);
ref.current.dispatchEvent(createPointerEvent('pointerdown'));
expect(fn).toHaveBeenCalledTimes(2);
expect(fn2).toHaveBeenCalledTimes(0);
ref.current.dispatchEvent(createPointerEvent('pointerup'));
expect(fn).toHaveBeenCalledTimes(2);
expect(fn2).toHaveBeenCalledTimes(2);
});
it('for onPressChange', () => {
const ref = React.createRef();
const fn = jest.fn();
const element = (
<Press onPressChange={fn}>
<Press onPressChange={fn} stopPropagation={false}>
<div ref={ref} />
</Press>
</Press>
);
ReactDOM.render(element, container);
ref.current.dispatchEvent(createPointerEvent('pointerdown'));
expect(fn).toHaveBeenCalledTimes(2);
ref.current.dispatchEvent(createPointerEvent('pointerup'));
expect(fn).toHaveBeenCalledTimes(4);
});
});
});
describe('link components', () => {
+16 -4
View File
@@ -88,12 +88,25 @@ export type ReactEventResponderEventType =
export type ReactEventResponder = {
targetEventTypes: Array<ReactEventResponderEventType>,
createInitialState?: (props: null | Object) => Object,
onEvent: (
stopLocalPropagation: boolean,
onEvent?: (
event: ReactResponderEvent,
context: ReactResponderContext,
props: null | Object,
state: null | Object,
) => boolean,
) => void,
onEventCapture?: (
event: ReactResponderEvent,
context: ReactResponderContext,
props: null | Object,
state: null | Object,
) => void,
onRootEvent?: (
event: ReactResponderEvent,
context: ReactResponderContext,
props: null | Object,
state: null | Object,
) => void,
onUnmount: (
context: ReactResponderContext,
props: null | Object,
@@ -135,7 +148,6 @@ export type ReactResponderEvent = {
type: string,
passive: boolean,
passiveSupported: boolean,
phase: 0 | 1 | 2,
};
export type ReactResponderDispatchEventOptions = {
@@ -168,7 +180,7 @@ export type ReactResponderContext = {
hasOwnership: () => boolean,
requestOwnership: () => boolean,
releaseOwnership: () => boolean,
setTimeout: (func: () => boolean, timeout: number) => Symbol,
setTimeout: (func: () => void, timeout: number) => Symbol,
clearTimeout: (timerId: Symbol) => void,
getEventTargetsFromTarget: (
target: Element | Document,