fix: cancel useLongPress when a second touch begins - #10535
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.
| altKey: false, | ||
| x: 0, | ||
| y: 0 | ||
| } |
There was a problem hiding this comment.
shouldn't longpress end have already happened by here? it's canceled on pointerDown
There was a problem hiding this comment.
No, and the pre-fix behavior is what makes it look like it should.
onLongPressEnd is emitted from one place only: usePress's onPressEnd (useLongPress.ts:150-160). In the uninterrupted path it arrives before pointerup because the timer callback dispatches a synthetic pointercancel (useLongPress.ts:95), which runs usePress's cancel (usePress.ts:309-315) and fires press end. That is why should perform a long press sees longpressstart, longpressend, longpress.
The cancel path does not dispatch pointercancel. It only clears the timer (useLongPress.ts:126-129), so the press is still live and nothing fires press end. The second pointerdown itself produces no event at all, because usePress.ts:560 skips the entire block when state.isPressed is already true.
I instrumented the element with both usePress and useLongPress handlers to confirm the order:
pointerDown 1 -> longpressstart, pressstart
pointerDown 2 -> (nothing)
+600ms -> (nothing)
pointerUp 2 -> (nothing)
pointerUp 1 -> longpressend, pressend, press
So at that line only longpressstart has fired, and longpressend arrives on lift, which the second assertion at the end of the test covers. Restoring the pre-fix source and keeping the tests turns exactly that assertion red, receiving longpressstart, longpressend, longpress, which is the sequence you have in mind.
One decision this exposes, though: since the press is left untouched, lifting the finger still fires onPress, so a three-finger tap on a menu item cancels the long press but still activates the item. Dispatching pointercancel on the second touch instead of just clearing the timer would both give you longpressend at that point and suppress the press. I kept the scope narrow deliberately, but I am happy to make that change if you prefer it.
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.