Event propagation test suite (#19483)
Dan Abramov committed
Jul 29, 2020 at 15:34 UTC
0eea16601cab75dbb698d67220bce5674a13e38a
1 file changed
+1327
packages/react-dom/src/__tests__/ReactDOMEventPropagation-test.js
new
+1327
@@ -0,0 +1,1327 @@
1
+/**
2
+ * Copyright (c) Facebook, Inc. and its affiliates.
3
+ *
4
+ * This source code is licensed under the MIT license found in the
5
+ * LICENSE file in the root directory of this source tree.
6
+ */
7
+
8
+'use strict';
9
+
10
+describe('ReactDOMEventListener', () => {
11
+ let React;
12
+ let OuterReactDOM;
13
+ let InnerReactDOM;
14
+ let container;
15
+
16
+ beforeEach(() => {
17
+ jest.resetModules();
18
+ React = require('react');
19
+ jest.isolateModules(() => {
20
+ OuterReactDOM = require('react-dom');
21
+ });
22
+ jest.isolateModules(() => {
23
+ InnerReactDOM = require('react-dom');
24
+ });
25
+ expect(OuterReactDOM).not.toBe(InnerReactDOM);
26
+ });
27
+
28
+ afterEach(() => {
29
+ cleanup();
30
+ });
31
+
32
+ function cleanup() {
33
+ if (container) {
34
+ OuterReactDOM.unmountComponentAtNode(container);
35
+ document.body.removeChild(container);
36
+ container = null;
37
+ }
38
+ }
39
+
40
+ function render(tree) {
41
+ cleanup();
42
+ container = document.createElement('div');
43
+ document.body.appendChild(container);
44
+ OuterReactDOM.render(tree, container);
45
+ }
46
+
47
+ describe('bubbling events', () => {
48
+ // This test will fail in legacy mode (only used in WWW)
49
+ // because we emulate the React 16 behavior where
50
+ // the click handler is attached to the document.
51
+ // @gate !enableLegacyFBSupport
52
+ it('onClick', () => {
53
+ testNativeBubblingEvent({
54
+ type: 'div',
55
+ reactEvent: 'onClick',
56
+ nativeEvent: 'click',
57
+ dispatch(node) {
58
+ node.click();
59
+ },
60
+ });
61
+ });
62
+
63
+ it('onKeyPress', () => {
64
+ testNativeBubblingEvent({
65
+ type: 'input',
66
+ reactEvent: 'onKeyPress',
67
+ nativeEvent: 'keypress',
68
+ dispatch(node) {
69
+ node.dispatchEvent(
70
+ new KeyboardEvent('keypress', {
71
+ keyCode: 13,
72
+ bubbles: true,
73
+ cancelable: true,
74
+ }),
75
+ );
76
+ },
77
+ });
78
+ });
79
+
80
+ it('onMouseDown', () => {
81
+ testNativeBubblingEvent({
82
+ type: 'button',
83
+ reactEvent: 'onMouseDown',
84
+ nativeEvent: 'mousedown',
85
+ dispatch(node) {
86
+ node.dispatchEvent(
87
+ new MouseEvent('mousedown', {
88
+ bubbles: true,
89
+ cancelable: true,
90
+ }),
91
+ );
92
+ },
93
+ });
94
+ });
95
+
96
+ it('onTouchStart', () => {
97
+ testNativeBubblingEvent({
98
+ type: 'div',
99
+ reactEvent: 'onTouchStart',
100
+ nativeEvent: 'touchstart',
101
+ dispatch(node) {
102
+ node.dispatchEvent(
103
+ new Event('touchstart', {
104
+ bubbles: true,
105
+ cancelable: true,
106
+ }),
107
+ );
108
+ },
109
+ });
110
+ });
111
+
112
+ it('onWheel', () => {
113
+ testNativeBubblingEvent({
114
+ type: 'div',
115
+ reactEvent: 'onWheel',
116
+ nativeEvent: 'wheel',
117
+ dispatch(node) {
118
+ node.dispatchEvent(
119
+ new Event('wheel', {
120
+ bubbles: true,
121
+ cancelable: true,
122
+ }),
123
+ );
124
+ },
125
+ });
126
+ });
127
+
128
+ it('onSubmit', () => {
129
+ testNativeBubblingEvent({
130
+ type: 'form',
131
+ reactEvent: 'onSubmit',
132
+ nativeEvent: 'submit',
133
+ dispatch(node) {
134
+ const e = new Event('submit', {
135
+ bubbles: true,
136
+ cancelable: true,
137
+ });
138
+ node.dispatchEvent(e);
139
+ },
140
+ });
141
+ });
142
+
143
+ it('onReset', () => {
144
+ testNativeBubblingEvent({
145
+ type: 'form',
146
+ reactEvent: 'onReset',
147
+ nativeEvent: 'reset',
148
+ dispatch(node) {
149
+ const e = new Event('reset', {
150
+ bubbles: true,
151
+ cancelable: true,
152
+ });
153
+ node.dispatchEvent(e);
154
+ },
155
+ });
156
+ });
157
+
158
+ it('onFocus', () => {
159
+ testNativeBubblingEvent({
160
+ type: 'input',
161
+ reactEvent: 'onFocus',
162
+ nativeEvent: 'focusin',
163
+ dispatch(node) {
164
+ const e = new Event('focusin', {
165
+ bubbles: true,
166
+ cancelable: true,
167
+ });
168
+ node.dispatchEvent(e);
169
+ },
170
+ });
171
+ });
172
+
173
+ it('onBlur', () => {
174
+ testNativeBubblingEvent({
175
+ type: 'input',
176
+ reactEvent: 'onBlur',
177
+ nativeEvent: 'focusout',
178
+ dispatch(node) {
179
+ const e = new Event('focusout', {
180
+ bubbles: true,
181
+ cancelable: true,
182
+ });
183
+ node.dispatchEvent(e);
184
+ },
185
+ });
186
+ });
187
+ });
188
+
189
+ describe('non-bubbling events that bubble in React', () => {
190
+ it('onInvalid', () => {
191
+ testEmulatedBubblingEvent({
192
+ type: 'input',
193
+ reactEvent: 'onInvalid',
194
+ nativeEvent: 'invalid',
195
+ dispatch(node) {
196
+ const e = new Event('invalid', {
197
+ bubbles: false,
198
+ cancelable: true,
199
+ });
200
+ node.dispatchEvent(e);
201
+ },
202
+ });
203
+ });
204
+
205
+ it('onLoad', () => {
206
+ testEmulatedBubblingEvent({
207
+ type: 'img',
208
+ reactEvent: 'onLoad',
209
+ nativeEvent: 'load',
210
+ dispatch(node) {
211
+ const e = new Event('load', {
212
+ bubbles: false,
213
+ cancelable: true,
214
+ });
215
+ node.dispatchEvent(e);
216
+ },
217
+ });
218
+ });
219
+
220
+ it('onError', () => {
221
+ testEmulatedBubblingEvent({
222
+ type: 'img',
223
+ reactEvent: 'onError',
224
+ nativeEvent: 'error',
225
+ dispatch(node) {
226
+ const e = new Event('error', {
227
+ bubbles: false,
228
+ cancelable: true,
229
+ });
230
+ node.dispatchEvent(e);
231
+ },
232
+ });
233
+ });
234
+
235
+ it('onClose', () => {
236
+ testEmulatedBubblingEvent({
237
+ type: 'dialog',
238
+ reactEvent: 'onClose',
239
+ nativeEvent: 'close',
240
+ dispatch(node) {
241
+ const e = new Event('close', {
242
+ bubbles: false,
243
+ cancelable: true,
244
+ });
245
+ node.dispatchEvent(e);
246
+ },
247
+ });
248
+ });
249
+
250
+ it('onCancel', () => {
251
+ testEmulatedBubblingEvent({
252
+ type: 'dialog',
253
+ reactEvent: 'onCancel',
254
+ nativeEvent: 'cancel',
255
+ dispatch(node) {
256
+ const e = new Event('cancel', {
257
+ bubbles: false,
258
+ cancelable: true,
259
+ });
260
+ node.dispatchEvent(e);
261
+ },
262
+ });
263
+ });
264
+
265
+ it('onPlay', () => {
266
+ testEmulatedBubblingEvent({
267
+ type: 'video',
268
+ reactEvent: 'onPlay',
269
+ nativeEvent: 'play',
270
+ dispatch(node) {
271
+ const e = new Event('play', {
272
+ bubbles: false,
273
+ cancelable: true,
274
+ });
275
+ node.dispatchEvent(e);
276
+ },
277
+ });
278
+ });
279
+ });
280
+
281
+ describe('non-bubbling events that do not bubble in React', () => {
282
+ // This test will fail outside of the no-bubbling flag
283
+ // because its bubbling emulation is currently broken.
284
+ // In particular, if the target itself doesn't have
285
+ // a handler, it will not emulate bubbling correctly.
286
+ // Instead of fixing this, we'll just turn this flag on.
287
+ // @gate disableOnScrollBubbling
288
+ it('onScroll', () => {
289
+ testNonBubblingEvent({
290
+ type: 'div',
291
+ reactEvent: 'onScroll',
292
+ nativeEvent: 'scroll',
293
+ dispatch(node) {
294
+ const e = new Event('scroll', {
295
+ bubbles: false,
296
+ cancelable: true,
297
+ });
298
+ node.dispatchEvent(e);
299
+ },
300
+ });
301
+ });
302
+ });
303
+
304
+ // Events that bubble in React and in the browser.
305
+ // React delegates them to the root.
306
+ function testNativeBubblingEvent(config) {
307
+ testNativeBubblingEventWithTargetListener(config);
308
+ testNativeBubblingEventWithoutTargetListener(config);
309
+ testReactStopPropagationInOuterCapturePhase(config);
310
+ testReactStopPropagationInInnerCapturePhase(config);
311
+ testReactStopPropagationInInnerBubblePhase(config);
312
+ testReactStopPropagationInOuterBubblePhase(config);
313
+ testNativeStopPropagationInOuterCapturePhase(config);
314
+ testNativeStopPropagationInInnerCapturePhase(config);
315
+ testNativeStopPropagationInInnerBubblePhase(config);
316
+ testNativeStopPropagationInOuterBubblePhase(config);
317
+ }
318
+
319
+ // Events that bubble in React but not in the browser.
320
+ // React attaches them to the elements.
321
+ function testEmulatedBubblingEvent(config) {
322
+ testEmulatedBubblingEventWithTargetListener(config);
323
+ testEmulatedBubblingEventWithoutTargetListener(config);
324
+ testReactStopPropagationInOuterCapturePhase(config);
325
+ testReactStopPropagationInInnerCapturePhase(config);
326
+ testReactStopPropagationInInnerBubblePhase(config);
327
+ testNativeStopPropagationInOuterCapturePhase(config);
328
+ testNativeStopPropagationInInnerCapturePhase(config);
329
+ testNativeStopPropagationInInnerEmulatedBubblePhase(config);
330
+ }
331
+
332
+ // Events that don't bubble either in React or in the browser.
333
+ function testNonBubblingEvent(config) {
334
+ testNonBubblingEventWithTargetListener(config);
335
+ testNonBubblingEventWithoutTargetListener(config);
336
+ testReactStopPropagationInOuterCapturePhase(config);
337
+ testReactStopPropagationInInnerCapturePhase(config);
338
+ testReactStopPropagationInInnerBubblePhase(config);
339
+ testNativeStopPropagationInOuterCapturePhase(config);
340
+ testNativeStopPropagationInInnerCapturePhase(config);
341
+ }
342
+
343
+ function testNativeBubblingEventWithTargetListener(eventConfig) {
344
+ const log = [];
345
+ const targetRef = React.createRef();
346
+ render(
347
+ <Fixture
348
+ type={eventConfig.type}
349
+ targetRef={targetRef}
350
+ targetProps={{
351
+ [eventConfig.reactEvent]: e => {
352
+ log.push('---- inner');
353
+ },
354
+ [eventConfig.reactEvent + 'Capture']: e => {
355
+ log.push('---- inner capture');
356
+ },
357
+ }}
358
+ parentProps={{
359
+ [eventConfig.reactEvent]: e => {
360
+ log.push('--- inner parent');
361
+ },
362
+ [eventConfig.reactEvent + 'Capture']: e => {
363
+ log.push('--- inner parent capture');
364
+ },
365
+ }}
366
+ outerProps={{
367
+ [eventConfig.reactEvent]: e => {
368
+ log.push('-- outer');
369
+ },
370
+ [eventConfig.reactEvent + 'Capture']: e => {
371
+ log.push('-- outer capture');
372
+ },
373
+ }}
374
+ outerParentProps={{
375
+ [eventConfig.reactEvent]: e => {
376
+ log.push('- outer parent');
377
+ },
378
+ [eventConfig.reactEvent + 'Capture']: e => {
379
+ log.push('- outer parent capture');
380
+ },
381
+ }}
382
+ />,
383
+ );
384
+ expect(log.length).toBe(0);
385
+ eventConfig.dispatch(targetRef.current);
386
+ // Should print all listeners.
387
+ expect(log).toEqual(unindent`
388
+ - outer parent capture
389
+ -- outer capture
390
+ --- inner parent capture
391
+ ---- inner capture
392
+ ---- inner
393
+ --- inner parent
394
+ -- outer
395
+ - outer parent
396
+ `);
397
+ }
398
+
399
+ function testEmulatedBubblingEventWithTargetListener(eventConfig) {
400
+ const log = [];
401
+ const targetRef = React.createRef();
402
+ render(
403
+ <Fixture
404
+ type={eventConfig.type}
405
+ targetRef={targetRef}
406
+ targetProps={{
407
+ [eventConfig.reactEvent]: e => {
408
+ log.push('---- inner');
409
+ },
410
+ [eventConfig.reactEvent + 'Capture']: e => {
411
+ log.push('---- inner capture');
412
+ },
413
+ }}
414
+ parentProps={{
415
+ [eventConfig.reactEvent]: e => {
416
+ log.push('--- inner parent');
417
+ },
418
+ [eventConfig.reactEvent + 'Capture']: e => {
419
+ log.push('--- inner parent capture');
420
+ },
421
+ }}
422
+ outerProps={{
423
+ [eventConfig.reactEvent]: e => {
424
+ log.push('-- outer');
425
+ },
426
+ [eventConfig.reactEvent + 'Capture']: e => {
427
+ log.push('-- outer capture');
428
+ },
429
+ }}
430
+ outerParentProps={{
431
+ [eventConfig.reactEvent]: e => {
432
+ log.push('- outer parent');
433
+ },
434
+ [eventConfig.reactEvent + 'Capture']: e => {
435
+ log.push('- outer parent capture');
436
+ },
437
+ }}
438
+ />,
439
+ );
440
+ expect(log.length).toBe(0);
441
+ eventConfig.dispatch(targetRef.current);
442
+ // This event doesn't bubble natively, but React emulates it.
443
+ // Since the element is created by the inner React, the bubbling
444
+ // stops at the inner parent and never reaches the outer React.
445
+ // In the future, we might consider not bubbling these events
446
+ // at all, in in which case inner parent also wouldn't be logged.
447
+ expect(log).toEqual(unindent`
448
+ - outer parent capture
449
+ -- outer capture
450
+ --- inner parent capture
451
+ ---- inner capture
452
+ ---- inner
453
+ --- inner parent
454
+ `);
455
+ }
456
+
457
+ function testNonBubblingEventWithTargetListener(eventConfig) {
458
+ const log = [];
459
+ const targetRef = React.createRef();
460
+ render(
461
+ <Fixture
462
+ type={eventConfig.type}
463
+ targetRef={targetRef}
464
+ targetProps={{
465
+ [eventConfig.reactEvent]: e => {
466
+ log.push('---- inner');
467
+ },
468
+ [eventConfig.reactEvent + 'Capture']: e => {
469
+ log.push('---- inner capture');
470
+ },
471
+ }}
472
+ parentProps={{
473
+ [eventConfig.reactEvent]: e => {
474
+ log.push('--- inner parent');
475
+ },
476
+ [eventConfig.reactEvent + 'Capture']: e => {
477
+ log.push('--- inner parent capture');
478
+ },
479
+ }}
480
+ outerProps={{
481
+ [eventConfig.reactEvent]: e => {
482
+ log.push('-- outer');
483
+ },
484
+ [eventConfig.reactEvent + 'Capture']: e => {
485
+ log.push('-- outer capture');
486
+ },
487
+ }}
488
+ outerParentProps={{
489
+ [eventConfig.reactEvent]: e => {
490
+ log.push('- outer parent');
491
+ },
492
+ [eventConfig.reactEvent + 'Capture']: e => {
493
+ log.push('- outer parent capture');
494
+ },
495
+ }}
496
+ />,
497
+ );
498
+ expect(log.length).toBe(0);
499
+ eventConfig.dispatch(targetRef.current);
500
+ // This event doesn't bubble natively, and React is
501
+ // not emulating it either. So it only reaches the
502
+ // target and stops there.
503
+ expect(log).toEqual(unindent`
504
+ - outer parent capture
505
+ -- outer capture
506
+ --- inner parent capture
507
+ ---- inner capture
508
+ ---- inner
509
+ `);
510
+ }
511
+
512
+ function testNativeBubblingEventWithoutTargetListener(eventConfig) {
513
+ const log = [];
514
+ const targetRef = React.createRef();
515
+ render(
516
+ <Fixture
517
+ type={eventConfig.type}
518
+ targetRef={targetRef}
519
+ targetProps={
520
+ {
521
+ // No listener on the target itself.
522
+ }
523
+ }
524
+ parentProps={{
525
+ [eventConfig.reactEvent]: e => {
526
+ log.push('--- inner parent');
527
+ },
528
+ [eventConfig.reactEvent + 'Capture']: e => {
529
+ log.push('--- inner parent capture');
530
+ },
531
+ }}
532
+ outerProps={{
533
+ [eventConfig.reactEvent]: e => {
534
+ log.push('-- outer');
535
+ },
536
+ [eventConfig.reactEvent + 'Capture']: e => {
537
+ log.push('-- outer capture');
538
+ },
539
+ }}
540
+ outerParentProps={{
541
+ [eventConfig.reactEvent]: e => {
542
+ log.push('- outer parent');
543
+ },
544
+ [eventConfig.reactEvent + 'Capture']: e => {
545
+ log.push('- outer parent capture');
546
+ },
547
+ }}
548
+ />,
549
+ );
550
+ expect(log.length).toBe(0);
551
+ eventConfig.dispatch(targetRef.current);
552
+ // Should print all listeners except the innermost one.
553
+ expect(log).toEqual(unindent`
554
+ - outer parent capture
555
+ -- outer capture
556
+ --- inner parent capture
557
+ --- inner parent
558
+ -- outer
559
+ - outer parent
560
+ `);
561
+ }
562
+
563
+ function testEmulatedBubblingEventWithoutTargetListener(eventConfig) {
564
+ const log = [];
565
+ const targetRef = React.createRef();
566
+ render(
567
+ <Fixture
568
+ type={eventConfig.type}
569
+ targetRef={targetRef}
570
+ targetProps={
571
+ {
572
+ // No listener on the target itself.
573
+ }
574
+ }
575
+ parentProps={{
576
+ [eventConfig.reactEvent]: e => {
577
+ log.push('--- inner parent');
578
+ },
579
+ [eventConfig.reactEvent + 'Capture']: e => {
580
+ log.push('--- inner parent capture');
581
+ },
582
+ }}
583
+ outerProps={{
584
+ [eventConfig.reactEvent]: e => {
585
+ log.push('-- outer');
586
+ },
587
+ [eventConfig.reactEvent + 'Capture']: e => {
588
+ log.push('-- outer capture');
589
+ },
590
+ }}
591
+ outerParentProps={{
592
+ [eventConfig.reactEvent]: e => {
593
+ log.push('- outer parent');
594
+ },
595
+ [eventConfig.reactEvent + 'Capture']: e => {
596
+ log.push('- outer parent capture');
597
+ },
598
+ }}
599
+ />,
600
+ );
601
+ expect(log.length).toBe(0);
602
+ eventConfig.dispatch(targetRef.current);
603
+ // This event doesn't bubble natively, but React emulates it.
604
+ // Since the element is created by the inner React, the bubbling
605
+ // stops at the inner parent and never reaches the outer React.
606
+ // In the future, we might consider not bubbling these events
607
+ // at all, in in which case inner parent also wouldn't be logged.
608
+ expect(log).toEqual(unindent`
609
+ - outer parent capture
610
+ -- outer capture
611
+ --- inner parent capture
612
+ --- inner parent
613
+ `);
614
+ }
615
+
616
+ function testNonBubblingEventWithoutTargetListener(eventConfig) {
617
+ const log = [];
618
+ const targetRef = React.createRef();
619
+ render(
620
+ <Fixture
621
+ type={eventConfig.type}
622
+ targetRef={targetRef}
623
+ targetProps={
624
+ {
625
+ // No listener on the target itself.
626
+ }
627
+ }
628
+ parentProps={{
629
+ [eventConfig.reactEvent]: e => {
630
+ log.push('--- inner parent');
631
+ },
632
+ [eventConfig.reactEvent + 'Capture']: e => {
633
+ log.push('--- inner parent capture');
634
+ },
635
+ }}
636
+ outerProps={{
637
+ [eventConfig.reactEvent]: e => {
638
+ log.push('-- outer');
639
+ },
640
+ [eventConfig.reactEvent + 'Capture']: e => {
641
+ log.push('-- outer capture');
642
+ },
643
+ }}
644
+ outerParentProps={{
645
+ [eventConfig.reactEvent]: e => {
646
+ log.push('- outer parent');
647
+ },
648
+ [eventConfig.reactEvent + 'Capture']: e => {
649
+ log.push('- outer parent capture');
650
+ },
651
+ }}
652
+ />,
653
+ );
654
+ expect(log.length).toBe(0);
655
+ eventConfig.dispatch(targetRef.current);
656
+ // This event doesn't bubble native, and React doesn't
657
+ // emulate bubbling either. Since we don't have a target
658
+ // listener, only capture phase listeners fire.
659
+ expect(log).toEqual(unindent`
660
+ - outer parent capture
661
+ -- outer capture
662
+ --- inner parent capture
663
+ `);
664
+ }
665
+
666
+ function testReactStopPropagationInOuterCapturePhase(eventConfig) {
667
+ const log = [];
668
+ const targetRef = React.createRef();
669
+ render(
670
+ <Fixture
671
+ type={eventConfig.type}
672
+ targetRef={node => {
673
+ targetRef.current = node;
674
+ if (node) {
675
+ // No cleanup, assume we render once.
676
+ node.addEventListener(eventConfig.nativeEvent, e => {
677
+ // We *don't* expect this to appear in the log
678
+ // at all because the event is stopped earlier.
679
+ log.push('---- inner (native)');
680
+ });
681
+ }
682
+ }}
683
+ targetProps={{
684
+ [eventConfig.reactEvent]: e => {
685
+ log.push('---- inner');
686
+ },
687
+ [eventConfig.reactEvent + 'Capture']: e => {
688
+ log.push('---- inner capture');
689
+ },
690
+ }}
691
+ parentProps={{
692
+ [eventConfig.reactEvent]: e => {
693
+ log.push('--- inner parent');
694
+ },
695
+ [eventConfig.reactEvent + 'Capture']: e => {
696
+ log.push('--- inner parent capture');
697
+ },
698
+ }}
699
+ outerProps={{
700
+ [eventConfig.reactEvent]: e => {
701
+ log.push('-- outer');
702
+ },
703
+ [eventConfig.reactEvent + 'Capture']: e => {
704
+ e.stopPropagation(); // <---------
705
+ log.push('-- outer capture');
706
+ },
707
+ }}
708
+ outerParentProps={{
709
+ [eventConfig.reactEvent]: e => {
710
+ log.push('- outer parent');
711
+ },
712
+ [eventConfig.reactEvent + 'Capture']: e => {
713
+ log.push('- outer parent capture');
714
+ },
715
+ }}
716
+ />,
717
+ );
718
+ expect(log.length).toBe(0);
719
+ eventConfig.dispatch(targetRef.current);
720
+ // Should stop at the outer capture.
721
+ // We don't get to the inner root at all.
722
+ expect(log).toEqual(unindent`
723
+ - outer parent capture
724
+ -- outer capture
725
+ `);
726
+ }
727
+
728
+ function testReactStopPropagationInInnerCapturePhase(eventConfig) {
729
+ const log = [];
730
+ const targetRef = React.createRef();
731
+ render(
732
+ <Fixture
733
+ type={eventConfig.type}
734
+ targetRef={node => {
735
+ targetRef.current = node;
736
+ if (node) {
737
+ // No cleanup, assume we render once.
738
+ node.addEventListener(eventConfig.nativeEvent, e => {
739
+ // We *don't* expect this to appear in the log
740
+ // at all because the event is stopped earlier.
741
+ log.push('---- inner (native)');
742
+ });
743
+ }
744
+ }}
745
+ targetProps={{
746
+ [eventConfig.reactEvent]: e => {
747
+ log.push('---- inner');
748
+ },
749
+ [eventConfig.reactEvent + 'Capture']: e => {
750
+ log.push('---- inner capture');
751
+ },
752
+ }}
753
+ parentProps={{
754
+ [eventConfig.reactEvent]: e => {
755
+ log.push('--- inner parent');
756
+ },
757
+ [eventConfig.reactEvent + 'Capture']: e => {
758
+ e.stopPropagation(); // <---------
759
+ log.push('--- inner parent capture');
760
+ },
761
+ }}
762
+ outerProps={{
763
+ [eventConfig.reactEvent]: e => {
764
+ log.push('-- outer');
765
+ },
766
+ [eventConfig.reactEvent + 'Capture']: e => {
767
+ log.push('-- outer capture');
768
+ },
769
+ }}
770
+ outerParentProps={{
771
+ [eventConfig.reactEvent]: e => {
772
+ log.push('- outer parent');
773
+ },
774
+ [eventConfig.reactEvent + 'Capture']: e => {
775
+ log.push('- outer parent capture');
776
+ },
777
+ }}
778
+ />,
779
+ );
780
+ expect(log.length).toBe(0);
781
+ eventConfig.dispatch(targetRef.current);
782
+ // We get to the inner root, but we don't
783
+ // get to the target and we don't bubble.
784
+ expect(log).toEqual(unindent`
785
+ - outer parent capture
786
+ -- outer capture
787
+ --- inner parent capture
788
+ `);
789
+ }
790
+
791
+ function testReactStopPropagationInInnerBubblePhase(eventConfig) {
792
+ const log = [];
793
+ const targetRef = React.createRef();
794
+ render(
795
+ <Fixture
796
+ type={eventConfig.type}
797
+ targetRef={targetRef}
798
+ targetProps={{
799
+ [eventConfig.reactEvent]: e => {
800
+ e.stopPropagation(); // <---------
801
+ log.push('---- inner');
802
+ },
803
+ [eventConfig.reactEvent + 'Capture']: e => {
804
+ log.push('---- inner capture');
805
+ },
806
+ }}
807
+ parentProps={{
808
+ [eventConfig.reactEvent]: e => {
809
+ log.push('--- inner parent');
810
+ },
811
+ [eventConfig.reactEvent + 'Capture']: e => {
812
+ log.push('--- inner parent capture');
813
+ },
814
+ }}
815
+ outerRef={node => {
816
+ if (node) {
817
+ // No cleanup, assume we render once.
818
+ node.addEventListener(eventConfig.nativeEvent, e => {
819
+ // We *don't* expect this to appear in the log
820
+ // at all because the event is stopped earlier.
821
+ log.push('-- outer (native)');
822
+ });
823
+ }
824
+ }}
825
+ outerProps={{
826
+ [eventConfig.reactEvent]: e => {
827
+ log.push('-- outer');
828
+ },
829
+ [eventConfig.reactEvent + 'Capture']: e => {
830
+ log.push('-- outer capture');
831
+ },
832
+ }}
833
+ outerParentProps={{
834
+ [eventConfig.reactEvent]: e => {
835
+ log.push('- outer parent');
836
+ },
837
+ [eventConfig.reactEvent + 'Capture']: e => {
838
+ log.push('- outer parent capture');
839
+ },
840
+ }}
841
+ />,
842
+ );
843
+ expect(log.length).toBe(0);
844
+ eventConfig.dispatch(targetRef.current);
845
+ // Should stop at the target and not go further.
846
+ expect(log).toEqual(unindent`
847
+ - outer parent capture
848
+ -- outer capture
849
+ --- inner parent capture
850
+ ---- inner capture
851
+ ---- inner
852
+ `);
853
+ }
854
+
855
+ function testReactStopPropagationInOuterBubblePhase(eventConfig) {
856
+ const log = [];
857
+ const targetRef = React.createRef();
858
+ render(
859
+ <Fixture
860
+ type={eventConfig.type}
861
+ targetRef={targetRef}
862
+ targetProps={{
863
+ [eventConfig.reactEvent]: e => {
864
+ log.push('---- inner');
865
+ },
866
+ [eventConfig.reactEvent + 'Capture']: e => {
867
+ log.push('---- inner capture');
868
+ },
869
+ }}
870
+ parentProps={{
871
+ [eventConfig.reactEvent]: e => {
872
+ log.push('--- inner parent');
873
+ },
874
+ [eventConfig.reactEvent + 'Capture']: e => {
875
+ log.push('--- inner parent capture');
876
+ },
877
+ }}
878
+ outerProps={{
879
+ [eventConfig.reactEvent]: e => {
880
+ e.stopPropagation(); // <---------
881
+ log.push('-- outer');
882
+ },
883
+ [eventConfig.reactEvent + 'Capture']: e => {
884
+ log.push('-- outer capture');
885
+ },
886
+ }}
887
+ outerParentProps={{
888
+ [eventConfig.reactEvent]: e => {
889
+ log.push('- outer parent');
890
+ },
891
+ [eventConfig.reactEvent + 'Capture']: e => {
892
+ log.push('- outer parent capture');
893
+ },
894
+ }}
895
+ />,
896
+ );
897
+ expect(log.length).toBe(0);
898
+ eventConfig.dispatch(targetRef.current);
899
+ // Should not reach the parent outer bubble handler.
900
+ expect(log).toEqual(unindent`
901
+ - outer parent capture
902
+ -- outer capture
903
+ --- inner parent capture
904
+ ---- inner capture
905
+ ---- inner
906
+ --- inner parent
907
+ -- outer
908
+ `);
909
+ }
910
+
911
+ function testNativeStopPropagationInOuterCapturePhase(eventConfig) {
912
+ const log = [];
913
+ const targetRef = React.createRef();
914
+ render(
915
+ <Fixture
916
+ type={eventConfig.type}
917
+ targetRef={targetRef}
918
+ targetProps={{
919
+ [eventConfig.reactEvent]: e => {
920
+ log.push('---- inner');
921
+ },
922
+ [eventConfig.reactEvent + 'Capture']: e => {
923
+ log.push('---- inner capture');
924
+ },
925
+ }}
926
+ parentProps={{
927
+ [eventConfig.reactEvent]: e => {
928
+ log.push('--- inner parent');
929
+ },
930
+ [eventConfig.reactEvent + 'Capture']: e => {
931
+ log.push('--- inner parent capture');
932
+ },
933
+ }}
934
+ outerProps={{
935
+ [eventConfig.reactEvent]: e => {
936
+ log.push('-- outer');
937
+ },
938
+ [eventConfig.reactEvent + 'Capture']: e => {
939
+ log.push('-- outer capture');
940
+ },
941
+ }}
942
+ outerParentRef={node => {
943
+ if (node) {
944
+ // No cleanup, assume we render once.
945
+ node.addEventListener(
946
+ eventConfig.nativeEvent,
947
+ e => {
948
+ log.push('- outer parent capture (native)');
949
+ e.stopPropagation(); // <---------
950
+ },
951
+ {capture: true},
952
+ );
953
+ }
954
+ }}
955
+ outerParentProps={{
956
+ [eventConfig.reactEvent]: e => {
957
+ log.push('- outer parent');
958
+ },
959
+ [eventConfig.reactEvent + 'Capture']: e => {
960
+ log.push('- outer parent capture');
961
+ },
962
+ }}
963
+ />,
964
+ );
965
+ expect(log.length).toBe(0);
966
+ eventConfig.dispatch(targetRef.current);
967
+ // The outer root has already received the event,
968
+ // so the capture phrase runs for it. But the inner
969
+ // root is prevented from receiving it by the native
970
+ // handler in the outer native capture phase.
971
+ expect(log).toEqual(unindent`
972
+ - outer parent capture
973
+ -- outer capture
974
+ - outer parent capture (native)
975
+ `);
976
+ }
977
+
978
+ function testNativeStopPropagationInInnerCapturePhase(eventConfig) {
979
+ const log = [];
980
+ const targetRef = React.createRef();
981
+ render(
982
+ <Fixture
983
+ type={eventConfig.type}
984
+ targetRef={targetRef}
985
+ targetProps={{
986
+ [eventConfig.reactEvent]: e => {
987
+ log.push('---- inner');
988
+ },
989
+ [eventConfig.reactEvent + 'Capture']: e => {
990
+ log.push('---- inner capture');
991
+ },
992
+ }}
993
+ parentRef={node => {
994
+ if (node) {
995
+ // No cleanup, assume we render once.
996
+ node.addEventListener(
997
+ eventConfig.nativeEvent,
998
+ e => {
999
+ log.push('--- inner parent capture (native)');
1000
+ e.stopPropagation(); // <---------
1001
+ },
1002
+ {capture: true},
1003
+ );
1004
+ }
1005
+ }}
1006
+ parentProps={{
1007
+ [eventConfig.reactEvent]: e => {
1008
+ log.push('--- inner parent');
1009
+ },
1010
+ [eventConfig.reactEvent + 'Capture']: e => {
1011
+ log.push('--- inner parent capture');
1012
+ },
1013
+ }}
1014
+ outerProps={{
1015
+ [eventConfig.reactEvent]: e => {
1016
+ log.push('-- outer');
1017
+ },
1018
+ [eventConfig.reactEvent + 'Capture']: e => {
1019
+ log.push('-- outer capture');
1020
+ },
1021
+ }}
1022
+ outerParentProps={{
1023
+ [eventConfig.reactEvent]: e => {
1024
+ log.push('- outer parent');
1025
+ },
1026
+ [eventConfig.reactEvent + 'Capture']: e => {
1027
+ log.push('- outer parent capture');
1028
+ },
1029
+ }}
1030
+ />,
1031
+ );
1032
+ expect(log.length).toBe(0);
1033
+ eventConfig.dispatch(targetRef.current);
1034
+ // The inner root has already received the event, so
1035
+ // all React capture phase listeners should run.
1036
+ // But then the native handler stops propagation
1037
+ // so none of the bubbling React handlers would run.
1038
+ expect(log).toEqual(unindent`
1039
+ - outer parent capture
1040
+ -- outer capture
1041
+ --- inner parent capture
1042
+ ---- inner capture
1043
+ --- inner parent capture (native)
1044
+ `);
1045
+ }
1046
+
1047
+ function testNativeStopPropagationInInnerBubblePhase(eventConfig) {
1048
+ const log = [];
1049
+ const targetRef = React.createRef();
1050
+ render(
1051
+ <Fixture
1052
+ type={eventConfig.type}
1053
+ targetRef={node => {
1054
+ targetRef.current = node;
1055
+ if (node) {
1056
+ // No cleanup, assume we render once.
1057
+ node.addEventListener(eventConfig.nativeEvent, e => {
1058
+ log.push('---- inner (native)');
1059
+ e.stopPropagation(); // <---------
1060
+ });
1061
+ }
1062
+ }}
1063
+ targetProps={{
1064
+ [eventConfig.reactEvent]: e => {
1065
+ log.push('---- inner');
1066
+ },
1067
+ [eventConfig.reactEvent + 'Capture']: e => {
1068
+ log.push('---- inner capture');
1069
+ },
1070
+ }}
1071
+ parentProps={{
1072
+ [eventConfig.reactEvent]: e => {
1073
+ log.push('--- inner parent');
1074
+ },
1075
+ [eventConfig.reactEvent + 'Capture']: e => {
1076
+ log.push('--- inner parent capture');
1077
+ },
1078
+ }}
1079
+ outerProps={{
1080
+ [eventConfig.reactEvent]: e => {
1081
+ log.push('-- outer');
1082
+ },
1083
+ [eventConfig.reactEvent + 'Capture']: e => {
1084
+ log.push('-- outer capture');
1085
+ },
1086
+ }}
1087
+ outerParentProps={{
1088
+ [eventConfig.reactEvent]: e => {
1089
+ log.push('- outer parent');
1090
+ },
1091
+ [eventConfig.reactEvent + 'Capture']: e => {
1092
+ log.push('- outer parent capture');
1093
+ },
1094
+ }}
1095
+ />,
1096
+ );
1097
+ expect(log.length).toBe(0);
1098
+ eventConfig.dispatch(targetRef.current);
1099
+ // The capture phase is entirely unaffected.
1100
+ // Then, we get into the bubble phase.
1101
+ // We start with the native innermost handler.
1102
+ // It stops propagation, so nothing else happens.
1103
+ expect(log).toEqual(unindent`
1104
+ - outer parent capture
1105
+ -- outer capture
1106
+ --- inner parent capture
1107
+ ---- inner capture
1108
+ ---- inner (native)
1109
+ `);
1110
+ }
1111
+
1112
+ function testNativeStopPropagationInInnerEmulatedBubblePhase(eventConfig) {
1113
+ const log = [];
1114
+ const targetRef = React.createRef();
1115
+ render(
1116
+ <Fixture
1117
+ type={eventConfig.type}
1118
+ targetRef={node => {
1119
+ targetRef.current = node;
1120
+ if (node) {
1121
+ // No cleanup, assume we render once.
1122
+ node.addEventListener(eventConfig.nativeEvent, e => {
1123
+ log.push('---- inner (native)');
1124
+ e.stopPropagation(); // <---------
1125
+ });
1126
+ }
1127
+ }}
1128
+ targetProps={{
1129
+ [eventConfig.reactEvent]: e => {
1130
+ log.push('---- inner');
1131
+ },
1132
+ [eventConfig.reactEvent + 'Capture']: e => {
1133
+ log.push('---- inner capture');
1134
+ },
1135
+ }}
1136
+ parentProps={{
1137
+ [eventConfig.reactEvent]: e => {
1138
+ log.push('--- inner parent');
1139
+ },
1140
+ [eventConfig.reactEvent + 'Capture']: e => {
1141
+ log.push('--- inner parent capture');
1142
+ },
1143
+ }}
1144
+ outerProps={{
1145
+ [eventConfig.reactEvent]: e => {
1146
+ log.push('-- outer');
1147
+ },
1148
+ [eventConfig.reactEvent + 'Capture']: e => {
1149
+ log.push('-- outer capture');
1150
+ },
1151
+ }}
1152
+ outerParentProps={{
1153
+ [eventConfig.reactEvent]: e => {
1154
+ log.push('- outer parent');
1155
+ },
1156
+ [eventConfig.reactEvent + 'Capture']: e => {
1157
+ log.push('- outer parent capture');
1158
+ },
1159
+ }}
1160
+ />,
1161
+ );
1162
+ expect(log.length).toBe(0);
1163
+ eventConfig.dispatch(targetRef.current);
1164
+ // This event does not natively bubble, so React
1165
+ // attaches the listener directly to the element.
1166
+ // As a result, by the time our custom native listener
1167
+ // fires, it is too late to do anything -- the React
1168
+ // emulated bubbilng has already happened.
1169
+ expect(log).toEqual(unindent`
1170
+ - outer parent capture
1171
+ -- outer capture
1172
+ --- inner parent capture
1173
+ ---- inner capture
1174
+ ---- inner
1175
+ --- inner parent
1176
+ ---- inner (native)
1177
+ `);
1178
+ }
1179
+
1180
+ function testNativeStopPropagationInOuterBubblePhase(eventConfig) {
1181
+ const log = [];
1182
+ const targetRef = React.createRef();
1183
+ render(
1184
+ <Fixture
1185
+ type={eventConfig.type}
1186
+ targetRef={targetRef}
1187
+ targetProps={{
1188
+ [eventConfig.reactEvent]: e => {
1189
+ log.push('---- inner');
1190
+ },
1191
+ [eventConfig.reactEvent + 'Capture']: e => {
1192
+ log.push('---- inner capture');
1193
+ },
1194
+ }}
1195
+ parentProps={{
1196
+ [eventConfig.reactEvent]: e => {
1197
+ log.push('--- inner parent');
1198
+ },
1199
+ [eventConfig.reactEvent + 'Capture']: e => {
1200
+ log.push('--- inner parent capture');
1201
+ },
1202
+ }}
1203
+ outerRef={node => {
1204
+ if (node) {
1205
+ // No cleanup, assume we render once.
1206
+ node.addEventListener(eventConfig.nativeEvent, e => {
1207
+ log.push('-- outer (native)');
1208
+ e.stopPropagation(); // <---------
1209
+ });
1210
+ }
1211
+ }}
1212
+ outerProps={{
1213
+ [eventConfig.reactEvent]: e => {
1214
+ log.push('-- outer');
1215
+ },
1216
+ [eventConfig.reactEvent + 'Capture']: e => {
1217
+ log.push('-- outer capture');
1218
+ },
1219
+ }}
1220
+ outerParentProps={{
1221
+ [eventConfig.reactEvent]: e => {
1222
+ log.push('- outer parent');
1223
+ },
1224
+ [eventConfig.reactEvent + 'Capture']: e => {
1225
+ log.push('- outer parent capture');
1226
+ },
1227
+ }}
1228
+ />,
1229
+ );
1230
+ expect(log.length).toBe(0);
1231
+ eventConfig.dispatch(targetRef.current);
1232
+ // The event bubbles upwards through the inner tree.
1233
+ // Then it reaches the native handler which stops propagation.
1234
+ // As a result, it never reaches the outer React root,
1235
+ // and thus the outer React event handlers don't fire.
1236
+ expect(log).toEqual(unindent`
1237
+ - outer parent capture
1238
+ -- outer capture
1239
+ --- inner parent capture
1240
+ ---- inner capture
1241
+ ---- inner
1242
+ --- inner parent
1243
+ -- outer (native)
1244
+ `);
1245
+ }
1246
+
1247
+ function Fixture({
1248
+ type,
1249
+ targetRef,
1250
+ targetProps,
1251
+ parentRef,
1252
+ parentProps,
1253
+ outerRef,
1254
+ outerProps,
1255
+ outerParentRef,
1256
+ outerParentProps,
1257
+ }) {
1258
+ const inner = React.useMemo(
1259
+ () => (
1260
+ <Inner
1261
+ type={type}
1262
+ targetRef={targetRef}
1263
+ targetProps={targetProps}
1264
+ parentRef={parentRef}
1265
+ parentProps={parentProps}
1266
+ />
1267
+ ),
1268
+ [type, targetRef, targetProps, parentProps],
1269
+ );
1270
+ return (
1271
+ <Outer
1272
+ outerRef={outerRef}
1273
+ outerProps={outerProps}
1274
+ outerParentRef={outerParentRef}
1275
+ outerParentProps={outerParentProps}>
1276
+ <NestedReact>{inner}</NestedReact>
1277
+ </Outer>
1278
+ );
1279
+ }
1280
+
1281
+ function NestedReact({children}) {
1282
+ const ref = React.useRef();
1283
+ React.useLayoutEffect(() => {
1284
+ const parent = ref.current;
1285
+ const innerContainer = document.createElement('div');
1286
+ parent.appendChild(innerContainer);
1287
+ InnerReactDOM.render(children, innerContainer);
1288
+ return () => {
1289
+ InnerReactDOM.unmountComponentAtNode(innerContainer);
1290
+ parent.removeChild(innerContainer);
1291
+ };
1292
+ }, [children, ref]);
1293
+ return <div ref={ref} />;
1294
+ }
1295
+
1296
+ function Inner({type, targetRef, targetProps, parentRef, parentProps}) {
1297
+ const T = type;
1298
+ return (
1299
+ <div {...parentProps} ref={parentRef}>
1300
+ <T {...targetProps} ref={targetRef} />
1301
+ </div>
1302
+ );
1303
+ }
1304
+
1305
+ function Outer({
1306
+ outerRef,
1307
+ outerProps,
1308
+ outerParentProps,
1309
+ outerParentRef,
1310
+ children,
1311
+ }) {
1312
+ return (
1313
+ <div {...outerParentProps} ref={outerParentRef}>
1314
+ <div {...outerProps} ref={outerRef}>
1315
+ {children}
1316
+ </div>
1317
+ </div>
1318
+ );
1319
+ }
1320
+
1321
+ function unindent(str) {
1322
+ return str[0]
1323
+ .split('\n')
1324
+ .map(s => s.trim())
1325
+ .filter(s => s !== '');
1326
+ }
1327
+});