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();