Skip to content

fix(menu press): cancel only the lifted finger's touchend - #11164

Draft
cixzhang wants to merge 6 commits into
mainfrom
l0/click-guard-touch-identifier
Draft

cixzhang wants to merge 6 commits into
mainfrom
l0/click-guard-touch-identifier

Conversation

@cixzhang

@cixzhang cixzhang commented Nov 11, 2026 •

Copy link
Copy Markdown
Contributor

User impact

When a finger picks a row in a DropdownMenu, ContextMenu, or a menu-press surface (Selector, TabList, TopNav, SideNav, Breadcrumbs), the menu cancels that finger's touchend so the lift does not also click whatever lies under it. Today the click guard cancels the next touchend it sees, whichever finger it belongs to.

After this change, the click guard cancels only the picking finger's touchend once the menu has named the touch, so another finger's touchend passes. The picking finger's own lift is still cancelled. A touchend without changedTouches, or a lift whose touch the menu could not name (its touchstart lists several new touches and none carries the pointer's id, or it came before the pointerdown), keeps the old rule: the next touchend is cancelled.

Problem and solution fit

  • Why does it happen? armClickGuard's touch path cancels the first touchend it sees, without checking which touch ended.
  • Why does it harm the task? The guard's contract is one finger's lift. Cancelling another finger's touchend is outside it, whatever order a browser delivers.
  • Why does it matter? spec:AST-094 FR21 and module:DropdownMenu/useMenuPress FR5: the press model swallows the click of the gesture it tracked, and only that gesture's.

The menu press now reads the finger's Touch.identifier from the touchstart that follows its pointerdown, and passes it to armClickGuard. The guard cancels only a touchend that ends that touch, and stays armed for it when another finger's touchend arrives first. A touchend without changedTouches still counts as the picking finger's own, so engines that omit changedTouches keep today's behaviour.

Expected behavior and authority

  • spec:AST-094 FR21 and module:DropdownMenu/useMenuPress FR5. Specs & Decisions gives a consistency stamp; no record lines change.

Smallest restoration

  • clickGuard.ts: a touchIdentifier option and an endsTouch check.
  • useMenuPress.ts: read the touch identifier from the matching touchstart, and pass it when arming the guard.
  • A [fix] Changeset.

Evidence

  • Red first: with the test commit on main's source, three tests fail:
    • "leaves another finger's touchend alone and cancels the lifted touch's own";
    • "names the touch by its place in the touchstart when its identifier is not the pointer id";
    • "armed with the lifted touch's identifier, leaves another touch's touchend alone and stays armed for its own".
  • Green: clickGuard.test.ts and useMenuPress.test.tsx, 76 passed.

Scope

  • One defect is restored.
  • Separable contradictory or unsettled tagalongs were removed or split.
  • No new public API, visual representation, default, or interaction model is hidden under the bug-fix label.
  • Public text and artifacts contain no internal Meta context.

Testing

  • vitest run packages/core/src/utils/clickGuard.test.ts packages/core/src/hooks/useMenuPress.test.tsx: 76 passed.
  • eslint on the changed files.

Credit

The failing tests and the fix were written by vjeux; both commits keep his authorship.

(Posted by Cindy's agent on her behalf.)

@vercel

vercel Bot commented Nov 11, 2026 •

Copy link
Copy Markdown

The latest updates on your projects. Learn more about Vercel for GitHub.

Project Deployment Actions Updated
astryx Ready Ready Preview Nov 12, 2026 2:00am UTC

Request Review

@astracat-bot

astracat-bot Bot commented Nov 11, 2026 •

Copy link
Copy Markdown
Contributor

AI review status for this pull request.

Review status Updated
🟢 Reviewed (for maintainers only) Nov 12, 2026, 2:15 AM UTC

@meta-cla meta-cla Bot added the CLA Signed This label is managed by the Meta Open Source bot. label Nov 11, 2026
@github-actions

github-actions Bot commented Nov 11, 2026 •

Copy link
Copy Markdown
Contributor

PR Analysis Report

Current run: The current CI run concluded skipped. Current analysis is unavailable.


Generated by PR Enrichment workflow | View current CI run

@cixzhang

Copy link
Copy Markdown
Contributor Author

/approve-spec bec2ad3

@astracat-bot astracat-bot Bot left a comment •

Copy link
Copy Markdown
Contributor

Choose a reason for hiding this comment

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

Changes requested: one blocking finding.

  • packages/core/src/hooks/useMenuPress.ts:814 adds its own document touchstart listener, but Core routes document and window input listeners through subscribeInput in packages/core/src/utils/inputStream.ts (architecture:react-component-runtime INV16, and pnpm review:pre flags this line), so subscribe there and stop the subscription at the first touchstart and on detach, as clickGuard.ts already does for its touchend subscription.

The touch-identifier fix itself restores module:DropdownMenu/useMenuPress FR5: the new tests fail on main and pass on this branch. The inline notes on clickGuard.ts stay non-blocking: two comment wordings, and the "names no touches" wording in a comment and the Changeset, which the code applies only when changedTouches is missing. The description's authority line should cite FR5 and spec:AST-094 FR26 rather than FR10.

Automated review (maintainer link)

Comment thread packages/core/src/hooks/useMenuPress.ts Resolved
Comment thread packages/core/src/utils/clickGuard.ts Resolved
Comment thread packages/core/src/utils/clickGuard.ts Resolved
Comment thread packages/core/src/utils/clickGuard.ts Resolved
…tream

The touchstart that names the lifting finger is read through
subscribeInput, as Core's other document and window input is, and the
subscription stops at the first touchstart and on detach. The guard's
comment and the changeset say a touchend without a list of changed
touches counts as the finger's own.
@cixzhang

Copy link
Copy Markdown
Contributor Author

/approve-spec c2be3b6

@astracat-bot astracat-bot Bot left a comment

Copy link
Copy Markdown
Contributor

Choose a reason for hiding this comment

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

Approved pending CI: CI has not run on this head yet.

The touchstart read now goes through subscribeInput, which resolves the earlier blocking finding, and the touch-identifier fix restores module:DropdownMenu/useMenuPress FR5: the new tests fail on the base and pass on this branch.

Optional, non-blocking:

  • packages/core/src/utils/clickGuard.ts:33-34: keep the two original header sentences together so "otherwise" still refers to the cancelled touchend, and add the identifier sentence after them.
  • packages/core/src/utils/clickGuard.ts:163: "its click" holds only once the guard disarms; "Another finger lifted: leave its touchend." says what the code does.
  • The description's authority line could cite FR5 and spec:AST-094 FR21 and FR26 rather than FR10.

Automated review (maintainer link)

@cixzhang cixzhang added ci:run A maintainer lets this outside contributor's pull request run CI (AST-030 FR17) ci:run-a11y Run the accessibility suite on this PR; write access, next push (AST-030 FR12) labels Nov 11, 2026
@cixzhang
cixzhang marked this pull request as ready for review November 11, 2026 14:31

@astracat-bot astracat-bot Bot left a comment •

Copy link
Copy Markdown
Contributor

Choose a reason for hiding this comment

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

Changes requested: one blocking finding. The guard change itself works as described: red-first tests back it, and CI is green at this head.

  • Miss: the changeset (.changeset/menu-press-touch-identifier.md) and the description say another finger's tap was lost and the picking finger then made a stray click, but only a synthetic jsdom event order shows this (in Chromium each finger's touchend directly follows its own pointerup, no click is raised while two fingers are down, and in the order described the guard would still swallow the other finger's click), so please state the guarantee instead, for example "when a finger picks a menu row, the menu cancels only that finger's touchend, so a touchend from another finger is never cancelled", and drop the lost-tap and stray-click sentence.

Non-blocking: the comment at clickGuard.ts:163 says the other finger's click is its own, but while the guard waits it still swallows that click. The description cites spec:AST-094 FR10, while FR21 and the useMenuPress module's FR5 govern this change.

Automated review (maintainer link)

… guard does

The changeset states the guarantee (the guard cancels only the picking
finger's touchend) instead of a lost tap and a stray click that only a
synthetic event order shows. Two clickGuard comments now say what the
guard does while it waits.
@cixzhang
cixzhang marked this pull request as draft November 11, 2026 18:08
@cixzhang

Copy link
Copy Markdown
Contributor Author

/approve-spec bd97219

@astracat-bot astracat-bot Bot left a comment

Copy link
Copy Markdown
Contributor

Choose a reason for hiding this comment

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

Changes requested: one blocking finding, a wording fix in the description. The guard change itself works as described: the new tests fail on the base and pass on this head, and a real finger tap in Chromium still cancels the pick's own touchend.

  • Newly live check: the description says "A touchend that lists no touches still counts as the picking finger's own", but endsTouch (packages/core/src/utils/clickGuard.ts, lines 186-192) counts only a missing changedTouches as the pick's own and treats an empty list as another finger's, so please write "A touchend without changedTouches" as the Changeset and the JSDoc already do, and use the same words in the test title at clickGuard.test.ts:309, or make an empty list count as the touch's own in code.

Approval also waits for CI: CI has not run on this head because the pull request is a draft, and it runs once the pull request is marked ready for review.

Claims and files checked
  • Changeset, "the click guard cancels only that finger's touchend, so a touchend from another finger is never cancelled": verified (clickGuard.ts:162-173, useMenuPress.test.tsx:667, clickGuard.test.ts:300).
  • Changeset, the listed surfaces (DropdownMenu, ContextMenu, Selector, TabList, TopNav, SideNav, Breadcrumbs): verified, each uses the menu press hook.
  • Changeset, "reads the finger's identifier from the touchstart that follows its pointerdown": verified (useMenuPress.ts:732-747).
  • Changeset, "a touchend without changedTouches still counts as the picking finger's": verified (clickGuard.ts:188-190, clickGuard.test.ts:309).
  • Description, today the guard cancels the next touchend whichever finger it belongs to: verified (base clickGuard.ts:145-164).
  • Description, the picking finger's own lift is still cancelled: verified (useMenuPress.test.tsx:667; Chromium tap).
  • Description, spec:AST-094 FR21 and module:DropdownMenu/useMenuPress FR5 govern: verified.
  • Description, the identifier is passed to armClickGuard, which stays armed when another finger's touchend comes first: verified (useMenuPress.ts:620-637, clickGuard.test.ts:300-307).
  • Description, "a touchend that lists no touches still counts as the picking finger's own": false (clickGuard.ts:186-192).
  • Description, no record lines change: verified (no record in the diff).
  • Description, red first with three named failing tests, then 76 passing: verified.
  • Description, eslint on the changed files: verified.
  • Description, no new public API, visual representation, default or interaction model: verified (no API rows; identical frames).
  • Description, the failing tests and the fix keep vjeux's authorship: verified.
  • Files: .changeset/menu-press-touch-identifier.md clear; packages/core/src/utils/clickGuard.ts clear; packages/core/src/utils/clickGuard.test.ts test title wording (above); packages/core/src/hooks/useMenuPress.ts clear; packages/core/src/hooks/useMenuPress.test.tsx clear.

Automated review (maintainer link)

@cixzhang

Copy link
Copy Markdown
Contributor Author

/approve-spec 8720b0d

@astracat-bot astracat-bot Bot left a comment •

Copy link
Copy Markdown
Contributor

Choose a reason for hiding this comment

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

Approval waits for CI: the required checks were skipped on this head because the pull request is a draft.

No blocking finding: the guard change does what the description and the Changeset say, and the new tests cover both another finger's touchend and the pick's own.

Next step: mark the pull request ready for review so CI runs on this head.

Claims and files checked
  • Claim, Changeset package and bump ('@astryxdesign/core': patch): verified; every changed source file is in packages/core.
  • Claim, DropdownMenu, ContextMenu, Selector, TabList, TopNav, SideNav and Breadcrumbs pick through the menu press model: verified (DropdownMenu.tsx:1477, ContextMenu.tsx:771, Selector.tsx:1753, TabMenu.tsx:318, TopNavMenu.tsx:468, SideNavHeading.tsx:445, BreadcrumbItem.tsx:590).
  • Claim, the guard cancels only the picking finger's touchend, another finger's is not cancelled, and the pick's own still is: verified (clickGuard.ts:161-173; useMenuPress.test.tsx:667-676).
  • Claim, the menu reads the identifier from the touchstart after its pointerdown and passes it to armClickGuard: verified (useMenuPress.ts:732-746, :620-637).
  • Claim, the guard stays armed for the pick's touch when another finger's touchend arrives first: verified (clickGuard.ts:162-165; clickGuard.test.ts:300-307).
  • Claim, a touchend without changedTouches still counts as the picking finger's own: verified (clickGuard.ts:186-190; clickGuard.test.ts:309-314).
  • Claim, today the guard cancels the next touchend it sees, whichever finger it belongs to: verified at the base (clickGuard.ts:145-164).
  • Claim, spec:AST-094 FR21 and module:DropdownMenu/useMenuPress FR5 govern the change and no record lines change: verified; no record file is in the diff.
  • Claim, smallest restoration (the option and endsTouch, the identifier read and passed, a [fix] Changeset): verified.
  • Claim, red first, the three named tests fail on the base source: verified from the base guard, which has no identifier check; the fourth new test passes there.
  • Claim, 76 tests pass and eslint is clean on the changed files: the two test files hold 76 cases; not yet shown at this head, since CI has not run.
  • Claim, scope (one defect, no tag-along, no new public API, default or interaction model, no internal context in public text): verified.
  • Claim, the test and fix commits keep their original author: verified from the commit history.
  • File, .changeset/menu-press-touch-identifier.md: clear.
  • File, packages/core/src/utils/clickGuard.ts: clear.
  • File, packages/core/src/hooks/useMenuPress.ts: clear.
  • File, packages/core/src/utils/clickGuard.test.ts: clear.
  • File, packages/core/src/hooks/useMenuPress.test.tsx: clear.

Automated review (maintainer link)

@astracat-bot astracat-bot Bot left a comment

Copy link
Copy Markdown
Contributor

Choose a reason for hiding this comment

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

Approved pending CI: CI has not run on this head yet, because the pull request is still a draft.

The guard change does what the description and the Changeset say: only the picking finger's touchend is cancelled, another finger's passes, and the new tests cover both another finger's touchend and the pick's own. There is no blocking finding.

Next step: mark the pull request ready for review so CI runs on this head.

Claims and files checked
  • Claim, patch bump for @astryxdesign/core: verified; every changed source file is in packages/core.
  • Claim, DropdownMenu, ContextMenu, Selector, TabList, TopNav, SideNav and Breadcrumbs pick through the menu press model: verified (DropdownMenu.tsx:1477, ContextMenu.tsx:771, Selector.tsx:1753, TabMenu.tsx:318, TopNavMenu.tsx:468, SideNavHeading.tsx:445, BreadcrumbItem.tsx:590).
  • Claim, today the guard cancels the next touchend it sees, whichever finger it belongs to: verified at the base (clickGuard.ts:145-163 there).
  • Claim, the guard now cancels only the picking finger's touchend, another finger's is not cancelled, and the pick's own still is: verified (clickGuard.ts:161-174; useMenuPress.test.tsx:667-676).
  • Claim, the menu reads the identifier from the touchstart after its pointerdown and passes it when arming the guard: verified (useMenuPress.ts:732-746, :620-637).
  • Claim, the guard stays armed for the pick's touch when another finger's touchend arrives first: verified (clickGuard.ts:162-165; clickGuard.test.ts:300-307).
  • Claim, a touchend without changedTouches still counts as the picking finger's own: verified (clickGuard.ts:186-192; clickGuard.test.ts:309-314).
  • Claim, the touch-presentation and menu-press records' click rules govern the change, the spec check passes, and no record line changes: verified; no record file is in the diff.
  • Claim, smallest restoration (the option and its check, the identifier read and passed, a [fix] Changeset): verified.
  • Claim, red first, the three named tests fail on the base source: verified from the base guard, which has no identifier check; the fourth new test passes there.
  • Claim, 76 tests pass and eslint is clean on the changed files: the two test files hold 76 cases; the run itself is pending CI.
  • Claim, scope (one defect, no tag-along, no new public API, default or interaction model, no internal context in public text): verified.
  • Claim, the test and fix commits keep their original author: verified from the commit history.
  • File, .changeset/menu-press-touch-identifier.md: clear.
  • File, packages/core/src/utils/clickGuard.ts: clear.
  • File, packages/core/src/hooks/useMenuPress.ts: clear.
  • File, packages/core/src/utils/clickGuard.test.ts: clear.
  • File, packages/core/src/hooks/useMenuPress.test.tsx: clear.

Automated review (maintainer link)

@cixzhang
cixzhang marked this pull request as ready for review November 12, 2026 00:27

@astracat-bot astracat-bot Bot left a comment

Copy link
Copy Markdown
Contributor

Choose a reason for hiding this comment

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

Changes requested: one blocking finding, a wording fix in the Changeset and the description. The guard change itself works as described once the menu has named the picking touch, and the new tests pass in CI.

  • Miss: the Changeset and the description say another finger's touchend is never cancelled, but when the menu cannot name the picking touch (its touchstart lists several new touches and none carries the pointer's id, or it arrives before the pointerdown), touchOfPointer returns null and the guard still cancels the next touchend of any finger (useMenuPress.ts:212, clickGuard.ts:162), so the text should say the old rule applies then.

Suggested wording is in the inline comment on the Changeset; the two comment-wording notes are optional.

Claims and files checked
  • Claim, patch bump for @astryxdesign/core: verified; every changed source file is in packages/core.
  • Claim, DropdownMenu, ContextMenu, Selector, TabList, TopNav, SideNav and Breadcrumbs pick through the menu press model: verified (DropdownMenu.tsx:1477, ContextMenu.tsx:771, Selector.tsx:1753, TabMenu.tsx:318, TopNavMenu.tsx:468, SideNavHeading.tsx:445, BreadcrumbItem.tsx:590).
  • Claim, today the guard cancels the next touchend it sees, whichever finger it belongs to: verified at the base (clickGuard.ts:145-163 there).
  • Claim, the guard now cancels only the picking finger's touchend, so another finger's is never cancelled: false when the menu could not name the touch (useMenuPress.ts:212, clickGuard.ts:162).
  • Claim, the picking finger's own lift is still cancelled: verified (clickGuard.ts:166-173; useMenuPress.test.tsx:667-676).
  • Claim, the menu reads the identifier from the touchstart after its pointerdown and passes it when arming the guard: verified (useMenuPress.ts:732-746, :620-637).
  • Claim, the guard stays armed for the pick's touch when another finger's touchend arrives first: verified (clickGuard.ts:162-165; clickGuard.test.ts:300-307).
  • Claim, a touchend without changedTouches still counts as the picking finger's own: verified (clickGuard.ts:186-192; clickGuard.test.ts:309-314).
  • Claim, the touch-presentation and menu-press click rules govern the change and no record line changes: verified; no record file is in the diff.
  • Claim, smallest restoration (the option and its check, the identifier read and passed, a [fix] Changeset): verified.
  • Claim, red first, the three named tests fail on the base source: verified by reading the base guard, which has no identifier check; the fourth new test passes there.
  • Claim, 76 tests pass in the two test files and eslint is clean: the two files hold 76 cases at this head, and both files and lint pass in CI.
  • Claim, scope (one defect, no tag-along, no new public API, default or interaction model, no internal context in public text): verified.
  • Claim, both commits keep their original author: verified from the commit history.
  • File, .changeset/menu-press-touch-identifier.md: the blocking finding.
  • File, packages/core/src/utils/clickGuard.ts: clear; one optional comment-wording note.
  • File, packages/core/src/hooks/useMenuPress.ts: clear; one optional comment-wording note.
  • File, packages/core/src/utils/clickGuard.test.ts: clear.
  • File, packages/core/src/hooks/useMenuPress.test.tsx: clear.

Automated review (maintainer link)

Comment thread packages/core/src/utils/clickGuard.ts Resolved
Comment thread packages/core/src/hooks/useMenuPress.ts Resolved
… touchend passes

Once the menu names the picking touch, another finger's touchend passes. A
touchend without changedTouches, or a lift whose touch the menu could not
name, keeps the old rule. Two comments reworded.
@cixzhang
cixzhang marked this pull request as draft November 12, 2026 01:53
@cixzhang

Copy link
Copy Markdown
Contributor Author

/approve-spec f598827

@astracat-bot astracat-bot Bot left a comment

Copy link
Copy Markdown
Contributor

Choose a reason for hiding this comment

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

Approved pending CI: CI has not run on this head yet, because the pull request is still a draft.

The guard change does what the Changeset and the description now say: once the menu has named the picking touch, only that touch's touchend is cancelled, and a touch it could not name keeps the next-touchend rule. The earlier wording finding is fixed, and the new cases cover the named-touch path. One optional comment-wording suggestion is inline.

Claims and files checked
  • Claim, patch bump for @astryxdesign/core: verified; every changed source file is in packages/core.
  • Claim, DropdownMenu, ContextMenu, Selector, TabList, TopNav, SideNav and Breadcrumbs pick through the menu press model: verified (DropdownMenu.tsx:1477, ContextMenu.tsx:771, Selector.tsx:1753, TabMenu.tsx:318, TopNavMenu.tsx:468, SideNavHeading.tsx:445, BreadcrumbItem.tsx:590).
  • Claim, today the guard cancels the next touchend it sees, whichever finger it belongs to: verified at the base (clickGuard.ts:145-163 there).
  • Claim, once the menu has named the touch, the guard cancels only that finger's touchend and another finger's passes: verified (clickGuard.ts:163-166; useMenuPress.test.tsx:667-684).
  • Claim, the picking finger's own lift is still cancelled: verified (clickGuard.ts:167-174; useMenuPress.test.tsx:667-675).
  • Claim, the menu names the touch from the touchstart after its pointerdown and passes it when arming the guard: verified (useMenuPress.ts:732-746, :620-637).
  • Claim, the guard stays armed for the picking touch when another finger's touchend arrives first: verified (clickGuard.ts:163-166; clickGuard.test.ts:300-307).
  • Claim, a touchend without changedTouches counts as the picking finger's own: verified (clickGuard.ts:187-193; clickGuard.test.ts:309-314).
  • Claim, a lift whose touch the menu could not name (several new touches with none carrying the pointer's id, or a touchstart before the pointerdown) keeps the next-touchend rule: verified (useMenuPress.ts:205-212; no identifier reaches the guard at :623 and :636).
  • Claim, the touch-presentation and menu-press click rules govern the change and no record line changes: verified; no record file is in the diff.
  • Claim, smallest restoration (the option and its check, the identifier read and passed, a [fix] Changeset): verified.
  • Claim, red first, the three named tests fail on the base source: verified by reading the base guard, which has no identifier check; the fourth new test passes there.
  • Claim, 76 tests in the two test files pass and eslint is clean: the two files hold 76 cases; they and lint passed in CI on the previous revision, whose code differs from this head only in comments.
  • Claim, scope (one defect, no tag-along, no new public API, default or interaction model, no internal context in public text): verified.
  • Claim, both original commits keep their author: verified from the commit history.
  • File, .changeset/menu-press-touch-identifier.md: clear.
  • File, packages/core/src/utils/clickGuard.ts: clear; one optional comment-wording suggestion.
  • File, packages/core/src/hooks/useMenuPress.ts: clear.
  • File, packages/core/src/utils/clickGuard.test.ts: clear.
  • File, packages/core/src/hooks/useMenuPress.test.tsx: clear.

Automated review (maintainer link)

Comment on lines +34 to +36
// when a drill-in has put another row under the finger. When the menu names
// the touch by its identifier, another finger's `touchend` passes; a touch it
// could not name keeps the old rule, and the next `touchend` is cancelled.

Copy link
Copy Markdown
Contributor

Choose a reason for hiding this comment

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

Non-blocking (comment wording): the new sentence runs to 29 words, carries two topics and ends in the passive. Suggest:

// when a drill-in has put another row under the finger. When the menu names
// the touch, the guard cancels only that touch's `touchend`. Without a name,
// it cancels the next `touchend`.

This branch was successfully deployed

1 active deployment
Preview — f598827a Deployed Nov 12, 2026 by vercel[bot]
Sign up for free to join this conversation on GitHub. Already have an account? Sign in to comment

Labels

ci:run A maintainer lets this outside contributor's pull request run CI (AST-030 FR17) ci:run-a11y Run the accessibility suite on this PR; write access, next push (AST-030 FR12) CLA Signed This label is managed by the Meta Open Source bot.

Projects

None yet

Development

Successfully merging this pull request may close these issues.

2 participants