Skip to content

fix(gestures): pointer-read fingers get the blur refusal and the pinch rule of the Touch Events path - #8309

Merged
cixzhang merged 1 commit into
meta:mainfrom
vjeux:uikit/f2-host-fixes
Nov 3, 2026
Merged

cixzhang merged 1 commit into
meta:mainfrom
vjeux:uikit/f2-host-fixes

Conversation

@vjeux

@vjeux vjeux commented Oct 24, 2026 •

Copy link
Copy Markdown
Contributor

One commit on main. It absorbs the closed #8336, whose authors the commit credits.

User impact

Where the host reads fingers from pointer events (inputMode: 'pointer-events', or no ontouchstart), a pen or a finger the window's blur cancelled clicked at its later lift, so a Switch toggled. Under a fine pointer the blur even removed the pointerup listener first. A second finger beside one resting on plain page let a one-finger pan claim a pinch. A disabled recognizer still added a blocking listener.

Problem and solution fit

  • Why does it happen? Only the Touch Events path refused a blur-cancelled lift and counted every finger. No path checked that a recognizer was enabled before adding a blocking listener.
  • Why does that hurt? A cancelled press acts, a pinch starts a pan, and an idle element slows a scroll.
  • Why does it matter? After a lost focus nothing may act, and a second finger leaves the page its pinch.

The host remembers each blur-cancelled pen or pointer-read finger until its lift, which clicks nothing. The pointer path reads a non-primary finger as a second one. Only an enabled participant adds a blocking listener.

Expected behavior and authority

spec:AST-094 (current): FR10, FR19, FR24, and FR44, added beside FR28 as the owner ruled: "A window blur, or the browser cancelling the pointer, ends any press the gesture host tracks, on every surface and for every touch and pen, and nothing acts at the later lift; a mouse button held through the blur keeps its click." FR28 and family:action-groups FR3 point to it.

Smallest restoration

GestureHost.ts only. A lost lift's wait ends at the next press, as the click guard reads one.

Evidence

  • Before: a blur-cancelled pen press toggled a Switch at the pen's click.
  • After: nothing toggles; a mouse held the same way keeps its click.
  • Unchanged: a tap with no gesture keeps its click; the Slider already committed nothing, and its cases guard that.
  • Regression: with main's host, both Switch cases and 29 of 227 host cases fail.

Writing rubric results

  • R1 narration: none.
  • R2 research: none.
  • R3 evidence: the FR44 row names facts, not changes.
  • R4 rejected alternatives: none added.
  • R5 change references: none.
  • R6 open questions: none.

Scope

  • One defect is restored: the blur refusal, pinch rule and listeners follow every press.
  • 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

Local gates at eafaa1c, on main ce667be:

  • tsc --project packages/core/tsconfig.json --noEmit, strict ESLint and Prettier on the changed files, check:* and pnpm review:pre: clean.
  • Core's unit tests: 624 files, 17,252 tests passed. The host, Switch and Slider tests run each surface under a coarse and a fine pointer; with main's host, both Switch cases and 29 host cases fail, and the Slider cases guard behavior main already had.
  • The touch-support bundle test: 8 passed. Switch's accessibility contracts on a Storybook build of this head: 12 passed.
  • The gesture and presented hit-testing browser proofs: Chromium 72 passed and 4 skipped; WebKit at CI's profile (--cpus=4, CI=1, LP_NUM_THREADS=4, one worker), ten runs, 640 passed and 120 skipped, none failed.

@vercel

vercel Bot commented Oct 24, 2026 •

Copy link
Copy Markdown

The latest updates on your projects. Learn more about Vercel for GitHub.

Project Deployment Actions Updated
astryx Ready Ready Preview Nov 3, 2026 11:14am UTC

Request Review

@meta-cla meta-cla Bot added the CLA Signed This label is managed by the Meta Open Source bot. label Oct 24, 2026
@astracat-bot

astracat-bot Bot commented Oct 24, 2026 •

Copy link
Copy Markdown
Contributor

AI review status for this pull request.

Review status Updated
🟢 Reviewed (for maintainers only) Nov 3, 2026, 11:27 AM UTC

@astracat-bot astracat-bot Bot left a comment

Copy link
Copy Markdown
Contributor

Choose a reason for hiding this comment

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

This pull request is on hold until an engineering owner decides the draft spec:AST-094 it implements: the six gesture-host fixes rest on its FR19 and FR20, and the 10 px scroll rule and the touch-action intersection go beyond what FR20 and FR18 say, so an engineering owner needs to approve or amend the record by commenting /approve-spec <full head SHA> on this pull request (the author may do so if they are an engineering owner). Separately from that decision, the diff against main still carries the whole unmerged stack, about 28,900 changed lines, so it should shrink to this commit before it merges. Please also address:

  • The compact-touch query in utils/touchPresentation.ts hard-codes 744 px, while current spec:AST-012 FR9 places the width edge at the theme's effective md point.
  • when.platform in themeAdaptations.ts is outside the closed condition set of current spec:AST-012 FR2.
  • The stylus, Safari scroll-wait and still-finger fixes need real-browser evidence under current spec:AST-013 FR10, from iPadOS and iOS Safari for the first two.

[Automated review]

Comment on lines +75 to +79
export const COMPACT_QUERY = '(width < 744px), (height < 500px)';

/** Both: presentations such as a sheet in place of a popover. */
export const COMPACT_TOUCH_QUERY =
'(width < 744px) and (pointer: coarse), (height < 500px) and (pointer: coarse)';

Copy link
Copy Markdown
Contributor

Choose a reason for hiding this comment

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

Current spec:AST-012 FR9 defines compact as a viewport width below the effective md point (768 unless a theme overrides it) or a height below 500, says native presentations do not map compact to platform size classes, and names the web touch presentation resolver as a user of that one definition. These 744 px and 500 px literals give coarse-pointer viewports 744–767 px wide the popover where FR9 gives the sheet, and they ignore a theme's md.

* is everything else. Pair `ios` with `pointer: 'coarse'` for a touch
* profile, such as touch motion tuned per platform.
*/
platform?: 'ios' | 'other';

Copy link
Copy Markdown
Contributor

Choose a reason for hiding this comment

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

platform is a new when field, but current spec:AST-012 FR2 says conditions are “named, closed, and composable” and lists width, height, pointer, contrast and motion. Remove it, or have an owner amend AST-012 first.

) {
return null;
}
if (attribute === PRESSABLE_ATTRIBUTE && isPressFeedbackInstalled()) {

Copy link
Copy Markdown
Contributor

Choose a reason for hiding this comment

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

isPressFeedbackInstalled() is module-wide, while the controller only listens on the global window and document. A delegated press installed on another document (an iframe's) stays idle whenever the main document mounts an Astryx pressable. Comparing doc with the controller's document keeps the guard to the case it is for.

Comment on lines +1335 to +1336
Math.hypot(touch.x - touch.startX, touch.y - touch.startY) >=
UIKIT_GESTURES.press.slop.value

Copy link
Copy Markdown
Contributor

Choose a reason for hiding this comment

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

Draft spec:AST-094 FR20 says “a native scroll under the touch MUST count as the browser taking the touch”, with no travel condition, so this rule needs FR20 amended by an owner. A scroll during a held touch is browser event timing, so current spec:AST-013 FR10 also asks for a real-browser proof of the still-finger case, not only the jsdom one.

@github-actions

github-actions Bot commented Oct 24, 2026 •

Copy link
Copy Markdown
Contributor

PR Analysis Report

🔎 Preview · eafaa1c

Storybook · Sandbox · built from this PR's head

Modified components

Component Size LoC Visual · A11y · RTL
lab/MobilePowerSearch 213.9 kB (+240 B) · 1.2 kB CSS (0) — — · — · —
Switch · Storybook 87.0 kB (+225 B) · 10.5 kB CSS (0) 1,719 (0) ✓ · ✓ · ✓
SegmentedControl 81.9 kB (+224 B) · 7.8 kB CSS (0) — — · — · —
2 more components
Component Size LoC Visual · A11y · RTL
DateTimeInput 158.7 kB (+223 B) · 17.0 kB CSS (0) — — · — · —
Slider · Storybook 88.8 kB (+214 B) · 9.4 kB CSS (0) 2,326 (0) ✓ · ✓ · ✓

Package size: @astryxdesign/core 6.09 MB packed (+2.2 kB); no new dependencies.

Touched components checked; add ci:run-visual, ci:run-a11y or ci:run-rtl to run the full suites.


Generated by PR Enrichment workflow | View full report

@astracat-bot astracat-bot Bot left a comment

Copy link
Copy Markdown
Contributor

Choose a reason for hiding this comment

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

This pull request is on hold for an engineering-owner decision on the scroll-lock requirements it carries from #8098 into component:BottomSheetSwitcher FR5, component:Drawer FR5 and component:MobileNav FR3, which no current spec states; an engineering owner records that decision by commenting /approve-spec <full head SHA> on this pull request (the author may, as an engineering owner), or by approving the same text in #8098 and landing it first. Whatever is decided, two changes are still needed:

  • Land #8360 and #8098 first, so this diff carries only its own 483 lines instead of 18,489.
  • Keep the blur refusal working under a fine primary pointer: the blur removes the press's page listeners (GestureHost.ts:2918-2923) before a pen's or pointer-read finger's later lift, so its click still lands; a case whose lift comes in a later task would catch it.

[Automated review]

// not click (FR28). A mouse button held through the blur keeps its click.
for (const [pointerId, kind] of this.pointerKinds) {
if (kind !== 'mouse') {
this.cancelledPointers.add(pointerId);

Copy link
Copy Markdown
Contributor

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 id is never read: onBlur calls releaseArmedPress right after this, its microtask finds no arena touch, and shouldListen (line 1536) uninstalls the page pointerup subscription. The pen's or pointer-read finger's later lift then reaches no host listener and its click lands. Keeping the pointerup and pointercancel subscriptions while cancelledPointers holds an id, plus a fine-pointer case whose lift comes after a microtask, would close it; a mouse press keeps today's teardown.

expect(click.defaultPrevented).toBe(false);
});

it('forgets a blur-cancelled pen whose contact ends in pointercancel: a later pen with its id keeps its click (spec:AST-094 FR28)', () => {

Copy link
Copy Markdown
Contributor

Choose a reason for hiding this comment

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

This case still passes with the pointercancel delete (GestureHost.ts:2712) removed: the later pen pointerdown clears the id at GestureHost.ts:2501 anyway. Asserting on what the pointercancel alone leaves (a lift after it arms no guard), or naming the case for what it pins, would make it guard its rule.

Comment on lines +1614 to +1616
// 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
Contributor

Choose a reason for hiding this comment

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

When the last attachment goes, cleanup has already removed the element's touchend listener (line 1232), so a kept note and its timer wait up to 500 ms with nothing left to take them, against FR24's "nothing touch-related remains after the last instance unmounts". Clearing the notes in uninstallIfIdle's idle branch would keep the armed-press case and drop them there.

@vjeux

vjeux commented Oct 31, 2026

Copy link
Copy Markdown
Contributor Author

Rebased onto #8098's new head, which carries #8360's lifecycle round. The pointers a blur cancelled now go after the last detach, as the host's other notes do: a pen held through a blur and then detached is a new case in the "nothing left after the last detach" block, and it failed before. Local gates at ca56e89 are in the description.

@astracat-bot astracat-bot Bot left a comment

Copy link
Copy Markdown
Contributor

Choose a reason for hiding this comment

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

This pull request is on hold for an engineering-owner decision on the scroll-lock requirements it carries from #8098 into component:BottomSheetSwitcher FR5, component:Drawer FR5 and component:MobileNav FR3, which no current spec states; an engineering owner records that decision by commenting /approve-spec <full head SHA> on this pull request (the author may, as an engineering owner), or by approving and landing the same text in #8098. Whatever that decision, two changes are still needed:

  • Land #8360 and #8098 first, so this diff carries only its own 507 lines instead of 19,128.
  • Keep the blur refusal working under a fine primary pointer: the blur removes the page pointerup listener before a pen or pointer-read finger lifts, so its click still lands, which the changeset says cannot happen.

[Automated review]

@@ -1780,49 +2640,126 @@ export class GestureHost {
}
const id = this.pointerIds.get(event.pointerId);
if (id == null) {
this.releaseArmedPress();
// A contact the lost focus cancelled: its lift clicks nothing (FR28).
if (this.cancelledPointers.delete(event.pointerId)) {

Copy link
Copy Markdown
Contributor

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 branch never runs after a blur: onBlur calls releaseArmedPress(), whose microtask finds no arena touch, turns shouldListen() false and uninstalls the window pointerup subscription before the pen (or a pointer-read finger) lifts, so the click lands. Keeping the page pointerup/pointercancel subscriptions while cancelledPointers is non-empty, plus a fine-pointer case that lifts in a later task, would close it.

expect(click.defaultPrevented).toBe(false);
});

it('forgets a blur-cancelled pen whose contact ends in pointercancel: a later pen with its id keeps its click (spec:AST-094 FR28)', () => {

Copy link
Copy Markdown
Contributor

Choose a reason for hiding this comment

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

This case still passes with the cancelledPointers.delete in onPointerCancel removed, because the later pen pointerdown clears the id on its own. Asserting that a lift right after the pointercancel arms no click guard would pin the rule.

@vjeux

vjeux commented Oct 31, 2026

Copy link
Copy Markdown
Contributor Author

Rebased onto #8098's new head, which sits on #8360's current head on main. This PR's own change is unchanged (the same patch). Local gates at 255e3d1 are in the description.

@astracat-bot astracat-bot Bot left a comment

Copy link
Copy Markdown
Contributor

Choose a reason for hiding this comment

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

This pull request needs an engineering owner's decision before it can be approved, and changes besides. The scroll-lock commit carried from #8098 writes new requirements into component:BottomSheetSwitcher FR5, component:Drawer FR5 and component:MobileNav FR3 that current spec does not state; an engineering owner records approval by commenting /approve-spec <full head SHA> on this pull request (the author may, as an engineering owner), or by approving them in #8098 and landing it first. Whatever that decision:

  • Under a fine primary pointer the blur releases the press's page listeners before the lift (GestureHost.ts:3006-3011, :1441-1452), so a blur-cancelled pen still clicks; keep the pointerup subscription while a refused lift is owed and add a fine-pointer case.
  • .changeset/gesture-host-fixes.md promises that refusal everywhere and announces stylus handling main already has, and the description's arena claim has no code change behind it.
  • The diff carries 19,430 lines of open #8360 and #8098 against this commit's 507; land those first.

[Automated review]

@@ -1780,49 +2676,126 @@ export class GestureHost {
}
const id = this.pointerIds.get(event.pointerId);
if (id == null) {
this.releaseArmedPress();
// A contact the lost focus cancelled: its lift clicks nothing (FR28).
if (this.cancelledPointers.delete(event.pointerId)) {

Copy link
Copy Markdown
Contributor

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 branch never runs after a blur: onBlur (:3006-3011) calls releaseArmedPress(), whose microtask finds no arena touch, turns shouldListen() (:1579-1603) false and uninstalls the window pointerup subscription, so the pen's later lift clicks. FR43 covers that pen and FR28 says nothing acts after a lost focus. Keeping the page pointerup and pointercancel subscriptions while cancelledPointers holds an id, with a fine-pointer case whose lift comes in a later task, would close it.

Comment thread .changeset/gesture-host-fixes.md Outdated
'@astryxdesign/core': patch
---

[chore] Internal: gesture host fixes from review. A claimed pen holds only its own stylus touches, and a finger is matched to its scroller, its target and a moving element along the flat tree, through slots and shadow roots. A disabled recognizer brings no listener; where a finger comes from its pointer events, a claimed finger's touchend is cancelled too, a touchscreen laptop's included and inside an open shadow root, a hold's that claims with its lift included; and a pen or a pointer-read finger the window's blur cancelled clicks nothing at its later lift, while a mouse keeps its click. Not exported from the package entry point.

Copy link
Copy Markdown
Contributor

Choose a reason for hiding this comment

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

Two parts of this note claim more than the diff does: the blur refusal holds only where the primary pointer is coarse (every case runs on the coarse default, and the fine-pointer lift still clicks), and "A claimed pen holds only its own stylus touches" is what main already does (GestureHost.ts:1348-1356 there). Qualifying the first and dropping the second keeps the release note accurate.

expect(click.defaultPrevented).toBe(false);
});

it('forgets a blur-cancelled pen whose contact ends in pointercancel: a later pen with its id keeps its click (spec:AST-094 FR28)', () => {

Copy link
Copy Markdown
Contributor

Choose a reason for hiding this comment

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

This case still passes with the cancelledPointers.delete in onPointerCancel (GestureHost.ts:2790) removed, because the later pen pointerdown clears the id at :2573 on its own. Asserting that a lift right after the pointercancel arms no guard would pin the rule the name describes.

@vjeux

vjeux commented Nov 1, 2026

Copy link
Copy Markdown
Contributor Author

Rebased onto #8098's new head 5adb879, which sits on #8360's new head a57e57e. This PR's own change is unchanged (the same changed lines). Local gates at fe02656 are in the description.

@astracat-bot astracat-bot Bot left a comment

Copy link
Copy Markdown
Contributor

Choose a reason for hiding this comment

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

This pull request needs an engineering owner's decision before it can be approved, and changes besides. The scroll-lock commit carried from #8098 writes new requirements into component:BottomSheetSwitcher FR5, component:Drawer FR5 and component:MobileNav FR3 that current spec does not state; an engineering owner records approval by commenting /approve-spec <full head SHA> on this pull request (the author may, as an engineering owner), or by approving them in #8098 and landing it first. Whatever that decision:

  • Under a fine primary pointer the blur releases the press's page listeners before the lift (GestureHost.ts:3042-3047), so a blur-cancelled pen still clicks; keep the pointerup subscription while a refused lift is owed and add a fine-pointer case whose lift comes in a later task.
  • .changeset/gesture-host-fixes.md promises that refusal everywhere, says a disabled recognizer brings no listener (it still brings passive ones), and announces stylus handling main already has; the description's arena claim has no code change behind it.
  • The iOS WebKit check and the flat-tree walk carried from #8360 are each written twice (GestureHost.ts, utils/selectionLock.ts); share them through utils/touchQueries.ts and utils/flatTree.ts.
  • The diff carries 19,586 lines of open #8360 and #8098 against this commit's 507; land those first.

[Automated review]

@@ -1780,49 +2712,126 @@ export class GestureHost {
}
const id = this.pointerIds.get(event.pointerId);
if (id == null) {
this.releaseArmedPress();
// A contact the lost focus cancelled: its lift clicks nothing (FR28).
if (this.cancelledPointers.delete(event.pointerId)) {

Copy link
Copy Markdown
Contributor

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 never runs for a blur-cancelled pen: onBlur calls releaseArmedPress, the arena has no touch left a microtask later, shouldListen() turns false and uninstall() drops this pointerup subscription, so the later lift's click lands. Keeping the page pointerup/pointercancel subscriptions while cancelledPointers holds an id would fix it; a fine-pointer case whose lift comes a task after the blur would pin it.

Comment thread .changeset/gesture-host-fixes.md Outdated
'@astryxdesign/core': patch
---

[chore] Internal: gesture host fixes from review. A claimed pen holds only its own stylus touches, and a finger is matched to its scroller, its target and a moving element along the flat tree, through slots and shadow roots. A disabled recognizer brings no listener; where a finger comes from its pointer events, a claimed finger's touchend is cancelled too, a touchscreen laptop's included and inside an open shadow root, a hold's that claims with its lift included; and a pen or a pointer-read finger the window's blur cancelled clicks nothing at its later lift, while a mouse keeps its click. Not exported from the package entry point.

Copy link
Copy Markdown
Contributor

Choose a reason for hiding this comment

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

Three claims here go further than the code: the blur refusal holds only under a coarse primary pointer; a disabled recognizer still brings passive touchmove/touchend/touchcancel listeners on the finger's target (only the blocking one is gone); and a claimed pen already holds its stylus touches on main.

expect(click.defaultPrevented).toBe(false);
});

it('forgets a blur-cancelled pen whose contact ends in pointercancel: a later pen with its id keeps its click (spec:AST-094 FR28)', () => {

Copy link
Copy Markdown
Contributor

Choose a reason for hiding this comment

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

This case still passes with the cancelledPointers.delete in onPointerCancel removed, because the later pen pointerdown clears the id anyway. Asserting that a pointerup right after the pointercancel arms no guard would pin the rule.

@vjeux

vjeux commented Nov 1, 2026

Copy link
Copy Markdown
Contributor Author

Rebased onto #8098's new head 4b839fd, on #8360's new head b5cbf78. This PR's own change is unchanged (the same changed lines). Local gates at b35db3f are in the description.

@astracat-bot astracat-bot Bot left a comment

Copy link
Copy Markdown
Contributor

Choose a reason for hiding this comment

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

This pull request needs an engineering owner's decision before it can be approved, and changes besides. The scroll-lock commit it carries from #8098 writes new requirements into the BottomSheetSwitcher and Drawer FR5 records (no pull-to-refresh or bounce behind a modal flow or drawer) and the MobileNav FR3 record (the clip moves to <body> when <body> sets its own overflow); an engineering owner records approval by commenting /approve-spec <full head SHA> on this pull request (the author may, as an engineering owner), or by approving and landing #8098 first. Independently of that decision:

  • Under a fine primary pointer the blur releases the press's page listeners before the pen lifts, so its click still lands; keep the pointerup and pointercancel subscriptions while a refused id is pending and add a fine-pointer case whose lift comes in a later task.
  • .changeset/gesture-host-fixes.md and the description claim more than the change does: qualify the blur sentence until the fix lands, say "no blocking listener", and drop the stylus sentence and the arena claim.
  • Land #8360 and #8098 first so this diff carries only its own 507 lines instead of 19,667.

[Automated review]

Comment on lines +3072 to +3077
// not click (FR28). A mouse button held through the blur keeps its click.
for (const [pointerId, kind] of this.pointerKinds) {
if (kind !== 'mouse') {
this.cancelledPointers.add(pointerId);
}
}

Copy link
Copy Markdown
Contributor

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 id is recorded, but onBlur then calls releaseArmedPress, which uninstalls the page pointerup subscription a microtask later (shouldListen is false once no press is armed). The pen's later lift never reaches onPointerUp, so its click lands. A jsdom probe with the lift one task after the blur shows click.defaultPrevented === false under a fine pointer. Keeping the pointerup/pointercancel subscriptions while cancelledPointers is non-empty would close it; a fine-pointer case with a later-task lift would pin it.

Comment thread .changeset/gesture-host-fixes.md Outdated
'@astryxdesign/core': patch
---

[chore] Internal: gesture host fixes from review. A claimed pen holds only its own stylus touches, and a finger is matched to its scroller, its target and a moving element along the flat tree, through slots and shadow roots. A disabled recognizer brings no listener; where a finger comes from its pointer events, a claimed finger's touchend is cancelled too, a touchscreen laptop's included and inside an open shadow root, a hold's that claims with its lift included; and a pen or a pointer-read finger the window's blur cancelled clicks nothing at its later lift, while a mouse keeps its click. Not exported from the package entry point.

Copy link
Copy Markdown
Contributor

Choose a reason for hiding this comment

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

Three parts of this line overstate the change: the blur refusal holds only under a coarse primary pointer today (see the comment in GestureHost.ts); a disabled recognizer's target still gets passive touchmove/touchend/touchcancel listeners, so "no blocking listener" is the accurate claim; and "A claimed pen holds only its own stylus touches" is already what main does.

expect(click.defaultPrevented).toBe(false);
});

it('forgets a blur-cancelled pen whose contact ends in pointercancel: a later pen with its id keeps its click (spec:AST-094 FR28)', () => {

Copy link
Copy Markdown
Contributor

Choose a reason for hiding this comment

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

This case still passes with the cancelledPointers.delete in onPointerCancel removed: the later pen pointerdown clears the id on its own. Asserting on a lift right after the pointercancel (no guard armed) would pin the rule.

@vjeux

vjeux commented Nov 2, 2026

Copy link
Copy Markdown
Contributor Author

Rebased onto #8098's new head d8a6106, on main now that #8360 is merged. This PR's own change is unchanged (the same changed lines). Local gates at e601777 are in the description.

@astracat-bot astracat-bot Bot left a comment

Copy link
Copy Markdown
Contributor

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 decision before it can be approved, plus a few fixes. The scroll-lock commit carried from #8098 turns off pull-to-refresh and the page's bounce behind every modal overlay and moves MobileNav's clip to <body> on pages whose <body> sets its own overflow, but no current spec states either; an engineering owner can settle that by commenting /approve-spec <full head SHA> on this pull request (the author may, as an engineering owner) or by approving and landing the same text in #8098 first.

  • Under a fine primary pointer the window blur removes the host's page pointerup listener before a pen's lift, so a blur-cancelled pen still clicks; keep that listener while a refusal is owed and add a fine-pointer case whose lift comes a task later.
  • The release notes and description claim more than the change does: the blur sentence holds only for a coarse pointer, a disabled recognizer still gets passive listeners, GestureArena.ts changes only a comment, and closing MobileNav still drops another lock's scrollbar gutter.
  • Landing #8098 first brings this diff from 2,663 changed lines down to its own 507, and the pointercancel test should assert on what the cancel alone leaves.

[Automated review]

Comment on lines +3082 to +3086
for (const [pointerId, kind] of this.pointerKinds) {
if (kind !== 'mouse') {
this.cancelledPointers.add(pointerId);
}
}

Copy link
Copy Markdown
Contributor

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 id is recorded, but the blur's releaseArmedPress() then uninstalls the page pointerup subscription in a microtask (no arena touch is left, so shouldListen() turns false), and a pen lift that comes a task later reaches no host listener: its click lands. Keeping the page pointerup/pointercancel subscriptions while cancelledPointers is non-empty would close it; a fine-pointer case with the lift after await of one task pins it.

Comment thread .changeset/gesture-host-fixes.md Outdated
---

[chore] Internal: gesture host fixes from review. A claimed pen holds only its own stylus touches, and a finger is matched to its scroller, its target and a moving element along the flat tree, through slots and shadow roots. Not exported from the package entry point.
[chore] Internal: gesture host fixes from review. A claimed pen holds only its own stylus touches, and a finger is matched to its scroller, its target and a moving element along the flat tree, through slots and shadow roots. A disabled recognizer brings no listener; where a finger comes from its pointer events, a claimed finger's touchend is cancelled too, a touchscreen laptop's included and inside an open shadow root, a hold's that claims with its lift included; and a pen or a pointer-read finger the window's blur cancelled clicks nothing at its later lift, while a mouse keeps its click. Not exported from the package entry point.

Copy link
Copy Markdown
Contributor

Choose a reason for hiding this comment

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

Two parts of this line claim more than the code does: "clicks nothing at its later lift" holds only where the primary pointer is coarse (see the comment on cancelEverything), and "A disabled recognizer brings no listener" — the finger's target still gets passive touchmove, touchend and touchcancel listeners; "no blocking listener" is what the change and its test show.

expect(click.defaultPrevented).toBe(false);
});

it('forgets a blur-cancelled pen whose contact ends in pointercancel: a later pen with its id keeps its click (spec:AST-094 FR28)', () => {

Copy link
Copy Markdown
Contributor

Choose a reason for hiding this comment

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

This case still passes with the cancelledPointers.delete in onPointerCancel removed, because the later pen pointerdown clears the id on its own. Lifting right after the pointercancel (no new press) and asserting the click guard stays disarmed would pin the rule the title names.

'@astryxdesign/core': patch
---

[fix] `useScrollLock`, and so Dialog, BottomSheet, Drawer and Lightbox, keeps the page where it is while an overlay is open. The lock used to pin `<body>` with `position: fixed` at minus the scroll offset and scroll back on release: the page's offset read 0 for the whole lock, and on iOS Safari the jump to the top brought the toolbar back, where it stayed after the overlay closed. Now the page gets `overflow: hidden` (on the root, or on `<body>` when the body's own overflow is the one the viewport uses), and on touch screens a one-finger pan that would scroll the page is cancelled, which iOS Safari before 26.4 needs because it scrolls an `overflow: hidden` page under a finger. Pans that a scroller inside the overlay can take, drags on a native range input, pinch-zoom, and one-finger drags on a pinch-zoomed page (they pan the zoomed view) still work. The lock no longer pins `<body>`, and `window.scrollY` keeps its value while the lock holds. The lock cancels those pans itself, leaving a touch a gesture recognizer owns to it, and judges each pan after a small slop so a swipe's first wobble does not fix the wrong axis. It follows the browser's scroll chain, so an overlay rendered inside a scrolling container still holds the page: a modal dialog's pans go to the page, not to that container. It also sets `overscroll-behavior-y: none` on the root and `<body>`, which turns off pull-to-refresh (Chrome on Android) while an overlay is open. MobileNav's own clip goes through the same page scroll-style claims, so closing or unmounting it no longer releases another overlay's lock.

Copy link
Copy Markdown
Contributor

Choose a reason for hiding this comment

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

"closing or unmounting it no longer releases another overlay's lock" holds for overflow only: MobileNav's own scrollbar-gutter hold still restores the root's gutter it saw at open, so closing it under a Dialog's lock drops the gutter while overflow: hidden holds, and after both close the root keeps scrollbar-gutter: stable. Either narrow the sentence to the clip or move the gutter into the same stacked claims.

@astracat-bot astracat-bot Bot left a comment

Copy link
Copy Markdown
Contributor

Choose a reason for hiding this comment

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

Approval waits on an engineering owner's decision, and three fixes are needed whatever they decide.

The decision is whether a window blur during a pen press, or during a press by a finger the gesture host reads from pointer events, ends it with nothing acting on every surface the host serves (Switch, Slider and SegmentedControl now attach to it), which spec:AST-094 FR28 states only for menus and family:action-groups FR3 only for action groups, while component:Switch FR15 says a hold toggles at its release. An engineering owner records it by commenting /approve-spec <full head SHA> on this pull request; the author may do so as an engineering owner.

Required changes:

  • Under a fine primary pointer the blur removes the page pointerup listener a microtask before the lift, so a blur-cancelled pen still clicks (a Switch still toggles), and the case named for that scenario (GestureHost.test.ts:2664) lifts in the same task, before the listener goes.
  • On the pointer path, a pinch whose first finger lands on plain page and whose second lands on a one-finger pan still lets the pan claim, because only contacts that reached the arena are counted (GestureHost.ts:3536-3600), against FR10.
  • The changeset says a blur-cancelled pen clicks nothing and a second finger "wherever it lands" leaves the page its pinch, and the fine-pointer case's name claims coverage it lacks; fix the behavior or narrow the wording.

The pointercancel case at GestureHost.test.ts:2715 also passes with the rule it names removed; asserting on a lift right after the cancel would pin it.

[Automated review]

Comment on lines +4035 to +4041
// The browser may keep a pen or a pointer-read finger down: its lift must
// not click. A mouse button held through the blur keeps its click.
for (const [pointerId, kind] of this.pointerKinds) {
if (kind !== 'mouse') {
this.cancelledPointers.add(pointerId);
}
}

Copy link
Copy Markdown
Contributor

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 refusal never runs for a lift that comes after the blur's microtask: onBlur calls releaseArmedPress, whose microtask drops the armed press, shouldListen turns false and uninstall removes the page pointerup listener that refuseCancelledLift hangs on. A pen held on a Switch through an app switch still toggles it on a desktop pen tablet. Keeping the page pointerup and pointercancel listeners while cancelledPointers is non-empty would close it.

Comment on lines +3534 to +3546
if (
event.pointerType === 'touch' &&
this.arena.touches.some(
touch =>
touch.pointerType === 'touch' &&
touch.phase !== 'ended' &&
touch.phase !== 'cancelled',
)
) {
// Fingers from pointer events: a second one, wherever it lands,
// leaves the two to the page's pinch, as onTouchStart does.
this.failUnclaimedLoneFingerPans();
}

Copy link
Copy Markdown
Contributor

Choose a reason for hiding this comment

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

This counts only arena touches, and a finger on an element with no participant returns at line 3566 before it gets there. So with the first finger on plain page and the second on a one-finger pan, neither check fires and the pan claims mid-pinch (FR10), while the Touch Events path, which counts event.touches, fails it. One live-finger count that includes untracked fingers, used here and at line 3595, would match it.

expect(states).not.toContain('ended');
});

it("refuses its click to a pen the window blur cancelled under a fine primary pointer too, where the blur takes the page's pointerup listener before the lift (spec:AST-094 FR28)", () => {

Copy link
Copy Markdown
Contributor

Choose a reason for hiding this comment

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

The pointerup here runs in the same task as the blur, before the microtask that removes the page listener, so this passes even though a later lift is not refused under a fine pointer. await Promise.resolve() (or a task) before the lift, as the fine-pointer touchend case further down does, makes it fail until the listener is kept.

expect(click.defaultPrevented).toBe(false);
});

it('forgets a blur-cancelled pen whose contact ends in pointercancel: a later pen with its id keeps its click (spec:AST-094 FR28)', () => {

Copy link
Copy Markdown
Contributor

Choose a reason for hiding this comment

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

Low: this still passes with the cancelledPointers.delete in onPointerCancel removed, because the later pointerdown with the same id clears it anyway. Asserting that a lift right after the pointercancel arms no guard would pin the rule.

'@astryxdesign/core': patch
---

[chore] Internal: where the gesture host reads a finger from its pointer events, the finger follows the Touch Events path's rules. A second finger, wherever it lands, leaves both to the page's pinch, and a pen or a finger the window's blur cancelled clicks nothing at its later lift, while a mouse keeps its click. A disabled recognizer brings no blocking listener. Not exported from the package entry point.

Copy link
Copy Markdown
Contributor

Choose a reason for hiding this comment

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

Both promises have gaps at this revision: a blur-cancelled pen still clicks under a fine primary pointer, and a pinch whose first finger lands on plain page still lets a one-finger pan claim when fingers come from pointer events. Fix those, or qualify the sentences until then.

private readonly processed = new WeakSet<Event>();
/** pointerId → arena id, for live mouse and pen contacts. */
private readonly pointerIds = new Map<number, number>();
/** pointerId → its kind, for the same contacts. */

Copy link
Copy Markdown
Contributor

Choose a reason for hiding this comment

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

Non-blocking: this restates the Map type, and "the same contacts" inherits the comment above, though pointer-read fingers are recorded too. Something like /** Each live contact's kind: a blur refuses a pen's or a finger's lift, never a mouse's. */ keeps only the reason.

@vjeux

vjeux commented Nov 3, 2026

Copy link
Copy Markdown
Contributor Author

Thanks. At bac5a17 the owner's ruling is recorded as AST-094 FR44: a window blur, or the browser cancelling the pointer, ends every touch or pen press the host tracks, and nothing acts at the later lift; a mouse button held through the blur keeps its click. FR28 and action-groups FR3 point to it, and the owner's stamp follows at this head.

  • An armed press keeps listening until a blur-cancelled pen or pointer-read finger lifts. The fine-pointer case now waits out the microtask before the lift and checks the listeners go after it.
  • The pointer path counts every finger it hears, plain page included, and a new case covers a first finger resting on plain page.
  • The pointercancel case asserts a lift right after the cancel, and the changeset claims only what changed.

@cixzhang

cixzhang commented Nov 3, 2026

Copy link
Copy Markdown
Contributor

/approve-spec bac5a17

@astracat-bot astracat-bot Bot left a comment

Copy link
Copy Markdown
Contributor

Choose a reason for hiding this comment

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

Changes requested. The blur refusal for pens and pointer-read fingers matches the newly approved AST-094 FR44, and the coarse-pointer pinch fix and the disabled-recognizer listener gate hold. Three items remain:

  • Under a fine primary pointer a first pointer-read finger resting on plain page is never counted (GestureHost.ts lines 3550-3557), so a one-finger pan still claims the pinch against FR10 and the changeset; count a touch pointerdown whose isPrimary is false as a second finger and add the fine-pointer case, or narrow the changeset.
  • When a blurred pen's lift never reaches the page, its id stays in cancelledPointers and the armed press never ends under a fine pointer (GestureHost.ts lines 1805-1809), so the page listeners stay on after every later release against FR24; bound the wait and add a case whose lift never comes.
  • FR44's verification row asks for Switch and Slider cases under a coarse and a fine pointer, but the Switch case runs only under a fine pointer and the Slider case only under a coarse one; add the missing two or narrow the row.

Non-blocking: the comment on pointerKinds (line 721) restates the map's type, and "the same contacts" leaves out the pointer-read fingers it also records.

[Automated review]

Comment on lines +3550 to +3557
if (event.pointerType === 'touch') {
this.heardFingers.add(event.pointerId);
if (this.heardFingers.size > 1) {
// Fingers from pointer events: a second one, wherever either lands,
// leaves the two to the page's pinch, as onTouchStart does.
this.failUnclaimedLoneFingerPans();
}
}

Copy link
Copy Markdown
Contributor

Choose a reason for hiding this comment

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

Under a fine primary pointer the host listens only during an armed press, so a first finger resting on plain page never reaches this count; when the second finger lands on a one-finger pan the count is 1 and the pan claims the pinch (FR10). A touch pointerdown whose isPrimary is false means another finger is already down, wherever it landed, so counting it as a second finger would close the gap; a fine-pointer case would pin it.

Comment on lines +1805 to +1809
if (
this.isArmedPress &&
this.arena.touches.length === 0 &&
this.cancelledPointers.size === 0
) {

Copy link
Copy Markdown
Contributor

Choose a reason for hiding this comment

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

If a blurred pen's lift never reaches the page (another window took focus, or the page went hidden) and the pen's next contact has another pointer id, cancelledPointers keeps the old id and this press never ends under a fine pointer: the page listeners stay on after every later release (FR24). Bounding the wait, for example forgetting noted pens when another pen press begins, and a case whose lift never comes would fix it.

| FR42 | Default-theme geometry tests under a coarse and a fine pointer, and hit-testing between adjacent targets | every component at phone width with a finger and with a mouse; a dense toolbar; a chip and its remove button; a Switch beside a label link | a default-theme box that grows under touch or at phone width other than with its text, or, where no component or module record sets the hit area, a touch target under 44 px where neighbors allow it or a hit area that overlaps a neighbor's target, fails |
| FR43 | Browser tests with a mouse, a trackpad, a pen, a finger, and the keyboard, and resource counts | a pan by drag and by wheel; a slider's thumb under a wheel and a trackpad scroll; a ctrl-wheel pinch; a long press, a right click, and Shift+F10; a mouse press; a handle drag; a row swipe with a mouse and a trackpad; a component with no gesture | a gesture missing under a mouse or pen, a mouse-drag swipe action, a pen pinch, an edge pan under a mouse, a trackpad, or a pen, a press delay under a fine pointer, a wheel or trackpad scroll that changes a thumb's value or does not reach the scroll under it, an idle listener in a component with no gesture, or added stylesheet bytes, fails |
| FR43 trackpad | Unit tests on timed wheel streams; WebKit browser tests with trackpad streams (Chromium's driver wheels read as a mouse wheel) | a form and a required sheet pulled 30 to 240 px by a finger and by a trackpad; mouse lines, notches, ticks and accelerated deltas over a sheet, a pager and a row; ten swipes each starting mid-momentum, ten whose momentum ends, and a slow drag with 150 ms pauses; a trackpad flick and a slow stopped drag on an info sheet; a fling, a slow 60 px swipe, a 42 px spike and back-to-back swipes on a pager; a row opened and swiped back within one gesture, and a long fast swipe; a pinch ending; a trackpad pan through a window blur and through a hidden page | a trackpad stretch that differs from a finger's for the same travel (120 px gives 54 px on the reference sheet), a mouse wheel that moves a sheet, a pager or a row, splitter counts other than ten, ten and one, momentum that moves a surface, a flick released with no velocity, a pager that travels past one page or pages on a spike, an info sheet a flick leaves open or a slow stopped drag closes, an open row that reveals its other side within one gesture, a trackpad pan a window blur or a hidden page leaves mid-gesture, or a pinch that ends after 100 ms fails |
| FR44 | Unit tests of a pen press a window blur ends, on a Switch, a Slider and the host, under a coarse and a fine pointer | a pen press on a Switch and on a Slider, blur mid-press: nothing toggles or commits; a pen whose contact ends in `pointercancel` | with the host keeping no note of a blur-cancelled pen, the Switch toggles at the pen's click |

Copy link
Copy Markdown
Contributor

Choose a reason for hiding this comment

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

This row asks for the Switch and the Slider under a coarse and a fine pointer, but the Switch case runs only under a fine pointer and the Slider case only under a coarse one. Adding the two missing cases, or narrowing the row, keeps the record and the tests in step.

private readonly processed = new WeakSet<Event>();
/** pointerId → arena id, for live mouse and pen contacts. */
private readonly pointerIds = new Map<number, number>();
/** pointerId → its kind, for the same contacts. */

Copy link
Copy Markdown
Contributor

Choose a reason for hiding this comment

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

Non-blocking: this restates the map's type, and "the same contacts" points at pointerIds' "live mouse and pen contacts", although pointer-read fingers are recorded too. Suggested: /** Each live contact's kind. A blur refuses a pen's or a finger's lift, never a mouse's. */

@vjeux

vjeux commented Nov 3, 2026

Copy link
Copy Markdown
Contributor Author

Thanks. At 7bded4d:

  • A finger that is not the primary pointer counts as a second finger, so a one-finger pan fails under a fine pointer even where the host never heard the first finger. A case covers it.
  • A new press ends the wait for a blur-cancelled lift, as the click guard reads a new press, so the page listeners go after it. A case covers a lift that never reaches the page.
  • The Switch and Slider cases now run under both a coarse and a fine pointer, as the FR44 row lists, and the pointerKinds comment says why it exists.

@astracat-bot astracat-bot Bot left a comment

Copy link
Copy Markdown
Contributor

Choose a reason for hiding this comment

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

Changes requested. The blur refusal for pens and pointer-read fingers matches the owner-approved AST-094 FR44, and the three earlier fixes (the fine-pointer pinch, the page listeners after a lost pen lift, and the Switch and Slider cases under both pointers) are in place. Two host issues remain:

  • The second pinch check after delivery (GestureHost.ts:2956-2960 and :3616-3623) also fails a second finger's own one-finger pan while the first finger's drag is claimed, which main allows and FR10 does not ask for ("no drag is claimed"), so run it only while no touch is claimed and add that case.
  • A pointer-read finger whose lift never reaches the page stays in heardFingers (GestureHost.ts:3565), so under a coarse pointer every later lone finger counts as a second finger and its one-finger pan fails; resetting the set at a primary finger's pointerdown, with the second finger marked non-primary in the two pinch cases, fixes it.

Optionally, add heardFingers to the "nothing left after the last detach" field list.

[Automated review]

Comment on lines +2956 to +2960
// Again once the new fingers are delivered: a pinch's second finger may
// have landed on another one-finger pan, the next row.
if (this.fingerTargets.length > 1) {
this.failUnclaimedLoneFingerPans();
}

Copy link
Copy Markdown
Contributor

Choose a reason for hiding this comment

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

This runs whether or not another finger's drag is claimed, and failUnclaimedLoneFingerPans only checks the pan's own touches. So while finger 1 drags a claimed one-finger pan, a second finger landing on another one-finger pan (another row, a second Slider thumb) has its pan cancelled; main lets it begin. FR10 keeps the page's pinch only when "no drag is claimed", and a claimed drag already blocks the pinch. Suggest guarding this call and the one at :3616-3623 with !this.arena.touches.some(t => this.arena.isClaimed(t.id)), plus a case for the claimed-drag order; with that guard every existing case still passes.

return;
}
if (event.pointerType === 'touch') {
this.heardFingers.add(event.pointerId);

Copy link
Copy Markdown
Contributor

Choose a reason for hiding this comment

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

An id leaves heardFingers only at its pointerup, its pointercancel or the uninstall. When a blur-cancelled finger's lift never reaches the page (the case dropCancelledLifts handles for the refusal), the id stays here, and under a coarse pointer the host never uninstalls, so every later lone finger counts as a second finger and its one-finger pan fails. A touch pointerdown with isPrimary true means no other finger is down, so resetting the set to this finger there fixes it; the cases at GestureHost.test.ts:5338 and :5396 then need their second finger sent with isPrimary: false, as browsers do.

@vjeux

vjeux commented Nov 3, 2026

Copy link
Copy Markdown
Contributor Author

Thanks. At cc7ccc3:

  • The check after a finger is delivered runs only while no finger's drag is claimed, as on main, so a second finger keeps its own pan beside a claimed drag. Cases cover it on both input paths.
  • The pointer path reads a finger that is not the primary pointer as a second finger and keeps no finger set of its own, so a lift that never reached the page leaves nothing behind. A case covers a lone finger after such a lift.

@astracat-bot astracat-bot Bot left a comment

Copy link
Copy Markdown
Contributor

Choose a reason for hiding this comment

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

Changes requested. The blur refusal for pens and pointer-read fingers matches the owner-approved AST-094 FR44, and both earlier pinch findings are fixed and covered. Two host gaps remain:

  • refuseCancelledLift (GestureHost.ts:4043) arms the click guard without takesLateMouse, so a blur-refused finger read from pointer events still sends WebKit's late mousedown and mouseup to the page, unlike the Touch Events refusal (:3214) and the claimed lift (:3697); pass {takesLateMouse: event.pointerType === 'touch'} and add a case.
  • Under a fine pointer with Touch Events, onPointerDown returns for a finger (:3549-3556) before dropCancelledLifts, so once a blur-cancelled pen's lift is lost, later finger presses leave the page listeners installed until a mouse or pen press (FR24); drop the note before that return as well and add a case.

Optionally, the comment at GestureHost.ts:2903 restates the isEnabled checks below it.

[Automated review]

* and a finger's touchend after it is cancelled.
*/
private refuseCancelledLift(event: PointerEvent): void {
armClickGuard(this.doc);

Copy link
Copy Markdown
Contributor

Choose a reason for hiding this comment

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

This arms the guard without takesLateMouse, then cancels the finger's touchend (lines 4044-4046). WebKit sends a late mousedown and mouseup after a held tap's cancelled touchend, so a finger read from pointer events and held through a blur still reaches the page's mouse handlers, while the Touch Events refusal (line 3214) and the claimed lift (line 3697) take both. armClickGuard(this.doc, {takesLateMouse: event.pointerType === 'touch'}) closes it; a case that dispatches a mousedown after the refused lift fails today and passes with that change.

// host never heard the first.
this.failUnclaimedLoneFingerPans();
}
this.dropCancelledLifts(event);

Copy link
Copy Markdown
Contributor

Choose a reason for hiding this comment

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

A Touch Events finger's pointerdown returns at lines 3549-3556 before it reaches this call. So under a fine primary pointer with Touch Events, a lost pen lift's note survives every later finger press, and releaseArmedPress keeps the page listeners installed after each release until a mouse or pen press (FR24). Calling this.dropCancelledLifts(event) before that return as well ends the wait at any press, as the description says.

toLock.push({id, target, consumerElement});
}
// The finger's events keep going to what the engine hit.
// Only an enabled participant brings a blocking listener.

Copy link
Copy Markdown
Contributor

Choose a reason for hiding this comment

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

Non-blocking (INV29): this restates the isEnabled checks below it. Drop it, or keep only the reason: "A disabled recognizer claims nothing, so it must not hold a scroll."

@vjeux

vjeux commented Nov 3, 2026

Copy link
Copy Markdown
Contributor Author

Thanks. At 421b17f:

  • A blur-refused finger read from its pointer events now keeps its late mousedown and mouseup from the page, as a claimed lift's does. A case covers it.
  • Any press, a finger read from Touch Events included, now ends the wait for a lost lift, so the page listeners go after that finger's press. A case covers it under a fine pointer.
  • The listener comment keeps only its reason.

@astracat-bot astracat-bot Bot left a comment

Copy link
Copy Markdown
Contributor

Choose a reason for hiding this comment

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

Changes requested. The blur refusal for pens and pointer-read fingers matches the owner-approved AST-094 FR44, and both earlier findings are fixed; one regression remains.

  • Moving the touchstart pinch check ahead of the input-path return (GestureHost.ts:2838-2842) makes it run after Chromium has already delivered a second finger from its pointerdown, so where Touch Events arrive without ontouchstart (touch emulation turned on after load) that finger's own one-finger pan is cancelled beside a claimed drag, which main keeps; putting the check back after the usesTouchEvents and takeEvent return fixes it, since the pointer path's isPrimary rule already covers pointer-read fingers.

A case in the "no ontouchstart" block that sends each finger's pointerdown and then its touchstart would pin this.

[Automated review]

Comment on lines +2838 to +2842
// A second finger leaves the two to the page's pinch, whichever events
// the host reads fingers from.
if (this.fingerTargets.length > 1) {
this.failUnclaimedLoneFingerPans();
}

Copy link
Copy Markdown
Contributor

Choose a reason for hiding this comment

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

Ahead of the input-path return, this check now runs where the host reads fingers from pointer events too. Chromium sends a finger's pointerdown before its touchstart, so by now the second finger is already delivered, and with no isFingerClaimed() guard its own one-finger pan is cancelled beside a claimed drag (Touch Events without ontouchstart, as after touch emulation turns on); main keeps it. The pointer path's isPrimary rule at 3558-3564 and 3608-3616 already covers pointer-read fingers, so putting this back after the usesTouchEvents and takeEvent return fixes it, and the existing cases still pass.

… the pinch rule of the Touch Events path

Where the host reads a finger from its pointer events (inputMode
'pointer-events', or a window with no ontouchstart, as after touch
emulation turns on), a pen or a finger the window's blur cancelled
clicked at its later lift, so a Switch toggled. Under a fine primary
pointer the blur also took the page's pointerup listener before the
lift. A second finger beside a first resting on plain page let a
one-finger pan claim a pinch. And a target got its blocking listener
without the host asking whether a recognizer there was enabled.

AST-094 FR44 records the rule for every press the host tracks: a window
blur, or the browser cancelling the pointer, ends it on every surface
and for every pointer, and nothing acts at the later lift. FR28 and
action-groups FR3 point to it, and a verification row names its tests.

The pointer path keeps the kind of each live contact. A blur marks every
pen and pointer-read finger, whose later lift clicks nothing and sends
the page no late mouse events, while a mouse keeps its click. An armed
press keeps its page listeners until that lift, or the next press. On the pointer path a finger that is not the primary pointer
is a second finger, so it fails the unclaimed one-finger pans unless a
drag is claimed. Only an enabled participant brings a blocking listener. Cases for each fail
before this change; a pen press on a Switch that a blur ends toggles
nothing, and one on a Slider commits nothing.

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

vjeux commented Nov 3, 2026

Copy link
Copy Markdown
Contributor Author

Thanks. At eafaa1c the Touch Events pinch check is back after the input-path return, as on main, so a pointer-read second finger keeps its own pan beside a claimed drag even when Touch Events still arrive. A case in the "no ontouchstart" block covers it, and the pointer path's own rule still covers a pointer-read pinch.

@astracat-bot astracat-bot Bot left a comment

Copy link
Copy Markdown
Contributor

Choose a reason for hiding this comment

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

Recommended for approval once the pull request leaves draft. The blur refusal for pens and pointer-read fingers matches spec:AST-094 FR44, which an engineering owner approved, the pinch and listener changes follow FR10 and FR19, and the earlier touchstart ordering regression is fixed.

  • Optional: a cross-reference from component:Switch FR15 to FR44 would state in the Switch record that a press a blur ends does not toggle.
  • Optional: the description says browser-proof results follow, but none are listed.

[Automated review]

@cixzhang

cixzhang commented Nov 3, 2026

Copy link
Copy Markdown
Contributor

/approve-spec eafaa1c

This branch was successfully deployed

1 active deployment
Preview — eafaa1c3 Deployed Nov 3, 2026 by vercel[bot]
Sign up for free to join this conversation on GitHub. Already have an account? Sign in to comment

Labels

ci:run A maintainer lets this outside contributor's pull request run CI (AST-030 FR17) CLA Signed This label is managed by the Meta Open Source bot.

Projects

None yet

Development

Successfully merging this pull request may close these issues.

2 participants