From ba3936d3c02ac01c9860637071fa26955c9abf67 Mon Sep 17 00:00:00 2001 From: Dominic Gannaway Date: Sat, 13 Apr 2019 14:07:11 +0100 Subject: [PATCH 1/4] Event API: Redesign event instance propagation --- .../src/events/DOMEventResponderSystem.js | 287 +++++++++++------- .../src/events/ReactDOMEventListener.js | 92 +++--- .../DOMEventResponderSystem-test.internal.js | 253 ++++++++++++--- packages/react-events/src/Drag.js | 12 +- packages/react-events/src/Focus.js | 13 +- packages/react-events/src/Hover.js | 21 +- packages/react-events/src/Press.js | 100 +++--- packages/react-events/src/Swipe.js | 18 +- .../src/__tests__/Press-test.internal.js | 246 +++++++++------ packages/shared/ReactTypes.js | 6 +- 10 files changed, 693 insertions(+), 355 deletions(-) diff --git a/packages/react-dom/src/events/DOMEventResponderSystem.js b/packages/react-dom/src/events/DOMEventResponderSystem.js index f0e98569278a5..dc77ac26c88de 100644 --- a/packages/react-dom/src/events/DOMEventResponderSystem.js +++ b/packages/react-dom/src/events/DOMEventResponderSystem.js @@ -42,11 +42,10 @@ export function setListenToResponderEventTypes( listenToResponderEventTypesImpl = _listenToResponderEventTypesImpl; } -type EventObjectTypes = {|stopPropagation: true|} | $Shape; +type EventObjectType = $Shape; type EventQueue = { - bubble: null | Array, - capture: null | Array, + events: Array, discrete: boolean, }; @@ -62,10 +61,13 @@ type ResponderTimeout = {| type ResponderTimer = {| instance: ReactEventComponentInstance, - func: () => void, + func: () => boolean, id: Symbol, |}; +const ROOT_PHASE = 0; +const BUBBLE_PHASE = 1; +const CAPTURE_PHASE = 2; const activeTimeouts: Map = new Map(); const rootEventTypesToEventComponentInstances: Map< DOMTopLevelEventType | string, @@ -122,17 +124,12 @@ const eventResponderContext: ReactResponderContext = { const eventObject = ((possibleEventObject: any): $Shape< PartialEventObject, >); - const events = getEventsFromEventQueue(capture); + const eventQueue = ((currentEventQueue: any): EventQueue); if (discrete) { - ((currentEventQueue: any): EventQueue).discrete = true; + eventQueue.discrete = true; } eventListeners.set(eventObject, listener); - events.push(eventObject); - }, - dispatchStopPropagation(capture?: boolean) { - validateResponderContext(); - const events = getEventsFromEventQueue(); - events.push({stopPropagation: true}); + eventQueue.events.push(eventObject); }, isPositionWithinTouchHitTarget(doc: Document, x: number, y: number): boolean { validateResponderContext(); @@ -256,7 +253,7 @@ const eventResponderContext: ReactResponderContext = { triggerOwnershipListeners(); return false; }, - setTimeout(func: () => void, delay): Symbol { + setTimeout(func: () => boolean, delay): Symbol { validateResponderContext(); if (currentTimers === null) { currentTimers = new Map(); @@ -350,32 +347,18 @@ const eventResponderContext: ReactResponderContext = { }, }; -function getEventsFromEventQueue(capture?: boolean): Array { - const eventQueue = ((currentEventQueue: any): EventQueue); - let events; - if (capture) { - events = eventQueue.capture; - if (events === null) { - events = eventQueue.capture = []; - } - } else { - events = eventQueue.bubble; - if (events === null) { - events = eventQueue.bubble = []; - } - } - return events; -} - 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 { - func(); + if (!shouldStopPropagation) { + shouldStopPropagation = func(); + } } finally { activeTimeouts.delete(id); } @@ -407,20 +390,25 @@ function createResponderEvent( nativeEvent: AnyNativeEvent, nativeEventTarget: Element | Document, eventSystemFlags: EventSystemFlags, + phase: 0 | 1 | 2, ): ReactResponderEvent { - return { + const responderEvent = { nativeEvent: nativeEvent, target: nativeEventTarget, type: topLevelType, passive: (eventSystemFlags & IS_PASSIVE) !== 0, passiveSupported: (eventSystemFlags & PASSIVE_NOT_SUPPORTED) === 0, + phase, }; + if (__DEV__) { + Object.freeze(responderEvent); + } + return responderEvent; } function createEventQueue(): EventQueue { return { - bubble: null, - capture: null, + events: [], discrete: false, }; } @@ -433,41 +421,24 @@ function processEvent(event: $Shape): void { invokeGuardedCallbackAndCatchFirstError(type, listener, undefined, event); } -function processEvents( - bubble: null | Array, - capture: null | Array, -): void { - let i, length; - - if (capture !== null) { - for (i = capture.length; i-- > 0; ) { - const event = capture[i]; - if (event.stopPropagation === true) { - return; - } - processEvent(((event: any): $Shape)); - } - } - if (bubble !== null) { - for (i = 0, length = bubble.length; i < length; ++i) { - const event = bubble[i]; - if (event.stopPropagation === true) { - return; - } - processEvent(((event: any): $Shape)); - } +function processEvents(events: Array): void { + for (let i = 0, length = events.length; i < length; i++) { + processEvent(events[i]); } } export function processEventQueue(): void { - const {bubble, capture, discrete} = ((currentEventQueue: any): EventQueue); + const {events, discrete} = ((currentEventQueue: any): EventQueue); + if (events.length === 0) { + return; + } if (discrete) { interactiveUpdates(() => { - processEvents(bubble, capture); + processEvents(events); }); } else { - processEvents(bubble, capture); + processEvents(events); } } @@ -489,77 +460,141 @@ function getTargetEventTypes( return cachedSet; } -function handleTopLevelType( +function getTargetEventResponderInstances( topLevelType: DOMTopLevelEventType, - responderEvent: ReactResponderEvent, - eventComponentInstance: ReactEventComponentInstance, - isRootLevelEvent: boolean, -): void { - let {props, responder, state} = eventComponentInstance; - if (!isRootLevelEvent) { - // Validate the target event type exists on the responder - const targetEventTypes = getTargetEventTypes(responder.targetEventTypes); - if (!targetEventTypes.has(topLevelType)) { - return; + targetFiber: null | Fiber, +): Array { + const eventResponderInstances = []; + let node = targetFiber; + while (node !== null) { + // Traverse up the fiber tree till we find event component fibers. + if (node.tag === EventComponent) { + const eventComponentInstance = node.stateNode; + if (currentOwner === null || currentOwner === eventComponentInstance) { + const responder = eventComponentInstance.responder; + // Validate the target event type exists on the responder + const targetEventTypes = getTargetEventTypes( + responder.targetEventTypes, + ); + if (targetEventTypes.has(topLevelType)) { + eventResponderInstances.push(eventComponentInstance); + } + } } + node = node.return; } + return eventResponderInstances; +} + +function getRootEventResponderInstances( + topLevelType: DOMTopLevelEventType, +): Array { + const eventResponderInstances = []; + const rootEventInstances = rootEventTypesToEventComponentInstances.get( + topLevelType, + ); + if (rootEventInstances !== undefined) { + const rootEventComponentInstances = Array.from(rootEventInstances); + + for (let i = 0; i < rootEventComponentInstances.length; i++) { + const rootEventComponentInstance = rootEventComponentInstances[i]; + + if ( + currentOwner === null || + currentOwner === rootEventComponentInstance + ) { + eventResponderInstances.push(rootEventComponentInstance); + } + } + } + return eventResponderInstances; +} + +function triggerEventResponderEventListener( + responderEvent: ReactResponderEvent, + eventComponentInstance: ReactEventComponentInstance, +): boolean { + const {responder, props, state} = eventComponentInstance; currentInstance = eventComponentInstance; - responder.onEvent(responderEvent, eventResponderContext, props, state); + return responder.onEvent(responderEvent, eventResponderContext, props, state); } -export function runResponderEventsInBatch( +function traverseAndTriggerEventResponderInstances( topLevelType: DOMTopLevelEventType, targetFiber: null | Fiber, nativeEvent: AnyNativeEvent, nativeEventTarget: EventTarget, eventSystemFlags: EventSystemFlags, ): void { - if (enableEventAPI) { - currentEventQueue = createEventQueue(); + // Trigger event responders in this order: + // - Capture target phase + // - Bubble target phase + // - Root phase + + const targetEventResponderInstances = getTargetEventResponderInstances( + topLevelType, + targetFiber, + ); + let length = targetEventResponderInstances.length; + let i; + let shouldStopPropagation = false; + + // Capture target phase + for (i = length; i-- > 0; ) { + const targetEventResponderInstance = targetEventResponderInstances[i]; const responderEvent = createResponderEvent( ((topLevelType: any): string), nativeEvent, ((nativeEventTarget: any): Element | Document), eventSystemFlags, + CAPTURE_PHASE, ); - - try { - let node = targetFiber; - // Traverse up the fiber tree till we find event component fibers. - while (node !== null) { - if (node.tag === EventComponent) { - const eventComponentInstance = node.stateNode; - handleTopLevelType( - topLevelType, - responderEvent, - eventComponentInstance, - false, - ); - } - node = node.return; - } - // Handle root level events - const rootEventInstances = rootEventTypesToEventComponentInstances.get( - topLevelType, - ); - if (rootEventInstances !== undefined) { - const rootEventComponentInstances = Array.from(rootEventInstances); - - for (let i = 0; i < rootEventComponentInstances.length; i++) { - const rootEventComponentInstance = rootEventComponentInstances[i]; - handleTopLevelType( - topLevelType, - responderEvent, - rootEventComponentInstance, - true, - ); - } - } - processEventQueue(); - } finally { - currentTimers = null; - currentInstance = null; - currentEventQueue = null; + shouldStopPropagation = triggerEventResponderEventListener( + responderEvent, + targetEventResponderInstance, + ); + if (shouldStopPropagation) { + return; + } + } + // Bubble target phase + for (i = 0; i < length; i++) { + const targetEventResponderInstance = targetEventResponderInstances[i]; + const responderEvent = createResponderEvent( + ((topLevelType: any): string), + nativeEvent, + ((nativeEventTarget: any): Element | Document), + eventSystemFlags, + BUBBLE_PHASE, + ); + shouldStopPropagation = triggerEventResponderEventListener( + responderEvent, + targetEventResponderInstance, + ); + if (shouldStopPropagation) { + return; + } + } + // Root phase + const rootEventResponderInstances = getRootEventResponderInstances( + topLevelType, + ); + length = rootEventResponderInstances.length; + for (i = 0; i < length; i++) { + const targetEventResponderInstance = rootEventResponderInstances[i]; + const responderEvent = createResponderEvent( + ((topLevelType: any): string), + nativeEvent, + ((nativeEventTarget: any): Element | Document), + eventSystemFlags, + ROOT_PHASE, + ); + shouldStopPropagation = triggerEventResponderEventListener( + responderEvent, + targetEventResponderInstance, + ); + if (shouldStopPropagation) { + return; } } } @@ -621,3 +656,29 @@ function validateResponderContext(): void { 'Use context.setTimeout() to use asynchronous responder context outside of event cycle .', ); } + +export function dispatchEventForResponderEventSystem( + topLevelType: DOMTopLevelEventType, + targetFiber: null | Fiber, + nativeEvent: AnyNativeEvent, + nativeEventTarget: EventTarget, + eventSystemFlags: EventSystemFlags, +): void { + if (enableEventAPI) { + currentEventQueue = createEventQueue(); + try { + traverseAndTriggerEventResponderInstances( + topLevelType, + targetFiber, + nativeEvent, + nativeEventTarget, + eventSystemFlags, + ); + processEventQueue(); + } finally { + currentTimers = null; + currentInstance = null; + currentEventQueue = null; + } + } +} diff --git a/packages/react-dom/src/events/ReactDOMEventListener.js b/packages/react-dom/src/events/ReactDOMEventListener.js index 1e1e67936f984..b710b2728dfa0 100644 --- a/packages/react-dom/src/events/ReactDOMEventListener.js +++ b/packages/react-dom/src/events/ReactDOMEventListener.js @@ -13,7 +13,7 @@ import type {DOMTopLevelEventType} from 'events/TopLevelEventTypes'; import {batchedUpdates, interactiveUpdates} from 'events/ReactGenericBatching'; import {runExtractedPluginEventsInBatch} from 'events/EventPluginHub'; -import {runResponderEventsInBatch} from '../events/DOMEventResponderSystem'; +import {dispatchEventForResponderEventSystem} from '../events/DOMEventResponderSystem'; import {isFiberMounted} from 'react-reconciler/reflection'; import {HostRoot} from 'shared/ReactWorkTags'; import { @@ -48,7 +48,6 @@ type BookKeepingInstance = { nativeEvent: AnyNativeEvent | null, targetInst: Fiber | null, ancestors: Array, - eventSystemFlags: EventSystemFlags, }; /** @@ -75,14 +74,12 @@ function getTopLevelCallbackBookKeeping( topLevelType: DOMTopLevelEventType, nativeEvent: AnyNativeEvent, targetInst: Fiber | null, - eventSystemFlags: EventSystemFlags, ): BookKeepingInstance { if (callbackBookkeepingPool.length) { const instance = callbackBookkeepingPool.pop(); instance.topLevelType = topLevelType; instance.nativeEvent = nativeEvent; instance.targetInst = targetInst; - instance.eventSystemFlags = eventSystemFlags; return instance; } return { @@ -90,7 +87,6 @@ function getTopLevelCallbackBookKeeping( nativeEvent, targetInst, ancestors: [], - eventSystemFlags, }; } @@ -101,7 +97,6 @@ function releaseTopLevelCallbackBookKeeping( instance.nativeEvent = null; instance.targetInst = null; instance.ancestors.length = 0; - instance.eventSystemFlags = 0; if (callbackBookkeepingPool.length < CALLBACK_BOOKKEEPING_POOL_SIZE) { callbackBookkeepingPool.push(instance); } @@ -131,28 +126,16 @@ function handleTopLevel(bookKeeping: BookKeepingInstance) { for (let i = 0; i < bookKeeping.ancestors.length; i++) { targetInst = bookKeeping.ancestors[i]; - const eventSystemFlags = bookKeeping.eventSystemFlags; const eventTarget = getEventTarget(bookKeeping.nativeEvent); const topLevelType = ((bookKeeping.topLevelType: any): DOMTopLevelEventType); const nativeEvent = ((bookKeeping.nativeEvent: any): AnyNativeEvent); - if (eventSystemFlags === PLUGIN_EVENT_SYSTEM) { - runExtractedPluginEventsInBatch( - topLevelType, - targetInst, - nativeEvent, - eventTarget, - ); - } else if (enableEventAPI) { - // Responder event system (experimental event API) - runResponderEventsInBatch( - topLevelType, - targetInst, - nativeEvent, - eventTarget, - eventSystemFlags, - ); - } + runExtractedPluginEventsInBatch( + topLevelType, + targetInst, + nativeEvent, + eventTarget, + ); } } @@ -242,6 +225,27 @@ function dispatchInteractiveEvent(topLevelType, eventSystemFlags, nativeEvent) { ); } +function dispatchEventForPluginEventSystem( + topLevelType: DOMTopLevelEventType, + eventSystemFlags: EventSystemFlags, + nativeEvent: AnyNativeEvent, + targetInst: null | Fiber, +): void { + const bookKeeping = getTopLevelCallbackBookKeeping( + topLevelType, + nativeEvent, + targetInst, + ); + + try { + // Event queue being processed in the same cycle allows + // `preventDefault`. + batchedUpdates(handleTopLevel, bookKeeping); + } finally { + releaseTopLevelCallbackBookKeeping(bookKeeping); + } +} + export function dispatchEvent( topLevelType: DOMTopLevelEventType, eventSystemFlags: EventSystemFlags, @@ -250,9 +254,9 @@ export function dispatchEvent( if (!_enabled) { return; } - const nativeEventTarget = getEventTarget(nativeEvent); let targetInst = getClosestInstanceFromNode(nativeEventTarget); + if ( targetInst !== null && typeof targetInst.tag === 'number' && @@ -265,18 +269,30 @@ export function dispatchEvent( targetInst = null; } - const bookKeeping = getTopLevelCallbackBookKeeping( - topLevelType, - nativeEvent, - targetInst, - eventSystemFlags, - ); - - try { - // Event queue being processed in the same cycle allows - // `preventDefault`. - batchedUpdates(handleTopLevel, bookKeeping); - } finally { - releaseTopLevelCallbackBookKeeping(bookKeeping); + if (enableEventAPI) { + if (eventSystemFlags === PLUGIN_EVENT_SYSTEM) { + dispatchEventForPluginEventSystem( + topLevelType, + eventSystemFlags, + nativeEvent, + targetInst, + ); + } else { + // Responder event system (experimental event API) + dispatchEventForResponderEventSystem( + topLevelType, + targetInst, + nativeEvent, + nativeEventTarget, + eventSystemFlags, + ); + } + } else { + dispatchEventForPluginEventSystem( + topLevelType, + eventSystemFlags, + nativeEvent, + targetInst, + ); } } 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 38ca51d5414d4..35323fde50c4a 100644 --- a/packages/react-dom/src/events/__tests__/DOMEventResponderSystem-test.internal.js +++ b/packages/react-dom/src/events/__tests__/DOMEventResponderSystem-test.internal.js @@ -37,6 +37,18 @@ 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 dispatchClickEvent(element) { const clickEvent = document.createEvent('Event'); clickEvent.initEvent('click', true, true); @@ -86,6 +98,7 @@ describe('DOMEventResponderSystem', () => { name: event.type, passive: event.passive, passiveSupported: event.passiveSupported, + phase: event.phase, }); }, ); @@ -99,28 +112,37 @@ describe('DOMEventResponderSystem', () => { ReactDOM.render(, container); expect(container.innerHTML).toBe(''); - // Clicking the button should trigger the event responder onEvent() + // Clicking the button should trigger the event responder onEvent() twice let buttonElement = buttonRef.current; dispatchClickEvent(buttonElement); - expect(eventResponderFiredCount).toBe(1); - expect(eventLog.length).toBe(1); + expect(eventResponderFiredCount).toBe(2); + expect(eventLog.length).toBe(2); // JSDOM does not support passive events, so this will be false - expect(eventLog[0]).toEqual({ - name: 'click', - passive: false, - passiveSupported: false, - }); + expect(eventLog).toEqual([ + { + name: 'click', + passive: false, + passiveSupported: false, + phase: CAPTURE_PHASE, + }, + { + name: 'click', + passive: false, + passiveSupported: false, + phase: BUBBLE_PHASE, + }, + ]); // Unmounting the container and clicking should not increment anything ReactDOM.render(null, container); dispatchClickEvent(buttonElement); - expect(eventResponderFiredCount).toBe(1); + expect(eventResponderFiredCount).toBe(2); - // Re-rendering the container and clicking should increase the counter again + // Re-rendering the container and clicking should increase the counters again ReactDOM.render(, container); buttonElement = buttonRef.current; dispatchClickEvent(buttonElement); - expect(eventResponderFiredCount).toBe(2); + expect(eventResponderFiredCount).toBe(4); }); it('the event responder onEvent() function should fire on click event (passive events forced)', () => { @@ -139,6 +161,7 @@ describe('DOMEventResponderSystem', () => { name: event.type, passive: event.passive, passiveSupported: event.passiveSupported, + phase: event.phase, }); }, ); @@ -154,12 +177,21 @@ describe('DOMEventResponderSystem', () => { // Clicking the button should trigger the event responder onEvent() let buttonElement = buttonRef.current; dispatchClickEvent(buttonElement); - expect(eventLog.length).toBe(1); - expect(eventLog[0]).toEqual({ - name: 'click', - passive: true, - passiveSupported: true, - }); + expect(eventLog.length).toBe(2); + expect(eventLog).toEqual([ + { + name: 'click', + passive: true, + passiveSupported: true, + phase: CAPTURE_PHASE, + }, + { + name: 'click', + passive: true, + passiveSupported: true, + phase: BUBBLE_PHASE, + }, + ]); }); it('nested event responders and their onEvent() function should fire multiple times', () => { @@ -176,6 +208,7 @@ describe('DOMEventResponderSystem', () => { name: event.type, passive: event.passive, passiveSupported: event.passiveSupported, + phase: event.phase, }); }, ); @@ -193,19 +226,35 @@ describe('DOMEventResponderSystem', () => { // Clicking the button should trigger the event responder onEvent() let buttonElement = buttonRef.current; dispatchClickEvent(buttonElement); - expect(eventResponderFiredCount).toBe(2); - expect(eventLog.length).toBe(2); + expect(eventResponderFiredCount).toBe(4); + expect(eventLog.length).toBe(4); // JSDOM does not support passive events, so this will be false - expect(eventLog[0]).toEqual({ - name: 'click', - passive: false, - passiveSupported: false, - }); - expect(eventLog[1]).toEqual({ - name: 'click', - passive: false, - passiveSupported: false, - }); + expect(eventLog).toEqual([ + { + name: 'click', + passive: false, + passiveSupported: false, + phase: CAPTURE_PHASE, + }, + { + name: 'click', + passive: false, + passiveSupported: false, + phase: CAPTURE_PHASE, + }, + { + name: 'click', + passive: false, + passiveSupported: false, + phase: BUBBLE_PHASE, + }, + { + name: 'click', + passive: false, + passiveSupported: false, + phase: BUBBLE_PHASE, + }, + ]); }); it('nested event responders and their onEvent() should fire in the correct order', () => { @@ -215,16 +264,16 @@ describe('DOMEventResponderSystem', () => { const ClickEventComponentA = createReactEventComponent( ['click'], undefined, - (context, props) => { - eventLog.push('A'); + (event, context, props) => { + eventLog.push(`A [${phaseToString(event.phase)}]`); }, ); const ClickEventComponentB = createReactEventComponent( ['click'], undefined, - (context, props) => { - eventLog.push('B'); + (event, context, props) => { + eventLog.push(`B [${phaseToString(event.phase)}]`); }, ); @@ -242,7 +291,109 @@ describe('DOMEventResponderSystem', () => { let buttonElement = buttonRef.current; dispatchClickEvent(buttonElement); - expect(eventLog).toEqual(['B', 'A']); + expect(eventLog).toEqual([ + 'A [capture]', + 'B [capture]', + 'B [bubble]', + 'A [bubble]', + ]); + }); + + it('nested event responders and their onEvent() should fire in the correct order with stopPropagation', () => { + let eventLog; + let stopPropagationOnPhase; + const buttonRef = React.createRef(); + + const ClickEventComponentA = createReactEventComponent( + ['click'], + undefined, + (event, context, props) => { + eventLog.push(`A [${phaseToString(event.phase)}]`); + }, + ); + + const ClickEventComponentB = createReactEventComponent( + ['click'], + undefined, + (event, context, props) => { + eventLog.push(`B [${phaseToString(event.phase)}]`); + if (event.phase === stopPropagationOnPhase) { + return true; + } + }, + ); + + const Test = () => ( + + + + + + ); + + function runTestWithPhase(phase) { + eventLog = []; + stopPropagationOnPhase = phase; + ReactDOM.render(, container); + let buttonElement = buttonRef.current; + dispatchClickEvent(buttonElement); + } + + runTestWithPhase(BUBBLE_PHASE); + expect(eventLog).toEqual(['A [capture]', 'B [capture]', 'B [bubble]']); + runTestWithPhase(CAPTURE_PHASE); + expect(eventLog).toEqual(['A [capture]', 'B [capture]']); + }); + + it('nested event responders and their onEvent() should fire in the correct order with stopPropagation #2', () => { + let eventLog; + let stopPropagationOnPhase; + const buttonRef = React.createRef(); + + const ClickEventComponentA = createReactEventComponent( + ['click'], + undefined, + (event, context, props) => { + eventLog.push(`A [${phaseToString(event.phase)}]`); + if (event.phase === stopPropagationOnPhase) { + return true; + } + }, + ); + + const ClickEventComponentB = createReactEventComponent( + ['click'], + undefined, + (event, context, props) => { + eventLog.push(`B [${phaseToString(event.phase)}]`); + }, + ); + + const Test = () => ( + + + + + + ); + + function runTestWithPhase(phase) { + eventLog = []; + stopPropagationOnPhase = phase; + ReactDOM.render(, container); + let buttonElement = buttonRef.current; + dispatchClickEvent(buttonElement); + } + + runTestWithPhase(BUBBLE_PHASE); + expect(eventLog).toEqual([ + 'A [capture]', + 'B [capture]', + 'B [bubble]', + 'A [bubble]', + ]); + runTestWithPhase(CAPTURE_PHASE); + expect(eventLog).toEqual(['A [capture]']); }); it('custom event dispatching for click -> magicClick works', () => { @@ -257,6 +408,7 @@ describe('DOMEventResponderSystem', () => { const syntheticEvent = { target: event.target, type: 'magicclick', + phase: phaseToString(event.phase), }; context.dispatchEvent(syntheticEvent, props.onMagicClick, { discrete: true, @@ -266,7 +418,7 @@ describe('DOMEventResponderSystem', () => { ); function handleMagicEvent(e) { - eventLog.push('magic event fired', e.type); + eventLog.push('magic event fired', e.type, e.phase); } const Test = () => ( @@ -281,7 +433,14 @@ describe('DOMEventResponderSystem', () => { let buttonElement = buttonRef.current; dispatchClickEvent(buttonElement); - expect(eventLog).toEqual(['magic event fired', 'magicclick']); + expect(eventLog).toEqual([ + 'magic event fired', + 'magicclick', + 'capture', + 'magic event fired', + 'magicclick', + 'bubble', + ]); }); it('async event dispatching works', () => { @@ -295,6 +454,7 @@ describe('DOMEventResponderSystem', () => { const pressEvent = { target: event.target, type: 'press', + phase: phaseToString(event.phase), }; context.dispatchEvent(pressEvent, props.onPress, {discrete: true}); @@ -303,6 +463,7 @@ describe('DOMEventResponderSystem', () => { const longPressEvent = { target: event.target, type: 'longpress', + phase: phaseToString(event.phase), }; context.dispatchEvent(longPressEvent, props.onLongPress, { discrete: true, @@ -313,6 +474,7 @@ describe('DOMEventResponderSystem', () => { const longPressChangeEvent = { target: event.target, type: 'longpresschange', + phase: phaseToString(event.phase), }; context.dispatchEvent( longPressChangeEvent, @@ -330,9 +492,9 @@ describe('DOMEventResponderSystem', () => { const Test = () => ( log('press')} - onLongPress={() => log('longpress')} - onLongPressChange={() => log('longpresschange')}> + onPress={e => log('press ' + e.phase)} + onLongPress={e => log('longpress ' + e.phase)} + onLongPressChange={e => log('longpresschange ' + e.phase)}> ); @@ -344,7 +506,14 @@ describe('DOMEventResponderSystem', () => { dispatchClickEvent(buttonElement); jest.runAllTimers(); - expect(eventLog).toEqual(['press', 'longpress', 'longpresschange']); + expect(eventLog).toEqual([ + 'press capture', + 'press bubble', + 'longpress capture', + 'longpresschange capture', + 'longpress bubble', + 'longpresschange bubble', + ]); }); it('the event responder onUnmount() function should fire', () => { @@ -404,7 +573,9 @@ describe('DOMEventResponderSystem', () => { ['click'], undefined, (event, context, props, state) => { - ownershipGained = context.requestOwnership(); + if (event.phase === BUBBLE_PHASE) { + ownershipGained = context.requestOwnership(); + } }, undefined, () => { diff --git a/packages/react-events/src/Drag.js b/packages/react-events/src/Drag.js index b06831851a30f..1802433643c11 100644 --- a/packages/react-events/src/Drag.js +++ b/packages/react-events/src/Drag.js @@ -13,6 +13,7 @@ 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}]; @@ -92,9 +93,13 @@ const DragResponder = { context: ReactResponderContext, props: Object, state: DragState, - ): void { - const {target, type, nativeEvent} = event; + ): boolean { + const {target, phase, 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': @@ -132,7 +137,7 @@ const DragResponder = { case 'mousemove': case 'pointermove': { if (event.passive) { - return; + return false; } if (state.isPointerDown) { const obj = @@ -225,6 +230,7 @@ const DragResponder = { break; } } + return false; }, }; diff --git a/packages/react-events/src/Focus.js b/packages/react-events/src/Focus.js index a710fbe3bc942..bd9c5bf85dc0d 100644 --- a/packages/react-events/src/Focus.js +++ b/packages/react-events/src/Focus.js @@ -13,6 +13,8 @@ import type { } from 'shared/ReactTypes'; import {REACT_EVENT_COMPONENT_TYPE} from 'shared/ReactSymbols'; +const CAPTURE_PHASE = 2; + type FocusProps = { disabled: boolean, onBlur: (e: FocusEvent) => void, @@ -136,12 +138,16 @@ const FocusResponder = { context: ReactResponderContext, props: Object, state: FocusState, - ): void { - const {type, target} = event; + ): boolean { + const {type, phase, target} = event; + // Focus doesn't handle capture target events at this point + if (phase === CAPTURE_PHASE) { + return false; + } switch (type) { case 'focus': { - if (!state.isFocused && !context.hasOwnership()) { + if (!state.isFocused) { state.focusTarget = target; dispatchFocusInEvents(event, context, props, state); state.isFocused = true; @@ -157,6 +163,7 @@ const FocusResponder = { break; } } + return false; }, onUnmount( context: ReactResponderContext, diff --git a/packages/react-events/src/Hover.js b/packages/react-events/src/Hover.js index dbb90f45380f1..d10e6a78e8d2d 100644 --- a/packages/react-events/src/Hover.js +++ b/packages/react-events/src/Hover.js @@ -13,6 +13,8 @@ import type { } from 'shared/ReactTypes'; import {REACT_EVENT_COMPONENT_TYPE} from 'shared/ReactSymbols'; +const CAPTURE_PHASE = 2; + type HoverProps = { disabled: boolean, delayHoverEnd: number, @@ -132,6 +134,7 @@ function dispatchHoverStartEvents( state.hoverStartTimeout = context.setTimeout(() => { state.hoverStartTimeout = null; activate(); + return false; }, delayHoverStart); } else { activate(); @@ -186,6 +189,7 @@ function dispatchHoverEndEvents( if (delayHoverEnd > 0) { state.hoverEndTimeout = context.setTimeout(() => { deactivate(); + return false; }, delayHoverEnd); } else { deactivate(); @@ -226,9 +230,13 @@ const HoverResponder = { context: ReactResponderContext, props: HoverProps, state: HoverState, - ): void { - const {type, target, nativeEvent} = event; + ): boolean { + const {type, phase, target, nativeEvent} = event; + // Hover doesn't handle capture target events at this point + if (phase === CAPTURE_PHASE) { + return false; + } switch (type) { /** * Prevent hover events when touch is being used. @@ -242,10 +250,10 @@ const HoverResponder = { case 'pointerover': case 'mouseover': { - if (!state.isHovered && !state.isTouched && !context.hasOwnership()) { + if (!state.isHovered && !state.isTouched) { if ((nativeEvent: any).pointerType === 'touch') { state.isTouched = true; - return; + return false; } if (type === 'pointerover') { state.skipMouseAfterPointer = true; @@ -258,7 +266,7 @@ const HoverResponder = { ) ) { state.isInHitSlop = true; - return; + return false; } state.hoverTarget = target; dispatchHoverStartEvents(event, context, props, state); @@ -280,7 +288,7 @@ const HoverResponder = { case 'pointermove': case 'mousemove': { if (type === 'mousemove' && state.skipMouseAfterPointer === true) { - return; + return false; } if (state.isHovered && !state.isTouched) { @@ -330,6 +338,7 @@ const HoverResponder = { break; } } + return false; }, onUnmount( context: ReactResponderContext, diff --git a/packages/react-events/src/Press.js b/packages/react-events/src/Press.js index df1140adf9484..65971d63c06f5 100644 --- a/packages/react-events/src/Press.js +++ b/packages/react-events/src/Press.js @@ -14,6 +14,8 @@ import type { } from 'shared/ReactTypes'; import {REACT_EVENT_COMPONENT_TYPE} from 'shared/ReactSymbols'; +const CAPTURE_PHASE = 2; + type PressProps = { disabled: boolean, delayLongPress: number, @@ -40,7 +42,6 @@ type PressProps = { type PointerType = '' | 'mouse' | 'keyboard' | 'pen' | 'touch'; type PressState = { - didDispatchEvent: boolean, isActivePressed: boolean, isActivePressStart: boolean, isAnchorTouched: boolean, @@ -140,7 +141,6 @@ function dispatchEvent( discrete: true, }, ); - state.didDispatchEvent = true; } function dispatchPressChangeEvent( @@ -200,6 +200,8 @@ function dispatchPressStartEvents( props: PressProps, state: PressState, ): void { + const shouldStopPropagation = + props.stopPropagation === undefined ? true : props.stopPropagation; state.isPressed = true; if (state.pressEndTimeout !== null) { @@ -229,14 +231,7 @@ function dispatchPressStartEvents( if (props.onLongPressChange) { dispatchLongPressChangeEvent(context, props, state); } - if (state.didDispatchEvent) { - const shouldStopPropagation = - props.stopPropagation === undefined ? true : props.stopPropagation; - if (shouldStopPropagation) { - context.dispatchStopPropagation(); - } - state.didDispatchEvent = false; - } + return shouldStopPropagation; }, delayLongPress); } }; @@ -251,6 +246,7 @@ function dispatchPressStartEvents( state.pressStartTimeout = context.setTimeout(() => { state.pressStartTimeout = null; dispatch(); + return shouldStopPropagation; }, delayPressStart); } else { dispatch(); @@ -263,6 +259,8 @@ function dispatchPressEndEvents( props: PressProps, state: PressState, ): void { + const shouldStopPropagation = + props.stopPropagation === undefined ? true : props.stopPropagation; const wasActivePressStart = state.isActivePressStart; let activationWasForced = false; @@ -298,6 +296,7 @@ function dispatchPressEndEvents( state.pressEndTimeout = context.setTimeout(() => { state.pressEndTimeout = null; deactivate(context, props, state); + return shouldStopPropagation; }, delayPressEnd); } else { deactivate(context, props, state); @@ -425,9 +424,16 @@ const PressResponder = { context: ReactResponderContext, props: PressProps, state: PressState, - ): void { - const {target, type} = event; + ): boolean { + const {phase, target, type} = event; + + // Press doesn't handle capture target events at this point + if (phase === CAPTURE_PHASE) { + return false; + } const nativeEvent: any = event.nativeEvent; + const shouldStopPropagation = + props.stopPropagation === undefined ? true : props.stopPropagation; switch (type) { /** @@ -435,19 +441,10 @@ const PressResponder = { */ case 'pointerdown': case 'mousedown': { - if ( - !state.isPressed && - !context.hasOwnership() && - !state.shouldSkipMouseAfterTouch - ) { + if (!state.isPressed && !state.shouldSkipMouseAfterTouch) { const pointerType = getPointerType(nativeEvent); state.pointerType = pointerType; - // Ignore any device buttons except left-mouse and touch/pen contact - if (nativeEvent.button > 0) { - return; - } - // Ignore pressing on hit slop area with mouse if ( (pointerType === 'mouse' || type === 'mousedown') && @@ -457,22 +454,28 @@ const PressResponder = { nativeEvent.y, ) ) { - return; + return false; + } + + // Ignore any device buttons except left-mouse and touch/pen contact + if (nativeEvent.button > 0) { + return shouldStopPropagation; } state.pressTarget = target; state.isPressWithinResponderRegion = true; dispatchPressStartEvents(context, props, state); context.addRootEventTypes(target.ownerDocument, rootEventTypes); + return shouldStopPropagation; } - break; + return false; } case 'pointermove': case 'mousemove': case 'touchmove': { if (state.isPressed) { if (state.shouldSkipMouseAfterTouch) { - return; + return shouldStopPropagation; } const pointerType = getPointerType(nativeEvent); @@ -503,15 +506,16 @@ const PressResponder = { state.isPressWithinResponderRegion = false; dispatchPressEndEvents(context, props, state); } + return shouldStopPropagation; } - break; + return false; } case 'pointerup': case 'mouseup': { if (state.isPressed) { if (state.shouldSkipMouseAfterTouch) { state.shouldSkipMouseAfterTouch = false; - return; + return shouldStopPropagation; } const pointerType = getPointerType(nativeEvent); @@ -535,10 +539,11 @@ const PressResponder = { } } context.removeRootEventTypes(rootEventTypes); + return shouldStopPropagation; } state.isAnchorTouched = false; state.shouldSkipMouseAfterTouch = false; - break; + return false; } /** @@ -546,12 +551,12 @@ const PressResponder = { * support for pointer events. */ case 'touchstart': { - if (!state.isPressed && !context.hasOwnership()) { + if (!state.isPressed) { // We bail out of polyfilling anchor tags, given the same heuristics // explained above in regards to needing to use click events. if (isAnchorTagElement(target)) { state.isAnchorTouched = true; - return; + return shouldStopPropagation; } const pointerType = getPointerType(nativeEvent); state.pointerType = pointerType; @@ -559,13 +564,14 @@ const PressResponder = { state.isPressWithinResponderRegion = true; dispatchPressStartEvents(context, props, state); context.addRootEventTypes(target.ownerDocument, rootEventTypes); + return shouldStopPropagation; } - break; + return false; } case 'touchend': { if (state.isAnchorTouched) { state.isAnchorTouched = false; - return; + return shouldStopPropagation; } if (state.isPressed) { const pointerType = getPointerType(nativeEvent); @@ -600,8 +606,9 @@ const PressResponder = { } state.shouldSkipMouseAfterTouch = true; context.removeRootEventTypes(rootEventTypes); + return shouldStopPropagation; } - break; + return false; } /** @@ -610,7 +617,7 @@ const PressResponder = { */ case 'keydown': case 'keypress': { - if (!context.hasOwnership() && isValidKeyPress(nativeEvent.key)) { + if (isValidKeyPress(nativeEvent.key)) { if (state.isPressed) { // Prevent spacebar press from scrolling the window if (nativeEvent.key === ' ') { @@ -623,8 +630,9 @@ const PressResponder = { dispatchPressStartEvents(context, props, state); context.addRootEventTypes(target.ownerDocument, rootEventTypes); } + return shouldStopPropagation; } - break; + return false; } case 'keyup': { if (state.isPressed && isValidKeyPress(nativeEvent.key)) { @@ -642,8 +650,9 @@ const PressResponder = { } } context.removeRootEventTypes(rootEventTypes); + return shouldStopPropagation; } - break; + return false; } case 'pointercancel': @@ -653,8 +662,9 @@ const PressResponder = { state.shouldSkipMouseAfterTouch = false; dispatchPressEndEvents(context, props, state); context.removeRootEventTypes(rootEventTypes); + return shouldStopPropagation; } - break; + return false; } case 'click': { @@ -665,8 +675,9 @@ const PressResponder = { if (preventDefault !== false && !shiftKey && !metaKey && !ctrlKey) { nativeEvent.preventDefault(); } + return shouldStopPropagation; } - break; + return false; } case 'contextmenu': { @@ -678,19 +689,12 @@ const PressResponder = { dispatchPressEndEvents(context, props, state); context.removeRootEventTypes(rootEventTypes); } + return shouldStopPropagation; } - break; - } - } - - if (state.didDispatchEvent) { - const shouldStopPropagation = - props.stopPropagation === undefined ? true : props.stopPropagation; - if (shouldStopPropagation) { - context.dispatchStopPropagation(); + return false; } - state.didDispatchEvent = false; } + return false; }, onUnmount( context: ReactResponderContext, diff --git a/packages/react-events/src/Swipe.js b/packages/react-events/src/Swipe.js index eea03c6d22f9a..025819c3ca7f3 100644 --- a/packages/react-events/src/Swipe.js +++ b/packages/react-events/src/Swipe.js @@ -13,6 +13,7 @@ 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}]; @@ -96,14 +97,18 @@ const SwipeResponder = { context: ReactResponderContext, props: Object, state: SwipeState, - ): void { - const {target, type, nativeEvent} = event; + ): boolean { + const {target, phase, 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': case 'pointerdown': { - if (!state.isSwiping && !context.hasOwnership()) { + if (!state.isSwiping) { let obj = nativeEvent; if (type === 'touchstart') { obj = (nativeEvent: any).targetTouches[0]; @@ -135,7 +140,7 @@ const SwipeResponder = { case 'mousemove': case 'pointermove': { if (event.passive) { - return; + return false; } if (state.isSwiping) { let obj = null; @@ -155,7 +160,7 @@ const SwipeResponder = { state.swipeTarget = null; state.touchId = null; context.removeRootEventTypes(rootEventTypes); - return; + return false; } const x = (obj: any).screenX; const y = (obj: any).screenY; @@ -191,7 +196,7 @@ const SwipeResponder = { case 'pointerup': { if (state.isSwiping) { if (state.x === state.startX && state.y === state.startY) { - return; + return false; } if (props.onShouldClaimOwnership) { context.releaseOwnership(); @@ -235,6 +240,7 @@ 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 c29d8340787f7..2bd77f3077fa3 100644 --- a/packages/react-events/src/__tests__/Press-test.internal.js +++ b/packages/react-events/src/__tests__/Press-test.internal.js @@ -429,38 +429,6 @@ describe('Event responder: Press', () => { expect(onPressChange).toHaveBeenCalledTimes(2); expect(onPressChange).toHaveBeenCalledWith(false); }); - - it('is called but does not bubble', () => { - const element = ( - - -
- - - ); - ReactDOM.render(element, container); - - ref.current.dispatchEvent(createPointerEvent('pointerdown')); - expect(onPressChange).toHaveBeenCalledTimes(1); - ref.current.dispatchEvent(createPointerEvent('pointerup')); - expect(onPressChange).toHaveBeenCalledTimes(2); - }); - - it('is called and bubbles correctly with stopPropagation set to false', () => { - const element = ( - - -
- - - ); - ReactDOM.render(element, container); - - ref.current.dispatchEvent(createPointerEvent('pointerdown')); - expect(onPressChange).toHaveBeenCalledTimes(2); - ref.current.dispatchEvent(createPointerEvent('pointerup')); - expect(onPressChange).toHaveBeenCalledTimes(4); - }); }); describe('onPress', () => { @@ -517,36 +485,6 @@ describe('Event responder: Press', () => { // ref.current.dispatchEvent(createPointerEvent('touchend')); // expect(onPress).toHaveBeenCalledTimes(1); // }); - - it('is called but does not bubble', () => { - const element = ( - - -
- - - ); - ReactDOM.render(element, container); - - ref.current.dispatchEvent(createPointerEvent('pointerdown')); - ref.current.dispatchEvent(createPointerEvent('pointerup')); - expect(onPress).toHaveBeenCalledTimes(1); - }); - - it('is called and bubbles correctly with stopPropagation set to false', () => { - const element = ( - - -
- - - ); - ReactDOM.render(element, container); - - ref.current.dispatchEvent(createPointerEvent('pointerdown')); - ref.current.dispatchEvent(createPointerEvent('pointerup')); - expect(onPress).toHaveBeenCalledTimes(2); - }); }); describe('onLongPress', () => { @@ -603,38 +541,6 @@ describe('Event responder: Press', () => { expect(onLongPress).not.toBeCalled(); }); - it('is called but does not bubble', () => { - const element = ( - - -
- - - ); - ReactDOM.render(element, container); - - ref.current.dispatchEvent(createPointerEvent('pointerdown')); - jest.advanceTimersByTime(DEFAULT_LONG_PRESS_DELAY); - ref.current.dispatchEvent(createPointerEvent('pointerup')); - expect(onLongPress).toHaveBeenCalledTimes(1); - }); - - it('is called and bubbles correctly with stopPropagation set to false', () => { - const element = ( - - -
- - - ); - ReactDOM.render(element, container); - - ref.current.dispatchEvent(createPointerEvent('pointerdown')); - jest.advanceTimersByTime(DEFAULT_LONG_PRESS_DELAY); - ref.current.dispatchEvent(createPointerEvent('pointerup')); - expect(onLongPress).toHaveBeenCalledTimes(2); - }); - describe('delayLongPress', () => { it('can be configured', () => { const element = ( @@ -1136,6 +1042,158 @@ describe('Event responder: Press', () => { 'outer: onPress', ]); }); + + describe('correctly get propagation stopped and do not bubble', () => { + 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(1); + }); + + 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(1); + }); + + 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(1); + expect(fn2).toHaveBeenCalledTimes(0); + ref.current.dispatchEvent(createPointerEvent('pointerup')); + expect(fn).toHaveBeenCalledTimes(1); + expect(fn2).toHaveBeenCalledTimes(1); + }); + + 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(1); + ref.current.dispatchEvent(createPointerEvent('pointerup')); + 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 cc6a9c3473781..905bea76b7784 100644 --- a/packages/shared/ReactTypes.js +++ b/packages/shared/ReactTypes.js @@ -93,7 +93,7 @@ export type ReactEventResponder = { context: ReactResponderContext, props: null | Object, state: null | Object, - ) => void, + ) => boolean, onUnmount: ( context: ReactResponderContext, props: null | Object, @@ -135,6 +135,7 @@ export type ReactResponderEvent = { type: string, passive: boolean, passiveSupported: boolean, + phase: 0 | 1 | 2, }; export type ReactResponderDispatchEventOptions = { @@ -148,7 +149,6 @@ export type ReactResponderContext = { listener: (Object) => void, otpions: ReactResponderDispatchEventOptions, ) => void, - dispatchStopPropagation: (passive?: boolean) => void, isTargetWithinElement: ( childTarget: Element | Document, parentTarget: Element | Document, @@ -169,7 +169,7 @@ export type ReactResponderContext = { hasOwnership: () => boolean, requestOwnership: () => boolean, releaseOwnership: () => boolean, - setTimeout: (func: () => void, timeout: number) => Symbol, + setTimeout: (func: () => boolean, timeout: number) => Symbol, clearTimeout: (timerId: Symbol) => void, getEventTargetsFromTarget: ( target: Element | Document, From 96f427e5008e0930a7a0e185597b089f221100de Mon Sep 17 00:00:00 2001 From: Dominic Gannaway Date: Sat, 13 Apr 2019 14:13:47 +0100 Subject: [PATCH 2/4] Remove dead code --- packages/react-dom/src/events/DOMEventResponderSystem.js | 2 +- packages/shared/ReactTypes.js | 1 - 2 files changed, 1 insertion(+), 2 deletions(-) diff --git a/packages/react-dom/src/events/DOMEventResponderSystem.js b/packages/react-dom/src/events/DOMEventResponderSystem.js index dc77ac26c88de..29fbab0e95e00 100644 --- a/packages/react-dom/src/events/DOMEventResponderSystem.js +++ b/packages/react-dom/src/events/DOMEventResponderSystem.js @@ -95,7 +95,7 @@ const eventResponderContext: ReactResponderContext = { dispatchEvent( possibleEventObject: Object, listener: ($Shape) => void, - {capture, discrete}: ReactResponderDispatchEventOptions, + {discrete}: ReactResponderDispatchEventOptions, ): void { validateResponderContext(); const {target, type} = possibleEventObject; diff --git a/packages/shared/ReactTypes.js b/packages/shared/ReactTypes.js index 905bea76b7784..f0676edb888d3 100644 --- a/packages/shared/ReactTypes.js +++ b/packages/shared/ReactTypes.js @@ -139,7 +139,6 @@ export type ReactResponderEvent = { }; export type ReactResponderDispatchEventOptions = { - capture?: boolean, discrete?: boolean, }; From 2f5292dce7ca24373a37f58cf518fa323d64584d Mon Sep 17 00:00:00 2001 From: Dominic Gannaway Date: Sat, 13 Apr 2019 14:19:30 +0100 Subject: [PATCH 3/4] Ensure all events are batched --- packages/react-dom/src/events/DOMEventResponderSystem.js | 6 +++--- 1 file changed, 3 insertions(+), 3 deletions(-) diff --git a/packages/react-dom/src/events/DOMEventResponderSystem.js b/packages/react-dom/src/events/DOMEventResponderSystem.js index 29fbab0e95e00..ebaf9ec5d3ce9 100644 --- a/packages/react-dom/src/events/DOMEventResponderSystem.js +++ b/packages/react-dom/src/events/DOMEventResponderSystem.js @@ -363,7 +363,7 @@ function processTimers(timers: Map): void { activeTimeouts.delete(id); } } - batchedUpdates(processEventQueue, currentEventQueue); + processEventQueue(); } finally { currentTimers = null; currentInstance = null; @@ -435,10 +435,10 @@ export function processEventQueue(): void { } if (discrete) { interactiveUpdates(() => { - processEvents(events); + batchedUpdates(processEvents, events); }); } else { - processEvents(events); + batchedUpdates(processEvents, events); } } From 157f4cbf5522b5229e927754ef0ae35ae0d01083 Mon Sep 17 00:00:00 2001 From: Dominic Gannaway Date: Sat, 13 Apr 2019 15:14:59 +0100 Subject: [PATCH 4/4] Refine responder event re-use --- .../src/events/DOMEventResponderSystem.js | 63 ++++++++++--------- 1 file changed, 34 insertions(+), 29 deletions(-) diff --git a/packages/react-dom/src/events/DOMEventResponderSystem.js b/packages/react-dom/src/events/DOMEventResponderSystem.js index ebaf9ec5d3ce9..2c76e03e9ab77 100644 --- a/packages/react-dom/src/events/DOMEventResponderSystem.js +++ b/packages/react-dom/src/events/DOMEventResponderSystem.js @@ -538,41 +538,44 @@ function traverseAndTriggerEventResponderInstances( let length = targetEventResponderInstances.length; let i; let shouldStopPropagation = false; + let responderEvent; // Capture target phase - for (i = length; i-- > 0; ) { - const targetEventResponderInstance = targetEventResponderInstances[i]; - const responderEvent = createResponderEvent( + if (length > 0) { + responderEvent = createResponderEvent( ((topLevelType: any): string), nativeEvent, ((nativeEventTarget: any): Element | Document), eventSystemFlags, CAPTURE_PHASE, ); - shouldStopPropagation = triggerEventResponderEventListener( - responderEvent, - targetEventResponderInstance, - ); - if (shouldStopPropagation) { - return; + for (i = length; i-- > 0; ) { + const targetEventResponderInstance = targetEventResponderInstances[i]; + shouldStopPropagation = triggerEventResponderEventListener( + responderEvent, + targetEventResponderInstance, + ); + if (shouldStopPropagation) { + return; + } } - } - // Bubble target phase - for (i = 0; i < length; i++) { - const targetEventResponderInstance = targetEventResponderInstances[i]; - const responderEvent = createResponderEvent( + // Bubble target phase + responderEvent = createResponderEvent( ((topLevelType: any): string), nativeEvent, ((nativeEventTarget: any): Element | Document), eventSystemFlags, BUBBLE_PHASE, ); - shouldStopPropagation = triggerEventResponderEventListener( - responderEvent, - targetEventResponderInstance, - ); - if (shouldStopPropagation) { - return; + for (i = 0; i < length; i++) { + const targetEventResponderInstance = targetEventResponderInstances[i]; + shouldStopPropagation = triggerEventResponderEventListener( + responderEvent, + targetEventResponderInstance, + ); + if (shouldStopPropagation) { + return; + } } } // Root phase @@ -580,21 +583,23 @@ function traverseAndTriggerEventResponderInstances( topLevelType, ); length = rootEventResponderInstances.length; - for (i = 0; i < length; i++) { - const targetEventResponderInstance = rootEventResponderInstances[i]; - const responderEvent = createResponderEvent( + if (length > 0) { + responderEvent = createResponderEvent( ((topLevelType: any): string), nativeEvent, ((nativeEventTarget: any): Element | Document), eventSystemFlags, ROOT_PHASE, ); - shouldStopPropagation = triggerEventResponderEventListener( - responderEvent, - targetEventResponderInstance, - ); - if (shouldStopPropagation) { - return; + for (i = 0; i < length; i++) { + const targetEventResponderInstance = rootEventResponderInstances[i]; + shouldStopPropagation = triggerEventResponderEventListener( + responderEvent, + targetEventResponderInstance, + ); + if (shouldStopPropagation) { + return; + } } } }