Skip to content

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

Draft
vjeux wants to merge 5 commits into
meta:mainfrom
vjeux:uikit/f2-host-fixes
Draft

vjeux wants to merge 5 commits into
meta:mainfrom
vjeux:uikit/f2-host-fixes

Conversation

@vjeux

@vjeux vjeux commented Oct 24, 2026 •

Copy link
Copy Markdown
Contributor

Section 3 of the merge plan: #8360 → #8098 → #8309 → #8763 → #8342. One commit on #8098 holding only this change. It absorbs #8336 (a second finger on a one-finger pan, and the arena's cancel), which closes with a link here; the commit credits its authors.

User impact

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, and a second finger did not fail a one-finger pan there. And a disabled recognizer that would claim a touch still gave its target a blocking listener, so a scroll waited on the main thread for nothing.

Problem and solution fit

  • Why does it happen? The host refused a blur-cancelled lift and failed a lone-finger pan for a second finger only on the Touch Events path, and gave a target its blocking listener without asking whether a recognizer there was enabled.
  • Why does that hurt? A cancelled hold still clicks what is beneath it, two fingers meant for a pinch start a pan, and an idle element slows a scroll.
  • Why does it matter? A lost focus means nothing acts, a second finger leaves the page its pinch, and only an owner that could claim may hold a blocking listener.

The fix remembers a pen or a pointer-read finger the lost focus cancelled by pointer id until its lift, which then clicks nothing, its touchend noted as a claimed lift is, capped and gone after its time (FR24); a mouse press keeps its click, and a contact that ends in pointercancel is forgotten. A target gets a blocking listener only for an enabled participant. From #8336, a second finger fails a one-finger pan wherever fingers come from, and the arena cancels a moving recognizer the browser takes. The claimed lift's touchend cancel under pointer-event fingers (FR21), which these build on, comes with #8360.

Expected behavior and authority

spec:AST-094 (current): FR10 (a second finger leaves the page its pinch), FR19 (only an owner claims), FR20 (the browser taking a touch) and FR28 (a lost focus ends the gesture).

Smallest restoration

Host and arena only: the blur-cancelled pointer ids (dropped after the last detach, as the host's other notes are), the second finger's fail and the enabled check in GestureHost.ts, and the moving recognizer's cancel in GestureArena.ts. Refusing every lift after a blur would take a mouse's click, which the browser still gives.

Evidence

  • Reproduction before the change: with Pointer Events input, a pen held through the window's blur clicked at its later lift.
  • Result after the change: its lift clicks nothing, and a mouse held the same way keeps its click.
  • Representative unchanged path: a tap on an element with no gesture still gets its mouse events and click.
  • Regression test or other mutation-sensitive proof: unit cases fail before each change (the blur-cancelled pen, a mouse press the blur cancelled, a pen whose contact ends in pointercancel, a refused finger's note that goes after its time, and a pen held through the blur whose host then detaches, which leaves nothing behind; the disabled recognizer's listeners; a second finger on a one-finger pan, from Touch Events and from pointer events). In Chromium, the browser proof's claimed hold sends no mouse event or click at its lift where fingers come from pointer events (the cancel fix(core): one click guard for the menu press model and the gesture host #8360 restores), and a screen-edge pan on an element wider than its band claims only past its travel.

Scope

  • One defect is restored: the host's blur refusal, pinch rule and listeners follow a finger wherever it comes from.
  • 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 255e3d1, one commit on #8098 holding only this change (7 files, +493/−14):

@vercel

vercel Bot commented Oct 24, 2026 •

Copy link
Copy Markdown

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

Project Deployment Actions Updated
astryx Ready Ready Preview Oct 31, 2026 5:01pm 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

AI review status for this pull request.

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

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

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

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

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

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


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.

This pull request is on hold for one engineering-owner decision: the carried rule that counts a finger's uncancelable touchmove as the browser's only when it is the first one and the browser could pan along it narrows current spec:AST-094 FR20, so FR20 needs updating (approving #8456 does that), or an engineering owner records the decision by commenting /approve-spec <full head SHA> on this pull request, which the author may do as an engineering owner. Whatever that decision, these still need changes:

  • Land the unmerged stack first or retarget: the diff carries 21,586 authored lines against this pull request's own 1,304, and two carried modules (Lab's internal-module resolvers and multi-edge screen-edge pans) contradict records that have since landed on main.
  • Add real-touch browser cases for the 10 px scroll rule at 9 px and 10 px straight and at (7, 7) and (8, 8), and iPadOS Safari evidence for the pen fix (spec:AST-013 FR10).
  • Give a finger's target the blocking listener only for an enabled recognizer (GestureHost.ts:887), and update the README and host header, which still say the touch-action is set inline.

[Automated review]

this.watchTarget(
touch.identifier,
target,
recognizers.some(claimsWhileMoving),

Copy link
Copy Markdown

Choose a reason for hiding this comment

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

participantsAt returns disabled recognizers too (lines 765-768), and claimsWhileMoving ignores isEnabled, unlike the element listener at line 475. A finger landing on an element whose only claiming recognizer was attached through the host and then turned off with setEnabled(false) still gets a non-passive touchmove listener on its target, so a scroll starting there waits on a busy main thread, against spec:AST-094 FR19 ("Only an element with a recognizer that must claim mid-gesture registers that blocking listener"). Suggest recognizers.some(r => r.isEnabled && claimsWhileMoving(r)), with a case beside the passive-listener test.

ids.push(id);
}
}
this.pointerIds.clear();

Copy link
Copy Markdown

Choose a reason for hiding this comment

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

Pens are dropped here without being remembered, so a pen the blur cancelled finds no id at its pointerup (lines 1373-1376), arms no swallower, and where the browser keeps the contact alive its click reaches the page. Fingers are now refused (lines 1018-1020); FR28 says losing focus means nothing acts. Remembering cancelled pen pointer ids, as cancelledFingers does, would close it.

Comment on lines +9 to +10
* layer (`reset`), so any author rule, in any layer or none, or inline
* value outranks the host's (`spec:AST-094` FR18, DEC-8)

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 holds only where reset is registered before every author layer. In Chromium 149, an author touch-action: none in a layer the page registers before reset, or in a shadow root whose styles lack Astryx's order statement, loses to this rule, and the next-frame read then records no author value, so manipulation stays in force. Every setup Astryx documents registers reset first (spec:AST-080 FR3), so stating that precondition here and in the README would make the claim accurate.

Comment thread packages/core/src/gestures/README.md Outdated
- **`touch-action`.** Each element gets the intersection of what its
recognizers leave the browser, and never more than the element's own
value allows: a caller's explicit `touch-action` (a trigger's `none`)
stays in force, as the intersection of both. It is set inline on attach

Copy link
Copy Markdown

Choose a reason for hiding this comment

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

Since the mark, the value is no longer set inline on attach: the host sets data-astryx-touch-action under a @layer reset rule and writes inline only an intersection with the author's value. The header comment at GestureHost.ts:37-40 says the same and needs the same update.

});
const start = await center(page, 'feedRow');
await clearLog(page);
await driver.play(drag(start, {x: start.x, y: start.y - 60}, 30));

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 drag travels 60 px, past the feed tap's default 45 px allowableMovement, so the tap fails by its own movement whether or not the scroll takes the touch, and the case cannot fail on the 10 px rule. The current spec:AST-094 FR18–FR22 verification row asks for real-touch cases at 9 px and 10 px straight and at (7, 7) and (8, 8); the boundary cases this push adds are jsdom only.

edge => edge === 'top' || edge === 'bottom',
);
return {
panX: !horizontal,

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 more than one horizontal edge this drops pan-x from the whole element, so a page's horizontal scrolling stops everywhere on it. Current spec:AST-094 FR18 keeps "the author's scrolling … untouched" outside the 40.2 px band, and DEC-13 rejects "claiming pan-x across the whole element". This came in with the recognizers beneath this pull request and should conform there.

@cixzhang

Copy link
Copy Markdown
Contributor

Posted by Cindy's agent on her behalf.

Cindy's decision on gestures on the web: gestures attach for every pointer, not only touch.

  • Stays off: mouse swipe actions (they'd take over text selection and native drag), pen pinch, edge pan on web inputs, and the 44 px touch target, which stays touch-only and never overlaps a neighbor.
  • Web adaptations: Pan takes wheel and trackpad scroll; Pinch takes ctrl+wheel. A shared "context request" input covers long-press, right-click and Shift+F10. The press tracker gets a fine-pointer profile with no delay, and reorder starts on a handle drag without a hold.
  • The gesture host drops its touch-only query, and pointer queries move into the environment queries. Desktop listeners attach only where a component uses a gesture, and no new code ships for that.

This supersedes the earlier exclusion of mouse press paint. The spec:AST-094 amendment follows in its own spec PR, and the gesture-host PRs conform to 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 pull request is on hold for one engineering-owner decision: the carried rule that counts a finger's uncancelable touchmove as the browser's only when it is the first one and the browser could pan along it narrows current spec:AST-094 FR20, so FR20 needs updating (approving #8456 does that), or an engineering owner records the decision by commenting /approve-spec <full head SHA> on this pull request, which the author may do as an engineering owner. Whatever that decision, these still need changes:

  • Land the unmerged stack first or retarget: the diff carries 18,542 authored lines against this pull request's own 1,733.
  • Make the real-touch cases at 10 px and (8, 8) able to fail (they drag toward the top of a list that is already at its top, so the list never moves), and add iPadOS Safari evidence for the pen fix (spec:AST-013 FR10).
  • Give a finger's target the blocking listener only for an enabled recognizer (GestureHost.ts:949), and update the README and host header, which still say the touch-action is set inline.

[Automated review]

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

Copy link
Copy Markdown

Choose a reason for hiding this comment

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

This rule narrows current spec:AST-094 FR20, which says an uncancelable touchmove MUST count as the browser taking the touch: here only a finger's first one counts, and only where the browser could pan along the finger's main axis. #8456 proposes the amendment and has no owner decision yet; an engineering owner records the decision by approving #8456 or by commenting /approve-spec <full head SHA> on this pull request.

this.watchTarget(
touch.identifier,
target,
recognizers.some(claimsWhileMoving),

Copy link
Copy Markdown

Choose a reason for hiding this comment

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

claimsWhileMoving ignores isEnabled here, while the element listener checks r.isEnabled && claimsWhileMoving(r) (line 506), and participantsAt includes disabled recognizers. A finger landing on an element whose only claiming recognizer was attached and then disabled with setEnabled(false) gets a blocking target listener whenever something else keeps the host listening, so a scroll starting there waits on the main thread (FR19). recognizers.some(r => r.isEnabled && claimsWhileMoving(r)) fixes it.

Comment on lines +1055 to +1069
await driver.play(drag(at, {x: at.x + travel.dx, y: at.y + travel.dy}, 6));
await page.waitForTimeout(100);
const taps = await page.evaluate(
() => (window as unknown as {__taps: string[]}).__taps,
);
const scrolled = (await scrollTop(page, 'list')) > 0;
if (travel.keeps) {
// Under 10 px of travel: a tap, and no scroll took the touch.
expect(taps).toContain('ended');
expect(scrolled).toBe(false);
} else {
// From 10 px the browser may scroll (WebKit on a phone starts at 10;
// Chromium waits longer): if it does, the scroll takes the touch and
// there is no tap; if it does not, the tap stands.
expect(scrolled && taps.includes('ended')).toBe(false);

Copy link
Copy Markdown

Choose a reason for hiding this comment

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

This drags the finger down over a fresh list at its top (openTapInScroller), toward an edge it cannot scroll past, so scrolled never holds in any engine and the 10 px and (8, 8) cases always take the "tap stands" branch: they pass with the take-distance check removed. Holding the finger after the move, scrolling the list from script as the still-finger case does, then lifting would make them fail when the 10 px rule breaks, which the FR18–FR22 row asks for.

Comment on lines +1096 to +1105
if (moved.some(touch => (touch as StylusTouch).touchType === 'stylus')) {
for (const id of this.pointerIds.values()) {
if (this.arena.isClaimed(id)) {
// A pen whose pointer a recognizer owns: its stylus touches too,
// never a finger no recognizer owns.
event.preventDefault();
return;
}
}
}

Copy link
Copy Markdown

Choose a reason for hiding this comment

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

touchType === 'stylus' exists only in iPadOS Safari, so the defect this fixes reproduces only there, and its one test dispatches a synthetic event in jsdom. Current spec:AST-013 FR10 asks for evidence from that browser: iPadOS Safari with a pen drawing on a claimed element while a finger scrolls elsewhere.

Comment on lines +1886 to +1899
private cancelEverything(time: number): void {
const ids = [...this.pointerIds.values()];
for (const id of ids) {
this.unlockSelection(id);
}
for (const identifier of [...this.touchIds.keys()]) {
// The browser may keep the touch alive: its lift must not click.
this.cancelledFingers.add(identifier);
const id = this.forgetFinger(identifier);
if (id != null) {
ids.push(id);
}
}
this.pointerIds.clear();

Copy link
Copy Markdown

Choose a reason for hiding this comment

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

Fingers cancelled here are remembered in cancelledFingers, but pens are dropped with pointerIds, so a pen's later pointerup finds no id and returns before armSwallower (lines 1630-1633), and its click reaches the page where the browser keeps the contact alive. FR28 says losing focus means nothing acts, for a finger or a pen; remembering cancelled pen pointer ids and arming the swallower at their pointerup would close it.

Comment on lines +7 to +10
* the derived value in TOUCH_ACTION_ATTRIBUTE, and its document or shadow
* root holds one zero-specificity rule per value in the first cascade
* layer (`reset`), so any author rule, in any layer or none, or inline
* value outranks the host's (`spec:AST-094` FR18, DEC-8)

Copy link
Copy Markdown

Choose a reason for hiding this comment

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

An author rule in a layer the page registers before reset, or in a shadow root whose styles lack Astryx's order statement, loses to this rule, and the next-frame read then records no author value, so the derived value stays wider than the author's. Saying the guarantee holds where reset is registered first (spec:AST-080 FR3) would keep this accurate.

Comment thread packages/core/src/gestures/README.md Outdated
Comment on lines +195 to +196
stays in force, as the intersection of both. It is set inline on attach
and restored on detach (opt out with `manageStyle: false`):

Copy link
Copy Markdown

Choose a reason for hiding this comment

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

The host now marks the element under a stylesheet rule and writes inline only an intersection, so "set inline on attach" no longer describes it; the host header (GestureHost.ts:43-46) says the same.

Comment thread packages/core/src/gestures/README.md Outdated
Comment on lines +52 to +54
- `usePressTracker` rebuilds its tracker when `trackingExpansion`,
`highlightDelay`, `cancelTravel` or `quickTapFlash` change, from the next
touch, and reads its callbacks (`isBraking` included) on every touch.

Copy link
Copy Markdown

Choose a reason for hiding this comment

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

usePressTracker builds its tracker once in useState and attaches it only on [tracker] (useGestureRecognizer.ts:136-195), so changing these four options after mount has no effect. Either rebuild the tracker when they change or say they are read once.

@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 pull request is on hold for one engineering-owner decision: the carried rule that counts a finger's uncancelable touchmove as the browser's only when it is the first one and the browser could pan along it narrows current spec:AST-094 FR20, so FR20 needs updating (approving #8456 does that), or an engineering owner records the decision by commenting /approve-spec <full head SHA> on this pull request, which the author may do as an engineering owner. Whatever that decision, these still need changes:

  • Land the unmerged stack first or retarget: the diff carries 18,644 authored lines against this pull request's own 1,733.
  • Make the real-touch cases at 9 px, 10 px, (7, 7) and (8, 8) able to fail, since they pass with the 10 px rule removed or never reached because the list they drag over cannot scroll, and add iPadOS Safari evidence for the pen fix (spec:AST-013 FR10) or drop that claim from the changeset.
  • Give a finger's target the blocking listener only for an enabled recognizer (GestureHost.ts:949), and update the README and host header, which still say the touch-action is set inline.

[Automated review]

Comment on lines +1027 to +1031
const dx = input.x - touch.startX;
const dy = input.y - touch.startY;
return Math.abs(dx) >= Math.abs(dy)
? touch.scroll.canScroll(dx, 0)
: touch.scroll.canScroll(0, dy);

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 narrows current spec:AST-094 FR20, which says an uncancelable touchmove MUST count as the browser taking the touch: here only a finger's first one counts, and only where the browser could pan along its main axis. #8456 proposes the amendment and has no owner decision yet; an engineering owner records the decision by approving #8456 or by commenting /approve-spec <full head SHA> on this pull request.

// From 10 px the browser may scroll (WebKit on a phone starts at 10;
// Chromium waits longer): if it does, the scroll takes the touch and
// there is no tap; if it does not, the tap stands.
expect(scrolled && taps.includes('ended')).toBe(false);

Copy link
Copy Markdown

Choose a reason for hiding this comment

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

These four cases drag the finger down over a list created at its top, so the list never scrolls and the 10 px and (8, 8) cases always take this branch. In Chromium all four pass with the take distance removed and with it never reached, while the FR18–FR22 row asks them to fail on "a scroll that does not take the touch at 10 px". Holding the finger after the move, scrolling the list from script as the still-finger case does, then lifting would make them fail.

this.watchTarget(
touch.identifier,
target,
recognizers.some(claimsWhileMoving),

Copy link
Copy Markdown

Choose a reason for hiding this comment

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

claimsWhileMoving ignores isEnabled here, while the element listener checks r.isEnabled && claimsWhileMoving(r) (line 506), and participantsAt includes disabled recognizers. A finger landing on an element whose only claiming recognizer was turned off with setEnabled(false) still gets a blocking target listener (FR19); recognizers.some(r => r.isEnabled && claimsWhileMoving(r)) fixes it.

return;
}
}
if (moved.some(touch => (touch as StylusTouch).touchType === 'stylus')) {

Copy link
Copy Markdown

Choose a reason for hiding this comment

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

touchType === 'stylus' exists only in iPadOS Safari, and the one case for this branch dispatches a synthetic event in jsdom. spec:AST-013 FR10 asks for evidence from that browser for the pen fix .changeset/gesture-host-fixes.md announces; an iPadOS Safari recording of a pen drawing on a claimed element while a finger scrolls elsewhere would cover it, or the claim can stay with #8072, where this code comes from.

ids.push(id);
}
}
this.pointerIds.clear();

Copy link
Copy Markdown

Choose a reason for hiding this comment

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

Pens are dropped here without being remembered, so a blur-cancelled pen's pointerup finds no id (lines 1630-1633), arms no swallower, and its click lands where the browser keeps the contact alive, while a finger's is refused. Remembering cancelled pen pointer ids, as cancelledFingers does, would close it (FR28: nothing acts).

* the derived value in TOUCH_ACTION_ATTRIBUTE, and its document or shadow
* root holds one zero-specificity rule per value in the first cascade
* layer (`reset`), so any author rule, in any layer or none, or inline
* value outranks the host's (`spec:AST-094` FR18, DEC-8)

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 holds only where reset is registered before every author layer: an author touch-action in a layer the page registers first, or in a shadow root without Astryx's order statement, loses to this rule, and the next-frame read then records no author value. Saying so here and in the README would keep it accurate (spec:AST-080 FR3).

Comment thread packages/core/src/gestures/README.md Outdated
recognizers leave the browser, and never more than the element's own
value allows: a caller's explicit `touch-action` (a trigger's `none`)
stays in force, as the intersection of both. It is set inline on attach
and restored on detach (opt out with `manageStyle: false`):

Copy link
Copy Markdown

Choose a reason for hiding this comment

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

The host now marks the element under a stylesheet rule and writes inline only an intersection, so "set inline on attach" no longer describes it; the host header (GestureHost.ts:43-46) says the same.

Comment thread packages/core/src/gestures/README.md Outdated
which reports it at once.
- `usePressTracker` rebuilds its tracker when `trackingExpansion`,
`highlightDelay`, `cancelTravel` or `quickTapFlash` change, from the next
touch, and reads its callbacks (`isBraking` included) on every touch.

Copy link
Copy Markdown

Choose a reason for hiding this comment

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

usePressTracker builds its tracker once in useState and attaches it only on [tracker] (useGestureRecognizer.ts:136-195), so changing these four options after mount has no effect. Either rebuild the tracker when they change or say they are read once.

@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 pull request is on hold for an engineering-owner decision on two carried touch rules that go beyond current spec:AST-094: counting a finger's uncancelable touchmove as the browser's only when it is the first one and the browser could pan along it (FR20 says every one counts; approving #8456 would settle it), and keeping a moving finger claimed after its gesture cancels itself, which keeps cancelling moves FR19 says no unowned touch may lose (approving #8641 would settle it). An engineering owner records the decision by approving those pull requests or by commenting /approve-spec <full head SHA> here, which the author may do as an engineering owner. Whatever that decision, these still need changes:

  • Land the unmerged stack first or retarget: the diff carries 18,734 authored lines against this pull request's own 1,698.
  • Make the real-touch cases at 9 px, 10 px, (7, 7) and (8, 8) able to fail, since they pass with the 10 px rule removed or never reached because the list they drag over cannot scroll, and add iPadOS Safari evidence for the pen fix (spec:AST-013 FR10) or drop that claim from the changeset.
  • Give a finger's target the blocking listener only for an enabled recognizer (GestureHost.ts:949), and correct the host header and README, which still say the touch-action is set inline, and the README's claim that usePressTracker rebuilds when its options change.

[Automated review]

this.watchTarget(
touch.identifier,
target,
recognizers.some(claimsWhileMoving),

Copy link
Copy Markdown

Choose a reason for hiding this comment

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

claimsWhileMoving ignores isEnabled here, while the element listener checks r.isEnabled && claimsWhileMoving(r) (line 506), and participantsAt includes disabled recognizers. A finger landing on an element whose only claiming recognizer was turned off with setEnabled(false) still gets a blocking target listener (spec:AST-094 FR19); recognizers.some(r => r.isEnabled && claimsWhileMoving(r)) fixes it.

Comment on lines +1010 to +1031
const placed = new Map(inputs.map(input => [input.id, input]));
const taken = ids.filter(id => {
const touch = this.arena.getTouch(id);
if (
touch == null ||
touch.takenByBrowser ||
this.arena.isClaimed(id) ||
touch.scroll?.allowsNativePan === false ||
this.judgedUncancelable.has(id)
) {
return false;
}
this.judgedUncancelable.add(id);
const input = placed.get(id);
if (input == null || touch.scroll == null) {
return true;
}
const dx = input.x - touch.startX;
const dy = input.y - touch.startY;
return Math.abs(dx) >= Math.abs(dy)
? touch.scroll.canScroll(dx, 0)
: touch.scroll.canScroll(0, dy);

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 narrows current spec:AST-094 FR20, which says an uncancelable touchmove MUST count as the browser taking the touch: here only a finger's first one counts, and only where the browser could pan along its main axis. #8456 proposes the amendment and has no owner decision yet; an engineering owner records the decision by approving #8456 or by commenting /approve-spec <full head SHA> on this pull request.

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

Keeping the claim here means the host keeps cancelling this finger's touchmove after the only recognizer that owned it has cancelled, so the page neither scrolls nor pinch-zooms with that finger until it lifts. Current spec:AST-094 FR19 says a touchmove of a touch no recognizer owns MUST NOT be cancelled outside its three exceptions, and FR14, which #8641 cites, decides between a scroll area and the surface around it. This waits on an engineering-owner decision (approving #8641, or /approve-spec <full head SHA> here).

// From 10 px the browser may scroll (WebKit on a phone starts at 10;
// Chromium waits longer): if it does, the scroll takes the touch and
// there is no tap; if it does not, the tap stands.
expect(scrolled && taps.includes('ended')).toBe(false);

Copy link
Copy Markdown

Choose a reason for hiding this comment

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

These four cases drag the finger down over a list created at its top, so the list never scrolls and the 10 px and (8, 8) cases always take this branch. In Chromium all four pass with the take distance removed and with it never reached, while the current FR18–FR22 row asks them to fail on "a scroll that does not take the touch at 10 px". Holding the finger after the move, scrolling the list from script as the still-finger case does, then lifting would make them fail.

ids.push(id);
}
}
this.pointerIds.clear();

Copy link
Copy Markdown

Choose a reason for hiding this comment

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

Pens are dropped here without being remembered, so a blur-cancelled pen's pointerup finds no id (lines 1630-1633), arms no swallower, and its click lands where the browser keeps the contact alive, while a finger's is refused. Remembering cancelled pen pointer ids, as cancelledFingers does, would close it (FR28: nothing acts).

* the derived value in TOUCH_ACTION_ATTRIBUTE, and its document or shadow
* root holds one zero-specificity rule per value in the first cascade
* layer (`reset`), so any author rule, in any layer or none, or inline
* value outranks the host's (`spec:AST-094` FR18, DEC-8)

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 holds only where reset is declared before every author layer, as Astryx's own stylesheets declare it (spec:AST-080 FR3): an author touch-action in a layer declared earlier, or in a shadow root whose own styles declare a layer before the adopted sheet, loses to this rule, and the next-frame read then records no author value. Saying so here and in the README would keep it accurate.

Comment thread packages/core/src/gestures/README.md Outdated
- **`touch-action`.** Each element gets the intersection of what its
recognizers leave the browser, and never more than the element's own
value allows: a caller's explicit `touch-action` (a trigger's `none`)
stays in force, as the intersection of both. It is set inline on attach

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 now marks the element under a stylesheet rule and writes inline only an intersection, so "set inline on attach" no longer describes it; the host header (GestureHost.ts:43-46) says the same.

Comment thread packages/core/src/gestures/README.md Outdated
it off owns that end (no consumer callback from an Effect). A handler that
wants UIKit's `.cancelled` calls `recognizer.setEnabled(false)` itself,
which reports it at once.
- `usePressTracker` rebuilds its tracker when `trackingExpansion`,

Copy link
Copy Markdown

Choose a reason for hiding this comment

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

usePressTracker builds its tracker once in useState with these four values (useGestureRecognizer.ts:136-172) and never rebuilds it, so changing them after mount has no effect. Either rebuild the tracker when they change or say they are read once.

@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 pull request is on hold for an engineering-owner decision on two carried touch rules that go beyond current spec:AST-094: counting a finger's uncancelable touchmove as the browser's only when it is the first one and the browser could pan along it, where FR20 counts every one (FR20 needs updating; approving #8456 would do that), and keeping a moving finger claimed after its gesture cancels itself, which keeps cancelling moves FR19 says no unowned touch may lose (an owner needs to identify or provide the spec that governs it; approving #8641 would settle it). An engineering owner records the decision by approving those pull requests or by commenting /approve-spec <full head SHA> here, and the author may do so as an engineering owner. Whatever that decision, these still need changes:

  • Land the unmerged stack first or retarget: the diff carries 18,738 authored lines against this pull request's own 1,698.
  • Make the real-touch cases at 9 px, 10 px, (7, 7) and (8, 8) able to fail, since they drag over a list that cannot scroll and so pass with the 10 px rule removed, and add iPadOS Safari evidence for the pen fix (spec:AST-013 FR10) or drop that claim from the changeset.
  • Give a finger's target the blocking listener only for an enabled recognizer (GestureHost.ts:949), and correct the README's "set inline", usePressTracker and Lab-import descriptions.

[Automated review]

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

Copy link
Copy Markdown

Choose a reason for hiding this comment

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

This counts a finger's first uncancelable touchmove as the browser taking it only where the browser could pan along the finger's main axis, and later ones never. Current spec:AST-094 FR20 says "pointercancel, an uncancelable touchmove, or a native scroll under the touch MUST count as the browser taking the touch". #8456 proposes the amendment and has no engineering-owner decision yet, so this rule waits on that decision.

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

Keeping the moving touches claimed here means the host keeps cancelling that finger's touchmove (GestureHost.ts:1085-1094) after the only recognizer that claimed it has cancelled, so the page neither scrolls nor pinch-zooms with it until the lift. Current spec:AST-094 FR19 says a touchmove of a touch no recognizer owns MUST NOT be cancelled outside three exceptions (a modal scroll lock, a control that keeps its press through a drag, a scroll-edge pan), and FR14 decides between a scroll area and the surface around it. This waits on an engineering-owner decision (#8641).

const driver = await openTapInScroller(page, browserName);
await logTouchMoves(page);
const at = await center(page, 'row');
await driver.play(drag(at, {x: at.x + travel.dx, y: at.y + travel.dy}, 6));

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 finger moves down over a list that openTapInScroller has just built at its top with overscroll-behavior: contain, so the list never scrolls and scrolled is always false: the 10 px and (8, 8) cases always take the "the tap stands" branch, and all four pass with the take distance removed or never reached. Holding the finger after its move, scrolling the list from script as the still-finger case does (lines 537-542), then lifting would make each case pin the 10 px rule that the FR18–FR22 verification row asks for.

this.watchTarget(
touch.identifier,
target,
recognizers.some(claimsWhileMoving),

Copy link
Copy Markdown

Choose a reason for hiding this comment

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

claimsWhileMoving ignores isEnabled here, while the element listener checks r.isEnabled && claimsWhileMoving(r) (line 506), and participantsAt includes disabled recognizers. A finger landing on an element whose only claiming recognizer is attached but disabled gets a blocking target listener whenever something else keeps the host listening, so a scroll starting there waits on a busy main thread (FR19). recognizers.some(r => r.isEnabled && claimsWhileMoving(r)), with a case, would fix it.

ids.push(id);
}
}
this.pointerIds.clear();

Copy link
Copy Markdown

Choose a reason for hiding this comment

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

Fingers are remembered in cancelledFingers above, but pens are dropped here, so a blur-cancelled pen's later pointerup finds no id (lines 1630-1633) and returns before armSwallower; where the browser keeps that contact alive, its click lands, while a finger's is refused. Remembering cancelled pen pointer ids the same way would keep FR28's "nothing acts" for a pen too.

Comment thread packages/core/src/gestures/README.md Outdated
it off owns that end (no consumer callback from an Effect). A handler that
wants UIKit's `.cancelled` calls `recognizer.setEnabled(false)` itself,
which reports it at once.
- `usePressTracker` rebuilds its tracker when `trackingExpansion`,

Copy link
Copy Markdown

Choose a reason for hiding this comment

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

usePressTracker builds its tracker once in useState with these four values (useGestureRecognizer.ts:136-172) and never rebuilds it, so changing them after mount has no effect. Either rebuild the tracker when they change, with a test, or say here that they are read once.

@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 pull request is on hold for an engineering-owner decision on two carried touch rules that go beyond current spec:AST-094: counting only a finger's first uncancelable touchmove, and only where the browser could pan along the finger, as the browser taking it, where FR20 counts every one (FR20 needs updating; approving #8456 would do that), and keeping a moving finger claimed after its gesture cancels itself, which keeps cancelling moves FR19 says no unowned touch may lose (an owner needs to identify or provide the governing spec; approving #8641 would settle it). An engineering owner records the decision by approving those pull requests or by commenting /approve-spec <full head SHA> here, and the author may do so as an engineering owner. Whatever that decision, these still need changes:

  • Conform the host to current FR19, FR41 and FR43, which now say gestures attach for every pointer: today nothing but an action group attaches where the primary pointer is fine, and a pen's hold takes no selection lock there.
  • Land the unmerged stack first or retarget: the diff carries 18,861 authored lines against this pull request's own 1,821.
  • Make the real-touch cases at 9 px, 10 px, (7, 7) and (8, 8) able to fail, since they drag over a list that cannot scroll, and add iPadOS Safari evidence for the pen fix (spec:AST-013 FR10) or drop that claim from the changeset.
  • Give a finger's target the blocking listener only for an enabled recognizer, and expire claimed-lift records that no touchend consumed.

[Automated review]

}
const dx = input.x - touch.startX;
const dy = input.y - touch.startY;
return Math.abs(dx) >= Math.abs(dy)

Copy link
Copy Markdown

Choose a reason for hiding this comment

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

This counts only a finger's first uncancelable touchmove, and only where the browser could pan along the finger's main axis, as the browser taking it. Current spec:AST-094 FR20 says "pointercancel, an uncancelable touchmove, or a native scroll under the touch MUST count as the browser taking the touch". #8456 proposes the amendment and has no engineering-owner decision yet, so this rule waits on that decision.

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

Keeping the moving touches claimed here means the host keeps cancelling that finger's touchmove (GestureHost.ts:1133-1150) after the only recognizer that claimed it has cancelled, so the page neither scrolls nor pinch-zooms with it until the lift. Current spec:AST-094 FR19 says a touchmove of a touch no recognizer owns MUST NOT be cancelled outside its three exceptions, and FR14 does not cover a gesture that cancelled itself. This waits on an engineering-owner decision (#8641).

}

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

Copy link
Copy Markdown

Choose a reason for hiding this comment

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

Current spec:AST-094 FR19 now says "Recognizers attach for every pointer, with FR43's exceptions", FR43 that "Gestures attach for every pointer, a mouse, a pen, and a finger", and architecture:react-component-runtime INV13 that the host attaches "with no touch-only query". This gate (with refresh at 515-517 and applyStyle at 568-570) still turns every gesture off under a fine pointer except an action group's, so it needs to follow FR43's exceptions and FR24's fine-pointer listener budget instead.

pointerType: string,
view: Window | null | undefined,
): boolean {
return pointerType !== 'mouse' && isCoarsePointer(view);

Copy link
Copy Markdown

Choose a reason for hiding this comment

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

Current spec:AST-094 FR41 says "A pen's hold acts as a finger's", without a coarse-pointer condition, so a pen's hold on a hold-driven element where the primary pointer is fine should take this lock too; GestureHost.test.ts:1692 pins the old behavior.

const driver = await openTapInScroller(page, browserName);
await logTouchMoves(page);
const at = await center(page, 'row');
await driver.play(drag(at, {x: at.x + travel.dx, y: at.y + travel.dy}, 6));

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 finger moves down over a list that openTapInScroller has just built at its top with overscroll-behavior: contain, so the list never scrolls and scrolled is always false: the 10 px and (8, 8) cases always take the "the tap stands" branch, and all four pass with or without the take distance. Holding the finger after its move, scrolling the list from script as the still-finger case does (lines 537-542), then lifting would make each case pin the 10 px rule the FR18–FR22 verification row asks for.

this.watchTarget(
touch.identifier,
target,
recognizers.some(claimsWhileMoving),

Copy link
Copy Markdown

Choose a reason for hiding this comment

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

claimsWhileMoving ignores isEnabled here, while the element listener checks r.isEnabled && claimsWhileMoving(r) (line 517), and participantsAt includes disabled recognizers. A finger landing on an element whose only claiming recognizer is attached but disabled gets a blocking target listener whenever something else keeps the host listening (FR19). recognizers.some(r => r.isEnabled && claimsWhileMoving(r)), with a case, would fix it.

ids.push(id);
}
}
this.pointerIds.clear();

Copy link
Copy Markdown

Choose a reason for hiding this comment

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

Fingers are remembered in cancelledFingers above, but pens are dropped here, so a blur-cancelled pen's later pointerup finds no id (lines 1685-1688) and returns before armSwallower; where the browser keeps that contact alive, its click lands. Remembering cancelled pen pointer ids the same way would keep FR28's "nothing acts" for a pen too.

* place on the same target (`spec:AST-094` FR21).
*/
private cancelClaimedLift(event: TouchEvent): void {
if (!event.cancelable || event.defaultPrevented) {

Copy link
Copy Markdown

Choose a reason for hiding this comment

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

When this touchend is uncancelable or already prevented, the matching claimedLifts record is never removed, and records are capped at eight but never expire, so a later lift within 1 px on the same target has its touchend cancelled and loses its click. Removing the matching record before this early return, or dropping records older than the stray-click window, would close it.

Comment thread packages/core/src/gestures/README.md Outdated
it off owns that end (no consumer callback from an Effect). A handler that
wants UIKit's `.cancelled` calls `recognizer.setEnabled(false)` itself,
which reports it at once.
- `usePressTracker` rebuilds its tracker when `trackingExpansion`,

Copy link
Copy Markdown

Choose a reason for hiding this comment

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

usePressTracker builds its tracker once in useState with these four values (useGestureRecognizer.ts:136-172) and never rebuilds it, so changing them after mount has no effect. Either rebuild the tracker when they change, with a test, or say here that they are read once.

@vjeux

vjeux commented Oct 29, 2026

Copy link
Copy Markdown
Contributor Author

On the review, in this push. It now sits on #8360, so the diff is this pull request's own:

@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 pull request is on hold for an engineering-owner decision on two carried touch rules that go beyond current spec:AST-094: counting only a finger's first uncancelable touchmove, and only where the browser could pan along it, as the browser taking the touch (FR20 needs updating for that; approving #8456 would do it), and keeping a moving finger claimed after its own gesture cancels itself (an owner needs to provide or identify the governing spec, since FR19 says an unowned touch's moves are never cancelled; approving #8641 would settle it). An engineering owner records the decision by approving those pull requests or by commenting /approve-spec <full head SHA> here, and the author may do so as an engineering owner. Whatever that decision, these still need changes:

  • Land #8360 first, so this diff carries only its own 252 lines instead of 27,466.
  • Conform the host to current FR19, FR41 and FR43, which say gestures attach for every pointer; today nothing but an action group or a perEventPointer participant attaches under a fine primary pointer.
  • Add a real-touch case for the claimed-lift touchend cancel with fingers read from pointer events, and make the 9 px, 10 px, (7, 7) and (8, 8) cases scroll the list under a held finger.
  • Cancel that lift wherever fingers come from pointer events, including 'auto' input with no ontouchstart, and update the description, which still cites #8270 and the dropped pen fix.

[Automated review]

// Pointer Events only: a claimed finger's touchend is not the host's own
// document listener's to cancel, so the element cancels it (FR21).
const needsEndListener =
this.inputMode === 'pointer-events' &&

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 keys the lift cancel to the explicit 'pointer-events' mode, but the host also reads fingers from their pointer events under 'auto' when the window exposes no ontouchstart (:1521-1522, :2021-2023), where Touch Events still arrive. There a claimed finger's touchend still goes through, which spec:AST-094 FR21 forbids. Keying this and the record at :2685 on the condition the move path uses would cover both.

ids.push(id);
}
}
this.pointerIds.clear();

Copy link
Copy Markdown

Choose a reason for hiding this comment

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

Pointer-tracked contacts are dropped here without being remembered, so a blur-cancelled pen's later pointerup (or a finger's, where fingers come from pointer events) returns at :2679-2681 before any record or click guard, and its click lands, while a Touch Events finger's lift is refused (:2073-2076, FR28).

// From 10 px the browser may scroll (WebKit on a phone starts at 10;
// Chromium waits longer): if it does, the scroll takes the touch and
// there is no tap; if it does not, the tap stands.
expect(scrolled && taps.includes('ended')).toBe(false);

Copy link
Copy Markdown

Choose a reason for hiding this comment

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

This branch accepts the tap whenever the engine has not scrolled yet, and the 9 px and (7, 7) cases put no scroll under the finger, so a take distance moved either way can stay green (architecture:component-test-sufficiency INV6). Holding the finger after the move, scrolling the list from script as the still-finger case does (:529-553), then lifting would pin 10 px exactly.

*
* ONLY WHERE THE PRIMARY POINTER IS COARSE
*
* - Recognizers attach, and the host registers listeners, only while the

Copy link
Copy Markdown

Choose a reason for hiding this comment

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

Current spec:AST-094 FR19 and FR43 now say recognizers attach for every pointer, with FR43's exceptions, and architecture:react-component-runtime INV13 says the host has no touch-only query; this gate (and utils/selectionLock.ts:66) still follows the replaced coarse-only wording.

@vjeux

vjeux commented Oct 29, 2026

Copy link
Copy Markdown
Contributor Author

Fixed in this push:

  • the claimed lift is cancelled wherever fingers come from pointer events, 'auto' input with no ontouchstart included, and a blur-cancelled pen's or finger's lift clicks nothing: 8ad84fb → 0d2723e;
  • a real-touch case: a hold that claims with its lift now cancels its touchend, which it did not before, since the claim was read before the lift: 1c6d1dd → fdb1309;
  • the 9 px, 10 px, (7, 7) and (8, 8) cases hold the finger while the list scrolls under it: 1c6d1dd.

This now sits on #8665, so the host attaches for every pointer here. FR20 (#8456) and the claim after a cancel (#8641) stay the owner's decisions.

@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 pull request is on hold for engineering-owner decisions on three touch rules beyond current spec:AST-094: this head's own edit to FR41 for claimed mouse and pen drags (FR41's sentence that a mouse keeps its released behavior needs that update approved), the first-uncancelable-touchmove rule that FR20 would need updating for (#8456), and keeping a moving finger claimed after its gesture cancels itself, for which an owner needs to provide or identify the governing spec, since FR19 says an unowned touch's moves are never cancelled (#8641). An engineering owner approves the FR41 edit by commenting /approve-spec <full head SHA> on this pull request, and the author may do so as an engineering owner; the other two are decided by approving #8456 and #8641. Whatever those decisions, these still need changes:

  • Land #8360 and #8665 first, so this diff carries only its own 499 lines instead of 28,871.
  • Add iPadOS Safari evidence for the claimed pen's stylus touchend cancel, or leave that branch out.
  • Rewrite the description for this head, naming the merged #8665 and the FR41 edit with its writing-rubric results.

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

These sentences add a requirement to current spec:AST-094 FR41, whose existing text says a mouse otherwise keeps its released behavior. A record change needs an engineering owner's exact-head approval (/approve-spec <full head SHA> on this pull request), and the description needs the record's ## Writing rubric results.

Comment on lines +2086 to +2096
if (hasStylusTouch(event)) {
// A pen's lift is its pointer's: the stylus touchend only refuses the
// compatibility mouse events and click when a gesture took the pen.
const isClaimed =
this.isPenLiftClaimed ||
[...this.pointerIds.values()].some(id => this.arena.isClaimed(id));
this.isPenLiftClaimed = false;
if (isClaimed && event.cancelable) {
event.preventDefault();
}
}

Copy link
Copy Markdown

Choose a reason for hiding this comment

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

touchType === 'stylus' exists only in iPadOS Safari, so this cancel reproduces only there, and its two cases are jsdom with synthetic events. spec:AST-013 FR10 asks for evidence from that browser: an iPadOS Safari recording of a claimed and an unclaimed pen lift would cover it, or the branch can wait until one exists.

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

Copy link
Copy Markdown

Choose a reason for hiding this comment

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

Counting only a finger's first uncancelable touchmove, and only where the browser could pan along it, narrows current FR20 ("an uncancelable touchmove ... MUST count as the browser taking the touch"). It waits on an engineering-owner decision in #8456.

Comment on lines +703 to +704
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.

Keeping a moving finger claimed after its only claimer cancels itself keeps its touchmoves cancelled, where FR19 says a touch no recognizer owns MUST NOT have its moves cancelled outside three exceptions. FR14 decides who gets a drag that starts in a scroll area, not this case; it waits on an owner decision in #8641.

Comment on lines +8 to +10
* root holds one zero-specificity rule per value in the first cascade
* layer (`reset`), so any author rule, in any layer or none, or inline
* value outranks the host's (`spec:AST-094` FR18, DEC-8); a root's rules

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 holds only when reset is the first declared layer. An app that declares its own layers before Astryx's stylesheet loses its touch-action to the derived value, which can be wider (FR18). Stating that precondition here and in the README, or placing the mark where an earlier author layer still wins, would close 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 pull request is on hold for engineering-owner decisions on two touch rules it carries beyond current spec:AST-094. The first keeps a moving finger claimed after its gesture is cancelled during that move: an owner needs to provide or identify the spec that governs it, since FR19 says an unowned touch's moves are never cancelled outside its three exceptions (#8641). The second counts only a finger's first uncancelable touchmove, and only where the browser could pan along it, which needs FR20 updated (#8456). An engineering owner decides by approving #8641 and #8456, or by commenting /approve-spec <full head SHA> on this pull request (the author may do so as an engineering owner); whatever they decide, these still need changes:

  • Land #8360 and #8098 first, so this diff carries only its own 713 lines instead of 31,161.
  • Cancel the claimed lift under a fine primary pointer too: the touchend listener is added only when the primary pointer is coarse (GestureHost.ts:1016-1018), so where fingers come from pointer events under a fine primary pointer (a touchscreen laptop), a claimed finger still sends mousedown and mouseup.
  • Add iPadOS Safari evidence for the stylus touchend cancel, or leave that branch out.

[Automated review]

// (FR21).
const needsEndListener =
this.readsFingersFromPointerEvents() &&
this.isCoarse &&

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 this.isCoarse here, a touchscreen laptop (fine primary pointer) whose fingers come from pointer events gets no touchend listener. The record pushed at pointerup (:2597-2608) is never consumed, and the claimed finger's mousedown and mouseup still reach the page; the document handler returns at :2063-2065. FR21 and FR43 cover this pointer, and the touchmove listener below has no coarse condition. Dropping the condition, with a fine-pointer case, would close it.

isSamePlace(lift, {
x: touch.clientX,
y: touch.clientY,
target: touch.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.

At this element listener, touch.target is retargeted to the shadow host when the finger landed inside an open shadow root below the element. The record holds the innermost node from landedOn(pointerup), so the two never match there. landedOn(end, touch.target), as onTouchStart uses at :1776, would compare like with like.

// The browser may keep a pen or a pointer-read finger down: its lift must
// not click (FR28).
for (const pointerId of this.pointerIds.keys()) {
this.cancelledPointers.add(pointerId);

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 mouse pointer ids too (pointerIds holds every primary press, :2487), so a mouse press the blur cancelled loses its click at the lift. The comments here and at :422-426 name only pens and pointer-read fingers. An id whose contact ends in pointercancel is also never removed.

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

Keeping the moving fingers claimed on every cancel inside a move keeps the host cancelling their touchmoves after no live recognizer owns them. Current spec:AST-094 FR19 says an unowned touch's moves MUST NOT be cancelled outside three exceptions. This waits on an engineering-owner decision (#8641).

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

Copy link
Copy Markdown

Choose a reason for hiding this comment

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

Counting only a finger's first uncancelable touchmove, and only where the browser could pan along its main axis, narrows current spec:AST-094 FR20 ("an uncancelable touchmove ... MUST count as the browser taking the touch"). This waits on an engineering-owner decision (#8456).

readonly target: unknown;
}

/** A touchstart and a pointerdown describe one finger: same target, same spot. */

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 doc comment belonged to isSamePlace; with CLAIMED_LIFT_MS inserted below it, it now documents nothing. Moving the constant above it keeps both.

@vjeux

vjeux commented Oct 31, 2026

Copy link
Copy Markdown
Contributor Author

Fixed in b3f492c: the claimed lift's touchend cancel now comes with #8360 below, with the fixes asked here (the listener under a fine primary pointer wherever the browser sends Touch Events, landedOn(end, touch.target) inside an open shadow root, and CLAIMED_LIFT_MS above isSamePlace's doc), so this pull request keeps the rest: the window's blur refuses a later lift only for a pen or a finger read from pointer events, so a mouse keeps its click, and a contact that ends in pointercancel is forgotten. The stylus touchend branch is gone until there is iPadOS Safari evidence, the scroll-area matrix rewrite gives way to #8071's FR20 cases, and the FR20 rule rests on #8456, merged.

@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 pull request is on hold for an engineering-owner decision on the scroll-lock requirements it writes into component:BottomSheetSwitcher FR5, component:Drawer FR5 and component:MobileNav FR3 (carried from #8098), 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 448 lines instead of 18,262.
  • Keep the blur refusal working under a fine primary pointer: the blur removes the press's page listeners (GestureHost.ts:1395-1406) before a pen's or pointer-read finger's later lift, so its click still lands, and a case whose lift comes in a later task would catch it.

[Automated review]

Comment on lines 2896 to +2903
private cancelEverything(time: number): void {
// The browser may keep a pen or a pointer-read finger down: its lift must
// 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

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, and its microtask finds no arena touch and uninstalls the page pointerup listener (:1395-1406). The pen's or pointer-read finger's later lift then reaches no host listener and its click lands. Keeping the armed press, or its pointerup and pointercancel listeners, while cancelledPointers holds an id, plus a fine-pointer case whose lift comes after a microtask, 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

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:2646-2647) removed: the later pen pointerdown clears the id at GestureHost.ts:2488 anyway. Asserting on what the pointercancel alone leaves, or naming the case for what it pins, would make it guard its rule.

@vjeux

vjeux commented Oct 31, 2026

Copy link
Copy Markdown
Contributor Author

Updated in b8af33b, on #8360's d7e6d99: a lift the window's blur refused is noted as a claimed lift is, so its note is capped and goes after its time (FR24), with a case that 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.

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

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

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

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

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

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

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 and others added 5 commits October 31, 2026 08:53
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>
…finger landed

On iOS WebKit the host resolves moving content at touch-down and asks the
engine what is under the finger. When a busy main thread hands it the touch
later, the content has moved on: the host now asks at the point where what
was under the finger at the touch's timestamp is drawn, so a tap on a moving
sheet's Done still clicks Done (spec:AST-094 FR11). A presented source
reports its offset at a given time for this.

The presented browser proof drives the sheet with Core's animator and a
critically damped spring, up into place and down off the screen over the
scrim: a tap on the drawn Done clicks it once, and a drag on the drawn sheet
catches it where it is drawn, in both directions.
…the finger (FR11, FR19); INV13 names the shared input listener

Where the engine hit-tests an element a compositor animation moves at the
motion's start (iOS WebKit), a touch on that content goes to what was drawn
under the finger at touch-down, and a tap there activates that element with
one click while the browser's own click is cancelled or swallowed (FR11).
While such content around a recognizer that claims moving touches is moving,
the document may carry a non-passive touchmove listener that cancels only
owned moves and comes off when the motion ends; while it is on, a native
scroll waits for a free main thread (FR19). The gesture host, press
feedback, page hold, finger-scroll watch and click guard hear document-wide
input through one capture-phase listener per event type on the window
(architecture:react-component-runtime INV13).
The scroll lock no longer pins <body>, which reset the page's offset to 0
(the page jumped and Safari's toolbar came back). It holds `overflow:
hidden` on the page's scroller and, through the gesture host's page hold,
cancels only a one-finger pan that nothing inside the overlay takes: a
scroller, a range input, a pinch or a zoomed page's pan still goes through.
The root's scroll styles become claim stacks (rootScrollStyles), so a lock
and a pull to refresh compose, and MobileNav's clip moves onto them. The
top-layer check asks CSS.supports for `:modal` and `:popover-open` instead
of catching errors from `matches`. The touch module bundle test names
useScrollLock and the imports that reach it as carrying the page hold.

The records state the clip on the page's scroller (MobileNav FR3) and that
pull-to-refresh and the page's bounce stop behind a modal overlay
(BottomSheetSwitcher FR5, Drawer FR5).

AlertDialog's layout tests stub the page hold, whose pointer read belongs to
the lock, so their guard still sees only the layout's own media queries.
…h rule of the Touch Events path

Where a finger comes from its pointer events, the host does what its
Touch Events path does (spec:AST-094 FR10, FR19, FR28): the lift of a
pen or a pointer-read finger the window's blur cancelled is refused,
while a mouse's keeps its click, and a contact that ends in
pointercancel is forgotten; a second finger fails an unclaimed
one-finger pan wherever it lands; and a target gets a blocking listener
only for an enabled participant. The arena cancels a moving recognizer
the browser takes. A refused lift is noted as a claimed lift is, and the
pointers a blur cancelled go, as the host's other notes do, after its
last detach (FR24). The claimed lift's touchend cancel these build on
comes with meta#8360. meta#8309's and meta#8336's changes, rebased onto meta#8098 as one
commit.

Co-authored-by: Cindy Zhang <cindyxz@meta.com>
@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

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

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.

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

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

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.

This branch was successfully deployed

1 active deployment
Preview — 255e3d17 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 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