Skip to content

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

Draft
vjeux wants to merge 3 commits into
meta:mainfrom
vjeux:fix/long-press-event-clock
Draft

vjeux wants to merge 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.

What led to it: TableCellMenus' first-long-press case failed on a loaded machine and passed alone.

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 (the arena's rule). A stamp nearer Date.now() than the window's clock (jsdom's) is placed by its age on Date.now(), 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 has the same exposure and moves with DropdownMenu's section. Sharing one deadline timer with GestureArena waits too: open #8309, #8763, #8342, #8665 and #8676 edit its files, and its timer also reads the page's clock, so one window-aware helper is a follow-up after them. Drafts editing useLongPress.ts (#8125, #8133, #8201, #8213, #8228, #8693, #8078, #8507, #8531) take this at their rebase.

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: build, typecheck, strict lint (ASTRYX_STRICT_LINT=1), the comment-provenance baseline check, Storybook, Prettier, knowledge, changesets, changeset coverage, check:repo; pnpm test:ui (17,980); pnpm test:node (all but #8520's two known cache.test.mjs failures, and global-local's 5 s interrupt case at box load, which passes alone on main and here); the Table, ContextMenu and TableRowExpansion accessibility specs in Chromium (119); the touch cases ×10 on the Chromium pin at 1 worker and in WebKit (--cpus=4, CI=1, LP_NUM_THREADS=4), and once at --cpus=3 with 1 and 16 workers.

@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

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

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

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

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

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

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

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

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

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

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

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

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

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

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

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.

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

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.

1 participant