Skip to content

feat(ContextMenu): open from a held finger on iOS 26's timeline - #8125

Draft
vjeux wants to merge 28 commits into
meta:mainfrom
vjeux:uikit/c20-contextmenu-press
Draft

vjeux wants to merge 28 commits into
meta:mainfrom
vjeux:uikit/c20-contextmenu-press

Conversation

@vjeux

@vjeux vjeux commented Oct 24, 2026 •

Copy link
Copy Markdown
Contributor

Stack: #8125 → #9816 → #8133 → #8201
Merge group: #8125, #9816, #8133

Behavior module: ContextMenu's touch hold.

Supersedes #8065.

User need

A held finger should get UIKit's menu at about 0.74 s and no click at the lift. Main opens it at ~500 ms, then clicks.

Problem and solution fit

  • One touch model where the primary pointer is coarse. Under a fine pointer, or with presentation="popover", a finger keeps today's 500 ms long press.
  • The hold: the commit at 400 ms claims the finger, so its lift sends no click and the page does not scroll. The menu, mounted closed at the commit, shows at 735 ms or at a later lift. A drift past 11.5 px, a second finger, an early lift or a scroll opens nothing.
  • No page lock. A touch outside the open menu closes it when it begins to pan (10 pt) or lifts.
  • A touch open lights no row.
  • The hold's moments stay internal (spec:AST-094 FR4, FR45); FR12 records them.

Current specification

spec:AST-094 FR13, FR25, FR32, FR33 and design:reduced-motion govern; createContextMenu takes the hold later (spec:AST-107 FR8).

Public delta and caller burden

An omitted presentation means the touch model. No API change. Eager import, main to #8133, min/gzip: ContextMenu +119.2/+38.3 kB, DropdownMenu +0.6/+0.2 kB, accepted by the spec owners.

Design and alternatives

The watch judges a mouse or pen at its bubbling mousedown, so main's Effect goes (architecture:react-component-runtime INV16). Fine pointers (spec:AST-017 FR59): unchanged. In an iframe or Picture-in-Picture window, a mouse press closes the menu in its own document only (family:overlay-dismissal FR8; Testing).

The late-timer wait stays in the press, not the arena. The spec owners' condition for this change is 'no behavior change for other recognizers'. An arena-wide wait would change List's touch reorder hold and the other long presses.

Evidence

Android (spec:AST-094 DEC-27): the three emulated Chrome takes at this head follow. iPhone, iOS 26.2 Simulator: hold, menu, tap outside. Real-device iOS: the owners' check, not public.

Scope

  • One capability is added.
  • Unrelated fixes, cleanup, and policy changes were removed or split.
  • Consumer docs, focused tests, and migration evidence ship with the feature.
  • Public text and artifacts contain no internal Meta context.

Writing rubric results

Over component:ContextMenu: the pan gap goes; FR12 records the hold's moments; PR1 names the mount, listeners and queries; a focus gap is recorded. spec:AST-094's FR16 gap line states the 50 ms shift behind a block. Owner-gated.

  • R1 narration: none.
  • R3 evidence: FR12's basis names the decision and the measurement.
  • R2 research, R4 rejected alternatives, R5 change references, R6 open questions: none.

Testing

Merged main e09956b at a9188e6 ("Merge main"). git merge-tree against that main left one conflict, .changeset/outside-press-pan-watch.md. Main's v0.7.12 release (#11029) removed that changeset after shipping it, and this pull request had edited it. The merge keeps the removal and adds .changeset/contextmenu-outside-press-watch.md with this pull request's note. Otherwise this pull request's own diff is byte-identical to 20dc83f's, in the same 19 files. Main's merge changes six of this pull request's files, so the Android takes were taken again at a9188e6. Local checks on the merged tree pass: core typecheck, strict eslint on the changed files, check:changesets and check:knowledge. The ContextMenu, Layer, DropdownMenu and box-hold units pass (1,164), and so does ContextMenuLongPress in WebKit under the CI profile (3) and in Chromium (3). Its a11y spec passes too (3).

At a5a0e12, a browser case pins a finger that lifts before 400 ms as a plain tap: one click and no menu. It fails by name when the lift reaches the page at 400 ms or later. The outside-press changeset now files the change as a fix. The product code is a9188e6's. ContextMenuLongPress passes in Chromium (4, three runs) and in WebKit (3). The tap case skips in WebKit, which makes a click only from its own tap gesture.

At cae3644, a drift past 11.5 px between the commit and the menu's moment gets no menu and no click, in every engine (spec:AST-094 FR21). Red first at 3c39a05, which only lifts the case's Chromium fixme: Chromium showed the menu at 1592.8 ms. On real phones the earlier head already cancels this drift, since moves arrive in time. The fix keeps the cancel when moves reach the page late. The gesture arena holds a late input's time at its clock, so such a move looked as if it came at the menu's moment. The arena now also keeps each input's own time, as the touch's inputTime, and each coalesced sample's, as inputSamples.

The press follows the finger through each sample at its own time. A drift past 11.5 px in a sample from before the moment ends the hold, as separate moves do. At the moment, the menu waits only when the page is behind its input. A recent move from before the moment, or the moment's own timer, ran over two frames late. The wait runs on the arena's clock, and a sample from the moment on ends it with the menu. A menu timer that runs more than a frame late ran behind a blocked page. The menu then waits up to 50 ms from when the page runs again, so moves queued behind the block decide first. The press reads the page's time from the arena's injected clock, which is performance.now() in a browser. So this head's timing equals 45bfb44's, and the owners' real-phone still-finger check carries.

In a same-origin iframe or a Picture-in-Picture window, the gesture host times touches on the page's clock, so a short tap opens the menu. That bug is on main too, for every timed gesture. #11155 fixes it on its own, and this pull request will add its ContextMenu iframe case once main has #11155.

Red first at 3b76a8c: a finger inside 11.5 px at 735 ms that crosses it just after got no menu at a4e0404. That was 3 of 3 in Chromium, and a6253f3 showed the menu 3 of 3. Red first at 46bc0da: at 88cc0d8, a late move whose coalesced samples cross 11.5 px before the moment still showed the menu. There, a 3 px move at 700 ms that reached the page on time got the menu at 786–788 ms. That was 3 of 3 in Chromium and in WebKit. Red first at d743922: under the test harness's clock, eight press units failed at 45bfb44, which read performance.now(). Red first at 889aded: the page is blocked past the moment, until 760, 800 or 900 ms. At cad79ec, a finger that crossed 11.5 px at 650 ms still got the menu.

No other code changes: inputTime and inputSamples are read only in contextMenuPress.ts, set in GestureArena.ts, and pinned by two arena unit cases. The arena's pageTime() is read only by the press. GestureTouch and GestureArena are not exported. pnpm api:report --check flags two reports stale from main's own changes. Regenerated, they hold no GestureTouch, inputTime or inputSamples line. ContextMenu adds no listener, window timer or clock of its own: the touch layer equals a6253f3's. The other recognizers' tests pass unchanged: the gesture units (880 in 38 files) and the core unit suite (21,504). The gesture browser specs pass in Chromium (76 passed, 4 skipped). In WebKit under the CI profile, 68 passed and 12 skipped. Presented hit testing passes 6 in each.

Measured in Chromium's phone profile, one worker, three runs each: a still finger gets the menu at 744 ms, as before. A finger creeping 6 px inside the tolerance gets it at 744–745 ms with 20 or 30 Hz moves. Those reach the page in about 25 ms. With the test driver's 60 Hz moves, which it delivers late, it gets it at 1,399–1,415 ms. Real phones' still-finger timing is the spec owners' check.

Local checks at this head, one worker:

  • ContextMenuLongPress.interaction: Chromium 7 passed; WebKit under the CI profile 6 passed, 1 skipped;
  • the drift, after-moment, drift-within-tolerance, 735 ms, on-time and plain-tap cases five times in Chromium (30 passed);
  • the 735 ms and on-time cases five times in WebKit (15 passed);
  • ContextMenuLongPress.a11y (3 passed), ContextMenuAudit.a11y (18 passed) and ContextMenuCursorAnchor.a11y.browser (3 passed) in Chromium;
  • ContextMenuCursorAnchor.a11y.browser in WebKit (3 passed);
  • the core typecheck, strict eslint on ContextMenu/ and gestures/, check:changesets and check:knowledge.

Main's ContextMenu opens through the same drags in Chromium. Its menu shows at 512 ms for 14–20 px drifts that cross 11.5 px before its 500 ms long press.

Replayed onto main cd748d0 after #9814's squash: 2bcc49a is one commit that carries this pull request's own diff at 6d7fceb (same patch-id; its message lists the folded commits). Five commits follow. Red at 812d3a4: the 735 ms proof wants the show inside the timer task that decides it (a WebKit CI run had shown the menu at 758 ms, past its 20 ms). Fix at 0776b40: the open commits with flushSync, under a reasoned @eslint-react/dom-no-flush-sync disable. Red at e8f5ca1: the menu mounts, closed, at the commit. Fix at 0fbd309: the layer hook's internal prepare() and releasePrepared() (the public useLayer does not change), and component:ContextMenu PR1 names the commit-time mount. Then 32e75c7: the second-document test. ba77ca8 merges main c8b4cba, for the harness's FrameStarvationError. df6fd04: PR1 names the outside-press watch's pointer and mousedown listeners and the coarse-pointer query. abb70e1: the 735 ms proof logs the page's frames (below). b8a9055 merges main 62c7802, which fixes NavigationStack's busy-thread proof. e66ff1d merges main 92784f6: main's #10160 dropped FR8's disabled-row gap sentence, and the FR table keeps main's FR8 text. 24c03c1: the proof's starvation rule takes WebKit's catch-up frame (below). 339bf7e and 3d78dae, from review: PR1 names each presentation's query subscriptions, and the record's current-state impact names the touch open's focus gap. Then 20dc83f, a maintainer's merge of main b87846c: its tree is the merge of the two, and this pull request's own diff is unchanged.

Android exit timing (contextmenu-default): main none/64 ms, head none/72 ms (6 interleaved rounds; main max gap 101 ms)

The menu at 735 ms, page-side, with the same code one pull request up (0c316d9): the show runs inside the deciding timer task in 96 of 96 takes (0 of 96 before), 1.7 ms after the decision in Chromium, 6.5 ms at 4× CPU and 3.0 ms in the WebKit CI container (8.8, 28.5 and 17 ms before). The commit-time mount's task takes 6.8 ms at normal speed, and the growth drops no compositor frame. At 3d78dae alone, the 735 ms and commit-mount proofs pass 48 of 48 in WebKit under the CI profile and 16 of 16 in Chromium. A WebKit CI run had shown the menu at 890 ms, inside its deciding task, so that task had started late. The proof now tells why: a deciding task that starts within a frame of touch + 735 keeps the 20 ms bound. A later start fails with FrameStarvationError and its numbers when a frame gap over 50 ms held the due time and the task ran within two frames of the gap's end. After a long task, WebKit draws a frame and runs the delayed timer about 20 ms later; at e66ff1d an 80 ms block failed bare there for that reason (90 ms late). Any other late start fails bare. Arms, each in both engines: an 80 ms block across the due time (94a1b24) fails named, 8 of 8; a 40 ms read inside the deciding task (25f99e6) fails bare on the bound, 4 of 4; the menu committed 50 ms after its decision (c0bdd6f) fails bare, 4 of 4, the task having started late while frames ran.

Fine pointers (spec:AST-017 FR59, family:overlay-dismissal FR8), before and after: a ContextMenu portaled into a same-origin iframe. Before, with main's ContextMenu.tsx under 3d78dae's test (c973d19): a mouse press in the page closes the menu, and the case fails. After, at 3d78dae: a press outside the menu in its own document closes it, and one in the page does not. In the page, the mouse and pen cases pass as before.

Size, each import's eager closure (min / gzip):

Import main cd748d0 #8125 #9816 #8133
ContextMenu JS 324,701 / 103,750 B 406,513 / 128,560 B 443,299 / 141,779 B 443,907 / 142,024 B
DropdownMenu JS 283,557 / 91,519 B 283,817 / 91,601 B 283,817 / 91,601 B 284,132 / 91,713 B

DropdownMenu's 82 B gzip here come from the layer hook's two members.

Local checks at an earlier head, 3d78dae: check:knowledge and prettier on the record. At 24c03c1, before its two record-only commits: core typecheck; strict eslint on the spec; ContextMenuLongPress in Chromium (3 passed) and in WebKit under the CI profile (3 passed). At e66ff1d, its parent: the ContextMenu, Layer and DropdownMenu units (1,099 passed); CI=true pnpm lint (0 errors); check:knowledge. At the stack's top on the same main: core typecheck and CI=true pnpm lint at all three heads (0 errors); the ui suite (22,774 passed; DropdownMenuLazySheet's keyboard-open case failed once under load and passes alone on main and here); test:node (17,446 passed; the one failure is the WebKit leg budget, see #8133). Earlier versions of this description said CI=true pnpm lint, but those runs were pnpm lint, which reports @eslint-react/dom-no-flush-sync only as a warning; that claim is corrected here.

The Android emulator run at an earlier head, 3d78dae (Chrome with Gboard; six takes: normal, 4× CPU and animations removed, twice each): the finger-still-down menu paints 752–837 ms after landing (735 ms plus paint; the 4× takes ran under host load), opens at the finger 6 of 6, an early lift and a pan open nothing 6 of 6, and an outside press closes it 6 of 6. Earlier, at abb70e1, the same six takes passed every event claim; since then this pull request merges main twice (62c7802, 92784f6) and changes no ContextMenu source, only the 735 ms proof's starvation rule (24c03c1) and two record sentences. Earlier, at b8b8d88 (Chrome with Gboard): the finger-down menu's first frame paints 745–868 ms after the landing (735 ms plus the emulator's frames); an early lift and a pan open nothing; an outside press closes it for touch, mouse and pen.

Earlier, at 543d05f, the same own diff ran in the Chrome-with-Gboard Android emulator: six takes at normal speed, a 4× CPU slowdown and animations removed. The menu opens at the finger. An early lift and a pan open nothing. An outside press closes it for touch, mouse and pen.

Earlier, restacked on main 4598223, which has #10275's guarded presentation read: that head merged #9814's head 57f4e92, ContextMenu's usePresentation read goes through that guard, and use-table-sortable-state-hook-usage.test.tsx passes 3 of 3 here. The long-press spec's note keeps to 20 words. Local checks at 260feb3: core typecheck and that test. At the stack's top on the same base: the Layer, ContextMenu, Indicator, utils, motion, gestures, DropdownMenu and hooks units (4,997 passed); ContextMenuLongPress in Chromium (22 passed, 1 skipped) and in WebKit under the CI profile (19 passed, 4 skipped); the three ContextMenu a11y specs (27 passed).

Earlier, restacked for the chain landing: that head merged #9814's head on main 79cc4ac and pins boxHold.test.tsx's CheckboxList-row case to presentation="popover", since the touch model opens at 735 ms, past that case's 600 ms hold. Local checks at that head, 543d05f: core typecheck; the Layer, ContextMenu, Indicator, utils, motion and gestures units (3,550 passed); eslint on the changed files; check:knowledge; check:changesets.

On #9814, at the merge worker's head, which merges main 3731ec3. FR5 merges row-wise with #9915's: the row is main's, minus the pan gap this pull request closes.

The review at 279fe10, addressed:

  • The long-press a11y contract's positive control opens TouchLongPressPopover, which now ships here.
  • The Chinese usage text and the touch story name the coarse primary pointer, as the English and the changeset do.
  • One outside-press path (INV16): watchOutsidePress judges a mouse or a pen at its bubbling mousedown, as main's Effect did, so ContextMenu's own Effect goes. Red first: 5 of the 29 outsidePress.test.ts cases fail before the change; the two new guards pass either way. The shadow-root ContextMenu case now fires a mouse press's pointerdown before its mousedown, as a browser does.
  • The watch's root takes a shadow root from another window, and the constants table's header no longer claims a lazy load.
  • The FR33 notes (the finger handoff, the menu beside the lifted trigger) stay FR5's recorded gaps; feat(ContextMenu): where a coarse pointer's held finger opens the menu (presentation unset, or adaptive where it stays a popover), the same finger slides onto an item and its lift selects it #8133 hands the finger over.

Main's #9988 dismissal count: the one watch counts every outside press. Main's #9008: the record's FR8 rows and compatibility row merge row by row with this pull request's.

Local checks at c4f8964: core typecheck and the Layer, ContextMenu, DropdownMenu, utils and motion units (2,865 passed). At #9816's head on the same base, 4efcbc4: CI=true pnpm lint (0 errors), core typecheck, the ContextMenu, Layer, DropdownMenu, motion, hooks, gestures, SegmentedControl and utils units (4,809 passed), check:knowledge, check:changesets, and the Chromium interaction specs for ContextMenu and Layer (34 passed, 1 skipped). On the previous base (main 9e559e9), the stack's top with this content (a4a1a46, apart from two comments) passed the full ui (21,276) and node (16,672) suites, the Chromium interaction specs for ContextMenu, Layer, DropdownMenu, Dialog and Drawer (60 passed, 1 skipped), the same specs in WebKit (51 passed, 10 skipped), and their a11y contracts (330 passed). Size (INV6), gzip, the import with its transitive modules: ContextMenu 85,043 → 110,004 B, DropdownMenu unchanged (79,793 B), CSS unchanged. ContextMenu's own touch code is about 2.4 KB of it for the whole stack (see #8133); the rest is core's shared gesture host, arena and long-press recognizer, which a page carries once for every touch component.

Earlier local checks at 1af10a0, on #9814 at ed150e2 (main f10bc91): core typecheck; the ContextMenu, Layer, motion, hooks, gestures and DropdownMenu units with the press-coverage guard (3,408 passed); strict eslint and prettier on the changed files; check:knowledge; check:changesets; the comment-length check; and pnpm review:pre (no findings). Browser: ContextMenuLongPress.interaction.browser.spec.ts (the 735 ms menu, and the outside pan that closes it while the page scrolls) in Chromium and WebKit. Red first: the 735 ms menu, the lift after the commit, an early lift, a second finger, a drift past the tolerance on one axis, the claimed finger's click and scroll, the outside pan and tap, the popover and fine-pointer long press, a touch open's focus, triggerAs swaps, consumer touch handlers, the commit haptic, and the recognizer's states each fail before the change. The timeline's other browser proofs come with #9816, held back for size. The Android emulator run at 653a5cd (Chrome; default and Remove animations), the unsplit touch model this stack rebuilds, passes: the commit lands at 399–460 ms, and the menu opens at 734–746 ms at the finger. A pan gives no menu, and the menu stays open after the lift. The full gates run before this leaves draft. It changes gesture feel, so it also waits for a human check on its preview.

Test names against main: none dropped. Main's five "a menu that was never opened" tests stay exactly as main has them. Six main tests are renamed where the touch model changed what they check: "opens on touch long-press" is "opens for a finger still down at 735 ms (iOS 26.2), not a millisecond before"; "composes consumer touch handlers with long-press detection" is "passes touch events through to the consumer's own handlers" and "still opens at 735 ms beside a consumer onTouchStart, which runs (#7167)"; "clears a pending long-press when a consumer prevents default on touch end" is "opens nothing for an early lift whose consumer onTouchEnd prevents default (#7167)"; "cancels the long-press when the finger moves past the threshold" is "cancels when the finger drifts past the tolerance on one axis (11.5 px)" and the popover "a drift past 10 px" case; the compatibility-mousedown case is "outside the touch model, a tap outside closes the menu through its compatibility mousedown, and a scroll that starts outside leaves it open"; and "presses that send no mousedown leave the menu open, as before" is "a mouse press that sends no mousedown leaves the menu open, as before".

Review notes

Follow-ups after merge, from non-blocking review notes: the touch layer's header reads as if any second finger cancels, where only one before the commit does; and the menu's focus after a touch open, now a known gap in the record, needs an indicator under forced colors.

@vercel

vercel Bot commented Oct 24, 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 11, 2026 1:46am UTC

Request Review

@astracat-bot

astracat-bot Bot commented Oct 24, 2026 •

Copy link
Copy Markdown
Contributor

AI review status for this pull request.

Review status Updated
🟡 Reviewed (for maintainers only) Nov 11, 2026, 2:27 AM UTC

@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.

This pull request is on hold for an engineering owner's spec decision. ContextMenu's new touch timeline (growth from 150 ms, commit at 400 ms, menu at 735 ms), the trigger growing while its menu is up, the touch scroll lock and the no-haptic choice for iOS Safari rest only on the draft ContextMenu record and the unapproved spec:AST-094. An engineering owner can record the decision by commenting /approve-spec <full head SHA> on this pull request (the author can do so as an engineering owner), or say which decision should change. Whatever that decision is, these changes are still needed:

  • Land the stacked predecessors first, or retarget onto the last of them, so that this diff is the 1,840-line ContextMenu commit rather than 24,862 authored lines.
  • Regenerate the search close CLI golden, which the new ContextMenu guidance pushes from 44 to 45 matches, so that the required test check passes.
  • Re-attach the press recognizer when the trigger node changes, because a triggerAs switch currently leaves it on the detached element (useGestureRecognizer.ts:71-79, ContextMenu.tsx:882).
  • Move the setIsTouchOpen(false) and release() calls from the Effect at ContextMenu.tsx:744-753 into the close handlers, and restore the two consumer-handler regression cases from #7167.
  • Add the ## Writing rubric results for the new AST-094 record to the description.

[Automated review]

@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 is on hold until an engineering owner decides the touch direction. The ContextMenu touch timeline, the growing trigger, the touch scroll lock and the no-haptic choice for iOS Safari rest only on the draft ContextMenu record and the unapproved spec:AST-094, so an owner needs to approve or reject them and the AST-094 requirements behind them. Any engineering owner can record that decision by commenting /approve-spec <full head SHA> on this pull request, and the author can do so as an engineering owner. Whatever the decision, these fixes are still needed:

  • Land or retarget the predecessor pull requests so this one carries only the ContextMenu change (24,866 authored lines today) and lint can finish.
  • Re-attach the press recognizer when triggerAs changes, and clear the touch-opened state in the close handlers instead of an Effect.
  • Restore the two #7167 touch-handler regression cases on the new timeline, and keep a caller's touch-action from triggerXstyle.
  • Remove the gestures README section for the missing touchPresentation module, and fix AST-094's citation of the nonexistent AST-090.

[Automated review]

Comment thread packages/core/src/gestures/README.md Resolved
@github-actions

github-actions Bot commented Oct 24, 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

@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 is on hold until an engineering owner decides the ContextMenu touch direction, which rests only on the draft ContextMenu record and the unapproved spec:AST-094: the iOS timeline, the growing trigger, the touch scroll lock and no haptic in iOS Safari, the changes to the 500 ms press on other touch screens, and the AST-094 requirements behind them. Any engineering owner can record that decision by commenting /approve-spec <full head SHA> on this pull request, and the author can do so as an engineering owner. Whatever the decision, these fixes are still needed:

  • Land or retarget the predecessor pull requests so this one carries only the ContextMenu change (26,052 authored lines today).
  • Keep the released (max-width: 768px) and (pointer: coarse) query for presentation="adaptive", which the Selector record's DEC-1 says it keeps, or move the new compact query to its own pull request with a DEC-1 amendment.
  • Re-attach the press recognizer when triggerAs changes, and clear the touch-opened state in the close handlers instead of an Effect.
  • Let a caller's triggerXstyle touch-action win, and restore the two #7167 regression cases on the new timeline.
  • Fix AST-094's citation of the nonexistent AST-090, and add the ## Writing rubric results section the new record needs.

[Automated review]

@cixzhang

Copy link
Copy Markdown
Contributor

Posted by Cindy's agent on her behalf.

Parts of this PR re-implement behavior that already exists, or is being specified, as a shared part of core. Converging keeps one implementation per behavior:

This is about where the behavior lives, not about the PR's intent.

@vjeux

vjeux commented Oct 24, 2026

Copy link
Copy Markdown
Contributor Author

Agreed: one implementation.

  • Writing direction: the GestureHost line belongs to the gesture host foundation, so it moves to the host's pull request, which reads isRtlElement.
  • Reduced motion: the press feedback asks the animator (animator.reducedMotion()); the local check and reducedMotion: 'keep' are gone.
  • Press feedback: the trigger becomes a pressable surface of utils/pressFeedback.ts, and its growth starts, holds and springs back on that controller's one press machine. A context-menu press profile carrying the measured onset, dated from the touch's timestamp, goes to the controller's owner; until it lands, the growth starts at the controller's own onset.
  • Long press: the hold runs on useLongPress's detector, with the measured delay and movement tolerance passed as its parameters, and useLongPress gains a second stage for the menu's moment instead of a second detector. Its 500 ms default stays for every other caller.

@vjeux
vjeux force-pushed the uikit/c20-contextmenu-press branch from d01580f to 7825a41 Compare October 25, 2026 01:37
@github-actions github-actions Bot added the needs:design-review Affects visuals — Design should review label Oct 25, 2026
@vjeux

vjeux commented Oct 25, 2026

Copy link
Copy Markdown
Contributor Author

Converged in 7825a41:

  • Press feedback: the trigger is a pressable surface of utils/pressFeedback.ts; the growth starts at the controller's "on" and springs back when its press ends. The context-menu profile (the measured onset dated from the touch, no release flash) is proposed to the controller's owner and not in yet.
  • Long press: useLongPress's detector runs the hold with the measured 400 ms delay and 11.5 px tolerance, and its new stages option opens the menu at 735 ms, so there is no second detector.
  • Reduced motion: animator.reducedMotion(); the local check and 'keep' are gone.
  • One touch model: everything keys on a coarse primary pointer (useTouchPresentation()), with no media keys in these files; the touch layer has one import site, ContextMenu/ContextMenuTouch.ts.

@vjeux

vjeux commented Oct 25, 2026

Copy link
Copy Markdown
Contributor Author

Addressed in 7825a41:

@vjeux

vjeux commented Nov 10, 2026

Copy link
Copy Markdown
Contributor Author

The description's Android emulator takes are at this head, 20dc83f. Its Testing lines that named 3d78dae and 543d05f now say those were earlier heads.

@astracat-bot

astracat-bot Bot commented Nov 10, 2026

Copy link
Copy Markdown
Contributor

In reply to @vjeux

I'll run a full review to re-check this with your note.

@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 on one owner's review.

ContextMenu's held-finger timeline matches the touch-presentations spec, the ContextMenu record edits and the second-document mouse dismissal carry an engineering owner's approval for this head, every CI check passes, and the description now reports the Android emulator run at this head in all three profiles.

  • @humbertovirtudes's changes-requested review of 2026-11-08 is still open on GitHub, and only that owner can clear it: each of its three blocking items looks addressed at this head, so it needs a re-review or a dismissal from them.

Non-blocking, from earlier threads: the header of motion/uikit/contextMenu.ts still describes a press feedback and scales that ship in #9816, and .changeset/outside-press-pan-watch.md does not mention pressTypes or that ContextMenu's mouse dismissal now follows the menu's own document. This pull request is the bottom of its stack, so it can land first once approved.

[Automated review]

@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 item.

ContextMenu's held-finger timeline matches the touch-presentation spec, the second-document mouse dismissal restores the overlay-dismissal rule, and the ContextMenu record edits carry the spec-owner approval at this head. What blocks is evidence for this head:

  • The Android emulator takes (Chrome with Gboard at normal speed, at 4× CPU and with animations removed) are from the previous head, and this merge of main changed six of this pull request's files (ContextMenu.tsx, Layer/outsidePress.ts and Layer/useLayer.tsx among them), so spec:AST-094 DEC-31 does not carry them and DEC-27 needs all three retaken at this head.

@humbertovirtudes's changes-requested review also stays in force until they re-review or dismiss it. CI has not run on this draft head. A few non-blocking wording notes (the new outside-press changeset, two stale comments) are in the review.

[Automated review]

A long press that fails before its commit at 400 ms ends as a plain tap:
the touch model suppresses the click only once a gesture takes the
touch. The browser case holds a finger 250 ms, past core's press onset,
checks that the lift reached the page before 400 ms, and reads one
click and no menu.

The outside-press changeset files the change as a fix: a menu in an
iframe or a Picture-in-Picture window no longer closes on a press in the
page.
@cixzhang

Copy link
Copy Markdown
Contributor

/approve-spec a5a0e12

@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 item. ContextMenu's held-finger timeline, the second-document mouse dismissal and the ContextMenu record edits are settled at this head, and an engineering owner approved the record; what blocks is head-bound evidence:

  • The Android emulator takes (Chrome with Gboard at normal speed, at 4× CPU and with animations removed) are from the previous head, and spec:AST-094 DEC-31 carries earlier takes only to a head that only merges main, so DEC-27 needs all three taken again at this head.

@humbertovirtudes's changes-requested review also stays in force until they re-review or dismiss it, and a few non-blocking wording notes are in the review.

[Automated review]

A finger that rests past the commit and then drifts past 11.5 px
before the menu's moment should get no menu and no click. WebKit
cancels it. Chromium opens the menu at about 736 ms, so the case is a
fixme there until a follow-up fixes it.

@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: two items. ContextMenu's held-finger timeline, the second-document mouse dismissal and the ContextMenu record edits are settled at this head; what blocks:

  • Miss: in Chromium a finger that drifts more than 11.5 px after the 400 ms commit still gets the menu at 735 ms (the new case is fixme there and fails with the fixme lifted), which breaks the measured context-menu tolerance spec:AST-094 FR12 and FR17 require and the changeset's "drifts more than 11.5 px on either axis before the menu opens, gets no menu", so please fix it in this pull request and drop the fixme.
  • The Android emulator takes (Chrome with Gboard at normal speed, at 4× CPU and with animations removed) are from an earlier head, and spec:AST-094 DEC-31 carries them only to a head that only merges main, so DEC-27 needs all three taken again at the head that carries the fix.

@humbertovirtudes's changes-requested review also stays in force until they re-review or dismiss it, and a few non-blocking wording notes are in the review.

[Automated review]

The case for a finger that drifts past 11.5 px after the commit, before
the menu's moment, now runs in Chromium too.
Under load, a held finger's moves can reach the page after the menu's
moment, though they came before it, and the gesture arena's clock has
then passed that moment. A finger that drifted past 11.5 px after the
commit still got the menu. The touch layer now reports each pointermove
of the held finger with its own time. At its moment the menu waits
while such moves still come, so a drift in them ends the hold.

@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: three items. The Chromium late-drift fix works, and the timeline, the second-document dismissal and the ContextMenu record edits stay settled at this head; what blocks:

  • A finger still inside the 11.5 px tolerance at 735 ms gets no menu when its next move, stamped after 735 ms, crosses the tolerance, because touchesMoved cancels on any drift while the new wait runs (contextMenuPress.ts:141-153), which breaks the measured rule that spec:AST-094 FR12 and FR16 require and component:ContextMenu FR12 states (in Chromium phone emulation such a finger got the menu at 736 ms at the previous head and gets none now), so please cancel only for a move stamped before the moment and restamp the unit case whose "late" move comes 10 ms after it.
  • ContextMenuTouchLayer.tsx:143-181 gives every trigger its own pointerdown capture listener and window pointermove, pointerup and pointercancel subscriptions for its whole life, plus a copy of the gesture host's eventTime, where the gesture host already reads each move's time before the arena clamps it (architecture:react-component-runtime INV16), so please carry that time through the gesture host to touchesMoved and drop the touch layer's listeners.
  • The Android emulator takes (Chrome with Gboard at normal speed, at 4× CPU and with animations removed) are from an earlier head, and spec:AST-094 DEC-31 carries them only to a head that only merges main, so DEC-27 needs all three taken again at the head that carries these fixes.

@humbertovirtudes's changes-requested review also stays in force until they re-review or dismiss it, and a few non-blocking notes are in the review.

[Automated review]

A finger still inside 11.5 px at 735 ms that crosses it just after
keeps its menu: the tolerance holds until the menu shows.
The gesture arena holds a late input's time at its clock, so a move
that reached the page after the menu's moment looked as if it came at
that moment. The arena now also keeps the time the input came with, as
the touch's inputTime, and no other recognizer reads it. The press
reads it: while the menu waits at its moment, a drift in a move from
before the moment ends the hold, and a move from the moment on shows
the menu. ContextMenu's own pointer listeners and clock copy are gone.

@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: two items. The late-move fix works for separate moves, the touch layer's own listeners are gone, and the timeline, the second-document dismissal and the ContextMenu record edits stay settled at this head; what blocks:

  • A late pointermove that coalesces several samples is judged by its last sample's time, so a finger that crossed 11.5 px at a sample stamped before 735 ms still gets the menu when the batch ends after 735 ms (contextMenuPress.ts:143-153), while the same samples delivered as separate moves end the hold, which breaks the measured rule spec:AST-094 FR12 and FR16 require; please judge each coalesced sample by its own timestamp while the menu waits (the arena needs to keep those times, as inputTime does for the last sample) and add a unit case with a coalesced history.
  • The Android emulator takes (Chrome with Gboard at normal speed, with animations removed and in a slow-device profile) are from an earlier head, and spec:AST-094 DEC-31 carries them only to a head that only merges main, so DEC-27 needs all three taken again at the head that carries this fix.

@humbertovirtudes's changes-requested review also stays in force until they re-review or dismiss it, and a few non-blocking notes are in the review.

[Automated review]

A late move whose coalesced samples cross 11.5 px before the menu's
moment ends the hold, as the same samples as separate moves do. A
finger that shifts slightly just before 735 ms, with its move on time,
gets the menu at 735 ms. A late timer after a recent move still waits.
The test harness's finger moves can carry coalesced samples.
The gesture arena now keeps each coalesced sample of a move at the
time it came with, as the touch's inputSamples, and no other
recognizer reads them. The press follows the finger through each of
them: a drift past 11.5 px before the menu's moment ends the hold, and
a sample from the moment on shows a waiting menu. The menu waits at
its moment only when the page is behind its input: a recent move from
before the moment, or the moment's timer, ran more than two frames
late. The wait runs on the arena's clock, like the hold's deadlines.
An on-time finger gets the menu at 735 ms.

@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: two items. The coalesced-sample fix works while the menu waits, and the held-finger timeline, the second-document dismissal and the ContextMenu record edits stay settled at this head; what blocks:

  • Miss: after a main-thread block of more than 50 ms over the menu's moment, the menu shows at 735 ms before a late coalesced move is judged, so a finger that crossed 11.5 px at 650 ms still gets the menu (contextMenuPress.ts:253-263) while the same samples delivered as separate moves end the hold, which breaks spec:AST-094 FR16 and FR12; please let the samples stamped before 735 ms decide first (wait for queued input when the moment's timer runs late, or have the arena deliver a batch's earlier samples before it fires a due timer) and add a unit case with a block and a coalesced history.
  • The Android emulator takes (Chrome with Gboard at normal speed, with animations removed and in a slow-device profile) are from an earlier head, and spec:AST-094 DEC-31 carries them only to a head that only merges main, so DEC-27 needs all three taken again at the head that carries the fix above.

@humbertovirtudes's changes-requested review also stays in force until they re-review or dismiss it, and a few non-blocking notes are in the review.

[Automated review]

A finger that crosses 11.5 px at 650 ms while the page is blocked past
the menu's moment, until 760, 800 or 900 ms, ends the hold when its
coalesced move arrives, as separate moves do. A still finger behind a
block gets its menu once no move comes. Every case fakes the page's
clock from 0, and the on-time browser case checks that the moment's
own timer task shows the menu.
When the menu's timer runs more than a frame late, the page was
blocked, and the finger's moves from before the moment may still be
queued. The menu then waits up to 50 ms, counted from when the page
runs again, so those moves are judged at their own times first. On an
idle page the timer runs on time, and nothing waits. The record's FR16
gap line states the shift and its 50 ms bound.
@cixzhang

Copy link
Copy Markdown
Contributor

/approve-spec 45bfb44

@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: three items.

ContextMenu's held-finger timeline matches the touch-presentation spec, and the owner approval for this head settles the record edits and the new FR16 gap; what blocks:

  • Miss: under a finger, a ContextMenu rendered into a same-origin iframe (or a Picture-in-Picture window) opens its menu on any tap and loses the tap's click, because the gesture host times that document's touches on the page's performance.now() (GestureHost.ts:913) and the recognizer reads performance.now() too (contextMenuPress.ts:241, :270, :286); in Chromium a 120 ms tap in an iframe opened the menu 141 ms after the touch, so please put each document's gesture host on that document's clock, read time only through the arena, and add a touch case to the second-document tests.
  • Miss: the late-timer wait in contextMenuPress.ts (lines 236–293) is a second copy of the gesture arena's own late-timer handling (GestureArena.ts:1153-1166), so other recognizers such as List's touch reorder hold keep the old behavior; please extend the arena's late-timer yield for every recognizer and keep only the tolerance rule in the recognizer, or say in the pull request why the arena cannot serve it.
  • The Android Chrome-with-Gboard emulator run that spec:AST-094 DEC-27 requires at the exact head (normal and removed animations, plus a slow-device profile) is reported for an earlier head, so rerun it at the head that carries the fixes above and update the description.

@humbertovirtudes's changes-requested review also stays in force until they re-review or dismiss it, and a few non-blocking notes are in the review.

Automated review (maintainer link)

The press's units drive the page's time with the gesture arena's own
injected clock, the test harness's manual clock, instead of faking
performance.now(). A move can reach the page after its own time, and a
blocked page runs the menu's timer late on that clock. A blocked main
thread until 900 ms now shows the still finger's menu at 950 ms.
The press measured how late a move or its timer ran with
performance.now(). It now reads the gesture arena's injected clock,
whose new pageTime() returns the clock's own reading. In a browser
that clock is performance.now(), so nothing changes there; the units
drive it with the test harness's manual clock.
@cixzhang

Copy link
Copy Markdown
Contributor

/approve-spec cae3644

@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: two items.

ContextMenu's held-finger timeline matches the touch-presentation spec, and the /approve-spec for this revision covers the record edits; what blocks:

  • Miss: under a finger, a ContextMenu rendered into a same-origin iframe (or a Picture-in-Picture window) opens its menu on any tap, because the gesture host times every document's touches on the page's clock (GestureHost.ts:913) while the hold counts from the touch's own timestamp (contextMenuPress.ts:148-149), so in Chromium a 120 ms tap in an iframe whose clock ran 3 s behind the page opened the menu 143 ms after the touch; please give each document's gesture host that document's clock (or rebase another document's event times) and add a touch case to the second-document tests.
  • The Android Chrome-with-Gboard emulator run that spec:AST-094 DEC-27 requires at the exact revision (normal and removed animations, plus a slow-device profile) is not reported for this revision, so please run it at the revision that carries the fix above and update the description.

@humbertovirtudes's changes-requested review also stays in force until they re-review or dismiss it, and a few non-blocking notes are in the review.

Automated review (maintainer link)

// The hold's deadlines count from the touch itself. This replaces the
// foundation's own press timer, which never fires.
this.holdFrom = {x: touch.x, y: touch.y};
this.holdStart = event.time;

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.

The hold counts from event.time, which a touch in another document (a same-origin iframe or a Picture-in-Picture window) takes on that document's clock, while the arena that runs these deadlines is on the page's performance.now() (GestureHost.ts:913), and pageTime() below reads that same page clock. In Chromium as a Pixel 7, with an iframe clock 3.16 s behind the page, a 120 ms tap opened the menu 110 ms after the touch and a 1 s hold at 111 ms; main opens nothing for the tap and opens the hold at 516 ms. Giving each document's host that document's clock in getGestureHost(doc) (or keeping the released long press for a trigger in another document) and adding a touch case to the second-document tests would close it.

This branch was successfully deployed

1 active deployment
Preview — cae3644e Deployed Nov 11, 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.

3 participants