[web] Fix grouped autofill on iOS Chrome (#187459)
**Fixes #185327**
**Problem**
On iOS Chrome, Chrome Password Manager did put the saved password into
Flutter Web's hidden browser input. But Flutter Web still thought the
password field was empty, so when it refreshed the autofill form, it
copied that old empty value back into the browser input. The password
was erased before it showed up in the visible Flutter `TextField`.
**Fix**
Before copying Flutter's stored value into a non-focused autofill field,
Flutter Web now checks what is already in the browser input. If the
browser input already has a password that Flutter has not seen yet,
Flutter Web sends that password to the framework and leaves the browser
input alone instead of clearing it.
To avoid mistaking a deliberate app change for an autofill, it tracks
the last framework value for each field, carried
over when the autofill form is reused. A value the app changed
programmatically is still pushed into the DOM; only an
unchanged framework value sitting next to a changed DOM value is treated
as a browser autofill.
**Demo**
iOS Chrome:
* Before: <https://flutter-demo-34-before.web.app> - username fills,
password stays empty
* After: <https://flutter-demo-34-after.web.app> - username and password
both fill
diff --git a/engine/src/flutter/lib/web_ui/lib/src/engine/text_editing/text_editing.dart b/engine/src/flutter/lib/web_ui/lib/src/engine/text_editing/text_editing.dart
index 438baa3..71378a6 100644
--- a/engine/src/flutter/lib/web_ui/lib/src/engine/text_editing/text_editing.dart
+++ b/engine/src/flutter/lib/web_ui/lib/src/engine/text_editing/text_editing.dart
@@ -198,6 +198,10 @@
final elements = <String, DomHTMLElement>{};
+ final Map<String, String> _lastSentAutofillText = <String, String>{};
+
+ final Map<String, String> _lastFrameworkText = <String, String>{};
+
final Map<String, FieldItem> items;
/// Identifier for the form.
@@ -318,6 +322,14 @@
// If the form already has a dormant DOM element, let's use it instead of creating a new one.
formElement = existingForm.formElement;
elements.addAll(existingForm.elements);
+ // Carry over the per-field tracking so the reused form remembers the
+ // last framework value and the last value forwarded for each field.
+ // Each text input connection builds a new form instance, so without
+ // this the tracking would reset on every focus change and the form
+ // could not tell a programmatic framework update apart from a browser
+ // autofill.
+ _lastFrameworkText.addAll(existingForm._lastFrameworkText);
+ _lastSentAutofillText.addAll(existingForm._lastSentAutofillText);
} else {
formElement = _createFormElementAndFields(focusedElement, focusedAutofill);
_insertEditingElementInView(formElement!, viewId);
@@ -416,8 +428,33 @@
final AutofillInfo autofill = items[key]!.autofillInfo;
// Focused elements are updated directly through `setEditingState`.
if (key != focusedElementId) {
- // Non-focused elements do not have selection, and applying selection on them may cause them
- // to gain focus unexpectedly.
+ // A non-focused field's DOM value and the framework's stored value can
+ // diverge in two ways. When the browser autofills the field while the
+ // form is dormant, the DOM holds a value the framework has not seen yet,
+ // so it must be forwarded and kept. Otherwise the framework is the source
+ // of truth, either it just changed (a programmatic update) or it is
+ // already in sync. The last framework value per field tells them apart:
+ // an autofill is an unchanged framework value next to a changed DOM value.
+ final domEditingState = EditingState.fromDomElement(element);
+ final String frameworkText = autofill.editingState.text;
+ final String lastFrameworkText =
+ _lastFrameworkText[autofill.uniqueIdentifier] ?? frameworkText;
+ _lastFrameworkText[autofill.uniqueIdentifier] = frameworkText;
+
+ final frameworkUnchanged = frameworkText == lastFrameworkText;
+ if (frameworkUnchanged &&
+ domEditingState.text.isNotEmpty &&
+ domEditingState.text != frameworkText) {
+ // The browser autofilled this field. Forward the new value and keep it
+ // instead of clearing it.
+ if (domEditingState.text != _lastSentAutofillText[autofill.uniqueIdentifier]) {
+ _sendAutofillEditingState(autofill.uniqueIdentifier, domEditingState);
+ }
+ continue;
+ }
+ // The framework wins: it changed (a programmatic update) or is in sync.
+ // Non-focused elements do not have selection, and applying selection on
+ // them may cause them to gain focus unexpectedly.
autofill.editingState.applyTextToDomElement(element);
}
}
@@ -467,6 +504,7 @@
/// Sends the 'TextInputClient.updateEditingStateWithTag' message to the framework.
void _sendAutofillEditingState(String tag, EditingState editingState) {
+ _lastSentAutofillText[tag] = editingState.text;
EnginePlatformDispatcher.instance.invokeOnPlatformMessage(
'flutter/textinput',
const JSONMethodCodec().encodeMethodCall(
diff --git a/engine/src/flutter/lib/web_ui/test/engine/text_editing_test.dart b/engine/src/flutter/lib/web_ui/test/engine/text_editing_test.dart
index 1c21f5c..f4bf898 100644
--- a/engine/src/flutter/lib/web_ui/test/engine/text_editing_test.dart
+++ b/engine/src/flutter/lib/web_ui/test/engine/text_editing_test.dart
@@ -2617,6 +2617,63 @@
hideKeyboard();
});
+ test('multiTextField Autofill preserves changed dormant values on wake', () async {
+ final Map<String, dynamic> flutterMultiAutofillElementConfig = createFlutterConfig(
+ 'text',
+ autofillHint: 'username',
+ autofillHintsForFields: <String>['username', 'current-password'],
+ );
+ final setClient = MethodCall('TextInput.setClient', <dynamic>[
+ 123,
+ flutterMultiAutofillElementConfig,
+ ]);
+ sendFrameworkMessage(codec.encodeMethodCall(setClient));
+
+ const show = MethodCall('TextInput.show');
+ sendFrameworkMessage(codec.encodeMethodCall(show));
+
+ final MethodCall setSizeAndTransform = configureSetSizeAndTransformMethodCall(
+ 150,
+ 50,
+ Matrix4.translationValues(10.0, 20.0, 30.0).storage.toList(),
+ );
+ sendFrameworkMessage(codec.encodeMethodCall(setSizeAndTransform));
+
+ var formElement = defaultTextEditingRoot.querySelector('form')! as DomHTMLFormElement;
+ final passwordElement = formElement.childNodes.toList()[1] as DomHTMLInputElement;
+
+ const clearClient = MethodCall('TextInput.clearClient');
+ sendFrameworkMessage(codec.encodeMethodCall(clearClient));
+ passwordElement.value = 'secret-password';
+ spy.messages.clear();
+
+ sendFrameworkMessage(codec.encodeMethodCall(setClient));
+ sendFrameworkMessage(codec.encodeMethodCall(show));
+ sendFrameworkMessage(codec.encodeMethodCall(setSizeAndTransform));
+
+ formElement = defaultTextEditingRoot.querySelector('form')! as DomHTMLFormElement;
+ final restoredPasswordElement = formElement.childNodes.toList()[1] as DomHTMLInputElement;
+ expect(restoredPasswordElement.value, 'secret-password');
+ final Iterable<PlatformMessage> autofillMessages = spy.messages.where(
+ (PlatformMessage message) =>
+ message.methodName == 'TextInputClient.updateEditingStateWithTag',
+ );
+ expect(autofillMessages, hasLength(1));
+ expect(autofillMessages.single.channel, 'flutter/textinput');
+ expect(autofillMessages.single.methodArguments, <dynamic>[
+ 0,
+ <String, dynamic>{
+ 'current-password': <String, dynamic>{
+ 'text': 'secret-password',
+ 'selectionBase': 15,
+ 'selectionExtent': 15,
+ 'composingBase': -1,
+ 'composingExtent': -1,
+ },
+ },
+ ]);
+ });
+
test('Multi-line mode also works', () async {
final setClient = MethodCall('TextInput.setClient', <dynamic>[123, flutterMultilineConfig]);
sendFrameworkMessage(codec.encodeMethodCall(setClient));
@@ -3220,6 +3277,80 @@
});
group('EngineAutofillForm', () {
+ test('applies a programmatic change to a non-focused field instead of reverting it', () {
+ // A programmatic framework update to a non-focused autofill field must win
+ // over the stale DOM value, and must not be mistaken for a browser
+ // autofill. See the review on https://github.com/flutter/flutter/pull/187459.
+ Map<String, Object?> field(String hint, String id, String text) => <String, Object?>{
+ 'inputType': <String, Object?>{
+ 'name': 'TextInputType.text',
+ 'signed': null,
+ 'decimal': null,
+ },
+ 'textCapitalization': 'TextCapitalization.none',
+ 'autofill': <String, dynamic>{
+ 'uniqueIdentifier': id,
+ 'hints': <String>[hint],
+ 'editingValue': <String, dynamic>{
+ 'text': text,
+ 'selectionBase': 0,
+ 'selectionExtent': 0,
+ 'selectionAffinity': 'TextAffinity.downstream',
+ 'selectionIsDirectional': false,
+ 'composingBase': -1,
+ 'composingExtent': -1,
+ },
+ },
+ };
+
+ final spy = PlatformMessagesSpy();
+ spy.setUp();
+ try {
+ // Form 1: the non-focused password field holds 'pw-v1'.
+ final fields1 = <Map<String, Object?>>[
+ field('username', 'field1', 'user'),
+ field('password', 'field2', 'pw-v1'),
+ ];
+ final focusedMap1 = fields1.first['autofill']! as Map<String, Object?>;
+ final EngineAutofillForm form1 = EngineAutofillForm.fromFrameworkMessage(
+ kImplicitViewId,
+ focusedMap1,
+ fields1,
+ )!;
+ form1.wakeUp(createDomHTMLInputElement(), AutofillInfo.fromFrameworkMessage(focusedMap1));
+ final passwordElement = form1.elements['field2']! as DomHTMLInputElement;
+ expect(passwordElement.value, 'pw-v1');
+ form1.goDormant();
+
+ // The app programmatically changes the password to 'pw-v2'. A new config
+ // arrives as a new form that reuses the dormant one.
+ final fields2 = <Map<String, Object?>>[
+ field('username', 'field1', 'user'),
+ field('password', 'field2', 'pw-v2'),
+ ];
+ final focusedMap2 = fields2.first['autofill']! as Map<String, Object?>;
+ final EngineAutofillForm form2 = EngineAutofillForm.fromFrameworkMessage(
+ kImplicitViewId,
+ focusedMap2,
+ fields2,
+ )!;
+ spy.messages.clear();
+ form2.wakeUp(createDomHTMLInputElement(), AutofillInfo.fromFrameworkMessage(focusedMap2));
+
+ // The DOM must reflect the app's new value, not revert to 'pw-v1', and we
+ // must not forward the stale value as if the browser had autofilled it.
+ final reusedPassword = form2.elements['field2']! as DomHTMLInputElement;
+ expect(reusedPassword.value, 'pw-v2');
+ expect(
+ spy.messages.where((m) => m.methodName == 'TextInputClient.updateEditingStateWithTag'),
+ isEmpty,
+ );
+ } finally {
+ spy.tearDown();
+ clearForms();
+ }
+ });
+
test('validate multi element form', () {
final List<Map<String, Object?>> fields = createFieldValues(
<String>['username', 'password', 'newPassword'],