Repository navigation
fix(gestures): a mouse or pen drag a gesture claims keeps the page's text selection (spec:AST-094 FR41) - #8665
Conversation
|
The latest updates on your projects. Learn more about Vercel for GitHub.
|
|
AI review status for this pull request.
|
There was a problem hiding this comment.
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
touchmovelistener 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]
| const needsListener = | ||
| this.isCoarse && | ||
| (registration.recognizers.some( | ||
| recognizer => recognizer.isEnabled && claimsWhileMoving(recognizer), | ||
| ) || | ||
| (registration.consumer?.keepsTouch != null && | ||
| registration.consumer.isEnabled?.() !== false)); |
There was a problem hiding this comment.
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.
| export function locksSelectionFor( | ||
| pointerType: string, | ||
| view: Window | null | undefined, | ||
| ): boolean { | ||
| return pointerType !== 'mouse' && isCoarsePointer(view); | ||
| } |
There was a problem hiding this comment.
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.
| | `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 | |
There was a problem hiding this comment.
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.
There was a problem hiding this comment.
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
isPrimaryClickto a pen as the README says (GestureHost.ts:1132,:2198), or correct the README.
[Automated review]
| (registration.consumer?.keepsTouch != null && | ||
| registration.consumer.isEnabled?.() !== false); |
There was a problem hiding this comment.
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); |
There was a problem hiding this comment.
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.
| | `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 | |
There was a problem hiding this comment.
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)) { |
There was a problem hiding this comment.
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.
…ment-wide; a fine-pointer finger's action-group press ends) into the context request
…o the FR43 harness helper
|
/approve-spec 9d937ed |
There was a problem hiding this comment.
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]
| 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; | ||
| } |
There was a problem hiding this comment.
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;
}|
Done at e0a5e25: |
|
/approve-spec e0a5e25 |
There was a problem hiding this comment.
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.
startsOnSelectableTextcallscaretPositionFromPoint(x, y)withoutshadowRoots, 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.lockDragSelectionadds its own documentselectstartandselectionchangelisteners; subscribing throughsubscribeInputkeeps the gesture host on the one shared window listener thatarchitecture:react-component-runtimeINV13 describes.
[Automated review]
| caretRangeFromPoint?: (x: number, y: number) => Range | null; | ||
| }; | ||
| if (typeof queries.caretPositionFromPoint === 'function') { | ||
| const position = queries.caretPositionFromPoint(x, y); |
There was a problem hiding this comment.
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});| doc?.addEventListener('selectstart', refuse, true); | ||
| doc?.addEventListener('selectionchange', restore); |
There was a problem hiding this comment.
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)?.(); | ||
| /** |
There was a problem hiding this comment.
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.
*/|
Both fixed at bec47e1. |
|
/approve-spec bec47e1 |
There was a problem hiding this comment.
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,
lockClaimedDragstakes a selection hold for a trackpad pan that never ends, so guard it to contacts that had a press (for exampleif (!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 andreleaseClaimedDrags.
[Automated review]
| claimer: GestureRecognizer, | ||
| ): void { | ||
| for (const id of ids) { | ||
| if (this.lockedBy.has(id)) { |
There was a problem hiding this comment.
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;
}|
Rebased onto main at 98a0346 after #8676 merged; the one conflict, in |
|
/approve-spec 98a0346 |
There was a problem hiding this comment.
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)) { |
There was a problem hiding this comment.
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); }.
|
Fixed at 18d1895. |
|
/approve-spec 18d1895 |
There was a problem hiding this comment.
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 inGestureHost.test.ts, so the leak test would catch a stale claim entry. - Trim
lockClaimedDrags' doc comment to its reasons, and update the@inputline of theselectionLock.tsheader for the drag hold's inputs.
[Automated review]
| 'lockedBy', | ||
| 'pressSelections', | ||
| 'selectingContacts', | ||
| 'dragHolds', |
There was a problem hiding this comment.
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',…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.
|
Rebased onto main at 9bca8e8. The one conflict was the |
|
/approve-spec 9bca8e8 |
There was a problem hiding this comment.
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:
lockClaimedDragsstyles every claiming element, including one attached withmanageStyle: false, whichAttachOptions.manageStylesays keeps its own styles; leave that element's styles alone aslockSelectiondoes, or document the exception (no caller passesfalsetoday).- Add
'claimHolds'to the "nothing left after the last detach" collection list inGestureHost.test.ts. - Trim
lockClaimedDrags' doc comment to its reasons, and update the@inputline of theselectionLock.tsheader for the press point and the page's selection.
[Automated review]
| ) { | ||
| continue; | ||
| } | ||
| const elements = new Set<Element>(); |
There was a problem hiding this comment.
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 | |||
There was a problem hiding this comment.
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
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-094FR41 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-094FR41, FR21, FR24, DEC-9;architecture:react-component-runtimeINV13;architecture:knowledge-contractsINV29.Smallest restoration
pointerdownreads the selection and whether the press lands on selectable text (a native button's label is not).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.refusesClaimfails a claim on a moved mouse or pen press over selectable text.Evidence
Scope
Testing
Local gates at 9bca8e8, on main eadd175 (11 commits; 9 files):
no-comment-provenance), Prettier, the private-attribute baseline andcheck:*(check:i18n-runtimeincluded): 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.CI=1,LP_NUM_THREADS=4, one worker), ten runs: 610 passed and 110 skipped (the Chromium-only cases), none failed.pnpm test:ui: 714 files, 17,800 tests passed.vitest --project node: 842 files passed; fix(cli): a crashed run's release install is recognized by the Node it ran on, whatever its name #8520's tworelease/cache.test.mjscases fail, as on main, and onecomponent-size.test.mjscase timed out under the full suite's load (8 of 8 alone).CI=1, one worker) 520 passed; WebKit at 3 CPUs, 520 at one worker and 520 at 16 (not a required profile); Chromium pinned to 2 cores, 630 at four workers, and at one worker 808 passed and 2 failed, in the animator's retarget case that fix(core): a retarget after a late first frame continues from the screen #9401 fixes (20 of 20 side by side on main and here).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.onPointerCancelmeets #8309 in one line; whichever merges second keeps both.