Skip to content

fix(core): a long press held through a busy main thread still reports, on the touch events' clock - #9455

Merged
cixzhang merged 3 commits into
meta:mainfrom
vjeux:fix/long-press-event-clock
Nov 3, 2026
Merged

cixzhang merged 3 commits into
meta:mainfrom
vjeux:fix/long-press-event-clock

Conversation

@vjeux

@vjeux vjeux commented Nov 2, 2026 •

Copy link
Copy Markdown
Contributor

User impact

On a busy page, a long press on a ContextMenu trigger can open nothing.

Found as a TableCellMenus case that failed only on a loaded machine.

Problem and solution fit

useLongPress timed the hold with a page timer cleared by touchend; with the main thread busy, the queued touchend runs first and the press is dropped. On main 83f5da1, Chromium, 3 runs each:

Main thread during the 800 ms hold Menu touchend handled after touchstart (stamped: 800 ms)
idle 3/3, ~512 ms 749–795 ms
busy 1,200 ms from just after touchstart 0/3 1,203 ms
busy 1,500 ms before the touch 0/3 15 ms

Expected behavior and authority

spec:AST-094 FR16 (current): a long press is a deadline on the input events' timestamps, "so a blocked main thread delays the report and never the moment reported", as the gesture system's long press already does.

Smallest restoration

  • The hold is a deadline on touchstart.timeStamp + delay on the touch's own window clock (an iframe or Picture-in-Picture window has its own origin): a move, second finger, lift or cancel stamped past it reports the press first.
  • The timer waits only for the rest, never past the delay; a late timer yields once to queued input, so an earlier-stamped lift still cancels. A jsdom stamp (nearer Date.now() than the window's clock) is placed by its age, so a frozen or zeroed system time keeps a tap a tap; a stamp of 0 gets the full delay.
  • onTouchEnd and onTouchCancel take the event, optionally; ContextMenu passes it.

Not here: useMenuPress's trigger hold, the same exposure, moves with DropdownMenu's section. A window-aware deadline helper shared with GestureArena follows once #8309, #8342, #8665 and #8763 land. Drafts editing useLongPress.ts take this at their rebase; #9642 builds on it.

Evidence

  • useLongPress.test.tsx: six event-clock cases fail on main's hook; the later-origin window, frozen-date, zeroed-date and zero-stamp cases fail on earlier versions of this PR; the yield and the cap each fail under mutation.
  • TableCellMenus.a11y.browser.spec.ts: a long press held through a 1,200 ms busy main thread opens the menu, present and painted, at the finger: 3/3 fail on main in Chromium and WebKit; 10/10 pass here on the 2-core Chromium pin and in WebKit (--cpus=4, LP_NUM_THREADS=4).

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

With touch emulation, open the TableRowExpansion "Gliding rows" story, run a 1 s busy loop right after touching a cell, and keep holding: the menu opens at the finger.

Local gates at 5153bfb: the static set (build, Storybook, typecheck, strict lint, provenance check, Prettier, changesets, knowledge, changeset coverage), test:ui and test:node all pass; the ContextMenu, TableCellMenus and TableRowExpansion accessibility specs pass; the busy-main-thread long press passes 10 of 10 on the 2-core Chromium profile at 1 worker and in the CI WebKit lane.

@vercel

vercel Bot commented Nov 2, 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 2, 2026 2:59pm UTC

Request Review

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

astracat-bot Bot commented Nov 2, 2026 •

Copy link
Copy Markdown
Contributor

AI review status for this pull request.

Review status Updated
🟢 Reviewed (for maintainers only) Nov 2, 2026, 3:10 PM UTC

@github-actions

github-actions Bot commented Nov 2, 2026 •

Copy link
Copy Markdown
Contributor

PR Analysis Report

🔎 Preview · 5153bfb

Storybook · Sandbox · built from this PR's head

Modified components

Component Size LoC Visual · A11y · RTL
TableBody 3.4 kB (0) · 3.3 kB CSS (0) — ✓ · ✓ · ✗
TableFooter 3.4 kB (0) · 3.3 kB CSS (0) — ✓ · ✓ · ✗
TableHeader 3.4 kB (0) · 3.3 kB CSS (0) — ✓ · ✓ · ✗
22 more components
Component Size LoC Visual · A11y · RTL
TableRow 6.5 kB (0) · 4.0 kB CSS (0) — ✓ · ✓ · ✗
richtext/RichTextEditorToolbar 219.9 kB (+363 B) · 454 B CSS (0) — — · — · —
Markdown 143.9 kB (+329 B) · 18.3 kB CSS (0) — — · — · —
Table 83.2 kB (+327 B) · 16.0 kB CSS (0) — ✓ · ✓ · —
TableCell 74.5 kB (+322 B) · 15.8 kB CSS (0) — ✓ · ✓ · ✓
Pagination 84.4 kB (+318 B) · 15.9 kB CSS (0) — — · — · —
BreadcrumbItem 65.2 kB (+316 B) · 11.4 kB CSS (0) — — · — · —
TopNavMenu 55.5 kB (+314 B) · 10.5 kB CSS (0) — — · — · —
TableHeaderCell 74.1 kB (+310 B) · 15.5 kB CSS (0) — ✓ · ✓ · ✓
DropdownMenu 71.1 kB (+303 B) · 15.4 kB CSS (0) — — · — · —
ContextMenu · Storybook 73.3 kB (+299 B) · 15.3 kB CSS (0) 693 (0) ✓ · ✓ · ✓
lab/CommentThread 225.1 kB (+299 B) · 2.4 kB CSS (0) — — · — · —
lab/Comments 230.4 kB (+297 B) · 2.5 kB CSS (0) — — · — · —
MoreMenu 71.4 kB (+295 B) · 15.4 kB CSS (0) — — · — · —
NavHeadingMenu 14.6 kB (+295 B) · 3.4 kB CSS (0) — — · — · —
SideNavHeading 56.1 kB (+293 B) · 10.5 kB CSS (0) — — · — · —
DropdownMenuSubMenu 56.2 kB (+288 B) · 9.9 kB CSS (0) — — · — · —
TabMenu 52.7 kB (+287 B) · 10.4 kB CSS (0) — — · — · —
TopNavHeading 55.6 kB (+286 B) · 10.4 kB CSS (0) — — · — · —
Selector 75.3 kB (+283 B) · 15.6 kB CSS (0) — — · — · —
PowerSearch 180.8 kB (+275 B) · 20.5 kB CSS (0) — — · — · —
TableSelectionToolbar 49.4 kB (0) · 11.5 kB CSS (0) — ✓ · ✓ · ✓

Package size: @astryxdesign/core 5.81 MB packed (+2.0 kB); no new dependencies.

API changes: core +0 · ~2 · −0

Committed API report behind this build: core 7 rows; run pnpm api:report.

2 API changes (advisory)
Package Where Signature Usage
~ core . · Types · interface UseLongPressHandlers onTouchCancel: () => void → onTouchCancel: (e?: React.TouchEvent) => void import type {UseLongPressHandlers} from '@astryxdesign/core'; const x: UseLongPressHandlers = {onTouchCancel: …}
~ core . · Types · interface UseLongPressHandlers onTouchEnd: () => void → onTouchEnd: (e?: React.TouchEvent) => void import type {UseLongPressHandlers} from '@astryxdesign/core'; const x: UseLongPressHandlers = {onTouchEnd: …}

Touched components checked; add ci:run-visual, ci:run-a11y or ci:run-rtl to run the full suites.


Generated by PR Enrichment workflow | View full report

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

Requesting changes. The event-clock deadline fixes the dropped long press on a busy page, but it measures a touch in a same-origin iframe or Picture-in-Picture window against the page's performance.now(). As a result, a tap of about 120 ms there opens ContextMenu, which the base never did.

  • Read the page clock from the event's own window ((e.view ?? window).performance, captured at touchstart) in begin and in the late-timer check, and add a unit case whose clock starts later than the page's.
  • Share one event-clock deadline timer with GestureArena (the one-time yield and eventTime's clock normalization) instead of keeping a second copy in useLongPress, or say in the PR why the arena's timer cannot serve.

[Automated review]

Comment thread packages/core/src/hooks/useLongPress.ts Outdated
Comment on lines +200 to +201
const handledLateBy =
startedAt == null ? 0 : performance.now() - startedAt;

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.

startedAt is on the clock of the touch's own window, but performance.now() here is the page's. For a trigger rendered into a same-origin iframe or a Picture-in-Picture window (which family:overlay-dismissal FR8 supports), the difference between the two time origins reads as lateness, so remaining is 0. Combined with the late check at line 211, a tap of about 120 ms then opens ContextMenu. Capturing (e.view ?? window).performance at touchstart and using it here and in run fixes it.

Comment on lines +209 to +216
const run = (mayYield: boolean) => {
timerRef.current = null;
if (mayYield && deadline != null && performance.now() > deadline) {
timerRef.current = setTimeout(() => run(false), 0);
return;
}
onLongPressRef.current({x: from.x, y: from.y});
}, delayMs);
fire();
};

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 copies GestureArena.scheduleRealTimer's fire(mayYield) (gestures/GestureArena.ts:1092-1108), while the clock handling above already differs from GestureHost's eventTime. Could one shared event-clock deadline timer serve both, so a clock fix lands once?

@vjeux

vjeux commented Nov 2, 2026

Copy link
Copy Markdown
Contributor Author

Both points, at 0952cbc:

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

Approving. Timing the long press as a deadline on the touch events' own clock makes a hold through a busy main thread open the menu late instead of never, and reading that clock from the touch's own window fixes the same-origin iframe and Picture-in-Picture tap regression in the previous revision. The new unit cases fail on the base hook and pass here, and Chromium reproduces both the busy-thread hold and the iframe tap.

[Automated review]

…, on the touch events' clock

useLongPress timed a hold with a page timer set at touchstart and cleared
by touchend. When the main thread was busy through the hold, the page ran
the queued touchend before the overdue timer, and the long press was lost:
a Table cell's ContextMenu opened nothing after an 800 ms hold.
spec:AST-094 FR16 asks a gesture to keep the event clock: a blocked main
thread delays the report and never the moment reported.

- The hold is a deadline on the touch events' own timestamps
  (touchstart.timeStamp + delay). A move, a second finger, or a lift
  stamped at or past it reports the pending press first, at its start
  point, then acts.
- The page timer waits only for what is left of the hold, so a touch the
  page handled late does not lengthen it. A timer that fires late yields
  once to queued input, so a lift stamped before the deadline still
  cancels the press: the gesture arena's rule (gestures/GestureArena.ts).
- The clock is the touch's own window's, (e.view ?? window).performance,
  captured at touchstart: a same-origin iframe or a Picture-in-Picture
  window has its own time origin. Epoch stamps (jsdom) come onto it through
  its time origin, and the timer never waits past the delay.
- onTouchEnd and onTouchCancel take the touch event, optionally;
  ContextMenu passes it.

Tests: useLongPress's event-clock cases (six fail on main), among them a
touch on a window clock whose origin is later than the page's, and a
TableCellMenus browser case whose first long press is held through a
1,200 ms busy main thread (fails on main). A [fix] changeset.

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

Requesting changes. Timing the long press on the touch events' own clock fixes the dropped ContextMenu long press on a busy page: in Chromium an 800 ms hold through a 1,200 ms busy main thread now opens the menu, and a short tap through the same busy thread still opens nothing. One regression remains: timeOn converts a jsdom stamp by subtracting the real performance.timeOrigin, so in a test that freezes the clock at an earlier date (vi.setSystemTime) a 100 ms tap opens ContextMenu; measuring an epoch stamp's age against Date.now() and adding a frozen-clock case would fix it. Two comments in useLongPress.ts could also be tightened (non-blocking).

[Automated review]

Comment thread packages/core/src/hooks/useLongPress.ts Outdated
if (timeStamp == null || !Number.isFinite(timeStamp)) {
return undefined;
}
if (timeStamp > 1e12 && clock != null && clock.timeOrigin > 0) {

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.

jsdom stamps events with Date.now(), but timeOrigin is the real origin. In a test that freezes the clock at an earlier date (vi.setSystemTime(new Date(2026, 2, 15))), this puts the touch months in the past: remaining is 0 and the press fires right after touchstart, so a 100 ms tap opens ContextMenu (the base never did). Measuring the stamp's age on the clock that made it avoids that: clock.now() - (Date.now() - timeStamp). A frozen-clock case would pin it.

Comment thread packages/core/src/hooks/useLongPress.ts Outdated
/** The part of a window's performance timeline a press reads. */
type EventClock = Pick<Performance, 'now' | 'timeOrigin'>;

/** The clock of the window a touch came from. */

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 restates the name clockOf; it can go.

Comment thread packages/core/src/hooks/useLongPress.ts Outdated
Comment on lines +29 to +33
// The hold is a deadline on the touch events' own timestamps, read on the
// clock of the window the touch came from (an iframe or a Picture-in-Picture
// window has its own). A busy main thread can hold the timer back past the
// deadline; a touch stamped past it then reports the long press before it
// acts, so a busy page delays the report and never drops it.

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: these two sentences run 31 and 36 words. A shorter form: "The hold is a deadline on the touch events' timestamps, on the clock of the touch's own window. A busy main thread can delay the timer past the deadline. A touch stamped after the deadline then reports the press before it acts."

jsdom stamps events with Date.now(). The hold converted such a stamp by
subtracting the window's real time origin, so in a test that freezes the
system time at an earlier date the touch landed months in the past, its
timer fired at once, and a 100 ms tap opened ContextMenu. An epoch stamp's
age is now read on Date.now(), the clock that made it, and placed on the
window's clock: clock.now() - (Date.now() - timeStamp).

Tests: an epoch stamp handled 300 ms late is due in 200 ms, and a tap
under a frozen earlier system time opens nothing; both fail on the
previous version.
@vjeux

vjeux commented Nov 2, 2026

Copy link
Copy Markdown
Contributor Author

At cab4f23: an epoch stamp is now placed by its age on Date.now(), the clock that made it (clock.now() - (Date.now() - timeStamp)), so a test that freezes the system time at an earlier date keeps a 100 ms tap a tap. Two new cases (a stamp handled 300 ms late, and a tap under a frozen earlier system time) fail on the previous version. Both comment suggestions are taken.

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

Requesting changes. Placing a jsdom stamp by its age fixes the frozen-earlier-date case, and both comment suggestions are in. One case remains: timeOn treats a stamp as a Date.now() stamp only above 1e12, so in a jsdom test that freezes the date at 0 and leaves performance unfaked, a 100 ms tap opens ContextMenu. Choosing the clock by distance, Math.abs(Date.now() - timeStamp) < Math.abs(clock.now() - timeStamp), plus a case for that setup, fixes it and keeps the existing tests green.

[Automated review]

Comment thread packages/core/src/hooks/useLongPress.ts Outdated
if (timeStamp == null || !Number.isFinite(timeStamp)) {
return undefined;
}
if (timeStamp > 1e12 && clock != null) {

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.

jsdom stamps events with Date.now(), but a stamp counts as one only above 1e12. In a test that freezes the date at 0 (vi.setSystemTime(0)) and leaves performance unfaked (an explicit toFake list without it), the stamp is read as a performance time several seconds in the past: remaining is 0 and a 100 ms tap opens ContextMenu, which the base never did. Choosing the clock by distance keeps a tap a tap in that setup and keeps the PR's own cases green:

if (
  clock != null &&
  Math.abs(Date.now() - timeStamp) < Math.abs(clock.now() - timeStamp)
) {
  return clock.now() - (Date.now() - timeStamp);
}

Comment thread packages/core/src/hooks/useLongPress.ts Outdated
Comment on lines +57 to +59
* An event's time on its window's clock. jsdom stamps events with
* `Date.now()`, so such a stamp's age is read on that clock: a test that
* freezes or moves the system time keeps the press's timing.

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 sentence runs 28 words, in the passive, with three topics. A shorter form: "jsdom stamps events with Date.now(). The hook reads the age of such a stamp on that clock. A frozen system time then keeps the hold's length."

…one by distance

A stamp counted as a Date.now() stamp only above 1e12, so in a jsdom test
that froze the date at 0 and left performance real, a small Date.now()
stamp read as a window-clock time seconds in the past and a 100 ms tap
opened ContextMenu. A stamp nearer Date.now() than the window's clock is
now read as a Date.now() stamp, by its age. A stamp of 0 carries no time,
so the hold falls back to the full delay on the page's timer.

Tests: a tap under a date frozen at 0 with a real window clock, and a
touch stamped 0; each fails on the previous version.
@vjeux

vjeux commented Nov 2, 2026

Copy link
Copy Markdown
Contributor Author

At 5153bfb: the clock is chosen by distance, as suggested, so a stamp nearer Date.now() than the window's clock is read by its age, and a date frozen at 0 with a real window clock keeps a 100 ms tap a tap. A stamp of 0 now counts as no time and gets the full delay. Each has a case that fails on the previous version; the comment is shortened as suggested.

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

Approving. Timing the long press on the touch events' own clock opens ContextMenu through a busy main thread, and choosing the Date.now() branch by distance fixes the earlier tap regression in tests that freeze the date near 0. One optional follow-up: the new date-frozen-at-0 test stamps its touch at exactly 0, so the zero-stamp guard decides it; advancing the frozen clock before the touch would make it cover the distance comparison too.

[Automated review]

});

it('keeps a tap a tap in a test that freezes the date at 0 and leaves the window clock real', () => {
vi.setSystemTime(0);

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: Date.now() is exactly 0 here, so the touch is stamped 0 and the timeStamp <= 0 guard decides this case before the distance comparison. With timeStamp > 1e12 restored in timeOn, every case in this file still passes. Advancing the clock before the touch (for example vi.advanceTimersByTime(50)) would make this case need the distance test.

@cixzhang

cixzhang commented Nov 3, 2026

Copy link
Copy Markdown
Contributor

/approve-spec 5153bfb

@vjeux
vjeux marked this pull request as ready for review November 3, 2026 14:28
@cixzhang cixzhang added the ci:run A maintainer lets this outside contributor's pull request run CI (AST-030 FR17) label Nov 3, 2026
@cixzhang
cixzhang marked this pull request as draft November 3, 2026 14:32
@cixzhang
cixzhang marked this pull request as ready for review November 3, 2026 14:32
@cixzhang
cixzhang merged commit acca589 into meta:main Nov 3, 2026
151 of 156 checks passed
@vjeux
vjeux deleted the fix/long-press-event-clock branch November 3, 2026 15:19

This branch was successfully deployed

1 active deployment
Preview — 5153bfbe Deployed Nov 2, 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) 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