Skip to content

Commit a2d2f16

Browse files
authored
[url_launcher][web] Prevent browser from navigating when followLink isn't called (#8675)
When a DOM click event is received, we need to make a decision before the end of the event loop whether to allow the browser to navigate or not. At the end of the event loop, the browser will perform the navigation if we don't prevent it. The problem occurs when the `followLink` signal arrives AFTER the event loop (this can happen in semantics mode when the web engine uses a debouncer to queue events and send them to the framework after some delay). This leads to a situation where we can't make a definitive decision by the end of the event loop, so this PR does the following: 1. [best case] If the `followLink` signal is received before the end of the event loop, we let the browser do the navigation for a full web link experience. 2. [meh case] If no `followLink` signal is received before the end of the event loop, we PREVENT the browser from navigating. But we keep waiting for the `followLink` signal. If the signal arrives a bit later, we fallback to a programmatic navigation through the `launchUrl` API. Fixes flutter/flutter#162927 Fixes flutter/flutter#162408
1 parent 5501c5e commit a2d2f16

File tree

4 files changed

+159
-19
lines changed

4 files changed

+159
-19
lines changed

packages/url_launcher/url_launcher_web/CHANGELOG.md

Lines changed: 4 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -1,3 +1,7 @@
1+
## 2.4.1
2+
3+
* Fixes a bug that triggers Links when they are not supposed to.
4+
15
## 2.4.0
26

37
* Enhances handling of out-of-order events.

packages/url_launcher/url_launcher_web/example/integration_test/link_widget_test.dart

Lines changed: 108 additions & 6 deletions
Original file line numberDiff line numberDiff line change
@@ -445,25 +445,23 @@ void main() {
445445
await followLinkCallback!();
446446
await Future<void>.delayed(const Duration(seconds: 1));
447447
final html.Event event1 = _simulateClick(anchor);
448+
await Future<void>.delayed(const Duration(seconds: 1));
448449

449450
// The link shouldn't have been triggered.
450451
expect(pushedRouteNames, isEmpty);
451452
expect(testPlugin.launches, isEmpty);
452-
expect(event1.defaultPrevented, isFalse);
453-
454-
await Future<void>.delayed(const Duration(seconds: 1));
453+
expect(event1.defaultPrevented, isTrue);
455454

456455
// Signals with large delay (in reverse order).
457456
final html.Event event2 = _simulateClick(anchor);
458457
await Future<void>.delayed(const Duration(seconds: 1));
459458
await followLinkCallback!();
459+
await Future<void>.delayed(const Duration(seconds: 1));
460460

461461
// The link shouldn't have been triggered.
462462
expect(pushedRouteNames, isEmpty);
463463
expect(testPlugin.launches, isEmpty);
464-
expect(event2.defaultPrevented, isFalse);
465-
466-
await Future<void>.delayed(const Duration(seconds: 1));
464+
expect(event2.defaultPrevented, isTrue);
467465

468466
// A small delay is okay.
469467
await followLinkCallback!();
@@ -473,6 +471,7 @@ void main() {
473471
// The link should've been triggered now.
474472
expect(pushedRouteNames, <String>['/foobar']);
475473
expect(testPlugin.launches, isEmpty);
474+
// (default is prevented here because the link is internal)
476475
expect(event3.defaultPrevented, isTrue);
477476
});
478477

@@ -972,6 +971,109 @@ void main() {
972971
expect(event.defaultPrevented, isTrue);
973972
});
974973

974+
testWidgets('handles debounced clicks on semantic link',
975+
(WidgetTester tester) async {
976+
final Uri uri = Uri.parse('https://flutter.dev');
977+
FollowLink? followLinkCallback;
978+
979+
await tester.pumpWidget(MaterialApp(
980+
home: WebLinkDelegate(
981+
semanticsIdentifier: 'test-link-71',
982+
TestLinkInfo(
983+
uri: uri,
984+
target: LinkTarget.blank,
985+
builder: (BuildContext context, FollowLink? followLink) {
986+
followLinkCallback = followLink;
987+
return GestureDetector(
988+
onTap: () {},
989+
child: const Text('My Link'),
990+
);
991+
},
992+
),
993+
),
994+
));
995+
// Platform view creation happens asynchronously.
996+
await tester.pumpAndSettle();
997+
await tester.pump();
998+
999+
final html.Element semanticsHost =
1000+
html.document.createElement('flt-semantics-host');
1001+
html.document.body!.append(semanticsHost);
1002+
final html.Element semanticsAnchor = html.document.createElement('a')
1003+
..setAttribute('id', 'flt-semantic-node-99')
1004+
..setAttribute('flt-semantics-identifier', 'test-link-71')
1005+
..setAttribute('href', uri.toString())
1006+
..textContent = 'My Text Link';
1007+
semanticsHost.append(semanticsAnchor);
1008+
1009+
expect(pushedRouteNames, isEmpty);
1010+
expect(testPlugin.launches, isEmpty);
1011+
1012+
// Receive the DOM click first.
1013+
final html.Event event = _simulateClick(semanticsAnchor);
1014+
// The browser will be prevented from navigating right at the end of the event loop because
1015+
// we are still not sure if the `followLink` will arrive or not.
1016+
await Future<void>.delayed(Duration.zero);
1017+
expect(event.defaultPrevented, isTrue);
1018+
// After some delay, the engine's click debouncer sends the events to the framework and we get
1019+
// the `followLink` signal.
1020+
await Future<void>.delayed(const Duration(milliseconds: 200));
1021+
await followLinkCallback!();
1022+
1023+
expect(pushedRouteNames, isEmpty);
1024+
expect(testPlugin.launches, <String>['https://flutter.dev']);
1025+
});
1026+
1027+
// Regression test for: https://github.com/flutter/flutter/issues/162927
1028+
testWidgets('prevents navigation with debounced clicks on semantic link',
1029+
(WidgetTester tester) async {
1030+
final Uri uri = Uri.parse('https://flutter.dev');
1031+
1032+
await tester.pumpWidget(MaterialApp(
1033+
home: WebLinkDelegate(
1034+
semanticsIdentifier: 'test-link-71',
1035+
TestLinkInfo(
1036+
uri: uri,
1037+
target: LinkTarget.blank,
1038+
builder: (BuildContext context, FollowLink? followLink) {
1039+
return GestureDetector(
1040+
onTap: () {},
1041+
child: const Text('My Link'),
1042+
);
1043+
},
1044+
),
1045+
),
1046+
));
1047+
// Platform view creation happens asynchronously.
1048+
await tester.pumpAndSettle();
1049+
await tester.pump();
1050+
1051+
final html.Element semanticsHost =
1052+
html.document.createElement('flt-semantics-host');
1053+
html.document.body!.append(semanticsHost);
1054+
final html.Element semanticsAnchor = html.document.createElement('a')
1055+
..setAttribute('id', 'flt-semantic-node-99')
1056+
..setAttribute('flt-semantics-identifier', 'test-link-71')
1057+
..setAttribute('href', uri.toString())
1058+
..textContent = 'My Text Link';
1059+
semanticsHost.append(semanticsAnchor);
1060+
1061+
expect(pushedRouteNames, isEmpty);
1062+
expect(testPlugin.launches, isEmpty);
1063+
1064+
// Receive the DOM click first.
1065+
final html.Event event = _simulateClick(semanticsAnchor);
1066+
// The browser will be prevented from navigating right at the end of the event loop because
1067+
// we are still not sure if the `followLink` will arrive or not.
1068+
await Future<void>.delayed(Duration.zero);
1069+
expect(event.defaultPrevented, isTrue);
1070+
1071+
// No navigation should occur because no `followLink` signal was received.
1072+
await Future<void>.delayed(const Duration(seconds: 1));
1073+
expect(pushedRouteNames, isEmpty);
1074+
expect(testPlugin.launches, isEmpty);
1075+
});
1076+
9751077
// TODO(mdebbar): Remove this test after the engine PR [1] makes it to stable.
9761078
// [1] https://github.com/flutter/engine/pull/52720
9771079
testWidgets('handles clicks on (old) semantic link with a button',

packages/url_launcher/url_launcher_web/lib/src/link.dart

Lines changed: 46 additions & 12 deletions
Original file line numberDiff line numberDiff line change
@@ -199,6 +199,16 @@ class LinkTriggerSignals {
199199
/// considered valid. If they don't, the signals are reset.
200200
final Duration staleTimeout;
201201

202+
/// Whether the browser can do the navigation itself.
203+
///
204+
/// If the link is activated by a click event, then we know the browser can do the navigation by
205+
/// itself.
206+
///
207+
/// In some cases, we decide to prevent default on the click event, so [canBrowserNavigate] is
208+
/// set to false to indicate that the browser is not able to perform the navigation anymore. In
209+
/// such case, the navigation has to be performed programmatically (using `launchUrl`).
210+
bool canBrowserNavigate = false;
211+
202212
bool get _hasAllSignals => _hasFollowLink && _hasDomEvent;
203213

204214
int? get _viewId {
@@ -232,7 +242,7 @@ class LinkTriggerSignals {
232242
void onFollowLink({required int viewId}) {
233243
_hasFollowLink = true;
234244
_viewIdFromFollowLink = viewId;
235-
_didUpdate();
245+
_scheduleReset();
236246
}
237247

238248
/// Handles a [mouseEvent] signal from a specific [viewId].
@@ -254,7 +264,26 @@ class LinkTriggerSignals {
254264
_hasDomEvent = true;
255265
_viewIdFromDomEvent = viewId;
256266
_mouseEvent = mouseEvent;
257-
_didUpdate();
267+
_scheduleReset();
268+
269+
if (mouseEvent != null) {
270+
canBrowserNavigate = true;
271+
// We have to make a decision whether to allow the browser to navigate or not. The decision
272+
// has to be made before the end of the event loop.
273+
//
274+
// If we don't hear back from the `followLink` signal by the end of the event loop, we should
275+
// prevent the browser from navigating. If the `followLink` signal arrives later, we can
276+
// navigate programmatically using `launchUrl`.
277+
scheduleMicrotask(() {
278+
if (!_hasDomEvent) {
279+
// By the time the microtask runs, the link may have already been triggered. In that case,
280+
// we want to do nothing here.
281+
return;
282+
}
283+
canBrowserNavigate = false;
284+
mouseEvent.preventDefault();
285+
});
286+
}
258287
}
259288

260289
bool _hasFollowLink = false;
@@ -279,7 +308,7 @@ class LinkTriggerSignals {
279308

280309
Timer? _resetTimer;
281310

282-
void _didUpdate() {
311+
void _scheduleReset() {
283312
_resetTimer?.cancel();
284313
_resetTimer = Timer(staleTimeout, reset);
285314
}
@@ -296,6 +325,7 @@ class LinkTriggerSignals {
296325
_viewIdFromDomEvent = null;
297326

298327
_mouseEvent = null;
328+
canBrowserNavigate = false;
299329
}
300330
}
301331

@@ -514,23 +544,23 @@ class LinkViewController extends PlatformViewController {
514544
static void _triggerLink(int viewId, html.MouseEvent? mouseEvent) {
515545
final LinkViewController controller = _instancesByViewId[viewId]!;
516546

517-
if (mouseEvent != null && _isModifierKey(mouseEvent)) {
547+
if (_triggerSignals.canBrowserNavigate && _isModifierKey(mouseEvent)) {
518548
// When the click is accompanied by a modifier key (e.g. cmd+click or
519549
// shift+click), we want to let the browser do its thing (e.g. open a new
520550
// tab or a new window).
521551
return;
522552
}
523553

524554
if (controller._isExternalLink) {
525-
if (mouseEvent == null) {
526-
// When external links are triggered by keyboard, they are not handled by
527-
// the browser. So we have to launch the url manually.
528-
UrlLauncherPlatform.instance
529-
.launchUrl(controller._uri.toString(), const LaunchOptions());
555+
if (!_triggerSignals.canBrowserNavigate) {
556+
// When the browser can't do the navigation, we have to launch the url manually.
557+
UrlLauncherPlatform.instance.launchUrl(
558+
controller._uri.toString(),
559+
const LaunchOptions(),
560+
);
530561
}
531562

532-
// When triggerd by a mouse event, external links will be handled by the
533-
// browser, so we don't have to do anything.
563+
// Otherwise, let the browser handle it, so we don't have to do anything.
534564
return;
535565
}
536566

@@ -675,7 +705,11 @@ html.Element? _getClosestSemanticsLink(html.Element element) {
675705
return element.closest('a[id^="flt-semantic-node-"]');
676706
}
677707

678-
bool _isModifierKey(html.Event event) {
708+
bool _isModifierKey(html.Event? event) {
709+
if (event == null) {
710+
return false;
711+
}
712+
679713
// This method accepts both KeyboardEvent and MouseEvent but there's no common
680714
// interface that contains the `ctrlKey`, `altKey`, `metaKey`, and `shiftKey`
681715
// properties. So we have to cast the event to either `KeyboardEvent` or

packages/url_launcher/url_launcher_web/pubspec.yaml

Lines changed: 1 addition & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -2,7 +2,7 @@ name: url_launcher_web
22
description: Web platform implementation of url_launcher
33
repository: https://github.com/flutter/packages/tree/main/packages/url_launcher/url_launcher_web
44
issue_tracker: https://github.com/flutter/flutter/issues?q=is%3Aissue+is%3Aopen+label%3A%22p%3A+url_launcher%22
5-
version: 2.4.0
5+
version: 2.4.1
66

77
environment:
88
sdk: ^3.6.0

0 commit comments

Comments
 (0)