Skip to content

fix: cancel useLongPress when a second touch begins - #10535

Open
giaBaoJS wants to merge 1 commit into
adobe:mainfrom
giaBaoJS:fix/long-press-cancel-multitouch
Open

fix: cancel useLongPress when a second touch begins#10535
giaBaoJS wants to merge 1 commit into
adobe:mainfrom
giaBaoJS:fix/long-press-cancel-multitouch

Conversation

@giaBaoJS

Copy link
Copy Markdown

Closes #5934

useLongPress schedules its timer in onPressStart and clears it only in onPressEnd. When a second finger touches the screen, usePress sees that state.isPressed is already true (usePress.ts line 559) and returns without firing any press event, so onPressEnd never runs and the timer completes. A three-finger double tap, which iOS uses for zoom, therefore opens a long press menu while the user is trying to trigger the system gesture. That is the accessibility report in this issue and in aeharding/voyager#1254.

What I wanted

Match the platform: on iOS and Android a pending long press is abandoned as soon as a second touch begins.

I made this the default rather than adding the cancelOnTouches prop the issue originally proposed, since @reidbarber's comment reads as endorsing the platform behavior, and a default means the four in-repo consumers (useSelectableItem, useMenuTrigger, usePreviewTrigger, useContextMenu) and every downstream user get the accessible behavior without each having to opt in. The issue is labelled bug even though it used the feature request template, which points the same way. If you would rather ship it opt-in, say so and I will move it behind a prop.

How it works

While a touch-initiated long press is pending, useLongPress registers a pointerdown listener on the owner window and clears the timer if another touch pointer goes down.

Two details drive the scoping:

Capture phase, not bubble. usePress calls stopPropagation() on the second pointerdown (it takes the shouldStopPropagation = true path because triggerPressStart is skipped), so a bubble-phase window listener never sees it. I checked this with a throwaway probe before writing the fix: a bubble listener saw only the first pointer, a capture listener saw only the second. The capture phase has a second useful property here. The listener is added while the initiating pointerdown is still bubbling, so the window capture phase for that event has already passed and the listener does not fire for the press that created it. That removes the need for any pointer-id bookkeeping, which is just as well since PressEvent does not expose pointerId.

Touch only, at both ends. The listener is registered only when the long press was started by a touch, and it cancels only on an incoming pointerType === 'touch'. Cancelling on any window pointerdown would be too broad: a mouse long press is untouched because the listener is never added, and a stray mouse pointerdown during a touch long press does not cancel it. Pen never starts a long press at all, since isAcceptedPointerType accepts only mouse and touch.

Scope is deliberately narrow. Only the pending long press timer is cleared; the underlying press is left alone, so lifting the finger still produces the normal press. Cancelling the press as well would mean changing usePress and would change what all four consumers do on a two-finger tap, which felt like a separate decision. Happy to do that too if you want it.

The listener goes through the existing useGlobalListeners registry, so it is torn down by the same removeAllGlobalListeners call after pointerup and on unmount, with the capture flag preserved on removal.

✅ Pull Request Checklist:

  • Included link to corresponding React Spectrum GitHub Issue.
  • Added/updated unit tests and storybook for this change (for new code or code which already has tests). Four unit tests added. There is no useLongPress story in packages/react-aria/stories/interactions, and multi-touch is not reproducible in Storybook without a device, so I did not add one.
  • Filled out test instructions.
  • Updated documentation (if it already exists for this component). One sentence added to the Features prose in useLongPress.mdx. No API change.
  • Looked at the Accessibility Practices for this feature - Aria Practices
  • I understand every change in this PR and can explain why it's there.
  • If AI-assisted, I followed our AI contribution guidance and pointed my assistant at CLAUDE.md.

📝 Test Instructions:

New tests in packages/react-aria/test/interactions/useLongPress.test.js:

  • should cancel the long press when a second touch begins reproduces the issue: pointer down, 100ms, a second pointer down, 600ms, and onLongPress does not fire. onLongPressEnd still fires when the fingers lift.
  • should not cancel the long press when a mouse pointer goes down elsewhere covers the incoming-pointerType guard.
  • should not cancel a mouse long press when a second pointer goes down covers a mouse-initiated long press.
  • should perform a long press after a previous one has completed covers a second touch arriving after a long press has already fired, so the listener left over from the first press cannot kill the next one.

Counterfactual, since passing tests on their own prove little: with only the source change reverted and the tests kept, exactly one test goes red, should cancel the long press when a second touch begins, receiving longpressend and longpress where none were expected. The other three stay green, which is what I want from them, since they assert unchanged behavior.

Numbers on this machine (node 24.13.0, yarn 4.18.0):

  • yarn jest packages/react-aria/test/interactions/useLongPress.test.js: 10 passed before, 14 passed after.
  • yarn jest packages/react-aria/test: 91 suites, 1198 passed, 3 skipped.
  • The four consumers plus their RAC components (react-aria selection/menu/tooltip/interactions, RAC Menu, Table, ListBox, GridList, Tree, Tooltip): 24 suites, 716 passed, 2 skipped.
  • yarn test: 374 of 375 suites pass, 8259 passed, 16 skipped. The one failure is DatePicker > editing > text input > should support typing into the era segment, which fails identically on a clean tree at this branch's base commit, so it is pre-existing in my environment and unrelated to this change.
  • yarn test:ssr: 60 suites, 74 passed.
  • yarn check-types: no errors in packages/react-aria. The 42 errors it reports are pre-existing and come from vitest matcher typings plus @spectrum-icons/color and @spectrum-icons/express, whose icons I did not generate locally.
  • oxfmt and oxlint clean on the changed files.

What I did not test: I have no physical touch device here, so the real three-finger double tap on iOS is unverified by me. The jsdom tests model the pointer event sequence, not the OS gesture. If you can run it on a device before merging, that is the gap.

🧢 Your Project:

Personal open source contribution, not on behalf of a company.

AI disclosure: this change was developed with AI assistance (Claude Code), pointed at CLAUDE.md and AGENTS.md. The root cause and the capture-phase behavior were verified empirically with the probe described above rather than asserted, and I have reviewed and understand every line.

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.
@giaBaoJS giaBaoJS closed this Aug 30, 2026
@giaBaoJS giaBaoJS reopened this Aug 30, 2026
altKey: false,
x: 0,
y: 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.

shouldn't longpress end have already happened by here? it's canceled on pointerDown

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.

useLongPress should be cancelled on multiple touches

2 participants