Skip to content

feat(useLongPress): listen to pointer events - #581

Open
rjsgud49 wants to merge 2 commits into
toss:v1-basefrom
rjsgud49:feat/use-long-press-pointer-events
Open

rjsgud49 wants to merge 2 commits into
toss:v1-basefrom
rjsgud49:feat/use-long-press-pointer-events

Conversation

@rjsgud49

@rjsgud49 rjsgud49 commented Oct 28, 2026 •

Copy link
Copy Markdown
Contributor

Overview

useLongPress listens to both touch and mouse events. After a tap, browsers fire emulated mousedown/mouseup, so one tap is handled as two presses. On main, #576 ignores mouse presses within 1 second of a touch. That commit is not on v1-base, so this branch fixes the same double handling by listening to pointer events instead.

pointerdown/pointerup/pointercancel give one event stream per press, so the emulated mouse events never reach the hook. pointercancel also cancels the press when the browser takes the pointer to scroll. delayMs is unchanged.

This is a breaking change. The returned handlers are onPointerDown, onPointerUp, onPointerLeave, and onPointerCancel, plus onPointerMove when moveThreshold is set. onMouseDown, onMouseUp, onMouseLeave, onTouchStart, onTouchEnd, onMouseMove, and onTouchMove are no longer returned. Callbacks receive React.PointerEvent instead of MouseEvent | TouchEvent.

Repro (Chromium via Playwright, hasTouch/isMobile, hook callbacks from useLongPress(onLongPress, { onClick, onLongPressEnd })). The v1-base column is the mouse-and-touch hook, which does not have #576:

v1-base this PR
tap click, click click
touch held 700 ms longPress, longPressEnd, click longPress, longPressEnd
mouse click click click
touch dragged into a scroll long press or click can still complete no callback (pointercancel)

The event order Chromium dispatches for the tap is pointerdown, pointerup, touchend, mousedown, mouseup, click. For a scroll it is pointerdown, pointercancel, pointerleave, and the scroller moves.

Fix: the hook tracks one pointerId and ignores a non-primary pointer and any mouse button other than the primary button. pointerup ends that press (onClick before the delay, onLongPressEnd after). pointercancel and pointerleave cancel it without calling onClick; if the long press already fired they call onLongPressEnd once.

Tests added:

  • tap followed by emulated mousedown/mouseup calls onClick once
  • touch long press followed by those mouse events does not call onClick
  • a mouse pointer press immediately after a touch pointer press is handled
  • pointercancel before the delay calls nothing, and after a long press calls onLongPressEnd once
  • non-primary pointers, non-left mouse buttons, a second pointer, and pointerId 0
  • movement threshold, pointer leave, unmount, and rerender
  • jsdom 25 PointerEvent polyfill in the spec file

Added a major changeset, matching the other breaking changes already on v1-base.

Closes #580

Checklist

  • Did you write the test code?
  • Have you run yarn run fix to format and lint the code and docs?
  • Have you run yarn run test:coverage to make sure there is no uncovered line? (100%; test:compiled, test:docs, and test:skill also pass)
  • Did you write the JSDoc?

@changeset-bot

changeset-bot Bot commented Oct 28, 2026 •

Copy link
Copy Markdown

🦋 Changeset detected

Latest commit: eae5371

The changes in this PR will be included in the next version bump.

This PR includes changesets to release 1 package
Name Type
react-simplikit Major

Not sure what this means? Click here to learn what changesets are.

Click here if you're a maintainer who wants to add another changeset to this PR

@codecov-commenter

codecov-commenter commented Oct 28, 2026 •

Copy link
Copy Markdown

Codecov Report

✅ All modified and coverable lines are covered by tests.
✅ Project coverage is 100.00%. Comparing base (e2057e9) to head (eae5371).

Additional details and impacted files

Impacted file tree graph

@@            Coverage Diff            @@
##           v1-base      #581   +/-   ##
=========================================
  Coverage   100.00%   100.00%           
=========================================
  Files           78        78           
  Lines         2563      2567    +4     
  Branches       820       827    +7     
=========================================
+ Hits          2563      2567    +4     
🚀 New features to boost your workflow:
  • ❄️ Test Analytics: Detect flaky tests, report on failures, and find test suite problems.
  • 📦 JS Bundle Analysis: Save yourself from yourself by tracking and limiting bundle sizes in JS merges.

@hyesungoh hyesungoh left a comment

Copy link
Copy Markdown
Member

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

it makes major bc. so please change base branch of this pr to v1-base

please check issue #580 and double check it

@cursor
cursor Bot force-pushed the feat/use-long-press-pointer-events branch from fb9ffe3 to b0f8b5c Compare October 28, 2026 06:00
@rjsgud49
rjsgud49 changed the base branch from main to v1-base October 28, 2026 06:00
@rjsgud49

Copy link
Copy Markdown
Contributor Author

Thanks for catching that! I switched the base to v1-base, rebased on top of it, and changed the changeset to major. Also went through #580 again and everything there is covered (delayMs stays as is). One small addition: pointerleave cancels the press too, so a leftover pointerId can't block the next press.

@hyesungoh

Copy link
Copy Markdown
Member

@rjsgud49 please rebase 🙏

@cursor
cursor Bot force-pushed the feat/use-long-press-pointer-events branch from b0f8b5c to f435c93 Compare October 28, 2026 06:47
@rjsgud49

Copy link
Copy Markdown
Contributor Author

Rebased onto the latest v1-base 👍 Dropped the 1s emulated-mouse window from #576 since pointer events cover that case now, and removed its unreleased changeset.

Keep delayMs from v1-base. A press is one pointer stream, so emulated
mouse events after a tap no longer call onClick again, and pointercancel
cancels the press when the browser scrolls.
@cursor
cursor Bot force-pushed the feat/use-long-press-pointer-events branch from f435c93 to abd64b2 Compare October 28, 2026 06:53

@hyesungoh hyesungoh left a comment

Copy link
Copy Markdown
Member

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Thanks! This matches #580 well, and the tests are thorough.

One fix: per the Pointer Events spec ("Chorded button interactions"), pointerup fires only when the last button is released. Other button changes fire pointermove. So for left down → right down → left up → right up, the only pointerup has button: 2. The release check skips it, the press never ends, and the next click calls onLongPressEnd instead of onClick. Suggestions below.

Comment on lines 110 to 115
// A mouse chord can release the right button while the left button is still down.
// Touch and pen releases are not mouse buttons; ending those on pointerup is the whole press.
if (outcome === 'release' && isNonPrimaryMouseButton(event)) {
return;
}

Copy link
Copy Markdown
Member

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Suggested change
// A mouse chord can release the right button while the left button is still down.
// Touch and pen releases are not mouse buttons; ending those on pointerup is the whole press.
if (outcome === 'release' && isNonPrimaryMouseButton(event)) {
return;
}

This case can't happen: a chord never fires pointerup until the last button is up.

Comment on lines 210 to 213

function isNonPrimaryMouseButton(event: PointerEvent<HTMLElement>) {
return event.pointerType === 'mouse' && event.button !== 0;
}

Copy link
Copy Markdown
Member

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Suggested change
function isNonPrimaryMouseButton(event: PointerEvent<HTMLElement>) {
return event.pointerType === 'mouse' && event.button !== 0;
}

No longer used.

Comment on lines +472 to +486
it('does not end a mouse press when another mouse button is released', async () => {
const callbacks = createCallbacks();
render(<PressTarget {...callbacks} />);
const button = screen.getByTestId('test-button');

fireEvent.pointerDown(button, pointerInit({ button: 0 }));
await advance(500);
fireEvent.pointerUp(button, pointerInit({ button: 2 }));

expect(callbacks.onLongPressEnd).not.toHaveBeenCalled();

fireEvent.pointerUp(button, pointerInit({ button: 0 }));
expect(callbacks.onLongPressEnd).toHaveBeenCalledTimes(1);
expect(callbacks.onClick).not.toHaveBeenCalled();
});

Copy link
Copy Markdown
Member

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Suggested change
it('does not end a mouse press when another mouse button is released', async () => {
const callbacks = createCallbacks();
render(<PressTarget {...callbacks} />);
const button = screen.getByTestId('test-button');
fireEvent.pointerDown(button, pointerInit({ button: 0 }));
await advance(500);
fireEvent.pointerUp(button, pointerInit({ button: 2 }));
expect(callbacks.onLongPressEnd).not.toHaveBeenCalled();
fireEvent.pointerUp(button, pointerInit({ button: 0 }));
expect(callbacks.onLongPressEnd).toHaveBeenCalledTimes(1);
expect(callbacks.onClick).not.toHaveBeenCalled();
});
it('ends a mouse press on the single pointerup after a button chord', async () => {
const callbacks = createCallbacks();
render(<PressTarget {...callbacks} />);
const button = screen.getByTestId('test-button');
fireEvent.pointerDown(button, pointerInit({ button: 0, buttons: 1 }));
await advance(500);
// Chord changes fire pointermove, not pointerdown or pointerup.
fireEvent.pointerMove(button, pointerInit({ button: 2, buttons: 3 }));
fireEvent.pointerMove(button, pointerInit({ button: 0, buttons: 2 }));
fireEvent.pointerUp(button, pointerInit({ button: 2, buttons: 0 }));
expect(callbacks.onLongPressEnd).toHaveBeenCalledTimes(1);
fireEvent.pointerDown(button, pointerInit({ button: 0, buttons: 1 }));
fireEvent.pointerUp(button, pointerInit({ button: 0, buttons: 0 }));
expect(callbacks.onClick).toHaveBeenCalledTimes(1);
});

The old test sent a pointerup that browsers don't send. This one follows the spec order.

@rjsgud49

Copy link
Copy Markdown
Contributor Author

Thanks, you are right about the chord order. Applied all three suggestions in eae5371: removed the non-primary button check on release and the unused helper, and replaced the test with the spec order. The new test fails on the previous commit and passes now. test:coverage, test:compiled, typecheck and lint pass locally.

This branch has not been deployed

No deployments
Sign up for free to join this conversation on GitHub. Already have an account? Sign in to comment

Labels

None yet

Projects

None yet

Development

Successfully merging this pull request may close these issues.

Move useLongPress to Pointer Events

3 participants