Skip to content

Commit 07a02fb

Browse files
authored
[react-events] Refactor unit tests for Hover (#16320)
**Problem** The existing responders listen to pointer events by default and add fallback events if PointerEvent is not supported. However, this complicates the responders and makes it easy to create a problematic unit test environment. jsdom doesn't support PointerEvent, which means that the responders end up listening to pointer events *and* fallback events in unit tests. This isn't a direct problem in production environments, because no browser will fire pointer events if they aren't supported. But in the unit test environment, we often dispatch event sequences taken from browsers that support pointer events. This means that what we're often testing is actually a (complex) scenario that cannot even occur in production: a responder that is listening to and receives both pointer events and fallback events. Not only does this risk making responders more complicated to implement but it could also hide bugs in implementations. **Response** Implement the responders so that they're only listening to *either* pointer events *or* fallback events, never both. This should make the default pointer events implementations significantly simpler and easier to test, as well as free to rely on the complete PointerEvents API. In the future it should also make DCE easier for target environments that are known to support PointerEvents, as we can use build tools with an equivalent of the runtime check. The fallback events (touch and mouse) need to coexist and be resilient to browser emulated events. Our unit tests should express a suite of high-level interactions that can be run in environments with and without PointerEvents support.
1 parent f62b53d commit 07a02fb

File tree

4 files changed

+670
-532
lines changed

4 files changed

+670
-532
lines changed

packages/react-events/src/dom/Hover.js

Lines changed: 111 additions & 96 deletions
Original file line numberDiff line numberDiff line change
@@ -10,6 +10,7 @@
1010
import type {
1111
ReactDOMResponderEvent,
1212
ReactDOMResponderContext,
13+
PointerType,
1314
} from 'shared/ReactDOMTypes';
1415
import type {ReactEventResponderListener} from 'shared/ReactTypes';
1516

@@ -29,15 +30,14 @@ type HoverState = {
2930
hoverTarget: null | Element | Document,
3031
isActiveHovered: boolean,
3132
isHovered: boolean,
32-
isTouched: boolean,
33-
hoverStartTimeout: null | number,
34-
hoverEndTimeout: null | number,
35-
ignoreEmulatedMouseEvents: boolean,
33+
isTouched?: boolean,
34+
ignoreEmulatedMouseEvents?: boolean,
3635
};
3736

3837
type HoverEventType = 'hoverstart' | 'hoverend' | 'hoverchange' | 'hovermove';
3938

4039
type HoverEvent = {|
40+
pointerType: PointerType,
4141
target: Element | Document,
4242
type: HoverEventType,
4343
timeStamp: number,
@@ -51,17 +51,8 @@ type HoverEvent = {|
5151
y: null | number,
5252
|};
5353

54-
const targetEventTypes = [
55-
'pointerover',
56-
'pointermove',
57-
'pointerout',
58-
'pointercancel',
59-
];
60-
61-
// If PointerEvents is not supported (e.g., Safari), also listen to touch and mouse events.
62-
if (typeof window !== 'undefined' && window.PointerEvent === undefined) {
63-
targetEventTypes.push('touchstart', 'mouseover', 'mousemove', 'mouseout');
64-
}
54+
const hasPointerEvents =
55+
typeof window !== 'undefined' && window.PointerEvent != null;
6556

6657
function isFunction(obj): boolean {
6758
return typeof obj === 'function';
@@ -79,13 +70,16 @@ function createHoverEvent(
7970
let pageY = null;
8071
let screenX = null;
8172
let screenY = null;
73+
let pointerType = '';
8274

8375
if (event) {
8476
const nativeEvent = (event.nativeEvent: any);
77+
pointerType = event.pointerType;
8578
({clientX, clientY, pageX, pageY, screenX, screenY} = nativeEvent);
8679
}
8780

8881
return {
82+
pointerType,
8983
target,
9084
type,
9185
timeStamp: context.getTimeStamp(),
@@ -131,11 +125,6 @@ function dispatchHoverStartEvents(
131125

132126
state.isHovered = true;
133127

134-
if (state.hoverEndTimeout !== null) {
135-
context.clearTimeout(state.hoverEndTimeout);
136-
state.hoverEndTimeout = null;
137-
}
138-
139128
if (!state.isActiveHovered) {
140129
state.isActiveHovered = true;
141130
const onHoverStart = props.onHoverStart;
@@ -152,6 +141,20 @@ function dispatchHoverStartEvents(
152141
}
153142
}
154143

144+
function dispatchHoverMoveEvent(event, context, props, state) {
145+
const target = state.hoverTarget;
146+
const onHoverMove = props.onHoverMove;
147+
if (isFunction(onHoverMove)) {
148+
const syntheticEvent = createHoverEvent(
149+
event,
150+
context,
151+
'hovermove',
152+
((target: any): Element | Document),
153+
);
154+
context.dispatchEvent(syntheticEvent, onHoverMove, UserBlockingEvent);
155+
}
156+
}
157+
155158
function dispatchHoverEndEvents(
156159
event: null | ReactDOMResponderEvent,
157160
context: ReactDOMResponderContext,
@@ -170,11 +173,6 @@ function dispatchHoverEndEvents(
170173

171174
state.isHovered = false;
172175

173-
if (state.hoverStartTimeout !== null) {
174-
context.clearTimeout(state.hoverStartTimeout);
175-
state.hoverStartTimeout = null;
176-
}
177-
178176
if (state.isActiveHovered) {
179177
state.isActiveHovered = false;
180178
const onHoverEnd = props.onHoverEnd;
@@ -189,7 +187,6 @@ function dispatchHoverEndEvents(
189187
}
190188
dispatchHoverChangeEvent(event, context, props, state);
191189
state.hoverTarget = null;
192-
state.ignoreEmulatedMouseEvents = false;
193190
state.isTouched = false;
194191
}
195192
}
@@ -204,24 +201,17 @@ function unmountResponder(
204201
}
205202
}
206203

207-
function isEmulatedMouseEvent(event, state) {
208-
const {type} = event;
209-
return (
210-
state.ignoreEmulatedMouseEvents &&
211-
(type === 'mousemove' || type === 'mouseover' || type === 'mouseout')
212-
);
213-
}
214-
215204
const hoverResponderImpl = {
216-
targetEventTypes,
205+
targetEventTypes: [
206+
'pointerover',
207+
'pointermove',
208+
'pointerout',
209+
'pointercancel',
210+
],
217211
getInitialState() {
218212
return {
219213
isActiveHovered: false,
220214
isHovered: false,
221-
isTouched: false,
222-
hoverStartTimeout: null,
223-
hoverEndTimeout: null,
224-
ignoreEmulatedMouseEvents: false,
225215
};
226216
},
227217
allowMultipleHostChildren: false,
@@ -237,95 +227,120 @@ const hoverResponderImpl = {
237227
if (props.disabled) {
238228
if (state.isHovered) {
239229
dispatchHoverEndEvents(event, context, props, state);
240-
state.ignoreEmulatedMouseEvents = false;
241-
}
242-
if (state.isTouched) {
243-
state.isTouched = false;
244230
}
245231
return;
246232
}
247233

248234
switch (type) {
249235
// START
250-
case 'pointerover':
251-
case 'mouseover':
252-
case 'touchstart': {
253-
if (!state.isHovered) {
254-
// Prevent hover events for touch
255-
if (state.isTouched || pointerType === 'touch') {
256-
state.isTouched = true;
257-
return;
258-
}
259-
260-
// Prevent hover events for emulated events
261-
if (isEmulatedMouseEvent(event, state)) {
262-
return;
263-
}
236+
case 'pointerover': {
237+
if (!state.isHovered && pointerType !== 'touch') {
264238
state.hoverTarget = event.responderTarget;
265-
state.ignoreEmulatedMouseEvents = true;
266239
dispatchHoverStartEvents(event, context, props, state);
267240
}
268-
return;
241+
break;
269242
}
270243

271244
// MOVE
272-
case 'pointermove':
273-
case 'mousemove': {
274-
if (state.isHovered && !isEmulatedMouseEvent(event, state)) {
275-
const onHoverMove = props.onHoverMove;
276-
if (state.hoverTarget !== null && isFunction(onHoverMove)) {
277-
const syntheticEvent = createHoverEvent(
278-
event,
279-
context,
280-
'hovermove',
281-
state.hoverTarget,
282-
);
283-
context.dispatchEvent(
284-
syntheticEvent,
285-
onHoverMove,
286-
UserBlockingEvent,
287-
);
288-
}
245+
case 'pointermove': {
246+
if (state.isHovered && state.hoverTarget !== null) {
247+
dispatchHoverMoveEvent(event, context, props, state);
289248
}
290-
return;
249+
break;
291250
}
292251

293252
// END
294253
case 'pointerout':
295-
case 'pointercancel':
296-
case 'mouseout':
297-
case 'touchcancel':
298-
case 'touchend': {
254+
case 'pointercancel': {
299255
if (state.isHovered) {
300256
dispatchHoverEndEvents(event, context, props, state);
301-
state.ignoreEmulatedMouseEvents = false;
302257
}
303-
if (state.isTouched) {
304-
state.isTouched = false;
305-
}
306-
return;
258+
break;
307259
}
308260
}
309261
},
310-
onUnmount(
311-
context: ReactDOMResponderContext,
312-
props: HoverProps,
313-
state: HoverState,
314-
) {
315-
unmountResponder(context, props, state);
262+
onUnmount: unmountResponder,
263+
onOwnershipChange: unmountResponder,
264+
};
265+
266+
const hoverResponderFallbackImpl = {
267+
targetEventTypes: ['mouseover', 'mousemove', 'mouseout', 'touchstart'],
268+
getInitialState() {
269+
return {
270+
isActiveHovered: false,
271+
isHovered: false,
272+
isTouched: false,
273+
ignoreEmulatedMouseEvents: false,
274+
};
316275
},
317-
onOwnershipChange(
276+
allowMultipleHostChildren: false,
277+
allowEventHooks: true,
278+
onEvent(
279+
event: ReactDOMResponderEvent,
318280
context: ReactDOMResponderContext,
319281
props: HoverProps,
320282
state: HoverState,
321-
) {
322-
unmountResponder(context, props, state);
283+
): void {
284+
const {type} = event;
285+
286+
if (props.disabled) {
287+
if (state.isHovered) {
288+
dispatchHoverEndEvents(event, context, props, state);
289+
state.ignoreEmulatedMouseEvents = false;
290+
}
291+
state.isTouched = false;
292+
return;
293+
}
294+
295+
switch (type) {
296+
// START
297+
case 'mouseover': {
298+
if (!state.isHovered && !state.ignoreEmulatedMouseEvents) {
299+
state.hoverTarget = event.responderTarget;
300+
dispatchHoverStartEvents(event, context, props, state);
301+
}
302+
break;
303+
}
304+
305+
// MOVE
306+
case 'mousemove': {
307+
if (
308+
state.isHovered &&
309+
state.hoverTarget !== null &&
310+
!state.ignoreEmulatedMouseEvents
311+
) {
312+
dispatchHoverMoveEvent(event, context, props, state);
313+
} else if (!state.isHovered && type === 'mousemove') {
314+
state.ignoreEmulatedMouseEvents = false;
315+
state.isTouched = false;
316+
}
317+
break;
318+
}
319+
320+
// END
321+
case 'mouseout': {
322+
if (state.isHovered) {
323+
dispatchHoverEndEvents(event, context, props, state);
324+
}
325+
break;
326+
}
327+
328+
case 'touchstart': {
329+
if (!state.isHovered) {
330+
state.isTouched = true;
331+
state.ignoreEmulatedMouseEvents = true;
332+
}
333+
break;
334+
}
335+
}
323336
},
337+
onUnmount: unmountResponder,
338+
onOwnershipChange: unmountResponder,
324339
};
325340

326341
export const HoverResponder = React.unstable_createResponder(
327342
'Hover',
328-
hoverResponderImpl,
343+
hasPointerEvents ? hoverResponderImpl : hoverResponderFallbackImpl,
329344
);
330345

331346
export function useHoverResponder(

0 commit comments

Comments
 (0)