Skip to content

chore(sandbox): preview only, do not merge, a touch log for a held ContextMenu - #11118

Closed
vjeux wants to merge 23 commits into
meta:mainfrom
vjeux:preview/contextmenu-touch-log
Closed

vjeux wants to merge 23 commits into
meta:mainfrom
vjeux:preview/contextmenu-touch-log

Conversation

@vjeux

@vjeux vjeux commented Nov 10, 2026

Copy link
Copy Markdown
Contributor

Preview only: #8125's head 88cc0d8 plus one sandbox page, /sandbox/pages/context-menu-touch-log/, for a check on a real iPhone. Do not merge; it closes after the check.

The page shows the ContextMenu long-press story's trigger and menu. Below them, a log lists the input events around a held finger's lift. Each pointer, touch, mouse and click event shows its time, isTrusted, detail, cancelable and defaultPrevented. A second line gives its outcome after the page's handlers. The log also lists each showPopover and hidePopover call, with the stack of each hide. A button clears the log.

Testing

At 91c183e: eslint and the public-text check pass on the page.

vjeux and others added 23 commits November 7, 2026 02:26
A finger held on the trigger opens the menu on UIKit's timeline: the
hold commits at 400 ms and the menu shows at 735 ms. One outside-press
watch closes the menu for every pointer, and a touch closes it as it
begins to pan.

It carries this pull request's own diff from 6d7fceb, replayed on
main after meta#9814's squash, and folds these 12 commits (the history of
6d7fceb has each one):

- chore(motion): ContextMenu's touch timeline and press curve, measured
  on iOS 26.2
- test(ContextMenu): a held finger opens the menu on iOS 26's timeline,
  and a touch outside closes it as it pans
- feat(ContextMenu): open from a held finger on iOS 26's timeline, with
  no page lock
- docs(ContextMenu): FR11 cites AST-094 FR45 for the hold's measured
  moments
- test(ContextMenu): the long-press a11y contract follows the touch
  model
- docs(ContextMenu): the touch changeset and docs name their condition,
  not every touch device
- test(Layer): the outside-press watch closes a layer at the press for a
  mouse or a pen
- feat(Layer): the outside-press watch closes a layer at the press for a
  mouse or a pen
- refactor(ContextMenu): one outside-press watch closes the menu for
  every pointer, and its mousedown Effect goes
- docs(ContextMenu): split long file-header sentences
- test(Indicator): the ContextMenu box-hold case pins the popover menu
- test(ContextMenu): the long-press spec note keeps to 20 words
The hold's timer only sets ContextMenu's state, so React mounts and
shows the menu in its next scheduled task. A WebKit CI run showed it
at 758 ms, past the 735 ms proof's 20 ms. The proof now also wants the
menu to show inside the timer task that decides it, and that task at
the deadline counted from the touch. The page's stamps record the
first show only.
The hold's timer opened the menu with a plain state update, which waits
for React's next scheduled task: the menu showed 9 ms after the
deadline in Chromium and 17 ms in the WebKit CI container. UIKit shows
the menu at its moment. The open now commits with flushSync, in the
timer task, as a lift's open commits in the lift's dispatch.
The menu's first show mounts its layer, a render of 6 ms in Chromium
and 17 to 21 ms in the WebKit CI container inside the task that shows
the menu at 735 ms. UIKit prepares a context menu as the interaction
begins and only presents it at its moment. The proofs want the menu
mounted, closed and out of the accessibility tree, at the commit; the
same element shown at 735 ms or at the lift; and nothing left mounted
by a move past the tolerance or a cancel.
A lazy context layer gains prepare(), which mounts its content without
showing it, and releasePrepared(), which unmounts that content unless
the layer has shown since. Both are internal to core's layer hook. At
the hold's commit, ContextMenu prepares its layer, so the show at
735 ms or at the lift only reveals it. A press that ends with no menu
releases it. component:ContextMenu PR1 names the commit-time mount.
On main, ContextMenu's mouse and pen dismissal listens on the page's
document. A menu rendered into a same-origin iframe or a Document
Picture-in-Picture window then closes on a press in the page and stays
open on one in its own window. The shared outside-press watch listens
in the trigger's own document, as `family:overlay-dismissal` FR8 has
every member work in the document it renders in. With main's
ContextMenu.tsx, this case fails.
PR1 listed one mousedown listener and the adaptive query. While the
menu is open, the outside-press watch adds pointer listeners to the
trigger's document and shadow roots, plus a mousedown listener where a
mouse or a pen closes it at the press. With presentation omitted,
ContextMenu subscribes to the coarse-pointer query while mounted.
A WebKit CI run showed the menu at 890 ms, inside the timer task that
decided it, so that task itself started late. The proof now logs the
page's frames. A deciding task that starts within a frame of its due
time keeps the 20 ms bound. A later start that a frame gap over 50 ms
covers fails with FrameStarvationError, its numbers named; any other
late start fails bare. The bound does not move.
After a long task, WebKit draws a frame and runs the delayed timer
about 20 ms later. The 735 ms proof counted a frame gap only when it
ended within a frame of the timer's start, so an 80 ms block failed
bare there. A gap over 50 ms that holds the due time now explains a
start within two frames of its end, and fails by name. A task held
longer while frames ran, and a slow read inside an on-time task,
still fail bare. The 20 ms bound does not move.
Unless `presentation="popover"`, the menu subscribes to the
coarse-pointer query while mounted, a bottom sheet included, and with
`presentation="adaptive"` also to the compact query, as usePresentation
reads them. PR1 named only the omitted and adaptive cases.
A touch open focuses the menu itself, and the menu draws no focus
outline, forced colors included. The record's current-state impact now
lists it: interaction-modality INV1 hides the indicator after pointer
input, and no record names one for this menu under forced colors.
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.
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.
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.
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.
One raw page with the long-press story's trigger and menu, and an
on-page log of the input events around a held finger's lift and of the
popover calls, with the stack of each hide. It never merges.
@vercel

vercel Bot commented Nov 10, 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 10, 2026 11:02pm UTC

Request Review

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

astracat-bot Bot commented Nov 10, 2026 •

Copy link
Copy Markdown
Contributor

AI review status for this pull request.

Review status Updated
❓ Needs maintainer input (for maintainers only) Nov 10, 2026, 11:16 PM UTC

@github-actions github-actions Bot added the needs:spec-owner-review Current knowledge records await owner approval label Nov 10, 2026
@github-actions

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.

This preview is not ready to approve, and its own description says it should not merge.

Approval first waits on an engineering owner's decision on two record changes this head makes to component:ContextMenu. One is the new FR12: the hold commits at 400 ms and the menu shows at 735 ms. The other is the rewritten PR1: the outside-press watch's listeners, and the menu mounted closed at the hold's commit. An engineering owner records that decision by commenting /approve-spec <full head SHA> on this pull request, and the author may do so as an engineering owner. Even then, two changes are required:

  • At 3,346 authored lines, this pull request bundles #8125's whole ContextMenu increment (3,045 lines) with a 301-line sandbox page, so the ContextMenu change should land through #8125 while this preview stays unmerged or carries only the page on top of #8125.
  • The ContextMenu presentation default change, from 'popover' to the touch model, is outside this pull request's sandbox scope, so either name it in the scope with an owner decision or leave it to #8125.

[Automated review]

isDisabled = false,
onOpenChange,
presentation = 'popover',
presentation,

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.

Tag-along (blocking): dropping the 'popover' default changes ContextMenu's public presentation default. That is outside this pull request's sandbox scope. Name it in the scope with an owner decision, or leave it to #8125.

Comment on lines +8 to +10
* finger opens on UIKit's timeline. They cover the menu's moments, the
* press feedback against UIKit's frames and a blocked main thread. They
* also cover the page under the finger, a pan outside and a storyboard of

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: this header promises proofs of the press feedback against UIKit's frames and a storyboard of the press, but the file has no such test. Drop those claims or add the tests. The note at line 38 and "scaled by the press feedback" at line 278 are stale in the same way.

@vjeux vjeux closed this Nov 10, 2026
@vjeux
vjeux deleted the preview/contextmenu-touch-log branch November 10, 2026 23:41

This branch was successfully deployed

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

Labels

CLA Signed This label is managed by the Meta Open Source bot. needs:spec-owner-review Current knowledge records await owner approval

Projects

None yet

Development

Successfully merging this pull request may close these issues.

2 participants