Repository: facebook/react
Pull request: Add simulateEventDispatch to test ReactDOMEventListener

## 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.


Changed files:
filename: packages/internal-test-utils/ReactInternalTestUtils.js
patch: @@ -9,6 +9,7 @@ import * as SchedulerMock from 'scheduler/unstable_mock';
 import {diff} from 'jest-diff';
 import {equals} from '@jest/expect-utils';
 import enqueueTask from './enqueueTask';
+import simulateBrowserEventDispatch from './simulateBrowserEventDispatch';
 
 export {act} from './internalAct';
 
@@ -264,3 +265,40 @@ ${diff(expectedLog, actualLog)}
   Error.captureStackTrace(error, assertLog);
   throw error;
 }
+
+// Simulates dispatching events, waiting for microtasks in between.
+// This matches the browser behavior, which will flush microtasks
+// between each event handler. This will allow discrete events to
+// flush between events across different event handlers.
+export async function simulateEventDispatch(
+  node: Node,
+  eventType: string,
+): Promise<void> {
+  // Ensure the node is in the document.
+  for (let current = node; current; current = current.parentNode) {
+    if (current === document) {
+      break;
+    } else if (current.parentNode == null) {
+      return;
+    }
+  }
+
+  const customEvent = new Event(eventType, {
+    bubbles: true,
+  });
+
+  Object.defineProperty(customEvent, 'target', {
+    // Override the target to the node on which we dispatched the event.
+    value: node,
+  });
+
+  const impl = Object.getOwnPropertySymbols(node)[0];
+  const oldDispatch = node[impl].dispatchEvent;
+  try {
+    node[impl].dispatchEvent = simulateBrowserEventDispatch;
+
+    await node.dispatchEvent(customEvent);
+  } finally {
+    node[impl].dispatchEvent = oldDispatch;
+  }
+}

filename: packages/internal-test-utils/__tests__/ReactInternalTestUtilsDOM-test.js
patch: @@ -0,0 +1,566 @@
+/**
+ * Copyright (c) Meta Platforms, Inc. and affiliates.
+ *
+ * This source code is licensed under the MIT license found in the
+ * LICENSE file in the root directory of this source tree.
+ *
+ * @emails react-core
+ */
+
+'use strict';
+
+let React;
+let act;
+let Scheduler;
+let ReactDOMClient;
+let simulateEventDispatch;
+let assertLog;
+
+describe('ReactInternalTestUtilsDOM', () => {
+  beforeEach(() => {
+    jest.resetModules();
+    act = require('internal-test-utils').act;
+    simulateEventDispatch =
+      require('internal-test-utils').simulateEventDispatch;
+    Scheduler = require('scheduler/unstable_mock');
+    ReactDOMClient = require('react-dom/client');
+    React = require('react');
+    assertLog = require('internal-test-utils').assertLog;
+  });
+
+  describe('simulateEventDispatch', () => {
+    it('should batch discrete capture events', async () => {
+      let childRef;
+      function Component() {
+        const [state, setState] = React.useState(0);
+        Scheduler.log(`Render ${state}`);
+        return (
+          <div
+            onClickCapture={() => {
+              queueMicrotask(() => {
+                Scheduler.log('Parent microtask');
+              });
+              setState(1);
+              Scheduler.log('onClickCapture parent');
+            }}>
+            <button
+              ref={ref => (childRef = ref)}
+              onClickCapture={() => {
+                queueMicrotask(() => {
+                  Scheduler.log('Child microtask');
+                });
+                setState(2);
+                Scheduler.log('onClickCapture child');
+              }}
+            />
+          </div>
+        );
+      }
+
+      const container = document.createElement('div');
+      document.body.appendChild(container);
+      const root = ReactDOMClient.createRoot(container);
+      await act(() => {
+        root.render(<Component />);
+      });
+
+      assertLog(['Render 0']);
+
+      await act(async () => {
+        await simulateEventDispatch(childRef, 'click');
+      });
+
+      // Capture runs on every event we dispatch,
+      // which means we get two for the parent, and one for the child.
+      assertLog([
+        'onClickCapture parent',
+        'onClickCapture child',
+        'Parent microtask',
+        'Render 2',
+        'Child microtask',
+      ]);
+
+      document.body.removeChild(container);
+    });
+
+    it('should batch continuous capture events', async () => {
+      let childRef;
+      function Component() {
+        const [state, setState] = React.useState(0);
+        Scheduler.log(`Render ${state}`);
+        return (
+          <div
+            onMouseOutCapture={() => {
+              queueMicrotask(() => {
+                Scheduler.log('Parent microtask');
+              });
+              setState(1);
+              Scheduler.log('onMouseOutCapture parent');
+            }}>
+            <button
+              ref={ref => (childRef = ref)}
+              onMouseOutCapture={() => {
+                queueMicrotask(() => {
+                  Scheduler.log('Child microtask');
+                });
+                setState(2);
+                Scheduler.log('onMouseOutCapture child');
+              }}
+            />
+          </div>
+        );
+      }
+
+      const container = document.createElement('div');
+      document.body.appendChild(container);
+      const root = ReactDOMClient.createRoot(container);
+      await act(() => {
+        root.render(<Component />);
+      });
+
+      assertLog(['Render 0']);
+
+      await act(async () => {
+        await simulateEventDispatch(childRef, 'mouseout');
+      });
+
+      assertLog([
+        'onMouseOutCapture parent',
+        'onMouseOutCapture child',
+        'Parent microtask',
+        'Child microtask',
+        'Render 2',
+      ]);
+    });
+
+    it('should batch bubbling discrete events', async () => {
+      let childRef;
+      function Component() {
+        const [state, setState] = React.useState(0);
+        Scheduler.log(`Render ${state}`);
+        return (
+          <div
+            onClick={() => {
+              queueMicrotask(() => {
+                Scheduler.log('Parent microtask');
+              });
+              setState(1);
+              Scheduler.log('onClick parent');
+            }}>
+            <button
+              ref={ref => (childRef = ref)}
+              onClick={() => {
+                queueMicrotask(() => {
+                  Scheduler.log('Child microtask');
+                });
+                setState(2);
+                Scheduler.log('onClick child');
+              }}
+            />
+          </div>
+        );
+      }
+
+      const container = document.createElement('div');
+      document.body.appendChild(container);
+      const root = ReactDOMClient.createRoot(container);
+      await act(() => {
+        root.render(<Component />);
+      });
+
+      assertLog(['Render 0']);
+
+      await act(async () => {
+        await simulateEventDispatch(childRef, 'click');
+      });
+
+      assertLog([
+        'onClick child',
+        'onClick parent',
+        'Child microtask',
+        'Render 1',
+        'Parent microtask',
+      ]);
+    });
+
+    it('should batch bubbling continuous events', async () => {
+      let childRef;
+      function Component() {
+        const [state, setState] = React.useState(0);
+        Scheduler.log(`Render ${state}`);
+        return (
+          <div
+            onMouseOut={() => {
+              queueMicrotask(() => {
+                Scheduler.log('Parent microtask');
+              });
+              setState(1);
+              Scheduler.log('onMouseOut parent');
+            }}>
+            <button
+              ref={ref => (childRef = ref)}
+              onMouseOut={() => {
+                queueMicrotask(() => {
+                  Scheduler.log('Child microtask');
+                });
+                setState(2);
+                Scheduler.log('onMouseOut child');
+              }}
+            />
+          </div>
+        );
+      }
+
+      const container = document.createElement('div');
+      document.body.appendChild(container);
+      const root = ReactDOMClient.createRoot(container);
+      await act(() => {
+        root.render(<Component />);
+      });
+
+      assertLog(['Render 0']);
+
+      await act(async () => {
+        await simulateEventDispatch(childRef, 'mouseout');
+      });
+
+      assertLog([
+        'onMouseOut child',
+        'onMouseOut parent',
+        'Child microtask',
+        'Parent microtask',
+        'Render 1',
+      ]);
+    });
+
+    it('does not batch discrete events between handlers', async () => {
+      let childRef = React.createRef();
+      function Component() {
+        const [state, setState] = React.useState(0);
+        const parentRef = React.useRef();
+        React.useEffect(() => {
+          function handleParentEvent() {
+            queueMicrotask(() => {
+              Scheduler.log('Parent microtask');
+            });
+            setState(2);
+            Scheduler.log(`Click parent`);
+          }
+
+          function handleChildEvent() {
+            queueMicrotask(() => {
+              Scheduler.log('Child microtask');
+            });
+            setState(1);
+            Scheduler.log(`Click child`);
+          }
+          parentRef.current.addEventListener('click', handleParentEvent);
+
+          childRef.current.addEventListener('click', handleChildEvent);
+
+          return () => {
+            parentRef.current.removeEventListener('click', handleParentEvent);
+
+            childRef.current.removeEventListener('click', handleChildEvent);
+          };
+        });
+
+        Scheduler.log(`Render ${state}`);
+        return (
+          <div ref={parentRef}>
+            <button ref={childRef} />
+          </div>
+        );
+      }
+
+      const container = document.createElement('div');
+      document.body.appendChild(container);
+      const root = ReactDOMClient.createRoot(container);
+      await act(() => {
+        root.render(<Component />);
+      });
+
+      assertLog(['Render 0']);
+
+      await act(async () => {
+        await simulateEventDispatch(childRef.current, 'click');
+      });
+
+      assertLog([
+        'Click child',
+        'Child microtask',
+        'Render 1',
+        'Click parent',
+        'Parent microtask',
+        'Render 2',
+      ]);
+    });
+
+    it('should batch continuous events between handlers', async () => {
+      let childRef = React.createRef();
+      function Component() {
+        const [state, setState] = React.useState(0);
+        const parentRef = React.useRef();
+        React.useEffect(() => {
+          function handleChildEvent() {
+            queueMicrotask(() => {
+              Scheduler.log('Child microtask');
+            });
+            setState(1);
+            Scheduler.log(`Mouseout child`);
+          }
+          function handleParentEvent() {
+            queueMicrotask(() => {
+              Scheduler.log('Parent microtask');
+            });
+            setState(2);
+            Scheduler.log(`Mouseout parent`);
+          }
+          parentRef.current.addEventListener('mouseout', handleParentEvent);
+
+          childRef.current.addEventListener('mouseout', handleChildEvent);
+
+          return () => {
+            parentRef.current.removeEventListener(
+              'mouseout',
+              handleParentEvent
+            );
+
+            childRef.current.removeEventListener('mouseout', handleChildEvent);
+          };
+        });
+
+        Scheduler.log(`Render ${state}`);
+        return (
+          <div ref={parentRef}>
+            <button ref={childRef} />
+          </div>
+        );
+      }
+
+      const container = document.createElement('div');
+      document.body.appendChild(container);
+      const root = ReactDOMClient.createRoot(container);
+      await act(() => {
+        root.render(<Component />);
+      });
+
+      assertLog(['Render 0']);
+
+      await act(async () => {
+        await simulateEventDispatch(childRef.current, 'mouseout');
+      });
+
+      assertLog([
+        'Mouseout child',
+        'Child microtask',
+        'Mouseout parent',
+        'Parent microtask',
+        'Render 2',
+      ]);
+    });
+
+    it('should flush discrete events between handlers from different roots', async () => {
+      const childContainer = document.createElement('div');
+      const parentContainer = document.createElement('main');
+
+      const childRoot = ReactDOMClient.createRoot(childContainer);
+      const parentRoot = ReactDOMClient.createRoot(parentContainer);
+      let childSetState;
+
+      function Parent() {
+        // eslint-disable-next-line no-unused-vars
+        const [state, _] = React.useState('Parent');
+        const handleClick = () => {
+          Promise.resolve().then(() => Scheduler.log('Flush Parent microtask'));
+          childSetState(2);
+          Scheduler.log('Parent click');
+        };
+        return <section onClick={handleClick}>{state}</section>;
+      }
+
+      function Child() {
+        const [state, setState] = React.useState('Child');
+        childSetState = setState;
+        const handleClick = () => {
+          Promise.resolve().then(() => Scheduler.log('Flush Child microtask'));
+          setState(1);
+          Scheduler.log('Child click');
+        };
+        Scheduler.log('Render ' + state);
+        return <span onClick={handleClick}>{state}</span>;
+      }
+
+      await act(() => {
+        childRoot.render(<Child />);
+        parentRoot.render(<Parent />);
+      });
+
+      const childNode = childContainer.firstChild;
+      const parentNode = parentContainer.firstChild;
+
+      parentNode.appendChild(childContainer);
+      document.body.appendChild(parentContainer);
+
+      assertLog(['Render Child']);
+      try {
+        await act(async () => {
+          await simulateEventDispatch(childNode, 'click');
+        });
+
+        // Since discrete events flush in a microtasks, they flush before
+        // the handler for the other root is called, after the microtask
+        // scheduled in the event fires.
+        assertLog([
+          'Child click',
+          'Flush Child microtask',
+          'Render 1',
+          'Parent click',
+          'Flush Parent microtask',
+          'Render 2',
+        ]);
+      } finally {
+        document.body.removeChild(parentContainer);
+      }
+    });
+
+    it('should batch continuous events between handlers from different roots', async () => {
+      const childContainer = document.createElement('div');
+      const parentContainer = document.createElement('main');
+
+      const childRoot = ReactDOMClient.createRoot(childContainer);
+      const parentRoot = ReactDOMClient.createRoot(parentContainer);
+      let childSetState;
+
+      function Parent() {
+        // eslint-disable-next-line no-unused-vars
+        const [state, _] = React.useState('Parent');
+        const handleMouseOut = () => {
+          Promise.resolve().then(() => Scheduler.log('Flush Parent microtask'));
+          childSetState(2);
+          Scheduler.log('Parent mouseout');
+        };
+        return <section onMouseOut={handleMouseOut}>{state}</section>;
+      }
+
+      function Child() {
+        const [state, setState] = React.useState('Child');
+        childSetState = setState;
+        const handleMouseOut = () => {
+          Promise.resolve().then(() => Scheduler.log('Flush Child microtask'));
+          setState(1);
+          Scheduler.log('Child mouseout');
+        };
+        Scheduler.log('Render ' + state);
+        return <span onMouseOut={handleMouseOut}>{state}</span>;
+      }
+
+      await act(() => {
+        childRoot.render(<Child />);
+        parentRoot.render(<Parent />);
+      });
+
+      const childNode = childContainer.firstChild;
+      const parentNode = parentContainer.firstChild;
+
+      parentNode.appendChild(childContainer);
+      document.body.appendChild(parentContainer);
+
+      assertLog(['Render Child']);
+      try {
+        await act(async () => {
+          await simulateEventDispatch(childNode, 'mouseout');
+        });
+
+        // Since continuous events flush in a macrotask, they are batched after
+        // with the handler for the other root, but the microtasks scheduled
+        // in the event handlers still fire in between.
+        assertLog([
+          'Child mouseout',
+          'Flush Child microtask',
+          'Parent mouseout',
+          'Flush Parent microtask',
+          'Render 2',
+        ]);
+      } finally {
+        document.body.removeChild(parentContainer);
+      }
+    });
+
+    it('should fire on nodes removed while dispatching', async () => {
+      let childRef;
+      function Component() {
+        const parentRef = React.useRef();
+        const middleRef = React.useRef();
+        Scheduler.log(`Render`);
+        return (
+          <div
+            ref={parentRef}
+            onClick={() => {
+              Scheduler.log('onMouseOut parent');
+            }}>
+            <div ref={middleRef}>
+              <button
+                ref={ref => (childRef = ref)}
+                onClick={() => {
+                  Scheduler.log('onMouseOut child');
+                  childRef.parentNode.remove();
+                }}
+              />
+            </div>
+          </div>
+        );
+      }
+
+      const container = document.createElement('div');
+      document.body.appendChild(container);
+      const root = ReactDOMClient.createRoot(container);
+      await act(() => {
+        root.render(<Component />);
+      });
+
+      assertLog(['Render']);
+
+      await act(async () => {
+        await simulateEventDispatch(childRef, 'click');
+      });
+
+      assertLog(['onMouseOut child', 'onMouseOut parent']);
+    });
+
+    it('should not fire if node is not in the document', async () => {
+      let childRef;
+      function Component() {
+        Scheduler.log(`Render`);
+        return (
+          <div
+            onMouseOut={() => {
+              Scheduler.log('onMouseOut parent');
+            }}>
+            <button
+              ref={ref => (childRef = ref)}
+              onMouseOut={() => {
+                Scheduler.log('onMouseOut child');
+              }}
+            />
+          </div>
+        );
+      }
+
+      // Do not attach root to document.
+      const root = ReactDOMClient.createRoot(document.createElement('div'));
+      await act(() => {
+        root.render(<Component />);
+      });
+
+      assertLog(['Render']);
+
+      await act(async () => {
+        await simulateEventDispatch(childRef, 'mouseout');
+      });
+
+      // No events flushed, root not in document.
+      assertLog([]);
+    });
+  });
+});

filename: packages/internal-test-utils/simulateBrowserEventDispatch.js
patch: @@ -0,0 +1,391 @@
+const DOMException = require('domexception/webidl2js-wrapper');
+const {nodeRoot} = require('jsdom/lib/jsdom/living/helpers/node');
+const reportException = require('jsdom/lib/jsdom/living/helpers/runtime-script-errors');
+const {
+  isNode,
+  isShadowRoot,
+  isSlotable,
+  getEventTargetParent,
+  isShadowInclusiveAncestor,
+  retarget,
+} = require('jsdom/lib/jsdom/living/helpers/shadow-dom');
+
+const {waitForMicrotasks} = require('./ReactInternalTestUtils');
+
+const EVENT_PHASE = {
+  NONE: 0,
+  CAPTURING_PHASE: 1,
+  AT_TARGET: 2,
+  BUBBLING_PHASE: 3,
+};
+
+// Hack to get Symbol(wrapper) for target nodes.
+let wrapperSymbol;
+function wrapperForImpl(impl) {
+  if (impl == null) {
+    return null;
+  }
+
+  return impl[wrapperSymbol];
+}
+
+// This is a forked implementation of the jsdom dispatchEvent. The goal of
+// this fork is to match the actual browser behavior of user events more closely.
+// Real browser events yield to microtasks in-between event handlers, which is
+// different from programmatically calling dispatchEvent (which does not yield).
+// JSDOM correctly implements programmatic dispatchEvent, but sometimes we need
+// to test the behavior of real user interactions, so we simulate it.
+//
+// It's async because we need to wait for microtasks between event handlers.
+//
+// Taken from:
+// https://github.com/jsdom/jsdom/blob/2f8a7302a43fff92f244d5f3426367a8eb2b8896/lib/jsdom/living/events/EventTarget-impl.js#L88
+async function simulateEventDispatch(eventImpl) {
+  if (eventImpl._dispatchFlag || !eventImpl._initializedFlag) {
+    throw DOMException.create(this._globalObject, [
+      'Tried to dispatch an uninitialized event',
+      'InvalidStateError',
+    ]);
+  }
+  if (eventImpl.eventPhase !== EVENT_PHASE.NONE) {
+    throw DOMException.create(this._globalObject, [
+      'Tried to dispatch a dispatching event',
+      'InvalidStateError',
+    ]);
+  }
+
+  eventImpl.isTrusted = false;
+
+  await _dispatch.call(this, eventImpl);
+}
+
+async function _dispatch(eventImpl, legacyTargetOverrideFlag) {
+  // Hack: save the wrapper Symbol.
+  wrapperSymbol = Object.getOwnPropertySymbols(eventImpl)[0];
+
+  let targetImpl = this;
+  let clearTargets = false;
+  let activationTarget = null;
+
+  eventImpl._dispatchFlag = true;
+
+  const targetOverride = legacyTargetOverrideFlag
+    ? wrapperForImpl(targetImpl._globalObject._document)
+    : targetImpl;
+  let relatedTarget = retarget(eventImpl.relatedTarget, targetImpl);
+
+  if (targetImpl !== relatedTarget || targetImpl === eventImpl.relatedTarget) {
+    const touchTargets = [];
+
+    appendToEventPath(
+      eventImpl,
+      targetImpl,
+      targetOverride,
+      relatedTarget,
+      touchTargets,
+      false,
+    );
+
+    const isActivationEvent = false; // TODO Not ported in fork.
+
+    if (isActivationEvent && targetImpl._hasActivationBehavior) {
+      activationTarget = targetImpl;
+    }
+
+    let slotInClosedTree = false;
+    let slotable =
+      isSlotable(targetImpl) && targetImpl._assignedSlot ? targetImpl : null;
+    let parent = getEventTargetParent(targetImpl, eventImpl);
+
+    // Populate event path
+    // https://dom.spec.whatwg.org/#event-path
+    while (parent !== null) {
+      if (slotable !== null) {
+        if (parent.localName !== 'slot') {
+          throw new Error(`JSDOM Internal Error: Expected parent to be a Slot`);
+        }
+
+        slotable = null;
+
+        const parentRoot = nodeRoot(parent);
+        if (isShadowRoot(parentRoot) && parentRoot.mode === 'closed') {
+          slotInClosedTree = true;
+        }
+      }
+
+      if (isSlotable(parent) && parent._assignedSlot) {
+        slotable = parent;
+      }
+
+      relatedTarget = retarget(eventImpl.relatedTarget, parent);
+
+      if (
+        (isNode(parent) &&
+          isShadowInclusiveAncestor(nodeRoot(targetImpl), parent)) ||
+        wrapperForImpl(parent).constructor.name === 'Window'
+      ) {
+        if (
+          isActivationEvent &&
+          eventImpl.bubbles &&
+          activationTarget === null &&
+          parent._hasActivationBehavior
+        ) {
+          activationTarget = parent;
+        }
+
+        appendToEventPath(
+          eventImpl,
+          parent,
+          null,
+          relatedTarget,
+          touchTargets,
+          slotInClosedTree,
+        );
+      } else if (parent === relatedTarget) {
+        parent = null;
+      } else {
+        targetImpl = parent;
+
+        if (
+          isActivationEvent &&
+          activationTarget === null &&
+          targetImpl._hasActivationBehavior
+        ) {
+          activationTarget = targetImpl;
+        }
+
+        appendToEventPath(
+          eventImpl,
+          parent,
+          targetImpl,
+          relatedTarget,
+          touchTargets,
+          slotInClosedTree,
+        );
+      }
+
+      if (parent !== null) {
+        parent = getEventTargetParent(parent, eventImpl);
+      }
+
+      slotInClosedTree = false;
+    }
+
+    let clearTargetsStructIndex = -1;
+    for (
+      let i = eventImpl._path.length - 1;
+      i >= 0 && clearTargetsStructIndex === -1;
+      i--
+    ) {
+      if (eventImpl._path[i].target !== null) {
+        clearTargetsStructIndex = i;
+      }
+    }
+    const clearTargetsStruct = eventImpl._path[clearTargetsStructIndex];
+
+    clearTargets =
+      (isNode(clearTargetsStruct.target) &&
+        isShadowRoot(nodeRoot(clearTargetsStruct.target))) ||
+      (isNode(clearTargetsStruct.relatedTarget) &&
+        isShadowRoot(nodeRoot(clearTargetsStruct.relatedTarget)));
+
+    if (
+      activationTarget !== null &&
+      activationTarget._legacyPreActivationBehavior
+    ) {
+      activationTarget._legacyPreActivationBehavior();
+    }
+
+    for (let i = eventImpl._path.length - 1; i >= 0; --i) {
+      const struct = eventImpl._path[i];
+
+      if (struct.target !== null) {
+        eventImpl.eventPhase = EVENT_PHASE.AT_TARGET;
+      } else {
+        eventImpl.eventPhase = EVENT_PHASE.CAPTURING_PHASE;
+      }
+
+      await invokeEventListeners(struct, eventImpl, 'capturing');
+    }
+
+    for (let i = 0; i < eventImpl._path.length; i++) {
+      const struct = eventImpl._path[i];
+
+      if (struct.target !== null) {
+        eventImpl.eventPhase = EVENT_PHASE.AT_TARGET;
+      } else {
+        if (!eventImpl.bubbles) {
+          continue;
+        }
+
+        eventImpl.eventPhase = EVENT_PHASE.BUBBLING_PHASE;
+      }
+
+      await invokeEventListeners(struct, eventImpl, 'bubbling');
+    }
+  }
+
+  eventImpl.eventPhase = EVENT_PHASE.NONE;
+
+  eventImpl.currentTarget = null;
+  eventImpl._path = [];
+  eventImpl._dispatchFlag = false;
+  eventImpl._stopPropagationFlag = false;
+  eventImpl._stopImmediatePropagationFlag = false;
+
+  if (clearTargets) {
+    eventImpl.target = null;
+    eventImpl.relatedTarget = null;
+  }
+
+  if (activationTarget !== null) {
+    if (!eventImpl._canceledFlag) {
+      activationTarget._activationBehavior(eventImpl);
+    } else if (activationTarget._legacyCanceledActivationBehavior) {
+      activationTarget._legacyCanceledActivationBehavior();
+    }
+  }
+
+  return !eventImpl._canceledFlag;
+}
+
+async function invokeEventListeners(struct, eventImpl, phase) {
+  const structIndex = eventImpl._path.indexOf(struct);
+  for (let i = structIndex; i >= 0; i--) {
+    const t = eventImpl._path[i];
+    if (t.target) {
+      eventImpl.target = t.target;
+      break;
+    }
+  }
+
+  eventImpl.relatedTarget = wrapperForImpl(struct.relatedTarget);
+
+  if (eventImpl._stopPropagationFlag) {
+    return;
+  }
+
+  eventImpl.currentTarget = wrapperForImpl(struct.item);
+
+  const listeners = struct.item._eventListeners;
+  await innerInvokeEventListeners(
+    eventImpl,
+    listeners,
+    phase,
+    struct.itemInShadowTree,
+  );
+}
+
+async function innerInvokeEventListeners(
+  eventImpl,
+  listeners,
+  phase,
+  itemInShadowTree,
+) {
+  let found = false;
+
+  const {type, target} = eventImpl;
+  const wrapper = wrapperForImpl(target);
+
+  if (!listeners || !listeners[type]) {
+    return found;
+  }
+
+  // Copy event listeners before iterating since the list can be modified during the iteration.
+  const handlers = listeners[type].slice();
+
+  for (let i = 0; i < handlers.length; i++) {
+    const listener = handlers[i];
+    const {capture, once, passive} = listener.options;
+
+    // Check if the event listener has been removed since the listeners has been cloned.
+    if (!listeners[type].includes(listener)) {
+      continue;
+    }
+
+    found = true;
+
+    if (
+      (phase === 'capturing' && !capture) ||
+      (phase === 'bubbling' && capture)
+    ) {
+      continue;
+    }
+
+    if (once) {
+      listeners[type].splice(listeners[type].indexOf(listener), 1);
+    }
+
+    let window = null;
+    if (wrapper && wrapper._document) {
+      // Triggered by Window
+      window = wrapper;
+    } else if (target._ownerDocument) {
+      // Triggered by most webidl2js'ed instances
+      window = target._ownerDocument._defaultView;
+    } else if (wrapper._ownerDocument) {
+      // Currently triggered by some non-webidl2js things
+      window = wrapper._ownerDocument._defaultView;
+    }
+
+    let currentEvent;
+    if (window) {
+      currentEvent = window._currentEvent;
+      if (!itemInShadowTree) {
+        window._currentEvent = eventImpl;
+      }
+    }
+
+    if (passive) {
+      eventImpl._inPassiveListenerFlag = true;
+    }
+
+    try {
+      listener.callback.call(eventImpl.currentTarget, eventImpl);
+    } catch (e) {
+      if (window) {
+        reportException(window, e);
+      }
+      // Errors in window-less documents just get swallowed... can you think of anything better?
+    }
+
+    eventImpl._inPassiveListenerFlag = false;
+
+    if (window) {
+      window._currentEvent = currentEvent;
+    }
+
+    if (eventImpl._stopImmediatePropagationFlag) {
+      return found;
+    }
+
+    // IMPORTANT: Flush microtasks
+    await waitForMicrotasks();
+  }
+
+  return found;
+}
+
+function appendToEventPath(
+  eventImpl,
+  target,
+  targetOverride,
+  relatedTarget,
+  touchTargets,
+  slotInClosedTree,
+) {
+  const itemInShadowTree = isNode(target) && isShadowRoot(nodeRoot(target));
+  const rootOfClosedTree = isShadowRoot(target) && target.mode === 'closed';
+
+  eventImpl._path.push({
+    item: target,
+    itemInShadowTree,
+    target: targetOverride,
+    relatedTarget,
+    touchTargets,
+    rootOfClosedTree,
+    slotInClosedTree,
+  });
+}
+
+export default simulateEventDispatch;

filename: packages/react-dom/src/__tests__/ReactDOMEventListener-test.js
patch: @@ -15,13 +15,16 @@ describe('ReactDOMEventListener', () => {
   let ReactDOMClient;
   let ReactDOMServer;
   let act;
+  let simulateEventDispatch;
 
   beforeEach(() => {
     React = require('react');
     ReactDOM = require('react-dom');
     ReactDOMClient = require('react-dom/client');
     ReactDOMServer = require('react-dom/server');
     act = require('internal-test-utils').act;
+    simulateEventDispatch =
+      require('internal-test-utils').simulateEventDispatch;
   });
 
   describe('Propagation', () => {
@@ -142,36 +145,51 @@ describe('ReactDOMEventListener', () => {
       }
     });
 
-    it('should batch between handlers from different roots', () => {
+    it('should batch between handlers from different roots (discrete)', async () => {
       const mock = jest.fn();
 
       const childContainer = document.createElement('div');
-      const handleChildMouseOut = () => {
-        ReactDOM.render(<div>1</div>, childContainer);
-        mock(childNode.textContent);
-      };
+      const parentContainer = document.createElement('main');
+
+      const childRoot = ReactDOMClient.createRoot(childContainer);
+      const parentRoot = ReactDOMClient.createRoot(parentContainer);
+      let childSetState;
+
+      function Parent() {
+        // eslint-disable-next-line no-unused-vars
+        const [state, _] = React.useState('Parent');
+        const handleClick = () => {
+          childSetState(2);
+          mock(childContainer.firstChild.textContent);
+        };
+        return <section onClick={handleClick}>{state}</section>;
+      }
+
+      function Child() {
+        const [state, setState] = React.useState('Child');
+        childSetState = setState;
+        const handleClick = () => {
+          setState(1);
+          mock(childContainer.firstChild.textContent);
+        };
+        return <span onClick={handleClick}>{state}</span>;
+      }
+
+      await act(() => {
+        childRoot.render(<Child />);
+        parentRoot.render(<Parent />);
+      });
+
+      const childNode = childContainer.firstChild;
+      const parentNode = parentContainer.firstChild;
 
-      const parentContainer = document.createElement('div');
-      const handleParentMouseOut = () => {
-        ReactDOM.render(<div>2</div>, childContainer);
-        mock(childNode.textContent);
-      };
-
-      const childNode = ReactDOM.render(
-        <div onMouseOut={handleChildMouseOut}>Child</div>,
-        childContainer,
-      );
-      const parentNode = ReactDOM.render(
-        <div onMouseOut={handleParentMouseOut}>Parent</div>,
-        parentContainer,
-      );
       parentNode.appendChild(childContainer);
       document.body.appendChild(parentContainer);
 
       try {
-        const nativeEvent = document.createEvent('Event');
-        nativeEvent.initEvent('mouseout', true, true);
-        childNode.dispatchEvent(nativeEvent);
+        await act(async () => {
+          await simulateEventDispatch(childNode, 'click');
+        });
 
         // Child and parent should both call from event handlers.
         expect(mock).toHaveBeenCalledTimes(2);
@@ -190,8 +208,74 @@ describe('ReactDOMEventListener', () => {
         // change anyway. We can maybe revisit this later as part of
         // the work to refine this in the scheduler (maybe by leveraging
         // isInputPending?).
+        //
+        // Since this is a discrete event, the previous update is already done.
         expect(mock.mock.calls[1][0]).toBe('1');
-        // By the time we leave the handler, the second update is flushed.
+
+        // And by the time we leave the handler, the second update is flushed.
+        expect(childNode.textContent).toBe('2');
+      } finally {
+        document.body.removeChild(parentContainer);
+      }
+    });
+
+    it('should batch between handlers from different roots (continuous)', async () => {
+      const mock = jest.fn();
+
+      const childContainer = document.createElement('div');
+      const parentContainer = document.createElement('main');
+
+      const childRoot = ReactDOMClient.createRoot(childContainer);
+      const parentRoot = ReactDOMClient.createRoot(parentContainer);
+      let childSetState;
+
+      function Parent() {
+        // eslint-disable-next-line no-unused-vars
+        const [state, _] = React.useState('Parent');
+        const handleMouseOut = () => {
+          childSetState(2);
+          mock(childContainer.firstChild.textContent);
+        };
+        return <section onMouseOut={handleMouseOut}>{state}</section>;
+      }
+
+      function Child() {
+        const [state, setState] = React.useState('Child');
+        childSetState = setState;
+        const handleMouseOut = () => {
+          setState(1);
+          mock(childContainer.firstChild.textContent);
+        };
+        return <span onMouseOut={handleMouseOut}>{state}</span>;
+      }
+
+      await act(() => {
+        childRoot.render(<Child />);
+        parentRoot.render(<Parent />);
+      });
+
+      const childNode = childContainer.firstChild;
+      const parentNode = parentContainer.firstChild;
+
+      parentNode.appendChild(childContainer);
+      document.body.appendChild(parentContainer);
+
+      try {
+        await act(async () => {
+          await simulateEventDispatch(childNode, 'mouseout');
+        });
+
+        // Child and parent should both call from event handlers.
+        expect(mock).toHaveBeenCalledTimes(2);
+        // The first call schedules a render of '1' into the 'Child'.
+        // However, we're batching, so it isn't flushed yet.
+        expect(mock.mock.calls[0][0]).toBe('Child');
+        // As we have two roots, it means we have two event listeners.
+        // This also means we enter the event batching phase twice.
+        // But since this is a continuous event, we still haven't flushed.
+        expect(mock.mock.calls[1][0]).toBe('Child');
+
+        // The batched update is applied after the events.
         expect(childNode.textContent).toBe('2');
       } finally {
         document.body.removeChild(parentContainer);