Repository navigation
Conversation
|
The latest updates on your projects. Learn more about Vercel for GitHub.
|
|
AI review status for this pull request.
|
There was a problem hiding this comment.
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.
attachTapHapticmoves focus into its hiddenaria-hiddenswitch 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.tsfollowsparentNoderather thanassignedSlot, so content slotted into a shadow-tree scroller looks unscrollable to ascrollEdgepan.- The pan's
scrollHandoffhands a drag over midway, which the draft's own FR14 forbids; remove it or have FR14 carve it out.
[Automated review]
| 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); |
There was a problem hiding this comment.
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.
| export function parentAcrossShadow(node: Node): Node | null { | ||
| if (node.parentNode != null) { | ||
| return node.parentNode instanceof ShadowRoot | ||
| ? node.parentNode.host | ||
| : node.parentNode; | ||
| } | ||
| return null; | ||
| } |
There was a problem hiding this comment.
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.
| 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); | ||
| } |
There was a problem hiding this comment.
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.
… 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>
c4bc6f8 to
e7181bd
Compare
…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>
There was a problem hiding this comment.
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-actionwith the element's own value (FR18, DEC-8) instead of overwriting a narrower author value such asnone(GestureHost.ts:548-555). - Give the host's environment
viewSizeso an edge-zone screen-edge pan keeps its 15 px start andpan-xclaim (FR18), and tell recognizers apart by akindfield rather thaninstanceof(INV13). - Either conform the uncancelable-
touchmoverule 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]
| private shouldListen(): boolean { | ||
| if (!this.isPrimaryPointerCoarse()) { | ||
| return false; |
There was a problem hiding this comment.
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.
| if (value != null) { | ||
| if (!registration.savedStyle.has(property)) { | ||
| registration.savedStyle.set( | ||
| property, | ||
| element.style.getPropertyValue(property), | ||
| ); | ||
| } | ||
| element.style.setProperty(property, value); |
There was a problem hiding this comment.
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.
| 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', | ||
| }; |
There was a problem hiding this comment.
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) |
There was a problem hiding this comment.
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.
| 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); | ||
| } | ||
| } |
There was a problem hiding this comment.
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 */ |
There was a problem hiding this comment.
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.
| 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; | ||
| } |
There was a problem hiding this comment.
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.
| * - A pan that coordinates with native scrolling keeps both axes: the | ||
| * browser scrolls until the edge, and the pan claims from there. |
There was a problem hiding this comment.
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.
… 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>
e7181bd to
e704bae
Compare
|
Thanks for the review. Point by point:
|
…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>
There was a problem hiding this comment.
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]
… 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>
e704bae to
76a48da
Compare
…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>
There was a problem hiding this comment.
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 windowresizelistener and aResizeObserver(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()) { |
There was a problem hiding this comment.
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.
| const edgePan = asScreenEdgePan(recognizer); | ||
| if (edgePan != null) { | ||
| edgePan.onTouchBehaviorChange = () => this.refresh(element, registration); | ||
| this.watchLayout(element); | ||
| } |
There was a problem hiding this comment.
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) { |
There was a problem hiding this comment.
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 */ |
There was a problem hiding this comment.
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.
| * - A pan that coordinates with native scrolling keeps both axes: the | ||
| * browser scrolls until the edge, and the pan claims from there. |
There was a problem hiding this comment.
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.
|
Thanks. Two points my earlier reply missed are fixed in a256f1c. An element attached with |
There was a problem hiding this comment.
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
ResizeObserverrunning (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()) { |
There was a problem hiding this comment.
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); |
There was a problem hiding this comment.
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) { |
There was a problem hiding this comment.
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 { |
There was a problem hiding this comment.
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.
|
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 |
There was a problem hiding this comment.
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 orResizeObserver(FR24). - Re-measure a screen-edge pan's edges when its view's writing direction changes in place, so a
leadingback 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) { |
There was a problem hiding this comment.
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) { |
There was a problem hiding this comment.
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); |
There was a problem hiding this comment.
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.
|
Thanks. Fixed in 5c4547e: both detach paths re-sync the host, so an element left with a disabled recognizer keeps no |
There was a problem hiding this comment.
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); |
There was a problem hiding this comment.
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) { |
There was a problem hiding this comment.
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>
|
Fixed in 15c7786: an inline CSS-wide |
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-actioneach 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-actionby 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 (thetouch-actionchain,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
touch-action, the scroll probe and haptics, and negotiates each touch alone.touchmoveonly before it scrolls, and the host negotiates every touch from these three answers.Deltas:
touchAction.ts:manipulationfor taps and long presses, nothing taken by a one-way or scroll-coordinating pan;pinch-zoomkept unless a pinch recognizer is attached;intersectTouchAction: what both a derived value and an element's own value allow, so the host never widens the author's (spec:AST-094FR18, DEC-8).nativeScroll.ts:canScroll(dx, dy)and the distance to the edge along the scroll chain, honouringtouch-action,overscroll-behavior, RTLscrollLeftranges and shadow roots.haptics.ts: UIKit's feedback kinds throughnavigator.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:touchmovewithin its slop);touchmovelistener 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;touchend, a one-shot swallower) and replay;pointerIdOf(touch): the pointer behind a held finger;spec:AST-094FR19, FR43): where the primary pointer is coarse, page listeners while something enabled is attached; where it is fine, one passivepointerdownon 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-groupsFR5) served like every attachment;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'stouchendcancelled too (FR21);ResizeObserverwatches only enabled edge pans, under every pointer, and adds no listener (FR24);pointerdown, page listener orResizeObserver(FR24);kind(RecognizerKind, which each recognizer reports), never byinstanceof(architecture:react-component-runtimeINV13).touch-actionon 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.__tests__/browser/gestures.interaction.browser.spec.tswith its fixture, underplaywright.interaction.config.tson a phone in Chromium and WebKit):touch-actionper 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 authortouch-action, and the screen-edge band in both writing directions;detailis 0 (FR21);build-fixture.mjswrites 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:
touchmovearrives from the first pixel;touchmoveis cancelable even 20–30 px out;touchendstops the click and mouse events;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: nonewith-webkit-touch-callout: nonestops 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-actionfor every recognizer combination, the scroll probe on nested scrollers withoverscroll-behaviorand 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 itspointerdown, 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 passingdetail === 0clicks, and an inlinetouch-action: initialorinheritwas intersected into a write before the keyword fix.Scope
Testing
Local gates at 15c7786, one commit on #8061's 31ce964 holding only this change (28 files, +7,111):
pnpm test:ui(core UI): 661 files, 16,481 tests passed.pnpm exec vitest run --project node: 803 files passed; the only failures are main's own story-tree and release-cache reds, tracked in test(storybook): run the story-tree check where Storybook is built, and bring the tree back to its shape #8509 and fix(cli): a crashed run's release install is recognized by the Node it ran on, whatever its name #8520.playwright.interaction.config.ts, a phone in Chromium and WebKit): Chromium 25 passed; WebKit 21 passed and 4 skipped (three need input WebKit's driver cannot send, and the pen case).tsc --project packages/core/tsconfig.json --noEmit, strict ESLint and Prettier on every changed file: clean.pnpm check:repo: clean.