Skip to content

fix(core): one click guard for the menu press model and the gesture host - #8360

Draft
vjeux wants to merge 1 commit into
meta:mainfrom
vjeux:uikit/f2-click-guard
Draft

vjeux wants to merge 1 commit into
meta:mainfrom
vjeux:uikit/f2-click-guard

Conversation

@vjeux

@vjeux vjeux commented Oct 25, 2026 •

Copy link
Copy Markdown
Contributor

Behavior-module PR: click guard, input stream, gesture host touch ownership, page hold, finger-scroll watch, selection lock, presented hit testing

Section 3 of the merge plan: #8360 → #8098 → #8309 → #8763 → #8342. One commit on main (c8e1804), after #8071 and #8076, holding only this change. It carries #8074, #8270, #8293, #8315, #8320 and #8356, which closed as carried by it, and absorbs #8358 (the long press's tolerance comment and an unused import), which closes with a link here. It also takes the pieces of #8665's serving of every pointer that belong to its own features; #8665 keeps only the undecided rule for text selection during a mouse or pen drag.

Primary intent

Make the gesture host the one touch owner on the page, and give the menu press model and the gesture host one guard for the click that follows a gesture that already acted. No narrower template fits: this is one section of the plan, internal infrastructure together with the four user-visible fixes those same changes make.

User or maintainer impact

  • After a gesture acts (a menu row chosen by a lift, a tap a recognizer took), the browser still sends its own click. Two guards swallowed it, for 400 ms and 1000 ms, with different rules for a script's click, so one stray click could act twice in one place and not at all in another. One guard now decides it everywhere.
  • An app's window-capture stopPropagation, or a document-capture stopImmediatePropagation registered before Core, hid touches from the press feedback and the recognizers. They now hear page-wide input through one stream on the window's capture phase.
  • A finger held on a DropdownMenu trigger may drift up to 13 px on each axis and still open the menu, under every primary pointer (spec:AST-094 FR29); it cancelled past 10 px.
  • Where the primary pointer is fine, the press feedback controller registers no touch listener.
  • For the touch components: one owner for the finger count, touch gates, page holds and kept presses; a watcher recognizer; useFingerScroll; presented hit testing on iOS, so a touch on an element the animator is moving reaches what is drawn; and a hold's selection lock that each recognizer brings for its own contact.

Problem and solution fit

  • Why does it happen? The menu press model and the gesture host each guarded the click their way, and each touch feature registered its own page listeners.
  • Why does that hurt? A click acts twice or not at all, an app's stop hides a touch, and listeners multiply with components.
  • Why does it matter? spec:AST-094 asks for one activation per gesture and for gesture work that costs nothing where it is not used (FR21, FR24), and every touch component builds on this host.

Deltas:

  • utils/clickGuard.ts: one guard for both. It swallows the next click on the window's capture phase and disarms on that click, at the next pointerdown or after 400 ms; a row's own activation passes through withoutClickGuard, and a keyboard's or assistive technology's click (detail 0) always passes. Its click listener stays once the menu press model arms it (module:DropdownMenu/useMenuPress PR2) and lasts only while armed for the gesture host (FR24).
  • utils/inputStream.ts: one native listener per event type on the window's capture phase, shared by every subscriber, passive unless a subscriber cancels the event.
  • GestureHost:
    • the one document-level touch owner: the finger count, touch gates, and a kept press for a control that tracks its finger;
    • a second finger fails a one-finger pan that has not claimed, so the page can pinch, and a scroll-edge pan holds a lone finger from its first move toward an edge every scroller under it is at;
    • a finger matched to its scroller, its target and a moving element along the flat tree, through slots and shadow roots, and a claimed pen holding only its own stylus touches;
    • presented hit testing on iOS WebKit, from the offsets the animator reports (motion/presentation.ts).
    • where fingers come from their pointer events (inputMode: 'pointer-events', or a window with no ontouchstart), a claimed finger's touchend cancelled from its element, as the Touch Events path cancels it (FR21): the lift is noted where its pointer landed, inside an open shadow root too, under a fine primary pointer wherever the browser sends Touch Events, and the note expires after 500 ms; at most eight notes wait, and none is kept where no touchend can come (FR24).
    • its lifecycle: an armed press ends once the arena drops its last touch (the arena's onIdle), a tap that waits on a double tap included, and in the microtask after its last detach, unless something attached again in that task, the host releases its notes, frames and timers (FR24).
  • pageHold.ts, fingerScroll.ts with useFingerScroll, and hostRegistry.ts: a scroll lock's page hold and the finger-scroll watch, which ask the host only about the touches it owns.
  • Recognizers and the arena: a watcher (claimsTouches: false) takes passive listeners only and follows a touch the browser scrolls with to the lift; a pan claims nothing before it begins at 10 pt; one velocity tracker for every gesture (spec:AST-100); the arena plumbing moves behind recognizerInternals.
  • utils/selectionLock.ts: the selection and callout lock a recognizer or a press tracker brings for its own contact; a long press brings one only when it has a hold, and an element attached with manageStyle: false keeps its own styles (FR41).
  • Hooks: useGestureRecognizer and usePressTracker (now its own file) call no caller back from an Effect or after unmount, and what a sync unblocks resolves a microtask later, where its listener reports as usual; usePressTracker brakes with the host and keeps one tracker through an option change.
  • useMenuPress uses the shared guard and FR29's drift allowance; useLongPress takes its 500 ms and 10 px from the long-press recognizer's table, and its public start takes the point only, so the per-press tolerance stays internal to useMenuPress (architecture:public-component-api INV12).
  • From feat(core): gestures attach for every pointer (spec:AST-094 FR43) #8665, with the host serving every pointer: a press tracker takes a mouse press as a press (FR11's fine-pointer profile); 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); perEventPointer is accepted and does nothing.
  • Its cases in the gesture browser proof, which comes with feat(core): touch-action derivation, native scroll probe, haptics and the gesture host #8071, and presentedHitTesting.interaction.browser.spec.ts, in Chromium and WebKit, and the README; the animator's busy proof is judged against the harness's compositor-only control (test(interaction-harness): a busy-window proof can judge a component against a compositor-only control #8906).

Known gap: in iPadOS Safari, the stylus touchend of a pen a recognizer claimed is not cancelled yet; that waits for evidence from that browser.

Authority

spec:AST-094 (current): FR11, FR19, FR20, FR21, FR24, FR29, FR41 and FR43; module:DropdownMenu/useMenuPress (current) FR5, FR9 and PR2; spec:AST-057 FR6; spec:AST-100; architecture:react-component-runtime INV2, INV3 and INV5. The hover recognizer and the host's hover routing land with hover's first user.

Change and evidence

  • The click guard's unit cases failed before it; so did the case for the gesture host's arm, whose click listener now goes at the click, the next press or the end of the window, and the browser case where a claimed drag leaves no page listener once that window ends.
  • The held trigger's 12 and 14 px drifts are pinned under either primary pointer.
  • Each review fix the carried pull requests made has a case that failed before it.
  • In the browser proofs, a claimed drag leaves no page listener once the click window ends, and a touch on a moving sheet reaches the button drawn there; the rest of the gesture proof comes with feat(core): touch-action derivation, native scroll probe, haptics and the gesture host #8071.
  • The public useLongPress start keeps its own tolerance when a caller passes one anyway, a case that failed before.
  • With fingers from pointer events, a claimed pan's lift and a recognized tap's have their touchend cancelled, under a fine primary pointer (with that touchend coming after the microtask that follows its pointerup, once the armed press's listeners have gone) and inside an open shadow root too, while an unclaimed lift keeps its own; those cases failed before.
  • A nothing left after the last detach block runs 17 scenarios and checks that no listener, note, frame or timer remains after each; 16 of them failed before this round's fixes, and the double-tap wait's release failed before too.
  • Claimed lifts that wait for their touchend stop at eight, the oldest going first, and go once 500 ms pass, while a desktop with no Touch Events keeps none; those cases failed before.

Scope

Lifecycle audit

Every listener, record and timer the gesture host (and the arena, where it holds host state) creates, what creates and clears it, and what happens in each case (spec:AST-094 FR21, FR24). "Fixed" marks a row this round changed. A nothing left after the last detach block runs 17 scenarios through one check: no listener added during the scenario is left on any target, every host collection is empty, no arena timer is armed, and no frame or timeout is pending. Before these fixes 16 of them failed.

Resource Created by Cleared by Double-tap wait Last detach Blur: coarse / fine Expiry Pointer-fed vs touch-fed
Page listeners (pointer, touch, scroll, visibility, blur) Coarse: something enabled attached, or a finger-scroll watch. Fine: an armed press Nothing enabled; the armed press over Fine: kept until the arena drops the waiting touch (fixed: arena onIdle) Removed at the detach Kept / removed in the next microtask (refusing a lift kept down through it: follow-up) — Same; 'pointer-events' reads fingers from pointer events
Claiming listeners (window touchend that cancels, contextmenu) A recognizer that can claim, while installed No claimer, or uninstall As above Removed As above — Touch-fed lifts are cancelled here
Element touchend (a claimed lift) Pointer-read fingers, a coarse pointer or Touch Events sent, an enabled touch recognizer Sync, detach — Removed with its element Kept on the element — Pointer-fed only
Element blocking touchmove A recognizer that claims a moving finger; a kept press (coarse: always; fine: during an armed press) Sync, detach — Removed Kept while attached — Both
Element pointerdown (armed press) Fine pointer, an enabled recognizer or consumer The pointer turns coarse, disabled, detach — Removed Kept — Both
Per-finger target listeners touchstart That finger's end or cancel; a later touch event without it; uninstall — Removed Removed at the lift / at uninstall — Touch-fed
Document blocking touchmove (moving content) Installed, iOS WebKit, an element around a claiming recognizer drawn away from its box The motion ends, uninstall — Removed As page listeners — Both
Presented-element watch Installed and hit-testing what is drawn Uninstall — Removed As page listeners — Both
Coarse-pointer media watch The first attach The last detach — Removed Kept — —
ResizeObserver An enabled screen-edge pan None left — Disconnected Kept — —
Pending author touch-action read and its frame An attach on an element whose author value is unknown The frame runs — Cancelled in the microtask after the last detach (fixed) — One frame —
Selection locks An admitted contact whose hold locks Its end or cancel; uninstall Released at the lift Released Released — Both
Per-contact maps (finger, pointer, pairing, samples, retargeted taps, FR20 judgement) Each contact Its end or cancel; uninstall Dropped at the lift (the arena keeps its own record until the decision) Empty Dropped — Pairing maps only where a browser sends both channels
Lifts a blur cancelled (cancelledFingers) Blur, or uninstall with a finger down That finger's touchend or touchcancel; a later touch event without it — Cleared in the microtask after the last detach, unless something attached again in the same task (fixed) Refused at the lift / follow-up — Touch-fed
Claimed-lift notes and their timer A pointer-read finger's claimed or retargeted lift Its touchend; 500 ms; at most 8 — Cleared after the last detach (fixed) — 500 ms, 8 at most Pointer-fed; kept across a fine pointer's uninstall, which runs in the microtask after the pointerup, before the touchend that comes later in the same task
ownScroll records and their frames A recognizer scrolling a scroller itself Two frames — Cancelled after the last detach (fixed) — Two frames —
Armed press A press on an armed element Its contacts end and the arena drops their last record Ends when the wait is decided (fixed) Reset Reset at blur — Both
Arena timers (long press, double tap, swipe) A recognizer's deadline It fires; the recognizer finishes or is cancelled (a detach cancels it) The double tap's timer decides the single tap None left Cancelled Per recognizer Both
Click guard (utils/clickGuard) A claimed, retargeted or refused lift The click, the next pointerdown, or 400 ms — Outlives the host by design: a late click is still swallowed (FR21) — 400 ms Both
Finger-scroll watch, touch gates, finger listeners Their owners Their owners' release — Removed with them — — —

Real-browser evidence

simulator/emulator evidence gates merge; real iPhone + Android gate release.

Pages built from this PR's tree with esbuild, as the gesture browser fixture is: the real host, animator, recognizers and press tracker, nothing stubbed. iOS: iPhone 17 Pro simulator, iOS 26.2, Safari. WebKit: Playwright 1.61 with WebKit 26.5, in a touch-enabled phone context. Every take was recorded and checked frame by frame. The takes ran at d7e6d99; this round's changes touch neither path (they run after the last detach, or end an armed press under a fine pointer).

Hit testing on moving content (AST-013 FR10), iOS 26.2 Safari. A sheet slides on the compositor over a scrim that closes it: down 0→444 px, back up, down again, 4 s each. Taps land on the drawn Done button while it slides down (A) or up (B), or where Done started (C). The run was 18 takes with this PR, then 18 with the correction off (presentedHitTesting: 'never') as a control. In every take the engine's own hit test was whatever sat under the finger where the slide started.

Tap (6 takes each) This PR Control (correction off)
A: on the drawn Done, sliding down Done clicked 6/6 Nothing 6/6 (lands on the sheet body)
B: on the drawn Done, sliding up Done clicked 6/6 Scrim tapped 6/6 (would close the sheet)
C: where Done started Scrim tapped 6/6; Done not clicked Done clicked 6/6

Every click with this PR came from the host, with the engine's touchend cancelled (18/18).

One loop: this PR left, the control right

Contact sheets of one loop: this PR, control.

Pointer-fed touchend timing (inputMode: 'pointer-events').

  • pointerup comes first and touchend follows in the same task, in every take: iOS 30/30, with equal timestamps; WebKit 30/30, 0–1 ms apart. Only microtasks ran between them; a message, a setTimeout(0) and a frame queued at the pointerup had not run yet. Under a fine pointer the armed press's listeners go in that microtask, which is why a claimed lift's note outlives the uninstall.
  • A claimed pan, 60 px and 25 px drags: touchend cancelled, and no mousedown, mouseup or click reached the element. iOS 12/12, WebKit 12/12.
  • An unclaimed tap keeps its click: iOS 6/6 (mousedown, mouseup and click 34–46 ms after the lift), WebKit 6/6.
  • Controls on an element with no gesture: iOS clicks an uncancelled 25 px drag 6/6 (33–37 ms after the lift) and a 60 px drag 0/6, so the 25 px claimed drag is where the cancel stops a click iOS would send. Linux WebKit sends no click from raw touch events (0/12), so its unclaimed taps go through Playwright's tap.
  • Frames: iOS, a tap and a 60 px drag, iOS, a claimed and a plain 25 px drag; the page's own per-take lines on iOS and WebKit.

Testing

Local gates at f125044, one commit on main c8e1804 holding only this change (100 files, +14,714/−1,787):

@vercel

vercel Bot commented Oct 25, 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 9:23am UTC

Request Review

@github-actions github-actions Bot added the needs:design-review Affects visuals — Design should review label Oct 25, 2026
@astracat-bot

astracat-bot Bot commented Oct 25, 2026 •

Copy link
Copy Markdown

AI review status for this pull request.

Review status Updated
❓ Needs maintainer input (for maintainers only) Oct 31, 2026, 9:37 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.

This needs an owner decision before it can be approved. Against main, the pull request carries the whole touch-presentation stack, and the draft spec:AST-094 it adds is the only spec for the gesture host, the motion animator, the experimental subpaths and the theme motion roles, though its FR4 and DEC-1 rule out those same theme roles.

An engineering owner can record that decision, including whether touch motion is themable, by commenting /approve-spec <full head SHA> on this pull request; the author may do so if they are an engineering owner. Whatever the decision, please land the predecessors first so this diff is the 550-line click-guard commit, and fix the following:

  • pressFeedback.ts now turns off text selection and the touch callout under a held finger on every stable press-feedback surface, Link included, under an [experimental] changeset, which spec:AST-017 FR22 does not allow for stable behaviour.
  • The touch resolver behind presentation="adaptive" stays at 768 px instead of the theme's effective md (spec:AST-012 FR9), although its changeset says it reads the theme.
  • theme.template.ts documents a platform adaptation axis that defineTheme rejects, and touch-experimental-subpaths.md misstates what is exported.

[Automated review]

Comment thread packages/cli/assets/theme.template.ts Outdated
Comment on lines +361 to +362
* are primary-pointer precision, contrast preference, motion preference, and
* platform (`ios` for the browsers of iPhone and iPad, `other` for the rest).

Copy link
Copy Markdown

Choose a reason for hiding this comment

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

themeAdaptations.ts accepts only width, pointer, contrast and motion in when (its CONDITION_AXES), so a rule copied from this sentence with platform makes defineTheme throw "… .platform is not supported." The stack removed the platform condition; please drop it from the template too.

@@ -283,6 +302,9 @@ function onPointerDown(event: PointerEvent): void {
gesture: idlePressGesture(),
pointerId: event.pointerId,
timer: null,
// A held finger never starts text selection on the surface (UIKit's
// controls and cells never do), on any coarse pointer.
unlock: lockSelection([element], event.target),

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 lock now applies to every stable usePressFeedback surface on a touch device, Link's <a href> included. -webkit-touch-callout: none for the whole hold removes the iOS link callout on Astryx links. spec:AST-017 FR22 keeps stable runtime changes out of an [experimental] changeset; can the lock stay on the experimental press trackers?

Comment on lines +71 to +72
export const COMPACT_TOUCH_QUERY =
DEFAULT_COMPACT_DEFINITION.coarsePointerQuery;

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 query is built from DEFAULT_COMPACT_DEFINITION (md 768), so a theme that overrides adaptations.widthBreakpoints.md never moves the sheet/popover boundary. spec:AST-012 FR9 defines compact by the effective md, and its verification row lists "overridden md"; .changeset/touch-presentation.md also says the theme's md is used.

'@astryxdesign/core': patch
---

[experimental] Two experimental subpaths for building touch presentations, outside the stable compatibility promise (they may change or be removed in a patch release). `@astryxdesign/core/motion/experimental` exports the compositor animator (`createAnimator`, `useAnimator`), UIKit's spring, rubber-band and cubic-bézier math, `resolveMotion` (a motion role's value under the element's theme), and the UIKit constants table as the read-only `uikit` namespace. `@astryxdesign/core/gestures/experimental` exports UIKit-style gesture recognizers (tap, long press, pan, screen-edge pan, swipe, pinch, rotation, hover), `useGestureRecognizer`, `usePressTracker`, `useTouchPresentation`, `readTouchPresentation`, `playHaptic` and `attachTapHaptic`, with the `GestureArena` and `VelocityStrategy` types their options reach. Nothing is added to the package root or to any other subpath.

Copy link
Copy Markdown

Choose a reason for hiding this comment

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

"Nothing is added to the package root or to any other subpath" does not hold at this head: packages/core/package.json also adds ./motion/reducedMotion.stylex. And VelocityStrategy is listed as exported, but gesture-recognizer-fixes.md removes it and gestures/experimental.ts does not export it.

@astracat-bot astracat-bot Bot left a comment

Copy link
Copy Markdown

Choose a reason for hiding this comment

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

This needs an engineering owner's spec decision before it can be approved: nothing current on main governs the gesture host's click handling or most of what this diff carries beneath it (the gesture and motion systems, theme motion roles, the selection lock, the long-press distance and undefinedVariables), so an owner needs to provide or identify that spec, or rule that those parts leave this pull request. An engineering owner records the decision by commenting /approve-spec <full head SHA> on this pull request, and the author may do so if they are an engineering owner. Whatever the decision, please land the predecessors first so this diff is the 549-line click-guard commit, and address these:

  • undefinedGeneratedVariables in packages/build/src/vite.ts flags an app's own --xs-style variables declared outside the StyleX stylesheet, so default builds warn and 'error' builds fail.
  • armClickGuard adds and removes its window listener per gesture, where useMenuPress PR2 says it is installed once per document.
  • .changeset/click-guard.md and .changeset/reduced-motion-record.md describe internal, unexported work as consumer fixes.

[Automated review]

Comment thread packages/core/src/utils/clickGuard.ts Outdated
Comment on lines +60 to +75
subscribeInput(
view,
'click',
event => {
if (isExempt(event)) {
return;
}
event.preventDefault();
event.stopImmediatePropagation();
disarmClickGuard(doc);
},
{cancels: true},
),
// The next press is a new gesture: its click must never be eaten.
subscribeInput(view, 'pointerdown', () => disarmClickGuard(doc)),
);

Copy link
Copy Markdown

Choose a reason for hiding this comment

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

useMenuPress.spec.md PR2 says the window capture click listener is installed once per document and armed per gesture; here it is subscribed at every arm and removed at every disarm, and clickGuard.test.ts asserts that. Either keep one installed listener per document and arm it per gesture, or have PR2 amended.

Comment thread packages/build/src/vite.ts Outdated
Comment on lines +419 to +431
const declared = new Set(
[...css.matchAll(new RegExp(`(--(?:${names})[a-z0-9]+)\\s*:`, 'g'))].map(
match => match[1],
),
);
const read = new Set(
[
...css.matchAll(
new RegExp(`var\\(\\s*(--(?:${names})[a-z0-9]+)\\s*[,)]`, 'g'),
),
].map(match => match[1]),
);
return [...read].filter(name => !declared.has(name)).sort();

Copy link
Copy Markdown

Choose a reason for hiding this comment

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

Any --x… or --astryx… name of lowercase letters and digits counts as generated here, and only declarations inside the StyleX stylesheet count, so an app style reading var(--xs) that its global CSS declares is reported as dropped (a warning by default, a failed build with 'error'). Could the check use the variable and constant names StyleX actually generated (from rulesById), or the declarations of every emitted CSS asset?

Comment thread .changeset/click-guard.md Outdated
'@astryxdesign/core': patch
---

[fix] The gesture recognizers and the menu press model share one guard for the browser's click after a gesture that already acted. The gesture system now swallows that click for 400ms or until the next press, as menus do, instead of 1000ms. Script-dispatched clicks with a non-zero `detail` are swallowed in that window too; keyboard and assistive-technology clicks (`detail` 0) always pass.

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 release has the gesture system or its 1000 ms window, and menu users see no change, so this reads as a consumer fix that is not one. The other internal touch changesets use "[chore] Internal: … Not exported from the package entry point"; could this one do the same?

Comment thread .changeset/reduced-motion-record.md Outdated
'@astryxdesign/core': patch
---

[fix] The experimental touch animator follows design:reduced-motion. Under Reduce Motion every `animate` and `play` (travel or opacity, on a spring or any curve) becomes an opacity-only cross-fade of 200 ms on a coarse primary pointer, or for a call marked `touchRelease`, and completes at once on any other pointer; nothing moves, scales or springs, and travel lands with a plain style write while the element is transparent, never as an animated transform. `timing.reducedMotionCrossFadeMs` is now 200 ms and is no longer a theme role. The `reducedMotion` and `crossfade` call options are removed; `crossfadeAs` stays. `animator.reducedMotion()` reports the mode for feedback with no fade of its own, `reducedMotionFade()` gives the same fade to code beside the animator, and `reducedMotion.stylex.ts` holds its constants for StyleX transitions.

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 animator is internal at this head (there is no experimental subpath), so "The experimental touch animator", its removed call options and reducedMotion.stylex.ts are nothing consumers can import. Could this be worded as an internal change like the other touch changesets?

} from './GestureArena';
import type {GestureRecognizer} from './GestureRecognizer';
import type {PressBounds} from './PressTracker';
import {HAPTIC_SWITCH_ATTRIBUTE} from './hapticSwitch';

Copy link
Copy Markdown

Choose a reason for hiding this comment

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

HAPTIC_SWITCH_ATTRIBUTE has no use here since onClick moved into utils/clickGuard.ts (an unused-import warning); the headers of hapticSwitch.ts and haptics.ts also still describe the host's own swallower.

@github-actions

github-actions Bot commented Oct 25, 2026 •

Copy link
Copy Markdown
Contributor

PR Analysis Report

Current run: The current CI run concluded skipped. Current analysis is unavailable.

The exact-head preview is not available yet.


Generated by PR Enrichment workflow | View current CI run

@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 engineering-owner decisions, and three changes are needed whatever they decide. An engineering owner records each decision by commenting /approve-spec <full head SHA> on the pull request after providing, identifying or amending the governing spec (the author may do so if they are an engineering owner):

  • The astryxStylex cross-file constants and the {stylexOptions} form's new treeshakeCompensation: true default have no governing spec.
  • Touches on moving content, where a retargeted tap dispatches its own click and the document holds a blocking touchmove, depart from spec:AST-094 FR11 and FR19.
  • The shared input stream on the window's capture phase has no governing record.
  • #8641's rule that a gesture which cancels itself keeps its finger until the lift is not one of FR19's three exceptions.

Independently, the gesture host must attach for every pointer and drop the per-event press-feedback carve-out (architecture:react-component-runtime INV13, spec:AST-094 FR19, FR41 and FR43), press tracking must be built by a create… factory rather than the PressTracker class, and the carried foundation should be split so this diff is the 786-line click-guard change instead of 27,395 authored lines.

[Automated review]

* @position Browser adapter of packages/core/src/gestures: turns real events
* into GestureArena input, and carries out what the arena decides
*
* ONLY WHERE THE PRIMARY POINTER IS COARSE

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 host attaches only while the primary pointer is coarse, but on main architecture:react-component-runtime INV13 reads “The gesture host attaches for every pointer, with no touch-only query; under a fine pointer only a component that uses a gesture listens”, and spec:AST-094 FR19 reads “Recognizers attach for every pointer, with FR43's exceptions”. A component that adopts the host would get no gesture under a mouse, a trackpad, or a finger or pen on a touchscreen laptop, and the perEventPointer carve-out (:228-243) exists only because of this gate. Please bring #8665's every-pointer host into this head (or land it first), within FR24's fine-pointer budget, and flip the cases that pin the coarse-only rule.

return tap.numberOfTapsRequired === 1 && tap.numberOfTouchesRequired === 1;
}

export class PressTracker implements TouchConsumer {

Copy link
Copy Markdown

Choose a reason for hiding this comment

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

PressTracker is a class outside the recognizer family. On main, architecture:react-component-runtime INV13 reads “The recognizer family (its base, the arena, and the host) is the one place these modules use classes … Every other module, and every public layer, is built from create… factories”, and its verification fails “a class outside the recognizer family”. #8763's createPressTracker is the fix; it needs to be in this head or land before it.

// compiled (the TypeScript transform drops it as unused), the module is
// never loaded, and its constants never reach the CSS: a media-query key
// fails the build and a value ships as an undefined custom property.
treeshakeCompensation: 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.

This gives the {stylexOptions} form a new treeshakeCompensation: true default (the options form already had it at :157), so Storybook, the sandbox and any app using this form compile differently unless they pass their own value. No current record governs the plugin's compile options (spec:AST-080 leaves how @astryxdesign/build plugs into each bundler out of scope), so an engineering owner needs to provide or identify the governing spec for this default, or the change moves to its own pull request.

* that cancels itself on a finger's move (a long press past its tolerance)
* keeps that finger until it lifts: the drag goes on, and the page never
* takes it over midway (`spec:AST-094` FR14), as UIKit's scroll view never
* takes a touch another recognizer had.

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 #8641's rule: a gesture that cancels itself on a finger's move keeps that finger claimed until it lifts, so the host keeps cancelling a touchmove no recognizer owns. On main, spec:AST-094 FR19 reads “A touchmove of a touch no recognizer owns MUST NOT be cancelled, with three exceptions”, and none of the three is a self-cancelled recognizer's finger; FR14 decides a scroll area's drag once but grants no fourth exception. An engineering owner decides whether AST-094 is amended for it or the rule waits for #8641.

* controller, the gesture host)
* @output Exports subscribeInput
* @position The one entry for document-wide input listeners in core: one
* native listener per event type on the window's capture phase, shared by

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 press feedback and the gesture host now hear page-wide input on the window's capture phase, and no current record states that: spec:AST-094 FR24 counts listeners once per page without choosing a phase, and module:DropdownMenu/useMenuPress PR2 and ORD4 put only the menu's stray-click listener there. The description lists “a record for listening on the window's capture phase” as an open owner decision; an engineering owner provides or identifies that record, or the stream leaves this pull request.

* Safari honours a blocking `touchmove` only where it was registered before
* the touch began, on the engine's own target, and on iOS WebKit a finger
* on an element a compositor animation moves lands, for the engine, on
* whatever sits under the motion's start (see presentedTarget). So while an

Copy link
Copy Markdown

Choose a reason for hiding this comment

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

While an element a claiming recognizer lives in moves, the document carries a blocking touchmove, and a retargeted tap dispatches its own click (clickRetargeted, :764-788). On main, spec:AST-094 FR19 reads “Only an element with a recognizer that must claim mid-gesture registers that blocking listener” and FR11 keeps a control's activation on the browser's click; FR6 covers moving content only for a catch. The description names “the AST-094 amendment for moving content” as an open owner decision, which this code waits on.

@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 engineering-owner decisions for three carried changes that the description itself lists as open: #8641's rule that a gesture which cancels itself keeps its finger claimed until the lift (spec:AST-094 FR19 allows three exceptions, and this is none of them), touches on moving content (a retargeted tap that dispatches its own click and a document-level blocking touchmove, against FR11 and FR19), and a record for listening on the window's capture phase. An engineering owner records that decision by commenting /approve-spec <full head SHA> on this pull request, and the author may do so as an engineering owner. Whatever is decided, these need changes:

  • Land the carried gesture and motion foundation through its own pull requests, so this diff is the click-guard change (about 870 of 28,389 authored lines).
  • Remove .changeset/build-stylex-cross-file-consts.md, which announces an @astryxdesign/build fix and default change that no file in this pull request makes.
  • Drop the coarse-pointer-only wording from PressTracker's selectionLock documentation, since the host locks for any finger or pen.

[Automated review]

'@astryxdesign/build': patch
---

[fix] `astryxStylex` resolves `defineConsts` values read from another file. With the `{stylexOptions}` form (the one Storybook uses), a file that read only constants from a `.stylex.ts` module lost that import once StyleX compiled it, so the constants never reached the CSS: a constant used as a media-query key failed the build ("Invalid empty selector") and one used as a value shipped as a custom property nothing declares. In either form, a constant defined on the other side of the layer split, or in an Astryx package (compiled with the library class-name prefix) and read from an app file (compiled with its own), stayed unresolved. The `{stylexOptions}` form now passes StyleX `treeshakeCompensation: true` by default (before, its babel pass ran with StyleX's default, `false`), as the plugin form already does; pass `treeshakeCompensation: false` in `stylexOptions` to restore the old behavior.

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 packages/build/ file changes in this pull request, and astryxStylex on main still sets treeshakeCompensation: true only in its plugin form, so this note would bump @astryxdesign/build and announce a fix and a default change that do not ship (spec:AST-017 FR7: "The tag and the Changeset body are the whole declaration"). Remove it, or bring the build change back with its governing record.

Comment on lines +91 to +95
* For a control whose own record defines a hold action (a stepper's
* hold-repeat): pass `lockSelection` (gestures/selectionLock.ts) to turn
* text selection and the callout off for each press, where the primary
* pointer is coarse (`spec:AST-094` FR41). A control whose hold does
* nothing passes none. @default undefined

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 host takes a press tracker's lock for a finger or a pen under any primary pointer (GestureHost.ts:1588-1596, :2393-2403; utils/selectionLock.ts:54-62), as spec:AST-094 FR41 requires ("A pen's hold acts as a finger's"). "where the primary pointer is coarse" tells a stepper author the opposite, and gestures/selectionLock.ts is the transition re-export marked for removal: point at utils/selectionLock.ts and drop the coarse clause.

@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 engineering-owner decisions for three carried changes: #8641's rule that a gesture which cancels itself keeps its finger claimed until the lift (spec:AST-094 FR19 allows three exceptions, and this is none of them), touches on moving content (a retargeted tap that dispatches its own click and a document-level blocking touchmove, against FR11 and FR19), and a governing record for Core listening on the window's capture phase. An engineering owner records each decision by commenting /approve-spec <full head SHA> on this pull request after providing, identifying or amending the governing spec, and the author may do so as an engineering owner. Whatever is decided, these need changes:

  • Land the carried gesture and motion foundation through its own pull requests, so this diff is the click-guard change (874 of 28,384 authored lines).
  • Have useLongPress return a start that forwards only the point, so its public handlers gain no undeclared tolerance argument.
  • Resolve an inline CSS-wide touch-action keyword such as initial before intersecting it, so a tap recognizer no longer turns it into none.
  • Drop the coarse-pointer-only wording from PressTracker's selectionLock documentation.

[Automated review]

Comment thread packages/core/src/hooks/useLongPress.ts Outdated
export function useLongPress(
options: UseLongPressOptions,
): UseLongPressHandlers {
return useLongPressDetector(options);

Copy link
Copy Markdown

Choose a reason for hiding this comment

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

Public useLongPress now returns the internal detector, so the start it hands callers honours a second tolerance argument that UseLongPressHandlers does not declare. Under architecture:public-component-api INV12 that reachable operation is public. Returning the handlers with a start that forwards only the point keeps the tolerance internal to useMenuPress.

Comment on lines +92 to +94
* hold-repeat): pass `lockSelection` (gestures/selectionLock.ts) to turn
* text selection and the callout off for each press, where the primary
* pointer is coarse (`spec:AST-094` FR41). A control whose hold does

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 host takes a press tracker's lock for a finger or a pen under any primary pointer (locksSelectionFor, utils/selectionLock.ts), as spec:AST-094 FR41 asks, so "where the primary pointer is coarse" is no longer true; gestures/selectionLock.ts is also a transition re-export marked for removal. Suggest dropping the coarse clause and pointing at utils/selectionLock.ts.

}
if (next === 'cancelled' || next === 'failed') {
if (next === 'cancelled') {
this.releaseClaim(recognizer, this.movingIds);

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 keeps a self-cancelled gesture's moving finger claimed until the lift, so its touchmove stays cancelled after no recognizer owns it. spec:AST-094 FR19 allows three exceptions to cancelling an unowned touch's moves, and this is not one of them; it waits on an engineering owner's decision.

@vjeux

vjeux commented Oct 31, 2026

Copy link
Copy Markdown
Contributor Author

Fixed in 04129a1: useLongPress returns a start that forwards only the point, so its public handlers take no tolerance argument; PressTracker's selectionLock documentation drops the coarse clause and points at utils/selectionLock.ts; and the inline CSS-wide touch-action keyword is resolved before intersecting in #8071, where that code lives; with #8061, #8071 and #8076 merged, this branch is one commit on main. A page hold also registers its blocking touchmove as it starts where the primary pointer is coarse, since Safari honours one only when it was registered before the touch (raised in #8098's review). The stylus touchend cancel is out until there is evidence from iPadOS Safari, and the description states that gap.

@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 decision about two carried changes: touches on moving content (on iOS a retargeted tap dispatches its own click, and the document carries a blocking touchmove while a claiming recognizer's element moves), which depart from spec:AST-094 FR11 and FR19 as they stand, and the window-capture input stream, which no current record governs. An engineering owner records the decision by amending or identifying the governing spec and commenting /approve-spec <full head SHA> on the pull request, and the author may do so as an engineering owner. Whatever is decided, two changes are needed:

  • Land the click guard on its own (about 1,000 authored lines) and move the host rework and its new modules to their own pull requests, since 15,277 authored lines with no behavior-module declaration block approval.
  • Keep cancelling a claimed finger's touchend where the host reads fingers from pointer events (gestures/GestureHost.ts:1431, :1977), as FR21 requires, together with the test that pinned it on main.

[Automated review]

unsubscribe();
}
if (view != null) {
if (this.inputMode === 'auto') {

Copy link
Copy Markdown

Choose a reason for hiding this comment

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

Only 'auto' mode subscribes touchend here, and onTouchEnd returns early whenever usesTouchEvents is false (:1977), so under inputMode: 'pointer-events' (and in 'auto' where Touch Events arrive without ontouchstart) a claimed finger's touchend is no longer cancelled, and its compatibility mouse events reach the page. main cancelled it through claimedLifts; spec:AST-094 FR21 requires it. Could that cancel and its test stay until #8309 replaces them?

@vjeux

vjeux commented Oct 31, 2026

Copy link
Copy Markdown
Contributor Author

Fixed in e5e49f4: where fingers come from their pointer events (inputMode: 'pointer-events', or a window with no ontouchstart), a claimed finger's touchend is cancelled again from its element (FR21), noted where its pointer landed at pointerup, inside an open shadow root too and under a fine primary pointer wherever the browser sends Touch Events; four of its cases failed before. The description now declares the behavior modules.

@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 decision about two changes this pull request pursues: touches on moving content (on iOS a retargeted tap dispatches its own click, and the document carries a blocking touchmove while a claiming recognizer's element moves), which depart from spec:AST-094 FR11 and FR19 as they stand, and the window-capture input stream, which no current record governs. An engineering owner records the decision by amending or identifying the governing spec and commenting /approve-spec <full head SHA> on the pull request; the author may do so as an engineering owner. Whatever is decided, one fix is needed: where the gesture host reads fingers from pointer events, a claimed lift's record and its target element are never dropped when no element listener consumes the touchend, and the list no longer has the cap main kept (gestures/GestureHost.ts:2560-2570), against FR24's rule that nothing touch-related remains after the last instance unmounts, so push records only while a listener can consume them (or restore the cap) and clear them on uninstall, with a test. The restored touchend cancel addresses the earlier FR21 concern, and the size is accepted under the behavior-module declaration.

[Automated review]

@vjeux

vjeux commented Oct 31, 2026

Copy link
Copy Markdown
Contributor Author

Fixed in 1379537: a claimed lift is noted only where an element can take its touchend, at most eight wait (main's cap, the oldest going first), and one timer drops each after 500 ms, so no node stays behind when no touchend comes (FR24). Cases for the cap, the expiry and a desktop with no Touch Events failed before.

@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 decision about two changes this pull request pursues: touches on moving content (on iOS a retargeted tap dispatches its own click and the document gets a blocking touchmove, which spec:AST-094 FR11 and FR19 do not allow as written) and the window-capture input stream, which no current record governs. An engineering owner records that decision by commenting /approve-spec <full head SHA> on the pull request; the author may do so as an engineering owner. Whatever is decided, one fix is needed: under a fine primary pointer with fingers read from pointer events, uninstall now clears a claimed lift's note in the microtask after pointerup, so its touchend is no longer cancelled (gestures/GestureHost.ts:1607, FR21), and the case at GestureHost.test.ts:4637 should await a microtask between the two events so it fails first. The new cap and expiry on those notes resolve the earlier leak.

[Automated review]

this.cancelEverything(this.arena.now());
this.clearClaimedLifts();

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 runs in releaseArmedPress's microtask (:1399-1405), which a browser runs between a claimed finger's pointerup and its touchend, so the note onPointerUp just pushed (:2576) is gone when onClaimedTouchEnd (:1992-2021) looks for it, and that touchend is not cancelled (spec:AST-094 FR21): after a tap a recognizer took, the browser's compatibility mouse events follow. The element's touchend listener outlives this uninstall, so the notes could go only when the host goes idle (uninstallIfIdle, once nothing is attached), leaving the rest to the 500 ms timer. The case at GestureHost.test.ts:4637 dispatches both events in one synchronous run; awaiting a microtask between them makes it fail first.

@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 decision about two changes this pull request pursues: touches on moving content (on iOS a retargeted tap dispatches its own click and the document gets a blocking touchmove, which spec:AST-094 FR11 and FR19 do not allow as written) and the window-capture input stream, which no current record governs. An engineering owner records that decision by commenting /approve-spec <full head SHA> on the pull request, and the author may do so as an engineering owner. Whatever is decided, three fixes are needed:

  • Under a fine primary pointer with fingers read from pointer events, uninstall clears a claimed lift's note in the microtask after pointerup, so its touchend is no longer cancelled (gestures/GestureHost.ts:1606, FR21); the case at GestureHost.test.ts:4637 fails once it awaits a microtask between the two events.
  • Under a fine primary pointer, releaseArmedPress now waits until the arena holds no lifted touch (gestures/GestureHost.ts:1399), so a press whose tap waits on a double tap leaves the page listeners on until some later press ends (FR24).
  • .changeset/long-press-one-source.md says the held DropdownMenu trigger keeps 10 px with no behavior change, which .changeset/menu-trigger-drift.md contradicts.

[Automated review]

this.cancelEverything(this.arena.now());
this.clearClaimedLifts();

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 with fingers read from pointer events, this runs in releaseArmedPress's microtask (:1398-1403), which a browser runs between a claimed finger's pointerup and its touchend, so onClaimedTouchEnd finds no note and the touchend is not cancelled (spec:AST-094 FR21). Could the notes be cleared only when the host goes idle, leaving the rest to the 500 ms timer? The case at GestureHost.test.ts:4637 fails once it awaits a microtask between the two events.

return;
}
queueMicrotask(() => {
if (this.isArmedPress && this.arena.touches.length === 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.

arena.touches includes a lifted touch whose click a recognizer may still claim (a tap waiting on a double tap), and nothing re-checks once that resolves, so under a fine primary pointer the page listeners stay on with no contact down until some later pointerup or touchend (spec:AST-094 FR24). main counted only contacts still down here.

Comment thread .changeset/long-press-one-source.md Outdated
'@astryxdesign/core': patch
---

[chore] Internal: `useLongPress` (ContextMenu's long press, and the held finger that opens a menu from its trigger) takes its default duration and tolerance, 500ms and 10px on either axis, from the same source as the long-press gesture recognizer. No behavior changes.

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 held finger that opens a menu from its trigger now gets 13 px (.changeset/menu-trigger-drift.md), so naming it here with 10 px and "No behavior changes" contradicts that note (spec:AST-017 FR7). Could this drop "and the held finger that opens a menu from its trigger"?

@vjeux

vjeux commented Oct 31, 2026

Copy link
Copy Markdown
Contributor Author

Fixed in d7e6d99: a claimed lift's note now survives the host's uninstall after an armed press, since its touchend comes a task after the pointerup; it goes only when taken or after 500 ms. The fine-pointer case leaves a microtask and a task between the two and checks the host has uninstalled; it failed before.

@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 decision about two changes this pull request pursues: touches on moving content (on iOS a retargeted tap dispatches its own click and the document gets a blocking touchmove, which spec:AST-094 FR11 and FR19 do not allow as written) and the window-capture input stream, which no current record governs. An engineering owner records that decision by commenting /approve-spec <full head SHA> on the pull request, and the author may do so as an engineering owner. The claimed-lift fix in this push holds; these changes are still needed whatever is decided:

  • Under a fine primary pointer, releaseArmedPress waits until the arena holds no lifted touch (gestures/GestureHost.ts:1399), so a press whose tap waits on a double tap leaves the page listeners on until some later press ends (FR24).
  • Claimed-lift notes and their timer now outlive the last detach (gestures/GestureHost.ts:1606-1608); drop them when the host goes idle (FR24).
  • .changeset/long-press-one-source.md says the held DropdownMenu trigger keeps 10 px with no behavior change, which .changeset/menu-trigger-drift.md contradicts.
  • spec:AST-013 FR10 asks for real-browser evidence of the pointer-fed touchend timing, and iOS Safari evidence for presented hit testing if it stays.

[Automated review]

return;
}
queueMicrotask(() => {
if (this.isArmedPress && this.arena.touches.length === 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.

arena.touches includes a lifted touch whose click a recognizer may still claim (a tap waiting on a double tap), and nothing re-checks once that resolves, so under a fine primary pointer the page listeners stay on with no contact down until some later press ends (spec:AST-094 FR24). Counting only contacts still down, as main did, or re-checking when the deferred click resolves would fix it; a fine-pointer case with a pending double tap would pin it.

Comment on lines +1606 to +1608
// A claimed lift's note stays: its touchend comes in a later task than
// the pointerup, often after an armed press's listeners have gone, and
// the element's own listener still takes it (noteClaimedLift).

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 uninstall keeping the notes, nothing drops them when the last recognizer detaches inside the 500 ms window: cleanup has already removed the element's touchend handler, so the note and its clock timer outlive the last attachment (spec:AST-094 FR24, "nothing touch-related remains after the last instance unmounts"). Clearing them in uninstallIfIdle's idle branch would keep this fix and close that gap.

Comment thread .changeset/long-press-one-source.md Outdated
'@astryxdesign/core': patch
---

[chore] Internal: `useLongPress` (ContextMenu's long press, and the held finger that opens a menu from its trigger) takes its default duration and tolerance, 500ms and 10px on either axis, from the same source as the long-press gesture recognizer. No behavior changes.

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 held finger that opens a menu from its trigger now gets 13 px (.changeset/menu-trigger-drift.md), so naming it here with 10 px and "No behavior changes" contradicts that note (spec:AST-017 FR7). Dropping "and the held finger that opens a menu from its trigger" would fix it.

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

vjeux commented Oct 31, 2026

Copy link
Copy Markdown
Contributor Author

Fixed in f125044, after an audit of every listener, record and timer the host creates (the table is in the description):

  • An armed press now ends once the arena has decided its last touch, so a tap that waits on a double tap lets a fine pointer's page listeners go when that wait ends.
  • In the microtask after the last detach, unless something attached again in that task, the host drops claimed-lift notes and their timer, lifts a blur left owed a refusal, the pending author touch-action read and its frame, and ownScroll's frames.
  • A "nothing left after the last detach" block runs 17 scenarios and checks that no listener, note, frame or timer remains; 16 of them failed before.

.changeset/long-press-one-source.md now covers ContextMenu's long press only and points the DropdownMenu trigger at its 13px drift fix.

The iOS simulator and WebKit evidence is in the description. It covers hit testing on moving content (this PR 18/18 against a control that fails 18/18) and the pointer-fed touchend. That touchend comes in the pointerup's own task, right after its microtasks, not in a later task as the comments said; the comments and the test now follow the measurement.

@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 decision about two changes this pull request pursues: touches on moving content (on iOS a retargeted tap dispatches its own click and the document gets a blocking touchmove, which spec:AST-094 FR11 and FR19 do not allow as written) and the window-capture input stream, which no current record governs. An engineering owner records that decision by commenting /approve-spec <full head SHA> on the pull request, and the author may do so as an engineering owner. The fixes from the last round hold: the double-tap listener window, the notes, frames and timers after the last detach, the long-press changeset, and the touchend timing evidence. If presented hit testing stays, spec:AST-094's Platform support also asks for real-device iOS Safari evidence beyond the simulator takes in the description.

[Automated review]

This branch was successfully deployed

1 active deployment
Preview — f125044b 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.

1 participant