Skip to content

feat(core): touch-action derivation, native scroll probe, haptics and the gesture host - #8071

Draft
vjeux wants to merge 2 commits into
meta:mainfrom
vjeux:wip/uikit-pr-g2a
Draft

vjeux wants to merge 2 commits into
meta:mainfrom
vjeux:wip/uikit-pr-g2a

Conversation

@vjeux

@vjeux vjeux commented Oct 24, 2026 •

Copy link
Copy Markdown
Contributor

Behavior-module PR: gesture host, touch-action derivation, native scroll probe, haptics

Section 2 of the merge plan: #8061 → #8071 → #8076. One commit on #8061 holding only this change. It absorbs #8072 (the gesture host), which closes with a link here, and #8665's serving of every pointer; their authors are credited with Co-authored-by.

Primary intent

Connect the gesture system to the DOM. Three browser-facing helpers come first: the touch-action each element gets from its recognizers, what the native scrollers under a touch could still do, and UIKit's haptic feedback kinds on the web. On them, 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, and an unclaimed one behaves as without the system.

User or maintainer impact

Components pick touch-action by hand today, and pinch-zoom is often switched off as a side effect, which breaks WCAG 1.4.4 (resize text) on phones. Whether a sheet's list "is at its top" ignores the browser's own rules (the touch-action chain, overscroll-behavior, RTL), so a pull can start a drag while the browser still scrolls. Haptics have no shared module.

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

  • Why does it happen? Each component re-derives touch-action, the scroll probe and haptics, and negotiates each touch alone.
  • Why does that hurt? Zoom is lost, drags fight scrolling, haptics are missing, and touches are lost or followed by clicks.
  • Why does it matter? Safari honors a cancelled touchmove only before it scrolls, and the host negotiates every touch from these three answers.

Deltas:

  • touchAction.ts:
    • what an element's recognizers leave the browser: the other axis for an axis-limited pan or swipe, manipulation for taps and long presses, nothing taken by a one-way or scroll-coordinating pan;
    • pinch-zoom kept unless a pinch recognizer is attached;
    • a long press also turns off the iOS callout and text selection;
    • intersectTouchAction: what both a derived value and an element's own value allow, so the host never widens the author's (spec:AST-094 FR18, DEC-8).
  • nativeScroll.ts: canScroll(dx, dy) and the distance to the edge along the scroll chain, honouring touch-action, overscroll-behavior, RTL scrollLeft ranges and shadow roots.
  • haptics.ts: UIKit's feedback kinds through navigator.vibrate; in iOS Safari, the one haptic WebKit plays, a light impact when a real tap toggles a hidden <input type=checkbox switch> in the control's label. Script cannot fire it later (WebKit requires a trusted event), so there are no mid-drag haptics.
  • GestureHost:
    • touch events for fingers (the cancelable channel), paired with their pointer events (Chromium sends no touchmove within its slop);
    • pointer events for mice and pens; per-target listeners;
    • claims: a non-passive touchmove listener only where a recognizer can claim a moving touch (not taps or press consumers), cancelling while one owns the touch;
    • pointercancel, a scroll, or an uncancelable move the browser could pan, as the browser taking the touch;
    • click suppression (a cancelled touchend, a one-shot swallower) and replay;
    • delegated consumers by attribute;
    • pointerIdOf(touch): the pointer behind a held finger;
    • a mouse, a pen and a finger served alike (spec:AST-094 FR19, FR43): where the primary pointer is coarse, page listeners while something enabled is attached; where it is fine, one passive pointerdown on each element that uses a gesture and page listeners only during a press, the click listener alone kept through the swallower's window (FR21, FR24); a right click, a pen's barrel button and a ctrl-click on macOS start no press; the pointer kind followed while anything is attached; an action group (family:action-groups FR5) served like every attachment;
    • under inputMode: 'pointer-events', a finger tracked once from its pointer events, a scroll taking it only after its 10 px of travel, and a claimed finger's touchend cancelled too (FR21);
    • a finger on page content reaches the recognizers tracking another finger as one that began elsewhere;
    • the view's box for a screen-edge pan, and a resize of that box, or of the viewport for a pan whose band moves with it, reported to it, so its edge-zone answer and the touch-action derived from it follow the layout (FR18, FR19, DEC-22); one ResizeObserver watches only enabled edge pans, under every pointer, and adds no listener (FR24);
    • a detach, even one that leaves the element other attachments, re-syncs the host, so an element left with a disabled recognizer keeps no pointerdown, page listener or ResizeObserver (FR24);
    • recognizers told apart by their kind (RecognizerKind, which each recognizer reports), never by instanceof (architecture:react-component-runtime INV13).
  • touch-action on attach for every pointer, never wider than the element's own: an inline value is intersected at once, except a CSS-wide keyword (initial, inherit, unset, revert, revert-layer), which names no value of its own and is read as an author rule is, as what it resolves to; otherwise the element is marked under a zero-specificity rule any author rule outranks, and the next frame reads the value in force and writes only what both allow (FR18, DEC-8). Restored on detach; the scroll probe on every touch.
  • The gesture system's real-touch browser proof (__tests__/browser/gestures.interaction.browser.spec.ts with its fixture, under playwright.interaction.config.ts on a phone in Chromium and WebKit):
    • touch-action per element, claims and cancelled moves, click suppression, pinch, a second finger, a long press under a blocked main thread, the 10 px take on a pan's axis, a narrower author touch-action, and the screen-edge band in both writing directions;
    • a scroll under a held press taking it only once the finger is 10 px from where it landed: a still finger, 9 px and (7, 7) keep it, while 10 px and (8, 8) lose it (FR20);
    • a keyboard activation inside the click window after a claimed drag still clicks: the swallower passes a click whose detail is 0 (FR21);
    • under a fine pointer, a mouse drag and tap, a pen drag, and the page listeners a press adds and removes (FR43, FR24);
    • build-fixture.mjs writes the same fixture as a standalone page for Mobile Safari on the iOS simulator.

Authority

spec:AST-094 (current): FR14 (nested scrolling), FR18 (touch-action), FR19 (claims), FR20 (the browser taking a touch), FR21 (clicks), FR23 (haptics), FR24 (gesture work costs nothing where it is not used) and FR43 (gestures work for every pointer).

Change and evidence

Measured in Mobile Safari 26.2 on the iOS 26.2 simulator, with HID touches:

  • touchmove arrives from the first pixel;
  • WebKit commits to a scroll at 10–11 px; cancelling from 8 px keeps the page still, and a flick's first touchmove is cancelable even 20–30 px out;
  • a cancelled touchend stops the click and mouse events;
  • claiming costs no delivery rate (median 17–20 ms apart, as passive);
  • under pan-y, WebKit scrolls only drags steeper than about 2:1 to 3:1, once vertical travel reaches about 10 px; a 4:1 drag is never taken;
  • user-select: none with -webkit-touch-callout: none stops a long-pressed link's preview and menu.

On the simulator the host passed 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, and a long press at exactly 500 ms. The tests check the derived touch-action for every recognizer combination, the scroll probe on nested scrollers with overscroll-behavior and RTL, the vibration patterns, the hidden switch staying out of layout, touch and assistive technology, and the host's claims, click suppression and takeovers. The partial-detach cases fail without the re-sync: the element keeps its pointerdown, the page its listeners, and an edge pan's box stays observed. The browser proof's FR20 case catches both of its mutants in both engines: a 9 px press slop fails its 9 px case, and a per-axis take distance fails its (8, 8) case. Its keyboard case fails in both engines when the swallower stops passing detail === 0 clicks, and an inline touch-action: initial or inherit was intersected into a write before the keyword fix.

Scope

  • This PR has one primary intent.
  • Independently removable deltas were removed or split (press tracking, hooks and docs follow in later sections).
  • Public text and artifacts contain no internal Meta context.

Testing

Local gates at 15c7786, one commit on #8061's 31ce964 holding only this change (28 files, +7,111):

@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 31, 2026 12:39am 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
@github-actions github-actions Bot added the needs:design-review Affects visuals — Design should review 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
🔄 In progress (for maintainers only) Oct 31, 2026, 12:39 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 engineering owner's decision: every change here rests on the draft spec:AST-094, which this pull request adds and no one has approved yet, so an engineering owner needs to approve or reject its DEC-1, DEC-2 and the requirements this code implements, recording an approval by commenting /approve-spec <full head SHA> on this pull request (the author, as an engineering owner, may do so). Independently of that decision, these changes are needed before merge:

  • Land #8052, #8053, #8058, #8059, #8060 and #8061 first (or rebase once they have), so this pull request's diff is its own 874-line commit instead of 8,484 lines.
  • attachTapHaptic moves focus into its hidden aria-hidden switch in Chromium, so the next Space toggles that switch instead of the control, and it plays nothing when the label already contains a control.
  • nativeScroll.ts follows parentNode rather than assignedSlot, so content slotted into a shadow-tree scroller looks unscrollable to a scrollEdge pan.
  • The pan's scrollHandoff hands a drag over midway, which the draft's own FR14 forbids; remove it or have FR14 carve it out.

[Automated review]

Comment thread packages/core/src/gestures/haptics.ts Outdated
Comment on lines +99 to +108
input.setAttribute('aria-hidden', 'true');
input.tabIndex = -1;
input.style.cssText = VISUALLY_HIDDEN;
// The label's activation clicks the switch, and that click would bubble
// through the control as a second click: keep it, and the switch's
// change events, inside.
input.addEventListener('click', swallow);
input.addEventListener('change', swallow);
input.addEventListener('input', swallow);
label.appendChild(input);

Copy link
Copy Markdown

Choose a reason for hiding this comment

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

In Chromium, activating the label focuses its control, so a tap or click on this label moves focus into this hidden aria-hidden checkbox; on a focusable label control the next Space toggles the checkbox instead of activating the control. The switch's click also reaches capture-phase listeners, despite the comment at line 29. And because it is appended last, a label that already wraps a control (or has for) never toggles it, so no haptic plays. Consider handing focus back on the switch's focus event, refusing when label.control !== input after appending, and testing a real click on the label in a browser.

Comment on lines +70 to +77
export function parentAcrossShadow(node: Node): Node | null {
if (node.parentNode != null) {
return node.parentNode instanceof ShadowRoot
? node.parentNode.host
: node.parentNode;
}
return 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 follows the light tree, so a node slotted into a scroller inside a shadow root never reaches that scroller: on real layout the probe reports no scroll either way while the scroller still has room, and a scrollEdge pan would claim the touch. Stepping to assignedSlot before parentNode (then crossing the ShadowRoot to its host as now) follows the flat tree the browser scrolls.

Comment on lines +281 to +296
protected override touchesTakenByBrowser(
touches: ReadonlyArray<GestureTouch>,
event: GestureEventContext,
): boolean {
if (
this.scrollHandoff &&
DIRECTION_VECTORS[this.direction] != null &&
this.state === 'possible' &&
this.pending == null
) {
this.isObserving = true;
this.anchor = null;
return true;
}
return super.touchesTakenByBrowser(touches, event);
}

Copy link
Copy Markdown

Choose a reason for hiding this comment

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

scrollHandoff lets a drag that began as a native scroll become the pan midway, and hand back when the finger returns. The draft spec:AST-094 FR14 in this pull request says the drag must not change hands midway in either direction, and the constants table calls the slop not a parity value. Either remove the option or get FR14 amended to allow it.

vjeux added a commit to vjeux/astryx that referenced this pull request Oct 30, 2026
… the gesture host

The browser negotiation and the gesture host on top of the recognizers:
touch-action derived from what is attached, a native scroll probe that
follows a slotted node into its shadow tree, the tap haptic, and the
host that claims a finger, suppresses the click a claimed touch would
send, and hands a touch to the browser when it takes over. meta#8071's and
meta#8072's changes, rebased onto main after meta#8061 as one commit; the host's
hover routing lands with the hover recognizer's first user.

Co-authored-by: Cindy Zhang <cindyxz@meta.com>
@vjeux vjeux changed the title feat(core): touch-action derivation, native scroll probe and haptics feat(core): touch-action derivation, native scroll probe, haptics and the gesture host Oct 30, 2026
vjeux added a commit to vjeux/astryx that referenced this pull request Oct 30, 2026
…or, and Lab's route through core's exports

Core's internal touch foundation on the gesture recognizers (meta#8061) and the
gesture host (meta#8071 + meta#8072): UIControl press tracking with its press
profiles, paint and hooks; the animator, which starts a motion from rest at
its commit, holds the value on screen inline while a curve runs, and lets a
call wait for a presented frame; and Lab reaching Core only through its
exports, internal entries included. It absorbs meta#8073 (closed: the press
tracking this rests on), meta#8562 (the internal entries) and meta#8340 (the
animator's start and hold fixes), with the held dip's load fixes, and keeps
meta#8641's rule: a gesture that cancels itself while its finger moves keeps
that finger until it lifts.

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

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

Changes are needed before this can merge: the new gesture host contradicts the current spec:AST-094 and architecture:react-component-runtime records in a few places, and at 5,538 authored lines, about a fifth of them from the open #8061, the diff is too large to review as one change. Please conform the host and split the change:

  • Serve every pointer, as FR43, FR24 and INV13 require; today shouldListen (GestureHost.ts:618-620) listens for nothing under a fine primary pointer, so a mouse, a pen or a touchscreen-laptop finger never reaches a recognizer.
  • Intersect the derived touch-action with the element's own value (FR18, DEC-8) instead of overwriting a narrower author value such as none (GestureHost.ts:548-555).
  • Give the host's environment viewSize so an edge-zone screen-edge pan keeps its 15 px start and pan-x claim (FR18), and tell recognizers apart by a kind field rather than instanceof (INV13).
  • Either conform the uncancelable-touchmove rule to FR20 or have an engineering owner amend FR20, and keep the screen-edge pan off under a mouse or a pen (FR43, in #8061).
  • Land #8061 first and split the helpers from the host so each part can be reviewed on its own.

[Automated review]

Comment on lines +618 to +620
private shouldListen(): boolean {
if (!this.isPrimaryPointerCoarse()) {
return false;

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 fine primary pointer this returns false, so no recognizer attached through the host hears a mouse, a pen or a finger; only an action group gets a listener. Current spec:AST-094 FR43 says gestures attach for every pointer, FR24 has a fine-pointer component listen on its own element with document listeners only during a press, and architecture:react-component-runtime INV13 says the host attaches for every pointer with no touch-only query. The test at GestureHost.test.ts:305-344 pins the opposite.

Comment on lines +548 to +555
if (value != null) {
if (!registration.savedStyle.has(property)) {
registration.savedStyle.set(
property,
element.style.getPropertyValue(property),
);
}
element.style.setProperty(property, value);

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 writes the derived touch-action over whatever the author set: an author none with a tap becomes manipulation, and pan-y with a horizontal pan becomes pan-y pinch-zoom. FR18 and DEC-8 require the derived value to be intersected with the element's own touch-action and never wider, the author's value winning when it leaves out pinch-zoom. Reading the computed value before the first write and intersecting with it would conform.

Comment on lines +319 to +327
const environment: GestureEnvironment = {
viewportSize: () => ({
width: doc.defaultView?.innerWidth ?? 0,
height: doc.defaultView?.innerHeight ?? 0,
}),
isRtl: view =>
view instanceof Element &&
doc.defaultView?.getComputedStyle(view).direction === 'rtl',
};

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 environment has no viewSize, and the arena uses it whole (GestureArena.ts:141), so once a screen-edge pan has joined the arena its isEdgeZone answers false. On a 40 px edge strip the pan then begins at 12 px instead of FR18's 15 px, and the next update drops its pan-x claim. Adding viewSize as DEFAULT_ENVIRONMENT has it (types.ts:142-148) fixes both.

function claimsWhileMoving(recognizer: GestureRecognizer): boolean {
return (
recognizer.acceptsTouchInput &&
!(recognizer instanceof TapGestureRecognizer)

Copy link
Copy Markdown

Choose a reason for hiding this comment

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

architecture:react-component-runtime INV13: the recognizer family "uses no inheritance checks: a recognizer is told apart by its kind field, never by instanceof". This and touchAction.ts:118 are the first such checks in the gesture code; the kind field INV13 names would replace both.

Comment on lines +849 to +884
if (!event.cancelable) {
// The browser no longer asks the page about these fingers. WebKit
// stops asking when it takes the touch to pan (its pointercancel comes
// with it); Chromium also stops after one unconsumed touchmove, even
// when `touch-action` keeps it from panning the way the finger went (a
// horizontal drag under pan-y). So a finger's FIRST uncancelable move
// counts as the browser's claim only if the browser could pan along
// the finger's main axis since touch-down; later ones add nothing, and
// pointercancel or a scroll still report any real pan.
const placed = new Map(inputs.map(input => [input.id, input]));
const taken = ids.filter(id => {
const touch = this.arena.getTouch(id);
if (
touch == null ||
touch.takenByBrowser ||
this.arena.isClaimed(id) ||
touch.scroll?.allowsNativePan === false ||
this.judgedUncancelable.has(id)
) {
return false;
}
this.judgedUncancelable.add(id);
const input = placed.get(id);
if (input == null || touch.scroll == null) {
return true;
}
const dx = input.x - touch.startX;
const dy = input.y - touch.startY;
return Math.abs(dx) >= Math.abs(dy)
? touch.scroll.canScroll(dx, 0)
: touch.scroll.canScroll(0, dy);
});
if (taken.length > 0) {
this.arena.browserTookOver(taken, 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.

FR20 counts an uncancelable touchmove as the browser taking the touch with no condition (its 10 px qualifier is on the native scroll). Here only a finger's first uncancelable move counts, and only where the probe says the browser could pan its main axis. If the Chromium behavior in the comment is the reason, the rule needs an FR20 amendment from an engineering owner; otherwise the host should conform.

}

export interface AttachOptions {
/** Let the host set touch-action (and long-press callout) styles. @default true */

Copy link
Copy Markdown

Choose a reason for hiding this comment

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

holdSelection writes -webkit-touch-callout, -webkit-user-select and user-select even when an element is attached with manageStyle: false (:1234-1269); either honor the option there or narrow this comment to touch-action.

Comment on lines +72 to +82
constructor(options: ScreenEdgePanGestureRecognizerOptions = {}) {
super({
maximumNumberOfTouches: 1,
hysteresis: UIKIT_SCREEN_EDGE_PAN.hysteresis.value,
...options,
});
this.edges = options.edges ?? ['leading'];
this.edgeRegion =
options.edgeRegion ?? UIKIT_SCREEN_EDGE_PAN.edgeRegion.value;
this.edgeHysteresis = this.hysteresis;
}

Copy link
Copy Markdown

Choose a reason for hiding this comment

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

No allowedPointerTypes default here, so the base admits a mouse and a pen: a mouse drag from 5 px off the edge begins the pan. FR43 keeps the screen-edge pan off under a mouse, a trackpad or a pen; defaulting to ['touch'] conforms.

Comment on lines +19 to +20
* - A pan that coordinates with native scrolling keeps both axes: the
* browser scrolls until the edge, and the pan claims from there.

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 still describes a midway handoff (the browser scrolls, then the pan claims), which FR14 rules out; a scrollEdge pan decides once, when it would begin.

vjeux added a commit to vjeux/astryx that referenced this pull request Oct 30, 2026
… the gesture host

The browser negotiation and the gesture host on top of the recognizers:
touch-action derived from what is attached and never wider than the
element's own (spec:AST-094 FR18, DEC-8), a native scroll probe that
follows a slotted node into its shadow tree, the tap haptic, and the
host that claims a finger, suppresses the click a claimed touch would
send, and hands a touch to the browser when it takes over. The host
measures a screen-edge pan's view, so an edge-zone strip decides at
UIKit's 15 px (FR18), and recognizers report their kind, which the
host and the touch-action rules read instead of checking a class
(architecture:react-component-runtime INV13). meta#8071's and meta#8072's
changes, rebased onto main after meta#8061 as one commit; the host's hover
routing lands with the hover recognizer's first user.

Co-authored-by: Cindy Zhang <cindyxz@meta.com>
@vjeux

vjeux commented Oct 30, 2026

Copy link
Copy Markdown
Contributor Author

Thanks for the review. Point by point:

  1. Every pointer (FR43, FR24): that lands in feat(core): gestures attach for every pointer (spec:AST-094 FR43) #8665, which follows this PR in the maintainers' merge plan. It serves a mouse, a pen and a touchscreen laptop's finger, with no page listener while idle.
  2. Touch-action intersection (FR18, DEC-8): fixed in e704bae. An inline value is intersected at once, and a stylesheet value is read in the next frame under a rule any author rule outranks, so the host never writes a wider value.
  3. viewSize and kind (FR18, INV13): fixed in e704bae. The host measures a screen-edge pan's view, so an edge-zone strip claims pan-x and starts at 15 px. Recognizers report a kind, which the host and the touch-action rules read instead of instanceof.
  4. FR20: the amendment this rule follows is in spec(AST-094): only a finger's first uncancelable move can hand it to the browser (FR20) #8456, with the spec owner. The screen-edge pan is now a finger's only, fixed in feat(core): swipe, pinch and screen-edge pan recognizers #8061 (65d81b5).
  5. Order and size: the merge plan lands feat(core): swipe, pinch and screen-edge pan recognizers #8061 first and keeps the helpers and the host together here, since this PR absorbed feat(core): gesture host — claims, click suppression and the browser's takeover #8072. The line count includes feat(core): swipe, pinch and screen-edge pan recognizers #8061's commit, which this branch stacks on.

vjeux added a commit to vjeux/astryx that referenced this pull request Oct 30, 2026
…or, and Lab's route through core's exports

Core's internal touch foundation on the gesture recognizers (meta#8061) and the
gesture host (meta#8071 + meta#8072): UIControl press tracking with its press
profiles and hooks; the animator, which starts a motion from rest at
its commit, holds the value on screen inline while a curve runs, and lets a
call wait for a presented frame; and Lab reaching Core only through its
exports, internal entries included. It absorbs meta#8073 (closed: the press
tracking this rests on), meta#8562 (the internal entries) and meta#8340 (the
animator's start and hold fixes), with the held dip's load fixes, and keeps
meta#8641's rule: a gesture that cancels itself while its finger moves keeps
that finger until it lifts. Press tracking is built by createPressTracker
(from meta#8763), and a gesture that ends on an unmount or a replaced node
calls no listener from the effect.

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

@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 engineering owner's decision: the gesture host's uncancelable-touchmove rule follows the spec:AST-094 FR20 amendment proposed in #8456, so current FR20, which counts every uncancelable move as the browser taking the touch, has to be updated before that rule has authority; an engineering owner records the decision by commenting /approve-spec <full head SHA> on #8456 and landing it first (the author may do so as an engineering owner). Whatever that decision, two changes are needed:

  • Serve every pointer as FR43, FR19, FR24 and INV13 require: today the host listens for nothing under a fine primary pointer (GestureHost.ts:747-764), so a mouse, a pen or a touchscreen-laptop finger never reaches a recognizer.
  • Land #8061 first and split the helpers from the host: the diff has 6,349 authored lines, 1,211 of them from #8061.

[Automated review]

vjeux added a commit to vjeux/astryx that referenced this pull request Oct 30, 2026
… the gesture host

The browser negotiation and the gesture host on top of the recognizers:
touch-action derived from what is attached and never wider than the
element's own (spec:AST-094 FR18, DEC-8), a native scroll probe that
follows a slotted node into its shadow tree, the tap haptic, and the
host that claims a finger, suppresses the click a claimed touch would
send, and hands a touch to the browser when it takes over. The host
gives a screen-edge pan its view's box and reports a resize of that box
or of the viewport, so the pan's edge-zone answer and the touch-action
derived from it follow the layout (FR18, FR19, DEC-22), and recognizers
report their kind, which the host and the touch-action rules read
instead of checking a class (architecture:react-component-runtime
INV13). meta#8071's and meta#8072's changes, rebased onto main after meta#8061 as
one commit; the host's hover routing lands with the hover recognizer's
first user.

Co-authored-by: Cindy Zhang <cindyxz@meta.com>
vjeux added a commit to vjeux/astryx that referenced this pull request Oct 30, 2026
…mator

Core's internal touch foundation on the gesture recognizers (meta#8061) and the
gesture host (meta#8071 + meta#8072): UIControl press tracking with its press
profiles and hooks, built by createPressTracker; and the animator, which
starts a motion from rest at its commit, holds the value on screen inline
while a curve runs, and lets a call wait for a presented frame. It absorbs
meta#8073 (closed: the press tracking this rests on) and meta#8340 (the animator's
start and hold fixes), with the held dip's load fixes, and keeps meta#8641's
rule: a gesture that cancels itself while its finger moves keeps that
finger until it lifts. A gesture that ends on an unmount or a replaced node
calls no listener from the effect.

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

@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 engineering owner's decision: the gesture host's uncancelable-touchmove rule follows the spec:AST-094 FR20 amendment proposed in #8456, so current FR20, which counts every uncancelable move as the browser taking the touch, has to be updated before that rule has authority; an engineering owner records the decision by commenting /approve-spec <full head SHA> on #8456 and landing it first (this pull request's author may do so as an engineering owner). Whatever that decision, these changes are needed:

  • Serve every pointer as FR43, FR19, FR24 and INV13 require: today the host listens for nothing under a fine primary pointer (GestureHost.ts:828-831), so a mouse, a pen or a touchscreen-laptop finger never reaches a recognizer.
  • Watch a screen-edge pan's layout only where the host manages its touch-action: under a fine pointer it keeps an idle window resize listener and a ResizeObserver (GestureHost.ts:423-475, FR24).
  • Land #8061 first and split the helpers from the host: the diff has 6,607 authored lines, 1,310 of them from #8061.

[Automated review]

}

private shouldListen(): boolean {
if (!this.isPrimaryPointerCoarse()) {

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 fine primary pointer the host returns here and registers nothing, so a mouse, a pen or a touchscreen-laptop finger never reaches a recognizer. Current spec:AST-094 FR43 and FR19 and architecture:react-component-runtime INV13 require the host to attach for every pointer, with no touch-only query; FR24 limits a fine-pointer component to a listener on its own element and document listeners during a press, which the action-group path (:629-647) already does.

Comment on lines +423 to +427
const edgePan = asScreenEdgePan(recognizer);
if (edgePan != null) {
edgePan.onTouchBehaviorChange = () => this.refresh(element, registration);
this.watchLayout(element);
}

Copy link
Copy Markdown

Choose a reason for hiding this comment

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

watchLayout runs for every screen-edge pan whatever the pointer, so under a fine primary pointer, where the host manages no touch-action, a window resize listener and a ResizeObserver stay registered for nothing (spec:AST-094 FR24: under a fine pointer a component listens only on its own element). Watching layout only while the host manages the pan's touch-action, started and stopped from refreshAll, would keep it within FR24.

if (inputs.length === 0) {
return;
}
if (!event.cancelable) {

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 counts only a finger's first uncancelable touchmove, and only where the browser could pan along its main axis: the FR20 amendment proposed in #8456. Current spec:AST-094 FR20 counts every uncancelable touchmove as the browser taking the touch, so this rule needs that amendment approved and landed first.

}

export interface AttachOptions {
/** Let the host set touch-action (and long-press callout) styles. @default true */

Copy link
Copy Markdown

Choose a reason for hiding this comment

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

manageStyle: false does not stop holdSelection (:1444-1479), which still writes -webkit-touch-callout, -webkit-user-select and user-select for a held long press. Either honor the option there or narrow this comment to touch-action.

Comment on lines +20 to +21
* - A pan that coordinates with native scrolling keeps both axes: the
* browser scrolls until the edge, and the pan claims from there.

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 describes a midway handoff (the browser scrolls to the edge, then the pan claims), which spec:AST-094 FR14 rules out: a scrollEdge pan decides once, when the drag begins.

@vjeux

vjeux commented Oct 30, 2026

Copy link
Copy Markdown
Contributor Author

Thanks. Two points my earlier reply missed are fixed in a256f1c. An element attached with manageStyle: false now keeps its own selection and callout styles through a hold, and the touch-action notes describe a scrollEdge pan as FR14 does: decided once, when the drag begins.

@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 engineering owner's decision: the gesture host's uncancelable-touchmove rule follows the spec:AST-094 FR20 amendment proposed in #8456, so current FR20, which counts every uncancelable move as the browser taking the touch, has to be updated before that rule has authority; an engineering owner records the decision by commenting /approve-spec <full head SHA> on #8456 and landing it first (this pull request's author may do so as an engineering owner). Whatever that decision, these changes are needed:

  • Serve every pointer as FR43, FR19 and INV13 require: today the host listens for nothing under a fine primary pointer (GestureHost.ts:850-853), so a mouse, a pen or a touchscreen-laptop finger never reaches a recognizer.
  • Re-sync the host when a detach leaves the element registered: removing the only enabled recognizer beside a disabled one, or a screen-edge pan beside another recognizer, leaves the document listeners or the ResizeObserver running (GestureHost.ts:435-451, FR24).
  • Re-measure a screen-edge pan when its view's direction changes in place: it keeps the old side until a resize (ScreenEdgePanGestureRecognizer.ts:196-219).
  • Land #8061 first and split the helpers from the host: the diff has 6,791 authored lines, 1,436 of them from #8061.

[Automated review]

}

private shouldListen(): boolean {
if (!this.isPrimaryPointerCoarse()) {

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 fine primary pointer the host listens for nothing here, and refresh and applyStyle gate on the same query, so a mouse, a pen or a touchscreen-laptop finger never reaches a recognizer. spec:AST-094 FR43 and FR19, and architecture:react-component-runtime INV13 ("The gesture host attaches for every pointer, with no touch-only query"), require every pointer; the action-group path's idle pointerdown listener with document listeners only during a press is the shape FR24 allows.

recognizer.cancel();
}
this.refresh(element, registration);
this.cleanup(element, registration);

Copy link
Copy Markdown

Choose a reason for hiding this comment

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

cleanup re-syncs only when the element's last recognizer and consumer are gone, so detaching the only enabled recognizer beside a disabled one leaves every document listener installed, and detaching a screen-edge pan beside another recognizer leaves the ResizeObserver watching (FR24). Calling this.syncListeners() here, and in attachConsumer's detach, after refresh fixes both.

if (inputs.length === 0) {
return;
}
if (!event.cancelable) {

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 counts only a finger's first uncancelable touchmove, and only where the browser could pan along its main axis, which is narrower than current FR20 (every uncancelable touchmove takes the touch). It matches the amendment proposed in #8456, so it needs that amendment approved and landed first.

* (a strip authored at the 40.2 px region lays out at 40.203 px, on
* 1/64 px). A view that cannot be measured lies within none.
*/
private measured(): EdgeLayout {

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 sides leading and trailing resolve to are cached with the edge zone until a size change, but an in-place dir switch moves an inline-start strip without resizing it, so after a runtime language switch a leading pan keeps watching the old side and never begins. Re-checking the view's computed direction at touch-down, and measuring again on a change, would keep DR13's mirroring.

@vjeux

vjeux commented Oct 30, 2026

Copy link
Copy Markdown
Contributor Author

The host now serves every pointer (FR19, FR43), in 341b75a. Where the primary pointer is fine, an element that uses a gesture keeps one passive pointerdown listener and the page has none until a press, whose listeners go once it ends (FR24); a right click, a pen's barrel button and a ctrl-click on macOS start no press. Tests cover a mouse, a pen, a touchscreen laptop's finger and the idle listener budget.

@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 a spec decision: the uncancelable-touchmove rule in GestureHost.ts follows the FR20 amendment proposed in #8456, so current spec:AST-094 FR20 has to be updated first; an engineering owner records that by commenting /approve-spec followed by #8456's full head commit SHA on #8456 (the author may do so as an engineering owner), and #8456 lands before this pull request. Independent of that decision, three changes are needed:

  • Re-sync the host's listeners when only some of an element's recognizers detach, so an element left with a disabled recognizer keeps no pointerdown, document listeners or ResizeObserver (FR24).
  • Re-measure a screen-edge pan's edges when its view's writing direction changes in place, so a leading back swipe follows a switch to RTL (DR13); the recognizer lives in #8061.
  • Land #8061 first and split the helpers from the host, since the diff is 6,987 authored lines against the 2,000-line scope limit.

[Automated review]

if (inputs.length === 0) {
return;
}
if (!event.cancelable) {

Copy link
Copy Markdown

Choose a reason for hiding this comment

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

Held: current spec:AST-094 FR20 counts every uncancelable touchmove as the browser taking the touch. Judging only the first one, and only where the scroll probe says the browser could pan along the finger's main axis, follows the amendment proposed in #8456, which still needs an engineering owner's /approve-spec and has to land first.

}

private cleanup(element: Element, registration: Registration): void {
if (registration.recognizers.length > 0 || registration.consumer != 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 early return skips syncListeners() when an element keeps any recognizer or consumer, so detaching its only enabled recognizer leaves the element's pointerdown (fine pointer), the eight document listeners (coarse pointer) or a screen-edge pan's ResizeObserver running until an unrelated attach re-syncs (FR24, INV5). Calling this.syncListeners() in both detach functions after refresh fixes it.

if (this.layout != null) {
return this.layout;
}
const isRtl = this.environment.isRtl(this.view);

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 writing direction read here is cached with the zone until a resize, so after an in-place switch to RTL (no resize) a leading pan keeps watching the left band and the back swipe from the new leading edge never begins (DR13, INV4). Re-checking the view's computed direction at touch-down, and announcing a change through onTouchBehaviorChange, keeps it live.

@vjeux

vjeux commented Oct 30, 2026

Copy link
Copy Markdown
Contributor Author

Thanks. Fixed in 5c4547e: both detach paths re-sync the host, so an element left with a disabled recognizer keeps no pointerdown, page listener or ResizeObserver (FR24), and the gesture browser proof now lives here, with FR20's scroll-take case (a still finger with sub-pixel jitter, 9 and 10 px straight, and (7, 7) and (8, 8) diagonally). The in-place RTL re-measure is in #8061 (8ceeaba).

@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 engineering owner's decision: the gesture host's uncancelable-touchmove rule in GestureHost.ts (onTouchMove) follows the spec:AST-094 FR20 amendment proposed in #8456, so current FR20, which counts every uncancelable move as the browser taking the touch, has to be updated first; an engineering owner records that by commenting /approve-spec followed by #8456's full head commit SHA on #8456 (the author may do so as an engineering owner), and #8456 then lands before this pull request. Independently, a screen-edge pan's onTouchBehaviorChange in GestureHost.ts only refreshes the element's style, so after an in-place direction switch re-measures a leading pan the root never joins the ResizeObserver and viewport resizes stop reaching its edge band; re-syncing the layout watch there, with a test that flips the direction in place, fixes it. The partial-detach leak is fixed, the new browser proof passes in Chromium and WebKit, and the size is a non-blocking note for this behavior-module pull request.

[Automated review]

};
const edgePan = asScreenEdgePan(recognizer);
if (edgePan != null) {
edgePan.onTouchBehaviorChange = () => this.refresh(element, registration);

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 an in-place switch to RTL, the touch-down re-check re-measures a leading pan onto the right edge, whose band moves with the viewport, but this callback only refreshes the style, so syncLayoutWatch never adds the root: a jsdom probe found watchesViewport true and the root unobserved until an unrelated attach. Viewport resizes then stop reaching the pan's band answer (spec:AST-094 FR18, DEC-22). Calling this.syncLayoutWatch() after refresh here, with a test that flips the direction in place, would fix it.

if (inputs.length === 0) {
return;
}
if (!event.cancelable) {

Copy link
Copy Markdown

Choose a reason for hiding this comment

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

Held: current spec:AST-094 FR20 counts every uncancelable touchmove as the browser taking the touch. Judging only a finger's first one, and only where the scroll probe says the browser could pan along its main axis, follows the amendment proposed in #8456, which still needs an engineering owner's /approve-spec on #8456 and to land before this rule has authority.

Three recognizers beside main's tap, long press and pan, each reading
its own UIKit table group: a swipe recognized from the path between
events, a pinch whose velocity settles, and a screen-edge pan from the
leading or trailing edge. The edge pan takes the horizontal axis only on
a view that lies within that edge's band. The sides its edges resolve to
under the view's writing direction and the edge zone are measured once,
together, for touch-action and the touch-down decision alike, and again
when the host reports a layout change or a touch-down finds the view's
direction switched in place; and it is a finger's only (a mouse, a
trackpad or a pen never starts it). meta#8061's change, rebased onto main as
one commit; its rotation and hover recognizers and its top and bottom
edges, which no component uses yet (and rotation's threshold is
unmeasured), land with their first users.

Co-authored-by: Cindy Zhang <cindyxz@meta.com>
… the gesture host

The browser negotiation and the gesture host on top of the recognizers:
touch-action derived from what is attached and never wider than the
element's own (spec:AST-094 FR18, DEC-8), a native scroll probe that
follows a slotted node into its shadow tree, the tap haptic, and the
host that claims a finger, suppresses the click a claimed touch would
send, and hands a touch to the browser when it takes over.

The host serves a mouse, a pen and a finger alike (FR19, FR43). Where
the primary pointer is fine, an element that uses a gesture keeps one
passive pointerdown listener and the page has none until a press, whose
listeners go once its contacts end (FR24); a right click, a pen's barrel
button and a ctrl-click on macOS start no press. A detach re-syncs the
host even when the element keeps another attachment, so one left
disabled keeps no listener or observer. It gives a screen-edge pan its
view's box and, through one ResizeObserver and no listener, reports a
resize of that box or of the viewport, so the pan's edge-zone answer and
the touch-action derived from it follow the layout (FR18, FR19, FR24,
DEC-22). Recognizers report their kind, which the host and the
touch-action rules read instead of checking a class
(architecture:react-component-runtime INV13).

The gesture system's real-touch browser proof runs with it, under
playwright.interaction.config.ts on a phone in Chromium and WebKit:
touch-action per element, claims and cancelled moves, click suppression,
pinch, a second finger, a long press under a blocked main thread, the 10
px take, the screen-edge band in both writing directions, and a scroll's
take of a held press only once the finger is 10 px from where it landed
(FR18-FR22); and, under a fine pointer, a mouse and a pen and the page
listeners a press adds and removes (FR43, FR24).

meta#8071's and meta#8072's changes, with meta#8665's serving of every pointer,
rebased onto main after meta#8061 as one commit; the host's hover routing
lands with the hover recognizer's first user.

Co-authored-by: Cindy Zhang <cindyxz@meta.com>
@vjeux

vjeux commented Oct 31, 2026

Copy link
Copy Markdown
Contributor Author

Fixed in 15c7786: an inline CSS-wide touch-action keyword (initial, inherit, unset, revert, revert-layer) is resolved before intersecting, read as an author rule is, so a tap recognizer no longer turns initial into none; and the browser proof now has the keyboard case, a detail === 0 click passing the swallower inside its window (it fails in both engines without that rule). Nothing else from the 21:23Z review is open.

This branch was successfully deployed

1 active deployment
Preview — 15c77860 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

Projects

None yet

Development

Successfully merging this pull request may close these issues.

2 participants