diff --git a/packages/react-dom/src/events/DOMEventResponderSystem.js b/packages/react-dom/src/events/DOMEventResponderSystem.js index a1898b332d6d5..685e227c7b1c7 100644 --- a/packages/react-dom/src/events/DOMEventResponderSystem.js +++ b/packages/react-dom/src/events/DOMEventResponderSystem.js @@ -18,6 +18,7 @@ import { HostComponent, } from 'shared/ReactWorkTags'; import type { + ReactEventResponder, ReactEventResponderEventType, ReactEventComponentInstance, ReactResponderContext, @@ -61,13 +62,10 @@ type ResponderTimeout = {| type ResponderTimer = {| instance: ReactEventComponentInstance, - func: () => boolean, + func: () => void, id: Symbol, |}; -const ROOT_PHASE = 0; -const BUBBLE_PHASE = 1; -const CAPTURE_PHASE = 2; const activeTimeouts: Map = new Map(); const rootEventTypesToEventComponentInstances: Map< DOMTopLevelEventType | string, @@ -253,7 +251,7 @@ const eventResponderContext: ReactResponderContext = { triggerOwnershipListeners(); return false; }, - setTimeout(func: () => boolean, delay): Symbol { + setTimeout(func: () => void, delay): Symbol { validateResponderContext(); if (currentTimers === null) { currentTimers = new Map(); @@ -349,16 +347,13 @@ const eventResponderContext: ReactResponderContext = { function processTimers(timers: Map): void { const timersArr = Array.from(timers.values()); - let shouldStopPropagation = false; currentEventQueue = createEventQueue(); try { for (let i = 0; i < timersArr.length; i++) { const {instance, func, id} = timersArr[i]; currentInstance = instance; try { - if (!shouldStopPropagation) { - shouldStopPropagation = func(); - } + func(); } finally { activeTimeouts.delete(id); } @@ -390,7 +385,6 @@ function createResponderEvent( nativeEvent: AnyNativeEvent, nativeEventTarget: Element | Document, eventSystemFlags: EventSystemFlags, - phase: 0 | 1 | 2, ): ReactResponderEvent { const responderEvent = { nativeEvent: nativeEvent, @@ -398,7 +392,6 @@ function createResponderEvent( type: topLevelType, passive: (eventSystemFlags & IS_PASSIVE) !== 0, passiveSupported: (eventSystemFlags & PASSIVE_NOT_SUPPORTED) === 0, - phase, }; if (__DEV__) { Object.freeze(responderEvent); @@ -510,16 +503,7 @@ function getRootEventResponderInstances( return eventResponderInstances; } -function triggerEventResponderEventListener( - responderEvent: ReactResponderEvent, - eventComponentInstance: ReactEventComponentInstance, -): boolean { - const {responder, props, state} = eventComponentInstance; - currentInstance = eventComponentInstance; - return responder.onEvent(responderEvent, eventResponderContext, props, state); -} - -function traverseAndTriggerEventResponderInstances( +function traverseAndHandleEventResponderInstances( topLevelType: DOMTopLevelEventType, targetFiber: null | Fiber, nativeEvent: AnyNativeEvent, @@ -535,46 +519,54 @@ function traverseAndTriggerEventResponderInstances( topLevelType, targetFiber, ); + const responderEvent = createResponderEvent( + ((topLevelType: any): string), + nativeEvent, + ((nativeEventTarget: any): Element | Document), + eventSystemFlags, + ); + const propagatedEventResponders: Set = new Set(); let length = targetEventResponderInstances.length; let i; - let shouldStopPropagation = false; - let responderEvent; + // Captured and bubbled event phases have the notion of local propagation. + // This means that the propgation chain can be stopped part of the the way + // through processing event component instances. The major difference to other + // events systems is that the stopping of propgation is localized to a single + // phase, rather than both phases. if (length > 0) { // Capture target phase - responderEvent = createResponderEvent( - ((topLevelType: any): string), - nativeEvent, - ((nativeEventTarget: any): Element | Document), - eventSystemFlags, - CAPTURE_PHASE, - ); for (i = length; i-- > 0; ) { const targetEventResponderInstance = targetEventResponderInstances[i]; - shouldStopPropagation = triggerEventResponderEventListener( - responderEvent, - targetEventResponderInstance, - ); - if (shouldStopPropagation) { - return; + const {responder, props, state} = targetEventResponderInstance; + if (responder.stopLocalPropagation) { + if (propagatedEventResponders.has(responder)) { + continue; + } + propagatedEventResponders.add(responder); + } + const eventListener = responder.onEventCapture; + if (eventListener !== undefined) { + currentInstance = targetEventResponderInstance; + eventListener(responderEvent, eventResponderContext, props, state); } } + // We clean propagated event responders between phases. + propagatedEventResponders.clear(); // Bubble target phase - responderEvent = createResponderEvent( - ((topLevelType: any): string), - nativeEvent, - ((nativeEventTarget: any): Element | Document), - eventSystemFlags, - BUBBLE_PHASE, - ); for (i = 0; i < length; i++) { const targetEventResponderInstance = targetEventResponderInstances[i]; - shouldStopPropagation = triggerEventResponderEventListener( - responderEvent, - targetEventResponderInstance, - ); - if (shouldStopPropagation) { - return; + const {responder, props, state} = targetEventResponderInstance; + if (responder.stopLocalPropagation) { + if (propagatedEventResponders.has(responder)) { + continue; + } + propagatedEventResponders.add(responder); + } + const eventListener = responder.onEvent; + if (eventListener !== undefined) { + currentInstance = targetEventResponderInstance; + eventListener(responderEvent, eventResponderContext, props, state); } } } @@ -584,21 +576,13 @@ function traverseAndTriggerEventResponderInstances( ); length = rootEventResponderInstances.length; if (length > 0) { - responderEvent = createResponderEvent( - ((topLevelType: any): string), - nativeEvent, - ((nativeEventTarget: any): Element | Document), - eventSystemFlags, - ROOT_PHASE, - ); for (i = 0; i < length; i++) { - const targetEventResponderInstance = rootEventResponderInstances[i]; - shouldStopPropagation = triggerEventResponderEventListener( - responderEvent, - targetEventResponderInstance, - ); - if (shouldStopPropagation) { - return; + const rootEventResponderInstance = rootEventResponderInstances[i]; + const {responder, props, state} = rootEventResponderInstance; + const eventListener = responder.onRootEvent; + if (eventListener !== undefined) { + currentInstance = rootEventResponderInstance; + eventListener(responderEvent, eventResponderContext, props, state); } } } @@ -672,7 +656,7 @@ export function dispatchEventForResponderEventSystem( if (enableEventAPI) { currentEventQueue = createEventQueue(); try { - traverseAndTriggerEventResponderInstances( + traverseAndHandleEventResponderInstances( topLevelType, targetFiber, nativeEvent, diff --git a/packages/react-dom/src/events/__tests__/DOMEventResponderSystem-test.internal.js b/packages/react-dom/src/events/__tests__/DOMEventResponderSystem-test.internal.js index 7a6b51bb8ca63..98b7ef9f2cf0e 100644 --- a/packages/react-dom/src/events/__tests__/DOMEventResponderSystem-test.internal.js +++ b/packages/react-dom/src/events/__tests__/DOMEventResponderSystem-test.internal.js @@ -18,15 +18,21 @@ function createReactEventComponent( targetEventTypes, createInitialState, onEvent, + onEventCapture, + onRootEvent, onUnmount, onOwnershipChange, + stopLocalPropagation, ) { const testEventResponder = { targetEventTypes, createInitialState, onEvent, + onEventCapture, + onRootEvent, onUnmount, onOwnershipChange, + stopLocalPropagation: stopLocalPropagation || false, }; return { @@ -37,18 +43,6 @@ function createReactEventComponent( }; } -const ROOT_PHASE = 0; -const BUBBLE_PHASE = 1; -const CAPTURE_PHASE = 2; - -function phaseToString(phase) { - return phase === ROOT_PHASE - ? 'root' - : phase === BUBBLE_PHASE - ? 'bubble' - : 'capture'; -} - function dispatchEvent(element, type) { const event = document.createEvent('Event'); event.initEvent(type, true, true); @@ -88,7 +82,7 @@ describe('DOMEventResponderSystem', () => { container = null; }); - it('the event responder onEvent() function should fire on click event', () => { + it('the event responder event listeners should fire on click event', () => { let eventResponderFiredCount = 0; let eventLog = []; const buttonRef = React.createRef(); @@ -102,7 +96,16 @@ describe('DOMEventResponderSystem', () => { name: event.type, passive: event.passive, passiveSupported: event.passiveSupported, - phase: event.phase, + phase: 'bubble', + }); + }, + (event, context, props) => { + eventResponderFiredCount++; + eventLog.push({ + name: event.type, + passive: event.passive, + passiveSupported: event.passiveSupported, + phase: 'capture', }); }, ); @@ -127,13 +130,13 @@ describe('DOMEventResponderSystem', () => { name: 'click', passive: false, passiveSupported: false, - phase: CAPTURE_PHASE, + phase: 'capture', }, { name: 'click', passive: false, passiveSupported: false, - phase: BUBBLE_PHASE, + phase: 'bubble', }, ]); @@ -149,7 +152,7 @@ describe('DOMEventResponderSystem', () => { expect(eventResponderFiredCount).toBe(4); }); - it('the event responder onEvent() function should fire on click event (passive events forced)', () => { + it('the event responder event listeners should fire on click event (passive events forced)', () => { // JSDOM does not support passive events, so this manually overrides the value to be true const checkPassiveEvents = require('react-dom/src/events/checkPassiveEvents'); checkPassiveEvents.passiveBrowserEventsSupported = true; @@ -165,7 +168,15 @@ describe('DOMEventResponderSystem', () => { name: event.type, passive: event.passive, passiveSupported: event.passiveSupported, - phase: event.phase, + phase: 'bubble', + }); + }, + (event, context, props) => { + eventLog.push({ + name: event.type, + passive: event.passive, + passiveSupported: event.passiveSupported, + phase: 'capture', }); }, ); @@ -187,18 +198,18 @@ describe('DOMEventResponderSystem', () => { name: 'click', passive: true, passiveSupported: true, - phase: CAPTURE_PHASE, + phase: 'capture', }, { name: 'click', passive: true, passiveSupported: true, - phase: BUBBLE_PHASE, + phase: 'bubble', }, ]); }); - it('nested event responders and their onEvent() function should fire multiple times', () => { + it('nested event responders and their event listeners should fire multiple times', () => { let eventResponderFiredCount = 0; let eventLog = []; const buttonRef = React.createRef(); @@ -212,7 +223,16 @@ describe('DOMEventResponderSystem', () => { name: event.type, passive: event.passive, passiveSupported: event.passiveSupported, - phase: event.phase, + phase: 'bubble', + }); + }, + (event, context, props) => { + eventResponderFiredCount++; + eventLog.push({ + name: event.type, + passive: event.passive, + passiveSupported: event.passiveSupported, + phase: 'capture', }); }, ); @@ -238,30 +258,30 @@ describe('DOMEventResponderSystem', () => { name: 'click', passive: false, passiveSupported: false, - phase: CAPTURE_PHASE, + phase: 'capture', }, { name: 'click', passive: false, passiveSupported: false, - phase: CAPTURE_PHASE, + phase: 'capture', }, { name: 'click', passive: false, passiveSupported: false, - phase: BUBBLE_PHASE, + phase: 'bubble', }, { name: 'click', passive: false, passiveSupported: false, - phase: BUBBLE_PHASE, + phase: 'bubble', }, ]); }); - it('nested event responders and their onEvent() should fire in the correct order', () => { + it('nested event responders and their event listeners should fire in the correct order', () => { let eventLog = []; const buttonRef = React.createRef(); @@ -269,7 +289,10 @@ describe('DOMEventResponderSystem', () => { ['click'], undefined, (event, context, props) => { - eventLog.push(`A [${phaseToString(event.phase)}]`); + eventLog.push(`A [bubble]`); + }, + (event, context, props) => { + eventLog.push(`A [capture]`); }, ); @@ -277,7 +300,10 @@ describe('DOMEventResponderSystem', () => { ['click'], undefined, (event, context, props) => { - eventLog.push(`B [${phaseToString(event.phase)}]`); + eventLog.push(`B [bubble]`); + }, + (event, context, props) => { + eventLog.push(`B [capture]`); }, ); @@ -303,140 +329,81 @@ describe('DOMEventResponderSystem', () => { ]); }); - it('nested event responders and their onEvent() should fire in the correct order with stopPropagation', () => { - let eventLog; - let stopPropagationOnPhase; + it('nested event responders should fire in the correct order without stopLocalPropagation', () => { + let eventLog = []; const buttonRef = React.createRef(); - const ClickEventComponentA = createReactEventComponent( + const ClickEventComponent = createReactEventComponent( ['click'], undefined, (event, context, props) => { - context.addRootEventTypes(event.target.ownerDocument, [ - 'click', - 'pointermove', - ]); - eventLog.push(`A [${event.type}, ${phaseToString(event.phase)}]`); + eventLog.push(`${props.name} [bubble]`); }, - ); - - const ClickEventComponentB = createReactEventComponent( - ['click'], - undefined, (event, context, props) => { - context.addRootEventTypes(event.target.ownerDocument, [ - 'click', - 'pointermove', - ]); - eventLog.push(`B [${event.type}, ${phaseToString(event.phase)}]`); - if (event.phase === stopPropagationOnPhase) { - return true; - } + eventLog.push(`${props.name} [capture]`); }, + undefined, + undefined, + undefined, + false, ); const Test = () => ( - - + + - - + + ); - function runTestWithPhase(phase) { - eventLog = []; - stopPropagationOnPhase = phase; - ReactDOM.render(, container); - let buttonElement = buttonRef.current; - dispatchClickEvent(buttonElement); - dispatchEvent(buttonElement, 'pointermove'); - } + ReactDOM.render(, container); + + // Clicking the button should trigger the event responder onEvent() + let buttonElement = buttonRef.current; + dispatchClickEvent(buttonElement); - runTestWithPhase(BUBBLE_PHASE); - // Root phase should not be skipped for different event type - expect(eventLog).toEqual([ - 'A [click, capture]', - 'B [click, capture]', - 'B [click, bubble]', - 'A [pointermove, root]', - 'B [pointermove, root]', - ]); - runTestWithPhase(CAPTURE_PHASE); - // Root phase should not be skipped for different event type expect(eventLog).toEqual([ - 'A [click, capture]', - 'B [click, capture]', - 'A [pointermove, root]', - 'B [pointermove, root]', + 'A [capture]', + 'B [capture]', + 'B [bubble]', + 'A [bubble]', ]); }); - it('nested event responders and their onEvent() should fire in the correct order with stopPropagation #2', () => { - let eventLog; - let stopPropagationOnPhase; + it('nested event responders should fire in the correct order with stopLocalPropagation', () => { + let eventLog = []; const buttonRef = React.createRef(); - const ClickEventComponentA = createReactEventComponent( + const ClickEventComponent = createReactEventComponent( ['click'], undefined, (event, context, props) => { - context.addRootEventTypes(event.target.ownerDocument, [ - 'click', - 'pointermove', - ]); - eventLog.push(`A [${event.type}, ${phaseToString(event.phase)}]`); - if (event.phase === stopPropagationOnPhase) { - return true; - } + eventLog.push(`${props.name} [bubble]`); }, - ); - - const ClickEventComponentB = createReactEventComponent( - ['click'], - undefined, (event, context, props) => { - context.addRootEventTypes(event.target.ownerDocument, [ - 'click', - 'pointermove', - ]); - eventLog.push(`B [${event.type}, ${phaseToString(event.phase)}]`); + eventLog.push(`${props.name} [capture]`); }, + undefined, + undefined, + undefined, + true, ); const Test = () => ( - - + + - - + + ); - function runTestWithPhase(phase) { - eventLog = []; - stopPropagationOnPhase = phase; - ReactDOM.render(, container); - let buttonElement = buttonRef.current; - dispatchClickEvent(buttonElement); - dispatchEvent(buttonElement, 'pointermove'); - } + ReactDOM.render(, container); - runTestWithPhase(BUBBLE_PHASE); - // Root phase should not be skipped for different event type - expect(eventLog).toEqual([ - 'A [click, capture]', - 'B [click, capture]', - 'B [click, bubble]', - 'A [click, bubble]', - 'A [pointermove, root]', - 'B [pointermove, root]', - ]); - runTestWithPhase(CAPTURE_PHASE); - // Root phase should not be skipped for different event type - expect(eventLog).toEqual([ - 'A [click, capture]', - 'A [pointermove, root]', - 'B [pointermove, root]', - ]); + // Clicking the button should trigger the event responder onEvent() + let buttonElement = buttonRef.current; + dispatchClickEvent(buttonElement); + + expect(eventLog).toEqual(['A [capture]', 'B [bubble]']); }); it('custom event dispatching for click -> magicClick works', () => { @@ -451,7 +418,19 @@ describe('DOMEventResponderSystem', () => { const syntheticEvent = { target: event.target, type: 'magicclick', - phase: phaseToString(event.phase), + phase: 'bubble', + }; + context.dispatchEvent(syntheticEvent, props.onMagicClick, { + discrete: true, + }); + } + }, + (event, context, props) => { + if (props.onMagicClick) { + const syntheticEvent = { + target: event.target, + type: 'magicclick', + phase: 'capture', }; context.dispatchEvent(syntheticEvent, props.onMagicClick, { discrete: true, @@ -490,42 +469,47 @@ describe('DOMEventResponderSystem', () => { let eventLog = []; const buttonRef = React.createRef(); + function handleEvent(event, context, props, phase) { + const pressEvent = { + target: event.target, + type: 'press', + phase, + }; + context.dispatchEvent(pressEvent, props.onPress, {discrete: true}); + + context.setTimeout(() => { + if (props.onLongPress) { + const longPressEvent = { + target: event.target, + type: 'longpress', + phase, + }; + context.dispatchEvent(longPressEvent, props.onLongPress, { + discrete: true, + }); + } + + if (props.onLongPressChange) { + const longPressChangeEvent = { + target: event.target, + type: 'longpresschange', + phase, + }; + context.dispatchEvent(longPressChangeEvent, props.onLongPressChange, { + discrete: true, + }); + } + }, 500); + } + const LongPressEventComponent = createReactEventComponent( ['click'], undefined, (event, context, props) => { - const pressEvent = { - target: event.target, - type: 'press', - phase: phaseToString(event.phase), - }; - context.dispatchEvent(pressEvent, props.onPress, {discrete: true}); - - context.setTimeout(() => { - if (props.onLongPress) { - const longPressEvent = { - target: event.target, - type: 'longpress', - phase: phaseToString(event.phase), - }; - context.dispatchEvent(longPressEvent, props.onLongPress, { - discrete: true, - }); - } - - if (props.onLongPressChange) { - const longPressChangeEvent = { - target: event.target, - type: 'longpresschange', - phase: phaseToString(event.phase), - }; - context.dispatchEvent( - longPressChangeEvent, - props.onLongPressChange, - {discrete: true}, - ); - } - }, 500); + handleEvent(event, context, props, 'bubble'); + }, + (event, context, props) => { + handleEvent(event, context, props, 'capture'); }, ); @@ -565,6 +549,8 @@ describe('DOMEventResponderSystem', () => { const EventComponent = createReactEventComponent( [], undefined, + undefined, + undefined, (event, context, props, state) => {}, () => { onUnmountFired++; @@ -590,7 +576,9 @@ describe('DOMEventResponderSystem', () => { () => ({ incrementAmount: 5, }), - (event, context, props, state) => {}, + undefined, + undefined, + undefined, (context, props, state) => { counter += state.incrementAmount; }, @@ -616,11 +604,11 @@ describe('DOMEventResponderSystem', () => { ['click'], undefined, (event, context, props, state) => { - if (event.phase === BUBBLE_PHASE) { - ownershipGained = context.requestOwnership(); - } + ownershipGained = context.requestOwnership(); }, undefined, + undefined, + undefined, () => { onOwnershipChangeFired++; }, diff --git a/packages/react-events/src/Drag.js b/packages/react-events/src/Drag.js index 1802433643c11..606102eb29b76 100644 --- a/packages/react-events/src/Drag.js +++ b/packages/react-events/src/Drag.js @@ -13,9 +13,12 @@ import type { } from 'shared/ReactTypes'; import {REACT_EVENT_COMPONENT_TYPE} from 'shared/ReactSymbols'; -const CAPTURE_PHASE = 2; -const targetEventTypes = ['pointerdown', 'pointercancel']; -const rootEventTypes = ['pointerup', {name: 'pointermove', passive: false}]; +const targetEventTypes = ['pointerdown']; +const rootEventTypes = [ + 'pointerup', + 'pointercancel', + {name: 'pointermove', passive: false}, +]; type DragState = { dragTarget: null | Element | Document, @@ -30,8 +33,8 @@ type DragState = { // In the case we don't have PointerEvents (Safari), we listen to touch events // too if (typeof window !== 'undefined' && window.PointerEvent === undefined) { - targetEventTypes.push('touchstart', 'touchend', 'mousedown', 'touchcancel'); - rootEventTypes.push('mouseup', 'mousemove', { + targetEventTypes.push('touchstart', 'mousedown'); + rootEventTypes.push('mouseup', 'mousemove', 'touchend', 'touchcancel', { name: 'touchmove', passive: false, }); @@ -88,18 +91,15 @@ const DragResponder = { y: 0, }; }, + stopLocalPropagation: true, onEvent( event: ReactResponderEvent, context: ReactResponderContext, props: Object, state: DragState, - ): boolean { - const {target, phase, type, nativeEvent} = event; + ): void { + const {target, type, nativeEvent} = event; - // Drag doesn't handle capture target events at this point - if (phase === CAPTURE_PHASE) { - return false; - } switch (type) { case 'touchstart': case 'mousedown': @@ -133,11 +133,22 @@ const DragResponder = { } break; } + } + }, + onRootEvent( + event: ReactResponderEvent, + context: ReactResponderContext, + props: Object, + state: DragState, + ): void { + const {type, nativeEvent} = event; + + switch (type) { case 'touchmove': case 'mousemove': case 'pointermove': { if (event.passive) { - return false; + return; } if (state.isPointerDown) { const obj = @@ -230,7 +241,6 @@ const DragResponder = { break; } } - return false; }, }; diff --git a/packages/react-events/src/Focus.js b/packages/react-events/src/Focus.js index f2eb1fd0975b0..7c8946520d2f5 100644 --- a/packages/react-events/src/Focus.js +++ b/packages/react-events/src/Focus.js @@ -14,8 +14,6 @@ import type { import {REACT_EVENT_COMPONENT_TYPE} from 'shared/ReactSymbols'; import {getEventCurrentTarget} from './utils.js'; -const CAPTURE_PHASE = 2; - type FocusProps = { disabled: boolean, onBlur: (e: FocusEvent) => void, @@ -112,10 +110,8 @@ const FocusResponder = { context: ReactResponderContext, props: Object, state: FocusState, - ): boolean { - const {type, phase, target} = event; - const shouldStopPropagation = - props.stopPropagation === undefined ? true : props.stopPropagation; + ): void { + const {type, target} = event; if (props.disabled) { if (state.isFocused) { @@ -123,12 +119,7 @@ const FocusResponder = { state.isFocused = false; state.focusTarget = null; } - return false; - } - - // Focus doesn't handle capture target events at this point - if (phase === CAPTURE_PHASE) { - return false; + return; } switch (type) { @@ -153,7 +144,6 @@ const FocusResponder = { break; } } - return shouldStopPropagation; }, onUnmount( context: ReactResponderContext, diff --git a/packages/react-events/src/Hover.js b/packages/react-events/src/Hover.js index b8144a9ec288b..90b5a00883c36 100644 --- a/packages/react-events/src/Hover.js +++ b/packages/react-events/src/Hover.js @@ -18,8 +18,6 @@ import { isEventPositionWithinTouchHitTarget, } from './utils'; -const CAPTURE_PHASE = 2; - type HoverProps = { disabled: boolean, delayHoverEnd: number, @@ -141,7 +139,6 @@ function dispatchHoverStartEvents( state.hoverStartTimeout = context.setTimeout(() => { state.hoverStartTimeout = null; activate(); - return false; }, delayHoverStart); } else { activate(); @@ -201,7 +198,6 @@ function dispatchHoverEndEvents( if (delayHoverEnd > 0) { state.hoverEndTimeout = context.setTimeout(() => { deactivate(); - return false; }, delayHoverEnd); } else { deactivate(); @@ -245,12 +241,13 @@ const HoverResponder = { ignoreEmulatedMouseEvents: false, }; }, + stopLocalPropagation: true, onEvent( event: ReactResponderEvent, context: ReactResponderContext, props: HoverProps, state: HoverState, - ): boolean { + ): void { const {type} = event; if (props.disabled) { @@ -261,14 +258,8 @@ const HoverResponder = { if (state.isTouched) { state.isTouched = false; } - return false; - } - - // Hover doesn't handle capture target events at this point - if (event.phase === CAPTURE_PHASE) { - return false; + return; } - const pointerType = getEventPointerType(event); switch (type) { @@ -280,23 +271,23 @@ const HoverResponder = { // Prevent hover events for touch if (state.isTouched || pointerType === 'touch') { state.isTouched = true; - return false; + return; } // Prevent hover events for emulated events if (isEmulatedMouseEvent(event, state)) { - return false; + return; } if (isEventPositionWithinTouchHitTarget(event, context)) { state.isOverTouchHitTarget = true; - return false; + return; } state.hoverTarget = getEventCurrentTarget(event, context); state.ignoreEmulatedMouseEvents = true; dispatchHoverStartEvents(event, context, props, state); } - return false; + return; } // MOVE @@ -331,7 +322,7 @@ const HoverResponder = { } } } - return false; + return; } // END @@ -347,10 +338,9 @@ const HoverResponder = { if (state.isTouched) { state.isTouched = false; } - return false; + return; } } - return false; }, onUnmount( context: ReactResponderContext, diff --git a/packages/react-events/src/Press.js b/packages/react-events/src/Press.js index eb83f6fb68023..14a6ee0e387cb 100644 --- a/packages/react-events/src/Press.js +++ b/packages/react-events/src/Press.js @@ -19,8 +19,6 @@ import { isEventPositionWithinTouchHitTarget, } from './utils'; -const CAPTURE_PHASE = 2; - type PressProps = { disabled: boolean, delayLongPress: number, @@ -103,10 +101,11 @@ const rootEventTypes = ['keyup', 'pointerup', 'pointermove', 'scroll']; // 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', 'touchend', 'touchcancel', 'mousedown'); + targetEventTypes.push('touchstart', 'touchcancel', 'mousedown'); rootEventTypes.push( {name: 'mouseup', passive: false}, 'touchmove', + 'touchend', 'mousemove', ); } @@ -199,8 +198,6 @@ function dispatchPressStartEvents( props: PressProps, state: PressState, ): void { - const shouldStopPropagation = - props.stopPropagation === undefined ? true : props.stopPropagation; state.isPressed = true; if (state.pressEndTimeout !== null) { @@ -230,7 +227,6 @@ function dispatchPressStartEvents( if (props.onLongPressChange) { dispatchLongPressChangeEvent(context, props, state); } - return shouldStopPropagation; }, delayLongPress); } }; @@ -245,7 +241,6 @@ function dispatchPressStartEvents( state.pressStartTimeout = context.setTimeout(() => { state.pressStartTimeout = null; dispatch(); - return shouldStopPropagation; }, delayPressStart); } else { dispatch(); @@ -258,8 +253,6 @@ function dispatchPressEndEvents( props: PressProps, state: PressState, ): void { - const shouldStopPropagation = - props.stopPropagation === undefined ? true : props.stopPropagation; const wasActivePressStart = state.isActivePressStart; let activationWasForced = false; @@ -295,7 +288,6 @@ function dispatchPressEndEvents( state.pressEndTimeout = context.setTimeout(() => { state.pressEndTimeout = null; deactivate(context, props, state); - return shouldStopPropagation; }, delayPressEnd); } else { deactivate(context, props, state); @@ -381,6 +373,24 @@ function unmountResponder( } } +function dispatchCancel( + type: string, + nativeEvent: $PropertyType, + context: ReactResponderContext, + props: PressProps, + state: PressState, +): void { + if (state.isPressed) { + if (type === 'contextmenu' && props.preventDefault !== false) { + (nativeEvent: any).preventDefault(); + } else { + state.ignoreEmulatedMouseEvents = false; + dispatchPressEndEvents(context, props, state); + context.removeRootEventTypes(rootEventTypes); + } + } +} + const PressResponder = { targetEventTypes, createInitialState(): PressState { @@ -400,30 +410,23 @@ const PressResponder = { ignoreEmulatedMouseEvents: false, }; }, + stopLocalPropagation: true, onEvent( event: ReactResponderEvent, context: ReactResponderContext, props: PressProps, state: PressState, - ): boolean { - const {phase, target, type} = event; + ): void { + const {target, type} = event; if (props.disabled) { dispatchPressEndEvents(context, props, state); context.removeRootEventTypes(rootEventTypes); state.ignoreEmulatedMouseEvents = false; - return false; + return; } - - // Press doesn't handle capture target events at this point - if (phase === CAPTURE_PHASE) { - return false; - } - const nativeEvent: any = event.nativeEvent; const pointerType = getEventPointerType(event); - const shouldStopPropagation = - props.stopPropagation === undefined ? true : props.stopPropagation; switch (type) { // START @@ -440,7 +443,7 @@ const PressResponder = { // Ignore unrelated key events if (pointerType === 'keyboard') { if (!isValidKeyPress(nativeEvent.key)) { - return shouldStopPropagation; + return; } } @@ -451,13 +454,13 @@ const PressResponder = { state.ignoreEmulatedMouseEvents || isEventPositionWithinTouchHitTarget(event, context) ) { - return shouldStopPropagation; + return; } } // Ignore any device buttons except left-mouse and touch/pen contact if (nativeEvent.button > 0) { - return shouldStopPropagation; + return; } state.pointerType = pointerType; @@ -465,17 +468,46 @@ const PressResponder = { state.isPressWithinResponderRegion = true; dispatchPressStartEvents(context, props, state); context.addRootEventTypes(target.ownerDocument, rootEventTypes); - return shouldStopPropagation; } else { // Prevent spacebar press from scrolling the window if (isValidKeyPress(nativeEvent.key) && nativeEvent.key === ' ') { nativeEvent.preventDefault(); - return shouldStopPropagation; } } - return shouldStopPropagation; + break; + } + + // CANCEL + case 'contextmenu': { + dispatchCancel(type, nativeEvent, context, props, state); + break; } + case 'click': { + if (isAnchorTagElement(target)) { + const {ctrlKey, metaKey, shiftKey} = (nativeEvent: MouseEvent); + // Check "open in new window/tab" and "open context menu" key modifiers + const preventDefault = props.preventDefault; + if (preventDefault !== false && !shiftKey && !metaKey && !ctrlKey) { + nativeEvent.preventDefault(); + } + } + break; + } + } + }, + onRootEvent( + event: ReactResponderEvent, + context: ReactResponderContext, + props: PressProps, + state: PressState, + ): void { + const {target, type} = event; + + const nativeEvent: any = event.nativeEvent; + const pointerType = getEventPointerType(event); + + switch (type) { // MOVE case 'pointermove': case 'mousemove': @@ -484,7 +516,7 @@ const PressResponder = { // Ignore emulated events (pointermove will dispatch touch and mouse events) // Ignore pointermove events during a keyboard press if (state.pointerType !== pointerType) { - return shouldStopPropagation; + return; } if (state.responderRegion == null) { @@ -504,9 +536,8 @@ const PressResponder = { state.isPressWithinResponderRegion = false; dispatchPressEndEvents(context, props, state); } - return shouldStopPropagation; } - return false; + break; } // END @@ -518,7 +549,7 @@ const PressResponder = { // Ignore unrelated keyboard events if (pointerType === 'keyboard') { if (!isValidKeyPress(nativeEvent.key)) { - return false; + return; } } @@ -539,45 +570,19 @@ const PressResponder = { } } context.removeRootEventTypes(rootEventTypes); - return shouldStopPropagation; } else if (type === 'mouseup' && state.ignoreEmulatedMouseEvents) { state.ignoreEmulatedMouseEvents = false; } - return false; + break; } // CANCEL - case 'contextmenu': case 'pointercancel': case 'scroll': case 'touchcancel': { - if (state.isPressed) { - if (type === 'contextmenu' && props.preventDefault !== false) { - nativeEvent.preventDefault(); - } else { - state.ignoreEmulatedMouseEvents = false; - dispatchPressEndEvents(context, props, state); - context.removeRootEventTypes(rootEventTypes); - } - return shouldStopPropagation; - } - return false; - } - - case 'click': { - if (isAnchorTagElement(target)) { - const {ctrlKey, metaKey, shiftKey} = (nativeEvent: MouseEvent); - // Check "open in new window/tab" and "open context menu" key modifiers - const preventDefault = props.preventDefault; - if (preventDefault !== false && !shiftKey && !metaKey && !ctrlKey) { - nativeEvent.preventDefault(); - } - return shouldStopPropagation; - } - return false; + dispatchCancel(type, nativeEvent, context, props, state); } } - return false; }, onUnmount( context: ReactResponderContext, diff --git a/packages/react-events/src/Swipe.js b/packages/react-events/src/Swipe.js index 025819c3ca7f3..404a92f16228a 100644 --- a/packages/react-events/src/Swipe.js +++ b/packages/react-events/src/Swipe.js @@ -13,15 +13,18 @@ import type { } from 'shared/ReactTypes'; import {REACT_EVENT_COMPONENT_TYPE} from 'shared/ReactSymbols'; -const CAPTURE_PHASE = 2; -const targetEventTypes = ['pointerdown', 'pointercancel']; -const rootEventTypes = ['pointerup', {name: 'pointermove', passive: false}]; +const targetEventTypes = ['pointerdown']; +const rootEventTypes = [ + 'pointerup', + 'pointercancel', + {name: 'pointermove', passive: false}, +]; // In the case we don't have PointerEvents (Safari), we listen to touch events // too if (typeof window !== 'undefined' && window.PointerEvent === undefined) { - targetEventTypes.push('touchstart', 'touchend', 'mousedown', 'touchcancel'); - rootEventTypes.push('mouseup', 'mousemove', { + targetEventTypes.push('touchstart', 'mousedown'); + rootEventTypes.push('mouseup', 'mousemove', 'touchend', 'touchcancel', { name: 'touchmove', passive: false, }); @@ -92,18 +95,15 @@ const SwipeResponder = { y: 0, }; }, + stopLocalPropagation: true, onEvent( event: ReactResponderEvent, context: ReactResponderContext, props: Object, state: SwipeState, - ): boolean { - const {target, phase, type, nativeEvent} = event; + ): void { + const {target, type, nativeEvent} = event; - // Swipe doesn't handle capture target events at this point - if (phase === CAPTURE_PHASE) { - return false; - } switch (type) { case 'touchstart': case 'mousedown': @@ -136,11 +136,22 @@ const SwipeResponder = { } break; } + } + }, + onRootEvent( + event: ReactResponderEvent, + context: ReactResponderContext, + props: Object, + state: SwipeState, + ): void { + const {type, nativeEvent} = event; + + switch (type) { case 'touchmove': case 'mousemove': case 'pointermove': { if (event.passive) { - return false; + return; } if (state.isSwiping) { let obj = null; @@ -160,7 +171,7 @@ const SwipeResponder = { state.swipeTarget = null; state.touchId = null; context.removeRootEventTypes(rootEventTypes); - return false; + return; } const x = (obj: any).screenX; const y = (obj: any).screenY; @@ -196,7 +207,7 @@ const SwipeResponder = { case 'pointerup': { if (state.isSwiping) { if (state.x === state.startX && state.y === state.startY) { - return false; + return; } if (props.onShouldClaimOwnership) { context.releaseOwnership(); @@ -240,7 +251,6 @@ const SwipeResponder = { break; } } - return false; }, }; diff --git a/packages/react-events/src/__tests__/Press-test.internal.js b/packages/react-events/src/__tests__/Press-test.internal.js index 550ad95eee0fe..be05710fb5fbf 100644 --- a/packages/react-events/src/__tests__/Press-test.internal.js +++ b/packages/react-events/src/__tests__/Press-test.internal.js @@ -1127,15 +1127,10 @@ describe('Event responder: Press', () => { 'pointerdown', 'inner: onPressStart', 'inner: onPressChange', - 'outer: onPressStart', - 'outer: onPressChange', 'pointerup', 'inner: onPressEnd', 'inner: onPressChange', 'inner: onPress', - 'outer: onPressEnd', - 'outer: onPressChange', - 'outer: onPress', ]); }); @@ -1214,82 +1209,6 @@ describe('Event responder: Press', () => { expect(fn).toHaveBeenCalledTimes(2); }); }); - - describe('correctly bubble to other event responders when stopPropagation is set to false', () => { - it('for onPress', () => { - const ref = React.createRef(); - const fn = jest.fn(); - const element = ( - - -
- - - ); - ReactDOM.render(element, container); - - ref.current.dispatchEvent(createPointerEvent('pointerdown')); - ref.current.dispatchEvent(createPointerEvent('pointerup')); - expect(fn).toHaveBeenCalledTimes(2); - }); - - it('for onLongPress', () => { - const ref = React.createRef(); - const fn = jest.fn(); - const element = ( - - -
- - - ); - ReactDOM.render(element, container); - - ref.current.dispatchEvent(createPointerEvent('pointerdown')); - jest.advanceTimersByTime(DEFAULT_LONG_PRESS_DELAY); - ref.current.dispatchEvent(createPointerEvent('pointerup')); - expect(fn).toHaveBeenCalledTimes(2); - }); - - it('for onPressStart/onPressEnd', () => { - const ref = React.createRef(); - const fn = jest.fn(); - const fn2 = jest.fn(); - const element = ( - - -
- - - ); - ReactDOM.render(element, container); - - ref.current.dispatchEvent(createPointerEvent('pointerdown')); - expect(fn).toHaveBeenCalledTimes(2); - expect(fn2).toHaveBeenCalledTimes(0); - ref.current.dispatchEvent(createPointerEvent('pointerup')); - expect(fn).toHaveBeenCalledTimes(2); - expect(fn2).toHaveBeenCalledTimes(2); - }); - - it('for onPressChange', () => { - const ref = React.createRef(); - const fn = jest.fn(); - const element = ( - - -
- - - ); - ReactDOM.render(element, container); - - ref.current.dispatchEvent(createPointerEvent('pointerdown')); - expect(fn).toHaveBeenCalledTimes(2); - ref.current.dispatchEvent(createPointerEvent('pointerup')); - expect(fn).toHaveBeenCalledTimes(4); - }); - }); }); describe('link components', () => { diff --git a/packages/shared/ReactTypes.js b/packages/shared/ReactTypes.js index f0676edb888d3..480fb271fcda0 100644 --- a/packages/shared/ReactTypes.js +++ b/packages/shared/ReactTypes.js @@ -88,12 +88,25 @@ export type ReactEventResponderEventType = export type ReactEventResponder = { targetEventTypes: Array, createInitialState?: (props: null | Object) => Object, - onEvent: ( + stopLocalPropagation: boolean, + onEvent?: ( event: ReactResponderEvent, context: ReactResponderContext, props: null | Object, state: null | Object, - ) => boolean, + ) => void, + onEventCapture?: ( + event: ReactResponderEvent, + context: ReactResponderContext, + props: null | Object, + state: null | Object, + ) => void, + onRootEvent?: ( + event: ReactResponderEvent, + context: ReactResponderContext, + props: null | Object, + state: null | Object, + ) => void, onUnmount: ( context: ReactResponderContext, props: null | Object, @@ -135,7 +148,6 @@ export type ReactResponderEvent = { type: string, passive: boolean, passiveSupported: boolean, - phase: 0 | 1 | 2, }; export type ReactResponderDispatchEventOptions = { @@ -168,7 +180,7 @@ export type ReactResponderContext = { hasOwnership: () => boolean, requestOwnership: () => boolean, releaseOwnership: () => boolean, - setTimeout: (func: () => boolean, timeout: number) => Symbol, + setTimeout: (func: () => void, timeout: number) => Symbol, clearTimeout: (timerId: Symbol) => void, getEventTargetsFromTarget: ( target: Element | Document,