Repository: flutter/flutter
Pull request #166565: [web] fix text selection offset in multi-line fields
Author: login: yjbanov
Labels: name: a: text input
description: Entering text in a text field or keyboard related problems
name: engine
description: flutter/engine related. See also e: labels.
name: a: accessibility
description: Accessibility, e.g. VoiceOver or TalkBack. (aka a11y)
name: platform-web
description: Web applications specifically
Created: 2025-04-04T00:10:57Z | Merged: 2025-04-04T21:05:14Z

Fixes https://github.com/flutter/flutter/issues/162698


Diff:
diff --git a/engine/src/flutter/lib/web_ui/lib/src/engine/dom.dart b/engine/src/flutter/lib/web_ui/lib/src/engine/dom.dart
index f9ed204f15c65..6887a9d12dfff 100644
--- a/engine/src/flutter/lib/web_ui/lib/src/engine/dom.dart
+++ b/engine/src/flutter/lib/web_ui/lib/src/engine/dom.dart
@@ -543,6 +543,7 @@ extension type DomCSSStyleDeclaration._(JSObject _) implements JSObject {
   set textAlign(String value) => setProperty('text-align', value);
   set font(String value) => setProperty('font', value);
   set cursor(String value) => setProperty('cursor', value);
+  set scrollbarWidth(String value) => setProperty('scrollbar-width', value);
   String get width => getPropertyValue('width');
   String get height => getPropertyValue('height');
   String get position => getPropertyValue('position');
@@ -604,6 +605,7 @@ extension type DomCSSStyleDeclaration._(JSObject _) implements JSObject {
   String get textAlign => getPropertyValue('text-align');
   String get font => getPropertyValue('font');
   String get cursor => getPropertyValue('cursor');
+  String get scrollbarWidth => getPropertyValue('scrollbar-width');
 
   external String getPropertyValue(String property);
 
diff --git a/engine/src/flutter/lib/web_ui/lib/src/engine/semantics/text_field.dart b/engine/src/flutter/lib/web_ui/lib/src/engine/semantics/text_field.dart
index 7bd9492984ece..c50e55d840c52 100644
--- a/engine/src/flutter/lib/web_ui/lib/src/engine/semantics/text_field.dart
+++ b/engine/src/flutter/lib/web_ui/lib/src/engine/semantics/text_field.dart
@@ -6,6 +6,7 @@ import 'package:ui/ui.dart' as ui;
 
 import '../dom.dart';
 import '../platform_dispatcher.dart';
+import '../text_editing/input_type.dart';
 import '../text_editing/text_editing.dart';
 import 'semantics.dart';
 
@@ -230,7 +231,7 @@ class SemanticTextField extends SemanticRole {
   }
 
   DomHTMLTextAreaElement _createMultiLineField() {
-    final textArea = createDomHTMLTextAreaElement();
+    final textArea = createMultilineTextArea();
 
     if (semanticsObject.hasFlag(ui.SemanticsFlag.isObscured)) {
       // -webkit-text-security is not standard, but it's the best we can do.
diff --git a/engine/src/flutter/lib/web_ui/lib/src/engine/text_editing/input_type.dart b/engine/src/flutter/lib/web_ui/lib/src/engine/text_editing/input_type.dart
index c5518427a6571..8a90067872698 100644
--- a/engine/src/flutter/lib/web_ui/lib/src/engine/text_editing/input_type.dart
+++ b/engine/src/flutter/lib/web_ui/lib/src/engine/text_editing/input_type.dart
@@ -120,7 +120,7 @@ class MultilineNoTextInputType extends MultilineInputType {
   String? get inputmodeAttribute => 'none';
 
   @override
-  DomHTMLElement createDomElement() => createDomHTMLTextAreaElement();
+  DomHTMLElement createDomElement() => createMultilineTextArea();
 }
 
 /// Single-line text input type.
@@ -184,5 +184,12 @@ class MultilineInputType extends EngineInputType {
   String? get inputmodeAttribute => null;
 
   @override
-  DomHTMLElement createDomElement() => createDomHTMLTextAreaElement();
+  DomHTMLElement createDomElement() => createMultilineTextArea();
+}
+
+DomHTMLTextAreaElement createMultilineTextArea() {
+  final element = createDomHTMLTextAreaElement();
+  // Scrollbar width affects text layout. This zeroes out the scrollbar width.
+  element.style.scrollbarWidth = 'none';
+  return element;
 }
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 79aae60ac65f7..244e7f4e2da39 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
@@ -64,7 +64,6 @@ void _setStaticStyleAttributes(DomHTMLElement domElement) {
     // For more details, see: https://developer.mozilla.org/en-US/docs/Web/CSS/forced-color-adjust
     ..setProperty('forced-color-adjust', 'none')
     ..whiteSpace = 'pre-wrap'
-    ..alignContent = 'center'
     ..position = 'absolute'
     ..top = '0'
     ..left = '0'
@@ -108,7 +107,6 @@ void _styleAutofillElements(
   final DomCSSStyleDeclaration elementStyle = domElement.style;
   elementStyle
     ..whiteSpace = 'pre-wrap'
-    ..alignContent = 'center'
     ..padding = '0'
     ..opacity = '1'
     ..color = 'transparent'
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 a6370f48cc381..ae14801ec2aaf 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
@@ -840,6 +840,45 @@ Future<void> testMain() async {
       expect(spy.messages, isEmpty);
     });
 
+    test('Does not align content in autofill group elements', () {
+      final setClient = MethodCall('TextInput.setClient', <dynamic>[
+        123,
+        createFlutterConfig('text'),
+      ]);
+      sendFrameworkMessage(codec.encodeMethodCall(setClient));
+
+      const setEditingState = MethodCall('TextInput.setEditingState', <String, dynamic>{
+        'text': 'abcd',
+        'selectionBase': 2,
+        'selectionExtent': 3,
+      });
+      sendFrameworkMessage(codec.encodeMethodCall(setEditingState));
+
+      const show = MethodCall('TextInput.show');
+      sendFrameworkMessage(codec.encodeMethodCall(show));
+
+      // Form elements
+      {
+        final formElement = textEditing!.configuration!.autofillGroup!.formElement;
+        expect(formElement.style.alignContent, isEmpty);
+
+        // Should contain one <input type="text"> and one <input type="submit">
+        expect(formElement.children, hasLength(2));
+
+        final inputElement = formElement.children.first;
+        expect(inputElement.style.alignContent, isEmpty);
+
+        final submitElement = formElement.children.last;
+        expect(submitElement.style.alignContent, isEmpty);
+      }
+
+      // Active element
+      {
+        final DomHTMLElement activeElement = textEditing!.strategy.activeDomElement;
+        expect(activeElement.style.alignContent, isEmpty);
+      }
+    });
+
     test('focus and connection with blur', () async {
       // In all the desktop browsers we are keeping the connection
       // open, keep the text editing element focused if it receives a blur
@@ -3585,6 +3624,10 @@ Future<void> testMain() async {
       // though it supports forced-colors. Safari doesn't support forced-colors
       // so this isn't a problem there.
     }, skip: isFirefox || isSafari);
+
+    test('Multi-line text area scrollbars are zero-width', () {
+      expect(createMultilineTextArea().style.scrollbarWidth, 'none');
+    });
   });
 }
 


Reviews:
user: login: loic-sharma
state: COMMENTED
author_association: MEMBER
submitted_at: 2025-04-04T16:24:26Z
user: login: justinmc
body: LGTM :+1: 

So the text in the underlying HTML input was not in the same position as Flutter's text because the HTML input had scrollbars and was aligned differently? Makes sense assuming this fixes the bug in the issue.

I guess it's not possible to write a test that reproduces the actual bug in the issue since the problem was with the engine.

The Google test failures seem to be an infrastructure problem of some sort.
state: APPROVED
author_association: CONTRIBUTOR
submitted_at: 2025-04-04T16:51:33Z
user: login: yjbanov
state: COMMENTED
author_association: CONTRIBUTOR
submitted_at: 2025-04-04T17:36:28Z

Review comments:
diff_hunk: @@ -184,5 +184,12 @@ class MultilineInputType extends EngineInputType {
   String? get inputmodeAttribute => null;
 
   @override
-  DomHTMLElement createDomElement() => createDomHTMLTextAreaElement();
+  DomHTMLElement createDomElement() => createMultilineTextArea();
+}
+
+DomHTMLTextAreaElement createMultilineTextArea() {
+  final element = createDomHTMLTextAreaElement();
+  // Scrollbar width affects text layout. This zeros out the scrollbar width.
path: engine/src/flutter/lib/web_ui/lib/src/engine/text_editing/input_type.dart
user: login: loic-sharma
body: Most pedantic nit in the world, I'm sorry: 😂 

```suggestion
  // Scrollbar width affects text layout. This zeroes out the scrollbar width.
```
created_at: 2025-04-04T16:24:26Z
author_association: MEMBER
diff_hunk: @@ -840,6 +840,45 @@ Future<void> testMain() async {
       expect(spy.messages, isEmpty);
     });
 
+    test('Does not align content in autofill group elements', () {
+      final setClient = MethodCall('TextInput.setClient', <dynamic>[
+        123,
+        createFlutterConfig('text'),
+      ]);
+      sendFrameworkMessage(codec.encodeMethodCall(setClient));
+
+      const setEditingState = MethodCall('TextInput.setEditingState', <String, dynamic>{
+        'text': 'abcd',
+        'selectionBase': 2,
+        'selectionExtent': 3,
+      });
+      sendFrameworkMessage(codec.encodeMethodCall(setEditingState));
+
+      const show = MethodCall('TextInput.show');
+      sendFrameworkMessage(codec.encodeMethodCall(show));
+
+      // Form elements
+      {
+        final formElement = textEditing!.configuration!.autofillGroup!.formElement;
+        expect(formElement.style.alignContent, isEmpty);
+
+        // Should contain one <input type="text"> and one <input type="submit">
+        expect(formElement.children, hasLength(2));
+
+        final inputElement = formElement.children.first;
+        expect(inputElement.style.alignContent, isEmpty);
+
+        final submitElement = formElement.children.last;
+        expect(submitElement.style.alignContent, isEmpty);
+      }
path: engine/src/flutter/lib/web_ui/test/engine/text_editing_test.dart
user: login: justinmc
body: I like this use of blocks to isolate variables, I might start doing this in tests...
created_at: 2025-04-04T16:47:43Z
author_association: CONTRIBUTOR
diff_hunk: @@ -184,5 +184,12 @@ class MultilineInputType extends EngineInputType {
   String? get inputmodeAttribute => null;
 
   @override
-  DomHTMLElement createDomElement() => createDomHTMLTextAreaElement();
+  DomHTMLElement createDomElement() => createMultilineTextArea();
+}
+
+DomHTMLTextAreaElement createMultilineTextArea() {
+  final element = createDomHTMLTextAreaElement();
+  // Scrollbar width affects text layout. This zeros out the scrollbar width.
path: engine/src/flutter/lib/web_ui/lib/src/engine/text_editing/input_type.dart
user: login: yjbanov
body: Done
created_at: 2025-04-04T17:36:28Z
author_association: CONTRIBUTOR

PR comments:
user: login: yjbanov
created_at: 2025-04-04T17:41:31Z
body: > So the text in the underlying HTML input was not in the same position as Flutter's text because the HTML input had scrollbars and was aligned differently? Makes sense assuming this fixes the bug in the issue.

It fixes the issue in my local testing. However, if you ask why it fixes it, I don't have a good explanation. My best guess is that the misalignment of the start of the text (top-left corner in LTR, and top-right in RTL) confuses the browser about start-end selection regions vs pointer events.

> I guess it's not possible to write a test that reproduces the actual bug in the issue since the problem was with the engine.

The challenge is that we're not able to dispatch pointer events in a way that would reproduce text selection in a way it happens in reality. There's some gnarly interaction between Flutter's `EditableText` and the underlying `<input>` element. I'm not aware of a way to synthesize events that would interact the same way.

> The Google test failures seem to be an infrastructure problem of some sort.

Yeah, I'm guessing we still don't know how to test engine code changes in the monorepo world.
author_association: CONTRIBUTOR
user: login: auto-submit[bot]
created_at: 2025-04-04T18:43:51Z
body: autosubmit label was removed for flutter/flutter/166565, because - The status or check suite [Google testing](http://frob/#/flutter/presubmit?pr=166565) has failed. Please fix the issues identified (or deflake) before re-applying this label.

author_association: CONTRIBUTOR
user: login: jtmcdole
created_at: 2025-04-05T17:11:06Z
body: Reason for revert: Tree is broken https://ci.chromium.org/ui/p/flutter/builders/prod/Linux%20linux_web_engine_tests/1240/overview
author_association: MEMBER