diff --git a/packages/legacy-events/EventSystemFlags.js b/packages/legacy-events/EventSystemFlags.js index a3d51f3197b7..98c2fd93943b 100644 --- a/packages/legacy-events/EventSystemFlags.js +++ b/packages/legacy-events/EventSystemFlags.js @@ -16,3 +16,4 @@ export const IS_ACTIVE = 1 << 3; export const PASSIVE_NOT_SUPPORTED = 1 << 4; export const IS_REPLAYED = 1 << 5; export const IS_FIRST_ANCESTOR = 1 << 6; +export const LEGACY_FB_SUPPORT = 1 << 7; diff --git a/packages/react-dom/src/client/ReactDOMComponent.js b/packages/react-dom/src/client/ReactDOMComponent.js index 1108f4965af1..c26205fdd988 100644 --- a/packages/react-dom/src/client/ReactDOMComponent.js +++ b/packages/react-dom/src/client/ReactDOMComponent.js @@ -1365,7 +1365,6 @@ export function listenToEventResponderEventTypes( (targetEventType: any), true, passiveItem.listener, - true, ); listenerMap.delete(passiveKey); } diff --git a/packages/react-dom/src/events/DOMModernPluginEventSystem.js b/packages/react-dom/src/events/DOMModernPluginEventSystem.js index a52374bea2e9..86a569223e1c 100644 --- a/packages/react-dom/src/events/DOMModernPluginEventSystem.js +++ b/packages/react-dom/src/events/DOMModernPluginEventSystem.js @@ -24,6 +24,7 @@ import {registrationNameDependencies} from 'legacy-events/EventPluginRegistry'; import {batchedEventUpdates} from 'legacy-events/ReactGenericBatching'; import {executeDispatchesInOrder} from 'legacy-events/EventPluginUtils'; import {plugins} from 'legacy-events/EventPluginRegistry'; +import {LEGACY_FB_SUPPORT, IS_REPLAYED} from 'legacy-events/EventSystemFlags'; import {HostRoot, HostPortal} from 'shared/ReactWorkTags'; @@ -66,6 +67,7 @@ import { TOP_RATE_CHANGE, TOP_PROGRESS, TOP_PLAYING, + TOP_CLICK, } from './DOMTopLevelEventTypes'; import { getClosestInstanceFromNode, @@ -78,7 +80,7 @@ import { ELEMENT_NODE, } from '../shared/HTMLNodeType'; -import {enableLegacyFBPrimerSupport} from 'shared/ReactFeatureFlags'; +import {enableLegacyFBSupport} from 'shared/ReactFeatureFlags'; const capturePhaseEvents = new Set([ TOP_FOCUS, @@ -197,7 +199,6 @@ export function listenToTopLevelEvent( topLevelType, isCapturePhase, ((listenerEntry: any): ElementListenerMapEntry).listener, - ((listenerEntry: any): ElementListenerMapEntry).passive, ); } const listener = addTrappedEventListener( @@ -225,42 +226,24 @@ export function listenToEvent( } } -const validFBLegacyPrimerRels = new Set([ - 'dialog', - 'dialog-post', - 'async', - 'async-post', - 'theater', - 'toggle', -]); - -function willDeferLaterForFBLegacyPrimer(nativeEvent: any): boolean { - let node = nativeEvent.target; - const type = nativeEvent.type; - if (type !== 'click') { +function willDeferLaterForLegacyFBSupport( + topLevelType: DOMTopLevelEventType, + targetContainer: EventTarget, +): boolean { + if (topLevelType !== TOP_CLICK) { return false; } - while (node !== null) { - // Primer works by intercepting a click event on an element - // that has a "rel" attribute that matches one of the valid ones - // in the Set above. If we intercept this before Primer does, we - // will need to defer the current event till later and discontinue - // execution of the current event. To do this we can add a document - // event listener and continue again later after propagation. - if (node.tagName === 'A' && validFBLegacyPrimerRels.has(node.rel)) { - const legacyFBSupport = true; - const isCapture = nativeEvent.eventPhase === 1; - addTrappedEventListener( - null, - ((type: any): DOMTopLevelEventType), - isCapture, - legacyFBSupport, - ); - return true; - } - node = node.parentNode; - } - return false; + // We defer all click events with legacy FB support mode on. + // This means we add a one time event listener to trigger + // after the FB delegated listeners fire. + const isDeferredListenerForLegacyFBSupport = true; + addTrappedEventListener( + targetContainer, + topLevelType, + false, + isDeferredListenerForLegacyFBSupport, + ); + return true; } function isMatchingRootContainer( @@ -303,12 +286,19 @@ export function dispatchEventForPluginEventSystem( // TODO: useEvent for document and window return; } - // If we detect the FB legacy primer system, we + // If we are using the legacy FB support flag, we // defer the event to the null with a one // time event listener so we can defer the event. if ( - enableLegacyFBPrimerSupport && - willDeferLaterForFBLegacyPrimer(nativeEvent) + enableLegacyFBSupport && + // We do not want to defer if the event system has already been + // set to LEGACY_FB_SUPPORT. LEGACY_FB_SUPPORT only gets set when + // we call willDeferLaterForLegacyFBSupport, thus not bailing out + // will result in endless cycles like an infinite loop. + (eventSystemFlags & LEGACY_FB_SUPPORT) === 0 && + // We also don't want to defer during event replaying. + (eventSystemFlags & IS_REPLAYED) === 0 && + willDeferLaterForLegacyFBSupport(topLevelType, targetContainer) ) { return; } diff --git a/packages/react-dom/src/events/ReactDOMEventListener.js b/packages/react-dom/src/events/ReactDOMEventListener.js index cbbe9226d16d..b69ad59adac0 100644 --- a/packages/react-dom/src/events/ReactDOMEventListener.js +++ b/packages/react-dom/src/events/ReactDOMEventListener.js @@ -42,6 +42,7 @@ import { IS_PASSIVE, IS_ACTIVE, PASSIVE_NOT_SUPPORTED, + LEGACY_FB_SUPPORT, } from 'legacy-events/EventSystemFlags'; import { @@ -59,7 +60,7 @@ import {passiveBrowserEventsSupported} from './checkPassiveEvents'; import { enableDeprecatedFlareAPI, enableModernEventSystem, - enableLegacyFBPrimerSupport, + enableLegacyFBSupport, enableUseEventAPI, } from 'shared/ReactFeatureFlags'; import { @@ -129,10 +130,10 @@ export function addResponderEventSystemEvent( } export function addTrappedEventListener( - targetContainer: null | EventTarget, + targetContainer: EventTarget, topLevelType: DOMTopLevelEventType, capture: boolean, - legacyFBSupport?: boolean, + isDeferredListenerForLegacyFBSupport?: boolean, passive?: boolean, priority?: EventPriority, ): any => void { @@ -159,11 +160,15 @@ export function addTrappedEventListener( if (passive === true && !passiveBrowserEventsSupported) { passive = false; } + const eventSystemFlags = + enableLegacyFBSupport && isDeferredListenerForLegacyFBSupport + ? PLUGIN_EVENT_SYSTEM | LEGACY_FB_SUPPORT + : PLUGIN_EVENT_SYSTEM; listener = listenerWrapper.bind( null, topLevelType, - PLUGIN_EVENT_SYSTEM, + eventSystemFlags, targetContainer, ); @@ -177,14 +182,17 @@ export function addTrappedEventListener( targetContainer = document; } - const validTargetContainer = ((targetContainer: any): EventTarget); + targetContainer = + enableLegacyFBSupport && isDeferredListenerForLegacyFBSupport + ? (targetContainer: any).ownerDocument + : targetContainer; const rawEventName = getRawEventName(topLevelType); let unsubscribeListener; // When legacyFBSupport is enabled, it's for when we // want to add a one time event listener to a container. - // This should only be used with enableLegacyFBPrimerSupport + // This should only be used with enableLegacyFBSupport // due to requirement to provide compatibility with // internal FB www event tooling. This works by removing // the event listener as soon as it is invoked. We could @@ -193,14 +201,14 @@ export function addTrappedEventListener( // browsers do not support this today, and given this is // to support legacy code patterns, it's likely they'll // need support for such browsers. - if (enableLegacyFBPrimerSupport && legacyFBSupport) { + if (enableLegacyFBSupport && isDeferredListenerForLegacyFBSupport) { const originalListener = listener; listener = function(...p) { try { return originalListener.apply(this, p); } finally { removeEventListener( - validTargetContainer, + targetContainer, rawEventName, unsubscribeListener, capture, @@ -212,14 +220,14 @@ export function addTrappedEventListener( if (enableUseEventAPI && passive !== undefined) { // This is only used with passive is either true or false. unsubscribeListener = addEventCaptureListenerWithPassiveFlag( - validTargetContainer, + targetContainer, rawEventName, listener, passive, ); } else { unsubscribeListener = addEventCaptureListener( - validTargetContainer, + targetContainer, rawEventName, listener, ); @@ -228,14 +236,14 @@ export function addTrappedEventListener( if (enableUseEventAPI && passive !== undefined) { // This is only used with passive is either true or false. unsubscribeListener = addEventBubbleListenerWithPassiveFlag( - validTargetContainer, + targetContainer, rawEventName, listener, passive, ); } else { unsubscribeListener = addEventBubbleListener( - validTargetContainer, + targetContainer, rawEventName, listener, ); @@ -249,7 +257,6 @@ export function removeTrappedEventListener( topLevelType: DOMTopLevelEventType, capture: boolean, listener: any => void, - passive: void | boolean, ): void { const rawEventName = getRawEventName(topLevelType); removeEventListener(targetContainer, rawEventName, listener, capture); diff --git a/packages/react-dom/src/events/__tests__/DOMModernPluginEventSystem-test.internal.js b/packages/react-dom/src/events/__tests__/DOMModernPluginEventSystem-test.internal.js index 2ded68a6edc4..17829a86761c 100644 --- a/packages/react-dom/src/events/__tests__/DOMModernPluginEventSystem-test.internal.js +++ b/packages/react-dom/src/events/__tests__/DOMModernPluginEventSystem-test.internal.js @@ -24,1731 +24,1769 @@ function dispatchClickEvent(element) { describe('DOMModernPluginEventSystem', () => { let container; - beforeEach(() => { - jest.resetModules(); - ReactFeatureFlags = require('shared/ReactFeatureFlags'); - ReactFeatureFlags.enableModernEventSystem = true; - - React = require('react'); - ReactDOM = require('react-dom'); - Scheduler = require('scheduler'); - ReactDOMServer = require('react-dom/server'); - container = document.createElement('div'); - document.body.appendChild(container); - }); - - afterEach(() => { - document.body.removeChild(container); - container = null; - }); - - it('handle propagation of click events', () => { - const buttonRef = React.createRef(); - const divRef = React.createRef(); - const log = []; - const onClick = jest.fn(e => log.push(['bubble', e.currentTarget])); - const onClickCapture = jest.fn(e => log.push(['capture', e.currentTarget])); - - function Test() { - return ( - - ); - } - - ReactDOM.render(, container); - - let buttonElement = buttonRef.current; - dispatchClickEvent(buttonElement); - expect(onClick).toHaveBeenCalledTimes(1); - expect(onClickCapture).toHaveBeenCalledTimes(1); - expect(log[0]).toEqual(['capture', buttonElement]); - expect(log[1]).toEqual(['bubble', buttonElement]); - - let divElement = divRef.current; - dispatchClickEvent(divElement); - expect(onClick).toHaveBeenCalledTimes(3); - expect(onClickCapture).toHaveBeenCalledTimes(3); - expect(log[2]).toEqual(['capture', buttonElement]); - expect(log[3]).toEqual(['capture', divElement]); - expect(log[4]).toEqual(['bubble', divElement]); - expect(log[5]).toEqual(['bubble', buttonElement]); - }); - - it('handle propagation of click events between roots', () => { - const buttonRef = React.createRef(); - const divRef = React.createRef(); - const childRef = React.createRef(); - const log = []; - const onClick = jest.fn(e => log.push(['bubble', e.currentTarget])); - const onClickCapture = jest.fn(e => log.push(['capture', e.currentTarget])); - - function Child() { - return ( -
- Click me! -
- ); - } - - function Parent() { - return ( - - ); - } - - ReactDOM.render(, container); - ReactDOM.render(, childRef.current); - - let buttonElement = buttonRef.current; - dispatchClickEvent(buttonElement); - expect(onClick).toHaveBeenCalledTimes(1); - expect(onClickCapture).toHaveBeenCalledTimes(1); - expect(log[0]).toEqual(['capture', buttonElement]); - expect(log[1]).toEqual(['bubble', buttonElement]); - - let divElement = divRef.current; - dispatchClickEvent(divElement); - expect(onClick).toHaveBeenCalledTimes(3); - expect(onClickCapture).toHaveBeenCalledTimes(3); - expect(log[2]).toEqual(['capture', divElement]); - expect(log[3]).toEqual(['bubble', divElement]); - expect(log[4]).toEqual(['capture', buttonElement]); - expect(log[5]).toEqual(['bubble', buttonElement]); - }); - - it('handle propagation of click events between disjointed roots', () => { - const buttonRef = React.createRef(); - const divRef = React.createRef(); - const log = []; - const onClick = jest.fn(e => log.push(['bubble', e.currentTarget])); - const onClickCapture = jest.fn(e => log.push(['capture', e.currentTarget])); - - function Child() { - return ( -
- Click me! -
- ); - } - - function Parent() { - return ( - - ); - } - - // We use a comment node here, then mount to it - const disjointedNode = document.createComment( - ' react-mount-point-unstable ', - ); - ReactDOM.render(, container); - spanRef.current.appendChild(disjointedNode); - ReactDOM.render(, disjointedNode); - - let buttonElement = buttonRef.current; - dispatchClickEvent(buttonElement); - expect(onClick).toHaveBeenCalledTimes(1); - expect(onClickCapture).toHaveBeenCalledTimes(1); - expect(log[0]).toEqual(['capture', buttonElement]); - expect(log[1]).toEqual(['bubble', buttonElement]); - - let divElement = divRef.current; - dispatchClickEvent(divElement); - expect(onClick).toHaveBeenCalledTimes(3); - expect(onClickCapture).toHaveBeenCalledTimes(3); - expect(log[2]).toEqual(['capture', divElement]); - expect(log[3]).toEqual(['bubble', divElement]); - expect(log[4]).toEqual(['capture', buttonElement]); - expect(log[5]).toEqual(['bubble', buttonElement]); - }); - - it('handle propagation of click events between portals', () => { - const buttonRef = React.createRef(); - const divRef = React.createRef(); - const log = []; - const onClick = jest.fn(e => log.push(['bubble', e.currentTarget])); - const onClickCapture = jest.fn(e => log.push(['capture', e.currentTarget])); - - const portalElement = document.createElement('div'); - document.body.appendChild(portalElement); - - function Child() { - return ( -
- Click me! -
- ); - } - - function Parent() { - return ( - - ); - } - - ReactDOM.render(, container); - - let buttonElement = buttonRef.current; - dispatchClickEvent(buttonElement); - expect(onClick).toHaveBeenCalledTimes(1); - expect(onClickCapture).toHaveBeenCalledTimes(1); - expect(log[0]).toEqual(['capture', buttonElement]); - expect(log[1]).toEqual(['bubble', buttonElement]); - - let divElement = divRef.current; - dispatchClickEvent(divElement); - expect(onClick).toHaveBeenCalledTimes(3); - expect(onClickCapture).toHaveBeenCalledTimes(3); - expect(log[2]).toEqual(['capture', buttonElement]); - expect(log[3]).toEqual(['capture', divElement]); - expect(log[4]).toEqual(['bubble', divElement]); - expect(log[5]).toEqual(['bubble', buttonElement]); - - document.body.removeChild(portalElement); - }); - - it('handle click events on document.body portals', () => { - const log = []; - - function Child({label}) { - return
log.push(label)}>{label}
; - } - - function Parent() { - return ( - <> - {ReactDOM.createPortal(, document.body)} - {ReactDOM.createPortal(, document.body)} - - ); - } - - ReactDOM.render(, container); - - const second = document.body.lastChild; - expect(second.textContent).toEqual('second'); - dispatchClickEvent(second); - - expect(log).toEqual(['second']); - - const first = second.previousSibling; - expect(first.textContent).toEqual('first'); - dispatchClickEvent(first); - - expect(log).toEqual(['second', 'first']); - }); - - it.experimental( - 'does not invoke an event on a parent tree when a subtree is dehydrated', - async () => { - let suspend = false; - let resolve; - let promise = new Promise(resolvePromise => (resolve = resolvePromise)); - - let clicks = 0; - let childSlotRef = React.createRef(); - - function Parent() { - return
clicks++} ref={childSlotRef} />; - } - - function Child({text}) { - if (suspend) { - throw promise; - } else { - return Click me; - } - } - - function App() { - // The root is a Suspense boundary. - return ( - - - - ); - } + function withEnableLegacyFBSupport(enableLegacyFBSupport) { + describe( + 'enableLegacyFBSupport ' + + (enableLegacyFBSupport ? 'enabled' : 'disabled'), + () => { + beforeEach(() => { + jest.resetModules(); + ReactFeatureFlags = require('shared/ReactFeatureFlags'); + ReactFeatureFlags.enableModernEventSystem = true; + ReactFeatureFlags.enableLegacyFBSupport = enableLegacyFBSupport; + + React = require('react'); + ReactDOM = require('react-dom'); + Scheduler = require('scheduler'); + ReactDOMServer = require('react-dom/server'); + container = document.createElement('div'); + document.body.appendChild(container); + }); - suspend = false; - let finalHTML = ReactDOMServer.renderToString(); + afterEach(() => { + document.body.removeChild(container); + container = null; + }); - let parentContainer = document.createElement('div'); - let childContainer = document.createElement('div'); + it('handle propagation of click events', () => { + const buttonRef = React.createRef(); + const divRef = React.createRef(); + const log = []; + const onClick = jest.fn(e => log.push(['bubble', e.currentTarget])); + const onClickCapture = jest.fn(e => + log.push(['capture', e.currentTarget]), + ); + + function Test() { + return ( + + ); + } - // We need this to be in the document since we'll dispatch events on it. - document.body.appendChild(parentContainer); + ReactDOM.render(, container); - // We're going to use a different root as a parent. - // This lets us detect whether an event goes through React's event system. - let parentRoot = ReactDOM.createRoot(parentContainer); - parentRoot.render(); - Scheduler.unstable_flushAll(); + let buttonElement = buttonRef.current; + dispatchClickEvent(buttonElement); - childSlotRef.current.appendChild(childContainer); + expect(onClick).toHaveBeenCalledTimes(1); + expect(onClickCapture).toHaveBeenCalledTimes(1); + expect(log[0]).toEqual(['capture', buttonElement]); + expect(log[1]).toEqual(['bubble', buttonElement]); - childContainer.innerHTML = finalHTML; + let divElement = divRef.current; + dispatchClickEvent(divElement); + expect(onClick).toHaveBeenCalledTimes(3); + expect(onClickCapture).toHaveBeenCalledTimes(3); + expect(log[2]).toEqual(['capture', buttonElement]); + expect(log[3]).toEqual(['capture', divElement]); + expect(log[4]).toEqual(['bubble', divElement]); + expect(log[5]).toEqual(['bubble', buttonElement]); + }); - let a = childContainer.getElementsByTagName('a')[0]; + it('handle propagation of click events between roots', () => { + const buttonRef = React.createRef(); + const divRef = React.createRef(); + const childRef = React.createRef(); + const log = []; + const onClick = jest.fn(e => log.push(['bubble', e.currentTarget])); + const onClickCapture = jest.fn(e => + log.push(['capture', e.currentTarget]), + ); + + function Child() { + return ( +
+ Click me! +
+ ); + } - suspend = true; + function Parent() { + return ( + + ); + } - // Hydrate asynchronously. - let root = ReactDOM.createRoot(childContainer, {hydrate: true}); - root.render(); - jest.runAllTimers(); - Scheduler.unstable_flushAll(); + ReactDOM.render(, container); + ReactDOM.render(, childRef.current); + + let buttonElement = buttonRef.current; + dispatchClickEvent(buttonElement); + expect(onClick).toHaveBeenCalledTimes(1); + expect(onClickCapture).toHaveBeenCalledTimes(1); + expect(log[0]).toEqual(['capture', buttonElement]); + expect(log[1]).toEqual(['bubble', buttonElement]); + + let divElement = divRef.current; + dispatchClickEvent(divElement); + expect(onClick).toHaveBeenCalledTimes(3); + expect(onClickCapture).toHaveBeenCalledTimes(3); + expect(log[2]).toEqual(['capture', divElement]); + expect(log[3]).toEqual(['bubble', divElement]); + expect(log[4]).toEqual(['capture', buttonElement]); + expect(log[5]).toEqual(['bubble', buttonElement]); + }); - // The Suspense boundary is not yet hydrated. - a.click(); - expect(clicks).toBe(0); + it('handle propagation of click events between disjointed roots', () => { + const buttonRef = React.createRef(); + const divRef = React.createRef(); + const log = []; + const onClick = jest.fn(e => log.push(['bubble', e.currentTarget])); + const onClickCapture = jest.fn(e => + log.push(['capture', e.currentTarget]), + ); + + function Child() { + return ( +
+ Click me! +
+ ); + } - // Resolving the promise so that rendering can complete. - suspend = false; - resolve(); - await promise; + function Parent() { + return ( + + ); + } - React.useEffect(() => { - setPortal( - ReactDOM.createPortal( - log.push('child')} id="child" />, - ref.current, - ), - ); - }); - - return ( -
log.push('parent')} id="parent"> - {portal} -
- ); - } - - ReactDOM.render(, container); - - const parent = container.lastChild; - expect(parent.id).toEqual('parent'); - dispatchClickEvent(parent); - - expect(log).toEqual(['parent']); - - const child = parent.lastChild; - expect(child.id).toEqual('child'); - dispatchClickEvent(child); - - // we add both 'child' and 'parent' due to bubbling - expect(log).toEqual(['parent', 'child', 'parent']); - }); - - // Slight alteration to the last test, to catch - // a subtle difference in traversal. - it('handle click events on dynamic portals #2', () => { - const log = []; - - function Parent() { - const ref = React.useRef(null); - const [portal, setPortal] = React.useState(null); - - React.useEffect(() => { - setPortal( - ReactDOM.createPortal( - log.push('child')} id="child" />, - ref.current, - ), - ); - }); - - return ( -
log.push('parent')} id="parent"> -
{portal}
-
- ); - } - - ReactDOM.render(, container); - - const parent = container.lastChild; - expect(parent.id).toEqual('parent'); - dispatchClickEvent(parent); - - expect(log).toEqual(['parent']); - - const child = parent.lastChild; - expect(child.id).toEqual('child'); - dispatchClickEvent(child); - - // we add both 'child' and 'parent' due to bubbling - expect(log).toEqual(['parent', 'child', 'parent']); - }); - - it('native stopPropagation on click events between portals', () => { - const buttonRef = React.createRef(); - const divRef = React.createRef(); - const middelDivRef = React.createRef(); - const log = []; - const onClick = jest.fn(e => log.push(['bubble', e.currentTarget])); - const onClickCapture = jest.fn(e => log.push(['capture', e.currentTarget])); - - const portalElement = document.createElement('div'); - document.body.appendChild(portalElement); - - function Child() { - return ( -
-
- Click me! -
-
- ); - } - - function Parent() { - React.useLayoutEffect(() => { - // This should prevent the portalElement listeners from - // capturing the events in the bubble phase. - middelDivRef.current.addEventListener('click', e => { - e.stopPropagation(); - }); - }); - - return ( - - ); - } - - ReactDOM.render(, container); - - let buttonElement = buttonRef.current; - dispatchClickEvent(buttonElement); - expect(onClick).toHaveBeenCalledTimes(1); - expect(onClickCapture).toHaveBeenCalledTimes(1); - expect(log[0]).toEqual(['capture', buttonElement]); - expect(log[1]).toEqual(['bubble', buttonElement]); - - let divElement = divRef.current; - dispatchClickEvent(divElement); - expect(onClick).toHaveBeenCalledTimes(1); - expect(onClickCapture).toHaveBeenCalledTimes(1); - - document.body.removeChild(portalElement); - }); - - it('handle propagation of focus events', () => { - const buttonRef = React.createRef(); - const divRef = React.createRef(); - const log = []; - const onFocus = jest.fn(e => log.push(['bubble', e.currentTarget])); - const onFocusCapture = jest.fn(e => log.push(['capture', e.currentTarget])); - - function Test() { - return ( - - ); - } - - ReactDOM.render(, container); - - let buttonElement = buttonRef.current; - buttonElement.focus(); - expect(onFocus).toHaveBeenCalledTimes(1); - expect(onFocusCapture).toHaveBeenCalledTimes(1); - expect(log[0]).toEqual(['capture', buttonElement]); - expect(log[1]).toEqual(['bubble', buttonElement]); - - let divElement = divRef.current; - divElement.focus(); - expect(onFocus).toHaveBeenCalledTimes(3); - expect(onFocusCapture).toHaveBeenCalledTimes(3); - expect(log[2]).toEqual(['capture', buttonElement]); - expect(log[3]).toEqual(['capture', divElement]); - expect(log[4]).toEqual(['bubble', divElement]); - expect(log[5]).toEqual(['bubble', buttonElement]); - }); - - it('handle propagation of focus events between roots', () => { - const buttonRef = React.createRef(); - const divRef = React.createRef(); - const childRef = React.createRef(); - const log = []; - const onFocus = jest.fn(e => log.push(['bubble', e.currentTarget])); - const onFocusCapture = jest.fn(e => log.push(['capture', e.currentTarget])); - - function Child() { - return ( -
- Click me! -
- ); - } - - function Parent() { - return ( - - ); - } - - ReactDOM.render(, container); - ReactDOM.render(, childRef.current); - - let buttonElement = buttonRef.current; - buttonElement.focus(); - expect(onFocus).toHaveBeenCalledTimes(1); - expect(onFocusCapture).toHaveBeenCalledTimes(1); - expect(log[0]).toEqual(['capture', buttonElement]); - expect(log[1]).toEqual(['bubble', buttonElement]); - - let divElement = divRef.current; - divElement.focus(); - expect(onFocus).toHaveBeenCalledTimes(3); - expect(onFocusCapture).toHaveBeenCalledTimes(3); - expect(log[2]).toEqual(['capture', buttonElement]); - expect(log[3]).toEqual(['bubble', buttonElement]); - expect(log[4]).toEqual(['capture', divElement]); - expect(log[5]).toEqual(['bubble', divElement]); - }); - - it('handle propagation of focus events between portals', () => { - const buttonRef = React.createRef(); - const divRef = React.createRef(); - const log = []; - const onFocus = jest.fn(e => log.push(['bubble', e.currentTarget])); - const onFocusCapture = jest.fn(e => log.push(['capture', e.currentTarget])); - - const portalElement = document.createElement('div'); - document.body.appendChild(portalElement); - - function Child() { - return ( -
- Click me! -
- ); - } - - function Parent() { - return ( - - ); - } - - ReactDOM.render(, container); - - let buttonElement = buttonRef.current; - buttonElement.focus(); - expect(onFocus).toHaveBeenCalledTimes(1); - expect(onFocusCapture).toHaveBeenCalledTimes(1); - expect(log[0]).toEqual(['capture', buttonElement]); - expect(log[1]).toEqual(['bubble', buttonElement]); - - let divElement = divRef.current; - divElement.focus(); - expect(onFocus).toHaveBeenCalledTimes(3); - expect(onFocusCapture).toHaveBeenCalledTimes(3); - expect(log[2]).toEqual(['capture', buttonElement]); - expect(log[3]).toEqual(['capture', divElement]); - expect(log[4]).toEqual(['bubble', divElement]); - expect(log[5]).toEqual(['bubble', buttonElement]); - - document.body.removeChild(portalElement); - }); - - it('native stopPropagation on focus events between portals', () => { - const buttonRef = React.createRef(); - const divRef = React.createRef(); - const middelDivRef = React.createRef(); - const log = []; - const onFocus = jest.fn(e => log.push(['bubble', e.currentTarget])); - const onFocusCapture = jest.fn(e => log.push(['capture', e.currentTarget])); - - const portalElement = document.createElement('div'); - document.body.appendChild(portalElement); - - function Child() { - return ( -
-
- Click me! -
-
- ); - } - - function Parent() { - React.useLayoutEffect(() => { - // This should prevent the portalElement listeners from - // capturing the events in the bubble phase. - middelDivRef.current.addEventListener('click', e => { - e.stopPropagation(); + // We use a comment node here, then mount to it + const disjointedNode = document.createComment( + ' react-mount-point-unstable ', + ); + ReactDOM.render(, container); + spanRef.current.appendChild(disjointedNode); + ReactDOM.render(, disjointedNode); + + let buttonElement = buttonRef.current; + dispatchClickEvent(buttonElement); + expect(onClick).toHaveBeenCalledTimes(1); + expect(onClickCapture).toHaveBeenCalledTimes(1); + expect(log[0]).toEqual(['capture', buttonElement]); + expect(log[1]).toEqual(['bubble', buttonElement]); + + let divElement = divRef.current; + dispatchClickEvent(divElement); + expect(onClick).toHaveBeenCalledTimes(3); + expect(onClickCapture).toHaveBeenCalledTimes(3); + expect(log[2]).toEqual(['capture', divElement]); + expect(log[3]).toEqual(['bubble', divElement]); + expect(log[4]).toEqual(['capture', buttonElement]); + expect(log[5]).toEqual(['bubble', buttonElement]); }); - }); - - return ( - - ); - } - - ReactDOM.render(, container); - - let buttonElement = buttonRef.current; - buttonElement.focus(); - expect(onFocus).toHaveBeenCalledTimes(1); - expect(onFocusCapture).toHaveBeenCalledTimes(1); - expect(log[0]).toEqual(['capture', buttonElement]); - expect(log[1]).toEqual(['bubble', buttonElement]); - - let divElement = divRef.current; - divElement.focus(); - expect(onFocus).toHaveBeenCalledTimes(1); - expect(onFocusCapture).toHaveBeenCalledTimes(1); - - document.body.removeChild(portalElement); - }); - - it('handle propagation of enter and leave events between portals', () => { - const buttonRef = React.createRef(); - const divRef = React.createRef(); - const log = []; - const onMouseEnter = jest.fn(e => log.push(e.currentTarget)); - const onMouseLeave = jest.fn(e => log.push(e.currentTarget)); - - const portalElement = document.createElement('div'); - document.body.appendChild(portalElement); - - function Child() { - return ( -
- ); - } - - function Parent() { - return ( - - ); - } - - ReactDOM.render(, container); - - let buttonElement = buttonRef.current; - buttonElement.dispatchEvent( - new MouseEvent('mouseover', { - bubbles: true, - cancelable: true, - relatedTarget: null, - }), - ); - expect(onMouseEnter).toHaveBeenCalledTimes(1); - expect(onMouseLeave).toHaveBeenCalledTimes(0); - expect(log[0]).toEqual(buttonElement); - - let divElement = divRef.current; - buttonElement.dispatchEvent( - new MouseEvent('mouseout', { - bubbles: true, - cancelable: true, - relatedTarget: divElement, - }), - ); - divElement.dispatchEvent( - new MouseEvent('mouseover', { - bubbles: true, - cancelable: true, - relatedTarget: buttonElement, - }), - ); - expect(onMouseEnter).toHaveBeenCalledTimes(2); - expect(onMouseLeave).toHaveBeenCalledTimes(0); - expect(log[1]).toEqual(divElement); - - document.body.removeChild(portalElement); - }); - - it('handle propagation of enter and leave events between portals #2', () => { - const buttonRef = React.createRef(); - const divRef = React.createRef(); - const portalRef = React.createRef(); - const log = []; - const onMouseEnter = jest.fn(e => log.push(e.currentTarget)); - const onMouseLeave = jest.fn(e => log.push(e.currentTarget)); - - function Child() { - return ( -
- ); - } - - function Parent() { - const [portal, setPortal] = React.useState(null); - - React.useLayoutEffect(() => { - setPortal(ReactDOM.createPortal(, portalRef.current)); - }, []); - - return ( - - ); - } - - ReactDOM.render(, container); - - let buttonElement = buttonRef.current; - buttonElement.dispatchEvent( - new MouseEvent('mouseover', { - bubbles: true, - cancelable: true, - relatedTarget: null, - }), - ); - expect(onMouseEnter).toHaveBeenCalledTimes(1); - expect(onMouseLeave).toHaveBeenCalledTimes(0); - expect(log[0]).toEqual(buttonElement); - - let divElement = divRef.current; - buttonElement.dispatchEvent( - new MouseEvent('mouseout', { - bubbles: true, - cancelable: true, - relatedTarget: divElement, - }), - ); - divElement.dispatchEvent( - new MouseEvent('mouseover', { - bubbles: true, - cancelable: true, - relatedTarget: buttonElement, - }), - ); - expect(onMouseEnter).toHaveBeenCalledTimes(2); - expect(onMouseLeave).toHaveBeenCalledTimes(0); - expect(log[1]).toEqual(divElement); - }); - - it('should preserve bubble/capture order between roots and nested portals', () => { - const targetRef = React.createRef(); - let log = []; - const onClickRoot = jest.fn(e => log.push('bubble root')); - const onClickCaptureRoot = jest.fn(e => log.push('capture root')); - const onClickPortal = jest.fn(e => log.push('bubble portal')); - const onClickCapturePortal = jest.fn(e => log.push('capture portal')); - - function Portal() { - return ( -
- Click me! -
- ); - } - - const portalContainer = document.createElement('div'); - - let shouldStopPropagation = false; - portalContainer.addEventListener( - 'click', - e => { - if (shouldStopPropagation) { - e.stopPropagation(); - } - }, - false, - ); - function Root() { - let portalTargetRef = React.useRef(null); - React.useLayoutEffect(() => { - portalTargetRef.current.appendChild(portalContainer); - }); - return ( -
-
- {ReactDOM.createPortal(, portalContainer)} -
- ); - } - - ReactDOM.render(, container); - - let divElement = targetRef.current; - dispatchClickEvent(divElement); - expect(log).toEqual([ - 'capture root', - 'capture portal', - 'bubble portal', - 'bubble root', - ]); - - log = []; - - shouldStopPropagation = true; - dispatchClickEvent(divElement); - expect(log).toEqual([ - // The events on root probably shouldn't fire if a non-React intermediated. but current behavior is that they do. - 'capture root', - 'capture portal', - 'bubble portal', - 'bubble root', - ]); - }); - - it('handle propagation of click events correctly with FB primer', () => { - ReactFeatureFlags.enableLegacyFBPrimerSupport = true; - const aRef = React.createRef(); - - const log = []; - // Stop propagation throught the React system - const onClick = jest.fn(e => e.stopPropagation()); - const onDivClick = jest.fn(); - - function Test() { - return ( - - ); - } - ReactDOM.render(, container); - - // Fake primer - document.addEventListener('click', e => { - if (e.target.rel === 'dialog') { - log.push('primer'); - } - }); - let aElement = aRef.current; - dispatchClickEvent(aElement); - expect(onClick).toHaveBeenCalledTimes(1); - expect(log).toEqual(['primer']); - expect(onDivClick).toHaveBeenCalledTimes(0); - - log.length = 0; - // This isn't something that should be picked up by Primer - function Test2() { - return ( - - ); - } - ReactDOM.render(, container); - aElement = aRef.current; - dispatchClickEvent(aElement); - expect(onClick).toHaveBeenCalledTimes(2); - expect(log).toEqual([]); - expect(onDivClick).toHaveBeenCalledTimes(0); - }); - - describe('ReactDOM.useEvent', () => { - beforeEach(() => { - jest.resetModules(); - ReactFeatureFlags = require('shared/ReactFeatureFlags'); - ReactFeatureFlags.enableModernEventSystem = true; - ReactFeatureFlags.enableUseEventAPI = true; - - React = require('react'); - ReactDOM = require('react-dom'); - Scheduler = require('scheduler'); - ReactDOMServer = require('react-dom/server'); - }); - - if (!__EXPERIMENTAL__) { - it("empty test so Jest doesn't complain", () => {}); - return; - } - - it('should create the same event listener map', () => { - let listenerMaps = []; - - function Test() { - const listenerMap = ReactDOM.unstable_useEvent('click'); - - listenerMaps.push(listenerMap); - - return
; - } - - ReactDOM.render(, container); - ReactDOM.render(, container); - expect(listenerMaps.length).toEqual(2); - expect(listenerMaps[0]).toEqual(listenerMaps[1]); - }); - - it('can render correctly with the ReactDOMServer', () => { - const clickEvent = jest.fn(); - - function Test() { - const divRef = React.useRef(null); - const click = ReactDOM.unstable_useEvent('click'); - - React.useEffect(() => { - click.setListener(divRef.current, clickEvent); + it('handle propagation of click events between portals', () => { + const buttonRef = React.createRef(); + const divRef = React.createRef(); + const log = []; + const onClick = jest.fn(e => log.push(['bubble', e.currentTarget])); + const onClickCapture = jest.fn(e => + log.push(['capture', e.currentTarget]), + ); + + const portalElement = document.createElement('div'); + document.body.appendChild(portalElement); + + function Child() { + return ( +
+ Click me! +
+ ); + } + + function Parent() { + return ( + + ); + } + + ReactDOM.render(, container); + + let buttonElement = buttonRef.current; + dispatchClickEvent(buttonElement); + expect(onClick).toHaveBeenCalledTimes(1); + expect(onClickCapture).toHaveBeenCalledTimes(1); + expect(log[0]).toEqual(['capture', buttonElement]); + expect(log[1]).toEqual(['bubble', buttonElement]); + + let divElement = divRef.current; + dispatchClickEvent(divElement); + expect(onClick).toHaveBeenCalledTimes(3); + expect(onClickCapture).toHaveBeenCalledTimes(3); + expect(log[2]).toEqual(['capture', buttonElement]); + expect(log[3]).toEqual(['capture', divElement]); + expect(log[4]).toEqual(['bubble', divElement]); + expect(log[5]).toEqual(['bubble', buttonElement]); + + document.body.removeChild(portalElement); }); - return
Hello world
; - } - const output = ReactDOMServer.renderToString(); - expect(output).toBe(`
Hello world
`); - }); + it('handle click events on document.body portals', () => { + const log = []; - it('can render correctly with the ReactDOMServer hydration', () => { - const clickEvent = jest.fn(); - const spanRef = React.createRef(); + function Child({label}) { + return
log.push(label)}>{label}
; + } - function Test() { - const click = ReactDOM.unstable_useEvent('click'); + function Parent() { + return ( + <> + {ReactDOM.createPortal( + , + document.body, + )} + {ReactDOM.createPortal( + , + document.body, + )} + + ); + } - React.useEffect(() => { - click.setListener(spanRef.current, clickEvent); - }); + ReactDOM.render(, container); - return ( -
- Hello world -
- ); - } - const output = ReactDOMServer.renderToString(); - expect(output).toBe( - `
Hello world
`, - ); - container.innerHTML = output; - ReactDOM.hydrate(, container); - Scheduler.unstable_flushAll(); - dispatchClickEvent(spanRef.current); - expect(clickEvent).toHaveBeenCalledTimes(1); - }); - - it('should correctly work for a basic "click" listener', () => { - let log = []; - const clickEvent = jest.fn(event => { - log.push({ - eventPhase: event.eventPhase, - type: event.type, - currentTarget: event.currentTarget, - target: event.target, - }); - }); - const divRef = React.createRef(); - const buttonRef = React.createRef(); + const second = document.body.lastChild; + expect(second.textContent).toEqual('second'); + dispatchClickEvent(second); + + expect(log).toEqual(['second']); - function Test() { - const click = ReactDOM.unstable_useEvent('click'); + const first = second.previousSibling; + expect(first.textContent).toEqual('first'); + dispatchClickEvent(first); - React.useEffect(() => { - click.setListener(buttonRef.current, clickEvent); + expect(log).toEqual(['second', 'first']); }); - return ( - + it.experimental( + 'does not invoke an event on a parent tree when a subtree is dehydrated', + async () => { + let suspend = false; + let resolve; + let promise = new Promise( + resolvePromise => (resolve = resolvePromise), + ); + + let clicks = 0; + let childSlotRef = React.createRef(); + + function Parent() { + return
clicks++} ref={childSlotRef} />; + } + + function Child({text}) { + if (suspend) { + throw promise; + } else { + return Click me; + } + } + + function App() { + // The root is a Suspense boundary. + return ( + + + + ); + } + + suspend = false; + let finalHTML = ReactDOMServer.renderToString(); + + let parentContainer = document.createElement('div'); + let childContainer = document.createElement('div'); + + // We need this to be in the document since we'll dispatch events on it. + document.body.appendChild(parentContainer); + + // We're going to use a different root as a parent. + // This lets us detect whether an event goes through React's event system. + let parentRoot = ReactDOM.createRoot(parentContainer); + parentRoot.render(); + Scheduler.unstable_flushAll(); + + childSlotRef.current.appendChild(childContainer); + + childContainer.innerHTML = finalHTML; + + let a = childContainer.getElementsByTagName('a')[0]; + + suspend = true; + + // Hydrate asynchronously. + let root = ReactDOM.createRoot(childContainer, {hydrate: true}); + root.render(); + jest.runAllTimers(); + Scheduler.unstable_flushAll(); + + // The Suspense boundary is not yet hydrated. + a.click(); + expect(clicks).toBe(0); + + // Resolving the promise so that rendering can complete. + suspend = false; + resolve(); + await promise; + + Scheduler.unstable_flushAll(); + jest.runAllTimers(); + + // We're now full hydrated. + + expect(clicks).toBe(1); + + document.body.removeChild(parentContainer); + }, ); - } - - ReactDOM.render(, container); - Scheduler.unstable_flushAll(); - - expect(container.innerHTML).toBe(''); - - // Clicking the button should trigger the event callback - let divElement = divRef.current; - dispatchClickEvent(divElement); - expect(log).toEqual([ - { - eventPhase: 3, - type: 'click', - currentTarget: buttonRef.current, - target: divRef.current, - }, - ]); - expect(clickEvent).toBeCalledTimes(1); - - // Unmounting the container and clicking should not work - ReactDOM.render(null, container); - Scheduler.unstable_flushAll(); - - dispatchClickEvent(divElement); - expect(clickEvent).toBeCalledTimes(1); - - // Re-rendering the container and clicking should work - ReactDOM.render(, container); - Scheduler.unstable_flushAll(); - - divElement = divRef.current; - dispatchClickEvent(divElement); - expect(clickEvent).toBeCalledTimes(2); - - log = []; - - // Clicking the button should also work - let buttonElement = buttonRef.current; - dispatchClickEvent(buttonElement); - expect(log).toEqual([ - { - eventPhase: 3, - type: 'click', - currentTarget: buttonRef.current, - target: buttonRef.current, - }, - ]); - - function Test2({clickEvent2}) { - const click = ReactDOM.unstable_useEvent('click', clickEvent2); - - React.useEffect(() => { - click.setListener(buttonRef.current, clickEvent2); + + it('handle click events on dynamic portals', () => { + const log = []; + + function Parent() { + const ref = React.useRef(null); + const [portal, setPortal] = React.useState(null); + + React.useEffect(() => { + setPortal( + ReactDOM.createPortal( + log.push('child')} id="child" />, + ref.current, + ), + ); + }); + + return ( +
log.push('parent')} id="parent"> + {portal} +
+ ); + } + + ReactDOM.render(, container); + + const parent = container.lastChild; + expect(parent.id).toEqual('parent'); + dispatchClickEvent(parent); + + expect(log).toEqual(['parent']); + + const child = parent.lastChild; + expect(child.id).toEqual('child'); + dispatchClickEvent(child); + + // we add both 'child' and 'parent' due to bubbling + expect(log).toEqual(['parent', 'child', 'parent']); }); - return ( - - ); - } + // Slight alteration to the last test, to catch + // a subtle difference in traversal. + it('handle click events on dynamic portals #2', () => { + const log = []; + + function Parent() { + const ref = React.useRef(null); + const [portal, setPortal] = React.useState(null); + + React.useEffect(() => { + setPortal( + ReactDOM.createPortal( + log.push('child')} id="child" />, + ref.current, + ), + ); + }); + + return ( +
log.push('parent')} id="parent"> +
{portal}
+
+ ); + } + + ReactDOM.render(, container); + + const parent = container.lastChild; + expect(parent.id).toEqual('parent'); + dispatchClickEvent(parent); + + expect(log).toEqual(['parent']); + + const child = parent.lastChild; + expect(child.id).toEqual('child'); + dispatchClickEvent(child); - let clickEvent2 = jest.fn(); - ReactDOM.render(, container); - Scheduler.unstable_flushAll(); + // we add both 'child' and 'parent' due to bubbling + expect(log).toEqual(['parent', 'child', 'parent']); + }); - divElement = divRef.current; - dispatchClickEvent(divElement); - expect(clickEvent2).toBeCalledTimes(1); + it('native stopPropagation on click events between portals', () => { + const buttonRef = React.createRef(); + const divRef = React.createRef(); + const middelDivRef = React.createRef(); + const log = []; + const onClick = jest.fn(e => log.push(['bubble', e.currentTarget])); + const onClickCapture = jest.fn(e => + log.push(['capture', e.currentTarget]), + ); + + const portalElement = document.createElement('div'); + document.body.appendChild(portalElement); + + function Child() { + return ( +
+
+ Click me! +
+
+ ); + } - // Reset the function we pass in, so it's different - clickEvent2 = jest.fn(); - ReactDOM.render(, container); - Scheduler.unstable_flushAll(); + function Parent() { + React.useLayoutEffect(() => { + // This should prevent the portalElement listeners from + // capturing the events in the bubble phase. + middelDivRef.current.addEventListener('click', e => { + e.stopPropagation(); + }); + }); + + return ( + + ); + } - divElement = divRef.current; - dispatchClickEvent(divElement); - expect(clickEvent2).toBeCalledTimes(1); - }); + ReactDOM.render(, container); - it('should correctly work for setting and clearing a basic "click" listener', () => { - const clickEvent = jest.fn(); - const divRef = React.createRef(); - const buttonRef = React.createRef(); + let buttonElement = buttonRef.current; + dispatchClickEvent(buttonElement); + expect(onClick).toHaveBeenCalledTimes(1); + expect(onClickCapture).toHaveBeenCalledTimes(1); + expect(log[0]).toEqual(['capture', buttonElement]); + expect(log[1]).toEqual(['bubble', buttonElement]); - function Test({off}) { - const click = ReactDOM.unstable_useEvent('click'); + let divElement = divRef.current; + dispatchClickEvent(divElement); + expect(onClick).toHaveBeenCalledTimes(1); + expect(onClickCapture).toHaveBeenCalledTimes(1); - React.useEffect(() => { - click.setListener(buttonRef.current, clickEvent); + document.body.removeChild(portalElement); }); - React.useEffect(() => { - if (off) { - click.setListener(buttonRef.current, null); + it('handle propagation of focus events', () => { + const buttonRef = React.createRef(); + const divRef = React.createRef(); + const log = []; + const onFocus = jest.fn(e => log.push(['bubble', e.currentTarget])); + const onFocusCapture = jest.fn(e => + log.push(['capture', e.currentTarget]), + ); + + function Test() { + return ( + + ); } - }, [off]); - return ( - - ); - } - - ReactDOM.render(, container); - Scheduler.unstable_flushAll(); - - let divElement = divRef.current; - dispatchClickEvent(divElement); - expect(clickEvent).toBeCalledTimes(1); - - // The listener should get unmounted in the second effect - ReactDOM.render(, container); - Scheduler.unstable_flushAll(); - - clickEvent.mockClear(); - - divElement = divRef.current; - dispatchClickEvent(divElement); - expect(clickEvent).toBeCalledTimes(0); - }); - - it('handle propagation of click events', () => { - const buttonRef = React.createRef(); - const divRef = React.createRef(); - const log = []; - const onClick = jest.fn(e => log.push(['bubble', e.currentTarget])); - const onClickCapture = jest.fn(e => - log.push(['capture', e.currentTarget]), - ); - - function Test() { - const click = ReactDOM.unstable_useEvent('click'); - const clickCapture = ReactDOM.unstable_useEvent('click', { - capture: true, + ReactDOM.render(, container); + + let buttonElement = buttonRef.current; + buttonElement.focus(); + expect(onFocus).toHaveBeenCalledTimes(1); + expect(onFocusCapture).toHaveBeenCalledTimes(1); + expect(log[0]).toEqual(['capture', buttonElement]); + expect(log[1]).toEqual(['bubble', buttonElement]); + + let divElement = divRef.current; + divElement.focus(); + expect(onFocus).toHaveBeenCalledTimes(3); + expect(onFocusCapture).toHaveBeenCalledTimes(3); + expect(log[2]).toEqual(['capture', buttonElement]); + expect(log[3]).toEqual(['capture', divElement]); + expect(log[4]).toEqual(['bubble', divElement]); + expect(log[5]).toEqual(['bubble', buttonElement]); }); - React.useEffect(() => { - click.setListener(buttonRef.current, onClick); - clickCapture.setListener(buttonRef.current, onClickCapture); - click.setListener(divRef.current, onClick); - clickCapture.setListener(divRef.current, onClickCapture); + it('handle propagation of focus events between roots', () => { + const buttonRef = React.createRef(); + const divRef = React.createRef(); + const childRef = React.createRef(); + const log = []; + const onFocus = jest.fn(e => log.push(['bubble', e.currentTarget])); + const onFocusCapture = jest.fn(e => + log.push(['capture', e.currentTarget]), + ); + + function Child() { + return ( +
+ Click me! +
+ ); + } + + function Parent() { + return ( + + ); + } + + ReactDOM.render(, container); + ReactDOM.render(, childRef.current); + + let buttonElement = buttonRef.current; + buttonElement.focus(); + expect(onFocus).toHaveBeenCalledTimes(1); + expect(onFocusCapture).toHaveBeenCalledTimes(1); + expect(log[0]).toEqual(['capture', buttonElement]); + expect(log[1]).toEqual(['bubble', buttonElement]); + + let divElement = divRef.current; + divElement.focus(); + expect(onFocus).toHaveBeenCalledTimes(3); + expect(onFocusCapture).toHaveBeenCalledTimes(3); + expect(log[2]).toEqual(['capture', buttonElement]); + expect(log[3]).toEqual(['bubble', buttonElement]); + expect(log[4]).toEqual(['capture', divElement]); + expect(log[5]).toEqual(['bubble', divElement]); }); - return ( - - ); - } - - ReactDOM.render(, container); - Scheduler.unstable_flushAll(); - - let buttonElement = buttonRef.current; - dispatchClickEvent(buttonElement); - expect(onClick).toHaveBeenCalledTimes(1); - expect(onClickCapture).toHaveBeenCalledTimes(1); - expect(log[0]).toEqual(['capture', buttonElement]); - expect(log[1]).toEqual(['bubble', buttonElement]); - - let divElement = divRef.current; - dispatchClickEvent(divElement); - expect(onClick).toHaveBeenCalledTimes(3); - expect(onClickCapture).toHaveBeenCalledTimes(3); - expect(log[2]).toEqual(['capture', buttonElement]); - expect(log[3]).toEqual(['capture', divElement]); - expect(log[4]).toEqual(['bubble', divElement]); - expect(log[5]).toEqual(['bubble', buttonElement]); - }); - - it('handle propagation of click events mixed with onClick events', () => { - const buttonRef = React.createRef(); - const divRef = React.createRef(); - const log = []; - const onClick = jest.fn(e => log.push(['bubble', e.currentTarget])); - const onClickCapture = jest.fn(e => - log.push(['capture', e.currentTarget]), - ); - - function Test() { - const click = ReactDOM.unstable_useEvent('click'); - const clickCapture = ReactDOM.unstable_useEvent('click', { - capture: true, + it('handle propagation of focus events between portals', () => { + const buttonRef = React.createRef(); + const divRef = React.createRef(); + const log = []; + const onFocus = jest.fn(e => log.push(['bubble', e.currentTarget])); + const onFocusCapture = jest.fn(e => + log.push(['capture', e.currentTarget]), + ); + + const portalElement = document.createElement('div'); + document.body.appendChild(portalElement); + + function Child() { + return ( +
+ Click me! +
+ ); + } + + function Parent() { + return ( + + ); + } + + ReactDOM.render(, container); + + let buttonElement = buttonRef.current; + buttonElement.focus(); + expect(onFocus).toHaveBeenCalledTimes(1); + expect(onFocusCapture).toHaveBeenCalledTimes(1); + expect(log[0]).toEqual(['capture', buttonElement]); + expect(log[1]).toEqual(['bubble', buttonElement]); + + let divElement = divRef.current; + divElement.focus(); + expect(onFocus).toHaveBeenCalledTimes(3); + expect(onFocusCapture).toHaveBeenCalledTimes(3); + expect(log[2]).toEqual(['capture', buttonElement]); + expect(log[3]).toEqual(['capture', divElement]); + expect(log[4]).toEqual(['bubble', divElement]); + expect(log[5]).toEqual(['bubble', buttonElement]); + + document.body.removeChild(portalElement); }); - React.useEffect(() => { - click.setListener(buttonRef.current, onClick); - clickCapture.setListener(buttonRef.current, onClickCapture); + it('native stopPropagation on focus events between portals', () => { + const buttonRef = React.createRef(); + const divRef = React.createRef(); + const middelDivRef = React.createRef(); + const log = []; + const onFocus = jest.fn(e => log.push(['bubble', e.currentTarget])); + const onFocusCapture = jest.fn(e => + log.push(['capture', e.currentTarget]), + ); + + const portalElement = document.createElement('div'); + document.body.appendChild(portalElement); + + function Child() { + return ( +
+
+ Click me! +
+
+ ); + } + + function Parent() { + React.useLayoutEffect(() => { + // This should prevent the portalElement listeners from + // capturing the events in the bubble phase. + middelDivRef.current.addEventListener('click', e => { + e.stopPropagation(); + }); + }); + + return ( + + ); + } + + ReactDOM.render(, container); + + let buttonElement = buttonRef.current; + buttonElement.focus(); + expect(onFocus).toHaveBeenCalledTimes(1); + expect(onFocusCapture).toHaveBeenCalledTimes(1); + expect(log[0]).toEqual(['capture', buttonElement]); + expect(log[1]).toEqual(['bubble', buttonElement]); + + let divElement = divRef.current; + divElement.focus(); + expect(onFocus).toHaveBeenCalledTimes(1); + expect(onFocusCapture).toHaveBeenCalledTimes(1); + + document.body.removeChild(portalElement); }); - return ( - - ); - } - - ReactDOM.render(, container); - Scheduler.unstable_flushAll(); - - let buttonElement = buttonRef.current; - dispatchClickEvent(buttonElement); - expect(onClick).toHaveBeenCalledTimes(1); - expect(onClickCapture).toHaveBeenCalledTimes(1); - expect(log[0]).toEqual(['capture', buttonElement]); - expect(log[1]).toEqual(['bubble', buttonElement]); - - let divElement = divRef.current; - dispatchClickEvent(divElement); - expect(onClick).toHaveBeenCalledTimes(3); - expect(onClickCapture).toHaveBeenCalledTimes(3); - expect(log[2]).toEqual(['capture', buttonElement]); - expect(log[3]).toEqual(['capture', divElement]); - expect(log[4]).toEqual(['bubble', divElement]); - expect(log[5]).toEqual(['bubble', buttonElement]); - }); - - it('should correctly work for a basic "click" listener on the outer target', () => { - const log = []; - const clickEvent = jest.fn(event => { - log.push({ - eventPhase: event.eventPhase, - type: event.type, - currentTarget: event.currentTarget, - target: event.target, + it('handle propagation of enter and leave events between portals', () => { + const buttonRef = React.createRef(); + const divRef = React.createRef(); + const log = []; + const onMouseEnter = jest.fn(e => log.push(e.currentTarget)); + const onMouseLeave = jest.fn(e => log.push(e.currentTarget)); + + const portalElement = document.createElement('div'); + document.body.appendChild(portalElement); + + function Child() { + return ( +
+ ); + } + + function Parent() { + return ( + + ); + } + + ReactDOM.render(, container); + + let buttonElement = buttonRef.current; + buttonElement.dispatchEvent( + new MouseEvent('mouseover', { + bubbles: true, + cancelable: true, + relatedTarget: null, + }), + ); + expect(onMouseEnter).toHaveBeenCalledTimes(1); + expect(onMouseLeave).toHaveBeenCalledTimes(0); + expect(log[0]).toEqual(buttonElement); + + let divElement = divRef.current; + buttonElement.dispatchEvent( + new MouseEvent('mouseout', { + bubbles: true, + cancelable: true, + relatedTarget: divElement, + }), + ); + divElement.dispatchEvent( + new MouseEvent('mouseover', { + bubbles: true, + cancelable: true, + relatedTarget: buttonElement, + }), + ); + expect(onMouseEnter).toHaveBeenCalledTimes(2); + expect(onMouseLeave).toHaveBeenCalledTimes(0); + expect(log[1]).toEqual(divElement); + + document.body.removeChild(portalElement); }); - }); - const divRef = React.createRef(); - const buttonRef = React.createRef(); - function Test() { - const click = ReactDOM.unstable_useEvent('click'); + it('handle propagation of enter and leave events between portals #2', () => { + const buttonRef = React.createRef(); + const divRef = React.createRef(); + const portalRef = React.createRef(); + const log = []; + const onMouseEnter = jest.fn(e => log.push(e.currentTarget)); + const onMouseLeave = jest.fn(e => log.push(e.currentTarget)); + + function Child() { + return ( +
+ ); + } + + function Parent() { + const [portal, setPortal] = React.useState(null); + + React.useLayoutEffect(() => { + setPortal(ReactDOM.createPortal(, portalRef.current)); + }, []); + + return ( + + ); + } - React.useEffect(() => { - click.setListener(divRef.current, clickEvent); + ReactDOM.render(, container); + + let buttonElement = buttonRef.current; + buttonElement.dispatchEvent( + new MouseEvent('mouseover', { + bubbles: true, + cancelable: true, + relatedTarget: null, + }), + ); + expect(onMouseEnter).toHaveBeenCalledTimes(1); + expect(onMouseLeave).toHaveBeenCalledTimes(0); + expect(log[0]).toEqual(buttonElement); + + let divElement = divRef.current; + buttonElement.dispatchEvent( + new MouseEvent('mouseout', { + bubbles: true, + cancelable: true, + relatedTarget: divElement, + }), + ); + divElement.dispatchEvent( + new MouseEvent('mouseover', { + bubbles: true, + cancelable: true, + relatedTarget: buttonElement, + }), + ); + expect(onMouseEnter).toHaveBeenCalledTimes(2); + expect(onMouseLeave).toHaveBeenCalledTimes(0); + expect(log[1]).toEqual(divElement); }); - return ( - - ); - } - - ReactDOM.render(, container); - Scheduler.unstable_flushAll(); - - expect(container.innerHTML).toBe(''); - - // Clicking the button should trigger the event callback - let divElement = divRef.current; - dispatchClickEvent(divElement); - expect(log).toEqual([ - { - eventPhase: 3, - type: 'click', - currentTarget: divRef.current, - target: divRef.current, - }, - ]); - - // Unmounting the container and clicking should not work - ReactDOM.render(null, container); - dispatchClickEvent(divElement); - expect(clickEvent).toBeCalledTimes(1); - - // Re-rendering the container and clicking should work - ReactDOM.render(, container); - Scheduler.unstable_flushAll(); - - divElement = divRef.current; - dispatchClickEvent(divElement); - expect(clickEvent).toBeCalledTimes(2); - - // Clicking the button should not work - let buttonElement = buttonRef.current; - dispatchClickEvent(buttonElement); - expect(clickEvent).toBeCalledTimes(2); - }); - - it('should correctly handle many nested target listeners', () => { - const buttonRef = React.createRef(); - const targetListener1 = jest.fn(); - const targetListener2 = jest.fn(); - const targetListener3 = jest.fn(); - const targetListener4 = jest.fn(); - - function Test() { - const click1 = ReactDOM.unstable_useEvent('click', {capture: true}); - const click2 = ReactDOM.unstable_useEvent('click', {capture: true}); - const click3 = ReactDOM.unstable_useEvent('click'); - const click4 = ReactDOM.unstable_useEvent('click'); - - React.useEffect(() => { - click1.setListener(buttonRef.current, targetListener1); - click2.setListener(buttonRef.current, targetListener2); - click3.setListener(buttonRef.current, targetListener3); - click4.setListener(buttonRef.current, targetListener4); + it('should preserve bubble/capture order between roots and nested portals', () => { + const targetRef = React.createRef(); + let log = []; + const onClickRoot = jest.fn(e => log.push('bubble root')); + const onClickCaptureRoot = jest.fn(e => log.push('capture root')); + const onClickPortal = jest.fn(e => log.push('bubble portal')); + const onClickCapturePortal = jest.fn(e => log.push('capture portal')); + + function Portal() { + return ( +
+ Click me! +
+ ); + } + + const portalContainer = document.createElement('div'); + + let shouldStopPropagation = false; + portalContainer.addEventListener( + 'click', + e => { + if (shouldStopPropagation) { + e.stopPropagation(); + } + }, + false, + ); + + function Root() { + let portalTargetRef = React.useRef(null); + React.useLayoutEffect(() => { + portalTargetRef.current.appendChild(portalContainer); + }); + return ( +
+
+ {ReactDOM.createPortal(, portalContainer)} +
+ ); + } + + ReactDOM.render(, container); + + let divElement = targetRef.current; + dispatchClickEvent(divElement); + expect(log).toEqual([ + 'capture root', + 'capture portal', + 'bubble portal', + 'bubble root', + ]); + + log = []; + + shouldStopPropagation = true; + dispatchClickEvent(divElement); + + if (enableLegacyFBSupport) { + // We aren't using roots with legacyFBSupport, we put clicks on the document, so we exbit the previous + // behavior. + expect(log).toEqual([]); + } else { + expect(log).toEqual([ + // The events on root probably shouldn't fire if a non-React intermediated. but current behavior is that they do. + 'capture root', + 'capture portal', + 'bubble portal', + 'bubble root', + ]); + } }); - return ; - } + describe('ReactDOM.useEvent', () => { + beforeEach(() => { + jest.resetModules(); + ReactFeatureFlags = require('shared/ReactFeatureFlags'); + ReactFeatureFlags.enableModernEventSystem = true; + ReactFeatureFlags.enableUseEventAPI = true; + + React = require('react'); + ReactDOM = require('react-dom'); + Scheduler = require('scheduler'); + ReactDOMServer = require('react-dom/server'); + }); - ReactDOM.render(, container); - Scheduler.unstable_flushAll(); + if (!__EXPERIMENTAL__) { + it("empty test so Jest doesn't complain", () => {}); + return; + } - let buttonElement = buttonRef.current; - dispatchClickEvent(buttonElement); + it('should create the same event listener map', () => { + let listenerMaps = []; - expect(targetListener1).toHaveBeenCalledTimes(1); - expect(targetListener2).toHaveBeenCalledTimes(1); - expect(targetListener3).toHaveBeenCalledTimes(1); - expect(targetListener4).toHaveBeenCalledTimes(1); + function Test() { + const listenerMap = ReactDOM.unstable_useEvent('click'); - function Test2() { - const click1 = ReactDOM.unstable_useEvent('click'); - const click2 = ReactDOM.unstable_useEvent('click'); - const click3 = ReactDOM.unstable_useEvent('click'); - const click4 = ReactDOM.unstable_useEvent('click'); + listenerMaps.push(listenerMap); - React.useEffect(() => { - click1.setListener(buttonRef.current, targetListener1); - click2.setListener(buttonRef.current, targetListener2); - click3.setListener(buttonRef.current, targetListener3); - click4.setListener(buttonRef.current, targetListener4); - }); + return
; + } - return ; - } + ReactDOM.render(, container); + ReactDOM.render(, container); + expect(listenerMaps.length).toEqual(2); + expect(listenerMaps[0]).toEqual(listenerMaps[1]); + }); - ReactDOM.render(, container); - Scheduler.unstable_flushAll(); + it('can render correctly with the ReactDOMServer', () => { + const clickEvent = jest.fn(); - buttonElement = buttonRef.current; - dispatchClickEvent(buttonElement); - expect(targetListener1).toHaveBeenCalledTimes(2); - expect(targetListener2).toHaveBeenCalledTimes(2); - expect(targetListener3).toHaveBeenCalledTimes(2); - expect(targetListener4).toHaveBeenCalledTimes(2); - }); + function Test() { + const divRef = React.useRef(null); + const click = ReactDOM.unstable_useEvent('click'); - it('should correctly handle stopPropagation corrrectly for target events', () => { - const buttonRef = React.createRef(); - const divRef = React.createRef(); - let clickEvent = jest.fn(); + React.useEffect(() => { + click.setListener(divRef.current, clickEvent); + }); - function Test() { - const click1 = ReactDOM.unstable_useEvent('click', { - bind: buttonRef, - }); - const click2 = ReactDOM.unstable_useEvent('click'); + return
Hello world
; + } + const output = ReactDOMServer.renderToString(); + expect(output).toBe(`
Hello world
`); + }); - React.useEffect(() => { - click1.setListener(buttonRef.current, clickEvent); - click2.setListener(divRef.current, e => { - e.stopPropagation(); + it('can render correctly with the ReactDOMServer hydration', () => { + const clickEvent = jest.fn(); + const spanRef = React.createRef(); + + function Test() { + const click = ReactDOM.unstable_useEvent('click'); + + React.useEffect(() => { + click.setListener(spanRef.current, clickEvent); + }); + + return ( +
+ Hello world +
+ ); + } + const output = ReactDOMServer.renderToString(); + expect(output).toBe( + `
Hello world
`, + ); + container.innerHTML = output; + ReactDOM.hydrate(, container); + Scheduler.unstable_flushAll(); + dispatchClickEvent(spanRef.current); + expect(clickEvent).toHaveBeenCalledTimes(1); }); - }); - return ( - - ); - } - - ReactDOM.render(, container); - Scheduler.unstable_flushAll(); - - let divElement = divRef.current; - dispatchClickEvent(divElement); - expect(clickEvent).toHaveBeenCalledTimes(0); - }); - - it('should correctly handle stopPropagation corrrectly for many target events', () => { - const buttonRef = React.createRef(); - const targetListerner1 = jest.fn(e => e.stopPropagation()); - const targetListerner2 = jest.fn(e => e.stopPropagation()); - const targetListerner3 = jest.fn(e => e.stopPropagation()); - const targetListerner4 = jest.fn(e => e.stopPropagation()); - - function Test() { - const click1 = ReactDOM.unstable_useEvent('click'); - const click2 = ReactDOM.unstable_useEvent('click'); - const click3 = ReactDOM.unstable_useEvent('click'); - const click4 = ReactDOM.unstable_useEvent('click'); - - React.useEffect(() => { - click1.setListener(buttonRef.current, targetListerner1); - click2.setListener(buttonRef.current, targetListerner2); - click3.setListener(buttonRef.current, targetListerner3); - click4.setListener(buttonRef.current, targetListerner4); - }); + it('should correctly work for a basic "click" listener', () => { + let log = []; + const clickEvent = jest.fn(event => { + log.push({ + eventPhase: event.eventPhase, + type: event.type, + currentTarget: event.currentTarget, + target: event.target, + }); + }); + const divRef = React.createRef(); + const buttonRef = React.createRef(); + + function Test() { + const click = ReactDOM.unstable_useEvent('click'); + + React.useEffect(() => { + click.setListener(buttonRef.current, clickEvent); + }); + + return ( + + ); + } + + ReactDOM.render(, container); + Scheduler.unstable_flushAll(); + + expect(container.innerHTML).toBe( + '', + ); + + // Clicking the button should trigger the event callback + let divElement = divRef.current; + dispatchClickEvent(divElement); + expect(log).toEqual([ + { + eventPhase: 3, + type: 'click', + currentTarget: buttonRef.current, + target: divRef.current, + }, + ]); + expect(clickEvent).toBeCalledTimes(1); + + // Unmounting the container and clicking should not work + ReactDOM.render(null, container); + Scheduler.unstable_flushAll(); + + dispatchClickEvent(divElement); + expect(clickEvent).toBeCalledTimes(1); + + // Re-rendering the container and clicking should work + ReactDOM.render(, container); + Scheduler.unstable_flushAll(); + + divElement = divRef.current; + dispatchClickEvent(divElement); + expect(clickEvent).toBeCalledTimes(2); + + log = []; + + // Clicking the button should also work + let buttonElement = buttonRef.current; + dispatchClickEvent(buttonElement); + expect(log).toEqual([ + { + eventPhase: 3, + type: 'click', + currentTarget: buttonRef.current, + target: buttonRef.current, + }, + ]); + + function Test2({clickEvent2}) { + const click = ReactDOM.unstable_useEvent('click', clickEvent2); + + React.useEffect(() => { + click.setListener(buttonRef.current, clickEvent2); + }); + + return ( + + ); + } + + let clickEvent2 = jest.fn(); + ReactDOM.render(, container); + Scheduler.unstable_flushAll(); + + divElement = divRef.current; + dispatchClickEvent(divElement); + expect(clickEvent2).toBeCalledTimes(1); + + // Reset the function we pass in, so it's different + clickEvent2 = jest.fn(); + ReactDOM.render(, container); + Scheduler.unstable_flushAll(); + + divElement = divRef.current; + dispatchClickEvent(divElement); + expect(clickEvent2).toBeCalledTimes(1); + }); - return ; - } - - ReactDOM.render(, container); - Scheduler.unstable_flushAll(); - - let buttonElement = buttonRef.current; - dispatchClickEvent(buttonElement); - expect(targetListerner1).toHaveBeenCalledTimes(1); - expect(targetListerner2).toHaveBeenCalledTimes(1); - expect(targetListerner3).toHaveBeenCalledTimes(1); - expect(targetListerner4).toHaveBeenCalledTimes(1); - }); - - it('should correctly handle stopPropagation for mixed capture/bubbling target listeners', () => { - const buttonRef = React.createRef(); - const targetListerner1 = jest.fn(e => e.stopPropagation()); - const targetListerner2 = jest.fn(e => e.stopPropagation()); - const targetListerner3 = jest.fn(e => e.stopPropagation()); - const targetListerner4 = jest.fn(e => e.stopPropagation()); - - function Test() { - const click1 = ReactDOM.unstable_useEvent('click', {capture: true}); - const click2 = ReactDOM.unstable_useEvent('click', {capture: true}); - const click3 = ReactDOM.unstable_useEvent('click'); - const click4 = ReactDOM.unstable_useEvent('click'); - - React.useEffect(() => { - click1.setListener(buttonRef.current, targetListerner1); - click2.setListener(buttonRef.current, targetListerner2); - click3.setListener(buttonRef.current, targetListerner3); - click4.setListener(buttonRef.current, targetListerner4); - }); + it('should correctly work for setting and clearing a basic "click" listener', () => { + const clickEvent = jest.fn(); + const divRef = React.createRef(); + const buttonRef = React.createRef(); - return ; - } + function Test({off}) { + const click = ReactDOM.unstable_useEvent('click'); - ReactDOM.render(, container); - Scheduler.unstable_flushAll(); + React.useEffect(() => { + click.setListener(buttonRef.current, clickEvent); + }); - let buttonElement = buttonRef.current; - dispatchClickEvent(buttonElement); - expect(targetListerner1).toHaveBeenCalledTimes(1); - expect(targetListerner2).toHaveBeenCalledTimes(1); - expect(targetListerner3).toHaveBeenCalledTimes(1); - expect(targetListerner4).toHaveBeenCalledTimes(1); - }); + React.useEffect(() => { + if (off) { + click.setListener(buttonRef.current, null); + } + }, [off]); - it.experimental('should work with concurrent mode updates', async () => { - const log = []; - const ref = React.createRef(); + return ( + + ); + } - function Test({counter}) { - const click = ReactDOM.unstable_useEvent('click'); + ReactDOM.render(, container); + Scheduler.unstable_flushAll(); - React.useLayoutEffect(() => { - click.setListener(ref.current, () => { - log.push({counter}); - }); - }); + let divElement = divRef.current; + dispatchClickEvent(divElement); + expect(clickEvent).toBeCalledTimes(1); - Scheduler.unstable_yieldValue('Test'); - return ; - } - - let root = ReactDOM.createRoot(container); - root.render(); - - // Dev double-render - if (__DEV__) { - expect(Scheduler).toFlushAndYield(['Test', 'Test']); - } else { - expect(Scheduler).toFlushAndYield(['Test']); - } - - // Click the button - dispatchClickEvent(ref.current); - expect(log).toEqual([{counter: 0}]); - - // Clear log - log.length = 0; - - // Increase counter - root.render(); - // Yield before committing - // Dev double-render - if (__DEV__) { - expect(Scheduler).toFlushAndYieldThrough(['Test', 'Test']); - } else { - expect(Scheduler).toFlushAndYieldThrough(['Test']); - } - - // Click the button again - dispatchClickEvent(ref.current); - expect(log).toEqual([{counter: 0}]); - - // Clear log - log.length = 0; - - // Commit - expect(Scheduler).toFlushAndYield([]); - dispatchClickEvent(ref.current); - expect(log).toEqual([{counter: 1}]); - }); - - it('should correctly work for a basic "click" listener that upgrades', () => { - const clickEvent = jest.fn(); - const buttonRef = React.createRef(); - const button2Ref = React.createRef(); - - function Test2() { - const click = ReactDOM.unstable_useEvent('click', { - passive: false, - }); + // The listener should get unmounted in the second effect + ReactDOM.render(, container); + Scheduler.unstable_flushAll(); - React.useEffect(() => { - click.setListener(button2Ref.current, clickEvent); - }); + clickEvent.mockClear(); - return ; - } + divElement = divRef.current; + dispatchClickEvent(divElement); + expect(clickEvent).toBeCalledTimes(0); + }); - function Test({extra}) { - const click = ReactDOM.unstable_useEvent('click', { - passive: true, - }); + it('handle propagation of click events', () => { + const buttonRef = React.createRef(); + const divRef = React.createRef(); + const log = []; + const onClick = jest.fn(e => log.push(['bubble', e.currentTarget])); + const onClickCapture = jest.fn(e => + log.push(['capture', e.currentTarget]), + ); + + function Test() { + const click = ReactDOM.unstable_useEvent('click'); + const clickCapture = ReactDOM.unstable_useEvent('click', { + capture: true, + }); + + React.useEffect(() => { + click.setListener(buttonRef.current, onClick); + clickCapture.setListener(buttonRef.current, onClickCapture); + click.setListener(divRef.current, onClick); + clickCapture.setListener(divRef.current, onClickCapture); + }); + + return ( + + ); + } + + ReactDOM.render(, container); + Scheduler.unstable_flushAll(); + + let buttonElement = buttonRef.current; + dispatchClickEvent(buttonElement); + expect(onClick).toHaveBeenCalledTimes(1); + expect(onClickCapture).toHaveBeenCalledTimes(1); + expect(log[0]).toEqual(['capture', buttonElement]); + expect(log[1]).toEqual(['bubble', buttonElement]); + + let divElement = divRef.current; + dispatchClickEvent(divElement); + expect(onClick).toHaveBeenCalledTimes(3); + expect(onClickCapture).toHaveBeenCalledTimes(3); + expect(log[2]).toEqual(['capture', buttonElement]); + expect(log[3]).toEqual(['capture', divElement]); + expect(log[4]).toEqual(['bubble', divElement]); + expect(log[5]).toEqual(['bubble', buttonElement]); + }); - React.useEffect(() => { - click.setListener(buttonRef.current, clickEvent); - }); + it('handle propagation of click events mixed with onClick events', () => { + const buttonRef = React.createRef(); + const divRef = React.createRef(); + const log = []; + const onClick = jest.fn(e => log.push(['bubble', e.currentTarget])); + const onClickCapture = jest.fn(e => + log.push(['capture', e.currentTarget]), + ); + + function Test() { + const click = ReactDOM.unstable_useEvent('click'); + const clickCapture = ReactDOM.unstable_useEvent('click', { + capture: true, + }); + + React.useEffect(() => { + click.setListener(buttonRef.current, onClick); + clickCapture.setListener(buttonRef.current, onClickCapture); + }); + + return ( + + ); + } + + ReactDOM.render(, container); + Scheduler.unstable_flushAll(); + + let buttonElement = buttonRef.current; + dispatchClickEvent(buttonElement); + expect(onClick).toHaveBeenCalledTimes(1); + expect(onClickCapture).toHaveBeenCalledTimes(1); + expect(log[0]).toEqual(['capture', buttonElement]); + expect(log[1]).toEqual(['bubble', buttonElement]); + + let divElement = divRef.current; + dispatchClickEvent(divElement); + expect(onClick).toHaveBeenCalledTimes(3); + expect(onClickCapture).toHaveBeenCalledTimes(3); + expect(log[2]).toEqual(['capture', buttonElement]); + expect(log[3]).toEqual(['capture', divElement]); + expect(log[4]).toEqual(['bubble', divElement]); + expect(log[5]).toEqual(['bubble', buttonElement]); + }); - return ( - <> - - {extra && } - - ); - } + it('should correctly work for a basic "click" listener on the outer target', () => { + const log = []; + const clickEvent = jest.fn(event => { + log.push({ + eventPhase: event.eventPhase, + type: event.type, + currentTarget: event.currentTarget, + target: event.target, + }); + }); + const divRef = React.createRef(); + const buttonRef = React.createRef(); + + function Test() { + const click = ReactDOM.unstable_useEvent('click'); + + React.useEffect(() => { + click.setListener(divRef.current, clickEvent); + }); + + return ( + + ); + } + + ReactDOM.render(, container); + Scheduler.unstable_flushAll(); + + expect(container.innerHTML).toBe( + '', + ); + + // Clicking the button should trigger the event callback + let divElement = divRef.current; + dispatchClickEvent(divElement); + expect(log).toEqual([ + { + eventPhase: 3, + type: 'click', + currentTarget: divRef.current, + target: divRef.current, + }, + ]); + + // Unmounting the container and clicking should not work + ReactDOM.render(null, container); + dispatchClickEvent(divElement); + expect(clickEvent).toBeCalledTimes(1); + + // Re-rendering the container and clicking should work + ReactDOM.render(, container); + Scheduler.unstable_flushAll(); + + divElement = divRef.current; + dispatchClickEvent(divElement); + expect(clickEvent).toBeCalledTimes(2); + + // Clicking the button should not work + let buttonElement = buttonRef.current; + dispatchClickEvent(buttonElement); + expect(clickEvent).toBeCalledTimes(2); + }); - ReactDOM.render(, container); - Scheduler.unstable_flushAll(); + it('should correctly handle many nested target listeners', () => { + const buttonRef = React.createRef(); + const targetListener1 = jest.fn(); + const targetListener2 = jest.fn(); + const targetListener3 = jest.fn(); + const targetListener4 = jest.fn(); + + function Test() { + const click1 = ReactDOM.unstable_useEvent('click', { + capture: true, + }); + const click2 = ReactDOM.unstable_useEvent('click', { + capture: true, + }); + const click3 = ReactDOM.unstable_useEvent('click'); + const click4 = ReactDOM.unstable_useEvent('click'); + + React.useEffect(() => { + click1.setListener(buttonRef.current, targetListener1); + click2.setListener(buttonRef.current, targetListener2); + click3.setListener(buttonRef.current, targetListener3); + click4.setListener(buttonRef.current, targetListener4); + }); + + return ; + } + + ReactDOM.render(, container); + Scheduler.unstable_flushAll(); + + let buttonElement = buttonRef.current; + dispatchClickEvent(buttonElement); + + expect(targetListener1).toHaveBeenCalledTimes(1); + expect(targetListener2).toHaveBeenCalledTimes(1); + expect(targetListener3).toHaveBeenCalledTimes(1); + expect(targetListener4).toHaveBeenCalledTimes(1); + + function Test2() { + const click1 = ReactDOM.unstable_useEvent('click'); + const click2 = ReactDOM.unstable_useEvent('click'); + const click3 = ReactDOM.unstable_useEvent('click'); + const click4 = ReactDOM.unstable_useEvent('click'); + + React.useEffect(() => { + click1.setListener(buttonRef.current, targetListener1); + click2.setListener(buttonRef.current, targetListener2); + click3.setListener(buttonRef.current, targetListener3); + click4.setListener(buttonRef.current, targetListener4); + }); + + return ; + } + + ReactDOM.render(, container); + Scheduler.unstable_flushAll(); + + buttonElement = buttonRef.current; + dispatchClickEvent(buttonElement); + expect(targetListener1).toHaveBeenCalledTimes(2); + expect(targetListener2).toHaveBeenCalledTimes(2); + expect(targetListener3).toHaveBeenCalledTimes(2); + expect(targetListener4).toHaveBeenCalledTimes(2); + }); - let button = buttonRef.current; - dispatchClickEvent(button); - expect(clickEvent).toHaveBeenCalledTimes(1); + it('should correctly handle stopPropagation corrrectly for target events', () => { + const buttonRef = React.createRef(); + const divRef = React.createRef(); + let clickEvent = jest.fn(); + + function Test() { + const click1 = ReactDOM.unstable_useEvent('click', { + bind: buttonRef, + }); + const click2 = ReactDOM.unstable_useEvent('click'); + + React.useEffect(() => { + click1.setListener(buttonRef.current, clickEvent); + click2.setListener(divRef.current, e => { + e.stopPropagation(); + }); + }); + + return ( + + ); + } + + ReactDOM.render(, container); + Scheduler.unstable_flushAll(); + + let divElement = divRef.current; + dispatchClickEvent(divElement); + expect(clickEvent).toHaveBeenCalledTimes(0); + }); - ReactDOM.render(, container); - Scheduler.unstable_flushAll(); + it('should correctly handle stopPropagation corrrectly for many target events', () => { + const buttonRef = React.createRef(); + const targetListerner1 = jest.fn(e => e.stopPropagation()); + const targetListerner2 = jest.fn(e => e.stopPropagation()); + const targetListerner3 = jest.fn(e => e.stopPropagation()); + const targetListerner4 = jest.fn(e => e.stopPropagation()); + + function Test() { + const click1 = ReactDOM.unstable_useEvent('click'); + const click2 = ReactDOM.unstable_useEvent('click'); + const click3 = ReactDOM.unstable_useEvent('click'); + const click4 = ReactDOM.unstable_useEvent('click'); + + React.useEffect(() => { + click1.setListener(buttonRef.current, targetListerner1); + click2.setListener(buttonRef.current, targetListerner2); + click3.setListener(buttonRef.current, targetListerner3); + click4.setListener(buttonRef.current, targetListerner4); + }); + + return ; + } + + ReactDOM.render(, container); + Scheduler.unstable_flushAll(); + + let buttonElement = buttonRef.current; + dispatchClickEvent(buttonElement); + expect(targetListerner1).toHaveBeenCalledTimes(1); + expect(targetListerner2).toHaveBeenCalledTimes(1); + expect(targetListerner3).toHaveBeenCalledTimes(1); + expect(targetListerner4).toHaveBeenCalledTimes(1); + }); - clickEvent.mockClear(); + it('should correctly handle stopPropagation for mixed capture/bubbling target listeners', () => { + const buttonRef = React.createRef(); + const targetListerner1 = jest.fn(e => e.stopPropagation()); + const targetListerner2 = jest.fn(e => e.stopPropagation()); + const targetListerner3 = jest.fn(e => e.stopPropagation()); + const targetListerner4 = jest.fn(e => e.stopPropagation()); + + function Test() { + const click1 = ReactDOM.unstable_useEvent('click', { + capture: true, + }); + const click2 = ReactDOM.unstable_useEvent('click', { + capture: true, + }); + const click3 = ReactDOM.unstable_useEvent('click'); + const click4 = ReactDOM.unstable_useEvent('click'); + + React.useEffect(() => { + click1.setListener(buttonRef.current, targetListerner1); + click2.setListener(buttonRef.current, targetListerner2); + click3.setListener(buttonRef.current, targetListerner3); + click4.setListener(buttonRef.current, targetListerner4); + }); + + return ; + } + + ReactDOM.render(, container); + Scheduler.unstable_flushAll(); + + let buttonElement = buttonRef.current; + dispatchClickEvent(buttonElement); + expect(targetListerner1).toHaveBeenCalledTimes(1); + expect(targetListerner2).toHaveBeenCalledTimes(1); + expect(targetListerner3).toHaveBeenCalledTimes(1); + expect(targetListerner4).toHaveBeenCalledTimes(1); + }); - button = button2Ref.current; - dispatchClickEvent(button); - expect(clickEvent).toHaveBeenCalledTimes(1); - }); + it.experimental( + 'should work with concurrent mode updates', + async () => { + const log = []; + const ref = React.createRef(); + + function Test({counter}) { + const click = ReactDOM.unstable_useEvent('click'); + + React.useLayoutEffect(() => { + click.setListener(ref.current, () => { + log.push({counter}); + }); + }); + + Scheduler.unstable_yieldValue('Test'); + return ; + } + + let root = ReactDOM.createRoot(container); + root.render(); + + // Dev double-render + if (__DEV__) { + expect(Scheduler).toFlushAndYield(['Test', 'Test']); + } else { + expect(Scheduler).toFlushAndYield(['Test']); + } + + // Click the button + dispatchClickEvent(ref.current); + expect(log).toEqual([{counter: 0}]); + + // Clear log + log.length = 0; + + // Increase counter + root.render(); + // Yield before committing + // Dev double-render + if (__DEV__) { + expect(Scheduler).toFlushAndYieldThrough(['Test', 'Test']); + } else { + expect(Scheduler).toFlushAndYieldThrough(['Test']); + } + + // Click the button again + dispatchClickEvent(ref.current); + expect(log).toEqual([{counter: 0}]); + + // Clear log + log.length = 0; + + // Commit + expect(Scheduler).toFlushAndYield([]); + dispatchClickEvent(ref.current); + expect(log).toEqual([{counter: 1}]); + }, + ); + + it('should correctly work for a basic "click" listener that upgrades', () => { + const clickEvent = jest.fn(); + const buttonRef = React.createRef(); + const button2Ref = React.createRef(); + + function Test2() { + const click = ReactDOM.unstable_useEvent('click', { + passive: false, + }); + + React.useEffect(() => { + click.setListener(button2Ref.current, clickEvent); + }); + + return ; + } + + function Test({extra}) { + const click = ReactDOM.unstable_useEvent('click', { + passive: true, + }); + + React.useEffect(() => { + click.setListener(buttonRef.current, clickEvent); + }); + + return ( + <> + + {extra && } + + ); + } + + ReactDOM.render(, container); + Scheduler.unstable_flushAll(); + + let button = buttonRef.current; + dispatchClickEvent(button); + expect(clickEvent).toHaveBeenCalledTimes(1); + + ReactDOM.render(, container); + Scheduler.unstable_flushAll(); + + clickEvent.mockClear(); + + button = button2Ref.current; + dispatchClickEvent(button); + expect(clickEvent).toHaveBeenCalledTimes(1); + }); - it('should correctly work for a basic "click" listener that upgrades #2', () => { - const clickEvent = jest.fn(); - const buttonRef = React.createRef(); - const button2Ref = React.createRef(); + it('should correctly work for a basic "click" listener that upgrades #2', () => { + const clickEvent = jest.fn(); + const buttonRef = React.createRef(); + const button2Ref = React.createRef(); - function Test2() { - const click = ReactDOM.unstable_useEvent('click', { - passive: false, - }); + function Test2() { + const click = ReactDOM.unstable_useEvent('click', { + passive: false, + }); - React.useEffect(() => { - click.setListener(button2Ref.current, clickEvent); - }); + React.useEffect(() => { + click.setListener(button2Ref.current, clickEvent); + }); - return ; - } + return ; + } - function Test({extra}) { - const click = ReactDOM.unstable_useEvent('click', { - passive: undefined, - }); + function Test({extra}) { + const click = ReactDOM.unstable_useEvent('click', { + passive: undefined, + }); - React.useEffect(() => { - click.setListener(buttonRef.current, clickEvent); - }); + React.useEffect(() => { + click.setListener(buttonRef.current, clickEvent); + }); - return ( - <> - - {extra && } - - ); - } + return ( + <> + + {extra && } + + ); + } + + ReactDOM.render(, container); + Scheduler.unstable_flushAll(); - ReactDOM.render(, container); - Scheduler.unstable_flushAll(); + let button = buttonRef.current; + dispatchClickEvent(button); + expect(clickEvent).toHaveBeenCalledTimes(1); - let button = buttonRef.current; - dispatchClickEvent(button); - expect(clickEvent).toHaveBeenCalledTimes(1); + ReactDOM.render(, container); + Scheduler.unstable_flushAll(); - ReactDOM.render(, container); - Scheduler.unstable_flushAll(); + clickEvent.mockClear(); - clickEvent.mockClear(); + button = button2Ref.current; + dispatchClickEvent(button); + expect(clickEvent).toHaveBeenCalledTimes(1); + }); + }); + }, + ); + } - button = button2Ref.current; - dispatchClickEvent(button); - expect(clickEvent).toHaveBeenCalledTimes(1); - }); - }); + withEnableLegacyFBSupport(false); + withEnableLegacyFBSupport(true); }); diff --git a/packages/shared/ReactFeatureFlags.js b/packages/shared/ReactFeatureFlags.js index 82fcce363608..650e24529098 100644 --- a/packages/shared/ReactFeatureFlags.js +++ b/packages/shared/ReactFeatureFlags.js @@ -140,4 +140,4 @@ export const warnUnstableRenderSubtreeIntoContainer = false; export const enableModernEventSystem = false; // Support legacy Primer support on internal FB www -export const enableLegacyFBPrimerSupport = false; +export const enableLegacyFBSupport = false; diff --git a/packages/shared/forks/ReactFeatureFlags.native-fb.js b/packages/shared/forks/ReactFeatureFlags.native-fb.js index e4acdc62e586..c70c777cfd9c 100644 --- a/packages/shared/forks/ReactFeatureFlags.native-fb.js +++ b/packages/shared/forks/ReactFeatureFlags.native-fb.js @@ -47,7 +47,7 @@ export const deferPassiveEffectCleanupDuringUnmount = false; export const runAllPassiveEffectDestroysBeforeCreates = false; export const enableModernEventSystem = false; export const warnAboutSpreadingKeyToJSX = false; -export const enableLegacyFBPrimerSupport = false; +export const enableLegacyFBSupport = false; // Internal-only attempt to debug a React Native issue. See D20130868. export const throwEarlyForMysteriousError = true; diff --git a/packages/shared/forks/ReactFeatureFlags.native-oss.js b/packages/shared/forks/ReactFeatureFlags.native-oss.js index 5707d4a36c41..61527ea6db69 100644 --- a/packages/shared/forks/ReactFeatureFlags.native-oss.js +++ b/packages/shared/forks/ReactFeatureFlags.native-oss.js @@ -46,7 +46,7 @@ export const deferPassiveEffectCleanupDuringUnmount = false; export const runAllPassiveEffectDestroysBeforeCreates = false; export const enableModernEventSystem = false; export const warnAboutSpreadingKeyToJSX = false; -export const enableLegacyFBPrimerSupport = false; +export const enableLegacyFBSupport = false; // Internal-only attempt to debug a React Native issue. See D20130868. export const throwEarlyForMysteriousError = false; diff --git a/packages/shared/forks/ReactFeatureFlags.test-renderer.js b/packages/shared/forks/ReactFeatureFlags.test-renderer.js index 917510d9e568..ffc66b7acc8a 100644 --- a/packages/shared/forks/ReactFeatureFlags.test-renderer.js +++ b/packages/shared/forks/ReactFeatureFlags.test-renderer.js @@ -46,7 +46,7 @@ export const deferPassiveEffectCleanupDuringUnmount = false; export const runAllPassiveEffectDestroysBeforeCreates = false; export const enableModernEventSystem = false; export const warnAboutSpreadingKeyToJSX = false; -export const enableLegacyFBPrimerSupport = false; +export const enableLegacyFBSupport = false; // Internal-only attempt to debug a React Native issue. See D20130868. export const throwEarlyForMysteriousError = false; diff --git a/packages/shared/forks/ReactFeatureFlags.test-renderer.www.js b/packages/shared/forks/ReactFeatureFlags.test-renderer.www.js index e3fb4c95cfc6..79332c5daba2 100644 --- a/packages/shared/forks/ReactFeatureFlags.test-renderer.www.js +++ b/packages/shared/forks/ReactFeatureFlags.test-renderer.www.js @@ -46,7 +46,7 @@ export const deferPassiveEffectCleanupDuringUnmount = false; export const runAllPassiveEffectDestroysBeforeCreates = false; export const enableModernEventSystem = false; export const warnAboutSpreadingKeyToJSX = false; -export const enableLegacyFBPrimerSupport = false; +export const enableLegacyFBSupport = false; // Internal-only attempt to debug a React Native issue. See D20130868. export const throwEarlyForMysteriousError = false; diff --git a/packages/shared/forks/ReactFeatureFlags.testing.js b/packages/shared/forks/ReactFeatureFlags.testing.js index d8e03a6c9376..6b279bc8a6a3 100644 --- a/packages/shared/forks/ReactFeatureFlags.testing.js +++ b/packages/shared/forks/ReactFeatureFlags.testing.js @@ -46,7 +46,7 @@ export const deferPassiveEffectCleanupDuringUnmount = false; export const runAllPassiveEffectDestroysBeforeCreates = false; export const enableModernEventSystem = false; export const warnAboutSpreadingKeyToJSX = false; -export const enableLegacyFBPrimerSupport = false; +export const enableLegacyFBSupport = false; // Internal-only attempt to debug a React Native issue. See D20130868. export const throwEarlyForMysteriousError = false; diff --git a/packages/shared/forks/ReactFeatureFlags.testing.www.js b/packages/shared/forks/ReactFeatureFlags.testing.www.js index 2db90f10a926..0528120f580e 100644 --- a/packages/shared/forks/ReactFeatureFlags.testing.www.js +++ b/packages/shared/forks/ReactFeatureFlags.testing.www.js @@ -46,7 +46,7 @@ export const deferPassiveEffectCleanupDuringUnmount = false; export const runAllPassiveEffectDestroysBeforeCreates = false; export const enableModernEventSystem = false; export const warnAboutSpreadingKeyToJSX = false; -export const enableLegacyFBPrimerSupport = !__EXPERIMENTAL__; +export const enableLegacyFBSupport = !__EXPERIMENTAL__; // Internal-only attempt to debug a React Native issue. See D20130868. export const throwEarlyForMysteriousError = false; diff --git a/packages/shared/forks/ReactFeatureFlags.www.js b/packages/shared/forks/ReactFeatureFlags.www.js index ddb54f26c136..3372d8bbd01a 100644 --- a/packages/shared/forks/ReactFeatureFlags.www.js +++ b/packages/shared/forks/ReactFeatureFlags.www.js @@ -104,7 +104,7 @@ export const disableModulePatternComponents = __EXPERIMENTAL__; export const warnUnstableRenderSubtreeIntoContainer = false; -export const enableLegacyFBPrimerSupport = !__EXPERIMENTAL__; +export const enableLegacyFBSupport = !__EXPERIMENTAL__; // Internal-only attempt to debug a React Native issue. See D20130868. export const throwEarlyForMysteriousError = false;