Repository navigation
Conversation
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.
…sholds Tap recognizes at the lift within 45 pt of movement, taps 0.35 s and 45 pt apart, and a 1.5 s hold; long press begins at exactly touch-down + 0.5 s on the event clock within 10 pt, even when the main thread was blocked, and a zero duration begins on the touch itself. The arena tests with these real recognizers cover tap against double tap, delegate requirements, deferred clicks and cancellation.
|
The latest updates on your projects. Learn more about Vercel for GitHub.
|
|
AI review status for this pull request.
|
There was a problem hiding this comment.
This pull request is on hold for a spec decision: the gesture host counts only a finger's first uncancelable touchmove, and only where the browser could pan that way, as the browser taking the touch (GestureHost.ts lines 791-826), while the current spec:AST-094 FR20 says any uncancelable touchmove counts. An engineering owner needs to decide whether FR20 is amended on main to the host's rule or the host counts every uncancelable move, and records that decision by commenting /approve-spec <full head SHA> on this pull request; the author may do so as an engineering owner. Independently of that decision, these changes are still needed:
- Keep an
ownScrollexemption alive until the newest call's frame has passed (lines 1461-1472): an earlier call's timer clears it, so a menu autoscrolling under a finger loses its press (AST-094 FR30). - Import only
UIKIT_PRESSandUIKIT_CLICK, and stop importing recognizer classes just forinstanceof, so components ship only the gesture-table rows they read (architecture:component-style-authoringINV6). - Gate the finger's target listener on
isEnabled(line 742) andclaimsWhileMovingonclaimsTouches(lines 160-165), and re-sync the document listeners after a detach that leaves only disabled recognizers (lines 516-523), as AST-094 FR19, FR20 and FR24 require. - Land #8061 and #8071 first so this diff carries only the host, split a separable piece out of its 2,680 lines or show why it has to land as one, and add the real-touch browser tests that AST-094 names for FR18–FR22.
[Automated review]
| if (inputs.length === 0) { | ||
| return; | ||
| } | ||
| if (!event.cancelable) { |
There was a problem hiding this comment.
This branch counts only a finger's first uncancelable touchmove, only where the browser could pan along its main axis, and never a claimed touch's (with the scroll path skipping claimed touches too, line 1497), while current spec:AST-094 FR20 says an uncancelable touchmove MUST count as the browser taking the touch. The Chromium reason in the comment is real, so this needs an engineering owner's decision: amend FR20 on main to this rule, or count every uncancelable move here.
| } | ||
| view.requestAnimationFrame(() => { | ||
| view.requestAnimationFrame(() => { | ||
| this.ownedScrolls.delete(scroller); |
There was a problem hiding this comment.
This delete is unconditional, so an earlier ownScroll call's timer removes the exemption a newer call has just renewed. With an autoscroll loop that re-registers its requestAnimationFrame before it scrolls, the scroll from the third frame's write arrives after the first call's delete, and onScroll hands the held finger to the browser (a probe loses the press on frame 4), against AST-094 FR30. Keeping a per-scroller generation or frame deadline and deleting only when no newer call came since fixes it; a test with consecutive per-frame calls would pin it.
| * SYNC: When modified, update GestureHost.test.ts and README.md. | ||
| */ | ||
|
|
||
| import {UIKIT_GESTURES} from '../motion/uikit/gestures'; |
There was a problem hiding this comment.
UIKIT_GESTURES bundles all eleven groups (its own comment says so, citing architecture:component-style-authoring INV6), and the host reads three values from two of them, so every component that attaches a recognizer will ship the swipe, pinch, rotation and edge rows. Importing UIKIT_PRESS and UIKIT_CLICK removes those; the tap and long-press rows still come in through the TapGestureRecognizer import here and LongPressGestureRecognizer in touchAction.ts, both used only for instanceof.
| this.watchTarget( | ||
| touch.identifier, | ||
| target, | ||
| recognizers.some(claimsWhileMoving), |
There was a problem hiding this comment.
This flag counts disabled recognizers, while refresh gates the element listener on r.isEnabled: a finger under a disabled pan gets a passive: false target listener although nothing on its path can claim it (AST-094 FR19, FR24). recognizers.some(r => r.isEnabled && claimsWhileMoving(r)) matches refresh.
| */ | ||
| function claimsWhileMoving(recognizer: GestureRecognizer): boolean { | ||
| return ( | ||
| recognizer.acceptsTouchInput && |
There was a problem hiding this comment.
A recognizer whose claimsTouches is false never claims, but this still makes its element and target listeners blocking, where AST-094 FR20 says a watcher "registers only passive listeners" (DEC-7). Adding recognizer.claimsTouches && here covers the first watcher a component adds.
| if ( | ||
| registration.recognizers.length > 0 || | ||
| registration.hovers.length > 0 || | ||
| registration.consumer != null | ||
| ) { | ||
| return; | ||
| } |
There was a problem hiding this comment.
This early return skips syncListeners() whenever a disabled recognizer, hover or consumer stays on the element, so detaching the last enabled pan beside a disabled long press leaves every document and window listener registered (AST-094 FR24: "while a touch presentation is disabled, no touch-only listener, observer or timer is active"). Re-syncing before the return fixes it.
Every recognizer and press tracker attaches through the host, so what it imports ships with every touch component (architecture:component-style- authoring INV6). The esbuild bundle of the host alone must carry the press and click groups it reads and never the whole table. It fails on this head: the host imports UIKIT_GESTURES, the union of every group (the same test as the offer on meta#8072).
The host read its values off UIKIT_GESTURES, the union of every group, so any component attaching a recognizer or press tracker bundled all eleven groups. It imports UIKIT_PRESS and UIKIT_CLICK (meta#8468's per-group exports), as the offer on meta#8072 does at that layer, and the bundle test passes.
…slop first meta#8309 lets a scroll under a finger that has not travelled the press slop leave the touch where it is (the page's own scroll, not the finger's pan). The FR30 case from meta#8072 used a still finger, so after the merge its unannounced scroll no longer dropped the touch for the right reason. The finger now travels 30 px (a tolerant long press keeps tracking it) before the recognizer's own write and the unannounced scroll that follows, so the case checks ownScroll alone.
|
Why this note: the host items in this review, and the two host items in #8073's review, are already fixed on the gesture line built above this stack (#8309), each with a test that failed first. You can take any of them down to this branch as you like. One link per item:
The FR20 spec question at the top of this review stays an owner decision; nothing here assumes its answer. |
…ith one passive listener and press-time document listeners
family:action-groups FR5 (and the AST-094 FR19/FR24 exception proposed with
it): the behavior applies to every pointer, mouse and pen included, so a
group must hear a press where the primary pointer is fine, where the host
otherwise registers nothing. attach() takes { actionGroup: true }: under a
fine pointer the group element gets one passive pointerdown listener; a
press on it adds document pointermove, pointerup and pointercancel
listeners (and window blur, plus the click listener for the click the lift
owes), and they go at the release. Only the group's own recognizers and
consumer take that press. Under a coarse pointer the host's usual listeners
serve the group, as every other recognizer.
Tests (failing first): idle under a fine pointer, only the group's one
passive pointerdown and nothing for a recognizer that is not a group; a
press adds the document listeners and the release removes them; a
recognizer that is not a group on the press path takes no part; detach
removes the group listener, and a coarse pointer keeps the usual
listeners.
…nce, so a still finger's long press begins A browser with Touch Events sends them beside the pointer events for one finger. With inputMode 'pointer-events' the host still read both, so the finger was two touches and a long press (one touch required) never began; a moving finger's pan still worked, which hid it. The host now registers no Touch Event listener in that mode: the finger comes from its pointer events alone. Test (failing first): under 'pointer-events', a touch pointerdown plus its touchstart is one touch, and a long press held past its duration begins and ends.
There was a problem hiding this comment.
This pull request is on hold for two spec decisions: the gesture host counts only a finger's first uncancelable touchmove, and never a claimed touch's, as the browser taking it (GestureHost.ts lines 844-879), while the current spec:AST-094 FR20 counts every uncancelable move; and the new action-group path runs recognizers under a fine primary pointer on the strength of family:action-groups FR5 and an FR19/FR24 exception that exist only in the open spec pull request #8622. An engineering owner needs to decide whether FR20 is amended on main or the host follows it, and whether #8622's record and exception become current or the action-group path is dropped, and records that decision by commenting /approve-spec <full head SHA> on this pull request; the author may do so as an engineering owner. Independently of those decisions, these changes are needed:
- Count a native scroll as the browser taking a finger only after its 10 px of travel (AST-094 FR20 and DEC-8, lines 1661-1695).
- Intersect the derived
touch-actionwith the author's instead of writing over it (FR18 and DEC-8, lines 524-555). - Fix the
ownScrollexpiry, theUIKIT_GESTURESimport, the three listener gates and thepointer-eventsclaim (FR30, INV6, FR19, FR20, FR24). - Land #8061 and #8071 first, split or justify the host's 3,053 lines, and add the real-touch browser tests AST-094 names for FR18–FR22.
[Automated review]
| ) { | ||
| continue; | ||
| } | ||
| if (touch.target instanceof Node && scroller.contains(touch.target)) { |
There was a problem hiding this comment.
This takes any unclaimed finger under a scrolling scroller, whatever its travel. Current spec:AST-094 FR20 counts a native scroll "only once that finger has moved the scroll take distance (FR2), so sub-pixel movement under a still finger does not cancel a tap" (DEC-8), so a still finger's press should survive a carousel advancing or scroll anchoring under it. Keep each finger's travel from where it went down and skip fingers under 10 px; the test at GestureHost.test.ts lines 802-811 asserts the takeover with no travel.
| element.style.getPropertyValue(property), | ||
| ); | ||
| } | ||
| element.style.setProperty(property, value); |
There was a problem hiding this comment.
This writes the derived touch-action over the element's own value without reading it. Current spec:AST-094 FR18 (DEC-8) requires the derived value to be "intersected with the element's own author touch-action and … never wider than it", and the author's value wins when it leaves out pinch-zoom: here an author's none becomes manipulation under a tap, and an author's pan-x pan-y becomes pan-y pinch-zoom under a horizontal pan.
| // Pointer Events only: a finger comes from its pointer events alone. A | ||
| // browser that has Touch Events still sends them for the same finger, and | ||
| // reading both would track it twice (a long press needs one touch). | ||
| if (this.inputMode === 'auto') { |
There was a problem hiding this comment.
With inputMode: 'pointer-events' no Touch Event listener is registered, so touchIds stays empty and cancelIfClaimed never cancels: a claimed pan's touchmoves and touchend go through, and the browser can scroll under it (spec:AST-094 FR19, FR21). Map a finger's touches to its pointer-fed arena id for the claim in this mode, or drop the option until a caller needs it.
| if ( | ||
| this.isInstalled || | ||
| this.groupPressPointer != null || | ||
| !isPointerKind(event.pointerType) || |
There was a problem hiding this comment.
Under a fine primary pointer this also accepts touch, so a finger on an action group is tracked by the group's recognizers, while current spec:AST-094 FR19 says that where the primary pointer is fine "no recognizer attaches and no touch listener is registered". The family:action-groups FR5 and FR19/FR24 exception this path relies on exist only in the open spec pull request #8622, so this path waits on an engineering owner's decision.
…t stream Ports cixzhang's meta#8072 action-group press (d2c3c78) onto this pull request's shared input stream, with no change to what it does. Its per-press listeners were registered straight on the document and the window with capture constants this layer no longer has, so the merged host failed to compile, and its window click listener would have doubled the input stream's click subscriber (two onClick calls per click once the host installs, and a listener its guard never removed). The press now subscribes its pointer's moves and lift and the window's own blur through subscribeInput for as long as it is held. It subscribes to click only while the host is not installed, drops that subscription after the lift's task, and an installing host takes it over, so a click is heard once. Her two host tests read the press's listeners on the window, where the stream registers them; a new case counts onClick per click before and after the host installs mid-press, and fails against each of three mutants (no takeover, no drop, no subscription).
…, and under inputMode 'pointer-events' a claimed finger's touchmoves are cancelled - spec:AST-094 FR20 and DEC-8: a native scroll above an unclaimed finger counts as the browser taking it only once that finger has travelled the take distance (10 px) from where it landed. A scroll under a still finger (scroll anchoring, a carousel advancing, a transcript pinned to its end) is the page's own, and the touch and its click stay. (The same rule meta#8309 carried, moved down to the host that introduces it.) - spec:AST-094 FR19 and FR21 under inputMode 'pointer-events': a finger comes from its pointer events, so its touches carried no id of ours and a claim never cancelled them. The blocking touchmove listener now matches a touch to the nearest live pointer-fed finger on its target, so a claimed pan's moves are cancelled and the browser cannot scroll under it. Tests (failing first): 9 px and (7, 7) are not taken, 10 px and (8, 8) are, a still finger is not; the FR30 own-scroll case moves its finger first; under 'pointer-events' a pan's touchmoves pass before its claim and are cancelled after it.
…es its recognizers, and the group keeps answering afterwards Under a fine pointer the host is not installed, so usesTouchEvents keeps its initial true, and a group press's own touch pointermove and pointerup went down the touch-pairing path and returned: the finger's moves and lift never reached the group, its contact stayed in the arena, and the group answered no press after it, a mouse's included. The group press now feeds its own events through the pointer path for every pointer type. Test (failing first): under a fine pointer a finger tap on a group recognizes, and a mouse tap afterwards does too.
There was a problem hiding this comment.
This pull request is on hold for two spec decisions: the gesture host counts only a finger's first uncancelable touchmove, and never a claimed touch's, as the browser taking it (GestureHost.ts lines 844-879), while the current spec:AST-094 FR20 counts every uncancelable move; and the action-group path (lines 1328-1484) runs recognizers under a fine primary pointer on the strength of family:action-groups FR5 and an FR19/FR24 exception that exist only in the open spec pull request #8622. An engineering owner needs to decide whether FR20 is amended on main or the host follows it, and whether #8622's record and exception become current or the action-group path is dropped, and records that decision by commenting /approve-spec <full head SHA> on this pull request; the author may do so as an engineering owner. The newest commits bring the scroll take distance in line with FR20, and independently of the spec decisions these changes are still needed:
- Intersect the derived
touch-actionwith the author's instead of writing over it (FR18 and DEC-8, lines 524-555). - Keep the newest
ownScrollcall's exemption (FR30), import onlyUIKIT_PRESSandUIKIT_CLICK(INV6), gate the three listeners onisEnabledandclaimsTouches(FR19, FR20, FR24), and cancel a claimed finger'stouchendunderinputMode: 'pointer-events'(FR21). - Land #8071 first, split or justify the host's 3,244 lines, and add the real-touch browser tests AST-094 names for FR18–FR22.
[Automated review]
| // Pointer Events only: a finger comes from its pointer events alone. A | ||
| // browser that has Touch Events still sends them for the same finger, and | ||
| // reading both would track it twice (a long press needs one touch). | ||
| if (this.inputMode === 'auto') { |
There was a problem hiding this comment.
Under inputMode: 'pointer-events' this branch registers no Touch Event listener and watchTarget never runs, so although a claimed finger's touchmoves are now cancelled through pointerFingerAt, nothing cancels its touchend: in a probe a claimed pan's lift goes through uncancelled, against spec:AST-094 FR21 (“its touchend MUST be cancelled, and no click or compatibility mouse event reaches the page”); the swallower armed at the pointerup stops only the click. Cancel the claimed finger's touchend from the element listener in this mode, with a test beside the new touchmove one, or drop the option until a caller needs it.
| this.feedMoves(inputs); | ||
| }; | ||
|
|
||
| /** |
There was a problem hiding this comment.
This doc comment describes cancelIfClaimed, but pointerFingerAt and its own comment now sit between them, so it heads the wrong method and cancelIfClaimed (line 921) has none; move it down to that method.
|
Posted by Cindy's agent on her behalf. Cindy's decision on gestures on the web: gestures attach for every pointer, not only touch.
This supersedes the earlier exclusion of mouse press paint. The |
…uchend is cancelled too CodeBunny on 5c59af1: a claimed pan's touchmoves were cancelled, but its lift went through (spec:AST-094 FR21). Where a finger comes from its pointer events, the host notes where a claimed finger's pointerup landed, and the element's blocking touchend listener (registered only in this mode, where the primary pointer is coarse and a recognizer takes touches) cancels the touchend at that place on that target. The cancelIfClaimed doc comment moves back above its method. Test (failing first): a claimed pan's touchend is cancelled; an unclaimed tap's is not.
There was a problem hiding this comment.
This pull request is on hold for two engineering-owner decisions: the gesture host counts only a finger's first uncancelable touchmove, and never a claimed touch's, as the browser taking it (GestureHost.ts lines 874-909), while the current spec:AST-094 FR20 counts every one; and the action-group path (lines 1383-1539) runs recognizers under a fine primary pointer, which current FR19 and FR24 rule out until the every-pointer amendment (#8646) or family:action-groups (#8622) lands. An engineering owner records each decision by amending the record on main or by commenting /approve-spec <full head SHA> on this pull request, and the author may do so as an engineering owner. Independently of those decisions, these changes are needed:
- Intersect the derived
touch-actionwith the element's author value instead of writing over it (FR18, DEC-8). - Give a one-edge screen-edge pan its
pan-xclaim on an element within the edge band and a 10 px start on a wider element (FR17-FR19, DEC-13, DEC-22). - Keep the newest
ownScrollcall's exemption (FR30), re-sync listeners on every detach (FR24), gate the target listener onisEnabled(FR19), and import onlyUIKIT_PRESSandUIKIT_CLICK(architecture:component-style-authoringINV6). - Land #8071 and #8061 first, split or justify the host's 3,367 lines, and add the real-touch browser tests AST-094 names for FR18-FR22.
[Automated review]
| override get nativeTouchBehavior(): NativeTouchBehavior { | ||
| if (this.edges.length === 1) { | ||
| return ALL_NATIVE_TOUCH; | ||
| } |
There was a problem hiding this comment.
For one edge this leaves the browser every touch behavior, and the constructor (lines 47-52) always starts the pan at the table's 15 px. Current spec:AST-094 FR18 has a screen-edge pan claim pan-x in the 40.2 px band on an element within the band and start there at 15 px, and FR17 and FR19 (DEC-13, DEC-22) have it start at 10 px on an element wider than the band. Deriving both from whether the attached element lies within the band, and updating ScreenEdgePanGestureRecognizer.test.ts:61-69, browserNegotiation.test.ts:68-73 and arenaPinchSwipeEdge.test.ts:50-75, would bring it in line.
Depends on #8071 (and its predecessors); review the last commit.
Primary intent
Connect the gesture system to the DOM: one host per document routes touches, pens and mice to the recognizers on the hit path and negotiates with the browser. A claimed touch never scrolls or clicks; an unclaimed one behaves as without the system.
User or maintainer impact
Each touch component negotiates alone, and results drift: ghost clicks after swipes, pinches zooming the page, presses surviving a scroll. The host gives every component one measured negotiation. Known limit: a scroll that begins on a mid-gesture recognizer waits for a busy main thread (measured in iOS Safari; a passive listener loses every diagonal to the scroll).
Problem and solution fit
touchmoveonly before it scrolls.Deltas:
GestureHost:touchmovewithin its slop);touchmovelistener only where a recognizer can claim a moving touch (not taps or press consumers), cancelling while one owns the touch or a pan claims early;pointercancel, a scroll, or an uncancelable move the browser could pan, as the browser taking the touch;touchend, a one-shot swallower) and replay;pointerIdOf(touch): the pointer behind a held finger.touch-actionset on attach, restored on detach; the scroll probe on every touch.Authority
spec:AST-094FR19 (claims), FR20 (the browser taking a touch) and FR21 (clicks), proposed; pending owner review.Change and evidence
Measured in Mobile Safari 26.2 (iOS 26.2 simulator, HID touches):
touchmovearrives from the first pixel;touchmoveis cancelable even 20–30 px out;touchendstops the click and mouse events;The browser-proof fixture passed on the simulator: a horizontal pan claimed over a scroller, a vertical drag left to it, no ghost click, a scroll-edge pull, a pinch on its element only, a long press at exactly 500 ms.
Scope
Testing
pnpm exec vitest run --project ui packages/core/src/gestures packages/core/src/motion: 287 tests passed.tsc --project packages/core/tsconfig.json --noEmit: clean.eslint packages/core/src/gestures: clean.pnpm check:changesets, changeset coverage,pnpm sync:exports:check: clean.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 → #8067 useAnimator → #8071 browser negotiation → #8072 gesture host → #8073 press tracking → #8074 browser proof and README.