Skip to content

feat(core): gesture recognizer core — UIKit's state machine and the arena - #8058

Merged
cixzhang merged 14 commits into
meta:mainfrom
vjeux:wip/uikit-pr-g1a
Oct 26, 2026
Merged

cixzhang merged 14 commits into
meta:mainfrom
vjeux:wip/uikit-pr-g1a

Conversation

@vjeux

@vjeux vjeux commented Oct 24, 2026 •

Copy link
Copy Markdown
Contributor

Depends on #8053 (and its predecessors); review the last commit.

Primary intent

Add the core of an internal gesture-recognizer system for touch presentations: UIKit's recognizer state machine and the arena that settles which recognizer owns a touch sequence. Recognizers, the DOM host and component migrations follow in their own pull requests; nothing is exported from the package.

User or maintainer impact

Touch components each carry their own pointer handling (sheet drags, swipe actions, toast dismissal, long press, press feedback), with their own answer to "who owns this touch". Two can act on one touch, and a second finger can break a drag. With one arena every component gets UIKit's answers: exclusive recognition by default, requireToFail, simultaneous recognition only on request, a deterministic resolution order, and the view's touches cancelled (no click, no pressed state) once a gesture takes them.

Problem and solution fit

  • Why does it happen? Ownership of a touch is decided separately in each component.
  • Why does that hurt? Ghost clicks, double actions, and rules that differ from component to component.
  • Why does it matter? Touch presentations aim at UIKit parity, and UIKit settles this in one place.

Deltas:

  • GestureRecognizer:
    • UIKit's states (possible, began, changed, ended, cancelled, failed) and allowed transitions;
    • discrete and continuous recognizers;
    • requireToFail, recognizeSimultaneouslyWith, shouldBegin, shouldReceiveTouch;
    • cancelsTouchesInView, delaysTouchesBegan and delaysTouchesEnded, allowedPointerTypes.
  • GestureArena, with no DOM:
    • participants per touch sequence, resolution and claims;
    • delivery to the view;
    • click dispositions: allow, suppress, or defer and replay if the recognizer fails;
    • timers as deadlines on the event clock.

Authority

spec:AST-094 FR15 (arbitration) and FR16 (the event clock), proposed; pending owner review.

Change and evidence

The arena's rules are tested with scripted recognizers, test doubles that begin, fail or wait on command. The rules hold independently of any real recognizer:

  • exclusivity and simultaneity;
  • require-to-fail: waiting, failing, delegate requirements, idle recognizers;
  • delegate hooks and innermost resolution;
  • independent touches;
  • delivery to the view, deferred and replayed clicks, the browser taking a touch;
  • cancellation;
  • a deadline that runs at its own time before a late input.

Size: 2,198 lines, about 400 of them tests. This is one indivisible intent: the recognizer base and the arena call each other, so neither builds or tests alone, and the doubles-only tests are the smallest proof of the rules.

Scope

  • This PR has one primary intent.
  • Independently removable deltas were removed or split.
  • Public text and artifacts contain no internal Meta context.

Testing

  • pnpm exec vitest run --project ui packages/core/src/gestures packages/core/src/motion: 159 tests passed.
  • tsc --project packages/core/tsconfig.json --noEmit: clean.
  • eslint packages/core/src/gestures: clean.
  • pnpm check:changesets, node scripts/release/changeset-coverage.mjs pr --base origin/main: clean.
  • Size: 6 files, 2,205 lines added.

Stack (merge in this order): #8052 spec → #8053 constants table → #8058 gesture core → #8059 tap and long press → #8060 pan → #8061 other recognizers → #8054 math → #8055 animator → #8056 animator curves; the gesture DOM host, press tracking and browser proof follow the animator.

vjeux added 3 commits October 23, 2026 20:53
Springs, scroll physics, timing curves, navigation values and gesture
thresholds for the touch presentations of core components, each with its
source (Apple documentation, a WWDC session, WebKit's UIKit-matched source,
or a measurement of UIKit on the iOS 26.2 simulator) or labelled a guess
with its measurement task. A test enforces the source rules on every domain
file, in their JSDoc and typed forms, and checks the list of simulator
measurements still owed.
…rena

An internal gesture system for touch presentations, not exported from the
package. GestureRecognizer carries UIKit's states and allowed transitions,
discrete and continuous recognizers, require-to-fail, simultaneous
recognition on request, shouldBegin and shouldReceiveTouch,
cancelsTouchesInView and the touch delays. GestureArena settles each touch
sequence without the DOM: participants, resolution order, claims, delivery
to the view, click dispositions (allow, suppress, defer and replay) and
timers as deadlines on the event clock. Its rules are tested with scripted
recognizers, independent of any real one.
@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 Oct 26, 2026 2:42am UTC

Request Review

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

astracat-bot Bot commented Oct 24, 2026 •

Copy link
Copy Markdown

AI review status for this pull request.

Review status Updated
🟢 Reviewed (for maintainers only) Oct 26, 2026, 3:07 AM UTC

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

Approval waits on an owner decision: the gesture core implements the draft spec:AST-094 FR15, FR16 and FR19–FR21, and no current spec governs touch arbitration yet, so an engineering owner needs to approve that direction by commenting /approve-spec <full head SHA> on this pull request (the author may do so as an engineering owner) or name the spec that should govern it. Independently of that decision, please address:

  • A timer that fires late moves the event clock to wall time (GestureArena.ts:835), so input queued behind a busy main thread is restamped and a long press can begin against a move that should have failed it.
  • cancel() on an idle recognizer leaves it failed for good, because only recognizers in the current interaction are ever reset (GestureArena.ts:397-405).
  • A claim made while a recognizer waits for another to fail is never released, so isClaimed can stay true with no recognizer owning the touch (GestureArena.ts:434-437).
  • The diff carries 4,244 authored lines, including the stacked #8052 and #8053, and lacks writing rubric results for the AST-094 record it creates; landing those first and then splitting or justifying the remaining 2,205-line core would settle both.

[Automated review]

this.realTimer = this.clock.setTimeout(() => {
this.realTimer = null;
this.realTimerDeadline = Number.POSITIVE_INFINITY;
this.advanceTime(Math.max(earliest, this.clock.now()));

Copy link
Copy Markdown

Choose a reason for hiding this comment

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

A late timer runs at its deadline, but advancing to Math.max(earliest, this.clock.now()) then moves the event clock to wall time, so every input still queued behind the block is clamped to that time. With a scripted long press (deadline 500 ms), a block until 800 ms and the timer task first, the long press began at 500 although the finger moved 40 px at 300 ms, and the queued moves at 300 and 600 ms arrived stamped 800. Advancing only to earliest keeps queued timestamps; the timer-first order also needs a test.

Comment on lines +397 to +405
stop(recognizer: GestureRecognizer): void {
this.run(this.now(), () => {
if (isActive(recognizer.state)) {
this.apply(recognizer, 'cancelled');
} else if (recognizer.state === 'possible') {
this.fail(recognizer);
}
});
}

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 fails a possible recognizer even when it is idle. An idle recognizer is not in this.active, so settle() never resets it, and wantsTouch refuses a failed one: after cancel() it never takes a touch again. The setEnabled guard (state !== 'possible' || isInvolved()) would fit here too.

Comment on lines +434 to +437
if (isWaiting) {
recognizer.pending = next;
this.claim(recognizer);
return;

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 claims the touches before anything recognizes, and nothing releases the claim if the waiting recognition then fails (its shouldBegin turns false, or it is disabled while waiting): isClaimed(id) stays true with no recognizer active, so a host that follows isClaimed's contract keeps cancelling that touch's moves until the finger lifts.

cixzhang added a commit to vjeux/astryx that referenced this pull request Oct 26, 2026
…IControl press tracking

# Conflicts:
#	packages/core/src/motion/uikit/gestures.ts
@cixzhang

Copy link
Copy Markdown
Contributor

Posted by Cindy's agent on her behalf.

Cindy waives the split request for this PR. View delivery, the claims and browser-takeover parts, and the touchBeganElsewhere hook can land here with the gesture core. Any other review findings still need to be fixed, and the normal checks apply.

GestureArena.ts and GestureRecognizer.ts named GestureHost.ts,
GestureArena.test.ts and README.md, none of which exists here; the arena's
tests are __tests__/arena.test.ts.
cixzhang added a commit to vjeux/astryx that referenced this pull request Oct 26, 2026
No conflicts. Brings main's per-group UIKit table (meta#8468) through meta#8058
and meta#8059, with meta#8060's velocity rows inside UIKIT_VELOCITY.

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

Recommending approval: the gesture core follows current spec:AST-094 (FR15 and FR16 for arbitration and the event clock, FR19–FR21 for claims, the browser taking a touch and click dispositions). The fix for a deadline that comes due inside a re-entrant entry holds, with a regression test that fails without it. Every arena issue raised in earlier reviews is fixed at this head, and the engineering owner's waiver of the split settles the size concern.

[Automated review]

@cixzhang
cixzhang merged commit d8ffb8f into meta:main Oct 26, 2026
67 checks passed
@vjeux
vjeux deleted the wip/uikit-pr-g1a branch October 26, 2026 03:34
cixzhang added a commit to vjeux/astryx that referenced this pull request Oct 26, 2026
The arena and recognizer base take main's landed versions; this branch
makes no edits to them.
cixzhang added a commit to vjeux/astryx that referenced this pull request Oct 26, 2026
cixzhang added a commit to vjeux/astryx that referenced this pull request Oct 26, 2026
# Conflicts:
#	packages/core/src/gestures/GestureArena.ts
#	packages/core/src/gestures/GestureRecognizer.ts
#	packages/core/src/gestures/types.ts
cixzhang added a commit to vjeux/astryx that referenced this pull request Oct 26, 2026
No conflicts. Brings main with meta#8058 landed and meta#8054 merged, plus meta#8060's
per-group pan and velocity imports and its FR19 decide-by-10-px fix.
cixzhang added a commit to vjeux/astryx that referenced this pull request Oct 26, 2026
No conflicts. Brings main with meta#8058 landed, through meta#8060 and meta#8061.
cixzhang added a commit to vjeux/astryx that referenced this pull request Oct 26, 2026
@vjeux

vjeux commented Oct 26, 2026

Copy link
Copy Markdown
Contributor Author

From a touch ContextMenu built on this core: the claim rule in 4bec6a0 ("claims come back") gives a dragging finger back to the browser when its gesture cancels itself on that finger's move.

  • The case: a long press that began, claiming its finger at the commit, cancels itself on the first move past its tolerance, before its menu is due. The arena releases the claim, the host stops cancelling that finger's touchmove (FR19: a touch no recognizer owns keeps its moves), and the page scrolls the rest of the drag.
  • Chromium, a drag that cancels the press after the commit does not scroll the page either (hold past the commit, then a 200 px drag): on a base without 4bec6a0 the page stays still; with it the page follows the drag (top 16 → −174), 2/2. With the change below on that same tree it passes 2/2, and the long-press suite's 38 runs pass.
  • spec:AST-094 FR14: a drag never changes hands midway. UIKit: a scroll view never takes a touch another recognizer had.

A failing-first branch, one commit on main: vjeux:offer/arena-cancel-keeps-moving-touch. A cancel during a finger's move keeps that finger claimed until it lifts; a resting finger is still given back when its gesture is cancelled (the pinch case), and a waiting recognition that fails still gives its claim back. The new arena case fails without it.

Question: should a gesture that cancels itself on a finger's move keep that finger claimed until it lifts (FR14), with the release kept for a resting finger?

vjeux added a commit to vjeux/astryx that referenced this pull request Oct 26, 2026
The touch foundation's fold, mergeable against main cd4aca6: her chain
on main (meta#8058, meta#8054, meta#8059, meta#8060) and meta#8258's rework, which keeps touch
motion internal (no theme surface): the generated role table, its script and
the role kinds file are gone, and a table registers only the roles it lists.
The motion doc and its golden take meta#8076's side; the role kinds file stays
deleted.
vjeux added a commit to vjeux/astryx that referenced this pull request Oct 26, 2026
meta#8076's head is F1's fold on the maintainer's chain now on main (meta#8058,
meta#8054, meta#8059, meta#8060): touch motion is internal (the tables register their
values as internal motion roles, with no theme surface), and the
`isRtlElement` resolver.

Conflicts, resolved:
- The theme-roles changeset and the motion guide take meta#8076's side; their
  sentence on ChatLayout's Reduce Motion cross-fade goes with the theme
  surface.
- The CLI goldens for the empty icon set take meta#8076's side and regenerate.
- keyboardModel.test.ts reads the internal role registry (`motionRoleIn`)
  now that the generated role-kind table is gone, and no longer names a
  theme.

This branch was successfully deployed

1 active deployment
Preview — 0bffdcf4 Deployed Oct 26, 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.

2 participants