Skip to content

Commit c904e06

Browse files
devongovettLFDanLu
andauthored
Workaround Android issue where click events are fired on the wrong target (#7026)
Co-authored-by: Daniel Lu <[email protected]>
1 parent dcbb9dc commit c904e06

File tree

4 files changed

+251
-7
lines changed

4 files changed

+251
-7
lines changed

packages/@react-aria/interactions/src/usePress.ts

Lines changed: 34 additions & 7 deletions
Original file line numberDiff line numberDiff line change
@@ -410,7 +410,7 @@ export function usePress(props: PressHookProps): PressResult {
410410

411411
// Due to browser inconsistencies, especially on mobile browsers, we prevent
412412
// default on pointer down and handle focusing the pressable element ourselves.
413-
if (shouldPreventDefault(e.currentTarget as Element)) {
413+
if (shouldPreventDefaultDown(e.currentTarget as Element)) {
414414
e.preventDefault();
415415
}
416416

@@ -452,7 +452,7 @@ export function usePress(props: PressHookProps): PressResult {
452452
// Chrome and Firefox on touch Windows devices require mouse down events
453453
// to be canceled in addition to pointer events, or an extra asynchronous
454454
// focus event will be fired.
455-
if (shouldPreventDefault(e.currentTarget as Element)) {
455+
if (shouldPreventDefaultDown(e.currentTarget as Element)) {
456456
e.preventDefault();
457457
}
458458

@@ -510,6 +510,25 @@ export function usePress(props: PressHookProps): PressResult {
510510
if (!allowTextSelectionOnPress) {
511511
restoreTextSelection(state.target);
512512
}
513+
514+
// Prevent subsequent touchend event from triggering onClick on unrelated elements on Android. See below.
515+
// Both 'touch' and 'pen' pointerTypes trigger onTouchEnd, but 'mouse' does not.
516+
if ('ontouchend' in state.target && e.pointerType !== 'mouse') {
517+
addGlobalListener(state.target, 'touchend', onTouchEnd, {once: true});
518+
}
519+
}
520+
};
521+
522+
// This is a workaround for an Android Chrome/Firefox issue where click events are fired on an incorrect element
523+
// if the original target is removed during onPointerUp (before onClick).
524+
// https://github.com/adobe/react-spectrum/issues/1513
525+
// https://issues.chromium.org/issues/40732224
526+
// Note: this event must be registered directly on the element, not via React props in order to work.
527+
// https://github.com/facebook/react/issues/9809
528+
let onTouchEnd = (e: TouchEvent) => {
529+
// Don't preventDefault if we actually want the default (e.g. submit/link click).
530+
if (shouldPreventDefaultUp(e.target as Element)) {
531+
e.preventDefault();
513532
}
514533
};
515534

@@ -534,7 +553,7 @@ export function usePress(props: PressHookProps): PressResult {
534553

535554
// Due to browser inconsistencies, especially on mobile browsers, we prevent
536555
// default on mouse down and handle focusing the pressable element ourselves.
537-
if (shouldPreventDefault(e.currentTarget)) {
556+
if (shouldPreventDefaultDown(e.currentTarget)) {
538557
e.preventDefault();
539558
}
540559

@@ -914,16 +933,16 @@ function isOverTarget(point: EventPoint, target: Element) {
914933
return areRectanglesOverlapping(rect, pointRect);
915934
}
916935

917-
function shouldPreventDefault(target: Element) {
936+
function shouldPreventDefaultDown(target: Element) {
918937
// We cannot prevent default if the target is a draggable element.
919938
return !(target instanceof HTMLElement) || !target.hasAttribute('draggable');
920939
}
921940

922-
function shouldPreventDefaultKeyboard(target: Element, key: string) {
941+
function shouldPreventDefaultUp(target: Element) {
923942
if (target instanceof HTMLInputElement) {
924-
return !isValidInputKey(target, key);
943+
return false;
925944
}
926-
945+
927946
if (target instanceof HTMLButtonElement) {
928947
return target.type !== 'submit' && target.type !== 'reset';
929948
}
@@ -935,6 +954,14 @@ function shouldPreventDefaultKeyboard(target: Element, key: string) {
935954
return true;
936955
}
937956

957+
function shouldPreventDefaultKeyboard(target: Element, key: string) {
958+
if (target instanceof HTMLInputElement) {
959+
return !isValidInputKey(target, key);
960+
}
961+
962+
return shouldPreventDefaultUp(target);
963+
}
964+
938965
const nonTextInputTypes = new Set([
939966
'checkbox',
940967
'radio',
Lines changed: 76 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,76 @@
1+
/*
2+
* Copyright 2024 Adobe. All rights reserved.
3+
* This file is licensed to you under the Apache License, Version 2.0 (the "License");
4+
* you may not use this file except in compliance with the License. You may obtain a copy
5+
* of the License at http://www.apache.org/licenses/LICENSE-2.0
6+
*
7+
* Unless required by applicable law or agreed to in writing, software distributed under
8+
* the License is distributed on an "AS IS" BASIS, WITHOUT WARRANTIES OR REPRESENTATIONS
9+
* OF ANY KIND, either express or implied. See the License for the specific language
10+
* governing permissions and limitations under the License.
11+
*/
12+
13+
.outer-div {
14+
background-color: #333;
15+
width: 320px;
16+
height: 320px;
17+
display: flex;
18+
flex-direction: column;
19+
justify-content: space-around;
20+
align-items: center;
21+
margin-bottom: 1000px;
22+
}
23+
24+
.open-btn {
25+
background-color: blue;
26+
font-size: 32px;
27+
padding: 10px;
28+
}
29+
30+
.visit-link {
31+
color: #9999ff;
32+
font-size: 16px;
33+
}
34+
35+
.fake-modal {
36+
background-color: rgba(224, 64, 0, 0.5);
37+
position: absolute;
38+
width: 320px;
39+
height: 320px;
40+
display: flex;
41+
flex-direction: column;
42+
align-items: center;
43+
justify-content: space-around;
44+
}
45+
46+
.side-by-side {
47+
color: white;
48+
display: flex;
49+
align-items: center;
50+
justify-content: space-between;
51+
width: 100%;
52+
}
53+
54+
.my-btn {
55+
background-color: rgb(0, 128, 0);
56+
cursor: pointer;
57+
padding: 5px;
58+
}
59+
60+
.fake-modal h1 {
61+
color: white;
62+
font-size: 60px;
63+
margin: 0;
64+
padding: 0;
65+
}
66+
67+
.fake-modal .close-btn {
68+
background-color: red;
69+
font-size: 16px;
70+
padding: 10px;
71+
}
72+
73+
.OnPress {
74+
cursor: pointer;
75+
color: #ffffff;
76+
}
Lines changed: 86 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,86 @@
1+
/*
2+
* Copyright 2024 Adobe. All rights reserved.
3+
* This file is licensed to you under the Apache License, Version 2.0 (the "License");
4+
* you may not use this file except in compliance with the License. You may obtain a copy
5+
* of the License at http://www.apache.org/licenses/LICENSE-2.0
6+
*
7+
* Unless required by applicable law or agreed to in writing, software distributed under
8+
* the License is distributed on an "AS IS" BASIS, WITHOUT WARRANTIES OR REPRESENTATIONS
9+
* OF ANY KIND, either express or implied. See the License for the specific language
10+
* governing permissions and limitations under the License.
11+
*/
12+
13+
import React from 'react';
14+
import styles from './usePress-stories.css';
15+
import {usePress} from '@react-aria/interactions';
16+
17+
export default {
18+
title: 'usePress'
19+
};
20+
21+
export function TouchIssue() {
22+
const [opened, setOpened] = React.useState(false);
23+
const handleOpen = React.useCallback(() => {
24+
console.log('opening');
25+
setOpened(true);
26+
}, []);
27+
const handleClose = React.useCallback(() => {
28+
console.log('closing');
29+
setOpened(false);
30+
}, []);
31+
const handleOnClick = React.useCallback(() => {
32+
alert('clicked it');
33+
}, []);
34+
35+
return (
36+
<div className={styles['outer-div']}>
37+
<OnPress onPress={handleOpen} className={styles['open-btn']}>
38+
Open
39+
</OnPress>
40+
<div className={styles['side-by-side']}>
41+
<div>Some text</div>
42+
<a href="https://www.google.com" className={styles['visit-link']}>
43+
Another Link
44+
</a>
45+
<button className={styles['my-btn']} onClick={handleOnClick}>
46+
On Click
47+
</button>
48+
</div>
49+
50+
{opened && (
51+
<div className={styles['fake-modal']}>
52+
<h1>Header</h1>
53+
<div className={styles['side-by-side']}>
54+
<OnPress onPress={handleClose} className={styles['close-btn']}>
55+
Close 1
56+
</OnPress>
57+
<OnPress onPress={handleClose} className={styles['close-btn']}>
58+
Close 2
59+
</OnPress>
60+
<OnPress onPress={handleClose} className={styles['close-btn']}>
61+
Close 3
62+
</OnPress>
63+
</div>
64+
</div>
65+
)}
66+
</div>
67+
);
68+
}
69+
70+
function OnPress(props) {
71+
const {className, onPress, children} = props;
72+
73+
const {pressProps} = usePress({
74+
onPress
75+
});
76+
77+
return (
78+
<div
79+
{...pressProps}
80+
role="button"
81+
tabIndex={0}
82+
className={`OnPress ${className || ''}`}>
83+
{children}
84+
</div>
85+
);
86+
}

packages/@react-aria/interactions/test/usePress.test.js

Lines changed: 55 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -822,6 +822,61 @@ describe('usePress', function () {
822822
fireEvent(el, pointerEvent('pointerup', {pointerId: 1, pointerType: 'mouse'}));
823823
expect(el).not.toHaveStyle('user-select: none');
824824
});
825+
826+
it('should preventDefault on touchend to prevent click events on the wrong element', function () {
827+
let res = render(<Example />);
828+
829+
let el = res.getByText('test');
830+
el.ontouchend = () => {}; // So that 'ontouchend' in target works
831+
fireEvent(el, pointerEvent('pointerdown', {pointerId: 1, pointerType: 'touch'}));
832+
fireEvent(el, pointerEvent('pointerup', {pointerId: 1, pointerType: 'touch'}));
833+
let browserDefault = fireEvent.touchEnd(el);
834+
expect(browserDefault).toBe(false);
835+
});
836+
837+
it('should not preventDefault on touchend when element is a submit button', function () {
838+
let res = render(<Example elementType="button" type="submit" />);
839+
840+
let el = res.getByText('test');
841+
el.ontouchend = () => {}; // So that 'ontouchend' in target works
842+
fireEvent(el, pointerEvent('pointerdown', {pointerId: 1, pointerType: 'touch'}));
843+
fireEvent(el, pointerEvent('pointerup', {pointerId: 1, pointerType: 'touch'}));
844+
let browserDefault = fireEvent.touchEnd(el);
845+
expect(browserDefault).toBe(true);
846+
});
847+
848+
it('should not preventDefault on touchend when element is an <input type="submit">', function () {
849+
let res = render(<Example elementType="input" type="submit" />);
850+
851+
let el = res.getByRole('button');
852+
el.ontouchend = () => {}; // So that 'ontouchend' in target works
853+
fireEvent(el, pointerEvent('pointerdown', {pointerId: 1, pointerType: 'touch'}));
854+
fireEvent(el, pointerEvent('pointerup', {pointerId: 1, pointerType: 'touch'}));
855+
let browserDefault = fireEvent.touchEnd(el);
856+
expect(browserDefault).toBe(true);
857+
});
858+
859+
it('should not preventDefault on touchend when element is an <input type="checkbox">', function () {
860+
let res = render(<Example elementType="input" type="checkbox" />);
861+
862+
let el = res.getByRole('checkbox');
863+
el.ontouchend = () => {}; // So that 'ontouchend' in target works
864+
fireEvent(el, pointerEvent('pointerdown', {pointerId: 1, pointerType: 'touch'}));
865+
fireEvent(el, pointerEvent('pointerup', {pointerId: 1, pointerType: 'touch'}));
866+
let browserDefault = fireEvent.touchEnd(el);
867+
expect(browserDefault).toBe(true);
868+
});
869+
870+
it('should not preventDefault on touchend when element is a link', function () {
871+
let res = render(<Example elementType="a" href="http://google.com" />);
872+
873+
let el = res.getByText('test');
874+
el.ontouchend = () => {}; // So that 'ontouchend' in target works
875+
fireEvent(el, pointerEvent('pointerdown', {pointerId: 1, pointerType: 'touch'}));
876+
fireEvent(el, pointerEvent('pointerup', {pointerId: 1, pointerType: 'touch'}));
877+
let browserDefault = fireEvent.touchEnd(el);
878+
expect(browserDefault).toBe(true);
879+
});
825880
});
826881

827882
describe('mouse events', function () {

0 commit comments

Comments
 (0)