@samitouri / QOS-React-2 / commits / 9672cf621b

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

Dominic Gannaway committed Apr 11, 2019 at 20:00 UTC 9672cf621bccc799d1d86f45c86e2fbcb97be5aa
5 files changed +249 -70
packages/react-dom/src/events/DOMEventResponderSystem.js
+117 -56
@@ -41,9 +41,11 @@ export function setListenToResponderEventTypes(
41 listenToResponderEventTypesImpl = _listenToResponderEventTypesImpl;
42 }
43
44 +type EventObjectTypes = {|stopPropagation: true|} | $Shape<PartialEventObject>;
45 +
46 type EventQueue = {
45 - bubble: null | Array<$Shape<PartialEventObject>>,
46 - capture: null | Array<$Shape<PartialEventObject>>,
47 + bubble: null | Array<EventObjectTypes>,
48 + capture: null | Array<EventObjectTypes>,
49 discrete: boolean,
50 };
51
@@ -53,6 +55,29 @@ type PartialEventObject = {
55 type: string,
56 };
57
58 +type ResponderTimeout = {|
59 + id: TimeoutID,
60 + timers: Map<Symbol, ResponderTimer>,
61 +|};
62 +
63 +type ResponderTimer = {|
64 + instance: ReactEventComponentInstance,
65 + func: () => void,
66 + id: Symbol,
67 +|};
68 +
69 +const activeTimeouts: Map<Symbol, ResponderTimeout> = new Map();
70 +const rootEventTypesToEventComponentInstances: Map<
71 + DOMTopLevelEventType | string,
72 + Set<ReactEventComponentInstance>,
73 +> = new Map();
74 +const targetEventTypeCached: Map<
75 + Array<ReactEventResponderEventType>,
76 + Set<DOMTopLevelEventType>,
77 +> = new Map();
78 +const ownershipChangeListeners: Set<ReactEventComponentInstance> = new Set();
79 +
80 +let currentTimers = new Map();
81 let currentOwner = null;
82 let currentInstance: ReactEventComponentInstance;
83 let currentEventQueue: EventQueue;
@@ -60,9 +85,8 @@ let currentEventQueue: EventQueue;
85 const eventResponderContext: ReactResponderContext = {
86 dispatchEvent(
87 possibleEventObject: Object,
63 - {capture, discrete, stopPropagation}: ReactResponderDispatchEventOptions,
88 + {capture, discrete}: ReactResponderDispatchEventOptions,
89 ): void {
65 - const eventQueue = currentEventQueue;
90 const {listener, target, type} = possibleEventObject;
91
92 if (listener == null || target == null || type == null) {
@@ -89,27 +113,15 @@ const eventResponderContext: ReactResponderContext = {
113 const eventObject = ((possibleEventObject: any): $Shape<
114 PartialEventObject,
115 >);
92 - let events;
93 -
94 - if (capture) {
95 - events = eventQueue.capture;
96 - if (events === null) {
97 - events = eventQueue.capture = [];
98 - }
99 - } else {
100 - events = eventQueue.bubble;
101 - if (events === null) {
102 - events = eventQueue.bubble = [];
103 - }
104 - }
116 + const events = getEventsFromEventQueue(capture);
117 if (discrete) {
106 - eventQueue.discrete = true;
118 + currentEventQueue.discrete = true;
119 }
120 events.push(eventObject);
109 -
110 - if (stopPropagation) {
111 - eventsWithStopPropagation.add(eventObject);
112 - }
121 + },
122 + dispatchStopPropagation(capture?: boolean) {
123 + const events = getEventsFromEventQueue();
124 + events.push({stopPropagation: true});
125 },
126 isPositionWithinTouchHitTarget(doc: Document, x: number, y: number): boolean {
127 // This isn't available in some environments (JSDOM)
@@ -222,21 +234,42 @@ const eventResponderContext: ReactResponderContext = {
234 triggerOwnershipListeners();
235 return false;
236 },
225 - setTimeout(func: () => void, delay): TimeoutID {
226 - const contextInstance = currentInstance;
227 - return setTimeout(() => {
228 - const previousEventQueue = currentEventQueue;
229 - const previousInstance = currentInstance;
230 - currentEventQueue = createEventQueue();
231 - currentInstance = contextInstance;
232 - try {
233 - func();
234 - batchedUpdates(processEventQueue, currentEventQueue);
235 - } finally {
236 - currentInstance = previousInstance;
237 - currentEventQueue = previousEventQueue;
237 + setTimeout(func: () => void, delay): Symbol {
238 + if (currentTimers === null) {
239 + currentTimers = new Map();
240 + }
241 + let timeout = currentTimers.get(delay);
242 +
243 + const timerId = Symbol();
244 + if (timeout === undefined) {
245 + const timers = new Map();
246 + const id = setTimeout(() => {
247 + processTimers(timers);
248 + }, delay);
249 + timeout = {
250 + id,
251 + timers,
252 + };
253 + currentTimers.set(delay, timeout);
254 + }
255 + timeout.timers.set(timerId, {
256 + instance: currentInstance,
257 + func,
258 + id: timerId,
259 + });
260 + activeTimeouts.set(timerId, timeout);
261 + return timerId;
262 + },
263 + clearTimeout(timerId: Symbol): void {
264 + const timeout = activeTimeouts.get(timerId);
265 +
266 + if (timeout !== undefined) {
267 + const timers = timeout.timers;
268 + timers.delete(timerId);
269 + if (timers.size === 0) {
270 + clearTimeout(timeout.id);
271 }
239 - }, delay);
272 + }
273 },
274 getEventTargetsFromTarget(
275 target: Element | Document,
@@ -292,6 +325,46 @@ const eventResponderContext: ReactResponderContext = {
325 },
326 };
327
328 +function getEventsFromEventQueue(capture?: boolean): Array<EventObjectTypes> {
329 + let events;
330 + if (capture) {
331 + events = currentEventQueue.capture;
332 + if (events === null) {
333 + events = currentEventQueue.capture = [];
334 + }
335 + } else {
336 + events = currentEventQueue.bubble;
337 + if (events === null) {
338 + events = currentEventQueue.bubble = [];
339 + }
340 + }
341 + return events;
342 +}
343 +
344 +function processTimers(timers: Map<Symbol, ResponderTimer>): void {
345 + const previousEventQueue = currentEventQueue;
346 + const previousInstance = currentInstance;
347 + currentEventQueue = createEventQueue();
348 +
349 + try {
350 + const timersArr = Array.from(timers.values());
351 + for (let i = 0; i < timersArr.length; i++) {
352 + const {instance, func, id} = timersArr[i];
353 + currentInstance = instance;
354 + try {
355 + func();
356 + } finally {
357 + activeTimeouts.delete(id);
358 + }
359 + }
360 + batchedUpdates(processEventQueue, currentEventQueue);
361 + } finally {
362 + currentInstance = previousInstance;
363 + currentEventQueue = previousEventQueue;
364 + currentTimers = null;
365 + }
366 +}
367 +
368 function queryEventTarget(
369 child: Fiber,
370 queryType: void | Symbol | number,
@@ -306,20 +379,6 @@ function queryEventTarget(
379 return true;
380 }
381
309 -const rootEventTypesToEventComponentInstances: Map<
310 - DOMTopLevelEventType | string,
311 - Set<ReactEventComponentInstance>,
312 -> = new Map();
313 -const PossiblyWeakSet = typeof WeakSet === 'function' ? WeakSet : Set;
314 -const eventsWithStopPropagation:
315 - | WeakSet
316 - | Set<$Shape<PartialEventObject>> = new PossiblyWeakSet();
317 -const targetEventTypeCached: Map<
318 - Array<ReactEventResponderEventType>,
319 - Set<DOMTopLevelEventType>,
320 -> = new Map();
321 -const ownershipChangeListeners: Set<ReactEventComponentInstance> = new Set();
322 -
382 function createResponderEvent(
383 topLevelType: string,
384 nativeEvent: AnyNativeEvent,
@@ -350,27 +409,27 @@ function processEvent(event: $Shape<PartialEventObject>): void {
409 }
410
411 function processEvents(
353 - bubble: null | Array<$Shape<PartialEventObject>>,
354 - capture: null | Array<$Shape<PartialEventObject>>,
412 + bubble: null | Array<EventObjectTypes>,
413 + capture: null | Array<EventObjectTypes>,
414 ): void {
415 let i, length;
416
417 if (capture !== null) {
418 for (i = capture.length; i-- > 0; ) {
419 const event = capture[i];
361 - processEvent(capture[i]);
362 - if (eventsWithStopPropagation.has(event)) {
420 + if (event.stopPropagation === true) {
421 return;
422 }
423 + processEvent(((event: any): $Shape<PartialEventObject>));
424 }
425 }
426 if (bubble !== null) {
427 for (i = 0, length = bubble.length; i < length; ++i) {
428 const event = bubble[i];
370 - processEvent(event);
371 - if (eventsWithStopPropagation.has(event)) {
429 + if (event.stopPropagation === true) {
430 return;
431 }
432 + processEvent(((event: any): $Shape<PartialEventObject>));
433 }
434 }
435 }
@@ -475,6 +534,7 @@ export function runResponderEventsInBatch(
534 }
535 }
536 processEventQueue();
537 + currentTimers = null;
538 }
539 }
540
@@ -518,6 +578,7 @@ export function unmountEventResponder(
578 } finally {
579 currentEventQueue = previousEventQueue;
580 currentInstance = previousInstance;
581 + currentTimers = null;
582 }
583 }
584 if (currentOwner === eventComponentInstance) {
packages/react-events/src/Hover.js
+4 -4
@@ -27,8 +27,8 @@ type HoverState = {
27 isHovered: boolean,
28 isInHitSlop: boolean,
29 isTouched: boolean,
30 - hoverStartTimeout: null | TimeoutID,
31 - hoverEndTimeout: null | TimeoutID,
30 + hoverStartTimeout: null | Symbol,
31 + hoverEndTimeout: null | Symbol,
32 };
33
34 type HoverEventType = 'hoverstart' | 'hoverend' | 'hoverchange';
@@ -97,7 +97,7 @@ function dispatchHoverStartEvents(
97 state.isHovered = true;
98
99 if (state.hoverEndTimeout !== null) {
100 - clearTimeout(state.hoverEndTimeout);
100 + context.clearTimeout(state.hoverEndTimeout);
101 state.hoverEndTimeout = null;
102 }
103
@@ -148,7 +148,7 @@ function dispatchHoverEndEvents(
148 state.isHovered = false;
149
150 if (state.hoverStartTimeout !== null) {
151 - clearTimeout(state.hoverStartTimeout);
151 + context.clearTimeout(state.hoverStartTimeout);
152 state.hoverStartTimeout = null;
153 }
154
packages/react-events/src/Press.js
+29 -7
@@ -33,19 +33,21 @@ type PressProps = {
33 left: number,
34 },
35 preventDefault: boolean,
36 + stopPropagation: boolean,
37 };
38
39 type PressState = {
40 + didDispatchEvent: boolean,
41 isActivePressed: boolean,
42 isActivePressStart: boolean,
43 isAnchorTouched: boolean,
44 isLongPressed: boolean,
45 isPressed: boolean,
46 isPressWithinResponderRegion: boolean,
45 - longPressTimeout: null | TimeoutID,
47 + longPressTimeout: null | Symbol,
48 pressTarget: null | Element | Document,
47 - pressEndTimeout: null | TimeoutID,
48 - pressStartTimeout: null | TimeoutID,
49 + pressEndTimeout: null | Symbol,
50 + pressStartTimeout: null | Symbol,
51 responderRegion: null | $ReadOnly<{|
52 bottom: number,
53 left: number,
@@ -124,7 +126,10 @@ function dispatchEvent(
126 ): void {
127 const target = ((state.pressTarget: any): Element | Document);
128 const syntheticEvent = createPressEvent(name, target, listener);
127 - context.dispatchEvent(syntheticEvent, {discrete: true});
129 + context.dispatchEvent(syntheticEvent, {
130 + discrete: true,
131 + });
132 + state.didDispatchEvent = true;
133 }
134
135 function dispatchPressChangeEvent(
@@ -185,7 +190,7 @@ function dispatchPressStartEvents(
190 state.isPressed = true;
191
192 if (state.pressEndTimeout !== null) {
188 - clearTimeout(state.pressEndTimeout);
193 + context.clearTimeout(state.pressEndTimeout);
194 state.pressEndTimeout = null;
195 }
196
@@ -211,6 +216,14 @@ function dispatchPressStartEvents(
216 if (props.onLongPressChange) {
217 dispatchLongPressChangeEvent(context, props, state);
218 }
219 + if (state.didDispatchEvent) {
220 + const shouldStopPropagation =
221 + props.stopPropagation === undefined ? true : props.stopPropagation;
222 + if (shouldStopPropagation) {
223 + context.dispatchStopPropagation();
224 + }
225 + state.didDispatchEvent = false;
226 + }
227 }, delayLongPress);
228 }
229 };
@@ -243,12 +256,12 @@ function dispatchPressEndEvents(
256 state.isPressed = false;
257
258 if (state.longPressTimeout !== null) {
246 - clearTimeout(state.longPressTimeout);
259 + context.clearTimeout(state.longPressTimeout);
260 state.longPressTimeout = null;
261 }
262
263 if (!wasActivePressStart && state.pressStartTimeout !== null) {
251 - clearTimeout(state.pressStartTimeout);
264 + context.clearTimeout(state.pressStartTimeout);
265 state.pressStartTimeout = null;
266 // don't activate if a press has moved beyond the responder region
267 if (state.isPressWithinResponderRegion) {
@@ -356,6 +369,7 @@ const PressResponder = {
369 targetEventTypes,
370 createInitialState(): PressState {
371 return {
372 + didDispatchEvent: false,
373 isActivePressed: false,
374 isActivePressStart: false,
375 isAnchorTouched: false,
@@ -602,6 +616,14 @@ const PressResponder = {
616 }
617 }
618 }
619 + if (state.didDispatchEvent) {
620 + const shouldStopPropagation =
621 + props.stopPropagation === undefined ? true : props.stopPropagation;
622 + if (shouldStopPropagation) {
623 + context.dispatchStopPropagation();
624 + }
625 + state.didDispatchEvent = false;
626 + }
627 },
628 onUnmount(
629 context: ReactResponderContext,
packages/react-events/src/__tests__/Press-test.internal.js
+96 -1
@@ -381,6 +381,38 @@ describe('Event responder: Press', () => {
381 expect(onPressChange).toHaveBeenCalledTimes(2);
382 expect(onPressChange).toHaveBeenCalledWith(false);
383 });
384 +
385 + it('is called but does not bubble', () => {
386 + const element = (
387 + <Press onPressChange={onPressChange}>
388 + <Press onPressChange={onPressChange}>
389 + <div ref={ref} />
390 + </Press>
391 + </Press>
392 + );
393 + ReactDOM.render(element, container);
394 +
395 + ref.current.dispatchEvent(createPointerEvent('pointerdown'));
396 + expect(onPressChange).toHaveBeenCalledTimes(1);
397 + ref.current.dispatchEvent(createPointerEvent('pointerup'));
398 + expect(onPressChange).toHaveBeenCalledTimes(2);
399 + });
400 +
401 + it('is called and bubbles correctly with stopPropagation set to false', () => {
402 + const element = (
403 + <Press onPressChange={onPressChange}>
404 + <Press onPressChange={onPressChange} stopPropagation={false}>
405 + <div ref={ref} />
406 + </Press>
407 + </Press>
408 + );
409 + ReactDOM.render(element, container);
410 +
411 + ref.current.dispatchEvent(createPointerEvent('pointerdown'));
412 + expect(onPressChange).toHaveBeenCalledTimes(2);
413 + ref.current.dispatchEvent(createPointerEvent('pointerup'));
414 + expect(onPressChange).toHaveBeenCalledTimes(4);
415 + });
416 });
417
418 describe('onPress', () => {
@@ -429,6 +461,36 @@ describe('Event responder: Press', () => {
461 // ref.current.dispatchEvent(createPointerEvent('touchend'));
462 // expect(onPress).toHaveBeenCalledTimes(1);
463 // });
464 +
465 + it('is called but does not bubble', () => {
466 + const element = (
467 + <Press onPress={onPress}>
468 + <Press onPress={onPress}>
469 + <div ref={ref} />
470 + </Press>
471 + </Press>
472 + );
473 + ReactDOM.render(element, container);
474 +
475 + ref.current.dispatchEvent(createPointerEvent('pointerdown'));
476 + ref.current.dispatchEvent(createPointerEvent('pointerup'));
477 + expect(onPress).toHaveBeenCalledTimes(1);
478 + });
479 +
480 + it('is called and bubbles correctly with stopPropagation set to false', () => {
481 + const element = (
482 + <Press onPress={onPress}>
483 + <Press onPress={onPress} stopPropagation={false}>
484 + <div ref={ref} />
485 + </Press>
486 + </Press>
487 + );
488 + ReactDOM.render(element, container);
489 +
490 + ref.current.dispatchEvent(createPointerEvent('pointerdown'));
491 + ref.current.dispatchEvent(createPointerEvent('pointerup'));
492 + expect(onPress).toHaveBeenCalledTimes(2);
493 + });
494 });
495
496 describe('onLongPress', () => {
@@ -477,6 +539,38 @@ describe('Event responder: Press', () => {
539 expect(onLongPress).not.toBeCalled();
540 });
541
542 + it('is called but does not bubble', () => {
543 + const element = (
544 + <Press onLongPress={onLongPress}>
545 + <Press onLongPress={onLongPress}>
546 + <div ref={ref} />
547 + </Press>
548 + </Press>
549 + );
550 + ReactDOM.render(element, container);
551 +
552 + ref.current.dispatchEvent(createPointerEvent('pointerdown'));
553 + jest.advanceTimersByTime(DEFAULT_LONG_PRESS_DELAY);
554 + ref.current.dispatchEvent(createPointerEvent('pointerup'));
555 + expect(onLongPress).toHaveBeenCalledTimes(1);
556 + });
557 +
558 + it('is called and bubbles correctly with stopPropagation set to false', () => {
559 + const element = (
560 + <Press onLongPress={onLongPress}>
561 + <Press onLongPress={onLongPress} stopPropagation={false}>
562 + <div ref={ref} />
563 + </Press>
564 + </Press>
565 + );
566 + ReactDOM.render(element, container);
567 +
568 + ref.current.dispatchEvent(createPointerEvent('pointerdown'));
569 + jest.advanceTimersByTime(DEFAULT_LONG_PRESS_DELAY);
570 + ref.current.dispatchEvent(createPointerEvent('pointerup'));
571 + expect(onLongPress).toHaveBeenCalledTimes(2);
572 + });
573 +
574 describe('delayLongPress', () => {
575 it('can be configured', () => {
576 const element = (
@@ -914,7 +1008,8 @@ describe('Event responder: Press', () => {
1008 onPress={createEventHandler('inner: onPress')}
1009 onPressChange={createEventHandler('inner: onPressChange')}
1010 onPressStart={createEventHandler('inner: onPressStart')}
917 - onPressEnd={createEventHandler('inner: onPressEnd')}>
1011 + onPressEnd={createEventHandler('inner: onPressEnd')}
1012 + stopPropagation={false}>
1013 <div
1014 ref={ref}
1015 onPointerDown={createEventHandler('pointerdown')}
packages/shared/ReactTypes.js
+3 -2
@@ -140,7 +140,6 @@ export type ReactResponderEvent = {
140 export type ReactResponderDispatchEventOptions = {
141 capture?: boolean,
142 discrete?: boolean,
143 - stopPropagation?: boolean,
143 };
144
145 export type ReactResponderContext = {
@@ -148,6 +147,7 @@ export type ReactResponderContext = {
147 eventObject: Object,
148 otpions: ReactResponderDispatchEventOptions,
149 ) => void,
150 + dispatchStopPropagation: (passive?: boolean) => void,
151 isTargetWithinElement: (
152 childTarget: Element | Document,
153 parentTarget: Element | Document,
@@ -168,7 +168,8 @@ export type ReactResponderContext = {
168 hasOwnership: () => boolean,
169 requestOwnership: () => boolean,
170 releaseOwnership: () => boolean,
171 - setTimeout: (func: () => void, timeout: number) => TimeoutID,
171 + setTimeout: (func: () => void, timeout: number) => Symbol,
172 + clearTimeout: (timerId: Symbol) => void,
173 getEventTargetsFromTarget: (
174 target: Element | Document,
175 queryType?: Symbol | number,