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,