From 07a02fb80d99710443f23e30122bc1ffa8ea4f40 Mon Sep 17 00:00:00 2001 From: Nicolas Gallagher Date: Fri, 9 Aug 2019 10:53:49 -0700 Subject: [PATCH] [react-events] Refactor unit tests for Hover (#16320) **Problem** The existing responders listen to pointer events by default and add fallback events if PointerEvent is not supported. However, this complicates the responders and makes it easy to create a problematic unit test environment. jsdom doesn't support PointerEvent, which means that the responders end up listening to pointer events *and* fallback events in unit tests. This isn't a direct problem in production environments, because no browser will fire pointer events if they aren't supported. But in the unit test environment, we often dispatch event sequences taken from browsers that support pointer events. This means that what we're often testing is actually a (complex) scenario that cannot even occur in production: a responder that is listening to and receives both pointer events and fallback events. Not only does this risk making responders more complicated to implement but it could also hide bugs in implementations. **Response** Implement the responders so that they're only listening to *either* pointer events *or* fallback events, never both. This should make the default pointer events implementations significantly simpler and easier to test, as well as free to rely on the complete PointerEvents API. In the future it should also make DCE easier for target environments that are known to support PointerEvents, as we can use build tools with an equivalent of the runtime check. The fallback events (touch and mouse) need to coexist and be resilient to browser emulated events. Our unit tests should express a suite of high-level interactions that can be run in environments with and without PointerEvents support. --- packages/react-events/src/dom/Hover.js | 207 +++++----- .../__tests__/ContextMenu-test.internal.js | 296 ++++++------- .../src/dom/__tests__/Hover-test.internal.js | 388 ++++++------------ packages/react-events/src/dom/test-utils.js | 311 ++++++++++++++ 4 files changed, 670 insertions(+), 532 deletions(-) create mode 100644 packages/react-events/src/dom/test-utils.js diff --git a/packages/react-events/src/dom/Hover.js b/packages/react-events/src/dom/Hover.js index 3d83313100b3e..df8469c1c86b4 100644 --- a/packages/react-events/src/dom/Hover.js +++ b/packages/react-events/src/dom/Hover.js @@ -10,6 +10,7 @@ import type { ReactDOMResponderEvent, ReactDOMResponderContext, + PointerType, } from 'shared/ReactDOMTypes'; import type {ReactEventResponderListener} from 'shared/ReactTypes'; @@ -29,15 +30,14 @@ type HoverState = { hoverTarget: null | Element | Document, isActiveHovered: boolean, isHovered: boolean, - isTouched: boolean, - hoverStartTimeout: null | number, - hoverEndTimeout: null | number, - ignoreEmulatedMouseEvents: boolean, + isTouched?: boolean, + ignoreEmulatedMouseEvents?: boolean, }; type HoverEventType = 'hoverstart' | 'hoverend' | 'hoverchange' | 'hovermove'; type HoverEvent = {| + pointerType: PointerType, target: Element | Document, type: HoverEventType, timeStamp: number, @@ -51,17 +51,8 @@ type HoverEvent = {| y: null | number, |}; -const targetEventTypes = [ - 'pointerover', - 'pointermove', - 'pointerout', - 'pointercancel', -]; - -// If PointerEvents is not supported (e.g., Safari), also listen to touch and mouse events. -if (typeof window !== 'undefined' && window.PointerEvent === undefined) { - targetEventTypes.push('touchstart', 'mouseover', 'mousemove', 'mouseout'); -} +const hasPointerEvents = + typeof window !== 'undefined' && window.PointerEvent != null; function isFunction(obj): boolean { return typeof obj === 'function'; @@ -79,13 +70,16 @@ function createHoverEvent( let pageY = null; let screenX = null; let screenY = null; + let pointerType = ''; if (event) { const nativeEvent = (event.nativeEvent: any); + pointerType = event.pointerType; ({clientX, clientY, pageX, pageY, screenX, screenY} = nativeEvent); } return { + pointerType, target, type, timeStamp: context.getTimeStamp(), @@ -131,11 +125,6 @@ function dispatchHoverStartEvents( state.isHovered = true; - if (state.hoverEndTimeout !== null) { - context.clearTimeout(state.hoverEndTimeout); - state.hoverEndTimeout = null; - } - if (!state.isActiveHovered) { state.isActiveHovered = true; const onHoverStart = props.onHoverStart; @@ -152,6 +141,20 @@ function dispatchHoverStartEvents( } } +function dispatchHoverMoveEvent(event, context, props, state) { + const target = state.hoverTarget; + const onHoverMove = props.onHoverMove; + if (isFunction(onHoverMove)) { + const syntheticEvent = createHoverEvent( + event, + context, + 'hovermove', + ((target: any): Element | Document), + ); + context.dispatchEvent(syntheticEvent, onHoverMove, UserBlockingEvent); + } +} + function dispatchHoverEndEvents( event: null | ReactDOMResponderEvent, context: ReactDOMResponderContext, @@ -170,11 +173,6 @@ function dispatchHoverEndEvents( state.isHovered = false; - if (state.hoverStartTimeout !== null) { - context.clearTimeout(state.hoverStartTimeout); - state.hoverStartTimeout = null; - } - if (state.isActiveHovered) { state.isActiveHovered = false; const onHoverEnd = props.onHoverEnd; @@ -189,7 +187,6 @@ function dispatchHoverEndEvents( } dispatchHoverChangeEvent(event, context, props, state); state.hoverTarget = null; - state.ignoreEmulatedMouseEvents = false; state.isTouched = false; } } @@ -204,24 +201,17 @@ function unmountResponder( } } -function isEmulatedMouseEvent(event, state) { - const {type} = event; - return ( - state.ignoreEmulatedMouseEvents && - (type === 'mousemove' || type === 'mouseover' || type === 'mouseout') - ); -} - const hoverResponderImpl = { - targetEventTypes, + targetEventTypes: [ + 'pointerover', + 'pointermove', + 'pointerout', + 'pointercancel', + ], getInitialState() { return { isActiveHovered: false, isHovered: false, - isTouched: false, - hoverStartTimeout: null, - hoverEndTimeout: null, - ignoreEmulatedMouseEvents: false, }; }, allowMultipleHostChildren: false, @@ -237,95 +227,120 @@ const hoverResponderImpl = { if (props.disabled) { if (state.isHovered) { dispatchHoverEndEvents(event, context, props, state); - state.ignoreEmulatedMouseEvents = false; - } - if (state.isTouched) { - state.isTouched = false; } return; } switch (type) { // START - case 'pointerover': - case 'mouseover': - case 'touchstart': { - if (!state.isHovered) { - // Prevent hover events for touch - if (state.isTouched || pointerType === 'touch') { - state.isTouched = true; - return; - } - - // Prevent hover events for emulated events - if (isEmulatedMouseEvent(event, state)) { - return; - } + case 'pointerover': { + if (!state.isHovered && pointerType !== 'touch') { state.hoverTarget = event.responderTarget; - state.ignoreEmulatedMouseEvents = true; dispatchHoverStartEvents(event, context, props, state); } - return; + break; } // MOVE - case 'pointermove': - case 'mousemove': { - if (state.isHovered && !isEmulatedMouseEvent(event, state)) { - const onHoverMove = props.onHoverMove; - if (state.hoverTarget !== null && isFunction(onHoverMove)) { - const syntheticEvent = createHoverEvent( - event, - context, - 'hovermove', - state.hoverTarget, - ); - context.dispatchEvent( - syntheticEvent, - onHoverMove, - UserBlockingEvent, - ); - } + case 'pointermove': { + if (state.isHovered && state.hoverTarget !== null) { + dispatchHoverMoveEvent(event, context, props, state); } - return; + break; } // END case 'pointerout': - case 'pointercancel': - case 'mouseout': - case 'touchcancel': - case 'touchend': { + case 'pointercancel': { if (state.isHovered) { dispatchHoverEndEvents(event, context, props, state); - state.ignoreEmulatedMouseEvents = false; } - if (state.isTouched) { - state.isTouched = false; - } - return; + break; } } }, - onUnmount( - context: ReactDOMResponderContext, - props: HoverProps, - state: HoverState, - ) { - unmountResponder(context, props, state); + onUnmount: unmountResponder, + onOwnershipChange: unmountResponder, +}; + +const hoverResponderFallbackImpl = { + targetEventTypes: ['mouseover', 'mousemove', 'mouseout', 'touchstart'], + getInitialState() { + return { + isActiveHovered: false, + isHovered: false, + isTouched: false, + ignoreEmulatedMouseEvents: false, + }; }, - onOwnershipChange( + allowMultipleHostChildren: false, + allowEventHooks: true, + onEvent( + event: ReactDOMResponderEvent, context: ReactDOMResponderContext, props: HoverProps, state: HoverState, - ) { - unmountResponder(context, props, state); + ): void { + const {type} = event; + + if (props.disabled) { + if (state.isHovered) { + dispatchHoverEndEvents(event, context, props, state); + state.ignoreEmulatedMouseEvents = false; + } + state.isTouched = false; + return; + } + + switch (type) { + // START + case 'mouseover': { + if (!state.isHovered && !state.ignoreEmulatedMouseEvents) { + state.hoverTarget = event.responderTarget; + dispatchHoverStartEvents(event, context, props, state); + } + break; + } + + // MOVE + case 'mousemove': { + if ( + state.isHovered && + state.hoverTarget !== null && + !state.ignoreEmulatedMouseEvents + ) { + dispatchHoverMoveEvent(event, context, props, state); + } else if (!state.isHovered && type === 'mousemove') { + state.ignoreEmulatedMouseEvents = false; + state.isTouched = false; + } + break; + } + + // END + case 'mouseout': { + if (state.isHovered) { + dispatchHoverEndEvents(event, context, props, state); + } + break; + } + + case 'touchstart': { + if (!state.isHovered) { + state.isTouched = true; + state.ignoreEmulatedMouseEvents = true; + } + break; + } + } }, + onUnmount: unmountResponder, + onOwnershipChange: unmountResponder, }; export const HoverResponder = React.unstable_createResponder( 'Hover', - hoverResponderImpl, + hasPointerEvents ? hoverResponderImpl : hoverResponderFallbackImpl, ); export function useHoverResponder( diff --git a/packages/react-events/src/dom/__tests__/ContextMenu-test.internal.js b/packages/react-events/src/dom/__tests__/ContextMenu-test.internal.js index 546378f98f16d..4e03d897bf82d 100644 --- a/packages/react-events/src/dom/__tests__/ContextMenu-test.internal.js +++ b/packages/react-events/src/dom/__tests__/ContextMenu-test.internal.js @@ -9,24 +9,16 @@ 'use strict'; +import {createEvent, platform, setPointerEvent} from '../test-utils'; + let React; let ReactFeatureFlags; let ReactDOM; let useContextMenuResponder; -function createEvent(type, data) { - const event = document.createEvent('CustomEvent'); - event.initCustomEvent(type, true, true); - if (data != null) { - Object.entries(data).forEach(([key, value]) => { - event[key] = value; - }); - } - return event; -} - -function init(hasPointerEvents) { - global.PointerEvents = hasPointerEvents ? function() {} : undefined; +function initializeModules(hasPointerEvents) { + setPointerEvent(hasPointerEvents); + jest.resetModules(); ReactFeatureFlags = require('shared/ReactFeatureFlags'); ReactFeatureFlags.enableFlareAPI = true; React = require('react'); @@ -35,29 +27,6 @@ function init(hasPointerEvents) { .useContextMenuResponder; } -const platformGetter = jest.spyOn(global.navigator, 'platform', 'get'); -function setPlatform(platform: 'mac' | 'windows') { - jest.resetModules(); - switch (platform) { - case 'mac': { - platformGetter.mockReturnValue('MacIntel'); - break; - } - case 'windows': { - platformGetter.mockReturnValue('Win32'); - break; - } - default: { - break; - } - } - init(); -} - -function clearPlatform() { - platformGetter.mockClear(); -} - function dispatchContextMenuEvents(ref, options) { const preventDefault = options.preventDefault || function() {}; const variant = (options.variant: 'mouse' | 'touch' | 'modified'); @@ -76,7 +45,7 @@ function dispatchContextMenuEvents(ref, options) { createEvent('pointerdown', {pointerType: 'mouse', button: 0}), ); dispatchEvent(createEvent('mousedown', {button: 0})); - if (global.navigator.platform === 'MacIntel') { + if (platform.get() === 'mac') { dispatchEvent( createEvent('contextmenu', {button: 0, ctrlKey: true, preventDefault}), ); @@ -97,144 +66,143 @@ function dispatchContextMenuEvents(ref, options) { } const forcePointerEvents = true; +const table = [[forcePointerEvents], [!forcePointerEvents]]; + +describe.each(table)('ContextMenu responder', hasPointerEvents => { + let container; + + beforeEach(() => { + initializeModules(hasPointerEvents); + container = document.createElement('div'); + document.body.appendChild(container); + }); + + afterEach(() => { + ReactDOM.render(null, container); + document.body.removeChild(container); + container = null; + }); + + describe('all platforms', () => { + it('mouse right-click', () => { + const onContextMenu = jest.fn(); + const preventDefault = jest.fn(); + const ref = React.createRef(); + const Component = () => { + const listener = useContextMenuResponder({onContextMenu}); + return
; + }; + ReactDOM.render(, container); + + dispatchContextMenuEvents(ref, {variant: 'mouse', preventDefault}); + expect(preventDefault).toHaveBeenCalledTimes(1); + expect(onContextMenu).toHaveBeenCalledTimes(1); + expect(onContextMenu).toHaveBeenCalledWith( + expect.objectContaining({pointerType: 'mouse', type: 'contextmenu'}), + ); + }); + + it('touch long-press', () => { + const onContextMenu = jest.fn(); + const preventDefault = jest.fn(); + const ref = React.createRef(); + const Component = () => { + const listener = useContextMenuResponder({onContextMenu}); + return
; + }; + ReactDOM.render(, container); + + dispatchContextMenuEvents(ref, {variant: 'touch', preventDefault}); + expect(preventDefault).toHaveBeenCalledTimes(1); + expect(onContextMenu).toHaveBeenCalledTimes(1); + expect(onContextMenu).toHaveBeenCalledWith( + expect.objectContaining({pointerType: 'touch', type: 'contextmenu'}), + ); + }); + + it('"disabled" is true', () => { + const onContextMenu = jest.fn(); + const ref = React.createRef(); + const Component = () => { + const listener = useContextMenuResponder({ + onContextMenu, + disabled: true, + }); + return
; + }; + ReactDOM.render(, container); + + dispatchContextMenuEvents(ref, 'mouse'); + expect(onContextMenu).toHaveBeenCalledTimes(0); + }); -describe.each([[forcePointerEvents], [!forcePointerEvents]])( - 'ContextMenu responder', - hasPointerEvents => { - let container; + it('"preventDefault" is false', () => { + const preventDefault = jest.fn(); + const onContextMenu = jest.fn(); + const ref = React.createRef(); + const Component = () => { + const listener = useContextMenuResponder({ + onContextMenu, + preventDefault: false, + }); + return
; + }; + ReactDOM.render(, container); + + dispatchContextMenuEvents(ref, {variant: 'mouse', preventDefault}); + expect(preventDefault).toHaveBeenCalledTimes(0); + expect(onContextMenu).toHaveBeenCalledTimes(1); + }); + }); + describe('mac platform', () => { beforeEach(() => { + platform.set('mac'); jest.resetModules(); - init(hasPointerEvents); - container = document.createElement('div'); - document.body.appendChild(container); }); afterEach(() => { - ReactDOM.render(null, container); - document.body.removeChild(container); - container = null; + platform.clear(); }); - describe('all platforms', () => { - it('mouse right-click', () => { - const onContextMenu = jest.fn(); - const preventDefault = jest.fn(); - const ref = React.createRef(); - const Component = () => { - const listener = useContextMenuResponder({onContextMenu}); - return
; - }; - ReactDOM.render(, container); - - dispatchContextMenuEvents(ref, {variant: 'mouse', preventDefault}); - expect(preventDefault).toHaveBeenCalledTimes(1); - expect(onContextMenu).toHaveBeenCalledTimes(1); - expect(onContextMenu).toHaveBeenCalledWith( - expect.objectContaining({pointerType: 'mouse', type: 'contextmenu'}), - ); - }); - - it('touch long-press', () => { - const onContextMenu = jest.fn(); - const preventDefault = jest.fn(); - const ref = React.createRef(); - const Component = () => { - const listener = useContextMenuResponder({onContextMenu}); - return
; - }; - ReactDOM.render(, container); - - dispatchContextMenuEvents(ref, {variant: 'touch', preventDefault}); - expect(preventDefault).toHaveBeenCalledTimes(1); - expect(onContextMenu).toHaveBeenCalledTimes(1); - expect(onContextMenu).toHaveBeenCalledWith( - expect.objectContaining({pointerType: 'touch', type: 'contextmenu'}), - ); - }); - - it('"disabled" is true', () => { - const onContextMenu = jest.fn(); - const ref = React.createRef(); - const Component = () => { - const listener = useContextMenuResponder({ - onContextMenu, - disabled: true, - }); - return
; - }; - ReactDOM.render(, container); - - dispatchContextMenuEvents(ref, 'mouse'); - expect(onContextMenu).toHaveBeenCalledTimes(0); - }); - - it('"preventDefault" is false', () => { - const preventDefault = jest.fn(); - const onContextMenu = jest.fn(); - const ref = React.createRef(); - const Component = () => { - const listener = useContextMenuResponder({ - onContextMenu, - preventDefault: false, - }); - return
; - }; - ReactDOM.render(, container); - - dispatchContextMenuEvents(ref, {variant: 'mouse', preventDefault}); - expect(preventDefault).toHaveBeenCalledTimes(0); - expect(onContextMenu).toHaveBeenCalledTimes(1); - }); + it('mouse modified left-click', () => { + const onContextMenu = jest.fn(); + const ref = React.createRef(); + const Component = () => { + const listener = useContextMenuResponder({onContextMenu}); + return
; + }; + ReactDOM.render(, container); + + dispatchContextMenuEvents(ref, {variant: 'modified'}); + expect(onContextMenu).toHaveBeenCalledTimes(1); + expect(onContextMenu).toHaveBeenCalledWith( + expect.objectContaining({pointerType: 'mouse', type: 'contextmenu'}), + ); }); + }); - describe('mac platform', () => { - beforeEach(() => { - setPlatform('mac'); - }); - - afterEach(() => { - clearPlatform(); - }); - - it('mouse modified left-click', () => { - const onContextMenu = jest.fn(); - const ref = React.createRef(); - const Component = () => { - const listener = useContextMenuResponder({onContextMenu}); - return
; - }; - ReactDOM.render(, container); - - dispatchContextMenuEvents(ref, {variant: 'modified'}); - expect(onContextMenu).toHaveBeenCalledTimes(1); - expect(onContextMenu).toHaveBeenCalledWith( - expect.objectContaining({pointerType: 'mouse', type: 'contextmenu'}), - ); - }); + describe('windows platform', () => { + beforeEach(() => { + platform.set('windows'); + jest.resetModules(); + }); + + afterEach(() => { + platform.clear(); }); - describe('windows platform', () => { - beforeEach(() => { - setPlatform('windows'); - }); - - afterEach(() => { - clearPlatform(); - }); - - it('mouse modified left-click', () => { - const onContextMenu = jest.fn(); - const ref = React.createRef(); - const Component = () => { - const listener = useContextMenuResponder({onContextMenu}); - return
; - }; - ReactDOM.render(, container); - - dispatchContextMenuEvents(ref, {variant: 'modified'}); - expect(onContextMenu).toHaveBeenCalledTimes(0); - }); + it('mouse modified left-click', () => { + const onContextMenu = jest.fn(); + const ref = React.createRef(); + const Component = () => { + const listener = useContextMenuResponder({onContextMenu}); + return
; + }; + ReactDOM.render(, container); + + dispatchContextMenuEvents(ref, {variant: 'modified'}); + expect(onContextMenu).toHaveBeenCalledTimes(0); }); - }, -); + }); +}); diff --git a/packages/react-events/src/dom/__tests__/Hover-test.internal.js b/packages/react-events/src/dom/__tests__/Hover-test.internal.js index 895a8236046e6..73d5106e6fbab 100644 --- a/packages/react-events/src/dom/__tests__/Hover-test.internal.js +++ b/packages/react-events/src/dom/__tests__/Hover-test.internal.js @@ -9,50 +9,41 @@ 'use strict'; +import { + dispatchPointerCancel, + dispatchPointerHoverEnter, + dispatchPointerHoverExit, + dispatchPointerHoverMove, + dispatchTouchTap, + setPointerEvent, +} from '../test-utils'; + let React; let ReactFeatureFlags; let ReactDOM; -let TestUtils; -let Scheduler; let HoverResponder; let useHoverResponder; -const createEvent = (type, data) => { - const event = document.createEvent('CustomEvent'); - event.initCustomEvent(type, true, true); - if (data != null) { - Object.entries(data).forEach(([key, value]) => { - event[key] = value; - }); - } - return event; -}; - -function createTouchEvent(type, id, data) { - return createEvent(type, { - changedTouches: [ - { - ...data, - identifier: id, - }, - ], - }); +function initializeModules(hasPointerEvents) { + jest.resetModules(); + setPointerEvent(hasPointerEvents); + ReactFeatureFlags = require('shared/ReactFeatureFlags'); + ReactFeatureFlags.enableFlareAPI = true; + ReactFeatureFlags.enableUserBlockingEvents = true; + React = require('react'); + ReactDOM = require('react-dom'); + HoverResponder = require('react-events/hover').HoverResponder; + useHoverResponder = require('react-events/hover').useHoverResponder; } -describe('Hover event responder', () => { +const forcePointerEvents = true; +const table = [[forcePointerEvents], [!forcePointerEvents]]; + +describe.each(table)('Hover responder', hasPointerEvents => { let container; beforeEach(() => { - jest.resetModules(); - ReactFeatureFlags = require('shared/ReactFeatureFlags'); - ReactFeatureFlags.enableFlareAPI = true; - ReactFeatureFlags.enableUserBlockingEvents = true; - React = require('react'); - ReactDOM = require('react-dom'); - TestUtils = require('react-dom/test-utils'); - Scheduler = require('scheduler'); - HoverResponder = require('react-events/hover').HoverResponder; - useHoverResponder = require('react-events/hover').useHoverResponder; + initializeModules(hasPointerEvents); container = document.createElement('div'); document.body.appendChild(container); }); @@ -64,17 +55,21 @@ describe('Hover event responder', () => { }); describe('disabled', () => { - let onHoverStart, onHoverEnd, ref; + let onHoverChange, onHoverStart, onHoverMove, onHoverEnd, ref; beforeEach(() => { + onHoverChange = jest.fn(); onHoverStart = jest.fn(); + onHoverMove = jest.fn(); onHoverEnd = jest.fn(); ref = React.createRef(); const Component = () => { const listener = useHoverResponder({ disabled: true, - onHoverStart: onHoverStart, - onHoverEnd: onHoverEnd, + onHoverChange, + onHoverStart, + onHoverMove, + onHoverEnd, }); return
; }; @@ -82,9 +77,11 @@ describe('Hover event responder', () => { }); it('prevents custom events being dispatched', () => { - ref.current.dispatchEvent(createEvent('pointerover')); - ref.current.dispatchEvent(createEvent('pointerout')); + dispatchPointerHoverEnter(ref); + dispatchPointerHoverExit(ref); + expect(onHoverChange).not.toBeCalled(); expect(onHoverStart).not.toBeCalled(); + expect(onHoverMove).not.toBeCalled(); expect(onHoverEnd).not.toBeCalled(); }); }); @@ -104,68 +101,21 @@ describe('Hover event responder', () => { ReactDOM.render(, container); }); - it('is called after "pointerover" event', () => { - ref.current.dispatchEvent(createEvent('pointerover')); + it('is called for mouse pointers', () => { + dispatchPointerHoverEnter(ref); expect(onHoverStart).toHaveBeenCalledTimes(1); }); - it('is not called if "pointerover" pointerType is touch', () => { - const event = createEvent('pointerover', {pointerType: 'touch'}); - ref.current.dispatchEvent(event); + it('is not called for touch pointers', () => { + dispatchTouchTap(ref); expect(onHoverStart).not.toBeCalled(); }); - it('is called if valid "pointerover" follows touch', () => { - ref.current.dispatchEvent( - createEvent('pointerover', {pointerType: 'touch'}), - ); - ref.current.dispatchEvent( - createEvent('pointerout', {pointerType: 'touch'}), - ); - ref.current.dispatchEvent( - createEvent('pointerover', {pointerType: 'mouse'}), - ); - expect(onHoverStart).toHaveBeenCalledTimes(1); - }); - - it('ignores browser emulated "mouseover" event', () => { - ref.current.dispatchEvent(createEvent('pointerover')); - ref.current.dispatchEvent( - createEvent('mouseover', { - button: 0, - }), - ); + it('is called if a mouse pointer is used after a touch pointer', () => { + dispatchTouchTap(ref); + dispatchPointerHoverEnter(ref); expect(onHoverStart).toHaveBeenCalledTimes(1); }); - - // No PointerEvent fallbacks - it('is called after "mouseover" event', () => { - ref.current.dispatchEvent( - createEvent('mouseover', { - button: 0, - }), - ); - expect(onHoverStart).toHaveBeenCalledTimes(1); - }); - - it('is not called after "touchstart"', () => { - ref.current.dispatchEvent( - createTouchEvent('touchstart', 0, { - target: ref.current, - }), - ); - ref.current.dispatchEvent( - createTouchEvent('touchend', 0, { - target: ref.current, - }), - ); - ref.current.dispatchEvent( - createEvent('mouseover', { - button: 0, - }), - ); - expect(onHoverStart).not.toBeCalled(); - }); }); describe('onHoverChange', () => { @@ -183,58 +133,18 @@ describe('Hover event responder', () => { ReactDOM.render(, container); }); - it('is called after "pointerover" and "pointerout" events', () => { - ref.current.dispatchEvent(createEvent('pointerover')); - expect(onHoverChange).toHaveBeenCalledTimes(1); - expect(onHoverChange).toHaveBeenCalledWith(true); - ref.current.dispatchEvent(createEvent('pointerout')); - expect(onHoverChange).toHaveBeenCalledTimes(2); - expect(onHoverChange).toHaveBeenCalledWith(false); - }); - - // No PointerEvent fallbacks - it('is called after "mouseover" and "mouseout" events', () => { - ref.current.dispatchEvent(createEvent('mouseover')); + it('is called for mouse pointers', () => { + dispatchPointerHoverEnter(ref); expect(onHoverChange).toHaveBeenCalledTimes(1); expect(onHoverChange).toHaveBeenCalledWith(true); - ref.current.dispatchEvent(createEvent('mouseout')); + dispatchPointerHoverExit(ref); expect(onHoverChange).toHaveBeenCalledTimes(2); expect(onHoverChange).toHaveBeenCalledWith(false); }); - it('should be user-blocking but not discrete', async () => { - const {act} = TestUtils; - const {useState} = React; - - const newContainer = document.createElement('div'); - document.body.appendChild(newContainer); - const root = ReactDOM.unstable_createRoot(newContainer); - - const target = React.createRef(null); - function Foo() { - const [isHover, setHover] = useState(false); - const listener = useHoverResponder({ - onHoverChange: setHover, - }); - return ( -
- {isHover ? 'hovered' : 'not hovered'} -
- ); - } - - await act(async () => { - root.render(); - }); - expect(newContainer.textContent).toEqual('not hovered'); - - await act(async () => { - target.current.dispatchEvent(createEvent('mouseover')); - - // 3s should be enough to expire the updates - Scheduler.unstable_advanceTime(3000); - expect(newContainer.textContent).toEqual('hovered'); - }); + it('is not called for touch pointers', () => { + dispatchTouchTap(ref); + expect(onHoverChange).not.toBeCalled(); }); }); @@ -253,64 +163,35 @@ describe('Hover event responder', () => { ReactDOM.render(, container); }); - it('is called after "pointerout" event', () => { - ref.current.dispatchEvent(createEvent('pointerover')); - ref.current.dispatchEvent(createEvent('pointerout')); + it('is called for mouse pointers', () => { + dispatchPointerHoverEnter(ref); + dispatchPointerHoverExit(ref); expect(onHoverEnd).toHaveBeenCalledTimes(1); }); - it('is not called if "pointerover" pointerType is touch', () => { - const event = createEvent('pointerover'); - event.pointerType = 'touch'; - ref.current.dispatchEvent(event); - ref.current.dispatchEvent(createEvent('pointerout')); - expect(onHoverEnd).not.toBeCalled(); - }); - - it('ignores browser emulated "mouseout" event', () => { - ref.current.dispatchEvent(createEvent('pointerover')); - ref.current.dispatchEvent(createEvent('pointerout')); - ref.current.dispatchEvent(createEvent('mouseout')); - expect(onHoverEnd).toHaveBeenCalledTimes(1); - }); - - it('is called after "pointercancel" event', () => { - ref.current.dispatchEvent(createEvent('pointerover')); - ref.current.dispatchEvent(createEvent('pointercancel')); - expect(onHoverEnd).toHaveBeenCalledTimes(1); - }); - - it('is not called again after "pointercancel" event if it follows "pointerout"', () => { - ref.current.dispatchEvent(createEvent('pointerover')); - ref.current.dispatchEvent(createEvent('pointerout')); - ref.current.dispatchEvent(createEvent('pointercancel')); - expect(onHoverEnd).toHaveBeenCalledTimes(1); - }); + if (hasPointerEvents) { + it('is called once for cancelled mouse pointers', () => { + dispatchPointerHoverEnter(ref); + dispatchPointerCancel(ref); + expect(onHoverEnd).toHaveBeenCalledTimes(1); + + // only called once if cancel follows exit + onHoverEnd.mockReset(); + dispatchPointerHoverEnter(ref); + dispatchPointerHoverExit(ref); + dispatchPointerCancel(ref); + expect(onHoverEnd).toHaveBeenCalledTimes(1); + }); + } - // No PointerEvent fallbacks - it('is called after "mouseout" event', () => { - ref.current.dispatchEvent(createEvent('mouseover')); - ref.current.dispatchEvent(createEvent('mouseout')); - expect(onHoverEnd).toHaveBeenCalledTimes(1); - }); - it('is not called after "touchend"', () => { - ref.current.dispatchEvent( - createTouchEvent('touchstart', 0, { - target: ref.current, - }), - ); - ref.current.dispatchEvent( - createTouchEvent('touchend', 0, { - target: ref.current, - }), - ); - ref.current.dispatchEvent(createEvent('mouseout')); + it('is not called for touch pointers', () => { + dispatchTouchTap(ref); expect(onHoverEnd).not.toBeCalled(); }); }); describe('onHoverMove', () => { - it('is called after "pointermove"', () => { + it('is called after the active pointer moves"', () => { const onHoverMove = jest.fn(); const ref = React.createRef(); const Component = () => { @@ -320,20 +201,9 @@ describe('Hover event responder', () => { return
; }; ReactDOM.render(, container); - - ref.current.getBoundingClientRect = () => ({ - top: 50, - left: 50, - bottom: 500, - right: 500, - }); - ref.current.dispatchEvent(createEvent('pointerover')); - ref.current.dispatchEvent( - createEvent('pointermove', {pointerType: 'mouse'}), - ); - ref.current.dispatchEvent(createEvent('touchmove')); - ref.current.dispatchEvent(createEvent('mousemove')); - expect(onHoverMove).toHaveBeenCalledTimes(1); + dispatchPointerHoverEnter(ref); + dispatchPointerHoverMove(ref, {from: {x: 0, y: 0}, to: {x: 1, y: 1}}); + expect(onHoverMove).toHaveBeenCalledTimes(2); expect(onHoverMove).toHaveBeenCalledWith( expect.objectContaining({type: 'hovermove'}), ); @@ -372,18 +242,13 @@ describe('Hover event responder', () => { }; ReactDOM.render(, container); - outerRef.current.dispatchEvent(createEvent('pointerover')); - outerRef.current.dispatchEvent( - createEvent('pointerout', {relatedTarget: innerRef.current}), - ); - innerRef.current.dispatchEvent(createEvent('pointerover')); - innerRef.current.dispatchEvent( - createEvent('pointerout', {relatedTarget: outerRef.current}), - ); - outerRef.current.dispatchEvent( - createEvent('pointerover', {relatedTarget: innerRef.current}), - ); - outerRef.current.dispatchEvent(createEvent('pointerout')); + dispatchPointerHoverEnter(outerRef, {relatedTarget: container}); + dispatchPointerHoverExit(outerRef, {relatedTarget: innerRef.current}); + dispatchPointerHoverEnter(innerRef, {relatedTarget: outerRef.current}); + dispatchPointerHoverExit(innerRef, {relatedTarget: outerRef.current}); + dispatchPointerHoverEnter(outerRef, {relatedTarget: innerRef.current}); + dispatchPointerHoverExit(outerRef, {relatedTarget: container}); + expect(events).toEqual([ 'outer: onHoverStart', 'outer: onHoverChange', @@ -411,10 +276,10 @@ describe('Hover event responder', () => { const eventLog = []; const logEvent = event => { const propertiesWeCareAbout = { + x: event.x, + y: event.y, pageX: event.pageX, pageY: event.pageY, - screenX: event.screenX, - screenY: event.screenY, clientX: event.clientX, clientY: event.clientY, pointerType: event.pointerType, @@ -435,79 +300,58 @@ describe('Hover event responder', () => { }; ReactDOM.render(, container); - ref.current.getBoundingClientRect = () => ({ - top: 10, - left: 10, - bottom: 20, - right: 20, - }); + dispatchPointerHoverEnter(ref, {x: 10, y: 10}); + dispatchPointerHoverMove(ref, {from: {x: 10, y: 10}, to: {x: 20, y: 20}}); + dispatchPointerHoverExit(ref, {x: 20, y: 20}); - ref.current.dispatchEvent( - createEvent('pointerover', { - pointerType: 'mouse', - pageX: 15, - pageY: 16, - screenX: 20, - screenY: 21, - clientX: 30, - clientY: 31, - }), - ); - ref.current.dispatchEvent( - createEvent('pointermove', { - pointerType: 'mouse', - pageX: 16, - pageY: 17, - screenX: 21, - screenY: 22, - clientX: 31, - clientY: 32, - }), - ); - ref.current.dispatchEvent( - createEvent('pointerout', { - pointerType: 'mouse', - pageX: 17, - pageY: 18, - screenX: 22, - screenY: 23, - clientX: 32, - clientY: 33, - }), - ); expect(eventLog).toEqual([ { - pageX: 15, - pageY: 16, - screenX: 20, - screenY: 21, - clientX: 30, - clientY: 31, + x: 10, + y: 10, + pageX: 10, + pageY: 10, + clientX: 10, + clientY: 10, target: ref.current, timeStamp: timeStamps[0], type: 'hoverstart', + pointerType: 'mouse', }, { - pageX: 16, - pageY: 17, - screenX: 21, - screenY: 22, - clientX: 31, - clientY: 32, + x: 10, + y: 10, + pageX: 10, + pageY: 10, + clientX: 10, + clientY: 10, target: ref.current, timeStamp: timeStamps[1], type: 'hovermove', + pointerType: 'mouse', }, { - pageX: 17, - pageY: 18, - screenX: 22, - screenY: 23, - clientX: 32, - clientY: 33, + x: 20, + y: 20, + pageX: 20, + pageY: 20, + clientX: 20, + clientY: 20, target: ref.current, timeStamp: timeStamps[2], + type: 'hovermove', + pointerType: 'mouse', + }, + { + x: 20, + y: 20, + pageX: 20, + pageY: 20, + clientX: 20, + clientY: 20, + target: ref.current, + timeStamp: timeStamps[3], type: 'hoverend', + pointerType: 'mouse', }, ]); }); diff --git a/packages/react-events/src/dom/test-utils.js b/packages/react-events/src/dom/test-utils.js new file mode 100644 index 0000000000000..ddf6bf6773a1f --- /dev/null +++ b/packages/react-events/src/dom/test-utils.js @@ -0,0 +1,311 @@ +/** + * Copyright (c) Facebook, Inc. and its 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'; + +/* eslint-disable no-unused-vars */ + +/** + * Change environment support for PointerEvent. + */ + +function hasPointerEvent(bool) { + return global != null && global.PointerEvent != null; +} + +function setPointerEvent(bool) { + global.PointerEvent = bool ? function() {} : undefined; +} + +/** + * Change environment host platform. + */ + +const platformGetter = jest.spyOn(global.navigator, 'platform', 'get'); + +const platform = { + clear() { + platformGetter.mockClear(); + }, + get() { + return global.navigator.platform === 'MacIntel' ? 'mac' : 'windows'; + }, + set(name: 'mac' | 'windows') { + switch (name) { + case 'mac': { + platformGetter.mockReturnValue('MacIntel'); + break; + } + case 'windows': { + platformGetter.mockReturnValue('Win32'); + break; + } + default: { + break; + } + } + }, +}; + +/** + * Mock native events + */ + +function createEvent(type, data) { + const event = document.createEvent('CustomEvent'); + event.initCustomEvent(type, true, true); + if (data != null) { + Object.entries(data).forEach(([key, value]) => { + event[key] = value; + }); + } + return event; +} + +function createTouchEvent(type, data, id) { + return createEvent(type, { + changedTouches: [ + { + ...data, + identifier: id, + }, + ], + }); +} + +const createKeyboardEvent = (type, data) => { + return new KeyboardEvent(type, { + bubbles: true, + cancelable: true, + ...data, + }); +}; + +function blur(data) { + return createEvent('blur', data); +} + +function click(data) { + return createEvent('click', data); +} + +function dragstart(data) { + return createEvent('dragstart', data); +} + +function focus(data) { + return createEvent('focus', data); +} + +function gotpointercapture(data) { + return createEvent('gotpointercapture', data); +} + +function lostpointercapture(data) { + return createEvent('lostpointercapture', data); +} + +function pointercancel(data) { + return createEvent('pointercancel', data); +} + +function pointerdown(data) { + return createEvent('pointerdown', data); +} + +function pointerenter(data) { + return createEvent('pointerenter', data); +} + +function pointerleave(data) { + return createEvent('pointerleave', data); +} + +function pointermove(data) { + return createEvent('pointermove', data); +} + +function pointerout(data) { + return createEvent('pointerout', data); +} + +function pointerover(data) { + return createEvent('pointerover', data); +} + +function pointerup(data) { + return createEvent('pointerup', data); +} + +function mousedown(data) { + return createEvent('mousedown', data); +} + +function mouseenter(data) { + return createEvent('mouseenter', data); +} + +function mouseleave(data) { + return createEvent('mouseleave', data); +} + +function mousemove(data) { + return createEvent('mousemove', data); +} + +function mouseout(data) { + return createEvent('mouseout', data); +} + +function mouseover(data) { + return createEvent('mouseover', data); +} + +function mouseup(data) { + return createEvent('mouseup', data); +} + +function touchcancel(data, id) { + return createTouchEvent('touchcancel', data, id); +} + +function touchend(data, id) { + return createTouchEvent('touchend', data, id); +} + +function touchmove(data, id) { + return createTouchEvent('touchmove', data, id); +} + +function touchstart(data, id) { + return createTouchEvent('touchstart', data, id); +} + +/** + * Dispatch high-level event sequences + */ + +function dispatchPointerHoverEnter(ref, {relatedTarget, x, y} = {}) { + const dispatch = arg => ref.current.dispatchEvent(arg); + const button = -1; + const pointerType = 'mouse'; + const event = { + button, + relatedTarget, + clientX: x, + clientY: y, + pageX: x, + pageY: y, + }; + if (hasPointerEvent()) { + dispatch(pointerover({pointerType, ...event})); + dispatch(pointerenter({pointerType, ...event})); + } + dispatch(mouseover(event)); + dispatch(mouseover(event)); +} + +function dispatchPointerHoverMove(ref, {from, to} = {}) { + const dispatch = arg => ref.current.dispatchEvent(arg); + const button = -1; + const pointerId = 1; + const pointerType = 'mouse'; + function dispatchMove({x, y}) { + const event = { + button, + clientX: x, + clientY: y, + pageX: x, + pageY: y, + }; + if (hasPointerEvent()) { + dispatch(pointermove({pointerId, pointerType, ...event})); + } + dispatch(mousemove(event)); + } + dispatchMove({x: from.x, y: from.y}); + dispatchMove({x: to.x, y: to.y}); +} + +function dispatchPointerHoverExit(ref, {relatedTarget, x, y} = {}) { + const dispatch = arg => ref.current.dispatchEvent(arg); + const button = -1; + const pointerType = 'mouse'; + const event = { + button, + relatedTarget, + clientX: x, + clientY: y, + pageX: x, + pageY: y, + }; + if (hasPointerEvent()) { + dispatch(pointerout({pointerType, ...event})); + dispatch(pointerleave({pointerType, ...event})); + } + dispatch(mouseout(event)); + dispatch(mouseleave(event)); +} + +function dispatchPointerCancel(ref, options) { + const dispatchEvent = arg => ref.current.dispatchEvent(arg); + dispatchEvent(pointercancel({pointerType: 'mouse'})); + dispatchEvent(dragstart({pointerType: 'mouse'})); +} + +function dispatchPointerPressDown(ref, {button = 0, pointerType = 'mouse'}) { + const dispatch = arg => ref.current.dispatchEvent(arg); + const pointerId = 1; + if (hasPointerEvent()) { + dispatch(pointerover({pointerId, pointerType, button})); + dispatch(pointerenter({pointerId, pointerType, button})); + dispatch(pointerdown({pointerId, pointerType, button})); + } + dispatch(touchstart(null, pointerId)); + if (hasPointerEvent()) { + dispatch(gotpointercapture({pointerId, pointerType, button})); + } +} + +function dispatchPointerPressRelease(ref, {button = 0, pointerType = 'mouse'}) { + const dispatch = arg => ref.current.dispatchEvent(arg); + const pointerId = 1; + if (hasPointerEvent()) { + dispatch(pointerup({pointerId, pointerType, button})); + dispatch(lostpointercapture({pointerId, pointerType, button})); + dispatch(pointerout({pointerId, pointerType, button})); + dispatch(pointerleave({pointerId, pointerType, button})); + } + dispatch(touchend(null, pointerId)); + dispatch(mouseover({button})); + dispatch(mousemove({button})); + dispatch(mousedown({button})); + dispatch(focus({button})); + dispatch(mouseup({button})); + dispatch(click({button})); +} + +function dispatchTouchTap(ref) { + dispatchPointerPressDown(ref, {pointerType: 'touch'}); + dispatchPointerPressRelease(ref, {pointerType: 'touch'}); +} + +module.exports = { + createEvent, + dispatchPointerCancel, + dispatchPointerHoverEnter, + dispatchPointerHoverExit, + dispatchPointerHoverMove, + dispatchPointerPressDown, + dispatchPointerPressRelease, + dispatchTouchTap, + platform, + hasPointerEvent, + setPointerEvent, +};