@samitouri / QOS-React / commits / cd63ef7921

Add simulateEventDispatch to test ReactDOMEventListener (#28079)

## Overview For events, the browser will yield to microtasks between calling event handers, allowing time to flush work inbetween. For example, in the browser, this code will log the flushes between events: ```js <body onclick="console.log('body'); Promise.resolve().then(() => console.log('flush body'));"> <div onclick="console.log('div'); Promise.resolve().then(() => console.log('flush div'));"> hi </div> </body> // Logs div flush div body flush body ``` [Sandbox](https://codesandbox.io/s/eloquent-noether-mw2cjg?file=/index.html) The problem is, `dispatchEvent` (either in the browser, or JSDOM) does not yield to microtasks. Which means, this code will log the flushes after the events: ```js const target = document.getElementsByTagName("div")[0]; const nativeEvent = document.createEvent("Event"); nativeEvent.initEvent("click", true, true); target.dispatchEvent(nativeEvent); // Logs div body flush div flush body ``` ## The problem This mostly isn't a problem because React attaches event handler at the root, and calls the event handlers on components via the synthetic event system. We handle flushing between calling event handlers as needed. However, if you're mixing capture and bubbling events, or using multiple roots, then the problem of not flushing microtasks between events can come into play. This was found when converting a test to `createRoot` in https://github.com/facebook/react/pull/28050#discussion_r1462118422, and that test is an example of where this is an issue with nested roots. Here's a sandox for [discrete](https://codesandbox.io/p/sandbox/red-http-2wg8k5) and [continuous](https://codesandbox.io/p/sandbox/gracious-voice-6r7tsc?file=%2Fsrc%2Findex.js%3A25%2C28) events, showing how the test should behave. The existing test, when switched to `createRoot` matches the browser behavior for continuous events, but not discrete. Continuous events should be batched, and discrete should flush individually. ## The fix This PR implements the fix suggested by @sebmarkbage, to manually traverse the path up from the element and dispatch events, yielding between each call.

Ricky committed Feb 8, 2024 at 16:06 UTC cd63ef79218a1d53c8739da75b154014f3b7cc73
4 files changed +1102 -23
packages/internal-test-utils/ReactInternalTestUtils.js
+38
@@ -9,6 +9,7 @@ import * as SchedulerMock from 'scheduler/unstable_mock';
9 import {diff} from 'jest-diff';
10 import {equals} from '@jest/expect-utils';
11 import enqueueTask from './enqueueTask';
12 +import simulateBrowserEventDispatch from './simulateBrowserEventDispatch';
13
14 export {act} from './internalAct';
15
@@ -264,3 +265,40 @@ ${diff(expectedLog, actualLog)}
265 Error.captureStackTrace(error, assertLog);
266 throw error;
267 }
268 +
269 +// Simulates dispatching events, waiting for microtasks in between.
270 +// This matches the browser behavior, which will flush microtasks
271 +// between each event handler. This will allow discrete events to
272 +// flush between events across different event handlers.
273 +export async function simulateEventDispatch(
274 + node: Node,
275 + eventType: string,
276 +): Promise<void> {
277 + // Ensure the node is in the document.
278 + for (let current = node; current; current = current.parentNode) {
279 + if (current === document) {
280 + break;
281 + } else if (current.parentNode == null) {
282 + return;
283 + }
284 + }
285 +
286 + const customEvent = new Event(eventType, {
287 + bubbles: true,
288 + });
289 +
290 + Object.defineProperty(customEvent, 'target', {
291 + // Override the target to the node on which we dispatched the event.
292 + value: node,
293 + });
294 +
295 + const impl = Object.getOwnPropertySymbols(node)[0];
296 + const oldDispatch = node[impl].dispatchEvent;
297 + try {
298 + node[impl].dispatchEvent = simulateBrowserEventDispatch;
299 +
300 + await node.dispatchEvent(customEvent);
301 + } finally {
302 + node[impl].dispatchEvent = oldDispatch;
303 + }
304 +}
packages/internal-test-utils/__tests__/ReactInternalTestUtilsDOM-test.js new
+566
@@ -0,0 +1,566 @@
1 +/**
2 + * Copyright (c) Meta Platforms, Inc. and 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 + * @emails react-core
8 + */
9 +
10 +'use strict';
11 +
12 +let React;
13 +let act;
14 +let Scheduler;
15 +let ReactDOMClient;
16 +let simulateEventDispatch;
17 +let assertLog;
18 +
19 +describe('ReactInternalTestUtilsDOM', () => {
20 + beforeEach(() => {
21 + jest.resetModules();
22 + act = require('internal-test-utils').act;
23 + simulateEventDispatch =
24 + require('internal-test-utils').simulateEventDispatch;
25 + Scheduler = require('scheduler/unstable_mock');
26 + ReactDOMClient = require('react-dom/client');
27 + React = require('react');
28 + assertLog = require('internal-test-utils').assertLog;
29 + });
30 +
31 + describe('simulateEventDispatch', () => {
32 + it('should batch discrete capture events', async () => {
33 + let childRef;
34 + function Component() {
35 + const [state, setState] = React.useState(0);
36 + Scheduler.log(`Render ${state}`);
37 + return (
38 + <div
39 + onClickCapture={() => {
40 + queueMicrotask(() => {
41 + Scheduler.log('Parent microtask');
42 + });
43 + setState(1);
44 + Scheduler.log('onClickCapture parent');
45 + }}>
46 + <button
47 + ref={ref => (childRef = ref)}
48 + onClickCapture={() => {
49 + queueMicrotask(() => {
50 + Scheduler.log('Child microtask');
51 + });
52 + setState(2);
53 + Scheduler.log('onClickCapture child');
54 + }}
55 + />
56 + </div>
57 + );
58 + }
59 +
60 + const container = document.createElement('div');
61 + document.body.appendChild(container);
62 + const root = ReactDOMClient.createRoot(container);
63 + await act(() => {
64 + root.render(<Component />);
65 + });
66 +
67 + assertLog(['Render 0']);
68 +
69 + await act(async () => {
70 + await simulateEventDispatch(childRef, 'click');
71 + });
72 +
73 + // Capture runs on every event we dispatch,
74 + // which means we get two for the parent, and one for the child.
75 + assertLog([
76 + 'onClickCapture parent',
77 + 'onClickCapture child',
78 + 'Parent microtask',
79 + 'Render 2',
80 + 'Child microtask',
81 + ]);
82 +
83 + document.body.removeChild(container);
84 + });
85 +
86 + it('should batch continuous capture events', async () => {
87 + let childRef;
88 + function Component() {
89 + const [state, setState] = React.useState(0);
90 + Scheduler.log(`Render ${state}`);
91 + return (
92 + <div
93 + onMouseOutCapture={() => {
94 + queueMicrotask(() => {
95 + Scheduler.log('Parent microtask');
96 + });
97 + setState(1);
98 + Scheduler.log('onMouseOutCapture parent');
99 + }}>
100 + <button
101 + ref={ref => (childRef = ref)}
102 + onMouseOutCapture={() => {
103 + queueMicrotask(() => {
104 + Scheduler.log('Child microtask');
105 + });
106 + setState(2);
107 + Scheduler.log('onMouseOutCapture child');
108 + }}
109 + />
110 + </div>
111 + );
112 + }
113 +
114 + const container = document.createElement('div');
115 + document.body.appendChild(container);
116 + const root = ReactDOMClient.createRoot(container);
117 + await act(() => {
118 + root.render(<Component />);
119 + });
120 +
121 + assertLog(['Render 0']);
122 +
123 + await act(async () => {
124 + await simulateEventDispatch(childRef, 'mouseout');
125 + });
126 +
127 + assertLog([
128 + 'onMouseOutCapture parent',
129 + 'onMouseOutCapture child',
130 + 'Parent microtask',
131 + 'Child microtask',
132 + 'Render 2',
133 + ]);
134 + });
135 +
136 + it('should batch bubbling discrete events', async () => {
137 + let childRef;
138 + function Component() {
139 + const [state, setState] = React.useState(0);
140 + Scheduler.log(`Render ${state}`);
141 + return (
142 + <div
143 + onClick={() => {
144 + queueMicrotask(() => {
145 + Scheduler.log('Parent microtask');
146 + });
147 + setState(1);
148 + Scheduler.log('onClick parent');
149 + }}>
150 + <button
151 + ref={ref => (childRef = ref)}
152 + onClick={() => {
153 + queueMicrotask(() => {
154 + Scheduler.log('Child microtask');
155 + });
156 + setState(2);
157 + Scheduler.log('onClick child');
158 + }}
159 + />
160 + </div>
161 + );
162 + }
163 +
164 + const container = document.createElement('div');
165 + document.body.appendChild(container);
166 + const root = ReactDOMClient.createRoot(container);
167 + await act(() => {
168 + root.render(<Component />);
169 + });
170 +
171 + assertLog(['Render 0']);
172 +
173 + await act(async () => {
174 + await simulateEventDispatch(childRef, 'click');
175 + });
176 +
177 + assertLog([
178 + 'onClick child',
179 + 'onClick parent',
180 + 'Child microtask',
181 + 'Render 1',
182 + 'Parent microtask',
183 + ]);
184 + });
185 +
186 + it('should batch bubbling continuous events', async () => {
187 + let childRef;
188 + function Component() {
189 + const [state, setState] = React.useState(0);
190 + Scheduler.log(`Render ${state}`);
191 + return (
192 + <div
193 + onMouseOut={() => {
194 + queueMicrotask(() => {
195 + Scheduler.log('Parent microtask');
196 + });
197 + setState(1);
198 + Scheduler.log('onMouseOut parent');
199 + }}>
200 + <button
201 + ref={ref => (childRef = ref)}
202 + onMouseOut={() => {
203 + queueMicrotask(() => {
204 + Scheduler.log('Child microtask');
205 + });
206 + setState(2);
207 + Scheduler.log('onMouseOut child');
208 + }}
209 + />
210 + </div>
211 + );
212 + }
213 +
214 + const container = document.createElement('div');
215 + document.body.appendChild(container);
216 + const root = ReactDOMClient.createRoot(container);
217 + await act(() => {
218 + root.render(<Component />);
219 + });
220 +
221 + assertLog(['Render 0']);
222 +
223 + await act(async () => {
224 + await simulateEventDispatch(childRef, 'mouseout');
225 + });
226 +
227 + assertLog([
228 + 'onMouseOut child',
229 + 'onMouseOut parent',
230 + 'Child microtask',
231 + 'Parent microtask',
232 + 'Render 1',
233 + ]);
234 + });
235 +
236 + it('does not batch discrete events between handlers', async () => {
237 + let childRef = React.createRef();
238 + function Component() {
239 + const [state, setState] = React.useState(0);
240 + const parentRef = React.useRef();
241 + React.useEffect(() => {
242 + function handleParentEvent() {
243 + queueMicrotask(() => {
244 + Scheduler.log('Parent microtask');
245 + });
246 + setState(2);
247 + Scheduler.log(`Click parent`);
248 + }
249 +
250 + function handleChildEvent() {
251 + queueMicrotask(() => {
252 + Scheduler.log('Child microtask');
253 + });
254 + setState(1);
255 + Scheduler.log(`Click child`);
256 + }
257 + parentRef.current.addEventListener('click', handleParentEvent);
258 +
259 + childRef.current.addEventListener('click', handleChildEvent);
260 +
261 + return () => {
262 + parentRef.current.removeEventListener('click', handleParentEvent);
263 +
264 + childRef.current.removeEventListener('click', handleChildEvent);
265 + };
266 + });
267 +
268 + Scheduler.log(`Render ${state}`);
269 + return (
270 + <div ref={parentRef}>
271 + <button ref={childRef} />
272 + </div>
273 + );
274 + }
275 +
276 + const container = document.createElement('div');
277 + document.body.appendChild(container);
278 + const root = ReactDOMClient.createRoot(container);
279 + await act(() => {
280 + root.render(<Component />);
281 + });
282 +
283 + assertLog(['Render 0']);
284 +
285 + await act(async () => {
286 + await simulateEventDispatch(childRef.current, 'click');
287 + });
288 +
289 + assertLog([
290 + 'Click child',
291 + 'Child microtask',
292 + 'Render 1',
293 + 'Click parent',
294 + 'Parent microtask',
295 + 'Render 2',
296 + ]);
297 + });
298 +
299 + it('should batch continuous events between handlers', async () => {
300 + let childRef = React.createRef();
301 + function Component() {
302 + const [state, setState] = React.useState(0);
303 + const parentRef = React.useRef();
304 + React.useEffect(() => {
305 + function handleChildEvent() {
306 + queueMicrotask(() => {
307 + Scheduler.log('Child microtask');
308 + });
309 + setState(1);
310 + Scheduler.log(`Mouseout child`);
311 + }
312 + function handleParentEvent() {
313 + queueMicrotask(() => {
314 + Scheduler.log('Parent microtask');
315 + });
316 + setState(2);
317 + Scheduler.log(`Mouseout parent`);
318 + }
319 + parentRef.current.addEventListener('mouseout', handleParentEvent);
320 +
321 + childRef.current.addEventListener('mouseout', handleChildEvent);
322 +
323 + return () => {
324 + parentRef.current.removeEventListener(
325 + 'mouseout',
326 + handleParentEvent
327 + );
328 +
329 + childRef.current.removeEventListener('mouseout', handleChildEvent);
330 + };
331 + });
332 +
333 + Scheduler.log(`Render ${state}`);
334 + return (
335 + <div ref={parentRef}>
336 + <button ref={childRef} />
337 + </div>
338 + );
339 + }
340 +
341 + const container = document.createElement('div');
342 + document.body.appendChild(container);
343 + const root = ReactDOMClient.createRoot(container);
344 + await act(() => {
345 + root.render(<Component />);
346 + });
347 +
348 + assertLog(['Render 0']);
349 +
350 + await act(async () => {
351 + await simulateEventDispatch(childRef.current, 'mouseout');
352 + });
353 +
354 + assertLog([
355 + 'Mouseout child',
356 + 'Child microtask',
357 + 'Mouseout parent',
358 + 'Parent microtask',
359 + 'Render 2',
360 + ]);
361 + });
362 +
363 + it('should flush discrete events between handlers from different roots', async () => {
364 + const childContainer = document.createElement('div');
365 + const parentContainer = document.createElement('main');
366 +
367 + const childRoot = ReactDOMClient.createRoot(childContainer);
368 + const parentRoot = ReactDOMClient.createRoot(parentContainer);
369 + let childSetState;
370 +
371 + function Parent() {
372 + // eslint-disable-next-line no-unused-vars
373 + const [state, _] = React.useState('Parent');
374 + const handleClick = () => {
375 + Promise.resolve().then(() => Scheduler.log('Flush Parent microtask'));
376 + childSetState(2);
377 + Scheduler.log('Parent click');
378 + };
379 + return <section onClick={handleClick}>{state}</section>;
380 + }
381 +
382 + function Child() {
383 + const [state, setState] = React.useState('Child');
384 + childSetState = setState;
385 + const handleClick = () => {
386 + Promise.resolve().then(() => Scheduler.log('Flush Child microtask'));
387 + setState(1);
388 + Scheduler.log('Child click');
389 + };
390 + Scheduler.log('Render ' + state);
391 + return <span onClick={handleClick}>{state}</span>;
392 + }
393 +
394 + await act(() => {
395 + childRoot.render(<Child />);
396 + parentRoot.render(<Parent />);
397 + });
398 +
399 + const childNode = childContainer.firstChild;
400 + const parentNode = parentContainer.firstChild;
401 +
402 + parentNode.appendChild(childContainer);
403 + document.body.appendChild(parentContainer);
404 +
405 + assertLog(['Render Child']);
406 + try {
407 + await act(async () => {
408 + await simulateEventDispatch(childNode, 'click');
409 + });
410 +
411 + // Since discrete events flush in a microtasks, they flush before
412 + // the handler for the other root is called, after the microtask
413 + // scheduled in the event fires.
414 + assertLog([
415 + 'Child click',
416 + 'Flush Child microtask',
417 + 'Render 1',
418 + 'Parent click',
419 + 'Flush Parent microtask',
420 + 'Render 2',
421 + ]);
422 + } finally {
423 + document.body.removeChild(parentContainer);
424 + }
425 + });
426 +
427 + it('should batch continuous events between handlers from different roots', async () => {
428 + const childContainer = document.createElement('div');
429 + const parentContainer = document.createElement('main');
430 +
431 + const childRoot = ReactDOMClient.createRoot(childContainer);
432 + const parentRoot = ReactDOMClient.createRoot(parentContainer);
433 + let childSetState;
434 +
435 + function Parent() {
436 + // eslint-disable-next-line no-unused-vars
437 + const [state, _] = React.useState('Parent');
438 + const handleMouseOut = () => {
439 + Promise.resolve().then(() => Scheduler.log('Flush Parent microtask'));
440 + childSetState(2);
441 + Scheduler.log('Parent mouseout');
442 + };
443 + return <section onMouseOut={handleMouseOut}>{state}</section>;
444 + }
445 +
446 + function Child() {
447 + const [state, setState] = React.useState('Child');
448 + childSetState = setState;
449 + const handleMouseOut = () => {
450 + Promise.resolve().then(() => Scheduler.log('Flush Child microtask'));
451 + setState(1);
452 + Scheduler.log('Child mouseout');
453 + };
454 + Scheduler.log('Render ' + state);
455 + return <span onMouseOut={handleMouseOut}>{state}</span>;
456 + }
457 +
458 + await act(() => {
459 + childRoot.render(<Child />);
460 + parentRoot.render(<Parent />);
461 + });
462 +
463 + const childNode = childContainer.firstChild;
464 + const parentNode = parentContainer.firstChild;
465 +
466 + parentNode.appendChild(childContainer);
467 + document.body.appendChild(parentContainer);
468 +
469 + assertLog(['Render Child']);
470 + try {
471 + await act(async () => {
472 + await simulateEventDispatch(childNode, 'mouseout');
473 + });
474 +
475 + // Since continuous events flush in a macrotask, they are batched after
476 + // with the handler for the other root, but the microtasks scheduled
477 + // in the event handlers still fire in between.
478 + assertLog([
479 + 'Child mouseout',
480 + 'Flush Child microtask',
481 + 'Parent mouseout',
482 + 'Flush Parent microtask',
483 + 'Render 2',
484 + ]);
485 + } finally {
486 + document.body.removeChild(parentContainer);
487 + }
488 + });
489 +
490 + it('should fire on nodes removed while dispatching', async () => {
491 + let childRef;
492 + function Component() {
493 + const parentRef = React.useRef();
494 + const middleRef = React.useRef();
495 + Scheduler.log(`Render`);
496 + return (
497 + <div
498 + ref={parentRef}
499 + onClick={() => {
500 + Scheduler.log('onMouseOut parent');
501 + }}>
502 + <div ref={middleRef}>
503 + <button
504 + ref={ref => (childRef = ref)}
505 + onClick={() => {
506 + Scheduler.log('onMouseOut child');
507 + childRef.parentNode.remove();
508 + }}
509 + />
510 + </div>
511 + </div>
512 + );
513 + }
514 +
515 + const container = document.createElement('div');
516 + document.body.appendChild(container);
517 + const root = ReactDOMClient.createRoot(container);
518 + await act(() => {
519 + root.render(<Component />);
520 + });
521 +
522 + assertLog(['Render']);
523 +
524 + await act(async () => {
525 + await simulateEventDispatch(childRef, 'click');
526 + });
527 +
528 + assertLog(['onMouseOut child', 'onMouseOut parent']);
529 + });
530 +
531 + it('should not fire if node is not in the document', async () => {
532 + let childRef;
533 + function Component() {
534 + Scheduler.log(`Render`);
535 + return (
536 + <div
537 + onMouseOut={() => {
538 + Scheduler.log('onMouseOut parent');
539 + }}>
540 + <button
541 + ref={ref => (childRef = ref)}
542 + onMouseOut={() => {
543 + Scheduler.log('onMouseOut child');
544 + }}
545 + />
546 + </div>
547 + );
548 + }
549 +
550 + // Do not attach root to document.
551 + const root = ReactDOMClient.createRoot(document.createElement('div'));
552 + await act(() => {
553 + root.render(<Component />);
554 + });
555 +
556 + assertLog(['Render']);
557 +
558 + await act(async () => {
559 + await simulateEventDispatch(childRef, 'mouseout');
560 + });
561 +
562 + // No events flushed, root not in document.
563 + assertLog([]);
564 + });
565 + });
566 +});
packages/internal-test-utils/simulateBrowserEventDispatch.js new
+391
@@ -0,0 +1,391 @@
1 +const DOMException = require('domexception/webidl2js-wrapper');
2 +const {nodeRoot} = require('jsdom/lib/jsdom/living/helpers/node');
3 +const reportException = require('jsdom/lib/jsdom/living/helpers/runtime-script-errors');
4 +const {
5 + isNode,
6 + isShadowRoot,
7 + isSlotable,
8 + getEventTargetParent,
9 + isShadowInclusiveAncestor,
10 + retarget,
11 +} = require('jsdom/lib/jsdom/living/helpers/shadow-dom');
12 +
13 +const {waitForMicrotasks} = require('./ReactInternalTestUtils');
14 +
15 +const EVENT_PHASE = {
16 + NONE: 0,
17 + CAPTURING_PHASE: 1,
18 + AT_TARGET: 2,
19 + BUBBLING_PHASE: 3,
20 +};
21 +
22 +// Hack to get Symbol(wrapper) for target nodes.
23 +let wrapperSymbol;
24 +function wrapperForImpl(impl) {
25 + if (impl == null) {
26 + return null;
27 + }
28 +
29 + return impl[wrapperSymbol];
30 +}
31 +
32 +// This is a forked implementation of the jsdom dispatchEvent. The goal of
33 +// this fork is to match the actual browser behavior of user events more closely.
34 +// Real browser events yield to microtasks in-between event handlers, which is
35 +// different from programmatically calling dispatchEvent (which does not yield).
36 +// JSDOM correctly implements programmatic dispatchEvent, but sometimes we need
37 +// to test the behavior of real user interactions, so we simulate it.
38 +//
39 +// It's async because we need to wait for microtasks between event handlers.
40 +//
41 +// Taken from:
42 +// https://github.com/jsdom/jsdom/blob/2f8a7302a43fff92f244d5f3426367a8eb2b8896/lib/jsdom/living/events/EventTarget-impl.js#L88
43 +async function simulateEventDispatch(eventImpl) {
44 + if (eventImpl._dispatchFlag || !eventImpl._initializedFlag) {
45 + throw DOMException.create(this._globalObject, [
46 + 'Tried to dispatch an uninitialized event',
47 + 'InvalidStateError',
48 + ]);
49 + }
50 + if (eventImpl.eventPhase !== EVENT_PHASE.NONE) {
51 + throw DOMException.create(this._globalObject, [
52 + 'Tried to dispatch a dispatching event',
53 + 'InvalidStateError',
54 + ]);
55 + }
56 +
57 + eventImpl.isTrusted = false;
58 +
59 + await _dispatch.call(this, eventImpl);
60 +}
61 +
62 +async function _dispatch(eventImpl, legacyTargetOverrideFlag) {
63 + // Hack: save the wrapper Symbol.
64 + wrapperSymbol = Object.getOwnPropertySymbols(eventImpl)[0];
65 +
66 + let targetImpl = this;
67 + let clearTargets = false;
68 + let activationTarget = null;
69 +
70 + eventImpl._dispatchFlag = true;
71 +
72 + const targetOverride = legacyTargetOverrideFlag
73 + ? wrapperForImpl(targetImpl._globalObject._document)
74 + : targetImpl;
75 + let relatedTarget = retarget(eventImpl.relatedTarget, targetImpl);
76 +
77 + if (targetImpl !== relatedTarget || targetImpl === eventImpl.relatedTarget) {
78 + const touchTargets = [];
79 +
80 + appendToEventPath(
81 + eventImpl,
82 + targetImpl,
83 + targetOverride,
84 + relatedTarget,
85 + touchTargets,
86 + false,
87 + );
88 +
89 + const isActivationEvent = false; // TODO Not ported in fork.
90 +
91 + if (isActivationEvent && targetImpl._hasActivationBehavior) {
92 + activationTarget = targetImpl;
93 + }
94 +
95 + let slotInClosedTree = false;
96 + let slotable =
97 + isSlotable(targetImpl) && targetImpl._assignedSlot ? targetImpl : null;
98 + let parent = getEventTargetParent(targetImpl, eventImpl);
99 +
100 + // Populate event path
101 + // https://dom.spec.whatwg.org/#event-path
102 + while (parent !== null) {
103 + if (slotable !== null) {
104 + if (parent.localName !== 'slot') {
105 + throw new Error(`JSDOM Internal Error: Expected parent to be a Slot`);
106 + }
107 +
108 + slotable = null;
109 +
110 + const parentRoot = nodeRoot(parent);
111 + if (isShadowRoot(parentRoot) && parentRoot.mode === 'closed') {
112 + slotInClosedTree = true;
113 + }
114 + }
115 +
116 + if (isSlotable(parent) && parent._assignedSlot) {
117 + slotable = parent;
118 + }
119 +
120 + relatedTarget = retarget(eventImpl.relatedTarget, parent);
121 +
122 + if (
123 + (isNode(parent) &&
124 + isShadowInclusiveAncestor(nodeRoot(targetImpl), parent)) ||
125 + wrapperForImpl(parent).constructor.name === 'Window'
126 + ) {
127 + if (
128 + isActivationEvent &&
129 + eventImpl.bubbles &&
130 + activationTarget === null &&
131 + parent._hasActivationBehavior
132 + ) {
133 + activationTarget = parent;
134 + }
135 +
136 + appendToEventPath(
137 + eventImpl,
138 + parent,
139 + null,
140 + relatedTarget,
141 + touchTargets,
142 + slotInClosedTree,
143 + );
144 + } else if (parent === relatedTarget) {
145 + parent = null;
146 + } else {
147 + targetImpl = parent;
148 +
149 + if (
150 + isActivationEvent &&
151 + activationTarget === null &&
152 + targetImpl._hasActivationBehavior
153 + ) {
154 + activationTarget = targetImpl;
155 + }
156 +
157 + appendToEventPath(
158 + eventImpl,
159 + parent,
160 + targetImpl,
161 + relatedTarget,
162 + touchTargets,
163 + slotInClosedTree,
164 + );
165 + }
166 +
167 + if (parent !== null) {
168 + parent = getEventTargetParent(parent, eventImpl);
169 + }
170 +
171 + slotInClosedTree = false;
172 + }
173 +
174 + let clearTargetsStructIndex = -1;
175 + for (
176 + let i = eventImpl._path.length - 1;
177 + i >= 0 && clearTargetsStructIndex === -1;
178 + i--
179 + ) {
180 + if (eventImpl._path[i].target !== null) {
181 + clearTargetsStructIndex = i;
182 + }
183 + }
184 + const clearTargetsStruct = eventImpl._path[clearTargetsStructIndex];
185 +
186 + clearTargets =
187 + (isNode(clearTargetsStruct.target) &&
188 + isShadowRoot(nodeRoot(clearTargetsStruct.target))) ||
189 + (isNode(clearTargetsStruct.relatedTarget) &&
190 + isShadowRoot(nodeRoot(clearTargetsStruct.relatedTarget)));
191 +
192 + if (
193 + activationTarget !== null &&
194 + activationTarget._legacyPreActivationBehavior
195 + ) {
196 + activationTarget._legacyPreActivationBehavior();
197 + }
198 +
199 + for (let i = eventImpl._path.length - 1; i >= 0; --i) {
200 + const struct = eventImpl._path[i];
201 +
202 + if (struct.target !== null) {
203 + eventImpl.eventPhase = EVENT_PHASE.AT_TARGET;
204 + } else {
205 + eventImpl.eventPhase = EVENT_PHASE.CAPTURING_PHASE;
206 + }
207 +
208 + await invokeEventListeners(struct, eventImpl, 'capturing');
209 + }
210 +
211 + for (let i = 0; i < eventImpl._path.length; i++) {
212 + const struct = eventImpl._path[i];
213 +
214 + if (struct.target !== null) {
215 + eventImpl.eventPhase = EVENT_PHASE.AT_TARGET;
216 + } else {
217 + if (!eventImpl.bubbles) {
218 + continue;
219 + }
220 +
221 + eventImpl.eventPhase = EVENT_PHASE.BUBBLING_PHASE;
222 + }
223 +
224 + await invokeEventListeners(struct, eventImpl, 'bubbling');
225 + }
226 + }
227 +
228 + eventImpl.eventPhase = EVENT_PHASE.NONE;
229 +
230 + eventImpl.currentTarget = null;
231 + eventImpl._path = [];
232 + eventImpl._dispatchFlag = false;
233 + eventImpl._stopPropagationFlag = false;
234 + eventImpl._stopImmediatePropagationFlag = false;
235 +
236 + if (clearTargets) {
237 + eventImpl.target = null;
238 + eventImpl.relatedTarget = null;
239 + }
240 +
241 + if (activationTarget !== null) {
242 + if (!eventImpl._canceledFlag) {
243 + activationTarget._activationBehavior(eventImpl);
244 + } else if (activationTarget._legacyCanceledActivationBehavior) {
245 + activationTarget._legacyCanceledActivationBehavior();
246 + }
247 + }
248 +
249 + return !eventImpl._canceledFlag;
250 +}
251 +
252 +async function invokeEventListeners(struct, eventImpl, phase) {
253 + const structIndex = eventImpl._path.indexOf(struct);
254 + for (let i = structIndex; i >= 0; i--) {
255 + const t = eventImpl._path[i];
256 + if (t.target) {
257 + eventImpl.target = t.target;
258 + break;
259 + }
260 + }
261 +
262 + eventImpl.relatedTarget = wrapperForImpl(struct.relatedTarget);
263 +
264 + if (eventImpl._stopPropagationFlag) {
265 + return;
266 + }
267 +
268 + eventImpl.currentTarget = wrapperForImpl(struct.item);
269 +
270 + const listeners = struct.item._eventListeners;
271 + await innerInvokeEventListeners(
272 + eventImpl,
273 + listeners,
274 + phase,
275 + struct.itemInShadowTree,
276 + );
277 +}
278 +
279 +async function innerInvokeEventListeners(
280 + eventImpl,
281 + listeners,
282 + phase,
283 + itemInShadowTree,
284 +) {
285 + let found = false;
286 +
287 + const {type, target} = eventImpl;
288 + const wrapper = wrapperForImpl(target);
289 +
290 + if (!listeners || !listeners[type]) {
291 + return found;
292 + }
293 +
294 + // Copy event listeners before iterating since the list can be modified during the iteration.
295 + const handlers = listeners[type].slice();
296 +
297 + for (let i = 0; i < handlers.length; i++) {
298 + const listener = handlers[i];
299 + const {capture, once, passive} = listener.options;
300 +
301 + // Check if the event listener has been removed since the listeners has been cloned.
302 + if (!listeners[type].includes(listener)) {
303 + continue;
304 + }
305 +
306 + found = true;
307 +
308 + if (
309 + (phase === 'capturing' && !capture) ||
310 + (phase === 'bubbling' && capture)
311 + ) {
312 + continue;
313 + }
314 +
315 + if (once) {
316 + listeners[type].splice(listeners[type].indexOf(listener), 1);
317 + }
318 +
319 + let window = null;
320 + if (wrapper && wrapper._document) {
321 + // Triggered by Window
322 + window = wrapper;
323 + } else if (target._ownerDocument) {
324 + // Triggered by most webidl2js'ed instances
325 + window = target._ownerDocument._defaultView;
326 + } else if (wrapper._ownerDocument) {
327 + // Currently triggered by some non-webidl2js things
328 + window = wrapper._ownerDocument._defaultView;
329 + }
330 +
331 + let currentEvent;
332 + if (window) {
333 + currentEvent = window._currentEvent;
334 + if (!itemInShadowTree) {
335 + window._currentEvent = eventImpl;
336 + }
337 + }
338 +
339 + if (passive) {
340 + eventImpl._inPassiveListenerFlag = true;
341 + }
342 +
343 + try {
344 + listener.callback.call(eventImpl.currentTarget, eventImpl);
345 + } catch (e) {
346 + if (window) {
347 + reportException(window, e);
348 + }
349 + // Errors in window-less documents just get swallowed... can you think of anything better?
350 + }
351 +
352 + eventImpl._inPassiveListenerFlag = false;
353 +
354 + if (window) {
355 + window._currentEvent = currentEvent;
356 + }
357 +
358 + if (eventImpl._stopImmediatePropagationFlag) {
359 + return found;
360 + }
361 +
362 + // IMPORTANT: Flush microtasks
363 + await waitForMicrotasks();
364 + }
365 +
366 + return found;
367 +}
368 +
369 +function appendToEventPath(
370 + eventImpl,
371 + target,
372 + targetOverride,
373 + relatedTarget,
374 + touchTargets,
375 + slotInClosedTree,
376 +) {
377 + const itemInShadowTree = isNode(target) && isShadowRoot(nodeRoot(target));
378 + const rootOfClosedTree = isShadowRoot(target) && target.mode === 'closed';
379 +
380 + eventImpl._path.push({
381 + item: target,
382 + itemInShadowTree,
383 + target: targetOverride,
384 + relatedTarget,
385 + touchTargets,
386 + rootOfClosedTree,
387 + slotInClosedTree,
388 + });
389 +}
390 +
391 +export default simulateEventDispatch;
packages/react-dom/src/__tests__/ReactDOMEventListener-test.js
+107 -23
@@ -15,6 +15,7 @@ describe('ReactDOMEventListener', () => {
15 let ReactDOMClient;
16 let ReactDOMServer;
17 let act;
18 + let simulateEventDispatch;
19
20 beforeEach(() => {
21 React = require('react');
@@ -22,6 +23,8 @@ describe('ReactDOMEventListener', () => {
23 ReactDOMClient = require('react-dom/client');
24 ReactDOMServer = require('react-dom/server');
25 act = require('internal-test-utils').act;
26 + simulateEventDispatch =
27 + require('internal-test-utils').simulateEventDispatch;
28 });
29
30 describe('Propagation', () => {
@@ -142,36 +145,51 @@ describe('ReactDOMEventListener', () => {
145 }
146 });
147
145 - it('should batch between handlers from different roots', () => {
148 + it('should batch between handlers from different roots (discrete)', async () => {
149 const mock = jest.fn();
150
151 const childContainer = document.createElement('div');
149 - const handleChildMouseOut = () => {
150 - ReactDOM.render(<div>1</div>, childContainer);
151 - mock(childNode.textContent);
152 - };
152 + const parentContainer = document.createElement('main');
153 +
154 + const childRoot = ReactDOMClient.createRoot(childContainer);
155 + const parentRoot = ReactDOMClient.createRoot(parentContainer);
156 + let childSetState;
157 +
158 + function Parent() {
159 + // eslint-disable-next-line no-unused-vars
160 + const [state, _] = React.useState('Parent');
161 + const handleClick = () => {
162 + childSetState(2);
163 + mock(childContainer.firstChild.textContent);
164 + };
165 + return <section onClick={handleClick}>{state}</section>;
166 + }
167 +
168 + function Child() {
169 + const [state, setState] = React.useState('Child');
170 + childSetState = setState;
171 + const handleClick = () => {
172 + setState(1);
173 + mock(childContainer.firstChild.textContent);
174 + };
175 + return <span onClick={handleClick}>{state}</span>;
176 + }
177 +
178 + await act(() => {
179 + childRoot.render(<Child />);
180 + parentRoot.render(<Parent />);
181 + });
182 +
183 + const childNode = childContainer.firstChild;
184 + const parentNode = parentContainer.firstChild;
185
154 - const parentContainer = document.createElement('div');
155 - const handleParentMouseOut = () => {
156 - ReactDOM.render(<div>2</div>, childContainer);
157 - mock(childNode.textContent);
158 - };
159 -
160 - const childNode = ReactDOM.render(
161 - <div onMouseOut={handleChildMouseOut}>Child</div>,
162 - childContainer,
163 - );
164 - const parentNode = ReactDOM.render(
165 - <div onMouseOut={handleParentMouseOut}>Parent</div>,
166 - parentContainer,
167 - );
186 parentNode.appendChild(childContainer);
187 document.body.appendChild(parentContainer);
188
189 try {
172 - const nativeEvent = document.createEvent('Event');
173 - nativeEvent.initEvent('mouseout', true, true);
174 - childNode.dispatchEvent(nativeEvent);
190 + await act(async () => {
191 + await simulateEventDispatch(childNode, 'click');
192 + });
193
194 // Child and parent should both call from event handlers.
195 expect(mock).toHaveBeenCalledTimes(2);
@@ -190,8 +208,74 @@ describe('ReactDOMEventListener', () => {
208 // change anyway. We can maybe revisit this later as part of
209 // the work to refine this in the scheduler (maybe by leveraging
210 // isInputPending?).
211 + //
212 + // Since this is a discrete event, the previous update is already done.
213 expect(mock.mock.calls[1][0]).toBe('1');
194 - // By the time we leave the handler, the second update is flushed.
214 +
215 + // And by the time we leave the handler, the second update is flushed.
216 + expect(childNode.textContent).toBe('2');
217 + } finally {
218 + document.body.removeChild(parentContainer);
219 + }
220 + });
221 +
222 + it('should batch between handlers from different roots (continuous)', async () => {
223 + const mock = jest.fn();
224 +
225 + const childContainer = document.createElement('div');
226 + const parentContainer = document.createElement('main');
227 +
228 + const childRoot = ReactDOMClient.createRoot(childContainer);
229 + const parentRoot = ReactDOMClient.createRoot(parentContainer);
230 + let childSetState;
231 +
232 + function Parent() {
233 + // eslint-disable-next-line no-unused-vars
234 + const [state, _] = React.useState('Parent');
235 + const handleMouseOut = () => {
236 + childSetState(2);
237 + mock(childContainer.firstChild.textContent);
238 + };
239 + return <section onMouseOut={handleMouseOut}>{state}</section>;
240 + }
241 +
242 + function Child() {
243 + const [state, setState] = React.useState('Child');
244 + childSetState = setState;
245 + const handleMouseOut = () => {
246 + setState(1);
247 + mock(childContainer.firstChild.textContent);
248 + };
249 + return <span onMouseOut={handleMouseOut}>{state}</span>;
250 + }
251 +
252 + await act(() => {
253 + childRoot.render(<Child />);
254 + parentRoot.render(<Parent />);
255 + });
256 +
257 + const childNode = childContainer.firstChild;
258 + const parentNode = parentContainer.firstChild;
259 +
260 + parentNode.appendChild(childContainer);
261 + document.body.appendChild(parentContainer);
262 +
263 + try {
264 + await act(async () => {
265 + await simulateEventDispatch(childNode, 'mouseout');
266 + });
267 +
268 + // Child and parent should both call from event handlers.
269 + expect(mock).toHaveBeenCalledTimes(2);
270 + // The first call schedules a render of '1' into the 'Child'.
271 + // However, we're batching, so it isn't flushed yet.
272 + expect(mock.mock.calls[0][0]).toBe('Child');
273 + // As we have two roots, it means we have two event listeners.
274 + // This also means we enter the event batching phase twice.
275 + // But since this is a continuous event, we still haven't flushed.
276 + expect(mock.mock.calls[1][0]).toBe('Child');
277 +
278 + // The batched update is applied after the events.
279 expect(childNode.textContent).toBe('2');
280 } finally {
281 document.body.removeChild(parentContainer);