Skip to content

fix(gestures): a mouse or pen drag a gesture claims keeps the page's text selection (spec:AST-094 FR41) - #8665

Merged
cixzhang merged 11 commits into
meta:mainfrom
vjeux:uikit/f2-every-pointer
Nov 3, 2026
Merged

cixzhang merged 11 commits into
meta:mainfrom
vjeux:uikit/f2-every-pointer

Conversation

@vjeux

@vjeux vjeux commented Oct 27, 2026 •

Copy link
Copy Markdown
Contributor

On main: eleven commits (the fix, then ten from review and rebases).

User impact

With a mouse or pen, a drag a gesture takes (a sheet's) selected text and cleared the person's selection. A drag from text moved the element.

Problem and solution fit

The host turned selection off only for a held finger or pen. Now each mouse or pen press keeps the page's selection, and a claimed drag refuses new ones and restores it. A press on selectable text stays the browser's.

Expected behavior and authority

Cindy approved option A; spec:AST-094 FR41 records it in her words: "A mouse or pen drag that a recognizer claims takes no new text selection for that drag and never clears an existing selection; a drag that starts on selectable text is not claimed. An unclaimed mouse drag keeps its released behavior." Its Verification row lists both facts; the record lines are unchanged since 27ba69f.

Records: spec:AST-094 FR41, FR21, FR24, DEC-9; architecture:react-component-runtime INV13; architecture:knowledge-contracts INV29.

Smallest restoration

  • A mouse or pen pointerdown reads the selection and whether the press lands on selectable text (a native button's label is not).
  • Any claim starts lockDragSelection, beside any press lock, until a task after the lift or a cancel; a given-back claim, or a tap's at its lift, holds nothing.
  • The arena's internal refusesClaim fails a claim on a moved mouse or pen press over selectable text.
  • The caret query and the kept selection reach into open shadow roots; the hold hears selection events through the shared window listener; a wheel's contact holds nothing.

Evidence

  • Real input, Chromium and WebKit: on the base, a claimed mouse drag replaced the selected heading; a drag from text began the pan. Both pass now.
  • Red first: a swipe's claim; a pan waiting on a hold's failure; a pen press a group claims in a menu row, and a pen drag a pan claims there; a drag from a button's label, slotted or not; shadow text; a kept shadow selection; the listener routing. Chromium checks four with real input.
  • Unchanged: a tap's click, a group's Copy.

Scope

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

Testing

Local gates at 9bca8e8, on main eadd175 (11 commits; 9 files):

  • tsc, strict ESLint (with no-comment-provenance), Prettier, the private-attribute baseline and check:* (check:i18n-runtime included): clean. The provenance baseline check reports drift in four files this change does not touch (ChatComposer.tsx, Item.tsx, layerSurfacePress.ts, Token.tsx), as it does on main.
  • The gesture, utility, hook and public StyleX namespace unit tests: 124 files, 2,063 tests passed.
  • The gesture and presented hit-testing browser proofs in Chromium: 68 passed and 4 skipped (the trackpad-only cases). The pen case passes 5 of 5, and fails 3 of 3 when the lift releases only the press lock; the button case fails 3 of 3 on 4009621. In the WebKit CI job's profile (4 CPUs, CI=1, LP_NUM_THREADS=4, one worker), ten runs: 610 passed and 110 skipped (the Chromium-only cases), none failed.
  • Carried from d362b03487, the same code on main 134afc4:

Writing rubric results

R1–R6: none. FR41's sentences are in the present tense, its Verification row names cases, and its question is ruled.

Notes

GestureHost.onPointerCancel meets #8309 in one line; whichever merges second keeps both.

@vercel

vercel Bot commented Oct 27, 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 2, 2026 11:59pm UTC

Request Review

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

astracat-bot Bot commented Oct 27, 2026 •

Copy link
Copy Markdown
Contributor

AI review status for this pull request.

Review status Updated
🟢 Reviewed (for maintainers only) Nov 3, 2026, 12:09 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 for a spec decision. Gestures for every pointer contradict spec:AST-094 FR19 and FR24 and architecture:react-component-runtime INV13 as they read on main, and the FR43 and DEC-21 amendment in #8646 that would allow them is not merged yet. Approval needs an engineering owner to settle that, either by landing #8646's amendment on main or by commenting /approve-spec <full head SHA> on this pull request; the author, as an engineering owner, may do so. Whatever the decision, these still need changes:

  • Land #8360 first, so this diff carries only its own 14 files instead of 27,964 authored lines.
  • Under a fine pointer, give a finger's claiming recognizer the same pre-registered blocking touchmove listener it gets under a coarse one (GestureHost.ts:825-831), with a test.
  • Apply a hold's selection lock to a finger or a pen whatever the primary pointer (utils/selectionLock.ts:62-67), with a test.
  • Either serve hover under a fine pointer or say in the README that it stays coarse-only.

[Automated review]

Comment on lines +825 to +831
const needsListener =
this.isCoarse &&
(registration.recognizers.some(
recognizer => recognizer.isEnabled && claimsWhileMoving(recognizer),
) ||
(registration.consumer?.keepsTouch != null &&
registration.consumer.isEnabled?.() !== false));

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 pointer this leaves a finger's claiming recognizer (an axis-limited pan, a sheet's pull) without a pre-registered blocking touchmove listener. The only non-passive one left is added at touchstart by watchTarget, which spec:AST-094 FR19's known limit says "would hand diagonal drags to the scroll". #8646's FR24 counts "a claiming recognizer's touchmove listener for a finger" outside the one-listener budget, so dropping this.isCoarse && for claiming recognizers, plus a fine-pointer finger test that a claimed touchmove is cancelled, would close this.

Comment on lines +62 to +67
export function locksSelectionFor(
pointerType: string,
view: Window | null | undefined,
): boolean {
return pointerType !== 'mouse' && isCoarsePointer(view);
}

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.

With gestures attaching for every pointer, a finger's long press under a fine pointer (a touchscreen laptop) now runs the hold, but this gate skips the lock, so the browser's text-selection start and callout begin too, which spec:AST-094 FR41 rules out for an element whose hold does something. Locking for a finger or a pen whatever the primary pointer (a mouse stays exempt), and updating the comment above, would fix it.

Comment thread packages/core/src/gestures/README.md Outdated
| `PinchGestureRecognizer` | `UIPinchGestureRecognizer` | continuous | `scale` (settable, to re-base it), `velocity` in scale per second, `focalPoint()` |
| `RotationGestureRecognizer` | `UIRotationGestureRecognizer` | continuous | `rotation` in radians, unwrapped, clockwise positive; `velocity` in rad/s |
| `ScreenEdgePanGestureRecognizer` | `UIScreenEdgePanGestureRecognizer` | continuous | `edges` including `leading` / `trailing` (resolved against the writing direction); fails as soon as the touch lands outside |
| `HoverGestureRecognizer` | `UIHoverGestureRecognizer` | continuous | a mouse or a hovering pen; silent for fingers |

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 host serves hover only where the primary pointer is coarse (GestureHost.ts:1267, :1395-1397), so on a desktop a hover recognizer never begins. Either say here that hover stays coarse-only, or serve it under a fine pointer.

@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 a spec decision: gestures for every pointer contradict spec:AST-094 FR19 and FR24 and architecture:react-component-runtime INV13 as they read on main, the FR43 and DEC-21 amendment in #8646 is not merged, and clearing the selection of a mouse or pen drag a recognizer claims also departs from FR41's "a mouse keeps its released behavior", which #8646 leaves unchanged. Approval needs an engineering owner to settle both, either by landing the amendment on main with FR41 covering drag selection, or by commenting /approve-spec <full head SHA> on this pull request; the author, as an engineering owner, may do so. Whatever the decision, these still need changes:

  • Land #8360 first, so this diff carries only its own 17 files instead of 28,460 authored lines.
  • Under a fine pointer, make a kept press's blocking touchmove (GestureHost.ts:832-833) actually keep the press, or stop registering it, and make the host header and README say which, with a test.
  • Apply a hold's selection lock to a finger whatever the primary pointer (utils/selectionLock.ts:62-67), with a test.
  • Either serve hover under a fine pointer or say in the README that it stays coarse-only.
  • Apply isPrimaryClick to a pen as the README says (GestureHost.ts:1132, :2198), or correct the README.

[Automated review]

Comment on lines +832 to +833
(registration.consumer?.keepsTouch != null &&
registration.consumer.isEnabled?.() !== false);

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 pointer this now registers the blocking touchmove for a keepsTouch press tracker, but cancelIfClaimed still cancels a kept press's moves only where the pointer is coarse (:1771-1773), and the target listener's blocking flag is coarse-only too (:1605). On a touchscreen laptop the listener makes scrolls that start on the control wait for the main thread without ever keeping the press. Either drop those two coarse gates (and have #8646's FR24 budget the listener) or restore this.isCoarse && here; the header at :21-23 and README.md:320-322 should then say which, with a fine-pointer kept-press test.

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

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.

With gestures attaching for every pointer, a finger's long press under a fine pointer (a touchscreen laptop) now runs the hold, but this gate skips the lock, so the browser's text-selection start and callout begin too, which spec:AST-094 FR41 rules out for an element whose hold does something. Locking for a finger whatever the primary pointer, keeping a mouse exempt, and updating the comment above and the README, would fix it.

Comment thread packages/core/src/gestures/README.md Outdated
| `PinchGestureRecognizer` | `UIPinchGestureRecognizer` | continuous | `scale` (settable, to re-base it), `velocity` in scale per second, `focalPoint()` |
| `RotationGestureRecognizer` | `UIRotationGestureRecognizer` | continuous | `rotation` in radians, unwrapped, clockwise positive; `velocity` in rad/s |
| `ScreenEdgePanGestureRecognizer` | `UIScreenEdgePanGestureRecognizer` | continuous | `edges` including `leading` / `trailing` (resolved against the writing direction); fails as soon as the touch lands outside |
| `HoverGestureRecognizer` | `UIHoverGestureRecognizer` | continuous | a mouse or a hovering pen; silent for fingers |

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 host serves hover only where the primary pointer is coarse (GestureHost.ts:1269, :1397-1399), so on a desktop a hover recognizer never begins, while this row and the host header now promise every pointer. Either say here that hover stays coarse-only, or serve it under a fine pointer.

}
// A right click, or a ctrl-click on macOS (its context click), starts no
// press (FR43).
if (event.pointerType === 'mouse' && !isPrimaryClick(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.

The README (README.md:247-249) and isPrimaryClick's JSDoc say a pen's barrel button starts no press, but this check, like the one at :1132, applies to a mouse only, so a pen pointerdown with its barrel button (button 2) still begins a pan or a press. Applying isPrimaryClick to every pointer but a finger here and at :1132, with a pen case, would match the docs.

vjeux added a commit to vjeux/astryx that referenced this pull request Oct 27, 2026
…ment-wide; a fine-pointer finger's action-group press ends) into the context request
vjeux added a commit to vjeux/astryx that referenced this pull request Oct 27, 2026
… selection off document-wide) into the meta#8228 helper
vjeux added a commit to vjeux/astryx that referenced this pull request Oct 27, 2026
@cixzhang

cixzhang commented Nov 2, 2026

Copy link
Copy Markdown
Contributor

/approve-spec 9d937ed

@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 button-label fix works (a mouse drag from a native button's label inside a pan pans again), and one small item remains. isInButton in utils/selectionLock.ts re-implements the flat-tree walk that parentAcrossShadow in utils/flatTree.ts already provides, and this file already imports from that module; please loop with parentAcrossShadow and delete the inline walk (suggestion inline).

[Automated review]

Comment on lines +284 to +299
function isInButton(node: Node): boolean {
let current: Node | null = node;
while (current != null) {
if (current.nodeType === 1 && (current as Element).localName === 'button') {
return true;
}
const parent: Node | null =
(current as Node & {assignedSlot?: Element | null}).assignedSlot ??
current.parentNode;
current =
parent != null && parent.nodeType === 11
? (parent as ShadowRoot).host
: parent;
}
return false;
}

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 walk is parentAcrossShadow from ./flatTree, which this file already imports from. Calling it keeps one flat-tree path:

function isInButton(node: Node): boolean {
  for (let at: Node | null = node; at != null; at = parentAcrossShadow(at)) {
    if (at instanceof Element && at.localName === 'button') {
      return true;
    }
  }
  return false;
}

@vjeux

vjeux commented Nov 2, 2026

Copy link
Copy Markdown
Contributor Author

Done at e0a5e25: isInButton loops with parentAcrossShadow from ./flatTree, and the inline walk is gone. The element test stays nodeType === 1 with localName rather than instanceof Element. The button, slotted-label and Chromium real-mouse cases pass as before.

@cixzhang

cixzhang commented Nov 2, 2026

Copy link
Copy Markdown
Contributor

/approve-spec e0a5e25

@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 for two small items; the spec:AST-094 FR41 amendment is owner-approved at this head, and the earlier isInButton note is fixed.

  • startsOnSelectableText calls caretPositionFromPoint(x, y) without shadowRoots, so in Chromium a mouse drag that starts on text inside an open shadow root in a pan is claimed and selects nothing; passing the open roots on the press target's root chain fixes it, and a host case with shadow-root text would cover it.
  • lockDragSelection adds its own document selectstart and selectionchange listeners; subscribing through subscribeInput keeps the gesture host on the one shared window listener that architecture:react-component-runtime INV13 describes.

[Automated review]

caretRangeFromPoint?: (x: number, y: number) => Range | null;
};
if (typeof queries.caretPositionFromPoint === 'function') {
const position = queries.caretPositionFromPoint(x, y);

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.

Without the shadowRoots option, the engine answers a point over text inside an open shadow root with the host's parent, so startsOnSelectableText says no and the pan claims the drag. In Chromium that drag then selects nothing, where the base selected it. Passing the open roots on the target's root chain fixes it:

const shadowRoots: ShadowRoot[] = [];
for (
  let root = target instanceof Node ? target.getRootNode() : null;
  root instanceof ShadowRoot;
  root = root.host.getRootNode()
) {
  shadowRoots.push(root);
}
const position = queries.caretPositionFromPoint(x, y, {shadowRoots});

Comment on lines +172 to +173
doc?.addEventListener('selectstart', refuse, true);
doc?.addEventListener('selectionchange', restore);

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 gesture host hears every other document-wide event through subscribeInput. Subscribing here too keeps one window listener per type:

const unsubscribes =
  view == null
    ? []
    : [
        subscribeInput(view, 'selectstart', refuse, {cancels: true}),
        subscribeInput(view, 'selectionchange', restore),
      ];

Then releaseDocument calls each unsubscribe instead of the two removeEventListener calls.

/** Undo `lockSelection` for a contact that ended; idempotent. */
private unlockSelection(id: number): void {
this.lockedBy.get(id)?.();
/**

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 comment restates the loop and the next method. A shorter version keeps only the reasons:

/**
 * Hold the page's selection for a mouse or pen contact that a recognizer
 * claims. A claim comes in any state: a waiting recognizer claims while
 * possible, and a swipe claims as ended. A tap claims only at its lift, so a
 * click keeps the selection it made. A finger keeps the touch-down lock alone.
 */

@vjeux

vjeux commented Nov 2, 2026

Copy link
Copy Markdown
Contributor Author

Both fixed at bec47e1. startsOnSelectableText passes the open shadow roots on the press target's root chain, and the root the target hosts, to caretPositionFromPoint; text directly under a root takes its host's style. The drag hold's selectstart and selectionchange listeners go through subscribeInput. A sweep of the whole selection-lock path also found that a selection inside an open shadow root read as collapsed, so a claimed drag elsewhere cleared it; the hold now keeps and restores the press's composed ranges, as live copies where it can. Host cases for shadow text and the listener routing, and Chromium cases with real mouse input for shadow text and a kept shadow selection, fail on e0a5e25 and pass now.

@cixzhang

cixzhang commented Nov 2, 2026

Copy link
Copy Markdown
Contributor

/approve-spec bec47e1

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

Approved from the review side: the spec:AST-094 FR41 amendment is owner-approved at this head, and the earlier shadow-root and shared-listener items are fixed. Before it lands, GestureHost.ts needs a merge of main, where trackpad pans now reach the arena as 'mouse' contacts with no pointerdown or pointerup.

  • Kept as is on that merge, lockClaimedDrags takes a selection hold for a trackpad pan that never ends, so guard it to contacts that had a press (for example if (!this.pressSelections.has(id)) continue;) and add a wheel case with a page selection.
  • Non-blocking: lockClaimedDrags' doc comment can drop the lines that restate its loop and releaseClaimedDrags.

[Automated review]

claimer: GestureRecognizer,
): void {
for (const id of ids) {
if (this.lockedBy.has(id)) {

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 main is merged in, its trackpad pans reach the arena as 'mouse' contacts with no pointerdown or pointerup, so this loop would take a hold for them that nothing ends (the page's selection goes, and every later selection is refused until the last detach). Holding only a contact that had a press keeps them out:

if (this.lockedBy.has(id) || !this.pressSelections.has(id)) {
  continue;
}

@vjeux

vjeux commented Nov 2, 2026

Copy link
Copy Markdown
Contributor Author

Rebased onto main at 98a0346 after #8676 merged; the one conflict, in GestureHost.ts, keeps both sides. One interaction needed a fix: the host feeds a wheel to the arena as a mouse contact, so the drag hold took a trackpad pan's claim for a mouse drag and cleared the page's selection. A wheel's contact now holds nothing, as a scroll keeps the selection; the wheel case for it failed on the rebased head and passes now.

@cixzhang

cixzhang commented Nov 2, 2026

Copy link
Copy Markdown
Contributor

/approve-spec 98a0346

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

Requesting changes for one defect: a pen drag that a pan claims after the press took a press lock (for example, one that starts in a row whose long press opens a menu) gets no drag hold, so the page's selection is cleared, against the approved spec:AST-094 FR41 sentence. In lockClaimedDrags, guarding on claimHolds instead of lockedBy, and keeping an existing press lock in lockedBy, keeps the selection in Chromium with the gesture and selection-lock tests still passing; please add a regression case for that pen path. The FR41 amendment is owner-approved at this head, and the trackpad guard works.

[Automated review]

claimer: GestureRecognizer,
): void {
for (const id of ids) {
if (this.lockedBy.has(id)) {

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 pen press already has a press lock here when a recognizer under it brings one (a row whose long press opens a menu), so a pan that claims the pen's drag later gets no drag hold, and the page's selection is cleared (reproduced in Chromium with pen input; a mouse keeps it). Guarding on the drag hold keeps both, since unlockSelection already releases each:

if (this.claimHolds.has(id)) {
  continue;
}

and below, if (!this.lockedBy.has(id)) { this.lockedBy.set(id, release); }.

@vjeux

vjeux commented Nov 2, 2026

Copy link
Copy Markdown
Contributor Author

Fixed at 18d1895. lockClaimedDrags guards on the contact's drag hold (claimHolds) and keeps an existing press lock in lockedBy; the contact's end releases both. A host case for the pen path, a pen drag on a pan inside a row whose long press opens a menu, keeps the page's selection; it fails on 98a0346 and passes now. A second case covers the other order, a group's claim at its own pointerdown before the press lock. In Chromium the press lock's inline user-select already keeps the press from collapsing the selection, so a real-input case passes before and after; the host case pins the hold itself.

@cixzhang

cixzhang commented Nov 2, 2026

Copy link
Copy Markdown
Contributor

/approve-spec 18d1895

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

Ready to approve: the spec:AST-094 FR41 amendment is owner-approved at this head, every behavior change follows it, and a pen drag that a pan claims after a press lock now keeps the page's selection (checked with real pen input in Chromium).

Two optional follow-ups:

  • Add 'claimHolds' to the "nothing left after the last detach" collection list in GestureHost.test.ts, so the leak test would catch a stale claim entry.
  • Trim lockClaimedDrags' doc comment to its reasons, and update the @input line of the selectionLock.ts header for the drag hold's inputs.

[Automated review]

'lockedBy',
'pressSelections',
'selectingContacts',
'dragHolds',

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: claimHolds is the one host collection missing from this list. Every path empties it today, but listing it lets this test catch a stale claim entry after the last detach:

    'dragHolds',
    'claimHolds',

vjeux added 11 commits November 2, 2026 15:42
…election and never clears the page's; a drag that starts on selectable text is not claimed (spec:AST-094 FR41)

FR41, as approved: a mouse or pen drag that a recognizer claims takes no
new text selection for that drag and never clears an existing selection; a
drag that starts on selectable text is not claimed.

- The host keeps the page's selection from a mouse or pen pointerdown,
  read before the arena admits the press. When a recognizer claims the
  contact, lockDragSelection refuses a new selection on the claiming
  elements and the document until a task after the lift (a cancel ends it
  at once), and puts the kept selection back over whatever the press's
  collapse or the drag made of it.
- At the pointerdown the host also reads whether the press lands on
  selectable text: a character of text whose element allows a selection,
  or a text field. Once such a contact moves, the arena's refusesClaim
  fails a recognizer that claims touches instead of letting it recognize,
  so the browser's selection runs. A press claimed at its own pointerdown,
  before it moves, and a tap's claim at the lift are unaffected.
- The last detach ends a lifted drag's hold at once, so nothing outlives
  the host; the nothing-left test lists the three new collections.
- FR41's text and Verification row state both facts; the gestures README
  and the host's header say what the host does.
…ny recognizer's claim

The drag selection hold covered only participants already recognizing
(began or changed), so a swipe, which recognizes as ended while the button
is still down, and a recognizer waiting on another's failure, which claims
while still possible, left a mouse or pen drag free to replace the page's
selection (spec:AST-094 FR41). The arena now names the recognizer that
claimed with each claim, and reports claims it gives back to the browser:
the host holds the claiming recognizer's element from its claim whatever
its state, and lets the hold go when the claim is given back. A claim at
the contact's own lift (a tap's) still holds nothing, so a click keeps its
selection. A swipe case and a requireToFail case, with a give-back, failed
before.
…edByConsumer keeps its doc comment

No released component runs the gesture host yet, so the changeset is a
[chore] Internal entry, as the other gesture changesets are. The doc
comment of isVetoedByConsumer, which had slid above isRefusedByBrowser's
own, is back on its method.
…lock applies too

For a pen, a recognizer that claims at its own pointerdown (an action
group's zero-delay press) takes its drag hold inside that pointerdown;
the press lock that follows, wherever a participant or the consumer brings
one (a row whose long press opens a menu, say), replaced the hold in the
contact's lock entry. At the lift only the press lock was released, so the
hold's user-select and its document selectstart and selectionchange
listeners stayed until the last detach, and a later mouse drag over a
paragraph selected nothing (spec:AST-094 FR41). The lift now releases both;
the hold still keeps its last task. The group-press case now runs inside
such a row and checks, for the pen, that the page selects again a task
after the lift; in Chromium, a real pen press on such a group followed by a
real mouse drag over a paragraph selects it. Both failed before.
…hort sentences, without record citations

Main's new no-comment-provenance rule refuses a new record or claim
citation in source comments, and source comments use short, one-topic
sentences. The comments this change added to the gesture host, the arena
and the selection lock cited the record they follow, and several ran long.
They now keep only the reason, in short sentences, and the files stay
linked from the record. Comments only.
…abel is the pan's

startsOnSelectableText counted a native button's label as selectable text,
because its computed user-select is auto, but the engine starts no
selection from a drag inside a button. So with a mouse or a pen, a drag
that started on a button's label inside a pan surface neither panned nor
selected, where the base panned (spec:AST-094 FR41). Text inside a native
button is no longer selectable text, across slots and shadow roots; a text
field still is. A host case, a slotted-label host case and a Chromium case
with real mouse input, in which such a drag pans, failed before.
Source comments use short, one-topic sentences. The host's header loses
the drag-selection bullet, which the drag hold's own doc and the README
carry. The arena option comment that restated the options below it is
gone. The selecting-contacts, given-back-claims and replaced-hold comments,
the selection lock's output line and its selectable-text doc, and two test
comments are split into short sentences. Comments only.
isInButton re-implemented the flat-tree walk that parentAcrossShadow in
utils/flatTree.ts provides, and this file already imports from that
module. It now loops with parentAcrossShadow, so the selection lock keeps
one flat-tree path. No behavior changes: the button and slotted-label
cases pass as before.
…he page through the shared stream

Text inside an open shadow root read as not selectable, because the caret
query answers at the host unless it gets the root. So a pan claimed a
mouse drag that started on it, and the drag selected nothing
(spec:AST-094 FR41). The query now gets the open roots on the press
target's root chain and the root the target hosts; text directly under a
root takes its host's style. A selection inside an open shadow root also
read as collapsed, so a claimed drag elsewhere cleared it. The hold now
keeps the press's composed ranges, reaching into each open root a
boundary lands on, and puts them back with setBaseAndExtent; engines
without getComposedRanges keep the old path. A kept range whose
boundaries share a root stays live, so it follows DOM changes, and the
hold puts back only boundaries that still exist. Its release stops its
listeners whatever the put-back does. The focused-field guard looks
through open roots. The hold's document-wide selectstart and
selectionchange listeners go through the shared window listener
(architecture:react-component-runtime INV13). Host cases for shadow text,
in a paragraph and directly under the root, and for the listener routing,
and Chromium cases with real mouse input for shadow text and a kept
shadow selection, failed before. A Chromium case in which a render
shortens the kept text during the drag guards the live copy.
…as a scroll does

On main, a pan follows a trackpad's scroll through a wheel contact the
host feeds to the arena as a mouse contact. The drag hold took that
contact for a mouse drag: it had no press selection to keep, so it
cleared the page's selection when a wheel pan claimed. A wheel's contact
now holds nothing, as a scroll keeps the selection. The wheel case that
keeps the page's selection when a wheel pan claims failed before.
… page's selection

A pen press takes a press lock where a recognizer's hold does something,
such as a row whose long press opens a menu. When a pan then claimed the
pen's drag, lockClaimedDrags found the press lock in lockedBy and took no
drag hold, so the page's selection was not kept (spec:AST-094 FR41). The
host now guards on the contact's drag hold instead, and keeps an existing
press lock in lockedBy; the contact's end releases both. A host case for
that pen path failed before. A second case covers the other order, a
group's claim at its own pointerdown before the press lock.
@vjeux

vjeux commented Nov 2, 2026

Copy link
Copy Markdown
Contributor Author

Rebased onto main at 9bca8e8. The one conflict was the spec:AST-094 Verification table, where main's paging change edits the FR36–FR40 row next to this change's FR41 row; both are kept. The code and the FR41 lines are unchanged.

@cixzhang

cixzhang commented Nov 2, 2026

Copy link
Copy Markdown
Contributor

/approve-spec 9bca8e8

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

Ready to approve: the spec:AST-094 FR41 amendment is owner-approved at this head, every behavior change follows it, and its Verification cases pass with real mouse input (they fail on main).

Optional follow-ups:

  • lockClaimedDrags styles every claiming element, including one attached with manageStyle: false, which AttachOptions.manageStyle says keeps its own styles; leave that element's styles alone as lockSelection does, or document the exception (no caller passes false today).
  • Add 'claimHolds' to the "nothing left after the last detach" collection list in GestureHost.test.ts.
  • Trim lockClaimedDrags' doc comment to its reasons, and update the @input line of the selectionLock.ts header for the press point and the page's selection.

[Automated review]

) {
continue;
}
const elements = new Set<Element>();

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 set takes every claiming element, including one attached with manageStyle: false, and lockDragSelection then writes user-select and -webkit-touch-callout on it. AttachOptions.manageStyle says such an element keeps its own styles, and lockSelection skips it through managed. No caller passes false today. Either leave an unmanaged element's styles alone here (its selectstart refusal and the document hold can stay), or say in the option's doc that a claimed drag still styles it.

@@ -3,11 +3,12 @@
/**
* @file selectionLock.ts
* @input The elements a held touch contact presses, and the touch's target

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 file header): @input still names only a held touch's elements and target. The module now also reads a press point and the page's selection, for example:

 * @input The elements a held or claimed contact presses, the press point and
 *   target, and the page's selection

This branch was successfully deployed

1 active deployment
Preview — 9bca8e85 Deployed Nov 2, 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