From 9307932fe5b59c2d5ce8d04823b94ec0e48b206e Mon Sep 17 00:00:00 2001 From: Dominic Gannaway Date: Tue, 2 Apr 2019 20:03:11 +0100 Subject: [PATCH] Refactor event object creation for the experimental event API (#15295) * Refactor event object creation for the experimental event API --- packages/events/EventTypes.js | 28 +-- .../src/events/DOMEventResponderSystem.js | 190 ++++++++++++------ .../DOMEventResponderSystem-test.internal.js | 12 +- packages/react-events/src/Drag.js | 53 +++-- packages/react-events/src/Focus.js | 42 +++- packages/react-events/src/Hover.js | 46 ++++- packages/react-events/src/Press.js | 75 ++++--- packages/react-events/src/Swipe.js | 43 +++- .../src/__tests__/Press-test.internal.js | 6 +- 9 files changed, 348 insertions(+), 147 deletions(-) diff --git a/packages/events/EventTypes.js b/packages/events/EventTypes.js index a03ef8f7405c1..b48052a6ad7a6 100644 --- a/packages/events/EventTypes.js +++ b/packages/events/EventTypes.js @@ -7,29 +7,29 @@ * @flow */ -import SyntheticEvent from 'events/SyntheticEvent'; import type {AnyNativeEvent} from 'events/PluginModuleType'; import type {ReactEventResponderEventType} from 'shared/ReactTypes'; export type EventResponderContext = { event: AnyNativeEvent, - eventTarget: EventTarget, + eventTarget: Element | Document, eventType: string, isPassive: () => boolean, isPassiveSupported: () => boolean, - dispatchEvent: ( - name: string, - listener: (e: SyntheticEvent) => void | null, - pressTarget: EventTarget | null, - discrete: boolean, - extraProperties?: Object, + dispatchEvent: ( + eventObject: E, + { + capture?: boolean, + discrete?: boolean, + stopPropagation?: boolean, + }, ) => void, isTargetWithinElement: ( - childTarget: EventTarget, - parentTarget: EventTarget, + childTarget: Element | Document, + parentTarget: Element | Document, ) => boolean, - isTargetOwned: EventTarget => boolean, - isTargetWithinEventComponent: EventTarget => boolean, + isTargetOwned: (Element | Document) => boolean, + isTargetWithinEventComponent: (Element | Document) => boolean, isPositionWithinTouchHitTarget: (x: number, y: number) => boolean, addRootEventTypes: ( rootEventTypes: Array, @@ -37,6 +37,6 @@ export type EventResponderContext = { removeRootEventTypes: ( rootEventTypes: Array, ) => void, - requestOwnership: (target: EventTarget | null) => boolean, - releaseOwnership: (target: EventTarget | null) => boolean, + requestOwnership: (target: Element | Document | null) => boolean, + releaseOwnership: (target: Element | Document | null) => boolean, }; diff --git a/packages/react-dom/src/events/DOMEventResponderSystem.js b/packages/react-dom/src/events/DOMEventResponderSystem.js index 9c52389654ffb..b4e982142b897 100644 --- a/packages/react-dom/src/events/DOMEventResponderSystem.js +++ b/packages/react-dom/src/events/DOMEventResponderSystem.js @@ -18,15 +18,13 @@ import type { ReactEventResponderEventType, } from 'shared/ReactTypes'; import type {DOMTopLevelEventType} from 'events/TopLevelEventTypes'; -import SyntheticEvent from 'events/SyntheticEvent'; -import {runEventsInBatch} from 'events/EventBatching'; -import {interactiveUpdates} from 'events/ReactGenericBatching'; -import {executeDispatch} from 'events/EventPluginUtils'; +import {batchedUpdates, interactiveUpdates} from 'events/ReactGenericBatching'; import type {Fiber} from 'react-reconciler/src/ReactFiber'; - import {getClosestInstanceFromNode} from '../client/ReactDOMComponentTree'; import {enableEventAPI} from 'shared/ReactFeatureFlags'; +import {invokeGuardedCallbackAndCatchFirstError} from 'shared/ReactErrorUtils'; +import warning from 'shared/warning'; let listenToResponderEventTypesImpl; @@ -36,6 +34,8 @@ export function setListenToResponderEventTypes( listenToResponderEventTypesImpl = _listenToResponderEventTypesImpl; } +const PossiblyWeakSet = typeof WeakSet === 'function' ? WeakSet : Set; + const rootEventTypesToEventComponents: Map< DOMTopLevelEventType | string, Set, @@ -45,12 +45,76 @@ const targetEventTypeCached: Map< Set, > = new Map(); const targetOwnership: Map = new Map(); +const eventsWithStopPropagation: + | WeakSet + | Set<$Shape> = new PossiblyWeakSet(); + +type PartialEventObject = { + listener: ($Shape) => void, + target: Element | Document, + type: string, +}; +type EventQueue = { + bubble: null | Array<$Shape>, + capture: null | Array<$Shape>, + discrete: boolean, + phase: EventQueuePhase, +}; +type EventQueuePhase = 0 | 1; + +const DURING_EVENT_PHASE = 0; +const AFTER_EVENT_PHASE = 1; -type EventListener = (event: SyntheticEvent) => void; +function createEventQueue(phase: EventQueuePhase): EventQueue { + return { + bubble: null, + capture: null, + discrete: false, + phase, + }; +} + +function processEvent(event: $Shape): void { + const type = event.type; + const listener = event.listener; + invokeGuardedCallbackAndCatchFirstError(type, listener, undefined, event); +} -function copyEventProperties(eventData, syntheticEvent) { - for (let propName in eventData) { - syntheticEvent[propName] = eventData[propName]; +function processEvents( + bubble: null | Array<$Shape>, + capture: null | Array<$Shape>, +): void { + let i, length; + + if (capture !== null) { + for (i = capture.length; i-- > 0; ) { + const event = capture[i]; + processEvent(capture[i]); + if (eventsWithStopPropagation.has(event)) { + return; + } + } + } + if (bubble !== null) { + for (i = 0, length = bubble.length; i < length; ++i) { + const event = bubble[i]; + processEvent(event); + if (eventsWithStopPropagation.has(event)) { + return; + } + } + } +} + +function processEventQueue(eventQueue: EventQueue): void { + const {bubble, capture, discrete} = eventQueue; + + if (discrete) { + interactiveUpdates(() => { + processEvents(bubble, capture); + }); + } else { + processEvents(bubble, capture); } } @@ -70,6 +134,7 @@ function DOMEventResponderContext( this._discreteEvents = null; this._nonDiscreteEvents = null; this._isBatching = true; + this._eventQueue = createEventQueue(DURING_EVENT_PHASE); } DOMEventResponderContext.prototype.isPassive = function(): boolean { @@ -81,49 +146,66 @@ DOMEventResponderContext.prototype.isPassiveSupported = function(): boolean { }; DOMEventResponderContext.prototype.dispatchEvent = function( - eventName: string, - eventListener: EventListener, - eventTarget: AnyNativeEvent, - discrete: boolean, - extraProperties?: Object, + possibleEventObject: Object, + { + capture, + discrete, + stopPropagation, + }: { + capture?: boolean, + discrete?: boolean, + stopPropagation?: boolean, + }, ): void { - const eventTargetFiber = getClosestInstanceFromNode(eventTarget); - const syntheticEvent = SyntheticEvent.getPooled( - null, - eventTargetFiber, - this.event, - eventTarget, - ); - if (extraProperties !== undefined) { - copyEventProperties(extraProperties, syntheticEvent); + const eventQueue = this._eventQueue; + const {listener, target, type} = possibleEventObject; + + if (listener == null || target == null || type == null) { + throw new Error( + 'context.dispatchEvent: "listener", "target" and "type" fields on event object are required.', + ); } - syntheticEvent.type = eventName; - syntheticEvent._dispatchInstances = [eventTargetFiber]; - syntheticEvent._dispatchListeners = [eventListener]; - - if (this._isBatching) { - let events; - if (discrete) { - events = this._discreteEvents; - if (events === null) { - events = this._discreteEvents = []; - } - } else { - events = this._nonDiscreteEvents; - if (events === null) { - events = this._nonDiscreteEvents = []; - } + if (__DEV__) { + possibleEventObject.preventDefault = () => { + // Update this warning when we have a story around dealing with preventDefault + warning( + false, + 'preventDefault() is no longer available on event objects created from event responder modules.', + ); + }; + possibleEventObject.stopPropagation = () => { + // Update this warning when we have a story around dealing with stopPropgation + warning( + false, + 'stopPropagation() is no longer available on event objects created from event responder modules.', + ); + }; + } + const eventObject = ((possibleEventObject: any): $Shape); + let events; + + if (capture) { + events = eventQueue.capture; + if (events === null) { + events = eventQueue.capture = []; } - events.push(syntheticEvent); } else { - if (discrete) { - interactiveUpdates(() => { - executeDispatch(syntheticEvent, eventListener, eventTargetFiber); - }); - } else { - executeDispatch(syntheticEvent, eventListener, eventTargetFiber); + events = eventQueue.bubble; + if (events === null) { + events = eventQueue.bubble = []; } } + if (discrete) { + eventQueue.discrete = true; + } + events.push(eventObject); + + if (stopPropagation) { + eventsWithStopPropagation.add(eventObject); + } + if (eventQueue.phase === AFTER_EVENT_PHASE) { + batchedUpdates(processEventQueue, eventQueue); + } }; DOMEventResponderContext.prototype.isTargetWithinEventComponent = function( @@ -318,17 +400,9 @@ export function runResponderEventsInBatch( ); } } - // Run batched events - const discreteEvents = context._discreteEvents; - if (discreteEvents !== null) { - interactiveUpdates(() => { - runEventsInBatch(discreteEvents); - }); - } - const nonDiscreteEvents = context._nonDiscreteEvents; - if (nonDiscreteEvents !== null) { - runEventsInBatch(nonDiscreteEvents); - } - context._isBatching = false; + processEventQueue(context._eventQueue); + // In order to capture and process async events from responder modules + // we create a new event queue. + context._eventQueue = createEventQueue(AFTER_EVENT_PHASE); } } 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 4af4f210dffc1..b8ef52edf08e9 100644 --- a/packages/react-dom/src/events/__tests__/DOMEventResponderSystem-test.internal.js +++ b/packages/react-dom/src/events/__tests__/DOMEventResponderSystem-test.internal.js @@ -228,12 +228,12 @@ describe('DOMEventResponderSystem', () => { ['click'], (context, props) => { if (props.onMagicClick) { - context.dispatchEvent( - 'magicclick', - props.onMagicClick, - context.eventTarget, - false, - ); + const event = { + listener: props.onMagicClick, + target: context.eventTarget, + type: 'magicclick', + }; + context.dispatchEvent(event, {discrete: true}); } }, ); diff --git a/packages/react-events/src/Drag.js b/packages/react-events/src/Drag.js index 7181b4efa233b..cc741e29b5b32 100644 --- a/packages/react-events/src/Drag.js +++ b/packages/react-events/src/Drag.js @@ -14,7 +14,7 @@ const targetEventTypes = ['pointerdown', 'pointercancel']; const rootEventTypes = ['pointerup', {name: 'pointermove', passive: false}]; type DragState = { - dragTarget: null | EventTarget, + dragTarget: null | Element | Document, isPointerDown: boolean, isDragging: boolean, startX: number, @@ -33,18 +33,45 @@ if (typeof window !== 'undefined' && window.PointerEvent === undefined) { }); } +type EventData = { + diffX: number, + diffY: number, +}; +type DragEventType = 'dragend' | 'dragchange' | 'dragmove'; + +type DragEvent = {| + listener: DragEvent => void, + target: Element | Document, + type: DragEventType, + diffX?: number, + diffY?: number, +|}; + +function createDragEvent( + type: DragEventType, + target: Element | Document, + listener: DragEvent => void, + eventData?: EventData, +): DragEvent { + return { + listener, + target, + type, + ...eventData, + }; +} + function dispatchDragEvent( context: EventResponderContext, - name: string, - listener: (e: Object) => void, + name: DragEventType, + listener: DragEvent => void, state: DragState, discrete: boolean, - eventData?: { - diffX: number, - diffY: number, - }, + eventData?: EventData, ): void { - context.dispatchEvent(name, listener, state.dragTarget, discrete, eventData); + const target = ((state.dragTarget: any): Element | Document); + const syntheticEvent = createDragEvent(name, target, listener, eventData); + context.dispatchEvent(syntheticEvent, {discrete}); } const DragResponder = { @@ -112,10 +139,11 @@ const DragResponder = { const dragChangeEventListener = () => { props.onDragChange(true); }; - context.dispatchEvent( + dispatchDragEvent( + context, 'dragchange', dragChangeEventListener, - state.dragTarget, + state, true, ); } @@ -160,10 +188,11 @@ const DragResponder = { const dragChangeEventListener = () => { props.onDragChange(false); }; - context.dispatchEvent( + dispatchDragEvent( + context, 'dragchange', dragChangeEventListener, - state.dragTarget, + state, true, ); } diff --git a/packages/react-events/src/Focus.js b/packages/react-events/src/Focus.js index 9c652f69d26be..c574f1e3a1f9b 100644 --- a/packages/react-events/src/Focus.js +++ b/packages/react-events/src/Focus.js @@ -19,24 +19,49 @@ type FocusState = { isFocused: boolean, }; +type FocusEventType = 'focus' | 'blur' | 'focuschange'; + +type FocusEvent = {| + listener: FocusEvent => void, + target: Element | Document, + type: FocusEventType, +|}; + +function createFocusEvent( + type: FocusEventType, + target: Element | Document, + listener: FocusEvent => void, +): FocusEvent { + return { + listener, + target, + type, + }; +} + function dispatchFocusInEvents(context: EventResponderContext, props: Object) { const {event, eventTarget} = context; if (context.isTargetWithinEventComponent((event: any).relatedTarget)) { return; } if (props.onFocus) { - context.dispatchEvent('focus', props.onFocus, eventTarget, true); + const syntheticEvent = createFocusEvent( + 'focus', + eventTarget, + props.onFocus, + ); + context.dispatchEvent(syntheticEvent, {discrete: true}); } if (props.onFocusChange) { const focusChangeEventListener = () => { props.onFocusChange(true); }; - context.dispatchEvent( + const syntheticEvent = createFocusEvent( 'focuschange', - focusChangeEventListener, eventTarget, - true, + focusChangeEventListener, ); + context.dispatchEvent(syntheticEvent, {discrete: true}); } } @@ -46,18 +71,19 @@ function dispatchFocusOutEvents(context: EventResponderContext, props: Object) { return; } if (props.onBlur) { - context.dispatchEvent('blur', props.onBlur, eventTarget, true); + const syntheticEvent = createFocusEvent('blur', eventTarget, props.onBlur); + context.dispatchEvent(syntheticEvent, {discrete: true}); } if (props.onFocusChange) { const focusChangeEventListener = () => { props.onFocusChange(false); }; - context.dispatchEvent( + const syntheticEvent = createFocusEvent( 'focuschange', - focusChangeEventListener, eventTarget, - true, + focusChangeEventListener, ); + context.dispatchEvent(syntheticEvent, {discrete: true}); } } diff --git a/packages/react-events/src/Hover.js b/packages/react-events/src/Hover.js index f8585fae8cdb3..75992d246aa17 100644 --- a/packages/react-events/src/Hover.js +++ b/packages/react-events/src/Hover.js @@ -23,6 +23,26 @@ type HoverState = { isTouched: boolean, }; +type HoverEventType = 'hoverstart' | 'hoverend' | 'hoverchange'; + +type HoverEvent = {| + listener: HoverEvent => void, + target: Element | Document, + type: HoverEventType, +|}; + +function createHoverEvent( + type: HoverEventType, + target: Element | Document, + listener: HoverEvent => void, +): HoverEvent { + return { + listener, + target, + type, + }; +} + // In the case we don't have PointerEvents (Safari), we listen to touch events // too if (typeof window !== 'undefined' && window.PointerEvent === undefined) { @@ -39,18 +59,23 @@ function dispatchHoverStartEvents( return; } if (props.onHoverStart) { - context.dispatchEvent('hoverstart', props.onHoverStart, eventTarget, true); + const syntheticEvent = createHoverEvent( + 'hoverstart', + eventTarget, + props.onHoverStart, + ); + context.dispatchEvent(syntheticEvent, {discrete: true}); } if (props.onHoverChange) { const hoverChangeEventListener = () => { props.onHoverChange(true); }; - context.dispatchEvent( + const syntheticEvent = createHoverEvent( 'hoverchange', - hoverChangeEventListener, eventTarget, - true, + hoverChangeEventListener, ); + context.dispatchEvent(syntheticEvent, {discrete: true}); } } @@ -60,18 +85,23 @@ function dispatchHoverEndEvents(context: EventResponderContext, props: Object) { return; } if (props.onHoverEnd) { - context.dispatchEvent('hoverend', props.onHoverEnd, eventTarget, true); + const syntheticEvent = createHoverEvent( + 'hoverend', + eventTarget, + props.onHoverEnd, + ); + context.dispatchEvent(syntheticEvent, {discrete: true}); } if (props.onHoverChange) { const hoverChangeEventListener = () => { props.onHoverChange(false); }; - context.dispatchEvent( + const syntheticEvent = createHoverEvent( 'hoverchange', - hoverChangeEventListener, eventTarget, - true, + hoverChangeEventListener, ); + context.dispatchEvent(syntheticEvent, {discrete: true}); } } diff --git a/packages/react-events/src/Press.js b/packages/react-events/src/Press.js index d2ea6121c88ba..3b7498153c977 100644 --- a/packages/react-events/src/Press.js +++ b/packages/react-events/src/Press.js @@ -36,13 +36,13 @@ type PressProps = { delayLongPress: number, delayPressEnd: number, delayPressStart: number, - onLongPress: (e: Object) => void, + onLongPress: (e: PressEvent) => void, onLongPressChange: boolean => void, onLongPressShouldCancelPress: () => boolean, - onPress: (e: Object) => void, + onPress: (e: PressEvent) => void, onPressChange: boolean => void, - onPressEnd: (e: Object) => void, - onPressStart: (e: Object) => void, + onPressEnd: (e: PressEvent) => void, + onPressStart: (e: PressEvent) => void, pressRententionOffset: Object, }; @@ -52,17 +52,45 @@ type PressState = { isLongPressed: boolean, isPressed: boolean, longPressTimeout: null | TimeoutID, - pressTarget: null | EventTarget, + pressTarget: null | Element | Document, shouldSkipMouseAfterTouch: boolean, }; +type PressEventType = + | 'press' + | 'pressstart' + | 'pressend' + | 'presschange' + | 'longpress' + | 'longpresschange'; + +type PressEvent = {| + listener: PressEvent => void, + target: Element | Document, + type: PressEventType, +|}; + +function createPressEvent( + type: PressEventType, + target: Element | Document, + listener: PressEvent => void, +): PressEvent { + return { + listener, + target, + type, + }; +} + function dispatchPressEvent( context: EventResponderContext, state: PressState, - name: string, + name: PressEventType, listener: (e: Object) => void, ): void { - context.dispatchEvent(name, listener, state.pressTarget, true); + const target = ((state.pressTarget: any): Element | Document); + const syntheticEvent = createPressEvent(name, target, listener); + context.dispatchEvent(syntheticEvent, {discrete: true}); } function dispatchPressStartEvents( @@ -97,9 +125,10 @@ function dispatchPressStartEvents( if (props.onLongPress) { const longPressEventListener = e => { props.onLongPress(e); - if (e.nativeEvent.defaultPrevented) { - state.defaultPrevented = true; - } + // TODO address this again at some point + // if (e.nativeEvent.defaultPrevented) { + // state.defaultPrevented = true; + // } }; dispatchPressEvent(context, state, 'longpress', longPressEventListener); } @@ -193,24 +222,7 @@ const PressResponder = { ) { return; } - let keyPressEventListener = props.onPress; - - // Wrap listener with prevent default behaviour, unless - // we are dealing with an anchor. Anchor tags are special beacuse - // we need to use the "click" event, to properly allow browser - // heuristics for cancelling link clicks. Furthermore, iOS and - // Android can show previous of anchor tags that requires working - // with click rather than touch events (and mouse down/up). - if (!isAnchorTagElement(eventTarget)) { - keyPressEventListener = e => { - if (!e.isDefaultPrevented() && !e.nativeEvent.defaultPrevented) { - e.preventDefault(); - state.defaultPrevented = true; - props.onPress(e); - } - }; - } - dispatchPressEvent(context, state, 'press', keyPressEventListener); + dispatchPressEvent(context, state, 'press', props.onPress); break; } @@ -332,9 +344,10 @@ const PressResponder = { ) { const pressEventListener = e => { props.onPress(e); - if (e.nativeEvent.defaultPrevented) { - state.defaultPrevented = true; - } + // TODO address this again at some point + // if (e.nativeEvent.defaultPrevented) { + // state.defaultPrevented = true; + // } }; dispatchPressEvent(context, state, 'press', pressEventListener); } diff --git a/packages/react-events/src/Swipe.js b/packages/react-events/src/Swipe.js index f265b2473242f..c67b502316e74 100644 --- a/packages/react-events/src/Swipe.js +++ b/packages/react-events/src/Swipe.js @@ -23,18 +23,45 @@ if (typeof window !== 'undefined' && window.PointerEvent === undefined) { }); } +type EventData = { + diffX: number, + diffY: number, +}; +type SwipeEventType = 'swipeleft' | 'swiperight' | 'swipeend' | 'swipemove'; + +type SwipeEvent = {| + listener: SwipeEvent => void, + target: Element | Document, + type: SwipeEventType, + diffX?: number, + diffY?: number, +|}; + +function createSwipeEvent( + type: SwipeEventType, + target: Element | Document, + listener: SwipeEvent => void, + eventData?: EventData, +): SwipeEvent { + return { + listener, + target, + type, + ...eventData, + }; +} + function dispatchSwipeEvent( context: EventResponderContext, - name: string, - listener: (e: Object) => void, + name: SwipeEventType, + listener: SwipeEvent => void, state: SwipeState, discrete: boolean, - eventData?: { - diffX: number, - diffY: number, - }, + eventData?: EventData, ) { - context.dispatchEvent(name, listener, state.swipeTarget, discrete, eventData); + const target = ((state.swipeTarget: any): Element | Document); + const syntheticEvent = createSwipeEvent(name, target, listener, eventData); + context.dispatchEvent(syntheticEvent, {discrete}); } type SwipeState = { @@ -44,7 +71,7 @@ type SwipeState = { startX: number, startY: number, touchId: null | number, - swipeTarget: null | EventTarget, + swipeTarget: null | Element | Document, x: number, y: number, }; diff --git a/packages/react-events/src/__tests__/Press-test.internal.js b/packages/react-events/src/__tests__/Press-test.internal.js index 1483e089ec5db..1505c1f3033a4 100644 --- a/packages/react-events/src/__tests__/Press-test.internal.js +++ b/packages/react-events/src/__tests__/Press-test.internal.js @@ -388,8 +388,10 @@ describe('Event responder: Press', () => { events = []; ref.current.dispatchEvent(createKeyboardEvent('keydown', {key: 'Enter'})); - // Outer press should not occur as inner press will preventDefault - expect(events).toEqual(['keydown', 'inner: onPress']); + // TODO update this test once we have a form of stopPropagation in + // the responder system again. This test had to be updated because + // we have removed stopPropagation() from synthetic events. + expect(events).toEqual(['keydown', 'inner: onPress', 'outer: onPress']); }); }); });