Skip to content

fix(core): a trackpad swipe moves the pager at most a page from where it began, a spike releases still, and back-to-back swipes page on - #8692

Closed
vjeux wants to merge 14 commits into
meta:mainfrom
vjeux:uikit/c23-fr43-pager
Closed

vjeux wants to merge 14 commits into
meta:mainfrom
vjeux:uikit/c23-fr43-pager

Conversation

@vjeux

@vjeux vjeux commented Oct 27, 2026 •

Copy link
Copy Markdown
Contributor

On #8146 (on #8145, on #8676, on #8360). Review only this PR's change: vjeux/astryx@9d14d11...874552f

User need

A trackpad swipe should page a pager the way spec:AST-094 FR43's pager rule says: one page at a time, never on a speed spike, and back-to-back swipes page on.

Problem and solution fit

#8146 attaches the pager for every pointer and follows a trackpad's horizontal scroll. This PR adds FR43's swipe rules, red first:

  • A trackpad swipe moves the track at most one page from the page it began on, across a loop too; a pager that does not loop keeps its first and last pages' rubber band.
  • A swipe that lands while the last one settles begins from that settle's page, so back-to-back swipes page on.
  • A trackpad swipe that moved under a tenth of a page releases still, whatever its speed; a finger's or a mouse's flick keeps its speed.

Current specification

spec:AST-094 FR43's trackpad rule and DEC-24, as merged in #8905.

Public delta and caller burden

None: internal. #8146's [chore] changeset now names the cap, the spike and the settle start.

Design and alternatives

Evidence

  • In WebKit, a 525 px trackpad scroll stops at the next page (−804 px) and pages once; before, it carried the track to −902 px. Chromium skips the case: its CDP wheels read as a mouse's wheel.
  • Unit cases, after FR43's list: a 1,000 px scroll stops at the next page, and in a loop at the copy of the first page past the last; a 42 px spike on a 480 px page releases still, while a mouse's short flick still pages; a swipe during a settle lands two pages on.

Scope

  • One capability is added.
  • Unrelated fixes, cleanup, and policy changes were removed or split.
  • Consumer docs, focused tests, and migration evidence ship with the feature.
  • Public text and artifacts contain no internal Meta context.

Testing

  • Red first: c7f6c48 (the one-page cap, in a loop too, the 42 px spike, back-to-back swipes). In WebKit the 525 px scroll carried the track to −902 px, past the next page at −804; in a loop the 1,000 px scroll carried it to −1,790 px, past the next page at −1,200, as it still did with a cap that skipped looping pagers.
  • Local gates at 874552f:
    • root build, typecheck, eslint (0 errors), check:knowledge, test:ui 17,844 passed, changesets, Prettier;
    • Chromium, the pager spec ×5 on two pinned cores, four workers: 114 passed, 10 skipped (the trackpad cases), 1 failed: the busy-thread proof once (5 frames during the block against 6), report-only at four workers; at one worker ×5: 5 passed;
    • WebKit in a 4-CPU container (one worker, LP_NUM_THREADS=4) ×10: 220 passed, 30 skipped.
  • Size: 4 files, 177 lines added, 10 removed: the hook +54 −4; tests +122 −5 (jsdom +116 −2, browser +6 −3); the changeset +1 −1.

Stack: #8360 → #8676 → #8145 → #8146 → this PR (the red commit, then the fix), no merges.

@vercel

vercel Bot commented Oct 27, 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 1, 2026 2:16pm UTC

Request Review

@astracat-bot

astracat-bot Bot commented Oct 27, 2026 •

Copy link
Copy Markdown

AI review status for this pull request.

Review status Updated
❓ Needs maintainer input (for maintainers only) Nov 1, 2026, 2:31 PM 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 is on hold for an engineering owner's spec decision: on main, spec:AST-094 FR19 and FR24 still keep recognizers and touch listeners to a coarse primary pointer and there is no FR43, so the pager attaching for every pointer and taking wheel and trackpad input needs FR19 and FR24 updated and FR43 added on main (as the open spec pull request #8646 proposes), or a ruling that the pager stays coarse-only. An engineering owner records that decision by commenting /approve-spec followed by the full head SHA on this pull request, and the author may do so as an engineering owner. Whatever the decision, these changes are still needed:

  • Land the stack first and rebase, so this pull request's diff against main is its own four files rather than 37,353 authored lines from 32 open pull requests.
  • At the pager's first or last page, let an outward wheel or trackpad scroll continue to the ancestor, as spec:AST-025 FR14 requires, and add a test for it.
  • Update Carousel's docs, comment and changeset, which say a mouse and a trackpad keep native snapping; under a coarse primary pointer they now drive the pager.

[Automated review]

settled = target;
}
animator.set({x: positionToX(offsetOf(target))});
// The recognizers, for every pointer (FR19, FR43).

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 attaches the pan and the catch for every pointer, but spec:AST-094 FR19 on main still says recognizers attach only where the primary pointer is coarse, and FR24 allows no touch-only listener under a fine pointer (the attached pan keeps a blocking touchmove on the viewport). The FR19/FR24 amendment and FR43 are in the open spec pull request #8646, so this waits on that decision.

onIndexChange: () => pageStore.notify(),
onPagesChange: () => pageStore.notify(),
onSettle: () => promote(),
shouldBeginPan: () => {

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 the new wheel default, this lets the pan take a wheel at every page, so at the first or last page an outward trackpad or wheel scroll rubber-bands and is cancelled instead of continuing to an ancestor, which spec:AST-025 FR14 requires. Leaving the wheel to the page when there is no page in that direction, with a test at the last page, would fix it.

name: 'hasSnap',
type: 'boolean',
description:
'Enable scroll-snap so each child snaps to the start edge. Under a finger, with the buttons shown, a swipe moves one child and settles like an iOS paging scroll view; a mouse, a trackpad, the keyboard, and a finger with hasButtons={false} (the buttons are the single-pointer way to page) snap natively.',

Copy link
Copy Markdown

Choose a reason for hiding this comment

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

Under a coarse primary pointer with the buttons shown, a mouse drag and a horizontal trackpad or wheel scroll now drive the pager rather than native snapping. This line, line 344, the comment at Carousel.tsx:420 and the carousel-paging changeset could state the pointer condition.

@github-actions

github-actions Bot commented Oct 27, 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

@meta-cla meta-cla Bot added the CLA Signed This label is managed by the Meta Open Source bot. label Oct 27, 2026
@cixzhang cixzhang closed this Oct 28, 2026
@cixzhang cixzhang reopened this Oct 28, 2026
@cixzhang

Copy link
Copy Markdown
Contributor

Posted by Cindy's agent on her behalf: closed and reopened only to restart this PR's CI on the current workflow after tonight's queue-wide cancel; nothing in the PR changed.

@vjeux
vjeux marked this pull request as draft October 29, 2026 22:39
vjeux added a commit to vjeux/astryx that referenced this pull request Oct 30, 2026
vjeux added a commit to vjeux/astryx that referenced this pull request Oct 30, 2026
vjeux added a commit to vjeux/astryx that referenced this pull request Oct 30, 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.

Approval is on hold for an engineering owner's spec decision: this head adds new text to spec:AST-094 FR41 (a mouse or pen drag that a gesture claims takes no new text selection, and a claim made after the drag moves clears the page's selection), which FR41 on main does not state, while the every-pointer pager and its wheel input already match FR19 and FR43 on main. An engineering owner records that decision by commenting /approve-spec followed by the full head SHA on this pull request, and the author may do so as an engineering owner. Whatever the decision, these changes are still needed:

  • Land the stack first and rebase, so this pull request's diff against main is its own four files rather than 37,686 authored lines from 32 unlanded pull requests.
  • At the pager's first or last page, let an outward wheel or trackpad scroll continue to the ancestor, as spec:AST-025 FR14 requires, and add a test for it.
  • Update Carousel's docs, comment and changeset, which say a mouse, a trackpad and the keyboard keep native snapping and that Shift + wheel moves the items; under a coarse primary pointer with the buttons shown, these inputs go through the pager.

[Automated review]

acts as a finger's; a mouse makes the context request by a right click, not
a long press (FR43), and otherwise keeps its released behavior
(`spec:AST-017` FR59; DEC-9).
(`spec:AST-017` FR59; DEC-9). A mouse or pen drag that a recognizer claims

Copy link
Copy Markdown

Choose a reason for hiding this comment

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

These FR41 sentences are new: on main, FR41 keeps a mouse's released selection behavior, and its verification row fails any change to mouse selection. The gesture host already implements them (lockClaimedDrags in GestureHost.ts), so approval waits on an engineering owner's decision on this text, recorded with /approve-spec and the full head SHA, or FR41 stays as it is on main.

{
guidance: true,
description:
'Trust the built-in navigation: trackpad users can swipe horizontally, and mouse users can hold Shift while scrolling the wheel to move through items.',

Copy link
Copy Markdown

Choose a reason for hiding this comment

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

Under a coarse primary pointer with the buttons shown, the viewport is a clip box (Carousel.tsx:255-263). Wherever the browser reports Shift + wheel as a vertical delta, the gesture host leaves it to the page and handleWheel's scrollBy cannot move a clip box, so this advice stops holding there; line 317 says the same.

vjeux and others added 5 commits November 1, 2026 01:11
meta#8360's change on the internal touch foundation: one click guard shared
by the menu press and the gesture host (its click listener stays once
the menu press arms it, as its record has it, and lasts only while armed
for the gesture host, FR24), the host as the one touch owner (finger
scroll, page holds, attachments, the press tracker hook and a held
control's kept touch, the long press's single source, presented hit
testing on iOS WebKit), its cases in the gesture browser proof (Chromium
and WebKit) and the README. It carries meta#8074, meta#8270, meta#8293, meta#8315, meta#8320
and meta#8356, and folds in meta#8358 (the long press's tolerance comment and an
unused import). Rebased onto main after section 2 as one commit; the
touch-action rule came with meta#8071. Where fingers come from their pointer
events, a claimed finger's touchend is cancelled from its element
(FR21), under a fine primary pointer wherever the browser sends Touch
Events and inside an open shadow root too; at most eight such lifts wait
for their touchend, and each goes once taken or after 500 ms, kept
across the host's uninstall, which can run in the microtask after the
pointerup, before the touchend that comes later in the same task (FR24).
An armed press's page listeners go once the arena has decided its last
touch, a tap that waits on a double tap included, and after its last
detach the host holds no listener, note, frame or timer (FR24).

The animator's busy proof is judged against the harness's compositor-only
control (meta#8906), with a main-thread mutant as its negative, and the
spring's curve is placed against the control's own clock, so the late
frames a loaded runner sends fail nothing.

With the host serving every pointer (meta#8071), this change's own pieces
follow it: a press tracker takes a mouse press as a press, painting at
once and braking no scroll (FR11); a finger's or a pen's hold that does
something keeps text unselected under any primary pointer, while a mouse
keeps its released behavior (FR41); and `perEventPointer`, from before
FR43, changes nothing. These come from meta#8665, which keeps only the
undecided rule for text selection during a mouse or pen drag.

The hover recognizer and the host's hover routing land with hover's
first user.

Co-authored-by: Cindy Zhang <cindyxz@meta.com>
…finger landed

On iOS WebKit the host resolves moving content at touch-down and asks the
engine what is under the finger. When a busy main thread hands it the touch
later, the content has moved on: the host now asks at the point where what
was under the finger at the touch's timestamp is drawn, so a tap on a moving
sheet's Done still clicks Done (spec:AST-094 FR11). A presented source
reports its offset at a given time for this.

The presented browser proof drives the sheet with Core's animator and a
critically damped spring, up into place and down off the screen over the
scrim: a tap on the drawn Done clicks it once, and a drag on the drawn sheet
catches it where it is drawn, in both directions.
…the finger (FR11, FR19); INV13 names the shared input listener

Where the engine hit-tests an element a compositor animation moves at the
motion's start (iOS WebKit), a touch on that content goes to what was drawn
under the finger at touch-down, and a tap there activates that element with
one click while the browser's own click is cancelled or swallowed (FR11).
While such content around a recognizer that claims moving touches is moving,
the document may carry a non-passive touchmove listener that cancels only
owned moves and comes off when the motion ends; while it is on, a native
scroll waits for a free main thread (FR19). The gesture host, press
feedback, page hold, finger-scroll watch and click guard hear document-wide
input through one capture-phase listener per event type on the window
(architecture:react-component-runtime INV13).
…view fixes

A touch the host handles late now also leaves alone content that slid
under the finger after it landed: the touch goes to what was beneath it
then (spec:AST-094 FR11). The input-stream changeset says what moved:
the gesture host's listeners, and the press feedback's scroll and
visibility listeners, since its pointer listeners were already on the
window's capture phase. The coarse-pointer gate reads TOUCH_QUERY from
touchQueries.ts instead of spelling it again.

The WebKit interaction job's scope covers every module the gesture host
now bundles: hooks/isRtlElement and utils' flatTree, hapticSwitch,
inputStream, clickGuard, selectionLock and coarsePointer. The scope
test's example of a util outside it moves from clickGuard, which the
gesture fixture now bundles, to focusReturn.

The presented proof checks that the sheet moves on a spring (its speed
changes along the animator's curve) and no longer compares the drawn box
with the animator's offset: under a 3-CPU cap the two disagree by 3 to
27 px in about half the frames whatever reference is used, while the tap
and drag cases go through the host's real path.
…e origin most frames agree with

Under the CI lane's WebKit profile (four CPUs, one worker), "a spring keeps
advancing every frame through a 1 s main-thread block" failed in two ways,
each about once in a few hundred runs, and a failing run's frames show each
cause:

- The spring drifted 138.6 ms against the control. The control's clock took
  its origin from the first usable frame alone, reading that frame's
  direction from the next usable one: the frame sat at x 210 on its way out,
  the next usable one at x 210 after the turn, and the tie read as "back".
  The clock now lives in controlClock.ts and takes the origin most frames
  agree with. On that run's own frames it reads 6.5 ms (bound 12), and its
  unit case for that layout fails on the old reading (150.7 ms astray).
- The spring "moved on 61 of the 62 frames the control moved". At the
  block's end it was in its tail, under two of the cast's 1 px steps a
  frame, where a frame delivered early shows no step and the next catches
  up two. The harness now reads such a tail at its own pace
  (busyWindowProof), and the proof judges the whole block: on that run's
  own frames the earlier proof fails it and this one passes.

No bound changes.
vjeux added 6 commits November 1, 2026 01:11
… (architecture:public-component-api INV23)

Main's lint rule now keeps every private data attribute in its owner module.
This change had moved `data-astryx-haptic-switch` from the haptics module to
utils/hapticSwitch.ts, so the click guard could skip the hidden switch
without importing haptics (architecture:react-component-runtime INV13). The
name now lives in privateAttributes/hapticSwitch.stylex.ts, modelled on
segmentDisabled.stylex.ts: it exports markHapticSwitch, which the haptics
module uses on its hidden switch, and isHapticSwitch, which the click guard
reads. utils/hapticSwitch.ts and the deprecated gestures/hapticSwitch.ts
re-export go, the haptics module's baseline entry goes with its raw name,
and the WebKit interaction job's scope covers the new module. No behavior
changes.
…trl-wheel (spec:AST-094 FR43)

meta#8676's reviewed change, rebuilt as one commit on meta#8360's head: a wheel
gesture drives the recognizers that allow its scroll type as a contact
moving with the content, a pan along its own axis once every scroller
between it and the page is at its edge, and a ctrl-wheel drives a pinch.
A screen-edge pan stays a finger's.
…ain input reads as a mouse wheel, and a pan can take a swipe as one step (spec:AST-094 FR43)

The round-4 trackpad behaviors, as a draft for the owner to try each one:

- wheelSegmenter.ts splits the wheel stream into gestures and reads a
  trackpad's lift: once a run of shrinking deltas has lost a fifth of its
  size, momentum began. A pan the host follows ends there at the fingers'
  travel, released at their last move with their speed, and the momentum
  after it never moves the pan. Fingers that stop release still, 180 ms
  (vertical) or 220 ms (horizontal) after their last wheel.
- Input the deltas cannot place reads as a mouse wheel: a delta a hair off a
  whole pixel, a scroll with no legacy wheelDelta (an engine that sends
  none), a legacy wheelDelta that is not three times the delta, or a 120
  tick over a 40 px delta.
- A pan created with wheelStep takes a trackpad swipe as one step: a short
  flick once the swipe travels 24 px, a slow swipe's travel carried across
  pauses, its slow tail absorbed, and one undo back to rest.

A mouse wheel's gesture and a pinch keep their 100 ms end. In Chromium the harness's wheels arrive as notches (wheelDeltaY -120 whatever the delta), which now read as a mouse wheel, so the two trackpad-only browser cases run in WebKit, whose driver sends a trackpad's precise deltas. The lift's
release is stamped at the fingers' last move: stamped at the lift, three
held wheels later, it read as a stop and lost the fling.
…speed until the splitter decides, so it never stalls before a lift (spec:AST-094 FR43)

The splitter reads a lift only after four shrinking deltas, so a pan that
held them stood still for that stretch (about 64 to 130 ms) and then let go
at the fingers' speed: a stop, then a go. Through the undecided stretch the
pan now moves on from where it last was at the fingers' last speed, never
past what that speed projects for the time since. At the lift it keeps that
travel and lets go there, still moving at that speed, and nothing more
moves. When the fingers carry on, their moves absorb what the stretch showed
past their travel or short of it, half a move at a time, so the pan never
steps back and ends on their travel; fingers that stop without a lift end on
their travel where it runs ahead.
…t, and never a mouse wheel (spec:AST-094 FR43, DEC-24)

The merged record decides it: a trackpad's two-finger scroll is direct
manipulation, so a pan follows it where its record allows it; a mouse
wheel's notches are a scrolling control, so a pan takes none, and a scroll
without the trackpad signature, Shift+wheel included, counts as a wheel's.

- A pan's allowedScrollTypes takes only 'continuous', and none by default,
  as UIKit's allowedScrollTypesMask: the sheet, the pager and swipe rows opt
  in. The host never feeds a wheel's notches to a pan, whatever a caller
  passes. ScrollType keeps 'discrete' for the pinch, which takes a
  ctrl-wheel from a trackpad or a mouse.
- A scroll with Shift held reads as a mouse wheel's.
- wheelStep goes: DEC-24 rejects a discrete step per trackpad swipe for rows
  and paging, and nothing used it.
- The splitter's verification streams: ten swipes that each start in the last
  one's momentum, ten whose momentum runs out, and a slow drag with 150 ms
  pauses count ten, ten and one gestures.
UIKit's paging values for touch presentations: the thresholds that pick the page a release settles on, the settle that carries it there and setContentOffset(animated:), measured on iOS 26.2 and replayed against the recorded frames to under half a pixel. Not exported from the package entry point.
@vjeux vjeux changed the title feat(core): the pager takes every pointer and the wheel (spec:AST-094 FR43) feat(core): the pager takes every pointer and a trackpad's scroll (spec:AST-094 FR43) Nov 1, 2026
@vjeux
vjeux force-pushed the uikit/c23-fr43-pager branch from 0ab33bc to 1507a14 Compare November 1, 2026 11:21
@github-actions github-actions Bot added the needs:spec-owner-review Current knowledge records await owner approval label Nov 1, 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.

Approval is on hold for an engineering owner's spec decision: this head adds new text to spec:AST-094 FR11 and FR19 and to architecture:react-component-runtime INV13 (carried from #8360) that main does not state, while the pager's own every-pointer and trackpad behavior already matches FR19, FR43 and DEC-24 on main. An engineering owner records that decision by commenting /approve-spec followed by the full head SHA on this pull request, and the author may do so as an engineering owner. Whatever the decision, these changes are still needed:

  • Land #8360, #8676, #8145 and #8146 first and rebase, so this diff is the pager's own four files rather than 25,390 lines.
  • Start a trackpad swipe from the settle's page only when the track is within a page of it; during a multi-page goTo the track now jumps to the page before the target.
  • Measure the tenth-of-a-page still release from where the swipe began, so a short flick late in a settle still pages on and a spike there does not reverse it.
  • Apply the one-page cap on a looping pager too, and move the pager's and useFingerScroll's attach Effects onto useAttachment.

[Automated review]

Comment on lines +958 to +961
dragStartIndex =
wasSettling && isScrollContact(recognizer)
? target
: nearestPage(current);

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 swipe that lands during any motion starts from target, and the clamp below then pins the track to the page before it. While goTo(3) carries the track from page 0, a swipe that begins at 0.8 pages moves the track to page 2 in one step (−390 px to −960 px). Taking target only when the track is within a page of it (a swipe's own settle), else nearestPage(current), keeps the track under the fingers (spec:AST-094 FR43).

Comment thread packages/core/src/hooks/usePager.ts Outdated
const dx = recognizer.translation().x;
// Finger right (dx > 0) moves content toward earlier pages in LTR.
let raw = dragStart + sign() * dx;
if (!isLooping() && isScrollContact(recognizer) && pageWidth > 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.

The one-page cap is skipped for a looping pager: a 1,000 px trackpad scroll carries the track 2.06 pages before it settles, though the doc comment, the changeset and FR43 say at most one page. Clamping to offsetOf(dragStartIndex ± 1) works for looping too, since its offsets are unbounded.

Comment on lines +1009 to +1015
if (
isScrollContact(recognizer) &&
Math.abs(dragPosition - offsetOf(dragStartIndex)) <
STILL_RELEASE_FRACTION * pageWidth
) {
velocity = 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.

This measures from the settle's page, not from where the swipe began. On a 480 px page, a 61 px flick that lands 20 px short of page 1 stays on page 1 (the same flick from rest pages on), and a 33 px backward spike that lands while the track is 0.83 of the way to page 1 reverses to page 0. Comparing with the position at began matches FR43's "moved it less than a tenth of a page".

Comment thread packages/core/src/hooks/usePager.ts Outdated
Comment on lines +1342 to +1354
useIsomorphicLayoutEffect(() => {
const viewport = viewportRef.current;
const track = trackRef.current;
const attached = attachedRef.current;
if (viewport === attached.viewport && track === attached.track) {
return;
}
attached.detach?.();
attached.viewport = viewport;
attached.track = track;
attached.detach =
viewport != null && track != null ? pager.attach(viewport, track) : 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 is useAttachment (gestures/useAttachment.ts:68-99) for two refs; useFingerScroll has a third copy. Extending useAttachment to take several refs (or a document) and calling it here keeps one attach path for every gesture hook.

@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 is on hold for an engineering owner's spec decision: this head adds new text to spec:AST-094 FR11 and FR19 and to architecture:react-component-runtime INV13 (carried from #8360) that main does not state, while the pager's own every-pointer and trackpad behavior already matches FR19, FR43 and DEC-24 on main. An engineering owner records that decision by commenting /approve-spec followed by the full head SHA on this pull request, and the author may do so as an engineering owner. Whatever the decision, these changes are still needed:

  • Land #8360, #8676, #8145 and #8146 first and rebase, so this diff is the pager's four files instead of 25,601 lines.
  • Cap a looping pager's trackpad swipe at one page, as FR43 requires (usePager.ts:972).
  • Start a swipe that lands during a multi-page goTo from where the track is, and measure the tenth-of-a-page still release from where the swipe began (usePager.ts:962-965, :1013-1019), each with a unit case.
  • Move useFingerScroll's attachment lifecycle onto the shared helper in useAttachment.ts.

[Automated review]

const attachedRef = useRef<{node: Node; detach: () => void} | null>(null);
// Follows the node on every commit: a ref that now points at another
// element moves the watch to it.
useIsomorphicLayoutEffect(() => {

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 Effect and the unmount one below copy the lifecycle useAttachedNodes in useAttachment.ts now owns for useAttachment and useAttachmentPair (follow the node after each commit, release the old attachment, release at unmount). Letting useAttachedNodes take a Document or a Node ref and calling it here would leave one path to fix.

vjeux added 3 commits November 1, 2026 05:23
A pager for touch presentations. A horizontal pan moves the pages one to one and rubber-bands at the ends. The release picks the page with UIKit's rule and settles on UIKit's paging curve on the compositor, and a finger can catch it mid-flight. Not exported from the package entry point.

The pan and the catch attach for every pointer (spec:AST-094 FR19, FR43).
A mouse drags the track as a finger does, its short fast drag paging as a
finger's flick does, and a mouse that catches a settle locks no text
selection (the selection lock reads the pointer kind alone).

The pan follows a trackpad's scroll along its axis and never a mouse
wheel's (DEC-24): notches, lines, a whole tick and an accelerated mouse's
deltas stay the page's. A trackpad's fling releases where the fingers
lift, at their speed, and a scroll that stops releases still, as the
record's slow 60 px swipe does. A vertical wheel stays the page's. Under
Reduce Motion a release from a mouse, a trackpad or a pen follows DR2 on
a coarse primary pointer and DR3 otherwise. The trackpad browser cases run
in WebKit: Chromium's CDP wheels read as a mouse's wheel.
…e it began, a spike releases still, and back-to-back swipes page on

Red for the pager's trackpad rule in spec:AST-094 FR43 (DEC-24). A
trackpad drag never carries the track more than one page from the page it
began on, in a looping pager too, across the loop. A trackpad swipe that moved the pager less than a tenth of a
page releases still, whatever its speed: the record's 42 px spike, on a
480 px page. A trackpad swipe that lands while the last swipe's settle
runs starts from that settle's page, so back-to-back swipes page on. The
browser proof that a scroll of more than a page carried the track past the
next page now asserts that the track stops there.
… it began, a spike releases still, and back-to-back swipes page on

During a trackpad drag the track moves at most one page from the page the
swipe began on, across a loop too; a pager that does not loop keeps the
rubber band of its first and last pages. A
trackpad swipe that moved under a tenth of a page releases still, whatever
its speed: a speed spike is not a flick, while a finger's and a mouse's
flick keep their speed. A swipe that lands while the last swipe's settle
runs begins from that settle's page, so back-to-back swipes page on
(spec:AST-094 FR43).
@vjeux vjeux changed the title feat(core): the pager takes every pointer and a trackpad's scroll (spec:AST-094 FR43) fix(core): a trackpad swipe moves the pager at most a page from where it began, a spike releases still, and back-to-back swipes page on Nov 1, 2026
@vjeux
vjeux force-pushed the uikit/c23-fr43-pager branch from a3571eb to 874552f Compare November 1, 2026 14:04

@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 is on hold for an engineering owner's spec decision: this head adds new text to spec:AST-094 FR11 and FR19 and to architecture:react-component-runtime INV13 (carried from #8360) that main does not state, while the pager's trackpad rules follow FR43 on main. An engineering owner records that decision by commenting /approve-spec followed by the full head SHA on this pull request, and the author may do so as an engineering owner. Whatever the decision, these changes are still needed:

  • Land #8360, #8676, #8145 and #8146 first and rebase, so this diff is the pager's four files instead of 25,643 lines.
  • Start a trackpad swipe from the settle's page only after a swipe's own release, so a swipe during a multi-page goTo follows the fingers (usePager.ts:962-965).
  • Measure the tenth-of-a-page still release from where the swipe began, so late back-to-back flicks page on and late spikes stay put (usePager.ts:1016-1022), each with a unit case.
  • Move useFingerScroll's attachment lifecycle onto the shared helper in useAttachment.ts.

[Automated review]

dragStartIndex =
wasSettling && isScrollContact(recognizer)
? target
: nearestPage(current);

Copy link
Copy Markdown

Choose a reason for hiding this comment

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

wasSettling is also true while goTo plays, so a trackpad swipe that lands during goTo(3) from page 0 gets dragStartIndex 3, and the clamp below pins the track to page 2 on the first delta: with the track at 0.7 page it jumps from -342 px to -960 px, 1.3 pages from where the swipe began (FR43 lines 577-578). Take target only after a swipe's own release, or when the track is within a page of it, and add a goTo case.

if (
isScrollContact(recognizer) &&
Math.abs(dragPosition - offsetOf(dragStartIndex)) <
STILL_RELEASE_FRACTION * pageWidth

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 measures from the start page's offset, not from where the swipe began. On a 480 px page, a ~60 px flick that lands 20 px short of page 1 ends 40 px from it and stays, while the same flick from rest pages on; a ~33 px backward spike with the track at 0.83 page ends 117 px away and flicks back to page 0 (FR43 lines 579-581). Compare with the track's position at began, and add late back-to-back cases.

const attachedRef = useRef<{node: Node; detach: () => void} | null>(null);
// Follows the node on every commit: a ref that now points at another
// element moves the watch to it.
useIsomorphicLayoutEffect(() => {

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 Effect and the unmount one below repeat the lifecycle useAttachedNodes in useAttachment.ts owns for useAttachment and useAttachmentPair. Letting the shared hook take a Document or Node and expose the attached nodes would leave one copy.

@vjeux

vjeux commented Nov 1, 2026

Copy link
Copy Markdown
Contributor Author

Folded into #8146 at 464de7d, so #8146 is complete against spec:AST-094 FR43 at its own head; the red-first numbers stay citable through c7f6c48.

@vjeux vjeux closed this Nov 1, 2026

This branch was successfully deployed

1 active deployment
Preview — 874552fb Deployed Nov 1, 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