Modern Event System: Support nested portal/root boundaries (#18201)
Dominic Gannaway committed
Mar 5, 2020 at 09:00 UTC
60b11f6c1be111c14c3607809ccad2b8ea680152
2 files changed
+936
-2
packages/react-dom/src/events/DOMModernPluginEventSystem.js
+65
-1
@@ -19,6 +19,8 @@ import {batchedEventUpdates} from 'legacy-events/ReactGenericBatching';
19
import {executeDispatchesInOrder} from 'legacy-events/EventPluginUtils';
20
import {plugins} from 'legacy-events/EventPluginRegistry';
21
22
+import {HostRoot, HostPortal} from 'shared/ReactWorkTags';
23
+
24
import {trapEventForPluginEventSystem} from './ReactDOMEventListener';
25
import getEventTarget from './getEventTarget';
26
import {getListenerMapForElement} from './DOMEventListenerMap';
@@ -56,7 +58,8 @@ import {
58
TOP_PROGRESS,
59
TOP_PLAYING,
60
} from './DOMTopLevelEventTypes';
59
-import {DOCUMENT_NODE} from '../shared/HTMLNodeType';
61
+import {getClosestInstanceFromNode} from '../client/ReactDOMComponentTree';
62
+import {DOCUMENT_NODE, COMMENT_NODE} from '../shared/HTMLNodeType';
63
64
import {enableLegacyFBPrimerSupport} from 'shared/ReactFeatureFlags';
65
@@ -206,6 +209,17 @@ function willDeferLaterForFBLegacyPrimer(nativeEvent: any): boolean {
209
return false;
210
}
211
212
+function isMatchingRootContainer(
213
+ grandContainer: Element,
214
+ rootContainer: Document | Element,
215
+): boolean {
216
+ return (
217
+ grandContainer === rootContainer ||
218
+ (grandContainer.nodeType === COMMENT_NODE &&
219
+ grandContainer.parentNode === rootContainer)
220
+ );
221
+}
222
+
223
export function dispatchEventForPluginEventSystem(
224
topLevelType: DOMTopLevelEventType,
225
eventSystemFlags: EventSystemFlags,
@@ -224,6 +238,56 @@ export function dispatchEventForPluginEventSystem(
238
) {
239
return;
240
}
241
+ // The below logic attempts to work out if we need to change
242
+ // the target fiber to a different ancestor. We had similar logic
243
+ // in the legacy event system, except the big difference between
244
+ // systems is that the modern event system now has an event listener
245
+ // attached to each React Root and React Portal Root. Together,
246
+ // the DOM nodes representing these roots are the "rootContainer".
247
+ // To figure out which ancestor instance we should use, we traverse
248
+ // up the fiber tree from the target instance and attempt to find
249
+ // root boundaries that match that of our current "rootContainer".
250
+ // If we find that "rootContainer", we find the parent fiber
251
+ // sub-tree for that root and make that our ancestor instance.
252
+ let node = targetInst;
253
+
254
+ while (true) {
255
+ if (node === null) {
256
+ return;
257
+ }
258
+ if (node.tag === HostRoot || node.tag === HostPortal) {
259
+ const container = node.stateNode.containerInfo;
260
+ if (isMatchingRootContainer(container, rootContainer)) {
261
+ break;
262
+ }
263
+ if (node.tag === HostPortal) {
264
+ // The target is a portal, but it's not the rootContainer we're looking for.
265
+ // Normally portals handle their own events all the way down to the root.
266
+ // So we should be able to stop now. However, we don't know if this portal
267
+ // was part of *our* root.
268
+ let grandNode = node.return;
269
+ while (grandNode !== null) {
270
+ if (grandNode.tag === HostRoot || grandNode.tag === HostPortal) {
271
+ const grandContainer = grandNode.stateNode.containerInfo;
272
+ if (isMatchingRootContainer(grandContainer, rootContainer)) {
273
+ // This is the rootContainer we're looking for and we found it as
274
+ // a parent of the Portal. That means we can ignore it because the
275
+ // Portal will bubble through to us.
276
+ return;
277
+ }
278
+ }
279
+ grandNode = grandNode.return;
280
+ }
281
+ }
282
+ const parentSubtreeInst = getClosestInstanceFromNode(container);
283
+ if (parentSubtreeInst === null) {
284
+ return;
285
+ }
286
+ node = ancestorInst = parentSubtreeInst;
287
+ continue;
288
+ }
289
+ node = node.return;
290
+ }
291
}
292
293
batchedEventUpdates(() =>
packages/react-dom/src/events/__tests__/DOMModernPluginEventSystem-test.internal.js
+871
-1
@@ -12,6 +12,8 @@
12
let React;
13
let ReactFeatureFlags;
14
let ReactDOM;
15
+let ReactDOMServer;
16
+let Scheduler;
17
18
function dispatchClickEvent(element) {
19
const event = document.createEvent('Event');
@@ -29,6 +31,8 @@ describe('DOMModernPluginEventSystem', () => {
31
32
React = require('react');
33
ReactDOM = require('react-dom');
34
+ Scheduler = require('scheduler');
35
+ ReactDOMServer = require('react-dom/server');
36
container = document.createElement('div');
37
document.body.appendChild(container);
38
});
@@ -77,6 +81,500 @@ describe('DOMModernPluginEventSystem', () => {
81
expect(log[5]).toEqual(['bubble', buttonElement]);
82
});
83
84
+ it('handle propagation of click events between roots', () => {
85
+ const buttonRef = React.createRef();
86
+ const divRef = React.createRef();
87
+ const childRef = React.createRef();
88
+ const log = [];
89
+ const onClick = jest.fn(e => log.push(['bubble', e.currentTarget]));
90
+ const onClickCapture = jest.fn(e => log.push(['capture', e.currentTarget]));
91
+
92
+ function Child() {
93
+ return (
94
+ <div ref={divRef} onClick={onClick} onClickCapture={onClickCapture}>
95
+ Click me!
96
+ </div>
97
+ );
98
+ }
99
+
100
+ function Parent() {
101
+ return (
102
+ <button
103
+ ref={buttonRef}
104
+ onClick={onClick}
105
+ onClickCapture={onClickCapture}>
106
+ <div ref={childRef} />
107
+ </button>
108
+ );
109
+ }
110
+
111
+ ReactDOM.render(<Parent />, container);
112
+ ReactDOM.render(<Child />, childRef.current);
113
+
114
+ let buttonElement = buttonRef.current;
115
+ dispatchClickEvent(buttonElement);
116
+ expect(onClick).toHaveBeenCalledTimes(1);
117
+ expect(onClickCapture).toHaveBeenCalledTimes(1);
118
+ expect(log[0]).toEqual(['capture', buttonElement]);
119
+ expect(log[1]).toEqual(['bubble', buttonElement]);
120
+
121
+ let divElement = divRef.current;
122
+ dispatchClickEvent(divElement);
123
+ expect(onClick).toHaveBeenCalledTimes(3);
124
+ expect(onClickCapture).toHaveBeenCalledTimes(3);
125
+ expect(log[2]).toEqual(['capture', divElement]);
126
+ expect(log[3]).toEqual(['bubble', divElement]);
127
+ expect(log[4]).toEqual(['capture', buttonElement]);
128
+ expect(log[5]).toEqual(['bubble', buttonElement]);
129
+ });
130
+
131
+ it('handle propagation of click events between disjointed roots', () => {
132
+ const buttonRef = React.createRef();
133
+ const divRef = React.createRef();
134
+ const log = [];
135
+ const onClick = jest.fn(e => log.push(['bubble', e.currentTarget]));
136
+ const onClickCapture = jest.fn(e => log.push(['capture', e.currentTarget]));
137
+
138
+ function Child() {
139
+ return (
140
+ <div ref={divRef} onClick={onClick} onClickCapture={onClickCapture}>
141
+ Click me!
142
+ </div>
143
+ );
144
+ }
145
+
146
+ function Parent() {
147
+ return (
148
+ <button
149
+ ref={buttonRef}
150
+ onClick={onClick}
151
+ onClickCapture={onClickCapture}
152
+ />
153
+ );
154
+ }
155
+
156
+ const disjointedNode = document.createElement('div');
157
+ ReactDOM.render(<Parent />, container);
158
+ buttonRef.current.appendChild(disjointedNode);
159
+ ReactDOM.render(<Child />, disjointedNode);
160
+
161
+ let buttonElement = buttonRef.current;
162
+ dispatchClickEvent(buttonElement);
163
+ expect(onClick).toHaveBeenCalledTimes(1);
164
+ expect(onClickCapture).toHaveBeenCalledTimes(1);
165
+ expect(log[0]).toEqual(['capture', buttonElement]);
166
+ expect(log[1]).toEqual(['bubble', buttonElement]);
167
+
168
+ let divElement = divRef.current;
169
+ dispatchClickEvent(divElement);
170
+ expect(onClick).toHaveBeenCalledTimes(3);
171
+ expect(onClickCapture).toHaveBeenCalledTimes(3);
172
+ expect(log[2]).toEqual(['capture', divElement]);
173
+ expect(log[3]).toEqual(['bubble', divElement]);
174
+ expect(log[4]).toEqual(['capture', buttonElement]);
175
+ expect(log[5]).toEqual(['bubble', buttonElement]);
176
+ });
177
+
178
+ it('handle propagation of click events between disjointed comment roots', () => {
179
+ const buttonRef = React.createRef();
180
+ const divRef = React.createRef();
181
+ const log = [];
182
+ const onClick = jest.fn(e => log.push(['bubble', e.currentTarget]));
183
+ const onClickCapture = jest.fn(e => log.push(['capture', e.currentTarget]));
184
+
185
+ function Child() {
186
+ return (
187
+ <div ref={divRef} onClick={onClick} onClickCapture={onClickCapture}>
188
+ Click me!
189
+ </div>
190
+ );
191
+ }
192
+
193
+ function Parent() {
194
+ return (
195
+ <button
196
+ ref={buttonRef}
197
+ onClick={onClick}
198
+ onClickCapture={onClickCapture}
199
+ />
200
+ );
201
+ }
202
+
203
+ // We use a comment node here, then mount to it
204
+ const disjointedNode = document.createComment(
205
+ ' react-mount-point-unstable ',
206
+ );
207
+ ReactDOM.render(<Parent />, container);
208
+ buttonRef.current.appendChild(disjointedNode);
209
+ ReactDOM.render(<Child />, disjointedNode);
210
+
211
+ let buttonElement = buttonRef.current;
212
+ dispatchClickEvent(buttonElement);
213
+ expect(onClick).toHaveBeenCalledTimes(1);
214
+ expect(onClickCapture).toHaveBeenCalledTimes(1);
215
+ expect(log[0]).toEqual(['capture', buttonElement]);
216
+ expect(log[1]).toEqual(['bubble', buttonElement]);
217
+
218
+ let divElement = divRef.current;
219
+ dispatchClickEvent(divElement);
220
+ expect(onClick).toHaveBeenCalledTimes(3);
221
+ expect(onClickCapture).toHaveBeenCalledTimes(3);
222
+ expect(log[2]).toEqual(['capture', divElement]);
223
+ expect(log[3]).toEqual(['bubble', divElement]);
224
+ expect(log[4]).toEqual(['capture', buttonElement]);
225
+ expect(log[5]).toEqual(['bubble', buttonElement]);
226
+ });
227
+
228
+ it('handle propagation of click events between disjointed comment roots #2', () => {
229
+ const buttonRef = React.createRef();
230
+ const divRef = React.createRef();
231
+ const spanRef = React.createRef();
232
+ const log = [];
233
+ const onClick = jest.fn(e => log.push(['bubble', e.currentTarget]));
234
+ const onClickCapture = jest.fn(e => log.push(['capture', e.currentTarget]));
235
+
236
+ function Child() {
237
+ return (
238
+ <div ref={divRef} onClick={onClick} onClickCapture={onClickCapture}>
239
+ Click me!
240
+ </div>
241
+ );
242
+ }
243
+
244
+ function Parent() {
245
+ return (
246
+ <button
247
+ ref={buttonRef}
248
+ onClick={onClick}
249
+ onClickCapture={onClickCapture}>
250
+ <span ref={spanRef} />
251
+ </button>
252
+ );
253
+ }
254
+
255
+ // We use a comment node here, then mount to it
256
+ const disjointedNode = document.createComment(
257
+ ' react-mount-point-unstable ',
258
+ );
259
+ ReactDOM.render(<Parent />, container);
260
+ spanRef.current.appendChild(disjointedNode);
261
+ ReactDOM.render(<Child />, disjointedNode);
262
+
263
+ let buttonElement = buttonRef.current;
264
+ dispatchClickEvent(buttonElement);
265
+ expect(onClick).toHaveBeenCalledTimes(1);
266
+ expect(onClickCapture).toHaveBeenCalledTimes(1);
267
+ expect(log[0]).toEqual(['capture', buttonElement]);
268
+ expect(log[1]).toEqual(['bubble', buttonElement]);
269
+
270
+ let divElement = divRef.current;
271
+ dispatchClickEvent(divElement);
272
+ expect(onClick).toHaveBeenCalledTimes(3);
273
+ expect(onClickCapture).toHaveBeenCalledTimes(3);
274
+ expect(log[2]).toEqual(['capture', divElement]);
275
+ expect(log[3]).toEqual(['bubble', divElement]);
276
+ expect(log[4]).toEqual(['capture', buttonElement]);
277
+ expect(log[5]).toEqual(['bubble', buttonElement]);
278
+ });
279
+
280
+ it('handle propagation of click events between portals', () => {
281
+ const buttonRef = React.createRef();
282
+ const divRef = React.createRef();
283
+ const log = [];
284
+ const onClick = jest.fn(e => log.push(['bubble', e.currentTarget]));
285
+ const onClickCapture = jest.fn(e => log.push(['capture', e.currentTarget]));
286
+
287
+ const portalElement = document.createElement('div');
288
+ document.body.appendChild(portalElement);
289
+
290
+ function Child() {
291
+ return (
292
+ <div ref={divRef} onClick={onClick} onClickCapture={onClickCapture}>
293
+ Click me!
294
+ </div>
295
+ );
296
+ }
297
+
298
+ function Parent() {
299
+ return (
300
+ <button
301
+ ref={buttonRef}
302
+ onClick={onClick}
303
+ onClickCapture={onClickCapture}>
304
+ {ReactDOM.createPortal(<Child />, portalElement)}
305
+ </button>
306
+ );
307
+ }
308
+
309
+ ReactDOM.render(<Parent />, container);
310
+
311
+ let buttonElement = buttonRef.current;
312
+ dispatchClickEvent(buttonElement);
313
+ expect(onClick).toHaveBeenCalledTimes(1);
314
+ expect(onClickCapture).toHaveBeenCalledTimes(1);
315
+ expect(log[0]).toEqual(['capture', buttonElement]);
316
+ expect(log[1]).toEqual(['bubble', buttonElement]);
317
+
318
+ let divElement = divRef.current;
319
+ dispatchClickEvent(divElement);
320
+ expect(onClick).toHaveBeenCalledTimes(3);
321
+ expect(onClickCapture).toHaveBeenCalledTimes(3);
322
+ expect(log[2]).toEqual(['capture', buttonElement]);
323
+ expect(log[3]).toEqual(['capture', divElement]);
324
+ expect(log[4]).toEqual(['bubble', divElement]);
325
+ expect(log[5]).toEqual(['bubble', buttonElement]);
326
+
327
+ document.body.removeChild(portalElement);
328
+ });
329
+
330
+ it('handle click events on document.body portals', () => {
331
+ const log = [];
332
+
333
+ function Child({label}) {
334
+ return <div onClick={() => log.push(label)}>{label}</div>;
335
+ }
336
+
337
+ function Parent() {
338
+ return (
339
+ <>
340
+ {ReactDOM.createPortal(<Child label={'first'} />, document.body)}
341
+ {ReactDOM.createPortal(<Child label={'second'} />, document.body)}
342
+ </>
343
+ );
344
+ }
345
+
346
+ ReactDOM.render(<Parent />, container);
347
+
348
+ const second = document.body.lastChild;
349
+ expect(second.textContent).toEqual('second');
350
+ dispatchClickEvent(second);
351
+
352
+ expect(log).toEqual(['second']);
353
+
354
+ const first = second.previousSibling;
355
+ expect(first.textContent).toEqual('first');
356
+ dispatchClickEvent(first);
357
+
358
+ expect(log).toEqual(['second', 'first']);
359
+ });
360
+
361
+ it.experimental(
362
+ 'does not invoke an event on a parent tree when a subtree is dehydrated',
363
+ async () => {
364
+ let suspend = false;
365
+ let resolve;
366
+ let promise = new Promise(resolvePromise => (resolve = resolvePromise));
367
+
368
+ let clicks = 0;
369
+ let childSlotRef = React.createRef();
370
+
371
+ function Parent() {
372
+ return <div onClick={() => clicks++} ref={childSlotRef} />;
373
+ }
374
+
375
+ function Child({text}) {
376
+ if (suspend) {
377
+ throw promise;
378
+ } else {
379
+ return <a>Click me</a>;
380
+ }
381
+ }
382
+
383
+ function App() {
384
+ // The root is a Suspense boundary.
385
+ return (
386
+ <React.Suspense fallback="Loading...">
387
+ <Child />
388
+ </React.Suspense>
389
+ );
390
+ }
391
+
392
+ suspend = false;
393
+ let finalHTML = ReactDOMServer.renderToString(<App />);
394
+
395
+ let parentContainer = document.createElement('div');
396
+ let childContainer = document.createElement('div');
397
+
398
+ // We need this to be in the document since we'll dispatch events on it.
399
+ document.body.appendChild(parentContainer);
400
+
401
+ // We're going to use a different root as a parent.
402
+ // This lets us detect whether an event goes through React's event system.
403
+ let parentRoot = ReactDOM.createRoot(parentContainer);
404
+ parentRoot.render(<Parent />);
405
+ Scheduler.unstable_flushAll();
406
+
407
+ childSlotRef.current.appendChild(childContainer);
408
+
409
+ childContainer.innerHTML = finalHTML;
410
+
411
+ let a = childContainer.getElementsByTagName('a')[0];
412
+
413
+ suspend = true;
414
+
415
+ // Hydrate asynchronously.
416
+ let root = ReactDOM.createRoot(childContainer, {hydrate: true});
417
+ root.render(<App />);
418
+ jest.runAllTimers();
419
+ Scheduler.unstable_flushAll();
420
+
421
+ // The Suspense boundary is not yet hydrated.
422
+ a.click();
423
+ expect(clicks).toBe(0);
424
+
425
+ // Resolving the promise so that rendering can complete.
426
+ suspend = false;
427
+ resolve();
428
+ await promise;
429
+
430
+ Scheduler.unstable_flushAll();
431
+ jest.runAllTimers();
432
+
433
+ // We're now full hydrated.
434
+
435
+ expect(clicks).toBe(1);
436
+
437
+ document.body.removeChild(parentContainer);
438
+ },
439
+ );
440
+
441
+ it('handle click events on dynamic portals', () => {
442
+ const log = [];
443
+
444
+ function Parent() {
445
+ const ref = React.useRef(null);
446
+ const [portal, setPortal] = React.useState(null);
447
+
448
+ React.useEffect(() => {
449
+ setPortal(
450
+ ReactDOM.createPortal(
451
+ <span onClick={() => log.push('child')} id="child" />,
452
+ ref.current,
453
+ ),
454
+ );
455
+ });
456
+
457
+ return (
458
+ <div ref={ref} onClick={() => log.push('parent')} id="parent">
459
+ {portal}
460
+ </div>
461
+ );
462
+ }
463
+
464
+ ReactDOM.render(<Parent />, container);
465
+
466
+ const parent = container.lastChild;
467
+ expect(parent.id).toEqual('parent');
468
+ dispatchClickEvent(parent);
469
+
470
+ expect(log).toEqual(['parent']);
471
+
472
+ const child = parent.lastChild;
473
+ expect(child.id).toEqual('child');
474
+ dispatchClickEvent(child);
475
+
476
+ // we add both 'child' and 'parent' due to bubbling
477
+ expect(log).toEqual(['parent', 'child', 'parent']);
478
+ });
479
+
480
+ // Slight alteration to the last test, to catch
481
+ // a subtle difference in traversal.
482
+ it('handle click events on dynamic portals #2', () => {
483
+ const log = [];
484
+
485
+ function Parent() {
486
+ const ref = React.useRef(null);
487
+ const [portal, setPortal] = React.useState(null);
488
+
489
+ React.useEffect(() => {
490
+ setPortal(
491
+ ReactDOM.createPortal(
492
+ <span onClick={() => log.push('child')} id="child" />,
493
+ ref.current,
494
+ ),
495
+ );
496
+ });
497
+
498
+ return (
499
+ <div ref={ref} onClick={() => log.push('parent')} id="parent">
500
+ <div>{portal}</div>
501
+ </div>
502
+ );
503
+ }
504
+
505
+ ReactDOM.render(<Parent />, container);
506
+
507
+ const parent = container.lastChild;
508
+ expect(parent.id).toEqual('parent');
509
+ dispatchClickEvent(parent);
510
+
511
+ expect(log).toEqual(['parent']);
512
+
513
+ const child = parent.lastChild;
514
+ expect(child.id).toEqual('child');
515
+ dispatchClickEvent(child);
516
+
517
+ // we add both 'child' and 'parent' due to bubbling
518
+ expect(log).toEqual(['parent', 'child', 'parent']);
519
+ });
520
+
521
+ it('native stopPropagation on click events between portals', () => {
522
+ const buttonRef = React.createRef();
523
+ const divRef = React.createRef();
524
+ const middelDivRef = React.createRef();
525
+ const log = [];
526
+ const onClick = jest.fn(e => log.push(['bubble', e.currentTarget]));
527
+ const onClickCapture = jest.fn(e => log.push(['capture', e.currentTarget]));
528
+
529
+ const portalElement = document.createElement('div');
530
+ document.body.appendChild(portalElement);
531
+
532
+ function Child() {
533
+ return (
534
+ <div ref={middelDivRef}>
535
+ <div ref={divRef} onClick={onClick} onClickCapture={onClickCapture}>
536
+ Click me!
537
+ </div>
538
+ </div>
539
+ );
540
+ }
541
+
542
+ function Parent() {
543
+ React.useLayoutEffect(() => {
544
+ // This should prevent the portalElement listeners from
545
+ // capturing the events in the bubble phase.
546
+ middelDivRef.current.addEventListener('click', e => {
547
+ e.stopPropagation();
548
+ });
549
+ });
550
+
551
+ return (
552
+ <button
553
+ ref={buttonRef}
554
+ onClick={onClick}
555
+ onClickCapture={onClickCapture}>
556
+ {ReactDOM.createPortal(<Child />, portalElement)}
557
+ </button>
558
+ );
559
+ }
560
+
561
+ ReactDOM.render(<Parent />, container);
562
+
563
+ let buttonElement = buttonRef.current;
564
+ dispatchClickEvent(buttonElement);
565
+ expect(onClick).toHaveBeenCalledTimes(1);
566
+ expect(onClickCapture).toHaveBeenCalledTimes(1);
567
+ expect(log[0]).toEqual(['capture', buttonElement]);
568
+ expect(log[1]).toEqual(['bubble', buttonElement]);
569
+
570
+ let divElement = divRef.current;
571
+ dispatchClickEvent(divElement);
572
+ expect(onClick).toHaveBeenCalledTimes(1);
573
+ expect(onClickCapture).toHaveBeenCalledTimes(1);
574
+
575
+ document.body.removeChild(portalElement);
576
+ });
577
+
578
it('handle propagation of focus events', () => {
579
const buttonRef = React.createRef();
580
const divRef = React.createRef();
@@ -120,6 +618,377 @@ describe('DOMModernPluginEventSystem', () => {
618
expect(log[5]).toEqual(['bubble', buttonElement]);
619
});
620
621
+ it('handle propagation of focus events between roots', () => {
622
+ const buttonRef = React.createRef();
623
+ const divRef = React.createRef();
624
+ const childRef = React.createRef();
625
+ const log = [];
626
+ const onFocus = jest.fn(e => log.push(['bubble', e.currentTarget]));
627
+ const onFocusCapture = jest.fn(e => log.push(['capture', e.currentTarget]));
628
+
629
+ function Child() {
630
+ return (
631
+ <div
632
+ ref={divRef}
633
+ onFocus={onFocus}
634
+ onFocusCapture={onFocusCapture}
635
+ tabIndex={0}>
636
+ Click me!
637
+ </div>
638
+ );
639
+ }
640
+
641
+ function Parent() {
642
+ return (
643
+ <button
644
+ ref={buttonRef}
645
+ onFocus={onFocus}
646
+ onFocusCapture={onFocusCapture}>
647
+ <div ref={childRef} />
648
+ </button>
649
+ );
650
+ }
651
+
652
+ ReactDOM.render(<Parent />, container);
653
+ ReactDOM.render(<Child />, childRef.current);
654
+
655
+ let buttonElement = buttonRef.current;
656
+ buttonElement.focus();
657
+ expect(onFocus).toHaveBeenCalledTimes(1);
658
+ expect(onFocusCapture).toHaveBeenCalledTimes(1);
659
+ expect(log[0]).toEqual(['capture', buttonElement]);
660
+ expect(log[1]).toEqual(['bubble', buttonElement]);
661
+
662
+ let divElement = divRef.current;
663
+ divElement.focus();
664
+ expect(onFocus).toHaveBeenCalledTimes(3);
665
+ expect(onFocusCapture).toHaveBeenCalledTimes(3);
666
+ expect(log[2]).toEqual(['capture', buttonElement]);
667
+ expect(log[3]).toEqual(['bubble', buttonElement]);
668
+ expect(log[4]).toEqual(['capture', divElement]);
669
+ expect(log[5]).toEqual(['bubble', divElement]);
670
+ });
671
+
672
+ it('handle propagation of focus events between portals', () => {
673
+ const buttonRef = React.createRef();
674
+ const divRef = React.createRef();
675
+ const log = [];
676
+ const onFocus = jest.fn(e => log.push(['bubble', e.currentTarget]));
677
+ const onFocusCapture = jest.fn(e => log.push(['capture', e.currentTarget]));
678
+
679
+ const portalElement = document.createElement('div');
680
+ document.body.appendChild(portalElement);
681
+
682
+ function Child() {
683
+ return (
684
+ <div
685
+ ref={divRef}
686
+ onFocus={onFocus}
687
+ onFocusCapture={onFocusCapture}
688
+ tabIndex={0}>
689
+ Click me!
690
+ </div>
691
+ );
692
+ }
693
+
694
+ function Parent() {
695
+ return (
696
+ <button
697
+ ref={buttonRef}
698
+ onFocus={onFocus}
699
+ onFocusCapture={onFocusCapture}>
700
+ {ReactDOM.createPortal(<Child />, portalElement)}
701
+ </button>
702
+ );
703
+ }
704
+
705
+ ReactDOM.render(<Parent />, container);
706
+
707
+ let buttonElement = buttonRef.current;
708
+ buttonElement.focus();
709
+ expect(onFocus).toHaveBeenCalledTimes(1);
710
+ expect(onFocusCapture).toHaveBeenCalledTimes(1);
711
+ expect(log[0]).toEqual(['capture', buttonElement]);
712
+ expect(log[1]).toEqual(['bubble', buttonElement]);
713
+
714
+ let divElement = divRef.current;
715
+ divElement.focus();
716
+ expect(onFocus).toHaveBeenCalledTimes(3);
717
+ expect(onFocusCapture).toHaveBeenCalledTimes(3);
718
+ expect(log[2]).toEqual(['capture', buttonElement]);
719
+ expect(log[3]).toEqual(['capture', divElement]);
720
+ expect(log[4]).toEqual(['bubble', divElement]);
721
+ expect(log[5]).toEqual(['bubble', buttonElement]);
722
+
723
+ document.body.removeChild(portalElement);
724
+ });
725
+
726
+ it('native stopPropagation on focus events between portals', () => {
727
+ const buttonRef = React.createRef();
728
+ const divRef = React.createRef();
729
+ const middelDivRef = React.createRef();
730
+ const log = [];
731
+ const onFocus = jest.fn(e => log.push(['bubble', e.currentTarget]));
732
+ const onFocusCapture = jest.fn(e => log.push(['capture', e.currentTarget]));
733
+
734
+ const portalElement = document.createElement('div');
735
+ document.body.appendChild(portalElement);
736
+
737
+ function Child() {
738
+ return (
739
+ <div ref={middelDivRef}>
740
+ <div
741
+ ref={divRef}
742
+ onClick={onFocus}
743
+ onClickCapture={onFocusCapture}
744
+ tabIndex={0}>
745
+ Click me!
746
+ </div>
747
+ </div>
748
+ );
749
+ }
750
+
751
+ function Parent() {
752
+ React.useLayoutEffect(() => {
753
+ // This should prevent the portalElement listeners from
754
+ // capturing the events in the bubble phase.
755
+ middelDivRef.current.addEventListener('click', e => {
756
+ e.stopPropagation();
757
+ });
758
+ });
759
+
760
+ return (
761
+ <button
762
+ ref={buttonRef}
763
+ onFocus={onFocus}
764
+ onFocusCapture={onFocusCapture}>
765
+ {ReactDOM.createPortal(<Child />, portalElement)}
766
+ </button>
767
+ );
768
+ }
769
+
770
+ ReactDOM.render(<Parent />, container);
771
+
772
+ let buttonElement = buttonRef.current;
773
+ buttonElement.focus();
774
+ expect(onFocus).toHaveBeenCalledTimes(1);
775
+ expect(onFocusCapture).toHaveBeenCalledTimes(1);
776
+ expect(log[0]).toEqual(['capture', buttonElement]);
777
+ expect(log[1]).toEqual(['bubble', buttonElement]);
778
+
779
+ let divElement = divRef.current;
780
+ divElement.focus();
781
+ expect(onFocus).toHaveBeenCalledTimes(1);
782
+ expect(onFocusCapture).toHaveBeenCalledTimes(1);
783
+
784
+ document.body.removeChild(portalElement);
785
+ });
786
+
787
+ it('handle propagation of enter and leave events between portals', () => {
788
+ const buttonRef = React.createRef();
789
+ const divRef = React.createRef();
790
+ const log = [];
791
+ const onMouseEnter = jest.fn(e => log.push(e.currentTarget));
792
+ const onMouseLeave = jest.fn(e => log.push(e.currentTarget));
793
+
794
+ const portalElement = document.createElement('div');
795
+ document.body.appendChild(portalElement);
796
+
797
+ function Child() {
798
+ return (
799
+ <div
800
+ ref={divRef}
801
+ onMouseEnter={onMouseEnter}
802
+ onMouseLeave={onMouseLeave}
803
+ />
804
+ );
805
+ }
806
+
807
+ function Parent() {
808
+ return (
809
+ <button
810
+ ref={buttonRef}
811
+ onMouseEnter={onMouseEnter}
812
+ onMouseLeave={onMouseLeave}>
813
+ {ReactDOM.createPortal(<Child />, portalElement)}
814
+ </button>
815
+ );
816
+ }
817
+
818
+ ReactDOM.render(<Parent />, container);
819
+
820
+ let buttonElement = buttonRef.current;
821
+ buttonElement.dispatchEvent(
822
+ new MouseEvent('mouseover', {
823
+ bubbles: true,
824
+ cancelable: true,
825
+ relatedTarget: null,
826
+ }),
827
+ );
828
+ expect(onMouseEnter).toHaveBeenCalledTimes(1);
829
+ expect(onMouseLeave).toHaveBeenCalledTimes(0);
830
+ expect(log[0]).toEqual(buttonElement);
831
+
832
+ let divElement = divRef.current;
833
+ buttonElement.dispatchEvent(
834
+ new MouseEvent('mouseout', {
835
+ bubbles: true,
836
+ cancelable: true,
837
+ relatedTarget: divElement,
838
+ }),
839
+ );
840
+ divElement.dispatchEvent(
841
+ new MouseEvent('mouseover', {
842
+ bubbles: true,
843
+ cancelable: true,
844
+ relatedTarget: buttonElement,
845
+ }),
846
+ );
847
+ expect(onMouseEnter).toHaveBeenCalledTimes(2);
848
+ expect(onMouseLeave).toHaveBeenCalledTimes(0);
849
+ expect(log[1]).toEqual(divElement);
850
+
851
+ document.body.removeChild(portalElement);
852
+ });
853
+
854
+ it('handle propagation of enter and leave events between portals #2', () => {
855
+ const buttonRef = React.createRef();
856
+ const divRef = React.createRef();
857
+ const portalRef = React.createRef();
858
+ const log = [];
859
+ const onMouseEnter = jest.fn(e => log.push(e.currentTarget));
860
+ const onMouseLeave = jest.fn(e => log.push(e.currentTarget));
861
+
862
+ function Child() {
863
+ return (
864
+ <div
865
+ ref={divRef}
866
+ onMouseEnter={onMouseEnter}
867
+ onMouseLeave={onMouseLeave}
868
+ />
869
+ );
870
+ }
871
+
872
+ function Parent() {
873
+ const [portal, setPortal] = React.useState(null);
874
+
875
+ React.useLayoutEffect(() => {
876
+ setPortal(ReactDOM.createPortal(<Child />, portalRef.current));
877
+ }, []);
878
+
879
+ return (
880
+ <button
881
+ ref={buttonRef}
882
+ onMouseEnter={onMouseEnter}
883
+ onMouseLeave={onMouseLeave}>
884
+ <div ref={portalRef}>{portal}</div>
885
+ </button>
886
+ );
887
+ }
888
+
889
+ ReactDOM.render(<Parent />, container);
890
+
891
+ let buttonElement = buttonRef.current;
892
+ buttonElement.dispatchEvent(
893
+ new MouseEvent('mouseover', {
894
+ bubbles: true,
895
+ cancelable: true,
896
+ relatedTarget: null,
897
+ }),
898
+ );
899
+ expect(onMouseEnter).toHaveBeenCalledTimes(1);
900
+ expect(onMouseLeave).toHaveBeenCalledTimes(0);
901
+ expect(log[0]).toEqual(buttonElement);
902
+
903
+ let divElement = divRef.current;
904
+ buttonElement.dispatchEvent(
905
+ new MouseEvent('mouseout', {
906
+ bubbles: true,
907
+ cancelable: true,
908
+ relatedTarget: divElement,
909
+ }),
910
+ );
911
+ divElement.dispatchEvent(
912
+ new MouseEvent('mouseover', {
913
+ bubbles: true,
914
+ cancelable: true,
915
+ relatedTarget: buttonElement,
916
+ }),
917
+ );
918
+ expect(onMouseEnter).toHaveBeenCalledTimes(2);
919
+ expect(onMouseLeave).toHaveBeenCalledTimes(0);
920
+ expect(log[1]).toEqual(divElement);
921
+ });
922
+
923
+ it('should preserve bubble/capture order between roots and nested portals', () => {
924
+ const targetRef = React.createRef();
925
+ let log = [];
926
+ const onClickRoot = jest.fn(e => log.push('bubble root'));
927
+ const onClickCaptureRoot = jest.fn(e => log.push('capture root'));
928
+ const onClickPortal = jest.fn(e => log.push('bubble portal'));
929
+ const onClickCapturePortal = jest.fn(e => log.push('capture portal'));
930
+
931
+ function Portal() {
932
+ return (
933
+ <div
934
+ onClick={onClickPortal}
935
+ onClickCapture={onClickCapturePortal}
936
+ ref={targetRef}>
937
+ Click me!
938
+ </div>
939
+ );
940
+ }
941
+
942
+ const portalContainer = document.createElement('div');
943
+
944
+ let shouldStopPropagation = false;
945
+ portalContainer.addEventListener(
946
+ 'click',
947
+ e => {
948
+ if (shouldStopPropagation) {
949
+ e.stopPropagation();
950
+ }
951
+ },
952
+ false,
953
+ );
954
+
955
+ function Root() {
956
+ let portalTargetRef = React.useRef(null);
957
+ React.useLayoutEffect(() => {
958
+ portalTargetRef.current.appendChild(portalContainer);
959
+ });
960
+ return (
961
+ <div onClick={onClickRoot} onClickCapture={onClickCaptureRoot}>
962
+ <div ref={portalTargetRef} />
963
+ {ReactDOM.createPortal(<Portal />, portalContainer)}
964
+ </div>
965
+ );
966
+ }
967
+
968
+ ReactDOM.render(<Root />, container);
969
+
970
+ let divElement = targetRef.current;
971
+ dispatchClickEvent(divElement);
972
+ expect(log).toEqual([
973
+ 'capture root',
974
+ 'capture portal',
975
+ 'bubble portal',
976
+ 'bubble root',
977
+ ]);
978
+
979
+ log = [];
980
+
981
+ shouldStopPropagation = true;
982
+ dispatchClickEvent(divElement);
983
+ expect(log).toEqual([
984
+ // The events on root probably shouldn't fire if a non-React intermediated. but current behavior is that they do.
985
+ 'capture root',
986
+ 'capture portal',
987
+ 'bubble portal',
988
+ 'bubble root',
989
+ ]);
990
+ });
991
+
992
it('handle propagation of click events correctly with FB primer', () => {
993
ReactFeatureFlags.enableLegacyFBPrimerSupport = true;
994
const aRef = React.createRef();
@@ -164,8 +1033,9 @@ describe('DOMModernPluginEventSystem', () => {
1033
);
1034
}
1035
ReactDOM.render(<Test2 />, container);
1036
+ aElement = aRef.current;
1037
dispatchClickEvent(aElement);
168
- expect(onClick).toHaveBeenCalledTimes(1);
1038
+ expect(onClick).toHaveBeenCalledTimes(2);
1039
expect(log).toEqual([]);
1040
expect(onDivClick).toHaveBeenCalledTimes(0);
1041
});