From 9672cf621bccc799d1d86f45c86e2fbcb97be5aa Mon Sep 17 00:00:00 2001 From: Dominic Gannaway Date: Thu, 11 Apr 2019 20:00:20 +0100 Subject: [PATCH] Experimental Event API: adds `stopPropagation` by default to Press (#15384) --- .../src/events/DOMEventResponderSystem.js | 173 ++++++++++++------ packages/react-events/src/Hover.js | 8 +- packages/react-events/src/Press.js | 36 +++- .../src/__tests__/Press-test.internal.js | 97 +++++++++- packages/shared/ReactTypes.js | 5 +- 5 files changed, 249 insertions(+), 70 deletions(-) diff --git a/packages/react-dom/src/events/DOMEventResponderSystem.js b/packages/react-dom/src/events/DOMEventResponderSystem.js index 5d32eeac71b8c..3572ca52aae31 100644 --- a/packages/react-dom/src/events/DOMEventResponderSystem.js +++ b/packages/react-dom/src/events/DOMEventResponderSystem.js @@ -41,9 +41,11 @@ export function setListenToResponderEventTypes( listenToResponderEventTypesImpl = _listenToResponderEventTypesImpl; } +type EventObjectTypes = {|stopPropagation: true|} | $Shape; + type EventQueue = { - bubble: null | Array<$Shape>, - capture: null | Array<$Shape>, + bubble: null | Array, + capture: null | Array, discrete: boolean, }; @@ -53,6 +55,29 @@ type PartialEventObject = { type: string, }; +type ResponderTimeout = {| + id: TimeoutID, + timers: Map, +|}; + +type ResponderTimer = {| + instance: ReactEventComponentInstance, + func: () => void, + id: Symbol, +|}; + +const activeTimeouts: Map = new Map(); +const rootEventTypesToEventComponentInstances: Map< + DOMTopLevelEventType | string, + Set, +> = new Map(); +const targetEventTypeCached: Map< + Array, + Set, +> = new Map(); +const ownershipChangeListeners: Set = new Set(); + +let currentTimers = new Map(); let currentOwner = null; let currentInstance: ReactEventComponentInstance; let currentEventQueue: EventQueue; @@ -60,9 +85,8 @@ let currentEventQueue: EventQueue; const eventResponderContext: ReactResponderContext = { dispatchEvent( possibleEventObject: Object, - {capture, discrete, stopPropagation}: ReactResponderDispatchEventOptions, + {capture, discrete}: ReactResponderDispatchEventOptions, ): void { - const eventQueue = currentEventQueue; const {listener, target, type} = possibleEventObject; if (listener == null || target == null || type == null) { @@ -89,27 +113,15 @@ const eventResponderContext: ReactResponderContext = { const eventObject = ((possibleEventObject: any): $Shape< PartialEventObject, >); - let events; - - if (capture) { - events = eventQueue.capture; - if (events === null) { - events = eventQueue.capture = []; - } - } else { - events = eventQueue.bubble; - if (events === null) { - events = eventQueue.bubble = []; - } - } + const events = getEventsFromEventQueue(capture); if (discrete) { - eventQueue.discrete = true; + currentEventQueue.discrete = true; } events.push(eventObject); - - if (stopPropagation) { - eventsWithStopPropagation.add(eventObject); - } + }, + dispatchStopPropagation(capture?: boolean) { + const events = getEventsFromEventQueue(); + events.push({stopPropagation: true}); }, isPositionWithinTouchHitTarget(doc: Document, x: number, y: number): boolean { // This isn't available in some environments (JSDOM) @@ -222,21 +234,42 @@ const eventResponderContext: ReactResponderContext = { triggerOwnershipListeners(); return false; }, - setTimeout(func: () => void, delay): TimeoutID { - const contextInstance = currentInstance; - return setTimeout(() => { - const previousEventQueue = currentEventQueue; - const previousInstance = currentInstance; - currentEventQueue = createEventQueue(); - currentInstance = contextInstance; - try { - func(); - batchedUpdates(processEventQueue, currentEventQueue); - } finally { - currentInstance = previousInstance; - currentEventQueue = previousEventQueue; + setTimeout(func: () => void, delay): Symbol { + if (currentTimers === null) { + currentTimers = new Map(); + } + let timeout = currentTimers.get(delay); + + const timerId = Symbol(); + if (timeout === undefined) { + const timers = new Map(); + const id = setTimeout(() => { + processTimers(timers); + }, delay); + timeout = { + id, + timers, + }; + currentTimers.set(delay, timeout); + } + timeout.timers.set(timerId, { + instance: currentInstance, + func, + id: timerId, + }); + activeTimeouts.set(timerId, timeout); + return timerId; + }, + clearTimeout(timerId: Symbol): void { + const timeout = activeTimeouts.get(timerId); + + if (timeout !== undefined) { + const timers = timeout.timers; + timers.delete(timerId); + if (timers.size === 0) { + clearTimeout(timeout.id); } - }, delay); + } }, getEventTargetsFromTarget( target: Element | Document, @@ -292,6 +325,46 @@ const eventResponderContext: ReactResponderContext = { }, }; +function getEventsFromEventQueue(capture?: boolean): Array { + let events; + if (capture) { + events = currentEventQueue.capture; + if (events === null) { + events = currentEventQueue.capture = []; + } + } else { + events = currentEventQueue.bubble; + if (events === null) { + events = currentEventQueue.bubble = []; + } + } + return events; +} + +function processTimers(timers: Map): void { + const previousEventQueue = currentEventQueue; + const previousInstance = currentInstance; + currentEventQueue = createEventQueue(); + + try { + const timersArr = Array.from(timers.values()); + for (let i = 0; i < timersArr.length; i++) { + const {instance, func, id} = timersArr[i]; + currentInstance = instance; + try { + func(); + } finally { + activeTimeouts.delete(id); + } + } + batchedUpdates(processEventQueue, currentEventQueue); + } finally { + currentInstance = previousInstance; + currentEventQueue = previousEventQueue; + currentTimers = null; + } +} + function queryEventTarget( child: Fiber, queryType: void | Symbol | number, @@ -306,20 +379,6 @@ function queryEventTarget( return true; } -const rootEventTypesToEventComponentInstances: Map< - DOMTopLevelEventType | string, - Set, -> = new Map(); -const PossiblyWeakSet = typeof WeakSet === 'function' ? WeakSet : Set; -const eventsWithStopPropagation: - | WeakSet - | Set<$Shape> = new PossiblyWeakSet(); -const targetEventTypeCached: Map< - Array, - Set, -> = new Map(); -const ownershipChangeListeners: Set = new Set(); - function createResponderEvent( topLevelType: string, nativeEvent: AnyNativeEvent, @@ -350,27 +409,27 @@ function processEvent(event: $Shape): void { } function processEvents( - bubble: null | Array<$Shape>, - capture: null | Array<$Shape>, + bubble: null | Array, + capture: null | Array, ): void { let i, length; if (capture !== null) { for (i = capture.length; i-- > 0; ) { const event = capture[i]; - processEvent(capture[i]); - if (eventsWithStopPropagation.has(event)) { + 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]; - processEvent(event); - if (eventsWithStopPropagation.has(event)) { + if (event.stopPropagation === true) { return; } + processEvent(((event: any): $Shape)); } } } @@ -475,6 +534,7 @@ export function runResponderEventsInBatch( } } processEventQueue(); + currentTimers = null; } } @@ -518,6 +578,7 @@ export function unmountEventResponder( } finally { currentEventQueue = previousEventQueue; currentInstance = previousInstance; + currentTimers = null; } } if (currentOwner === eventComponentInstance) { diff --git a/packages/react-events/src/Hover.js b/packages/react-events/src/Hover.js index 116416bc7e865..1eebab060f5e7 100644 --- a/packages/react-events/src/Hover.js +++ b/packages/react-events/src/Hover.js @@ -27,8 +27,8 @@ type HoverState = { isHovered: boolean, isInHitSlop: boolean, isTouched: boolean, - hoverStartTimeout: null | TimeoutID, - hoverEndTimeout: null | TimeoutID, + hoverStartTimeout: null | Symbol, + hoverEndTimeout: null | Symbol, }; type HoverEventType = 'hoverstart' | 'hoverend' | 'hoverchange'; @@ -97,7 +97,7 @@ function dispatchHoverStartEvents( state.isHovered = true; if (state.hoverEndTimeout !== null) { - clearTimeout(state.hoverEndTimeout); + context.clearTimeout(state.hoverEndTimeout); state.hoverEndTimeout = null; } @@ -148,7 +148,7 @@ function dispatchHoverEndEvents( state.isHovered = false; if (state.hoverStartTimeout !== null) { - clearTimeout(state.hoverStartTimeout); + context.clearTimeout(state.hoverStartTimeout); state.hoverStartTimeout = null; } diff --git a/packages/react-events/src/Press.js b/packages/react-events/src/Press.js index 0f79406a84234..ce58d7a8c6308 100644 --- a/packages/react-events/src/Press.js +++ b/packages/react-events/src/Press.js @@ -33,19 +33,21 @@ type PressProps = { left: number, }, preventDefault: boolean, + stopPropagation: boolean, }; type PressState = { + didDispatchEvent: boolean, isActivePressed: boolean, isActivePressStart: boolean, isAnchorTouched: boolean, isLongPressed: boolean, isPressed: boolean, isPressWithinResponderRegion: boolean, - longPressTimeout: null | TimeoutID, + longPressTimeout: null | Symbol, pressTarget: null | Element | Document, - pressEndTimeout: null | TimeoutID, - pressStartTimeout: null | TimeoutID, + pressEndTimeout: null | Symbol, + pressStartTimeout: null | Symbol, responderRegion: null | $ReadOnly<{| bottom: number, left: number, @@ -124,7 +126,10 @@ function dispatchEvent( ): void { const target = ((state.pressTarget: any): Element | Document); const syntheticEvent = createPressEvent(name, target, listener); - context.dispatchEvent(syntheticEvent, {discrete: true}); + context.dispatchEvent(syntheticEvent, { + discrete: true, + }); + state.didDispatchEvent = true; } function dispatchPressChangeEvent( @@ -185,7 +190,7 @@ function dispatchPressStartEvents( state.isPressed = true; if (state.pressEndTimeout !== null) { - clearTimeout(state.pressEndTimeout); + context.clearTimeout(state.pressEndTimeout); state.pressEndTimeout = null; } @@ -211,6 +216,14 @@ 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; + } }, delayLongPress); } }; @@ -243,12 +256,12 @@ function dispatchPressEndEvents( state.isPressed = false; if (state.longPressTimeout !== null) { - clearTimeout(state.longPressTimeout); + context.clearTimeout(state.longPressTimeout); state.longPressTimeout = null; } if (!wasActivePressStart && state.pressStartTimeout !== null) { - clearTimeout(state.pressStartTimeout); + context.clearTimeout(state.pressStartTimeout); state.pressStartTimeout = null; // don't activate if a press has moved beyond the responder region if (state.isPressWithinResponderRegion) { @@ -356,6 +369,7 @@ const PressResponder = { targetEventTypes, createInitialState(): PressState { return { + didDispatchEvent: false, isActivePressed: false, isActivePressStart: false, isAnchorTouched: false, @@ -602,6 +616,14 @@ const PressResponder = { } } } + if (state.didDispatchEvent) { + const shouldStopPropagation = + props.stopPropagation === undefined ? true : props.stopPropagation; + if (shouldStopPropagation) { + context.dispatchStopPropagation(); + } + state.didDispatchEvent = false; + } }, onUnmount( context: ReactResponderContext, diff --git a/packages/react-events/src/__tests__/Press-test.internal.js b/packages/react-events/src/__tests__/Press-test.internal.js index 8367c46c37b03..331685eb4d1c9 100644 --- a/packages/react-events/src/__tests__/Press-test.internal.js +++ b/packages/react-events/src/__tests__/Press-test.internal.js @@ -381,6 +381,38 @@ 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', () => { @@ -429,6 +461,36 @@ 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', () => { @@ -477,6 +539,38 @@ 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 = ( @@ -914,7 +1008,8 @@ describe('Event responder: Press', () => { onPress={createEventHandler('inner: onPress')} onPressChange={createEventHandler('inner: onPressChange')} onPressStart={createEventHandler('inner: onPressStart')} - onPressEnd={createEventHandler('inner: onPressEnd')}> + onPressEnd={createEventHandler('inner: onPressEnd')} + stopPropagation={false}>
void, + dispatchStopPropagation: (passive?: boolean) => void, isTargetWithinElement: ( childTarget: Element | Document, parentTarget: Element | Document, @@ -168,7 +168,8 @@ export type ReactResponderContext = { hasOwnership: () => boolean, requestOwnership: () => boolean, releaseOwnership: () => boolean, - setTimeout: (func: () => void, timeout: number) => TimeoutID, + setTimeout: (func: () => void, timeout: number) => Symbol, + clearTimeout: (timerId: Symbol) => void, getEventTargetsFromTarget: ( target: Element | Document, queryType?: Symbol | number,