diff --git a/lib/web_ui/lib/src/engine/text_editing/text_editing.dart b/lib/web_ui/lib/src/engine/text_editing/text_editing.dart index 49746c9245e90..f599c56b88b0a 100644 --- a/lib/web_ui/lib/src/engine/text_editing/text_editing.dart +++ b/lib/web_ui/lib/src/engine/text_editing/text_editing.dart @@ -1077,24 +1077,35 @@ class SafariDesktopTextEditingStrategy extends DefaultTextEditingStrategy { void placeElement() { geometry?.applyToDomElement(activeDomElement); if (hasAutofillGroup) { - placeForm(); - // On Safari Desktop, when a form is focused, it opens an autofill menu - // immediately. - // Flutter framework sends `setEditableSizeAndTransform` for informing - // the engine about the location of the text field. This call may arrive - // after the first `show` call, depending on the text input widget's - // implementation. Therefore form is placed, when - // `setEditableSizeAndTransform` method is called and focus called on the - // form only after placing it to the correct position and only once after - // that. Calling focus multiple times causes flickering. - focusedFormElement!.focus(); - - // Set the last editing state if it exists, this is critical for a - // users ongoing work to continue uninterrupted when there is an update to - // the transform. - // If domElement is not focused cursor location will not be correct. - activeDomElement.focus(); - lastEditingState?.applyToDomElement(activeDomElement); + // We listen to pointerdown events on the Flutter View element and programatically + // focus our inputs. However, these inputs are focused before the pointerdown + // events conclude. Thus, the browser triggers a blur event immediately after + // focusing these inputs. This causes issues with Safari Desktop's autofill + // dialog (ref: https://github.com/flutter/flutter/issues/127960). + // In order to guarantee that we only focus after the pointerdown event concludes, + // we wrap the form autofill placement and focus logic in a zero-duration Timer. + // This ensures that our input doesn't have instantaneous focus/blur events + // occur on it and fixes the autofill dialog bug as a result. + Timer(Duration.zero, () { + placeForm(); + // On Safari Desktop, when a form is focused, it opens an autofill menu + // immediately. + // Flutter framework sends `setEditableSizeAndTransform` for informing + // the engine about the location of the text field. This call may arrive + // after the first `show` call, depending on the text input widget's + // implementation. Therefore form is placed, when + // `setEditableSizeAndTransform` method is called and focus called on the + // form only after placing it to the correct position and only once after + // that. Calling focus multiple times causes flickering. + focusedFormElement!.focus(); + + // Set the last editing state if it exists, this is critical for a + // users ongoing work to continue uninterrupted when there is an update to + // the transform. + // If domElement is not focused cursor location will not be correct. + activeDomElement.focus(); + lastEditingState?.applyToDomElement(activeDomElement); + }); } } diff --git a/lib/web_ui/test/engine/text_editing_test.dart b/lib/web_ui/test/engine/text_editing_test.dart index 0b651e6c677a9..07e68c3e746ee 100644 --- a/lib/web_ui/test/engine/text_editing_test.dart +++ b/lib/web_ui/test/engine/text_editing_test.dart @@ -512,7 +512,7 @@ Future testMain() async { //No-op and without crashing. }); - test('setClient, show, setEditingState, hide', () { + test('setClient, show, setEditingState, hide', () async { final MethodCall setClient = MethodCall( 'TextInput.setClient', [123, flutterSinglelineConfig]); sendFrameworkMessage(codec.encodeMethodCall(setClient)); @@ -532,6 +532,8 @@ Future testMain() async { Matrix4.translationValues(10.0, 20.0, 30.0).storage.toList()); sendFrameworkMessage(codec.encodeMethodCall(setSizeAndTransform)); + await waitForDesktopSafariFocus(); + checkInputEditingState(textEditing!.strategy.domElement, '', 0, 0); const MethodCall setEditingState = @@ -555,7 +557,7 @@ Future testMain() async { expect(spy.messages, isEmpty); }); - test('setClient, setEditingState, show, clearClient', () { + test('setClient, setEditingState, show, clearClient', () async { final MethodCall setClient = MethodCall( 'TextInput.setClient', [123, flutterSinglelineConfig]); sendFrameworkMessage(codec.encodeMethodCall(setClient)); @@ -583,6 +585,8 @@ Future testMain() async { Matrix4.translationValues(10.0, 20.0, 30.0).storage.toList()); sendFrameworkMessage(codec.encodeMethodCall(setSizeAndTransform)); + await waitForDesktopSafariFocus(); + checkInputEditingState( textEditing!.strategy.domElement, 'abcd', 2, 3); @@ -595,20 +599,15 @@ Future testMain() async { expect(spy.messages, isEmpty); }); - test('setClient, setEditingState, setSizeAndTransform, show - input element is put into the DOM', () { + test('setClient, setEditingState, setSizeAndTransform, show - input element is put into the DOM Safari Desktop', () async { editingStrategy = SafariDesktopTextEditingStrategy(textEditing!); textEditing!.debugTextEditingStrategyOverride = editingStrategy; final MethodCall setClient = MethodCall( 'TextInput.setClient', [123, flutterSinglelineConfig]); sendFrameworkMessage(codec.encodeMethodCall(setClient)); - const MethodCall setEditingState = - MethodCall('TextInput.setEditingState', { - 'text': 'abcd', - 'selectionBase': 2, - 'selectionExtent': 3, - }); - sendFrameworkMessage(codec.encodeMethodCall(setEditingState)); + const MethodCall show = MethodCall('TextInput.show'); + sendFrameworkMessage(codec.encodeMethodCall(show)); // Editing shouldn't have started yet. expect(domDocument.activeElement, domDocument.body); @@ -622,12 +621,19 @@ Future testMain() async { Matrix4.translationValues(10.0, 20.0, 30.0).storage.toList()); sendFrameworkMessage(codec.encodeMethodCall(setSizeAndTransform)); - const MethodCall show = MethodCall('TextInput.show'); - sendFrameworkMessage(codec.encodeMethodCall(show)); + await waitForDesktopSafariFocus(); + + const MethodCall setEditingState = + MethodCall('TextInput.setEditingState', { + 'text': 'abcd', + 'selectionBase': 2, + 'selectionExtent': 3, + }); + sendFrameworkMessage(codec.encodeMethodCall(setEditingState)); expect(defaultTextEditingRoot.ownerDocument?.activeElement, textEditing!.strategy.domElement); - }); + }, skip: !isSafari); test('setClient, setEditingState, show, updateConfig, clearClient', () { final MethodCall setClient = MethodCall('TextInput.setClient', [ @@ -700,6 +706,8 @@ Future testMain() async { Matrix4.translationValues(10.0, 20.0, 30.0).storage.toList()); sendFrameworkMessage(codec.encodeMethodCall(setSizeAndTransform)); + await waitForDesktopSafariFocus(); + checkInputEditingState( textEditing!.strategy.domElement, 'abcd', 2, 3); expect(textEditing!.isEditing, isTrue); @@ -796,6 +804,8 @@ Future testMain() async { Matrix4.translationValues(10.0, 20.0, 30.0).storage.toList()); sendFrameworkMessage(codec.encodeMethodCall(setSizeAndTransform)); + await waitForDesktopSafariFocus(); + checkInputEditingState( textEditing!.strategy.domElement, 'abcd', 2, 3); @@ -852,6 +862,8 @@ Future testMain() async { Matrix4.translationValues(10.0, 20.0, 30.0).storage.toList()); sendFrameworkMessage(codec.encodeMethodCall(setSizeAndTransform)); + await waitForDesktopSafariFocus(); + // Form is added to DOM. expect(defaultTextEditingRoot.querySelectorAll('form'), isNotEmpty); @@ -906,6 +918,8 @@ Future testMain() async { Matrix4.translationValues(10.0, 20.0, 30.0).storage.toList()); sendFrameworkMessage(codec.encodeMethodCall(setSizeAndTransform)); + await waitForDesktopSafariFocus(); + // Form is added to DOM. expect(defaultTextEditingRoot.querySelectorAll('form'), isNotEmpty); final DomHTMLFormElement formElement = @@ -959,6 +973,8 @@ Future testMain() async { Matrix4.translationValues(10.0, 20.0, 30.0).storage.toList()); sendFrameworkMessage(codec.encodeMethodCall(setSizeAndTransform)); + await waitForDesktopSafariFocus(); + // Form is added to DOM. expect(defaultTextEditingRoot.querySelectorAll('form'), isNotEmpty); final DomHTMLFormElement formElement = @@ -985,7 +1001,52 @@ Future testMain() async { expect(formsOnTheDom, hasLength(0)); }); - test('setClient, setEditingState, show, setClient', () { + test('form is not placed and input is not focused until after tick on Desktop Safari', () async { + // Create a configuration with an AutofillGroup of four text fields. + final Map flutterMultiAutofillElementConfig = + createFlutterConfig('text', + autofillHint: 'username', + autofillHintsForFields: [ + 'username', + 'email', + 'name', + 'telephoneNumber' + ]); + final MethodCall setClient = MethodCall('TextInput.setClient', + [123, flutterMultiAutofillElementConfig]); + sendFrameworkMessage(codec.encodeMethodCall(setClient)); + + const MethodCall setEditingState1 = + MethodCall('TextInput.setEditingState', { + 'text': 'abcd', + 'selectionBase': 2, + 'selectionExtent': 3, + }); + sendFrameworkMessage(codec.encodeMethodCall(setEditingState1)); + + const MethodCall 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)); + + // Prior to tick, form should not exist and no elements should be focused. + expect(defaultTextEditingRoot.querySelectorAll('form'), isEmpty); + expect(domDocument.activeElement, domDocument.body); + + await waitForDesktopSafariFocus(); + + // Form is added to DOM. + expect(defaultTextEditingRoot.querySelectorAll('form'), isNotEmpty); + + final DomHTMLInputElement inputElement = + textEditing!.strategy.domElement! as DomHTMLInputElement; + expect(domDocument.activeElement, inputElement); + }, skip: !isSafari); + + test('setClient, setEditingState, show, setClient', () async { final MethodCall setClient = MethodCall( 'TextInput.setClient', [123, flutterSinglelineConfig]); sendFrameworkMessage(codec.encodeMethodCall(setClient)); @@ -1013,6 +1074,8 @@ Future testMain() async { Matrix4.translationValues(10.0, 20.0, 30.0).storage.toList()); sendFrameworkMessage(codec.encodeMethodCall(setSizeAndTransform)); + await waitForDesktopSafariFocus(); + checkInputEditingState( textEditing!.strategy.domElement, 'abcd', 2, 3); @@ -1030,7 +1093,7 @@ Future testMain() async { hideKeyboard(); }); - test('setClient, setEditingState, show, setEditingState, clearClient', () { + test('setClient, setEditingState, show, setEditingState, clearClient', () async { final MethodCall setClient = MethodCall( 'TextInput.setClient', [123, flutterSinglelineConfig]); sendFrameworkMessage(codec.encodeMethodCall(setClient)); @@ -1063,6 +1126,7 @@ Future testMain() async { }); sendFrameworkMessage(codec.encodeMethodCall(setEditingState2)); + await waitForDesktopSafariFocus(); // The second [setEditingState] should override the first one. checkInputEditingState( textEditing!.strategy.domElement, 'xyz', 0, 2); @@ -1076,7 +1140,7 @@ Future testMain() async { test( 'singleTextField Autofill: setClient, setEditingState, show, ' - 'setSizeAndTransform, setEditingState, clearClient', () { + 'setSizeAndTransform, setEditingState, clearClient', () async { // Create a configuration with focused element has autofil hint. final Map flutterSingleAutofillElementConfig = createFlutterConfig('text', autofillHint: 'username'); @@ -1104,6 +1168,7 @@ Future testMain() async { Matrix4.translationValues(10.0, 20.0, 30.0).storage.toList()); sendFrameworkMessage(codec.encodeMethodCall(setSizeAndTransform)); + await waitForDesktopSafariFocus(); // The second [setEditingState] should override the first one. checkInputEditingState( textEditing!.strategy.domElement, 'abcd', 2, 3); @@ -1125,7 +1190,7 @@ Future testMain() async { test( 'singleTextField Autofill setEditableSizeAndTransform preserves' - 'editing state', () { + 'editing state', () async { // Create a configuration with focused element has autofil hint. final Map flutterSingleAutofillElementConfig = createFlutterConfig('text', autofillHint: 'username'); @@ -1174,6 +1239,7 @@ Future testMain() async { Matrix4.translationValues(10.0, 20.0, 30.0).storage.toList()); sendFrameworkMessage(codec.encodeMethodCall(updateSizeAndTransform)); + await waitForDesktopSafariFocus(); // Check the element still has focus. User can keep editing. expect(defaultTextEditingRoot.ownerDocument?.activeElement, textEditing!.strategy.domElement); @@ -1194,7 +1260,7 @@ Future testMain() async { test( 'multiTextField Autofill: setClient, setEditingState, show, ' - 'setSizeAndTransform setEditingState, clearClient', () { + 'setSizeAndTransform setEditingState, clearClient', () async { // Create a configuration with an AutofillGroup of four text fields. final Map flutterMultiAutofillElementConfig = createFlutterConfig('text', @@ -1229,6 +1295,8 @@ Future testMain() async { Matrix4.translationValues(10.0, 20.0, 30.0).storage.toList()); sendFrameworkMessage(codec.encodeMethodCall(setSizeAndTransform)); + await waitForDesktopSafariFocus(); + // The second [setEditingState] should override the first one. checkInputEditingState( textEditing!.strategy.domElement, 'abcd', 2, 3); @@ -1514,7 +1582,7 @@ Future testMain() async { test( 'negative base offset and selection extent values in editing state is handled', - () { + () async { final MethodCall setClient = MethodCall( 'TextInput.setClient', [123, flutterSinglelineConfig]); sendFrameworkMessage(codec.encodeMethodCall(setClient)); @@ -1539,6 +1607,8 @@ Future testMain() async { Matrix4.translationValues(10.0, 20.0, 30.0).storage.toList()); sendFrameworkMessage(codec.encodeMethodCall(setSizeAndTransform)); + await waitForDesktopSafariFocus(); + // Check if the selection range is correct. checkInputEditingState( textEditing!.strategy.domElement, 'xyz', 1, 2); @@ -1687,7 +1757,7 @@ Future testMain() async { hideKeyboard(); }); - test('multiTextField Autofill sync updates back to Flutter', () { + test('multiTextField Autofill sync updates back to Flutter', () async { // Create a configuration with an AutofillGroup of four text fields. const String hintForFirstElement = 'familyName'; final Map flutterMultiAutofillElementConfig = @@ -1723,6 +1793,7 @@ Future testMain() async { Matrix4.translationValues(10.0, 20.0, 30.0).storage.toList()); sendFrameworkMessage(codec.encodeMethodCall(setSizeAndTransform)); + await waitForDesktopSafariFocus(); // The second [setEditingState] should override the first one. checkInputEditingState( textEditing!.strategy.domElement, 'abcd', 2, 3); @@ -1769,7 +1840,7 @@ Future testMain() async { hideKeyboard(); }); - test('Multi-line mode also works', () { + test('Multi-line mode also works', () async { final MethodCall setClient = MethodCall( 'TextInput.setClient', [123, flutterMultilineConfig]); sendFrameworkMessage(codec.encodeMethodCall(setClient)); @@ -1790,6 +1861,8 @@ Future testMain() async { Matrix4.translationValues(10.0, 20.0, 30.0).storage.toList()); sendFrameworkMessage(codec.encodeMethodCall(setSizeAndTransform)); + await waitForDesktopSafariFocus(); + final DomHTMLTextAreaElement textarea = textEditing!.strategy.domElement! as DomHTMLTextAreaElement; checkTextAreaEditingState(textarea, '', 0, 0); @@ -2868,3 +2941,12 @@ void clearForms() { } formsOnTheDom.clear(); } + +/// On Desktop Safari, the editing element is focused after a zero-duration timer +/// to prevent autofill popup flickering. We must wait a tick for this placement +/// before referencing these elements. +Future waitForDesktopSafariFocus() async { + if (textEditing.strategy is SafariDesktopTextEditingStrategy) { + await Future.delayed(Duration.zero); + } +}