@samitouri / QOS-React / commits / 60b11f6c1b

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 });