Skip to content

Commit 7ca63fa

Browse files
yjbanovmdebbar
andauthored
[web] close input connection when window/iframe loses focus (flutter#166804)
Fixes flutter#155265 This includes 2 fixes: * When the window/iframe loses focus, close the text input connection instead of grabbing the focus again. * Do not enable semantics using the placeholder when moving focus using the "Tab" key. Bonus: remove the no longer necessary `ViewFocusBinding.isEnabled` (doesn't fix any issues, just a clean-up). --------- Co-authored-by: Mouad Debbar <[email protected]>
1 parent 3ed38e2 commit 7ca63fa

File tree

7 files changed

+227
-24
lines changed

7 files changed

+227
-24
lines changed

engine/src/flutter/lib/web_ui/lib/src/engine/platform_dispatcher/view_focus_binding.dart

Lines changed: 0 additions & 14 deletions
Original file line numberDiff line numberDiff line change
@@ -5,20 +5,13 @@
55
import 'dart:async';
66
import 'dart:js_interop';
77

8-
import 'package:meta/meta.dart';
98
import 'package:ui/src/engine.dart';
109
import 'package:ui/ui.dart' as ui;
1110

1211
/// Tracks the [FlutterView]s focus changes.
1312
final class ViewFocusBinding {
1413
ViewFocusBinding(this._viewManager, this._onViewFocusChange);
1514

16-
/// Whether [FlutterView] focus changes will be reported and performed.
17-
///
18-
/// DO NOT rely on this bit as it will go away soon. You're warned :)!
19-
@visibleForTesting
20-
static bool isEnabled = true;
21-
2215
final FlutterViewManager _viewManager;
2316
final ui.ViewFocusChangeCallback _onViewFocusChange;
2417

@@ -46,9 +39,6 @@ final class ViewFocusBinding {
4639
}
4740

4841
void changeViewFocus(int viewId, ui.ViewFocusState state) {
49-
if (!isEnabled) {
50-
return;
51-
}
5242
final DomElement? viewElement = _viewManager[viewId]?.dom.rootElement;
5343

5444
switch (state) {
@@ -97,10 +87,6 @@ final class ViewFocusBinding {
9787
});
9888

9989
void _handleFocusChange(DomElement? focusedElement) {
100-
if (!isEnabled) {
101-
return;
102-
}
103-
10490
final int? viewId = _viewId(focusedElement);
10591
if (viewId == _lastViewId) {
10692
return;

engine/src/flutter/lib/web_ui/lib/src/engine/semantics/semantics_helper.dart

Lines changed: 9 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -79,6 +79,15 @@ abstract class SemanticsEnabler {
7979
/// Or if the received [DomEvent] is suitable/enough for enabling the
8080
/// semantics. See [tryEnableSemantics].
8181
bool shouldEnableSemantics(DomEvent event) {
82+
// Simply tabbing into the placeholder element should not cause semantics
83+
// to be enabled. The user should actually click on the placeholder.
84+
if (event.isA<DomKeyboardEvent>()) {
85+
event as DomKeyboardEvent;
86+
if (event.key == 'Tab') {
87+
return true;
88+
}
89+
}
90+
8291
if (!isWaitingToEnableSemantics) {
8392
// Forward to framework as normal.
8493
return true;

engine/src/flutter/lib/web_ui/lib/src/engine/text_editing/text_editing.dart

Lines changed: 31 additions & 4 deletions
Original file line numberDiff line numberDiff line change
@@ -170,7 +170,12 @@ void _insertEditingElementInView(DomElement element, int viewId) {
170170
view != null,
171171
'Could not find View with id $viewId. This should never happen, please file a bug!',
172172
);
173-
view!.dom.textEditingHost.append(element);
173+
final host = view!.dom.textEditingHost;
174+
// Do not cause DOM disturbance unless necessary. Doing superfluous DOM operations may seem
175+
// harmless, but it actually causes focus changes that could break things.
176+
if (!host.contains(element)) {
177+
host.append(element);
178+
}
174179
}
175180

176181
/// Form that contains all the fields in the same AutofillGroup.
@@ -363,7 +368,11 @@ class EngineAutofillForm {
363368
mainTextEditingElement.style.pointerEvents = 'all';
364369
}
365370

366-
formElement.insertBefore(mainTextEditingElement, insertionReferenceNode);
371+
// Do not cause DOM disturbance unless necessary. Doing superfluous DOM operations may seem
372+
// harmless, but it actually causes focus changes that could break things.
373+
if (!formElement.contains(mainTextEditingElement)) {
374+
formElement.insertBefore(mainTextEditingElement, insertionReferenceNode);
375+
}
367376
_insertEditingElementInView(formElement, viewId);
368377
}
369378

@@ -1535,8 +1544,26 @@ abstract class DefaultTextEditingStrategy
15351544
event as DomFocusEvent;
15361545

15371546
final DomElement? willGainFocusElement = event.relatedTarget as DomElement?;
1538-
if (willGainFocusElement == null ||
1539-
_viewForElement(willGainFocusElement) == activeDomElementView) {
1547+
if (willGainFocusElement == null) {
1548+
// If the element to gain focus is reported as `null`, it means that the
1549+
// window/iframe that Flutter runs in is losing focus. In this case, the
1550+
// engine signals to the framework to close the text input connection,
1551+
// allowing the focus to move elsewhere.
1552+
//
1553+
// This is fixing https://github.com/flutter/flutter/issues/155265.
1554+
textEditing.sendTextConnectionClosedToFrameworkIfAny();
1555+
} else if (_viewForElement(willGainFocusElement) == activeDomElementView) {
1556+
// If the focus stays within the same FlutterView, ensure the focus stays
1557+
// on the input element.
1558+
1559+
// TODO(yjbanov): Make text input less grabby. See: https://github.com/flutter/flutter/issues/166857
1560+
// The motivation/reasoning behind this remains murky.
1561+
// It's unclear why, if the browser wants to remove focus from the input,
1562+
// we must insist that it stays on the element. This could lead to
1563+
// different elements/widgets fighting over who gets the focus, or resist
1564+
// to user's request to move focus elsewhere, which can be super-annoying
1565+
// UX. We should reevaluate what it is we're trying to do here. Perhaps
1566+
// there's a better way.
15401567
moveFocusToActiveDomElement();
15411568
}
15421569
}

engine/src/flutter/lib/web_ui/test/common/spy.dart

Lines changed: 24 additions & 3 deletions
Original file line numberDiff line numberDiff line change
@@ -11,7 +11,7 @@ import 'package:ui/ui.dart';
1111

1212
/// Encapsulates the info of a platform message that was intercepted by
1313
/// [PlatformMessagesSpy].
14-
class PlatformMessage {
14+
final class PlatformMessage {
1515
PlatformMessage(this.channel, this.methodCall);
1616

1717
/// The name of the channel on which the message was sent.
@@ -27,6 +27,19 @@ class PlatformMessage {
2727
dynamic get methodArguments => methodCall.arguments;
2828
}
2929

30+
/// A message that carries a plain string.
31+
///
32+
/// Messages of this type are coded using [StringCodec].
33+
final class PlatformStringMessage {
34+
PlatformStringMessage(this.channel, this.string);
35+
36+
/// The name of the channel on which the message was sent.
37+
final String channel;
38+
39+
/// The value of the string passed in the message.
40+
final String string;
41+
}
42+
3043
/// Intercepts platform messages sent from the engine to the framework.
3144
///
3245
/// It holds all intercepted platform messages in a [messages] list that can
@@ -37,16 +50,23 @@ class PlatformMessagesSpy {
3750

3851
bool get _isActive => _callback != null;
3952

40-
/// List of intercepted messages since the last [setUp] call.
53+
/// List of intercepted method calls since the last [setUp] call.
4154
final List<PlatformMessage> messages = <PlatformMessage>[];
4255

56+
/// List of intercepted string messages since the last [setUp] call.
57+
final List<PlatformStringMessage> strings = <PlatformStringMessage>[];
58+
4359
/// Start spying on platform messages.
4460
///
4561
/// This is typically called inside a test's `setUp` callback.
4662
void setUp() {
4763
assert(!_isActive);
4864
_callback = (String channel, ByteData? data, PlatformMessageResponseCallback? callback) {
49-
messages.add(PlatformMessage(channel, const JSONMethodCodec().decodeMethodCall(data)));
65+
if (channel == 'flutter/lifecycle') {
66+
strings.add(PlatformStringMessage(channel, const StringCodec().decodeMessage(data!)));
67+
} else {
68+
messages.add(PlatformMessage(channel, const JSONMethodCodec().decodeMethodCall(data)));
69+
}
5070
};
5171

5272
_backup = PlatformDispatcher.instance.onPlatformMessage;
@@ -62,6 +82,7 @@ class PlatformMessagesSpy {
6282
assert(PlatformDispatcher.instance.onPlatformMessage == _callback);
6383
_callback = null;
6484
messages.clear();
85+
strings.clear();
6586
PlatformDispatcher.instance.onPlatformMessage = _backup;
6687
}
6788
}

engine/src/flutter/lib/web_ui/test/engine/platform_dispatcher/view_focus_binding_test.dart

Lines changed: 0 additions & 3 deletions
Original file line numberDiff line numberDiff line change
@@ -17,15 +17,12 @@ void testMain() {
1717
late EnginePlatformDispatcher dispatcher;
1818

1919
setUp(() {
20-
ViewFocusBinding.isEnabled = true;
21-
2220
dispatcher = EnginePlatformDispatcher.instance;
2321
dispatchedViewFocusEvents = <ui.ViewFocusEvent>[];
2422
dispatcher.onViewFocusChange = dispatchedViewFocusEvents.add;
2523
});
2624

2725
tearDown(() {
28-
ViewFocusBinding.isEnabled = false;
2926
EngineSemantics.instance.semanticsEnabled = false;
3027
});
3128

engine/src/flutter/lib/web_ui/test/engine/semantics/semantics_helper_test.dart

Lines changed: 41 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -64,6 +64,24 @@ void testMain() {
6464
}
6565
});
6666

67+
test('Tab key should not enable semantics', () async {
68+
final testSemanticsEnabler = FakeSemanticsEnabler();
69+
70+
// Tab should not enable semantics
71+
{
72+
final event = createDomKeyboardEvent('keydown', <String, Object>{'key': 'Tab'});
73+
expect(testSemanticsEnabler.shouldEnableSemantics(event), isTrue);
74+
expect(testSemanticsEnabler.tryEnableSemanticsCallCount, 0);
75+
}
76+
77+
// Enter key is allowed to try to enable semantics
78+
{
79+
final event = createDomKeyboardEvent('keydown', <String, Object>{'key': 'Enter'});
80+
expect(testSemanticsEnabler.shouldEnableSemantics(event), isFalse);
81+
expect(testSemanticsEnabler.tryEnableSemanticsCallCount, 1);
82+
}
83+
});
84+
6785
test(
6886
'Relevant events targeting placeholder should not be forwarded to the framework',
6987
() async {
@@ -151,3 +169,26 @@ void testMain() {
151169
skip: isDesktop && ui_web.browser.browserEngine != ui_web.BrowserEngine.blink,
152170
);
153171
}
172+
173+
class FakeSemanticsEnabler extends SemanticsEnabler {
174+
@override
175+
void dispose() {
176+
throw UnimplementedError();
177+
}
178+
179+
@override
180+
bool get isWaitingToEnableSemantics => true;
181+
182+
@override
183+
DomElement prepareAccessibilityPlaceholder() {
184+
throw UnimplementedError();
185+
}
186+
187+
int tryEnableSemanticsCallCount = 0;
188+
189+
@override
190+
bool tryEnableSemantics(DomEvent event) {
191+
tryEnableSemanticsCallCount += 1;
192+
return false;
193+
}
194+
}

engine/src/flutter/lib/web_ui/test/engine/text_editing_test.dart

Lines changed: 122 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -585,6 +585,128 @@ Future<void> testMain() async {
585585
expect(editingStrategy.domElement!.style.width, '10px');
586586
expect(editingStrategy.domElement!.style.height, '10px');
587587
});
588+
589+
test('does not blur input when size is updated', () async {
590+
final PlatformMessagesSpy spy = PlatformMessagesSpy();
591+
spy.setUp();
592+
593+
editingStrategy!.enable(
594+
multilineConfig,
595+
onChange: trackEditingState,
596+
onAction: trackInputAction,
597+
);
598+
599+
expect(editingStrategy!.isEnabled, isTrue);
600+
601+
final inputElement = editingStrategy!.domElement!;
602+
expect(domDocument.activeElement, inputElement);
603+
604+
int blurCount = 0;
605+
final blurListener = createDomEventListener((_) {
606+
blurCount++;
607+
});
608+
inputElement.addEventListener('blur', blurListener);
609+
610+
final sizeCompleter = Completer<void>();
611+
testTextEditing.acceptCommand(
612+
TextInputSetEditableSizeAndTransform(
613+
geometry: EditableTextGeometry(
614+
width: 240,
615+
height: 60,
616+
globalTransform: Matrix4.translationValues(11, 12, 0).storage,
617+
),
618+
),
619+
sizeCompleter.complete,
620+
);
621+
await sizeCompleter.future;
622+
623+
expect(blurCount, isZero);
624+
// `TextInputClient.onConnectionClosed` shouldn't have been called.
625+
expect(spy.messages, isEmpty);
626+
627+
inputElement.removeEventListener('blur', blurListener);
628+
spy.tearDown();
629+
});
630+
631+
test('closes input connection when window/iframe loses focus', () async {
632+
final PlatformMessagesSpy spy = PlatformMessagesSpy();
633+
spy.setUp();
634+
635+
textEditing.configuration = singlelineConfig;
636+
637+
final showCompleter = Completer<void>();
638+
textEditing.acceptCommand(const TextInputShow(), showCompleter.complete);
639+
await showCompleter.future;
640+
641+
expect(textEditing.isEditing, isTrue);
642+
643+
expect(domDocument.activeElement, textEditing.strategy.domElement);
644+
645+
final event = createDomEvent('Event', 'blur');
646+
editingStrategy!.handleBlur(event);
647+
648+
expect(spy.messages, hasLength(1));
649+
expect(spy.messages[0].channel, 'flutter/textinput');
650+
expect(spy.messages[0].methodName, 'TextInputClient.onConnectionClosed');
651+
652+
spy.tearDown();
653+
});
654+
655+
test(
656+
'keeps focus within window/iframe when the focus moves within the flutter view in Chrome but not Safari',
657+
() async {
658+
final PlatformMessagesSpy spy = PlatformMessagesSpy();
659+
spy.setUp();
660+
661+
textEditing.configuration = singlelineConfig;
662+
663+
final showCompleter = Completer<void>();
664+
textEditing.acceptCommand(const TextInputShow(), showCompleter.complete);
665+
await showCompleter.future;
666+
667+
// The "setSizeAndTransform" message has to be here before we call
668+
// checkInputEditingState, since on some platforms (e.g. Desktop Safari)
669+
// we don't put the input element into the DOM until we get its correct
670+
// dimensions from the framework.
671+
final MethodCall setSizeAndTransform = configureSetSizeAndTransformMethodCall(
672+
150,
673+
50,
674+
Matrix4.translationValues(10.0, 20.0, 30.0).storage.toList(),
675+
);
676+
textEditing.channel.handleTextInput(
677+
codec.encodeMethodCall(setSizeAndTransform),
678+
(ByteData? data) {},
679+
);
680+
681+
expect(textEditing.isEditing, isTrue);
682+
683+
expect(domDocument.activeElement, textEditing.strategy.domElement);
684+
685+
final flutterView =
686+
EnginePlatformDispatcher.instance.viewManager.findViewForElement(
687+
textEditing.strategy.domElement,
688+
)!;
689+
690+
flutterView.dom.rootElement.focusWithoutScroll();
691+
expect(spy.messages, isEmpty);
692+
693+
if (isSafari) {
694+
// In Safari the web engine does not respond to blur, so there's no
695+
// expectation that the input element keep focus.
696+
expect(domDocument.activeElement, flutterView.dom.rootElement);
697+
} else if (isFirefox) {
698+
// This is a mysterious behavior in Firefox. Even though the engine does
699+
// call <input>.focus() the browser doesn't move focus to the target
700+
// element. This only happens in the test harness. When testing
701+
// manually, Firefox happily moves focus to the input element.
702+
expect(domDocument.activeElement, flutterView.dom.rootElement);
703+
} else {
704+
expect(domDocument.activeElement, textEditing.strategy.domElement);
705+
}
706+
707+
spy.tearDown();
708+
},
709+
);
588710
});
589711

590712
group('$HybridTextEditing', () {

0 commit comments

Comments
 (0)