fix: cancel useLongPress when a second touch begins - #10535
Open
giaBaoJS wants to merge 1 commit into
Open
Conversation
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.
snowystinger
reviewed
Sep 1, 2026
| altKey: false, | ||
| x: 0, | ||
| y: 0 | ||
| } |
Member
There was a problem hiding this comment.
shouldn't longpress end have already happened by here? it's canceled on pointerDown
This file contains hidden or bidirectional Unicode text that may be interpreted or compiled differently than what appears below. To review, open the file in an editor that reveals hidden Unicode characters.
Learn more about bidirectional Unicode characters
Sign up for free
to join this conversation on GitHub.
Already have an account?
Sign in to comment
Add this suggestion to a batch that can be applied as a single commit.This suggestion is invalid because no changes were made to the code.Suggestions cannot be applied while the pull request is closed.Suggestions cannot be applied while viewing a subset of changes.Only one suggestion per line can be applied in a batch.Add this suggestion to a batch that can be applied as a single commit.Applying suggestions on deleted lines is not supported.You must change the existing code in this line in order to create a valid suggestion.Outdated suggestions cannot be applied.This suggestion has been applied or marked resolved.Suggestions cannot be applied from pending reviews.Suggestions cannot be applied on multi-line comments.Suggestions cannot be applied while the pull request is queued to merge.Suggestion cannot be applied right now. Please check back later.
Closes #5934
useLongPressschedules its timer inonPressStartand clears it only inonPressEnd. When a second finger touches the screen,usePresssees thatstate.isPressedis already true (usePress.tsline 559) and returns without firing any press event, soonPressEndnever 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
cancelOnTouchesprop 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 labelledbugeven 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,
useLongPressregisters apointerdownlistener on the owner window and clears the timer if another touch pointer goes down.Two details drive the scoping:
Capture phase, not bubble.
usePresscallsstopPropagation()on the secondpointerdown(it takes theshouldStopPropagation = truepath becausetriggerPressStartis 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 initiatingpointerdownis 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 sincePressEventdoes not exposepointerId.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 windowpointerdownwould be too broad: a mouse long press is untouched because the listener is never added, and a stray mousepointerdownduring a touch long press does not cancel it. Pen never starts a long press at all, sinceisAcceptedPointerTypeaccepts 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
usePressand 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
useGlobalListenersregistry, so it is torn down by the sameremoveAllGlobalListenerscall afterpointerupand on unmount, with the capture flag preserved on removal.✅ Pull Request Checklist:
useLongPressstory inpackages/react-aria/stories/interactions, and multi-touch is not reproducible in Storybook without a device, so I did not add one.useLongPress.mdx. No API change.📝 Test Instructions:
New tests in
packages/react-aria/test/interactions/useLongPress.test.js:should cancel the long press when a second touch beginsreproduces the issue: pointer down, 100ms, a second pointer down, 600ms, andonLongPressdoes not fire.onLongPressEndstill fires when the fingers lift.should not cancel the long press when a mouse pointer goes down elsewherecovers the incoming-pointerType guard.should not cancel a mouse long press when a second pointer goes downcovers a mouse-initiated long press.should perform a long press after a previous one has completedcovers 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, receivinglongpressendandlongpresswhere 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.react-ariaselection/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 isDatePicker > 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 inpackages/react-aria. The 42 errors it reports are pre-existing and come from vitest matcher typings plus@spectrum-icons/colorand@spectrum-icons/express, whose icons I did not generate locally.oxfmtandoxlintclean 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.mdandAGENTS.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.