Skip to content

[preview, do not merge] FR43 trackpad rescope: swipe rows follow the fingers - #9195

Draft
cixzhang wants to merge 2424 commits into
mainfrom
preview/8676-rows-follow
Draft

cixzhang wants to merge 2424 commits into
mainfrom
preview/8676-rows-follow

Conversation

@cixzhang

Copy link
Copy Markdown
Contributor

Preview build only, for a live trackpad check of the AST-094 FR43 rescope proposed on #8676. Do not merge, review or land; it closes after the check.

vjeux added 30 commits October 27, 2026 05:11
…, which this pull request already carries; landedOn moves above eventTime's comment) into the every-pointer host
…; the drag selection case reads a frame later) into the wheel input
… s of wall clock

The two proofs that a Reduce Motion dip held before its first frame
still lands (after the hold is finished, and after it is resumed) polled
the box for 2 s of wall clock. On a loaded machine the page's main
thread ran the dip's next step late and the poll ran out first: one
WebKit run read {opacity: 0, translate: 'none'} with four WebKit
containers on the box, then passed in 530 ms on a rerun. They now wait
until the box runs no animation, on the animations' own finished
promises (including the fade back in the dip starts when its fade out
ends), and then assert the landed state. The test's own timeout still
bounds them.

With a 2.5 s main-thread block induced right after the release, both
proofs failed before this change (Timeout 2000ms exceeded while waiting
on the predicate) and pass after, 3 runs each in Chromium.
… element moves from under the pointer (spec:AST-094 FR43)

The wheel listener sat on the recognizer's element only, so once a pan
moved its element out from under a still cursor, the next steps landed on
what was under it, went unprevented, and the gesture ended early: of six
16 px trackpad steps, two moved the card. Fails until a wheel gesture is
latched to the recognizer that took it, as a browser latches a scroller.
… it (spec:AST-094 FR43, FR24)

As a browser latches a scroller: from the claim until the gesture ends,
a capture wheel listener on the document hands the host every step first,
wherever it lands, so a pan whose element moves from under a still cursor
keeps the gesture and every step stays prevented. It goes when the gesture
ends (100 ms after its last wheel), and at once when every recognizer that
took it has left: a detached, disabled or cancelled taker gives the rest
of the gesture back to the page. A page listener only during a gesture.
…gh the composed path) into the input hold
…input drivers) into the base of the every-pointer follow-up

# Conflicts:
#	packages/core/src/motion/README.md
) into the base of the every-pointer follow-up
…s attach for every pointer, and the pan's allowed pointer, a finger, is what keeps a mouse and a pen off (spec:AST-094 FR43, spec:AST-057 FR6, AR2)
…rag (real Chromium) or a pen never moves it, a finger's pointer events come with its touches, and the dismissing tap's click goes to the click guard (spec:AST-094 FR43, spec:AST-057 FR6, AR2)
…first lift event, its pointerup or its touchend, whichever the gesture host releases on (spec:AST-094 FR43)
…oes, and so does a mouse wheel's horizontal notch (spec:AST-057 FR4, DEC-7)
…r under the pointer could scroll for it (spec:AST-094 FR43, FR19)

A pull to refresh sits on the content, inside the scroller it pulls: the
host checked only the scrollers between the pointer and the pan's element,
so with scroll input allowed it would take any vertical wheel over the
content, mid-scroll or scrolling down from the top, and break native
scrolling. A finger's scroll-edge rule reads every scroller under it, the
page included, and so must a wheel's. And touch-action narrows only a
finger's pan: content with touch-action none still scrolls its owner with
a wheel. Both cases fail until the wheel reads the same chain.
…er under the pointer (spec:AST-094 FR43, FR19)

The wheel gesture now reads the scrollers under the pointer as a finger's
probe does, its own scroll owner and the page included, with
overscroll-behavior ending the chain, but without touch-action, which
narrows only a finger's pan (createNativeScrollProbe takes
readsTouchAction: false). beginWheel leaves a wheel to the page when a
scroll-edge pan's scrollers could still scroll for it, before anything is
prevented, and the wheel contact carries the probe, so the pan's own
scroll-edge check reads the same chain at its first move. Other pans keep
the rule for the scrollers between the pointer and their element.
…ss (spec:AST-094 FR43, FR24)

A pan with no pointer types and scroll input allowed, such as a pull's
wheel-only pan beside its finger pan, still armed its element for a press
where the primary pointer is fine: a pointerdown listener for a recognizer
no pointer can start. Fails until arming counts only recognizers that take
a pointer.
…t for a press (spec:AST-094 FR43, FR24)

Where the primary pointer is fine, an element is armed for a press while an
enabled recognizer or consumer on it could take one. A recognizer whose
allowedPointerTypes is empty takes only scroll input, so it no longer arms
one: its element keeps the wheel listener alone.
…e pointer, and no menu observes the pointer query (useMenuPress FR9; spec:AST-094 FR24)

Where the primary pointer is fine, a finger held on a DropdownMenu trigger
keeps the released behavior, whose tolerance is 10 px on each axis: an
(8, 8) settle opened the menu, and the long press's distance rule now
cancels it. And every useMenuPress caller, each menu and sub-menu,
subscribed to (pointer: coarse) though only the trigger reads it, when its
press starts. Both fail until the trigger takes its tolerance at the press.
…rts, and no menu observes the pointer query (spec:AST-094 FR29, FR24; useMenuPress FR9)

Every useMenuPress caller, each menu and sub-menu, subscribed to
(pointer: coarse) through useSyncExternalStore, though only the trigger's
held finger reads it. The trigger now reads the pointer as its press
starts and hands useLongPress that press's tolerance (start's new
tolerance argument): FR29's 13px drift where the primary pointer is
coarse, the long press's own 10px where it is fine, as the released
behavior keeps (on each axis, with #8358's measure). A changeset declares
the 13px a finger now gets on phones and tablets.
…entation says

useLongPress documents its tolerance as movement past moveCancelPx on
either axis, a released contract, and ContextMenu's measured hold and the
menu trigger's released behavior keep it. This pull request had changed it
to a distance, which cancels a (9, 9) drift the contract keeps. Fails until
the measure is the documented one again.
…ocumented; only the defaults have one source

useLongPress documents movement past moveCancelPx on either axis, and its
callers keep that: ContextMenu's measured hold is per axis, and the menu
trigger's released behavior keeps the hook's tolerance (useMenuPress FR9).
The defaults, 500ms and 10px, still come from the long-press
recognizer's table, which measures its own tolerance as a distance, as
UIKit's does. The changeset now says what this is: one source for the
numbers, no behavior change; useLongPress.doc.mjs was right all along.
…and a scroller inside a shadow tree from what the wheel landed on (spec:AST-094 FR43)

scrollsBy compared Math.abs(scrollLeft) against left-to-right bounds, so at
a right-to-left scroller's start (scrollLeft 0) it read a scroll right as
the scroller's and a scroll left as nobody's, handing the pan the wheels
the scroller should get. And the wheel's target at a shadow host's
listener is the host itself, so a scroller inside its shadow tree, between
the pointer and the pan, went unseen. Both fail until the host reads them.
…nd the scrollers under what it landed on (spec:AST-094 FR43)

scrollsBy reads scrollLeft against the scroller's own range: from 0 to the
overflow left to right, from minus the overflow to 0 right to left, where
a scroll right moves it toward 0. beginWheel walks, probes and records the
wheel from landedOn, the composed path's first element, so a scroller
inside a shadow tree between the pointer and the pan counts.
…adow root; a claimed touch's moves are cancelled where fingers come from pointer events

With targets from the composed path, a finger inside an open shadow root
sits below the scroller only along the flat tree, which Node.contains does
not follow: a scroll under it did not take it, and its flick did not arm
the scroll brake. And where the host reads fingers from pointer events
(inputMode 'auto' with no ontouchstart), the blocking touchmove found no
finger for a claimed pan's touch, so its moves went to the page. Three
cases fail until the host compares along the flat tree and finds that
finger; a fourth pins a finger scroll inside a shadow root.
… tree; a claimed touch finds its pointer finger wherever fingers come from pointer events

containsAlongFlatTree walks parentAcrossShadow, so a finger inside an open
shadow root, now that targets come from the composed path, counts as under
a scroller outside it: a scroll there takes it, its flick arms the scroll
brake, and a touch there brakes. cancelIfClaimed matches a touch to its
pointer finger whenever the host reads fingers from pointer events, not
only under inputMode 'pointer-events', so a claimed pan's moves are
cancelled where a browser that exposes no ontouchstart still sends Touch
Events.
…t tree, and a claimed touch finds its pointer finger wherever fingers come from pointer events) into #8320
…the flat tree, and a claimed touch finds its pointer finger wherever fingers come from pointer events) into #8293
@vercel

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

Request Review

@astracat-bot

astracat-bot Bot commented Oct 31, 2026 •

Copy link
Copy Markdown

AI review status for this pull request.

Review status Updated
❓ Needs maintainer input (for maintainers only) Oct 31, 2026, 10:29 PM UTC

@github-actions github-actions Bot added the needs:design-review Affects visuals — Design should review label Oct 31, 2026
@github-actions github-actions Bot added the needs:spec-owner-review Current knowledge records await owner approval label Oct 31, 2026
@meta-cla meta-cla Bot added the CLA Signed This label is managed by the Meta Open Source bot. label Oct 31, 2026

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

This preview can't be approved as it stands: it changes established row-swipe behavior and carries far more than its own change. Rows no longer take a mouse's horizontal wheel, which AST-057 FR4 and DEC-7 require, and trackpad pans now release at momentum onset or still, which AST-094 FR43 does not state; an engineering owner needs to settle that rescope and update those records, recording the decision by commenting /approve-spec <full head SHA> on this pull request (the author may do so as an engineering owner). Whatever is decided:

  • Carry only the 339-line own change (for example against the #8690 branch); the compare view holds about 54,000 lines of unmerged base pull requests.
  • After the release at momentum onset, the remaining momentum reaches the page because the taker check in GestureHost.ts runs before the absorb branch, and that release reads 0 px/s.
  • Three carried tests (the trackpad end and speed cases in wheel.test.ts and the mouse-wheel notch case in Item.test.tsx) contradict this head and were not updated.

[Automated review]

// (DEC-7) through the pan's default scroll input (FR43): the same
// contact, moving with the content.
allowedPointerTypes: ['touch', 'pen'],
allowedScrollTypes: ['continuous'],

Copy link
Copy Markdown

Choose a reason for hiding this comment

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

With continuous only, a mouse's horizontal wheel (a 100 px notch classifies as discrete) no longer swipes the row. AST-057 FR4 and DEC-7 say it must, the comment at lines 1249-1251 still says it does, and Item.test.tsx:1299-1306 expects it. This needs an engineering owner's decision on the FR43 rescope before it can stay.

gesture != null &&
gesture.ids.length > 0 &&
gesture.step == null &&
!this.wheelHasTaker(gesture)

Copy link
Copy Markdown

Choose a reason for hiding this comment

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

Once the follow mode ends the contact at momentum onset (line 2095), the taker has ended, so this check hands the next wheel to the page (ids: [], no preventDefault) before the follow.lifted branch at line 2016 can absorb it. The remaining momentum, and a new swipe started inside it, scroll whatever is under the pointer. Adding !gesture.follow?.lifted here, as gesture.step == null does for the step mode, keeps the gesture.

f.lifted = true;
f.heldX = 0;
f.heldY = 0;
this.arena.ended([{id: gesture.ids[0], x: gesture.x, y: gesture.y, time}]);

Copy link
Copy Markdown

Choose a reason for hiding this comment

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

The decaying wheels before this were held, not moved, so the release is stamped about three wheels (about 48 ms at 16 ms per wheel) after the last moved sample. That is past the 40 ms stillness window in VelocityTracker.releases, so it reads 0 px/s, not the fingers' speed the comment promises, and a quick flick decides by position alone. Stamping the release at the last applied move's time keeps the speed.


type Row = {gap: number; mode: number; dx: number; dy: number; wdx: number | null; wdy: number | null; kind: string};

function classify(e: WheelEvent): string {

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 scrollTypeOf from GestureHost.ts and has already drifted: with no wheelDelta it reports "older rule" instead of applying the 120-tick and notch rules. Its Effect at lines 37-51 attaches a listener a callback ref could own. As a preview-only story it is best dropped before anything merges.

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

This preview can't be approved as it stands: it changes established row-swipe behavior and carries far more than its own change. Rows no longer take a mouse's horizontal wheel (AST-057 FR4, DEC-7), trackpad-driven rows play their own unmeasured spring and dead zone (AST-094 FR1), and trackpad pans now release when momentum is read to start, or still, which AST-094 FR43 does not state. An engineering owner needs to settle that rescope and update those records, recording the decision by commenting /approve-spec <full head SHA> on this pull request; the author may do so as an engineering owner. Whatever is decided:

  • Carry only the roughly 500-line own change, for example against the #8690 branch; the compare view holds about 54,000 lines of the stack underneath.
  • After the release at momentum onset, the remaining momentum still reaches the page, and that release carries 0 px/s.
  • The new wheel splitter also splits ctrl-wheel pinches, and mouse-wheel and pinch gestures now end at 180 ms instead of 100 ms.
  • Eight carried tests in wheel.test.ts and Item.test.tsx fail at this head.

[Automated review]

Comment on lines +2006 to +2011
if (
gesture != null &&
gesture.ids.length > 0 &&
gesture.step == null &&
!this.wheelHasTaker(gesture)
) {

Copy link
Copy Markdown

Choose a reason for hiding this comment

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

After the follow mode ends the pan's contact at the read lift (line 2101), this check finds no taker and hands the rest of the gesture to the page, so the momentum is never prevented and the f.lifted branch at 2083-2085 never runs. A probe at this head: the five wheels after the release were not prevented. Adding !gesture.follow?.lifted to this condition keeps the momentum absorbed.

f.lifted = true;
f.heldX = 0;
f.heldY = 0;
this.arena.ended([{id: gesture.ids[0], x: gesture.x, y: gesture.y, time}]);

Copy link
Copy Markdown

Choose a reason for hiding this comment

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

The four shrinking wheels before this were held, not moved, so this release is stamped 64 ms after the last applied move and reads 0 px/s (a probe pan at 750 px/s ends at 0). A quick trackpad flick then opens a row only past half the panel (AST-057 FR5). Stamping the release at the last applied move's time keeps its speed.

Comment on lines +2041 to +2046
if (seg.boundary != null) {
this.lastBoundary = seg.boundary;
if (gesture != null) {
this.endWheel(time);
gesture = 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.

This ends any live wheel gesture at a splitter boundary, a ctrl-wheel pinch included: a pinch that slows for four wheels and then reverses is split, and in a probe its scale fell from 1.865 to 0.984. Pushing only pan wheels, or ignoring boundaries for kind === 'pinch', keeps a pinch whole.

Comment on lines +2069 to +2071
gesture.step != null && !gesture.step.committed
? WHEEL_STEP_CARRY_MS
: gesture.follow?.endMs ?? WHEEL_FOLLOW_END_V_MS,

Copy link
Copy Markdown

Choose a reason for hiding this comment

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

For a gesture without follow mode (a mouse wheel's notches, a ctrl-wheel pinch) the end timer is now 180 ms, while the quiet-gap check (1998-2001) and the end stamp (2448) keep WHEEL_END_MS (100). wheel.test.ts:486 and :611 fail on it. Keeping gesture.follow?.endMs ?? WHEEL_END_MS here restores the 100 ms end.

// =============================================================================

/** Scratch: a trackpad drag tracks and settles on a quick, critically damped spring. */
const TRACKPAD_SPRING = {mass: 1, stiffness: 1600, damping: 80};

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 trackpad-driven row tracks and settles on this spring (about four times faster than the measured swipeActions.tracking and settle) with an 8 px dead zone instead of the measured 25 px hysteresis (lines 1134, 1179). AST-094 FR1 asks every component playing the same motion to use the measured value, and FR2 asks a guess to be labelled with its measurement, so this needs an owner's decision or the measured values.

…he fingers' speed (stamped at their last move); unit case

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

This preview can't be approved as it stands: it changes established row-swipe behavior and carries far more than its own change. Rows no longer take a mouse's horizontal wheel (AST-057 FR4, DEC-7) and trackpad-driven rows play their own unmeasured spring and dead zone (AST-094 FR1), while trackpad pans now release at momentum onset or still after a pause, which AST-094 FR43 does not state; an engineering owner needs to settle that rescope and update those records, recording the decision by commenting /approve-spec <full head SHA> on this pull request (the author may do so as an engineering owner). Whatever the decision, a trackpad flick's momentum still reaches the page after the release, the wheel splitter also splits ctrl-wheel pinches, six of the branch's own tests contradict this head, and the preview should carry only its own change rather than the whole stack beneath it.

[Automated review]

Comment on lines +2006 to +2010
if (
gesture != null &&
gesture.ids.length > 0 &&
gesture.step == null &&
!this.wheelHasTaker(gesture)

Copy link
Copy Markdown

Choose a reason for hiding this comment

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

After the follow mode ends the pan's contact at the read lift (line 2106), this check finds no taker and hands the rest of the gesture to the page (ids: [], no preventDefault), so a trackpad flick's momentum scrolls whatever is under the pointer and the absorb branch at line 2085 never runs. Adding !gesture.follow?.lifted to this condition would keep the momentum with the lifted gesture.

Comment on lines +2043 to +2048
if (seg.boundary != null) {
this.lastBoundary = seg.boundary;
if (gesture != null) {
this.endWheel(time);
gesture = 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.

This ends any live wheel gesture at a splitter boundary, a ctrl-wheel pinch included: a pinch that slows over four wheels and then reverses, speeds up or pauses 50 ms is split, and the new pinch starts again at scale 1. Pushing only pan wheels to the splitter, or ignoring boundaries for kind === 'pinch', would keep pinches whole.

Comment on lines +2449 to +2452
// A trackpad's scroll releases at its last wheel, with its speed: its
// momentum arrives as more wheel. A mouse wheel's notches release still,
// past the velocity's stillness interval, so position alone decides
// where a pan settles (the coordinator's ruling; FR43 is silent).

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 says a trackpad's scroll releases at its last wheel with its speed, but a followed pan's idle end is stamped follow.endMs (220 or 180 ms) later and releases still (line 2456). Worth rewording with the code.

Comment on lines +1262 to +1267
// (`spec:AST-057` FR4, FR6, AR2). A trackpad's horizontal scroll and
// a mouse wheel's horizontal notches swipe it as a finger does
// (DEC-7) through the pan's default scroll input (FR43): the same
// contact, moving with the content.
allowedPointerTypes: ['touch', 'pen'],
allowedScrollTypes: ['continuous'],

Copy link
Copy Markdown

Choose a reason for hiding this comment

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

With continuous only, a mouse's horizontal wheel (a 100 px notch classifies as discrete) no longer swipes the row, which AST-057 FR4 and DEC-7 require, while the comment above still says the notches swipe it. This is part of the rescope that waits on an owner decision.

const now = e.timeStamp;
const legacy = e as WheelEvent & {wheelDeltaX?: number; wheelDeltaY?: number};
const horizontal = Math.abs(e.deltaX) > Math.abs(e.deltaY);
const seg = segmenter.push(now, horizontal ? e.deltaX : e.deltaY, horizontal ? 220 : 180);

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 idle is hard-coded to 220/180 ms for every gesture, while the host now passes 100 ms for mouse wheels and pinches (GestureHost.ts:2036-2037); with the copied classifier above, the probe can disagree with the host it is meant to show. Calling the host's own rules, or dropping the preview story, avoids the drift.

This branch was successfully deployed

1 active deployment
Preview — 2a4f2ff6 Deployed Oct 31, 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:design-review Affects visuals — Design should review 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