From cd63ef79218a1d53c8739da75b154014f3b7cc73 Mon Sep 17 00:00:00 2001 From: Ricky Date: Thu, 8 Feb 2024 16:06:03 -0500 Subject: [PATCH] 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
hi
// 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. --- .../ReactInternalTestUtils.js | 38 ++ .../ReactInternalTestUtilsDOM-test.js | 566 ++++++++++++++++++ .../simulateBrowserEventDispatch.js | 391 ++++++++++++ .../__tests__/ReactDOMEventListener-test.js | 128 +++- 4 files changed, 1101 insertions(+), 22 deletions(-) create mode 100644 packages/internal-test-utils/__tests__/ReactInternalTestUtilsDOM-test.js create mode 100644 packages/internal-test-utils/simulateBrowserEventDispatch.js diff --git a/packages/internal-test-utils/ReactInternalTestUtils.js b/packages/internal-test-utils/ReactInternalTestUtils.js index 8e68e83344..df77f23448 100644 --- a/packages/internal-test-utils/ReactInternalTestUtils.js +++ b/packages/internal-test-utils/ReactInternalTestUtils.js @@ -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 { + // 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; + } +} diff --git a/packages/internal-test-utils/__tests__/ReactInternalTestUtilsDOM-test.js b/packages/internal-test-utils/__tests__/ReactInternalTestUtilsDOM-test.js new file mode 100644 index 0000000000..6fe3aede71 --- /dev/null +++ b/packages/internal-test-utils/__tests__/ReactInternalTestUtilsDOM-test.js @@ -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 ( +
{ + queueMicrotask(() => { + Scheduler.log('Parent microtask'); + }); + setState(1); + Scheduler.log('onClickCapture parent'); + }}> +
+ ); + } + + const container = document.createElement('div'); + document.body.appendChild(container); + const root = ReactDOMClient.createRoot(container); + await act(() => { + root.render(); + }); + + 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 ( +
{ + queueMicrotask(() => { + Scheduler.log('Parent microtask'); + }); + setState(1); + Scheduler.log('onMouseOutCapture parent'); + }}> +
+ ); + } + + const container = document.createElement('div'); + document.body.appendChild(container); + const root = ReactDOMClient.createRoot(container); + await act(() => { + root.render(); + }); + + 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 ( +
{ + queueMicrotask(() => { + Scheduler.log('Parent microtask'); + }); + setState(1); + Scheduler.log('onClick parent'); + }}> +
+ ); + } + + const container = document.createElement('div'); + document.body.appendChild(container); + const root = ReactDOMClient.createRoot(container); + await act(() => { + root.render(); + }); + + 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 ( +
{ + queueMicrotask(() => { + Scheduler.log('Parent microtask'); + }); + setState(1); + Scheduler.log('onMouseOut parent'); + }}> +
+ ); + } + + const container = document.createElement('div'); + document.body.appendChild(container); + const root = ReactDOMClient.createRoot(container); + await act(() => { + root.render(); + }); + + 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 ( +
+
+ ); + } + + const container = document.createElement('div'); + document.body.appendChild(container); + const root = ReactDOMClient.createRoot(container); + await act(() => { + root.render(); + }); + + 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 ( +
+
+ ); + } + + const container = document.createElement('div'); + document.body.appendChild(container); + const root = ReactDOMClient.createRoot(container); + await act(() => { + root.render(); + }); + + 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
{state}
; + } + + 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 {state}; + } + + await act(() => { + childRoot.render(); + parentRoot.render(); + }); + + 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
{state}
; + } + + 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 {state}; + } + + await act(() => { + childRoot.render(); + parentRoot.render(); + }); + + 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 ( +
{ + Scheduler.log('onMouseOut parent'); + }}> +
+
+
+ ); + } + + const container = document.createElement('div'); + document.body.appendChild(container); + const root = ReactDOMClient.createRoot(container); + await act(() => { + root.render(); + }); + + 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 ( +
{ + Scheduler.log('onMouseOut parent'); + }}> +
+ ); + } + + // Do not attach root to document. + const root = ReactDOMClient.createRoot(document.createElement('div')); + await act(() => { + root.render(); + }); + + assertLog(['Render']); + + await act(async () => { + await simulateEventDispatch(childRef, 'mouseout'); + }); + + // No events flushed, root not in document. + assertLog([]); + }); + }); +}); diff --git a/packages/internal-test-utils/simulateBrowserEventDispatch.js b/packages/internal-test-utils/simulateBrowserEventDispatch.js new file mode 100644 index 0000000000..4dcf916a1d --- /dev/null +++ b/packages/internal-test-utils/simulateBrowserEventDispatch.js @@ -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; diff --git a/packages/react-dom/src/__tests__/ReactDOMEventListener-test.js b/packages/react-dom/src/__tests__/ReactDOMEventListener-test.js index 26382c9dc5..8a05453848 100644 --- a/packages/react-dom/src/__tests__/ReactDOMEventListener-test.js +++ b/packages/react-dom/src/__tests__/ReactDOMEventListener-test.js @@ -15,6 +15,7 @@ describe('ReactDOMEventListener', () => { let ReactDOMClient; let ReactDOMServer; let act; + let simulateEventDispatch; beforeEach(() => { React = require('react'); @@ -22,6 +23,8 @@ describe('ReactDOMEventListener', () => { 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(
1
, childContainer); - mock(childNode.textContent); - }; + const parentContainer = document.createElement('main'); - const parentContainer = document.createElement('div'); - const handleParentMouseOut = () => { - ReactDOM.render(
2
, childContainer); - mock(childNode.textContent); - }; + 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
{state}
; + } + + function Child() { + const [state, setState] = React.useState('Child'); + childSetState = setState; + const handleClick = () => { + setState(1); + mock(childContainer.firstChild.textContent); + }; + return {state}; + } + + await act(() => { + childRoot.render(); + parentRoot.render(); + }); + + const childNode = childContainer.firstChild; + const parentNode = parentContainer.firstChild; - const childNode = ReactDOM.render( -
Child
, - childContainer, - ); - const parentNode = ReactDOM.render( -
Parent
, - 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
{state}
; + } + + function Child() { + const [state, setState] = React.useState('Child'); + childSetState = setState; + const handleMouseOut = () => { + setState(1); + mock(childContainer.firstChild.textContent); + }; + return {state}; + } + + await act(() => { + childRoot.render(); + parentRoot.render(); + }); + + 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);