Experimental Event API: adds stopPropagation by default to Press (#15384)

This commit is contained in:
Dominic Gannaway
2019-04-11 20:00:20 +01:00
committed by GitHub
parent a9eff329c6
commit 9672cf621b
5 changed files with 249 additions and 70 deletions
+117 -56
View File
@@ -41,9 +41,11 @@ export function setListenToResponderEventTypes(
listenToResponderEventTypesImpl = _listenToResponderEventTypesImpl;
}
type EventObjectTypes = {|stopPropagation: true|} | $Shape<PartialEventObject>;
type EventQueue = {
bubble: null | Array<$Shape<PartialEventObject>>,
capture: null | Array<$Shape<PartialEventObject>>,
bubble: null | Array<EventObjectTypes>,
capture: null | Array<EventObjectTypes>,
discrete: boolean,
};
@@ -53,6 +55,29 @@ type PartialEventObject = {
type: string,
};
type ResponderTimeout = {|
id: TimeoutID,
timers: Map<Symbol, ResponderTimer>,
|};
type ResponderTimer = {|
instance: ReactEventComponentInstance,
func: () => void,
id: Symbol,
|};
const activeTimeouts: Map<Symbol, ResponderTimeout> = new Map();
const rootEventTypesToEventComponentInstances: Map<
DOMTopLevelEventType | string,
Set<ReactEventComponentInstance>,
> = new Map();
const targetEventTypeCached: Map<
Array<ReactEventResponderEventType>,
Set<DOMTopLevelEventType>,
> = new Map();
const ownershipChangeListeners: Set<ReactEventComponentInstance> = new Set();
let currentTimers = new Map();
let currentOwner = null;
let currentInstance: ReactEventComponentInstance;
let currentEventQueue: EventQueue;
@@ -60,9 +85,8 @@ let currentEventQueue: EventQueue;
const eventResponderContext: ReactResponderContext = {
dispatchEvent(
possibleEventObject: Object,
{capture, discrete, stopPropagation}: ReactResponderDispatchEventOptions,
{capture, discrete}: ReactResponderDispatchEventOptions,
): void {
const eventQueue = currentEventQueue;
const {listener, target, type} = possibleEventObject;
if (listener == null || target == null || type == null) {
@@ -89,27 +113,15 @@ const eventResponderContext: ReactResponderContext = {
const eventObject = ((possibleEventObject: any): $Shape<
PartialEventObject,
>);
let events;
if (capture) {
events = eventQueue.capture;
if (events === null) {
events = eventQueue.capture = [];
}
} else {
events = eventQueue.bubble;
if (events === null) {
events = eventQueue.bubble = [];
}
}
const events = getEventsFromEventQueue(capture);
if (discrete) {
eventQueue.discrete = true;
currentEventQueue.discrete = true;
}
events.push(eventObject);
if (stopPropagation) {
eventsWithStopPropagation.add(eventObject);
}
},
dispatchStopPropagation(capture?: boolean) {
const events = getEventsFromEventQueue();
events.push({stopPropagation: true});
},
isPositionWithinTouchHitTarget(doc: Document, x: number, y: number): boolean {
// This isn't available in some environments (JSDOM)
@@ -222,21 +234,42 @@ const eventResponderContext: ReactResponderContext = {
triggerOwnershipListeners();
return false;
},
setTimeout(func: () => void, delay): TimeoutID {
const contextInstance = currentInstance;
return setTimeout(() => {
const previousEventQueue = currentEventQueue;
const previousInstance = currentInstance;
currentEventQueue = createEventQueue();
currentInstance = contextInstance;
try {
func();
batchedUpdates(processEventQueue, currentEventQueue);
} finally {
currentInstance = previousInstance;
currentEventQueue = previousEventQueue;
setTimeout(func: () => void, delay): Symbol {
if (currentTimers === null) {
currentTimers = new Map();
}
let timeout = currentTimers.get(delay);
const timerId = Symbol();
if (timeout === undefined) {
const timers = new Map();
const id = setTimeout(() => {
processTimers(timers);
}, delay);
timeout = {
id,
timers,
};
currentTimers.set(delay, timeout);
}
timeout.timers.set(timerId, {
instance: currentInstance,
func,
id: timerId,
});
activeTimeouts.set(timerId, timeout);
return timerId;
},
clearTimeout(timerId: Symbol): void {
const timeout = activeTimeouts.get(timerId);
if (timeout !== undefined) {
const timers = timeout.timers;
timers.delete(timerId);
if (timers.size === 0) {
clearTimeout(timeout.id);
}
}, delay);
}
},
getEventTargetsFromTarget(
target: Element | Document,
@@ -292,6 +325,46 @@ const eventResponderContext: ReactResponderContext = {
},
};
function getEventsFromEventQueue(capture?: boolean): Array<EventObjectTypes> {
let events;
if (capture) {
events = currentEventQueue.capture;
if (events === null) {
events = currentEventQueue.capture = [];
}
} else {
events = currentEventQueue.bubble;
if (events === null) {
events = currentEventQueue.bubble = [];
}
}
return events;
}
function processTimers(timers: Map<Symbol, ResponderTimer>): void {
const previousEventQueue = currentEventQueue;
const previousInstance = currentInstance;
currentEventQueue = createEventQueue();
try {
const timersArr = Array.from(timers.values());
for (let i = 0; i < timersArr.length; i++) {
const {instance, func, id} = timersArr[i];
currentInstance = instance;
try {
func();
} finally {
activeTimeouts.delete(id);
}
}
batchedUpdates(processEventQueue, currentEventQueue);
} finally {
currentInstance = previousInstance;
currentEventQueue = previousEventQueue;
currentTimers = null;
}
}
function queryEventTarget(
child: Fiber,
queryType: void | Symbol | number,
@@ -306,20 +379,6 @@ function queryEventTarget(
return true;
}
const rootEventTypesToEventComponentInstances: Map<
DOMTopLevelEventType | string,
Set<ReactEventComponentInstance>,
> = new Map();
const PossiblyWeakSet = typeof WeakSet === 'function' ? WeakSet : Set;
const eventsWithStopPropagation:
| WeakSet
| Set<$Shape<PartialEventObject>> = new PossiblyWeakSet();
const targetEventTypeCached: Map<
Array<ReactEventResponderEventType>,
Set<DOMTopLevelEventType>,
> = new Map();
const ownershipChangeListeners: Set<ReactEventComponentInstance> = new Set();
function createResponderEvent(
topLevelType: string,
nativeEvent: AnyNativeEvent,
@@ -350,27 +409,27 @@ function processEvent(event: $Shape<PartialEventObject>): void {
}
function processEvents(
bubble: null | Array<$Shape<PartialEventObject>>,
capture: null | Array<$Shape<PartialEventObject>>,
bubble: null | Array<EventObjectTypes>,
capture: null | Array<EventObjectTypes>,
): void {
let i, length;
if (capture !== null) {
for (i = capture.length; i-- > 0; ) {
const event = capture[i];
processEvent(capture[i]);
if (eventsWithStopPropagation.has(event)) {
if (event.stopPropagation === true) {
return;
}
processEvent(((event: any): $Shape<PartialEventObject>));
}
}
if (bubble !== null) {
for (i = 0, length = bubble.length; i < length; ++i) {
const event = bubble[i];
processEvent(event);
if (eventsWithStopPropagation.has(event)) {
if (event.stopPropagation === true) {
return;
}
processEvent(((event: any): $Shape<PartialEventObject>));
}
}
}
@@ -475,6 +534,7 @@ export function runResponderEventsInBatch(
}
}
processEventQueue();
currentTimers = null;
}
}
@@ -518,6 +578,7 @@ export function unmountEventResponder(
} finally {
currentEventQueue = previousEventQueue;
currentInstance = previousInstance;
currentTimers = null;
}
}
if (currentOwner === eventComponentInstance) {
+4 -4
View File
@@ -27,8 +27,8 @@ type HoverState = {
isHovered: boolean,
isInHitSlop: boolean,
isTouched: boolean,
hoverStartTimeout: null | TimeoutID,
hoverEndTimeout: null | TimeoutID,
hoverStartTimeout: null | Symbol,
hoverEndTimeout: null | Symbol,
};
type HoverEventType = 'hoverstart' | 'hoverend' | 'hoverchange';
@@ -97,7 +97,7 @@ function dispatchHoverStartEvents(
state.isHovered = true;
if (state.hoverEndTimeout !== null) {
clearTimeout(state.hoverEndTimeout);
context.clearTimeout(state.hoverEndTimeout);
state.hoverEndTimeout = null;
}
@@ -148,7 +148,7 @@ function dispatchHoverEndEvents(
state.isHovered = false;
if (state.hoverStartTimeout !== null) {
clearTimeout(state.hoverStartTimeout);
context.clearTimeout(state.hoverStartTimeout);
state.hoverStartTimeout = null;
}
+29 -7
View File
@@ -33,19 +33,21 @@ type PressProps = {
left: number,
},
preventDefault: boolean,
stopPropagation: boolean,
};
type PressState = {
didDispatchEvent: boolean,
isActivePressed: boolean,
isActivePressStart: boolean,
isAnchorTouched: boolean,
isLongPressed: boolean,
isPressed: boolean,
isPressWithinResponderRegion: boolean,
longPressTimeout: null | TimeoutID,
longPressTimeout: null | Symbol,
pressTarget: null | Element | Document,
pressEndTimeout: null | TimeoutID,
pressStartTimeout: null | TimeoutID,
pressEndTimeout: null | Symbol,
pressStartTimeout: null | Symbol,
responderRegion: null | $ReadOnly<{|
bottom: number,
left: number,
@@ -124,7 +126,10 @@ function dispatchEvent(
): void {
const target = ((state.pressTarget: any): Element | Document);
const syntheticEvent = createPressEvent(name, target, listener);
context.dispatchEvent(syntheticEvent, {discrete: true});
context.dispatchEvent(syntheticEvent, {
discrete: true,
});
state.didDispatchEvent = true;
}
function dispatchPressChangeEvent(
@@ -185,7 +190,7 @@ function dispatchPressStartEvents(
state.isPressed = true;
if (state.pressEndTimeout !== null) {
clearTimeout(state.pressEndTimeout);
context.clearTimeout(state.pressEndTimeout);
state.pressEndTimeout = null;
}
@@ -211,6 +216,14 @@ function dispatchPressStartEvents(
if (props.onLongPressChange) {
dispatchLongPressChangeEvent(context, props, state);
}
if (state.didDispatchEvent) {
const shouldStopPropagation =
props.stopPropagation === undefined ? true : props.stopPropagation;
if (shouldStopPropagation) {
context.dispatchStopPropagation();
}
state.didDispatchEvent = false;
}
}, delayLongPress);
}
};
@@ -243,12 +256,12 @@ function dispatchPressEndEvents(
state.isPressed = false;
if (state.longPressTimeout !== null) {
clearTimeout(state.longPressTimeout);
context.clearTimeout(state.longPressTimeout);
state.longPressTimeout = null;
}
if (!wasActivePressStart && state.pressStartTimeout !== null) {
clearTimeout(state.pressStartTimeout);
context.clearTimeout(state.pressStartTimeout);
state.pressStartTimeout = null;
// don't activate if a press has moved beyond the responder region
if (state.isPressWithinResponderRegion) {
@@ -356,6 +369,7 @@ const PressResponder = {
targetEventTypes,
createInitialState(): PressState {
return {
didDispatchEvent: false,
isActivePressed: false,
isActivePressStart: false,
isAnchorTouched: false,
@@ -602,6 +616,14 @@ const PressResponder = {
}
}
}
if (state.didDispatchEvent) {
const shouldStopPropagation =
props.stopPropagation === undefined ? true : props.stopPropagation;
if (shouldStopPropagation) {
context.dispatchStopPropagation();
}
state.didDispatchEvent = false;
}
},
onUnmount(
context: ReactResponderContext,
@@ -381,6 +381,38 @@ describe('Event responder: Press', () => {
expect(onPressChange).toHaveBeenCalledTimes(2);
expect(onPressChange).toHaveBeenCalledWith(false);
});
it('is called but does not bubble', () => {
const element = (
<Press onPressChange={onPressChange}>
<Press onPressChange={onPressChange}>
<div ref={ref} />
</Press>
</Press>
);
ReactDOM.render(element, container);
ref.current.dispatchEvent(createPointerEvent('pointerdown'));
expect(onPressChange).toHaveBeenCalledTimes(1);
ref.current.dispatchEvent(createPointerEvent('pointerup'));
expect(onPressChange).toHaveBeenCalledTimes(2);
});
it('is called and bubbles correctly with stopPropagation set to false', () => {
const element = (
<Press onPressChange={onPressChange}>
<Press onPressChange={onPressChange} stopPropagation={false}>
<div ref={ref} />
</Press>
</Press>
);
ReactDOM.render(element, container);
ref.current.dispatchEvent(createPointerEvent('pointerdown'));
expect(onPressChange).toHaveBeenCalledTimes(2);
ref.current.dispatchEvent(createPointerEvent('pointerup'));
expect(onPressChange).toHaveBeenCalledTimes(4);
});
});
describe('onPress', () => {
@@ -429,6 +461,36 @@ describe('Event responder: Press', () => {
// ref.current.dispatchEvent(createPointerEvent('touchend'));
// expect(onPress).toHaveBeenCalledTimes(1);
// });
it('is called but does not bubble', () => {
const element = (
<Press onPress={onPress}>
<Press onPress={onPress}>
<div ref={ref} />
</Press>
</Press>
);
ReactDOM.render(element, container);
ref.current.dispatchEvent(createPointerEvent('pointerdown'));
ref.current.dispatchEvent(createPointerEvent('pointerup'));
expect(onPress).toHaveBeenCalledTimes(1);
});
it('is called and bubbles correctly with stopPropagation set to false', () => {
const element = (
<Press onPress={onPress}>
<Press onPress={onPress} stopPropagation={false}>
<div ref={ref} />
</Press>
</Press>
);
ReactDOM.render(element, container);
ref.current.dispatchEvent(createPointerEvent('pointerdown'));
ref.current.dispatchEvent(createPointerEvent('pointerup'));
expect(onPress).toHaveBeenCalledTimes(2);
});
});
describe('onLongPress', () => {
@@ -477,6 +539,38 @@ describe('Event responder: Press', () => {
expect(onLongPress).not.toBeCalled();
});
it('is called but does not bubble', () => {
const element = (
<Press onLongPress={onLongPress}>
<Press onLongPress={onLongPress}>
<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(onLongPress).toHaveBeenCalledTimes(1);
});
it('is called and bubbles correctly with stopPropagation set to false', () => {
const element = (
<Press onLongPress={onLongPress}>
<Press onLongPress={onLongPress} 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(onLongPress).toHaveBeenCalledTimes(2);
});
describe('delayLongPress', () => {
it('can be configured', () => {
const element = (
@@ -914,7 +1008,8 @@ describe('Event responder: Press', () => {
onPress={createEventHandler('inner: onPress')}
onPressChange={createEventHandler('inner: onPressChange')}
onPressStart={createEventHandler('inner: onPressStart')}
onPressEnd={createEventHandler('inner: onPressEnd')}>
onPressEnd={createEventHandler('inner: onPressEnd')}
stopPropagation={false}>
<div
ref={ref}
onPointerDown={createEventHandler('pointerdown')}
+3 -2
View File
@@ -140,7 +140,6 @@ export type ReactResponderEvent = {
export type ReactResponderDispatchEventOptions = {
capture?: boolean,
discrete?: boolean,
stopPropagation?: boolean,
};
export type ReactResponderContext = {
@@ -148,6 +147,7 @@ export type ReactResponderContext = {
eventObject: Object,
otpions: ReactResponderDispatchEventOptions,
) => void,
dispatchStopPropagation: (passive?: boolean) => void,
isTargetWithinElement: (
childTarget: Element | Document,
parentTarget: Element | Document,
@@ -168,7 +168,8 @@ export type ReactResponderContext = {
hasOwnership: () => boolean,
requestOwnership: () => boolean,
releaseOwnership: () => boolean,
setTimeout: (func: () => void, timeout: number) => TimeoutID,
setTimeout: (func: () => void, timeout: number) => Symbol,
clearTimeout: (timerId: Symbol) => void,
getEventTargetsFromTarget: (
target: Element | Document,
queryType?: Symbol | number,