From e7072899e9928bbb42775bf0324788b97db66b0a Mon Sep 17 00:00:00 2001 From: Bao Nguyen Date: Sat, 29 Aug 2026 10:56:07 +0700 Subject: [PATCH] fix: cancel useLongPress when a second touch begins A pending long press kept its timer running when another finger touched the screen, so multi-touch accessibility gestures such as the iOS three finger double tap opened long press menus. usePress ignores the additional pointerdown because a press is already active, so onPressEnd never fires and nothing clears the timer. Listen for a second touch pointerdown on the window during the capture phase while a touch long press is pending, and clear the timer when one arrives. --- .../s2-docs/pages/react-aria/useLongPress.mdx | 2 +- .../src/interactions/useLongPress.ts | 20 +- .../test/interactions/useLongPress.test.js | 244 ++++++++++++++++++ 3 files changed, 263 insertions(+), 3 deletions(-) diff --git a/packages/dev/s2-docs/pages/react-aria/useLongPress.mdx b/packages/dev/s2-docs/pages/react-aria/useLongPress.mdx index 9adaaaa6e5f..e3b039e1a3b 100644 --- a/packages/dev/s2-docs/pages/react-aria/useLongPress.mdx +++ b/packages/dev/s2-docs/pages/react-aria/useLongPress.mdx @@ -86,7 +86,7 @@ friendly alternative to all long press interactions if you are using this hook d ## Features -A long press is triggered when a user presses and holds their pointer over a target for a minimum period of time. If the user moves their pointer off of the target before the time threshold, the interaction is canceled. Once a long press event is triggered, other pointer interactions that may be active such as `usePress` and `useMove` will be canceled so that only the long press is activated. +A long press is triggered when a user presses and holds their pointer over a target for a minimum period of time. If the user moves their pointer off of the target before the time threshold, the interaction is canceled. On touch devices, a second finger touching the screen also cancels the interaction, matching native iOS and Android behavior. Once a long press event is triggered, other pointer interactions that may be active such as `usePress` and `useMove` will be canceled so that only the long press is activated. * Handles mouse and touch events * Prevents text selection on touch devices while long pressing diff --git a/packages/react-aria/src/interactions/useLongPress.ts b/packages/react-aria/src/interactions/useLongPress.ts index 61a45fc7250..0d960b51ede 100644 --- a/packages/react-aria/src/interactions/useLongPress.ts +++ b/packages/react-aria/src/interactions/useLongPress.ts @@ -111,12 +111,28 @@ export function useLongPress(props: LongPressProps): LongPressResult { timeRef.current = undefined; }, threshold); - // Prevent context menu, which may be opened on long press on touch devices + let ownerWindow = getOwnerWindow(e.target); + if (e.pointerType === 'touch') { + // Prevent context menu, which may be opened on long press on touch devices addGlobalListener(e.target, 'contextmenu', e => e.preventDefault(), {once: true}); + + // A second finger cancels the long press, matching iOS and Android. The capture phase skips + // the pointerdown that started this press, and still sees the next one, which usePress + // stops bubbling. + addGlobalListener( + ownerWindow, + 'pointerdown', + event => { + if (event.pointerType === 'touch' && timeRef.current) { + clearTimeout(timeRef.current); + timeRef.current = undefined; + } + }, + true + ); } - let ownerWindow = getOwnerWindow(e.target); addGlobalListener( ownerWindow, 'pointerup', diff --git a/packages/react-aria/test/interactions/useLongPress.test.js b/packages/react-aria/test/interactions/useLongPress.test.js index 47fee40d5e8..a01db58d18b 100644 --- a/packages/react-aria/test/interactions/useLongPress.test.js +++ b/packages/react-aria/test/interactions/useLongPress.test.js @@ -202,6 +202,250 @@ describe('useLongPress', function () { ]); }); + it('should cancel the long press when a second touch begins', function () { + let events = []; + let addEvent = e => events.push(e); + let res = render( + + ); + + let el = res.getByText('test'); + + fireEvent.pointerDown(el, {pointerType: 'touch', pointerId: 1}); + act(() => jest.advanceTimersByTime(100)); + fireEvent.pointerDown(el, {pointerType: 'touch', pointerId: 2}); + act(() => jest.advanceTimersByTime(600)); + expect(events).toEqual([ + { + type: 'longpressstart', + target: el, + pointerType: 'touch', + ctrlKey: false, + metaKey: false, + shiftKey: false, + altKey: false, + x: 0, + y: 0 + } + ]); + + fireEvent.pointerUp(el, {pointerType: 'touch', pointerId: 2}); + fireEvent.pointerUp(el, {pointerType: 'touch', pointerId: 1}); + act(() => jest.advanceTimersByTime(800)); + expect(events).toEqual([ + { + type: 'longpressstart', + target: el, + pointerType: 'touch', + ctrlKey: false, + metaKey: false, + shiftKey: false, + altKey: false, + x: 0, + y: 0 + }, + { + type: 'longpressend', + target: el, + pointerType: 'touch', + ctrlKey: false, + metaKey: false, + shiftKey: false, + altKey: false, + x: 0, + y: 0 + } + ]); + }); + + it('should not cancel the long press when a mouse pointer goes down elsewhere', function () { + let events = []; + let addEvent = e => events.push(e); + let res = render( + + ); + + let el = res.getByText('test'); + + fireEvent.pointerDown(el, {pointerType: 'touch', pointerId: 1}); + act(() => jest.advanceTimersByTime(100)); + fireEvent.pointerDown(document.body, {pointerType: 'mouse', pointerId: 2}); + act(() => jest.advanceTimersByTime(600)); + expect(events).toEqual([ + { + type: 'longpressstart', + target: el, + pointerType: 'touch', + ctrlKey: false, + metaKey: false, + shiftKey: false, + altKey: false, + x: 0, + y: 0 + }, + { + type: 'longpressend', + target: el, + pointerType: 'touch', + ctrlKey: false, + metaKey: false, + shiftKey: false, + altKey: false, + x: 0, + y: 0 + }, + { + type: 'longpress', + target: el, + pointerType: 'touch', + ctrlKey: false, + metaKey: false, + shiftKey: false, + altKey: false, + x: 0, + y: 0 + } + ]); + }); + + it('should not cancel a mouse long press when a second pointer goes down', function () { + let events = []; + let addEvent = e => events.push(e); + let res = render( + + ); + + let el = res.getByText('test'); + + fireEvent.pointerDown(el, {pointerType: 'mouse', pointerId: 1}); + act(() => jest.advanceTimersByTime(100)); + fireEvent.pointerDown(document.body, {pointerType: 'touch', pointerId: 2}); + act(() => jest.advanceTimersByTime(600)); + expect(events).toEqual([ + { + type: 'longpressstart', + target: el, + pointerType: 'mouse', + ctrlKey: false, + metaKey: false, + shiftKey: false, + altKey: false, + x: 0, + y: 0 + }, + { + type: 'longpressend', + target: el, + pointerType: 'mouse', + ctrlKey: false, + metaKey: false, + shiftKey: false, + altKey: false, + x: 0, + y: 0 + }, + { + type: 'longpress', + target: el, + pointerType: 'mouse', + ctrlKey: false, + metaKey: false, + shiftKey: false, + altKey: false, + x: 0, + y: 0 + } + ]); + }); + + it('should perform a long press after a previous one has completed', function () { + let events = []; + let addEvent = e => events.push(e); + let res = render( + + ); + + let el = res.getByText('test'); + + fireEvent.pointerDown(el, {pointerType: 'touch', pointerId: 1}); + act(() => jest.advanceTimersByTime(600)); + fireEvent.pointerDown(el, {pointerType: 'touch', pointerId: 2}); + act(() => jest.advanceTimersByTime(600)); + expect(events).toEqual([ + { + type: 'longpressstart', + target: el, + pointerType: 'touch', + ctrlKey: false, + metaKey: false, + shiftKey: false, + altKey: false, + x: 0, + y: 0 + }, + { + type: 'longpressend', + target: el, + pointerType: 'touch', + ctrlKey: false, + metaKey: false, + shiftKey: false, + altKey: false, + x: 0, + y: 0 + }, + { + type: 'longpress', + target: el, + pointerType: 'touch', + ctrlKey: false, + metaKey: false, + shiftKey: false, + altKey: false, + x: 0, + y: 0 + }, + { + type: 'longpressstart', + target: el, + pointerType: 'touch', + ctrlKey: false, + metaKey: false, + shiftKey: false, + altKey: false, + x: 0, + y: 0 + }, + { + type: 'longpressend', + target: el, + pointerType: 'touch', + ctrlKey: false, + metaKey: false, + shiftKey: false, + altKey: false, + x: 0, + y: 0 + }, + { + type: 'longpress', + target: el, + pointerType: 'touch', + ctrlKey: false, + metaKey: false, + shiftKey: false, + altKey: false, + x: 0, + y: 0 + } + ]); + }); + it('should cancel other press events', function () { let events = []; let addEvent = e => events.push(e);