Skip to content
This repository was archived by the owner on Feb 25, 2025. It is now read-only.

Commit f6bde7e

Browse files
authored
[web:semantics] fix double click due to long-press (#54697)
Remember the timestamp of _all_ `pointerup` events, not just those that were flushed. Clicks should be deduplicated after a `pointerup` even when not debouncing anything. This is because when not debouncing the engine already forwards all the pointer events to the framework, and sending click events on top only causes double-clicks. Fixes flutter/flutter#147050
1 parent 0eb33af commit f6bde7e

File tree

2 files changed

+82
-11
lines changed

2 files changed

+82
-11
lines changed

lib/web_ui/lib/src/engine/pointer_binding.dart

Lines changed: 18 additions & 11 deletions
Original file line numberDiff line numberDiff line change
@@ -205,12 +205,13 @@ class ClickDebouncer {
205205
@visibleForTesting
206206
DebounceState? get debugState => _state;
207207

208-
// The timestamp of the last "pointerup" DOM event that was flushed.
208+
// The timestamp of the last "pointerup" DOM event that was sent to the
209+
// framework.
209210
//
210211
// Not to be confused with the time when it was flushed. The two may be far
211212
// apart because the flushing can happen after a delay due to timer, or events
212213
// that happen after the said "pointerup".
213-
Duration? _lastFlushedPointerUpTimeStamp;
214+
Duration? _lastSentPointerUpTimeStamp;
214215

215216
/// Returns true if the debouncer has a non-empty queue of pointer events that
216217
/// were withheld from the framework.
@@ -244,6 +245,14 @@ class ClickDebouncer {
244245
} else if (event.type == 'pointerdown') {
245246
_startDebouncing(event, data);
246247
} else {
248+
if (event.type == 'pointerup') {
249+
// Record the last pointerup event even if not debouncing. This is
250+
// because the sequence of pointerdown-pointerup could indicate a
251+
// long-press, and the debounce timer is not long enough to capture it.
252+
// If a "click" is observed after a long-press it should be
253+
// discarded.
254+
_lastSentPointerUpTimeStamp = _BaseAdapter._eventTimeStampToDuration(event.timeStamp!);
255+
}
247256
_sendToFramework(event, data);
248257
}
249258
}
@@ -295,6 +304,7 @@ class ClickDebouncer {
295304

296305
EnginePlatformDispatcher.instance.invokeOnSemanticsAction(
297306
semanticsNodeId, ui.SemanticsAction.tap, null);
307+
reset();
298308
}
299309

300310
void _startDebouncing(DomEvent event, List<ui.PointerData> data) {
@@ -351,10 +361,7 @@ class ClickDebouncer {
351361
// It's only interesting to debounce clicks when both `pointerdown` and
352362
// `pointerup` land on the same element.
353363
if (event.type == 'pointerup') {
354-
// TODO(yjbanov): this is a bit mouthful, but see https://github.com/dart-lang/sdk/issues/53070
355-
final DomEventTarget? eventTarget = event.target;
356-
final DomElement stateTarget = state.target;
357-
final bool targetChanged = eventTarget != stateTarget;
364+
final bool targetChanged = event.target != state.target;
358365
if (targetChanged) {
359366
_flush();
360367
}
@@ -372,15 +379,15 @@ class ClickDebouncer {
372379
// already flushed to the framework, the click event is dropped to avoid
373380
// double click.
374381
bool _shouldSendClickEventToFramework(DomEvent click) {
375-
final Duration? lastFlushedPointerUpTimeStamp = _lastFlushedPointerUpTimeStamp;
382+
final Duration? lastSentPointerUpTimeStamp = _lastSentPointerUpTimeStamp;
376383

377-
if (lastFlushedPointerUpTimeStamp == null) {
384+
if (lastSentPointerUpTimeStamp == null) {
378385
// We haven't seen a pointerup. It's standalone click event. Let it through.
379386
return true;
380387
}
381388

382389
final Duration clickTimeStamp = _BaseAdapter._eventTimeStampToDuration(click.timeStamp!);
383-
final Duration delta = clickTimeStamp - lastFlushedPointerUpTimeStamp;
390+
final Duration delta = clickTimeStamp - lastSentPointerUpTimeStamp;
384391
return delta >= const Duration(milliseconds: 50);
385392
}
386393

@@ -393,7 +400,7 @@ class ClickDebouncer {
393400
final List<ui.PointerData> aggregateData = <ui.PointerData>[];
394401
for (final QueuedEvent queuedEvent in state.queue) {
395402
if (queuedEvent.event.type == 'pointerup') {
396-
_lastFlushedPointerUpTimeStamp = queuedEvent.timeStamp;
403+
_lastSentPointerUpTimeStamp = queuedEvent.timeStamp;
397404
}
398405
aggregateData.addAll(queuedEvent.data);
399406
}
@@ -419,7 +426,7 @@ class ClickDebouncer {
419426
void reset() {
420427
_state?.timer.cancel();
421428
_state = null;
422-
_lastFlushedPointerUpTimeStamp = null;
429+
_lastSentPointerUpTimeStamp = null;
423430
}
424431
}
425432

lib/web_ui/test/engine/pointer_binding_test.dart

Lines changed: 64 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -4,6 +4,7 @@
44

55
import 'dart:js_util' as js_util;
66

7+
import 'package:meta/meta.dart';
78
import 'package:test/bootstrap/browser.dart';
89
import 'package:test/test.dart';
910
import 'package:ui/src/engine.dart';
@@ -2757,6 +2758,7 @@ void _testClickDebouncer({required PointerBinding Function() getBinding}) {
27572758
String description,
27582759
Future<void> Function() body, {
27592760
Object? skip,
2761+
@doNotSubmit bool solo = false,
27602762
}) {
27612763
test(
27622764
description,
@@ -2768,6 +2770,7 @@ void _testClickDebouncer({required PointerBinding Function() getBinding}) {
27682770
EngineSemantics.instance.semanticsEnabled = false;
27692771
},
27702772
skip: skip,
2773+
solo: solo, // ignore: invalid_use_of_do_not_submit_member
27712774
);
27722775
}
27732776

@@ -3053,6 +3056,67 @@ void _testClickDebouncer({required PointerBinding Function() getBinding}) {
30533056
// TODO(yjbanov): https://github.com/flutter/flutter/issues/142991.
30543057
}, skip: ui_web.browser.operatingSystem == ui_web.OperatingSystem.windows);
30553058

3059+
// Regression test for https://github.com/flutter/flutter/issues/147050
3060+
//
3061+
// This test emulates a long-press. Start with a "pointerdown" followed by no
3062+
// activity long enough that the debounce timer expires and the state of the
3063+
// ClickDebouncer is reset back to idle. Then a "pointerup" arrives seemingly
3064+
// standalone. Since no gesture is being debounced, the debouncer simply
3065+
// forwards it to the framework. However, "pointerup" will be immediately
3066+
// followed by a "click". Since we sent the "pointerdown" and "pointerup" to
3067+
// the framework already, the framework registered a tap. Forwarding the
3068+
// "click" would lead to a double-tap. This was the bug.
3069+
testWithSemantics('Dedupes click if pointer up happened recently without debouncing', () async {
3070+
expect(EnginePlatformDispatcher.instance.semanticsEnabled, true);
3071+
expect(PointerBinding.clickDebouncer.isDebouncing, false);
3072+
3073+
final DomElement testElement = createDomElement('flt-semantics');
3074+
testElement.setAttribute('flt-tappable', '');
3075+
view.dom.semanticsHost.appendChild(testElement);
3076+
3077+
// Begin a long-press with a "pointerdown".
3078+
testElement.dispatchEvent(context.primaryDown());
3079+
3080+
// Expire the timer causing the debouncer to reset itself.
3081+
await Future<void>.delayed(const Duration(milliseconds: 250));
3082+
expect(
3083+
reason: '"pointerdown" should be flushed when the timer expires.',
3084+
pointerPackets,
3085+
<ui.PointerChange>[ui.PointerChange.add, ui.PointerChange.down],
3086+
);
3087+
pointerPackets.clear();
3088+
3089+
// Send a "pointerup" while the debouncer is not debouncing anything.
3090+
testElement.dispatchEvent(context.primaryUp());
3091+
3092+
// A standalone "pointerup" should not start debouncing anything.
3093+
expect(PointerBinding.clickDebouncer.isDebouncing, isFalse);
3094+
expect(
3095+
reason: 'The "pointerup" should be forwarded to the framework immediately',
3096+
pointerPackets,
3097+
<ui.PointerChange>[ui.PointerChange.up],
3098+
);
3099+
3100+
// Use a delay that's short enough for the click to be deduped.
3101+
await Future<void>.delayed(const Duration(milliseconds: 10));
3102+
3103+
final DomEvent click = createDomMouseEvent(
3104+
'click',
3105+
<Object?, Object?>{
3106+
'clientX': testElement.getBoundingClientRect().x,
3107+
'clientY': testElement.getBoundingClientRect().y,
3108+
}
3109+
);
3110+
PointerBinding.clickDebouncer.onClick(click, 42, true);
3111+
3112+
expect(
3113+
reason: 'Because the DOM click event was deduped.',
3114+
semanticsActions,
3115+
isEmpty,
3116+
);
3117+
// TODO(yjbanov): https://github.com/flutter/flutter/issues/142991.
3118+
}, skip: ui_web.browser.operatingSystem == ui_web.OperatingSystem.windows);
3119+
30563120
testWithSemantics('Forwards click if enough time passed after the last flushed pointerup', () async {
30573121
expect(EnginePlatformDispatcher.instance.semanticsEnabled, true);
30583122
expect(PointerBinding.clickDebouncer.isDebouncing, false);

0 commit comments

Comments
 (0)