diff --git a/packages/internal-test-utils/ReactInternalTestUtils.js b/packages/internal-test-utils/ReactInternalTestUtils.js index 8e68e83344241..df77f23448b13 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 0000000000000..6fe3aede714c6 --- /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 0000000000000..4dcf916a1d12c --- /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 d884b92d7fd49..f059d7bd2f560 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(() => { jest.resetModules(); @@ -23,6 +24,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', () => { @@ -143,36 +146,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 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 parentContainer = document.createElement('div'); - const handleParentMouseOut = () => { - ReactDOM.render(
2
, childContainer); - mock(childNode.textContent); - }; - - 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); @@ -191,8 +209,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);