From dd78fad006bd0d29fccac6d6815edea47ec28443 Mon Sep 17 00:00:00 2001 From: flutter-zl Date: Tue, 2 Jun 2026 15:27:12 -0700 Subject: [PATCH 1/2] [web] Fix grouped autofill on iOS Chrome --- .../src/engine/text_editing/text_editing.dart | 14 +++++ .../web_ui/test/engine/text_editing_test.dart | 57 +++++++++++++++++++ 2 files changed, 71 insertions(+) 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 1c099470e831f..9a4051bd9508d 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,8 @@ class EngineAutofillForm { final elements = {}; + final Map _lastSentAutofillText = {}; + final Map items; /// Identifier for the form. @@ -416,6 +418,17 @@ class EngineAutofillForm { final AutofillInfo autofill = items[key]!.autofillInfo; // Focused elements are updated directly through `setEditingState`. if (key != focusedElementId) { + // If the browser autofilled a non-focused field while this form was + // dormant, the DOM can have the new text before the framework state + // does. Forward that value and leave it in place instead of overwriting + // it with stale autofill state. + final domEditingState = EditingState.fromDomElement(element); + if (domEditingState.text.isNotEmpty && domEditingState.text != autofill.editingState.text) { + if (domEditingState.text != _lastSentAutofillText[autofill.uniqueIdentifier]) { + _sendAutofillEditingState(autofill.uniqueIdentifier, domEditingState); + } + continue; + } // 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 +480,7 @@ class EngineAutofillForm { /// 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 5ae92e1d7a6c5..df860633b1152 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 @@ -2597,6 +2597,63 @@ Future testMain() async { hideKeyboard(); }); + test('multiTextField Autofill preserves changed dormant values on wake', () async { + final Map flutterMultiAutofillElementConfig = createFlutterConfig( + 'text', + autofillHint: 'username', + autofillHintsForFields: ['username', 'current-password'], + ); + final setClient = MethodCall('TextInput.setClient', [ + 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 autofillMessages = spy.messages.where( + (PlatformMessage message) => + message.methodName == 'TextInputClient.updateEditingStateWithTag', + ); + expect(autofillMessages, hasLength(1)); + expect(autofillMessages.single.channel, 'flutter/textinput'); + expect(autofillMessages.single.methodArguments, [ + 0, + { + 'current-password': { + 'text': 'secret-password', + 'selectionBase': 15, + 'selectionExtent': 15, + 'composingBase': -1, + 'composingExtent': -1, + }, + }, + ]); + }); + test('Multi-line mode also works', () async { final setClient = MethodCall('TextInput.setClient', [123, flutterMultilineConfig]); sendFrameworkMessage(codec.encodeMethodCall(setClient)); From b65c116e6581586587c13f5a95b3ae1ce094720c Mon Sep 17 00:00:00 2001 From: flutter-zl Date: Fri, 12 Jun 2026 11:23:12 -0700 Subject: [PATCH 2/2] [web] Keep programmatic updates to non-focused autofill fields --- .../src/engine/text_editing/text_editing.dart | 38 ++++++++-- .../web_ui/test/engine/text_editing_test.dart | 74 +++++++++++++++++++ 2 files changed, 105 insertions(+), 7 deletions(-) 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 9a4051bd9508d..e8cb3ea3c6fae 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 @@ -200,6 +200,8 @@ class EngineAutofillForm { final Map _lastSentAutofillText = {}; + final Map _lastFrameworkText = {}; + final Map items; /// Identifier for the form. @@ -320,6 +322,14 @@ class EngineAutofillForm { // 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); @@ -418,19 +428,33 @@ class EngineAutofillForm { final AutofillInfo autofill = items[key]!.autofillInfo; // Focused elements are updated directly through `setEditingState`. if (key != focusedElementId) { - // If the browser autofilled a non-focused field while this form was - // dormant, the DOM can have the new text before the framework state - // does. Forward that value and leave it in place instead of overwriting - // it with stale autofill state. + // 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); - if (domEditingState.text.isNotEmpty && domEditingState.text != autofill.editingState.text) { + 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; } - // Non-focused elements do not have selection, and applying selection on them may cause them - // to gain focus unexpectedly. + // 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); } } 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 df860633b1152..1da5fb2319e52 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 @@ -3257,6 +3257,80 @@ Future testMain() async { }); 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 field(String hint, String id, String text) => { + 'inputType': { + 'name': 'TextInputType.text', + 'signed': null, + 'decimal': null, + }, + 'textCapitalization': 'TextCapitalization.none', + 'autofill': { + 'uniqueIdentifier': id, + 'hints': [hint], + 'editingValue': { + '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 = >[ + field('username', 'field1', 'user'), + field('password', 'field2', 'pw-v1'), + ]; + final focusedMap1 = fields1.first['autofill']! as Map; + 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 = >[ + field('username', 'field1', 'user'), + field('password', 'field2', 'pw-v2'), + ]; + final focusedMap2 = fields2.first['autofill']! as Map; + 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> fields = createFieldValues( ['username', 'password', 'newPassword'],