Skip to content

fix(lab): SearchField's tap claim takes a pen on touch screens and lets go after the click guard - #8689

Draft
vjeux wants to merge 1999 commits into
meta:mainfrom
vjeux:uikit/c09-search-fr43
Draft

vjeux wants to merge 1999 commits into
meta:mainfrom
vjeux:uikit/c09-search-fr43

Conversation

@vjeux

@vjeux vjeux commented Oct 27, 2026 •

Copy link
Copy Markdown
Contributor

Depends on #8224, #8665 and #8666. Review only this PR's change: vjeux/astryx@4fd7d5b...2370b6c

User impact

On iPhone and iPad, SearchField's bottom and tab placements claim a tap on the field's text so Safari doesn't pan the page (spec:AST-099 FR4). Now the press after a claimed tap loses its click, and an Apple Pencil's tap isn't claimed.

Problem and solution fit

What led to it: the UIKit-parity program moved gestures to every pointer (#8665, spec:AST-094 FR43). The field swallows a claimed tap's late mouse events until their click, but #8665's click guard now takes that click first, so the swallow never ends. And the claim took only a finger.

Options:

  • Rely on the guard alone: without it, late events would focus natively and Safari would pan.
  • End the swallow on a timer: a slow tap leaks.
  • Claim every pointer: a mouse click would lose its click and caret (DEC-21), and a desktop pen tablet's click would be swallowed (spec:AST-017 FR59).

Chosen: the swallow ends once the guard has the click (else at the click); the claim takes a finger and a pen, only under a coarse primary pointer.

Expected behavior and authority

spec:AST-099 FR4; spec:AST-094 FR43 and DEC-21; spec:AST-017 FR59. Owner question: FR43 names no mouse exception for a tap; may a recognizer serving only the touch keyboard's reveal stay off for a mouse?

Smallest restoration

A swallow condition, the pen, a coarse-pointer gate, and an isClickGuardArmed re-export in internal/touch.

Size (gzip, base → head): SearchField JS 60.35 → 60.38 KB, CSS 1.54 → 1.54 KB.

Evidence

  • Reproduction before the change: Testing 1 to 3 failed.
  • Result after the change: they pass.
  • Representative unchanged path: the stacked field; mouse and trackpad.
  • Regression test or other mutation-sensitive proof: undoing each part fails its own browser test.

On iPad #8665 cancels a claimed pen's touchend (real-device check owed). The ci-duration red here was a GitHub 502, fixed by #8744.

Scope

  • One defect is restored.
  • Separable contradictory or unsettled tagalongs were removed or split.
  • No new public API, visual representation, default, or interaction model is hidden under the bug-fix label.
  • Public text and artifacts contain no internal Meta context.

Testing

What I ran and what it showed, in Lab/SearchField's bottom story:

  1. Chromium and WebKit, touch: two taps on the text; the second tap's click was eaten before and lands now.
  2. Chromium, test(interaction-harness): wheel and pen input drivers for every-pointer proofs #8666's pen driver: a pen tap is claimed as a finger's (WebKit has no pen).
  3. Chromium as a desktop: no claim; mouse and pen keep the browser's focus and click.
  4. SearchField's browser suite passes in both engines.

vjeux and others added 30 commits October 26, 2026 22:09
…tory's host on public APIs) into uikit/c09-search-bottom
…uikit/c09-search-tab

# Conflicts:
#	apps/storybook/stories/SearchField.stories.tsx
…sture system (spec:AST-094 FR24, FR43)

utils/pressFeedback.ts reaches gestures/coarsePointer.ts, so a page whose
only touch work is press feedback carries a gesture module, and a bundle
check that counts gestures/ code finds it in the root and hooks entries.
The coarse-pointer query belongs with the environment queries. This test
walks the controller's import graph and fails until it reaches no module
under gestures/.
…ries in utils/ (spec:AST-094 FR43)

gestures/coarsePointer.ts becomes utils/coarsePointer.ts, beside
utils/touchQueries.ts, with its test; the gesture host's selection lock,
the press controller and the pointer test helper import it from there. The
press controller's import graph now reaches no module under gestures/, so
a page whose only touch work is press feedback carries none of the gesture
system. No behavior changes.
CI's lint rejected the three Array<T> types the previous commit added
(@typescript-eslint/array-type); the test now names its record once
(WatchTransition) and reuses it.
…e arena's reset

The arena resets a recognizer to possible after it ends, as UIKit does,
and the transition listener records that too, so the last transition is
possible: the test now asserts the ended-then-possible tail.
…finger, meta#8589's watcher test, its lint fix; meta#8076 3bfd7c3; the coarse-pointer query in utils/) into meta#8293
…nter query in utils/) into meta#8356

# Conflicts:
#	packages/core/src/gestures/GestureHost.ts
#	packages/core/src/gestures/fingerScroll.ts
#	packages/core/src/gestures/pageHold.ts
#	packages/core/src/gestures/selectionLock.ts
#	packages/core/src/utils/pressFeedback.ts
… FR19, FR24)

Under a fine primary pointer: each element that uses a gesture keeps one
passive pointerdown listener and the page none while idle; a mouse or a
pen pans, its page listeners lasting only the press; a press consumer
takes a mouse press with no feedback-only mode (DEC-21); a pan writes its
touch-action for a touchscreen laptop's finger (FR18); perEventPointer is
taken as any attach, with no warning; a secondary button starts nothing.
All but the last fail until the host attaches for every pointer.
… the scroll brake for a finger only (spec:AST-094 FR11, DEC-21)

A mouse or a pen press paints at once, inside a scroller too; a mouse
press on a scroller a flick set moving still presses, as the brake is a
finger's. The brake case fails until the tracker brakes only a touch.
… (spec:AST-094 FR43)

A drag from the edge band begins a screen-edge pan for a finger only,
unless the caller allows another pointer. The mouse and pen case fails
until the recognizer defaults to touches.
… FR19, FR24, DEC-21)

Recognizers and press consumers attach for a mouse, a pen and a finger
alike. Where the primary pointer is fine, each element with an enabled
recognizer or consumer keeps one passive pointerdown listener and the page
has none: a press on it (a mouse's primary button, a pen, or a finger)
installs the host's page listeners until that press's contacts end. The
action group's own fine-pointer path folds into this one, so a group is
served like every attachment.

- touch-action is written for every pointer, a kept press's manipulation
  too (FR18); the element's blocking touchmove stays a coarse pointer's,
  so under a fine one the element keeps its one listener (FR24).
- A press consumer takes a mouse press as a press (DEC-21): no
  feedback-only mode; PressTracker brakes only a finger's press (FR11).
- The screen-edge pan allows touches only unless its caller allows another
  pointer (FR43).
- perEventPointer is accepted and does nothing; the fine-pointer warning
  goes.

Tests that encoded the coarse-only design now state FR43's; a finger's
pointerdown comes before its touchstart in them, as in a browser.
…th real mouse drags (spec:AST-094 FR43, FR24)

Under a desktop's mouse the host still writes touch-action, leaves the
page no listener while idle and warns for nothing; a real mouse drag pans
both the bare page's pan and the fixture's horizontal pan, and the page's
touch listeners are gone again after the release.
…(spec:AST-094 FR18, DEC-13)

A screen-edge pan attached to a stack container, as wide as the screen,
with a touch 4 px from its edge: inside the 40.2 px band the pan decides at
UIKit's 15 px whatever the element's width, and then tracks the travel past
it. From several edges too. Both fail while a wider element decides at
FR19's 10 px.
…'s width (spec:AST-094 FR18, DEC-13)

FR18: inside the 40.2 px band a screen-edge pan decides at UIKit's 15 px
screen-edge hysteresis; outside it the author's scrolling is untouched and
FR19's 10 px governs every other claim. The pan decided at 10 px on any
element wider than the band, so NavigationStack's edge pan, attached to the
stack container, began 5 px early. The edge zone still decides only the
touch-action; a touch outside every band still fails at once. The two
cases that pinned the 10 px decision go.
…px whatever the element's width) into the experimental touch exports
… move keeps that finger until it lifts) into the experimental touch exports
…than the band (spec:AST-094 FR18, DEC-22)

FR18 as amended in meta#8631 (DEC-22): the band's pan-x claim and 15 px start
hold only where the screen-edge pan is attached to an element within the
band; on an element that reaches beyond it, whose touch-action cannot
claim per touch, a touch in the band decides at FR19's 10 px like every
other claim. This restores the 10 px decision 3d85c30 removed and keeps
15 px on an element within the band. The cases pin DEC-22 both ways (a wide
element with a touch at x=4 begins at 10, an in-band strip at 15, and from
several edges at 10 on a wide element); the 10 px cases fail on 3d85c30.
…he edge band decides at 10 px again, DEC-22) into the experimental touch exports
…ouch decides at 15 px on an element within the band and at 10 px beyond it, DEC-22; meta#8641 bec2283) into meta#8270
vjeux added 13 commits October 29, 2026 12:29
…age's selection

The drag selection hold cleared the page's selection when any mouse or
pen contact was claimed, but an action group's press claims at its
pointerdown, before it has moved or selected anything, so a plain click
on a group's button left its Copy or Cut with nothing to act on. A claim
before any movement now keeps the page's selection: the hold still
refuses a new one, and puts the page's back over a selection that
appears while it runs. A claim after movement clears what its drag
selected, as before (spec:AST-094 FR41).

The case before the new one now waits for its hold to end a task after
the lift, so no hold outlives that test.
…t every pointer does

- GestureHost's header, the gestures README and coarsePointer.ts no
  longer describe the coarse-only host: a finger or a pen takes the
  touch-down lock under any primary pointer, a kept press's finger is
  kept during a fine pointer's press too, and recognizers attach for
  every pointer while the page listeners last a press (spec:AST-094 FR19,
  FR24, FR41, FR43).
- The fine-pointer selection case asserted the replaced rule and passed
  only because it read iOS WebKit's inline lock without iOS WebKit: it
  now asserts the lock a fine pointer's finger and pen take, and that a
  mouse takes none.
- FR41's sentence and its verification row say that a press claimed at
  its own pointerdown, before it moves, keeps the page's selection, while
  a drag that moved before its claim loses what it selected.
…ion, in the present tense

The sentence said a drag that moved before its claim loses the selection
it started, but the hold clears the page's selection then, whatever made
it; and an unclaimed mouse drag kept "today's" behavior, a phrase that
ages. It now says a claim after movement clears the page's selection, a
press claimed at its own pointerdown keeps it, and an unclaimed mouse
drag keeps its released behavior.
…6c02fc, the harness README, spec and index.ts as in 801dfa6750 (meta#8666 on meta#8360)
…task ended at once, however late the first read

WebKit under load from other tasks: the call's task ends at once, another
task holds the main thread, and the browser commits the animation after
it, dated at the call. Nothing in the call's task delayed it, but the
first read still moved its start to the read, about 95 ms late on a
loaded box, so whatever chains on its end (the second half of a Reduce
Motion cross-fade, a cancel fading on the previous fade's end) ran that
much late and the cross-fade missed design:reduced-motion DR2's 200 ms.
Fails now: the start moves to 1100.
e7b0676 moved a call's start from rest to its first read whenever
WebKit had dated it more than two frames before that read. Under load
from other tasks the first read comes late with nothing in the call's
task delaying it, so the start moved about 95 ms on a loaded box, and
whatever chained on the call's end ran that late: the second half of a
Reduce Motion cross-fade, so SearchField's cancel missed DR2's 200 ms.
The call's task now records when it ended, at its first microtask (or a
read in those microtasks is that time), and only a call whose own task
ran past the call by more than two frames, the block e7b0676 fixed,
moves its start; a read other tasks delay keeps the browser's start.
…s its start past the block

Core Animation dates an animation at the commit, when the calling task
ends, however short the block before it. A 20 ms block in the call's task,
with WebKit's start dated at the call, left the start at the call: the
two-frame bound skipped it. Fails now.
… task, with no threshold

Core Animation dates an animation at the commit, when the calling task
ends, whatever the block's length. The call's task already records when
it ended, at its first microtask, so a start the browser dated before that
(WebKit's last frame time before the task) now moves to it: a task that
ended at once leaves the start at the call, a blocked one moves it past
the block, however short, and a read other tasks delay changes nothing.
The two-frame bound, which told a read's own frame from a block before the
task's end was known, goes, and the start no longer moves to the read.
…'s click guard has taken the click

The claimed tap swallows its own late mouse events until their click. With
meta#8360, the gesture host arms its click guard at the claimed lift, and the
guard takes that click first, on the window's capture phase, so the
swallow never saw it and stayed armed: the next press's mousedown, mouseup
and click never reached the page. The swallow now notes the guard while
it is armed and lets go once the guard has (its click, the next press, or
its window); with no guard it ends at the click, as before.
`internal/touch` re-exports `isClickGuardArmed` by name.

Red-first: "a claimed tap swallows its own late mouse events, until the
next touch" fails on the previous head (the second press's events are
swallowed) and passes in Chromium and WebKit with this change.
…eta#8666 08a2a28) into the web-input drivers helper

The harness files are 8d35612's verified union, byte for byte, plus meta#8535's change to the backward self-test, which this line carries; the two helpers hold the same content apart from that change.

This line's own harness additions leave it, as no open pull request builds on them: the earlier busy-window control and pooled proof (now meta#8906), openStory waiting for Storybook to finish a render, the touch driver's synthesizesClicks and SCRIPTED_CLICK_SKIP, and the wheel-at-DPR-3 self-test.
@vjeux
vjeux marked this pull request as draft October 29, 2026 22:39
vjeux added 12 commits October 29, 2026 16:10
…few ms, and one task's calls start together

Browser start times are frame times, while a task's end is a live clock,
so on every call they differ by up to a frame plus the task's own length.
Moving every start from rest to its task's end shifted each one by that
length, about 5 ms in a component's effect after a tap, and the shift
added up along a chained sequence: SearchField's Reduce Motion cancel
measured 204-207 ms against design:reduced-motion DR2's 200. The first
case fails now (the start moves to 1010). The second keeps one motion's
parts together when they are separate calls in one task: a block
measured per call would split them by a frame.
…n on more than two frames, and all the task's calls move together

fc42d64 moved every start from rest to the end of the call's task.
Browser start times are frame times while that end is a live clock, so on
every call they differ by up to a frame plus the task's own length: each
start slid by that length, and a chained sequence added it up, about
5 ms per link in SearchField. The rule from e6876ca is back, with its
bound now named for that quantization: only a task that ran on more than
LATE_START_MS (two frames) past its first call blocked anything, and its
calls' starts move to their first read. The block is now measured per
task, not per call, and every call of a blocked task takes the same read,
so one motion's parts that are separate calls in one task (a component's
effect) start together instead of splitting by a frame. The 20 ms case
keeps the browser's start, as no clock here tells it from quantization.
…art moves only past a real block) into meta#8181 [skip ci]
@github-actions github-actions Bot added the needs:spec-owner-review Current knowledge records await owner approval label Oct 30, 2026

@astracat-bot astracat-bot Bot left a comment

Copy link
Copy Markdown

Choose a reason for hiding this comment

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

Approval waits on two spec decisions: spec:AST-094 FR43 names no exception for this tap claim staying off for a mouse and attaching only under a coarse primary pointer, and the FR41 claimed-drag selection rule carried from #8665 is new text that main does not state, so an engineering owner needs to settle each, on main or by commenting /approve-spec <full head SHA> here (the author may do so if they are an engineering owner). Whichever way those go, two changes are still needed:

  • Land #8224, #8665, #8666 and the pull requests beneath them first and rebase, so this diff is the 207-line, two-file fix instead of about 37,000 lines across 216 files.
  • Add Safari-on-iOS evidence (Simulator or device) that a claimed Pencil tap focuses the bottom field without the page panning, as spec:AST-099 asks of its iOS claims; the Chromium pen proof cannot show it, because Chromium sends the pen's compatibility mousedown before the tap is recognized.

The title and description still describe the click-guard swallow fix, which now arrives with #8216, so they should be updated to the pen and the coarse-pointer gate.

[Automated review]

Comment on lines +471 to +475
(`spec:AST-017` FR59; DEC-9). A mouse or pen drag that a recognizer claims
takes no new text selection for that drag only. A claim made after the
drag has moved clears the page's selection; a press claimed at its own
pointerdown, before it moves, keeps it. An unclaimed mouse drag keeps its
released 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.

This adds a requirement main does not state: FR41 there keeps a mouse at its released behavior, and its verification row fails any change to mouse selection. It arrives with #8665 and needs an engineering owner's decision, by /approve-spec on this head or by landing it on main with #8665.

This branch had an error being deployed

1 failed deployment
Preview — e72a432b Deployed Oct 30, 2026 by vercel[bot]
Sign up for free to join this conversation on GitHub. Already have an account? Sign in to comment

Labels

CLA Signed This label is managed by the Meta Open Source bot. needs:design-review Affects visuals — Design should review needs:spec-owner-review Current knowledge records await owner approval

Projects

None yet

Development

Successfully merging this pull request may close these issues.

2 participants