From bee72c2a734b4916a8cf730b975cc267e4227b08 Mon Sep 17 00:00:00 2001 From: park gunhyung <162693556+rjsgud49@users.noreply.github.com> Date: Sun, 25 Oct 2026 11:23:43 +0900 Subject: [PATCH 1/2] fix(useLongPress): ignore emulated mouse events after a touch --- .changeset/use-long-press-emulated-mouse.md | 5 ++ .../hooks/useLongPress/useLongPress.spec.tsx | 50 +++++++++++++++++++ .../src/hooks/useLongPress/useLongPress.ts | 25 +++++++++- 3 files changed, 79 insertions(+), 1 deletion(-) create mode 100644 .changeset/use-long-press-emulated-mouse.md diff --git a/.changeset/use-long-press-emulated-mouse.md b/.changeset/use-long-press-emulated-mouse.md new file mode 100644 index 00000000..d976ace5 --- /dev/null +++ b/.changeset/use-long-press-emulated-mouse.md @@ -0,0 +1,5 @@ +--- +'react-simplikit': patch +--- + +`useLongPress` no longer calls `onClick` twice for a tap on touch devices. After `touchend`, browsers fire emulated `mousedown`/`mouseup` events on the same element, and the hook handled them as a second, mouse press. That also called `onClick` after `onLongPressEnd` when the long press came from touch. Mouse presses that start within 1 second of a touch event are now ignored. diff --git a/packages/react-simplikit/src/hooks/useLongPress/useLongPress.spec.tsx b/packages/react-simplikit/src/hooks/useLongPress/useLongPress.spec.tsx index 70968e52..cce86672 100644 --- a/packages/react-simplikit/src/hooks/useLongPress/useLongPress.spec.tsx +++ b/packages/react-simplikit/src/hooks/useLongPress/useLongPress.spec.tsx @@ -389,6 +389,56 @@ describe('useLongPress', () => { expect(onLongPress).toHaveBeenCalledTimes(1); }); + // Browsers follow a tap with emulated mousedown/mouseup events on the same element. + it('calls onClick once for a tap followed by emulated mouse events', () => { + const callbacks = createCallbacks(); + const { getByRole } = render(); + const button = getByRole('button'); + + fireEvent.touchStart(button, { touches: [{ clientX: 0, clientY: 0 }] }); + fireEvent.touchEnd(button); + fireEvent.mouseDown(button, { clientX: 0, clientY: 0 }); + fireEvent.mouseUp(button); + + expect(callbacks.onClick).toHaveBeenCalledTimes(1); + expect(callbacks.onLongPress).not.toHaveBeenCalled(); + }); + + it('does not call onClick for emulated mouse events after a long press', async () => { + const callbacks = createCallbacks(); + const { getByRole } = render(); + const button = getByRole('button'); + + fireEvent.touchStart(button, { touches: [{ clientX: 0, clientY: 0 }] }); + await act(async () => { + vi.advanceTimersByTime(1500); + }); + fireEvent.touchEnd(button); + fireEvent.mouseDown(button, { clientX: 0, clientY: 0 }); + fireEvent.mouseUp(button); + + expect(callbacks.onLongPress).toHaveBeenCalledTimes(1); + expect(callbacks.onLongPressEnd).toHaveBeenCalledTimes(1); + expect(callbacks.onClick).not.toHaveBeenCalled(); + }); + + it('handles a mouse press again once the touch has ended a while ago', async () => { + const callbacks = createCallbacks(); + const { getByRole } = render(); + const button = getByRole('button'); + + fireEvent.touchStart(button, { touches: [{ clientX: 0, clientY: 0 }] }); + fireEvent.touchEnd(button); + await act(async () => { + vi.advanceTimersByTime(1000); + }); + + fireEvent.mouseDown(button, { clientX: 0, clientY: 0 }); + fireEvent.mouseUp(button); + + expect(callbacks.onClick).toHaveBeenCalledTimes(2); + }); + it.each(['mouse', 'touch'] as const)('cancels a pending %s press on unmount', async input => { const callbacks = createCallbacks(); const { getByRole, unmount } = render(); diff --git a/packages/react-simplikit/src/hooks/useLongPress/useLongPress.ts b/packages/react-simplikit/src/hooks/useLongPress/useLongPress.ts index ba728804..b41a2050 100644 --- a/packages/react-simplikit/src/hooks/useLongPress/useLongPress.ts +++ b/packages/react-simplikit/src/hooks/useLongPress/useLongPress.ts @@ -1,6 +1,11 @@ import { type MouseEvent, type TouchEvent, useCallback, useEffect, useRef } from 'react'; import { usePreservedCallback } from '../usePreservedCallback/index.ts'; + +// After a tap, browsers fire emulated `mousedown`/`mouseup` events on the same element. +// Mouse presses that start this soon after a touch event are treated as part of that touch. +const EMULATED_MOUSE_EVENT_WINDOW_MS = 1000; + type Handler = (event: MouseEvent | TouchEvent) => void; export type UseLongPressOptions = { @@ -67,6 +72,7 @@ export function useLongPress( const timeoutRef = useRef(null); const isLongPressActiveRef = useRef(false); const initialPositionRef = useRef({ x: 0, y: 0 }); + const lastTouchTimeRef = useRef(null); const preservedOnLongPress = usePreservedCallback(onLongPress); const preservedOnClick = usePreservedCallback(onClick || (() => {})); @@ -75,7 +81,7 @@ export function useLongPress( const hasThreshold = moveThreshold?.x !== undefined || moveThreshold?.y !== undefined; const getClientPosition = useCallback((event: MouseEvent | TouchEvent) => { - if ('touches' in event.nativeEvent) { + if (isTouchEvent(event)) { const touch = event.nativeEvent.touches[0]; return { x: touch.clientX, y: touch.clientY }; } @@ -115,6 +121,15 @@ export function useLongPress( const handlePressStart = useCallback( (event: MouseEvent | TouchEvent) => { + if (isTouchEvent(event)) { + lastTouchTimeRef.current = Date.now(); + } else if ( + lastTouchTimeRef.current !== null && + Date.now() - lastTouchTimeRef.current < EMULATED_MOUSE_EVENT_WINDOW_MS + ) { + return; + } + cancelLongPress(); const position = getClientPosition(event); @@ -131,6 +146,10 @@ export function useLongPress( const handlePressEnd = useCallback( (event: MouseEvent | TouchEvent) => { + if (isTouchEvent(event)) { + lastTouchTimeRef.current = Date.now(); + } + if (isLongPressActiveRef.current) { preservedOnLongPressEnd(event); } else if (timeoutRef.current !== null) { @@ -161,3 +180,7 @@ export function useLongPress( ...(hasThreshold ? { onTouchMove: handlePressMove, onMouseMove: handlePressMove } : {}), }; } + +function isTouchEvent(event: MouseEvent | TouchEvent): event is TouchEvent { + return 'touches' in event.nativeEvent; +} From ef1e11a30e67218e75ef202cf5d30b26262e2f76 Mon Sep 17 00:00:00 2001 From: Cursor Agent Date: Wed, 28 Oct 2026 00:15:38 +0000 Subject: [PATCH 2/2] test(useLongPress): advance timers before asserting onLongPress in emulated mouse test Co-authored-by: park gun hyung --- .../src/hooks/useLongPress/useLongPress.spec.tsx | 3 +++ 1 file changed, 3 insertions(+) diff --git a/packages/react-simplikit/src/hooks/useLongPress/useLongPress.spec.tsx b/packages/react-simplikit/src/hooks/useLongPress/useLongPress.spec.tsx index cce86672..ede2e7a6 100644 --- a/packages/react-simplikit/src/hooks/useLongPress/useLongPress.spec.tsx +++ b/packages/react-simplikit/src/hooks/useLongPress/useLongPress.spec.tsx @@ -399,6 +399,9 @@ describe('useLongPress', () => { fireEvent.touchEnd(button); fireEvent.mouseDown(button, { clientX: 0, clientY: 0 }); fireEvent.mouseUp(button); + act(() => { + vi.advanceTimersByTime(600); + }); expect(callbacks.onClick).toHaveBeenCalledTimes(1); expect(callbacks.onLongPress).not.toHaveBeenCalled();