Skip to content

[web] Don't debounce physical mouse clicks in ClickDebouncer - #192641

Closed
kevmoo wants to merge 2 commits into
flutter:masterfrom
kevmoo:issue-188859
Closed

kevmoo wants to merge 2 commits into
flutter:masterfrom
kevmoo:issue-188859

Conversation

@kevmoo

@kevmoo kevmoo commented Sep 11, 2026 •

Copy link
Copy Markdown
Contributor

Fixes #188859

Problem

When semantics is enabled, Tappable marks every tappable node with flt-tappable. ClickDebouncer then withholds pointer events that land on such a node and, when the DOM click arrives, synthesizes a generic SemanticsAction.tap in their place and drops the queued pointer events.

The framework turns that action into a tap at the center of the widget with PointerDeviceKind.unknown. So with ensureSemantics() active, a GestureDetector under a physical mouse cursor gets Tap*Details with the wrong position and the wrong device kind.

Fix

Forward physical mouse input straight to the framework rather than debouncing it. The DOM click that follows is then deduped by the existing _shouldSendClickEventToFramework check, because the forwarded pointerup sets _lastSentPointerUpTimeStamp. No double-fire.

This also removes the 200ms press latency that mouse clicks on a tappable node currently incur.

⚠️ Known issue: this also captures screen-reader activation on Chrome

Putting this up front because it is the thing that needs a decision before this can land. I went looking for what pointerType assistive technologies actually report. The two engines differ, and Blink is the problem:

Blink / Chrome — event_dispatcher.cc dispatches a full pointerdown → mousedown → pointerup → mouseup → click sequence for SimulatedClickCreationScope::kFromAccessibility, and simulated_event_util.cc hardcodes the pointer type:

initializer->setPointerId(PointerEventFactory::kReservedNonPointerId);
if (creation_scope == SimulatedClickCreationScope::kFromAccessibility) {
  initializer->setPointerId(PointerEventFactory::kMouseId);
  initializer->setPointerType(pointer_type_names::kMouse);
  initializer->setIsPrimary(true);
}

Coordinates are set to the element's border-box center (not 0,0), and isTrusted is true. So on Chrome, AT activation is indistinguishable from a physical mouse click by pointerType, pointerId, isTrusted, coordinates, or detail.

Incidentally this also identifies the sequence described in #174046 as the Blink path, not Safari.

WebKit / Safari — unaffected. AccessibilityObject::press() goes through in-engine dispatchSimulatedClick, which emits mousedown/mouseup/click and no pointer events at all; the lone click PointerEvent is spec-compliant with pointerType = "" and pointerId = -1. _maybeStartDebouncing is never reached.

What that means in practice

On Chrome, a screen-reader activation now takes the new early return, so the subsequent click is deduped and no SemanticsAction.tap is sent. Activation instead becomes a real down/up tap at the widget's centroid:

  • Node with a hit-testable gesture recognizer (GestureDetector, InkWell, most buttons) — still activates, with better fidelity than before.
  • Semantics(onTap:) node with no recognizer under the centroid — silently stops responding to screen-reader activation.

That is the same trade described below, but extended to AT users, which raises the stakes a lot. I don't think I should pick the resolution unilaterally. Options as I see them:

  1. Land as-is, on the argument that a centroid tap through a real recognizer beats a positionless SemanticsAction.tap, and recognizer-less Semantics(onTap:) is rare.
  2. Keep debouncing and instead flush the queued pointer events when the click arrives — the isListening == false branch already does this. Preserves AT behavior exactly; gives up the 200ms latency win.
  3. Something smarter I haven't thought of.

/cc @yjbanov @mdebbar — you two own this code path; I'd rather have your call than guess.

Trade-off (independent of the above)

Stating plainly, since it is also documented at the call site: mouse input can no longer reach SemanticsAction.tap by any route. The engine cannot know whether the framework consumed the pointer gesture, so there is no version of this fix that keeps both.

Only pointerType == 'mouse' is exempted. pen and touch lose the same detail for the same reason, but touch is the case the debouncer was written for and both were in scope when the 200ms window was tuned, so widening the exemption felt like a bigger swing than this fix warrants.

Tests

  • Existing _testClickDebouncer cases migrated from context.primaryDown()/primaryUp() to local touchDown()/touchUp() helpers, since mouse no longer debounces.
  • New regression test: mouse pointerdown/pointerup on an flt-tappable node reach the framework as pointer packets, and the following DOM click does not additionally fire a SemanticsAction.tap.

Possibly also fixed

From the code path these look like the same root cause, but I have not reproduced them:

This fixes issue flutter#188859 where ensureSemantics() breaks Tap*Details from GestureDetector on mouse click on the web. The ClickDebouncer was capturing physical mouse pointer events and synthesizing SemanticsAction.tap. The debouncer should only handle gestures on semantics tree nodes from screen readers (which dispatch synthetic pointer Type 'touch' / undefined, not 'mouse'). Now it skips debouncing when pointerType == 'mouse'.
@flutter-dashboard flutter-dashboard Bot added the CICD Run CI/CD label Sep 11, 2026
@github-actions github-actions Bot added engine flutter/engine related. See also e: labels. platform-web Web applications specifically team-web Owned by Web platform team labels Sep 11, 2026

@gemini-code-assist gemini-code-assist 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.

Code Review

This pull request introduces touchDown and touchUp helper methods in pointer_binding_test.dart to simulate touch events. It refactors existing tests to use these touch helpers instead of mouse-simulating primary events, and adds a new test specifically verifying that mouse events are forwarded to the framework when semantics is enabled. There are no review comments, and I have no feedback to provide.

When semantics is enabled, `ClickDebouncer` withholds pointer events that
land on an `flt-tappable` node and, on the subsequent DOM "click",
synthesizes a generic `SemanticsAction.tap` in their place. The framework
turns that into a tap at the center of the widget with
`PointerDeviceKind.unknown`, so a `GestureDetector` under a physical mouse
cursor reports `Tap*Details` with the wrong position and kind.

Forward physical mouse input straight to the framework instead. The
subsequent "click" is then deduped by `_shouldSendClickEventToFramework`,
because the forwarded `pointerup` sets `_lastSentPointerUpTimeStamp`.

Trade-off, documented at the call site: mouse input can no longer reach
`SemanticsAction.tap` by any route, so a semantics node exposing a tap
action with no hit-testable gesture recognizer under the cursor stops
responding to mouse clicks. Only `mouse` is exempted; `pen` and `touch`
still debounce.

Also removes a 200ms delay on every mouse press over a tappable node.

Tests: the existing `_testClickDebouncer` cases were written against
`context.primaryDown()`, which is `pointerType: 'mouse'`, so they now
exercise touch instead via local `touchDown`/`touchUp` helpers. Adds a
regression test that mouse input reaches the framework as pointer packets
and that the following "click" does not also fire a semantics tap.

Fixes flutter#188859
@kevmoo kevmoo changed the title Fix web Semantics ClickDebouncer dropping physical mouse events [web] Don't debounce physical mouse clicks in ClickDebouncer Sep 11, 2026
@kevmoo
kevmoo marked this pull request as draft September 11, 2026 23:00
@kevmoo

kevmoo commented Sep 11, 2026

Copy link
Copy Markdown
Contributor Author

Moving this to draft — I found a problem with my own approach and I don't think it should land as written. Flagging it here because the /cc I added by editing the description above wouldn't have notified anyone.

@yjbanov @mdebbar — you own this code path, and I'd value your read on the design question rather than guessing.

Short version: filtering the debouncer on pointerType == 'mouse' also captures every screen-reader activation on Chrome.

simulated_event_util.cc hardcodes it for the accessibility scope:

initializer->setPointerId(PointerEventFactory::kReservedNonPointerId);
if (creation_scope == SimulatedClickCreationScope::kFromAccessibility) {
  initializer->setPointerId(PointerEventFactory::kMouseId);
  initializer->setPointerType(pointer_type_names::kMouse);
  initializer->setIsPrimary(true);
}

It deliberately overrides the reserved non-pointer id with the real mouse id, sets coordinates to the element's border-box center, and isTrusted is true. On Chrome there is no DOM-level signal — pointerType, pointerId, isTrusted, coordinates, detail — that separates AT activation from a physical mouse click.

Safari is unaffected, for a different reason: AccessibilityObject::press() goes through in-engine dispatchSimulatedClick, which emits mousedown/mouseup/click and no pointer events at all; the lone click PointerEvent is spec-compliant with pointerType = "" and pointerId = -1. So _maybeStartDebouncing is never even reached there.

Incidentally, this identifies the sequence in #174046 as the Blink path — that observation was on Chrome, not Safari.

Consequence of this PR on Chrome: a screen-reader activation takes the new early return, the following click gets deduped by _shouldSendClickEventToFramework, and no SemanticsAction.tap is sent. Activation degrades to a real tap at the widget centroid — fine where a hit-testable recognizer sits there, but a Semantics(onTap:) node without one silently stops responding.

Where I got stuck: I initially thought the safe fallback was to keep debouncing and just flush the queued pointer events on click instead of synthesizing the tap. That doesn't help — it still sends no SemanticsAction.tap, so the recognizer-less case breaks identically, and it gives up the latency win too.

The underlying tension seems to be that the engine can't know whether the framework's gesture arena consumed the forwarded pointer events, so any engine-only fix has to pick between coordinate fidelity and the semantics tap. Resolving it properly might need the framework to tell the engine whether a node has a real hit-testable recognizer versus a semantics-only tap action — which is well beyond this PR.

Is that trade acceptable (is recognizer-less Semantics(onTap:) rare enough in practice?), or is the protocol change the actual path here? Happy to take it in whatever direction you prefer, or to hand it off.

Context on the bug itself: #188859, and #146063 / #177314 look like the same root cause.

@flutter-dashboard

Copy link
Copy Markdown

This pull request has been changed to a draft. The currently pending flutter-gold status will not be able to resolve until a new commit is pushed or the change is marked ready for review again.

For more guidance, visit Writing a golden file test for package:flutter.

Reviewers: Read the Tree Hygiene page and make sure this patch meets those guidelines before LGTMing.

/// Physical mouse input is exempt from all of the above and is forwarded to
/// the framework directly, because a synthesized `SemanticsAction.tap` loses
/// the pointer position and device kind that a mouse gesture carries. See
/// [_maybeStartDebouncing].

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.

Restoring the pointer position part makes sense. How do we know this does not reintroduce the double-click issue, when SemanticsAction.tap and pointer down + up are both forwarded to the tap handler? Can we instead find a way to attach the pointer position to SemanticsAction.tap?

@kevmoo

kevmoo commented Oct 8, 2026

Copy link
Copy Markdown
Contributor Author

Closing unmerged — the pointerType filter also captures Chrome screen-reader activation (details and the alternative design direction are in #188859 (comment)). Branch kept for reference.

@kevmoo kevmoo closed this Oct 8, 2026
@kevmoo
kevmoo deleted the issue-188859 branch October 13, 2026 05:22
Sign up for free to join this conversation on GitHub. Already have an account? Sign in to comment

Labels

CICD Run CI/CD engine flutter/engine related. See also e: labels. platform-web Web applications specifically team-web Owned by Web platform team

Projects

None yet

2 participants