[flutter_releases] Flutter Stable 2.2.1 Engine Cherrypicks (#26405)

* 'Update Dart SDK to 375a2d7c66c8eb5d40872bdb57dd664b5f850d94'

* Fix a11y tab traversal (#25797)

* fix race with framework when using tab to traverse in a11y mode

* Fix CanvasKit SVG clipPath leak (#26227)

Co-authored-by: Yegor <yjbanov@google.com>
Co-authored-by: Harry Terkelsen <hterkelsen@users.noreply.github.com>
diff --git a/DEPS b/DEPS
index 9229478..d522f9e 100644
--- a/DEPS
+++ b/DEPS
@@ -35,7 +35,7 @@
   # Dart is: https://github.com/dart-lang/sdk/blob/master/DEPS.
   # You can use //tools/dart/create_updated_flutter_deps.py to produce
   # updated revision list of existing dependencies.
-  'dart_revision': '9094f738083c746a15b496ea5f882362a3dc4889',
+  'dart_revision': '375a2d7c66c8eb5d40872bdb57dd664b5f850d94',
 
   # WARNING: DO NOT EDIT MANUALLY
   # The lines between blank lines above and below are generated by a script. See create_updated_flutter_deps.py
diff --git a/lib/web_ui/lib/src/engine/canvaskit/embedded_views.dart b/lib/web_ui/lib/src/engine/canvaskit/embedded_views.dart
index aa9055f..9c3e978 100644
--- a/lib/web_ui/lib/src/engine/canvaskit/embedded_views.dart
+++ b/lib/web_ui/lib/src/engine/canvaskit/embedded_views.dart
@@ -187,7 +187,7 @@
       );
       _rootViews[viewId] = newPlatformViewRoot;
     }
-    _applyMutators(params.mutators, platformView);
+    _applyMutators(params.mutators, platformView, viewId);
   }
 
   int _countClips(MutatorsStack mutators) {
@@ -233,11 +233,33 @@
     return head;
   }
 
-  void _applyMutators(MutatorsStack mutators, html.Element embeddedView) {
+  /// Clean up the old SVG clip definitions, as this platform view is about to
+  /// be recomposited.
+  void _cleanUpClipDefs(int viewId) {
+    if (_svgClipDefs.containsKey(viewId)) {
+      final html.Element clipDefs =
+          _svgPathDefs!.querySelector('#sk_path_defs')!;
+      final List<html.Element> nodesToRemove = <html.Element>[];
+      final Set<String> oldDefs = _svgClipDefs[viewId]!;
+      for (html.Element child in clipDefs.children) {
+        if (oldDefs.contains(child.id)) {
+          nodesToRemove.add(child);
+        }
+      }
+      for (html.Element node in nodesToRemove) {
+        node.remove();
+      }
+      _svgClipDefs[viewId]!.clear();
+    }
+  }
+
+  void _applyMutators(
+      MutatorsStack mutators, html.Element embeddedView, int viewId) {
     html.Element head = embeddedView;
     Matrix4 headTransform = Matrix4.identity();
     double embeddedOpacity = 1.0;
     _resetAnchor(head);
+    _cleanUpClipDefs(viewId);
 
     for (final Mutator mutator in mutators) {
       switch (mutator.type) {
@@ -265,28 +287,38 @@
             html.Element pathDefs =
                 _svgPathDefs!.querySelector('#sk_path_defs')!;
             _clipPathCount += 1;
+            final String clipId = 'svgClip$_clipPathCount';
             html.Node newClipPath = html.DocumentFragment.svg(
-              '<clipPath id="svgClip$_clipPathCount">'
+              '<clipPath id="$clipId">'
               '<path d="${path.toSvgString()}">'
               '</path></clipPath>',
               treeSanitizer: _NullTreeSanitizer(),
             );
             pathDefs.append(newClipPath);
-            clipView.style.clipPath = 'url(#svgClip$_clipPathCount)';
+            // Store the id of the node instead of [newClipPath] directly. For
+            // some reason, calling `newClipPath.remove()` doesn't remove it
+            // from the DOM.
+            _svgClipDefs.putIfAbsent(viewId, () => <String>{}).add(clipId);
+            clipView.style.clipPath = 'url(#$clipId)';
           } else if (mutator.path != null) {
             final CkPath path = mutator.path as CkPath;
             _ensureSvgPathDefs();
             html.Element pathDefs =
                 _svgPathDefs!.querySelector('#sk_path_defs')!;
             _clipPathCount += 1;
+            final String clipId = 'svgClip$_clipPathCount';
             html.Node newClipPath = html.DocumentFragment.svg(
-              '<clipPath id="svgClip$_clipPathCount">'
+              '<clipPath id="$clipId">'
               '<path d="${path.toSvgString()}">'
               '</path></clipPath>',
               treeSanitizer: _NullTreeSanitizer(),
             );
             pathDefs.append(newClipPath);
-            clipView.style.clipPath = 'url(#svgClip$_clipPathCount)';
+            // Store the id of the node instead of [newClipPath] directly. For
+            // some reason, calling `newClipPath.remove()` doesn't remove it
+            // from the DOM.
+            _svgClipDefs.putIfAbsent(viewId, () => <String>{}).add(clipId);
+            clipView.style.clipPath = 'url(#$clipId)';
           }
           _resetAnchor(clipView);
           head = clipView;
@@ -323,6 +355,9 @@
 
   html.Element? _svgPathDefs;
 
+  /// The nodes containing the SVG clip definitions needed to clip this view.
+  Map<int, Set<String>> _svgClipDefs = <int, Set<String>>{};
+
   /// Ensures we add a container of SVG path defs to the DOM so they can
   /// be referred to in clip-path: url(#blah).
   void _ensureSvgPathDefs() {
@@ -411,6 +446,8 @@
       _currentCompositionParams.remove(viewId);
       _clipCount.remove(viewId);
       _viewsToRecomposite.remove(viewId);
+      _cleanUpClipDefs(viewId);
+      _svgClipDefs.remove(viewId);
     }
     _viewsToDispose.clear();
   }
@@ -439,6 +476,14 @@
 
     _overlays[viewId] = overlay;
   }
+
+  /// Deletes SVG clip paths, useful for tests.
+  void debugCleanupSvgClipPaths() {
+    _svgPathDefs?.children.single.children.forEach((element) {
+      element.remove();
+    });
+    _svgClipDefs.clear();
+  }
 }
 
 /// Caches surfaces used to overlay platform views.
diff --git a/lib/web_ui/lib/src/engine/dom_renderer.dart b/lib/web_ui/lib/src/engine/dom_renderer.dart
index cc0dff0..1f0eb83 100644
--- a/lib/web_ui/lib/src/engine/dom_renderer.dart
+++ b/lib/web_ui/lib/src/engine/dom_renderer.dart
@@ -93,8 +93,8 @@
   /// This getter calls the `hasFocus` method of the `Document` interface.
   /// See for more details:
   /// https://developer.mozilla.org/en-US/docs/Web/API/Document/hasFocus
-  bool? get windowHasFocus =>
-      js_util.callMethod(html.document, 'hasFocus', <dynamic>[]);
+  bool get windowHasFocus =>
+      js_util.callMethod(html.document, 'hasFocus', <dynamic>[]) ?? false;
 
   void _setupHotRestart() {
     // This persists across hot restarts to clear stale DOM.
diff --git a/lib/web_ui/lib/src/engine/semantics/label_and_value.dart b/lib/web_ui/lib/src/engine/semantics/label_and_value.dart
index 7975ba3..c573b09 100644
--- a/lib/web_ui/lib/src/engine/semantics/label_and_value.dart
+++ b/lib/web_ui/lib/src/engine/semantics/label_and_value.dart
@@ -50,12 +50,9 @@
     final bool hasValue = semanticsObject.hasValue;
     final bool hasLabel = semanticsObject.hasLabel;
 
-    // If the node is incrementable or a text field the value is reported to the
-    // browser via the respective role managers. We do not need to also render
-    // it again here.
-    final bool shouldDisplayValue = hasValue &&
-        !semanticsObject.isIncrementable &&
-        !semanticsObject.isTextField;
+    // If the node is incrementable the value is reported to the browser via
+    // the respective role manager. We do not need to also render it again here.
+    final bool shouldDisplayValue = hasValue && !semanticsObject.isIncrementable;
 
     if (!hasLabel && !shouldDisplayValue) {
       _cleanUpDom();
diff --git a/lib/web_ui/lib/src/engine/semantics/semantics.dart b/lib/web_ui/lib/src/engine/semantics/semantics.dart
index 3472695..5c05e65 100644
--- a/lib/web_ui/lib/src/engine/semantics/semantics.dart
+++ b/lib/web_ui/lib/src/engine/semantics/semantics.dart
@@ -270,8 +270,8 @@
   }
 
   /// See [ui.SemanticsUpdateBuilder.updateNode].
-  int? get flags => _flags;
-  int? _flags;
+  int get flags => _flags;
+  int _flags = 0;
 
   /// Whether the [flags] field has been updated but has not been applied to the
   /// DOM yet.
@@ -584,7 +584,7 @@
   SemanticsObject? _parent;
 
   /// Whether this node currently has a given [SemanticsFlag].
-  bool hasFlag(ui.SemanticsFlag flag) => _flags! & flag.index != 0;
+  bool hasFlag(ui.SemanticsFlag flag) => _flags & flag.index != 0;
 
   /// Whether [actions] contains the given action.
   bool hasAction(ui.SemanticsAction action) => (_actions! & action.index) != 0;
@@ -785,15 +785,24 @@
   /// > A map literal is ordered: iterating over the keys and/or values of the maps always happens in the order the keys appeared in the source code.
   final Map<Role, RoleManager?> _roleManagers = <Role, RoleManager?>{};
 
+  /// Returns the role manager for the given [role].
+  ///
+  /// If a role manager does not exist for the given role, returns null.
+  RoleManager? debugRoleManagerFor(Role role) => _roleManagers[role];
+
   /// Detects the roles that this semantics object corresponds to and manages
   /// the lifecycles of [SemanticsObjectRole] objects.
   void _updateRoles() {
-    _updateRole(Role.labelAndValue, (hasLabel || hasValue) && !isVisualOnly);
+    _updateRole(Role.labelAndValue, (hasLabel || hasValue) && !isTextField && !isVisualOnly);
     _updateRole(Role.textField, isTextField);
-    _updateRole(
-        Role.tappable,
-        hasAction(ui.SemanticsAction.tap) ||
-            hasFlag(ui.SemanticsFlag.isButton));
+
+    bool shouldUseTappableRole =
+      (hasAction(ui.SemanticsAction.tap) || hasFlag(ui.SemanticsFlag.isButton)) &&
+      // Text fields manage their own focus/tap interactions. We don't need the
+      // tappable role manager. It only confuses AT.
+      !isTextField;
+
+    _updateRole(Role.tappable, shouldUseTappableRole);
     _updateRole(Role.incrementable, isIncrementable);
     _updateRole(Role.scrollable,
         isVerticalScrollContainer || isHorizontalScrollContainer);
@@ -1145,7 +1154,7 @@
     _instance = null;
   }
 
-  final Map<int?, SemanticsObject?> _semanticsTree = <int?, SemanticsObject?>{};
+  final Map<int, SemanticsObject> _semanticsTree = <int, SemanticsObject>{};
 
   /// Map [SemanticsObject.id] to parent [SemanticsObject] it was attached to
   /// this frame.
@@ -1222,8 +1231,8 @@
   /// Returns the entire semantics tree for testing.
   ///
   /// Works only in debug mode.
-  Map<int?, SemanticsObject?>? get debugSemanticsTree {
-    Map<int?, SemanticsObject?>? result;
+  Map<int, SemanticsObject>? get debugSemanticsTree {
+    Map<int, SemanticsObject>? result;
     assert(() {
       result = _semanticsTree;
       return true;
diff --git a/lib/web_ui/lib/src/engine/semantics/tappable.dart b/lib/web_ui/lib/src/engine/semantics/tappable.dart
index f7edf34..dea3e86 100644
--- a/lib/web_ui/lib/src/engine/semantics/tappable.dart
+++ b/lib/web_ui/lib/src/engine/semantics/tappable.dart
@@ -21,6 +21,10 @@
   void update() {
     final html.Element element = semanticsObject.element;
 
+    // "tab-index=0" is used to allow keyboard traversal of non-form elements.
+    // See also: https://developer.mozilla.org/en-US/docs/Web/Accessibility/Keyboard-navigable_JavaScript_widgets
+    element.tabIndex = 0;
+
     semanticsObject.setAriaRole(
         'button', semanticsObject.hasFlag(ui.SemanticsFlag.isButton));
 
@@ -49,6 +53,11 @@
         _stopListening();
       }
     }
+
+    // Request focus so that the AT shifts a11y focus to this node.
+    if (semanticsObject.isFlagsDirty && semanticsObject.hasFocus) {
+      element.focus();
+    }
   }
 
   void _stopListening() {
diff --git a/lib/web_ui/lib/src/engine/semantics/text_field.dart b/lib/web_ui/lib/src/engine/semantics/text_field.dart
index fe01510..c428b83 100644
--- a/lib/web_ui/lib/src/engine/semantics/text_field.dart
+++ b/lib/web_ui/lib/src/engine/semantics/text_field.dart
@@ -15,28 +15,94 @@
 /// This class is still responsible for hooking up the DOM element with the
 /// [HybridTextEditing] instance so that changes are communicated to Flutter.
 class SemanticsTextEditingStrategy extends DefaultTextEditingStrategy {
-  /// The semantics object which this text editing element belongs to.
-  final SemanticsObject semanticsObject;
+  /// Initializes the [SemanticsTextEditingStrategy] singleton.
+  ///
+  /// This method must be called prior to accessing [instance].
+  static SemanticsTextEditingStrategy ensureInitialized(HybridTextEditing owner) {
+    if (_instance != null && instance.owner == owner) {
+      return instance;
+    }
+    return _instance = SemanticsTextEditingStrategy(owner);
+  }
+
+  /// The [SemanticsTextEditingStrategy] singleton.
+  static SemanticsTextEditingStrategy get instance => _instance!;
+  static SemanticsTextEditingStrategy? _instance;
 
   /// Creates a [SemanticsTextEditingStrategy] that eagerly instantiates
   /// [domElement] so the caller can insert it before calling
   /// [SemanticsTextEditingStrategy.enable].
-  SemanticsTextEditingStrategy(SemanticsObject semanticsObject,
-      HybridTextEditing owner, html.HtmlElement domElement)
-      : this.semanticsObject = semanticsObject,
-        super(owner) {
-    // Make sure the DOM element is of a type that we support for text editing.
-    // TODO(yjbanov): move into initializer list when https://github.com/dart-lang/sdk/issues/37881 is fixed.
-    assert((domElement is html.InputElement) ||
-        (domElement is html.TextAreaElement));
-    super.domElement = domElement;
+  SemanticsTextEditingStrategy(HybridTextEditing owner)
+      : super(owner);
+
+  /// The text field whose DOM element is currently used for editing.
+  ///
+  /// If this field is null, no editing takes place.
+  TextField? activeTextField;
+
+  /// Current input configuration supplied by the "flutter/textinput" channel.
+  InputConfiguration? inputConfig;
+
+  _OnChangeCallback? onChange;
+  _OnActionCallback? onAction;
+
+  /// The semantics implementation does not operate on DOM nodes, but only
+  /// remembers the config and callbacks. This is because the DOM nodes are
+  /// supplied in the semantics update and enabled by [activate].
+  @override
+  void enable(
+    InputConfiguration inputConfig, {
+    required _OnChangeCallback onChange,
+    required _OnActionCallback onAction,
+  }) {
+    this.inputConfig = inputConfig;
+    this.onChange = onChange;
+    this.onAction = onAction;
+  }
+
+  /// Attaches the DOM element owned by [textField] to the text editing
+  /// strategy.
+  ///
+  /// This method must be called after [enable] to name sure that [inputConfig],
+  /// [onChange], and [onAction] are not null.
+  void activate(TextField textField) {
+    assert(
+      inputConfig != null && onChange != null && onAction != null,
+      '"enable" should be called before "enableFromSemantics" and initialize input configuration',
+    );
+
+    if (activeTextField == textField) {
+      // The specified field is already active. Skip.
+      return;
+    } else if (activeTextField != null) {
+      // Another text field is currently active. Deactivate it before switching.
+      disable();
+    }
+
+    activeTextField = textField;
+    domElement = textField.editableElement;
+    _syncStyle();
+    super.enable(inputConfig!, onChange: onChange!, onAction: onAction!);
+  }
+
+  /// Detaches the DOM element owned by [textField] from this text editing
+  /// strategy.
+  ///
+  /// Typically at this point the element loses focus (blurs) and stops being
+  /// used for editing.
+  void deactivate(TextField textField) {
+    if (activeTextField == textField) {
+      disable();
+    }
   }
 
   @override
   void disable() {
     // We don't want to remove the DOM element because the caller is responsible
     // for that. However we still want to stop editing, cleanup the handlers.
-    assert(isEnabled);
+    if (!isEnabled) {
+      return;
+    }
 
     isEnabled = false;
     _style = null;
@@ -48,28 +114,15 @@
     _subscriptions.clear();
     _lastEditingState = null;
 
-    // If focused element is a part of a form, it needs to stay on the DOM
-    // until the autofill context of the form is finalized.
-    // More details on `TextInput.finishAutofillContext` call.
-    if (_appendedToForm &&
-        _inputConfiguration.autofillGroup?.formElement != null) {
-      // We want to save the domElement with the form. However we still
-      // need to keep the text editing domElement attached to the semantics
-      // tree. In order to simplify the logic we will create a clone of the
-      // element.
-      final html.Node textFieldClone = domElement.clone(false);
-      domElement = textFieldClone as html.HtmlElement;
-      _inputConfiguration.autofillGroup?.storeForm();
-    }
-
     // If the text element still has focus, remove focus from the editable
-    // element to cause the keyboard to hide.
+    // element to cause the on-screen keyboard, if any, to hide (e.g. on iOS,
+    // Android).
     // Otherwise, the keyboard stays on screen even when the user navigates to
     // a different screen (e.g. by hitting the "back" button).
-    if (operatingSystem == OperatingSystem.android ||
-        operatingSystem == OperatingSystem.iOs) {
-      domElement.blur();
-    }
+    domElement?.blur();
+    domElement = null;
+    activeTextField = null;
+    _queuedStyle = null;
   }
 
   @override
@@ -80,27 +133,15 @@
     }
 
     // Subscribe to text and selection changes.
-    _subscriptions.add(domElement.onInput.listen(_handleChange));
-
-    _subscriptions.add(domElement.onKeyDown.listen(_maybeSendAction));
-
+    _subscriptions.add(activeDomElement.onInput.listen(_handleChange));
+    _subscriptions.add(activeDomElement.onKeyDown.listen(_maybeSendAction));
     _subscriptions.add(html.document.onSelectionChange.listen(_handleChange));
-
     preventDefaultForMouseEvents();
   }
 
   @override
-  void initializeElementPlacement() {
-    // Element placement is done by [TextField].
-  }
-
-  @override
   void initializeTextEditing(InputConfiguration inputConfig,
       {_OnChangeCallback? onChange, _OnActionCallback? onAction}) {
-    // In accesibilty mode, the user of this class is supposed to insert the
-    // [domElement] on their own. Let's make sure they did.
-    assert(html.document.body!.contains(domElement));
-
     isEnabled = true;
     _inputConfiguration = inputConfig;
     _onChange = onChange;
@@ -109,30 +150,46 @@
   }
 
   @override
-  void setEditingState(EditingState? editingState) {
-    super.setEditingState(editingState);
-
-    // Refocus after setting editing state.
-    domElement.focus();
-  }
-
-  @override
   void placeElement() {
     // If this text editing element is a part of an autofill group.
     if (hasAutofillGroup) {
       placeForm();
     }
-    domElement.focus();
+    activeDomElement.focus();
+  }
+
+  @override
+  void initializeElementPlacement() {
+    // Element placement is done by [TextField].
   }
 
   @override
   void placeForm() {
-    // Switch domElement's parent from semantics object to form.
-    domElement.remove();
-    _inputConfiguration.autofillGroup!.formElement.append(domElement);
-    semanticsObject.element
-        .append(_inputConfiguration.autofillGroup!.formElement);
-    _appendedToForm = true;
+  }
+
+  @override
+  void updateElementPlacement(EditableTextGeometry geometry) {
+    // Element placement is done by [TextField].
+  }
+
+  EditableTextStyle? _queuedStyle;
+
+  @override
+  void updateElementStyle(EditableTextStyle style) {
+    _queuedStyle = style;
+    _syncStyle();
+  }
+
+  /// Apply style to the element, if both style and element are available.
+  ///
+  /// Because style is supplied by the "flutter/textinput" channel and the DOM
+  /// element is supplied by the semantics tree, the existence of both at the
+  /// same time is not guaranteed.
+  void _syncStyle() {
+    if (_queuedStyle == null || domElement == null) {
+      return;
+    }
+    super.updateElementStyle(_queuedStyle!);
   }
 }
 
@@ -147,20 +204,15 @@
 class TextField extends RoleManager {
   TextField(SemanticsObject semanticsObject)
       : super(Role.textField, semanticsObject) {
-    final html.HtmlElement editableDomElement =
+    editableElement =
         semanticsObject.hasFlag(ui.SemanticsFlag.isMultiline)
             ? html.TextAreaElement()
             : html.InputElement();
-    textEditingElement = SemanticsTextEditingStrategy(
-      semanticsObject,
-      textEditing,
-      editableDomElement,
-    );
     _setupDomElement();
   }
 
-  SemanticsTextEditingStrategy? textEditingElement;
-  html.HtmlElement get _textFieldElement => textEditingElement!.domElement;
+  /// The element used for editing, e.g. `<input>`, `<textarea>`.
+  late final html.HtmlElement editableElement;
 
   void _setupDomElement() {
     // On iOS, even though the semantic text field is transparent, the cursor
@@ -168,13 +220,13 @@
     // are made invisible by CSS in [DomRenderer.reset].
     // But there's one more case where iOS highlights text. That's when there's
     // and autocorrect suggestion. To disable that, we have to do the following:
-    _textFieldElement
+    editableElement
       ..spellcheck = false
       ..setAttribute('autocorrect', 'off')
       ..setAttribute('autocomplete', 'off')
       ..setAttribute('data-semantics-role', 'text-field');
 
-    _textFieldElement.style
+    editableElement.style
       ..position = 'absolute'
       // `top` and `left` are intentionally set to zero here.
       //
@@ -189,7 +241,7 @@
       ..left = '0'
       ..width = '${semanticsObject.rect!.width}px'
       ..height = '${semanticsObject.rect!.height}px';
-    semanticsObject.element.append(_textFieldElement);
+    semanticsObject.element.append(editableElement);
 
     switch (browserEngine) {
       case BrowserEngine.blink:
@@ -212,12 +264,11 @@
   /// When in browser gesture mode, the focus is forwarded to the framework as
   /// a tap to initialize editing.
   void _initializeForBlink() {
-    _textFieldElement.addEventListener('focus', (html.Event event) {
+    editableElement.addEventListener('focus', (html.Event event) {
       if (semanticsObject.owner.gestureMode != GestureMode.browserGestures) {
         return;
       }
 
-      textEditing.useCustomEditableElement(textEditingElement);
       EnginePlatformDispatcher.instance.invokeOnSemanticsAction(
           semanticsObject.id, ui.SemanticsAction.tap, null);
     });
@@ -237,14 +288,13 @@
     num? lastTouchStartOffsetX;
     num? lastTouchStartOffsetY;
 
-    _textFieldElement.addEventListener('touchstart', (html.Event event) {
-      textEditing.useCustomEditableElement(textEditingElement);
+    editableElement.addEventListener('touchstart', (html.Event event) {
       final html.TouchEvent touchEvent = event as html.TouchEvent;
       lastTouchStartOffsetX = touchEvent.changedTouches!.last.client.x;
       lastTouchStartOffsetY = touchEvent.changedTouches!.last.client.y;
     }, true);
 
-    _textFieldElement.addEventListener('touchend', (html.Event event) {
+    editableElement.addEventListener('touchend', (html.Event event) {
       final html.TouchEvent touchEvent = event as html.TouchEvent;
 
       if (lastTouchStartOffsetX != null) {
@@ -252,7 +302,7 @@
         final num offsetX = touchEvent.changedTouches!.last.client.x;
         final num offsetY = touchEvent.changedTouches!.last.client.y;
 
-        // This should match the similar constant define in:
+        // This should match the similar constant defined in:
         //
         // lib/src/gestures/constants.dart
         //
@@ -273,15 +323,75 @@
     }, true);
   }
 
+  bool _hasFocused = false;
+
   @override
   void update() {
     // The user is editing the semantic text field directly, so there's no need
     // to do any update here.
+    if (semanticsObject.hasLabel) {
+      editableElement.setAttribute(
+        'aria-label',
+        semanticsObject.label!,
+      );
+    } else {
+      editableElement.removeAttribute('aria-label');
+    }
+
+    editableElement.style
+      ..width = '${semanticsObject.rect!.width}px'
+      ..height = '${semanticsObject.rect!.height}px';
+
+    // Whether we should request that the browser shift focus to the editable
+    // element, so that both the framework and the browser agree on what's
+    // currently focused.
+    bool needsDomFocusRequest = false;
+    final EditingState editingState = EditingState(
+      text: semanticsObject.value,
+      baseOffset: semanticsObject.textSelectionBase,
+      extentOffset: semanticsObject.textSelectionExtent,
+    );
+    if (semanticsObject.hasFocus) {
+      if (!_hasFocused) {
+        _hasFocused = true;
+        SemanticsTextEditingStrategy.instance.activate(this);
+        needsDomFocusRequest = true;
+      }
+      if (html.document.activeElement != editableElement) {
+        needsDomFocusRequest = true;
+      }
+      // Focused elements should have full text editing state applied.
+      SemanticsTextEditingStrategy.instance.setEditingState(editingState);
+    } else if (_hasFocused) {
+      SemanticsTextEditingStrategy.instance.deactivate(this);
+
+      // Only apply text, because this node is not focused.
+      editingState.applyTextToDomElement(editableElement);
+
+      if (_hasFocused && html.document.activeElement == editableElement) {
+        // Unlike `editableElement.focus()` we don't need to schedule `blur`
+        // post-update because `document.activeElement` implies that the
+        // element is already attached to the DOM. If it's not, it can't
+        // possibly be focused and therefore there's no need to blur.
+        editableElement.blur();
+      }
+      _hasFocused = false;
+    }
+
+    if (needsDomFocusRequest) {
+      // Schedule focus post-update to make sure the element is attached to
+      // the document. Otherwise focus() has no effect.
+      semanticsObject.owner.addOneTimePostUpdateCallback(() {
+        if (html.document.activeElement != editableElement) {
+          editableElement.focus();
+        }
+      });
+    }
   }
 
   @override
   void dispose() {
-    _textFieldElement.remove();
-    textEditing.stopUsingCustomEditableElement();
+    editableElement.remove();
+    SemanticsTextEditingStrategy.instance.deactivate(this);
   }
 }
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 c5d908f..1d190ea 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
@@ -8,6 +8,9 @@
 /// Make the content editable span visible to facilitate debugging.
 bool _debugVisibleTextEditing = false;
 
+/// Set this to `true` to print when text input commands are scheduled and run.
+bool _debugPrintTextInputCommands = false;
+
 /// The `keyCode` of the "Enter" key.
 const int _kReturnKeyCode = 13;
 
@@ -486,6 +489,14 @@
   ///
   /// [domElement] can be a [InputElement] or a [TextAreaElement] depending on
   /// the [InputType] of the text field.
+  ///
+  /// This should only be used by focused elements only, because only focused
+  /// elements can have their text selection range set. Attempting to set
+  /// selection range on a non-focused element will cause it to request focus.
+  ///
+  /// See also:
+  ///
+  ///  * [applyTextToDomElement], which is used for non-focused elements.
   void applyToDomElement(html.HtmlElement? domElement) {
     if (domElement is html.InputElement) {
       html.InputElement element = domElement;
@@ -496,6 +507,25 @@
       element.value = text;
       element.setSelectionRange(baseOffset!, extentOffset!);
     } else {
+      throw UnsupportedError('Unsupported DOM element type: <${domElement?.tagName}> (${domElement.runtimeType})');
+    }
+  }
+
+  /// Applies the [text] to the [domElement].
+  ///
+  /// This is used by non-focused elements.
+  ///
+  /// See also:
+  ///
+  ///  * [applyToDomElement], which is used for focused elements.
+  void applyTextToDomElement(html.HtmlElement? domElement) {
+    if (domElement is html.InputElement) {
+      html.InputElement element = domElement;
+      element.value = text;
+    } else if (domElement is html.TextAreaElement) {
+      html.TextAreaElement element = domElement;
+      element.value = text;
+    } else {
       throw UnsupportedError('Unsupported DOM element type');
     }
   }
@@ -653,9 +683,9 @@
       // does not appear on top-left of the page.
       // Refocus on the elements after applying the geometry.
       focusedFormElement!.focus();
-      domElement.focus();
+      activeDomElement.focus();
     } else {
-      _geometry?.applyToDomElement(domElement);
+      _geometry?.applyToDomElement(activeDomElement);
     }
   }
 }
@@ -686,7 +716,7 @@
   /// Making an extra `focus` request causes flickering in Safari.
   @override
   void placeElement() {
-    _geometry?.applyToDomElement(domElement);
+    _geometry?.applyToDomElement(activeDomElement);
     if (hasAutofillGroup) {
       placeForm();
       // On Safari Desktop, when a form is focused, it opens an autofill menu
@@ -703,16 +733,16 @@
       // 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.
-      domElement.focus();
+      activeDomElement.focus();
       if (_lastEditingState != null) {
-        _lastEditingState!.applyToDomElement(domElement);
+        _lastEditingState!.applyToDomElement(activeDomElement);
       }
     }
   }
 
   @override
   void initializeElementPlacement() {
-    domElement.focus();
+    activeDomElement.focus();
   }
 }
 
@@ -745,12 +775,20 @@
   @visibleForTesting
   bool isEnabled = false;
 
-  html.HtmlElement get domElement => _domElement!;
-  set domElement(html.HtmlElement element) {
-    _domElement = element;
-  }
+  /// The DOM element used for editing, if any.
+  html.HtmlElement? domElement;
 
-  html.HtmlElement? _domElement;
+  /// Same as [domElement] but null-checked.
+  ///
+  /// This must only be called in places that know for sure that a DOM element
+  /// is currently available for editing.
+  html.HtmlElement get activeDomElement {
+    assert(
+      domElement != null,
+      'The DOM element of this text editing strategy is not currently active.',
+    );
+    return domElement!;
+  }
 
   late InputConfiguration _inputConfiguration;
   EditingState? _lastEditingState;
@@ -784,18 +822,18 @@
   }) {
     assert(!isEnabled);
 
-    _domElement = inputConfig.inputType.createDomElement();
+    domElement = inputConfig.inputType.createDomElement();
     _applyConfiguration(inputConfig);
 
-    _setStaticStyleAttributes(domElement);
-    _style?.applyToDomElement(domElement);
+    _setStaticStyleAttributes(activeDomElement);
+    _style?.applyToDomElement(activeDomElement);
 
     if (!hasAutofillGroup) {
       // If there is an Autofill Group the `FormElement`, it will be appended to the
       // DOM later, when the first location information arrived.
       // Otherwise, on Blink based Desktop browsers, the autofill menu appears
       // on top left of the screen.
-      domRenderer.glassPaneElement!.append(domElement);
+      domRenderer.glassPaneElement!.append(activeDomElement);
       _appendedToForm = false;
     }
 
@@ -810,19 +848,19 @@
     _inputConfiguration = config;
 
     if (config.readOnly) {
-      domElement.setAttribute('readonly', 'readonly');
+      activeDomElement.setAttribute('readonly', 'readonly');
     } else {
-      domElement.removeAttribute('readonly');
+      activeDomElement.removeAttribute('readonly');
     }
 
     if (config.obscureText) {
-      domElement.setAttribute('type', 'password');
+      activeDomElement.setAttribute('type', 'password');
     }
 
-    config.autofill?.applyToDomElement(domElement, focusedElement: true);
+    config.autofill?.applyToDomElement(activeDomElement, focusedElement: true);
 
     final String autocorrectValue = config.autocorrect ? 'on' : 'off';
-    domElement.setAttribute('autocorrect', autocorrectValue);
+    activeDomElement.setAttribute('autocorrect', autocorrectValue);
   }
 
   @override
@@ -838,16 +876,16 @@
     }
 
     // Subscribe to text and selection changes.
-    _subscriptions.add(domElement.onInput.listen(_handleChange));
+    _subscriptions.add(activeDomElement.onInput.listen(_handleChange));
 
-    _subscriptions.add(domElement.onKeyDown.listen(_maybeSendAction));
+    _subscriptions.add(activeDomElement.onKeyDown.listen(_maybeSendAction));
 
     _subscriptions.add(html.document.onSelectionChange.listen(_handleChange));
 
-    // Refocus on the domElement after blur, so that user can keep editing the
+    // Refocus on the activeDomElement after blur, so that user can keep editing the
     // text field.
-    _subscriptions.add(domElement.onBlur.listen((_) {
-      domElement.focus();
+    _subscriptions.add(activeDomElement.onBlur.listen((_) {
+      activeDomElement.focus();
     }));
 
     preventDefaultForMouseEvents();
@@ -861,12 +899,11 @@
     }
   }
 
-  @mustCallSuper
   @override
   void updateElementStyle(EditableTextStyle style) {
     _style = style;
     if (isEnabled) {
-      _style!.applyToDomElement(domElement);
+      _style!.applyToDomElement(activeDomElement);
     }
   }
 
@@ -889,16 +926,15 @@
     if (_appendedToForm &&
         _inputConfiguration.autofillGroup?.formElement != null) {
       // Subscriptions are removed, listeners won't be triggered.
-      domElement.blur();
-      _hideAutofillElements(domElement, isOffScreen: true);
+      activeDomElement.blur();
+      _hideAutofillElements(activeDomElement, isOffScreen: true);
       _inputConfiguration.autofillGroup?.storeForm();
     } else {
-      domElement.remove();
+      activeDomElement.remove();
     }
-    _domElement = null;
+    domElement = null;
   }
 
-  @mustCallSuper
   @override
   void setEditingState(EditingState? editingState) {
     _lastEditingState = editingState;
@@ -909,18 +945,18 @@
   }
 
   void placeElement() {
-    domElement.focus();
+    activeDomElement.focus();
   }
 
   void placeForm() {
-    _inputConfiguration.autofillGroup!.placeForm(domElement);
+    _inputConfiguration.autofillGroup!.placeForm(activeDomElement);
     _appendedToForm = true;
   }
 
   void _handleChange(html.Event event) {
     assert(isEnabled);
 
-    EditingState newEditingState = EditingState.fromDomElement(domElement,
+    EditingState newEditingState = EditingState.fromDomElement(activeDomElement,
         textCapitalization: _inputConfiguration.textCapitalization);
 
     if (newEditingState != _lastEditingState) {
@@ -963,7 +999,7 @@
     }
 
     // Re-focuses after setting editing state.
-    domElement.focus();
+    activeDomElement.focus();
   }
 
   /// Prevent default behavior for mouse down, up and move.
@@ -972,15 +1008,15 @@
   /// selection conflicts with selection sent from the framework, which creates
   /// flickering during selection by mouse.
   void preventDefaultForMouseEvents() {
-    _subscriptions.add(domElement.onMouseDown.listen((_) {
+    _subscriptions.add(activeDomElement.onMouseDown.listen((_) {
       _.preventDefault();
     }));
 
-    _subscriptions.add(domElement.onMouseUp.listen((_) {
+    _subscriptions.add(activeDomElement.onMouseUp.listen((_) {
       _.preventDefault();
     }));
 
-    _subscriptions.add(domElement.onMouseMove.listen((_) {
+    _subscriptions.add(activeDomElement.onMouseMove.listen((_) {
       _.preventDefault();
     }));
   }
@@ -1039,11 +1075,11 @@
   }) {
     super.initializeTextEditing(inputConfig,
         onChange: onChange, onAction: onAction);
-    inputConfig.inputType.configureInputMode(domElement);
+    inputConfig.inputType.configureInputMode(activeDomElement);
     if (hasAutofillGroup) {
       placeForm();
     }
-    inputConfig.textCapitalization.setAutocapitalizeAttribute(domElement);
+    inputConfig.textCapitalization.setAutocapitalizeAttribute(activeDomElement);
   }
 
   @override
@@ -1051,7 +1087,7 @@
     /// Position the element outside of the page before focusing on it. This is
     /// useful for not triggering a scroll when iOS virtual keyboard is
     /// coming up.
-    domElement.style.transform = 'translate(-9999px, -9999px)';
+    activeDomElement.style.transform = 'translate(-9999px, -9999px)';
 
     _canPosition = false;
   }
@@ -1064,14 +1100,14 @@
     }
 
     // Subscribe to text and selection changes.
-    _subscriptions.add(domElement.onInput.listen(_handleChange));
+    _subscriptions.add(activeDomElement.onInput.listen(_handleChange));
 
-    _subscriptions.add(domElement.onKeyDown.listen(_maybeSendAction));
+    _subscriptions.add(activeDomElement.onKeyDown.listen(_maybeSendAction));
 
     _subscriptions.add(html.document.onSelectionChange.listen(_handleChange));
 
     // Position the DOM element after it is focused.
-    _subscriptions.add(domElement.onFocus.listen((_) {
+    _subscriptions.add(activeDomElement.onFocus.listen((_) {
       // Cancel previous timer if exists.
       _schedulePlacement();
     }));
@@ -1083,7 +1119,7 @@
     //
     // Since in all these cases, the connection needs to be closed,
     // [domRenderer.windowHasFocus] is not checked in [IOSTextEditingStrategy].
-    _subscriptions.add(domElement.onBlur.listen((_) {
+    _subscriptions.add(activeDomElement.onBlur.listen((_) {
       owner.sendTextConnectionClosedToFrameworkIfAny();
     }));
   }
@@ -1121,7 +1157,7 @@
   /// [_positionInputElementTimer] timer is restarted. The element will be
   /// placed to its correct position after [_delayBeforePlacement].
   void _addTapListener() {
-    _subscriptions.add(domElement.onClick.listen((_) {
+    _subscriptions.add(activeDomElement.onClick.listen((_) {
       // Check if the element is already positioned. If not this does not fall
       // under `The user was using the long press, now they want to enter text
       // via keyboard` journey.
@@ -1145,8 +1181,8 @@
 
   @override
   void placeElement() {
-    domElement.focus();
-    _geometry?.applyToDomElement(domElement);
+    activeDomElement.focus();
+    _geometry?.applyToDomElement(activeDomElement);
   }
 }
 
@@ -1168,13 +1204,13 @@
   }) {
     super.initializeTextEditing(inputConfig,
         onChange: onChange, onAction: onAction);
-    inputConfig.inputType.configureInputMode(domElement);
+    inputConfig.inputType.configureInputMode(activeDomElement);
     if (hasAutofillGroup) {
       placeForm();
     } else {
-      domRenderer.glassPaneElement!.append(domElement);
+      domRenderer.glassPaneElement!.append(activeDomElement);
     }
-    inputConfig.textCapitalization.setAutocapitalizeAttribute(domElement);
+    inputConfig.textCapitalization.setAutocapitalizeAttribute(activeDomElement);
   }
 
   @override
@@ -1185,19 +1221,19 @@
     }
 
     // Subscribe to text and selection changes.
-    _subscriptions.add(domElement.onInput.listen(_handleChange));
+    _subscriptions.add(activeDomElement.onInput.listen(_handleChange));
 
-    _subscriptions.add(domElement.onKeyDown.listen(_maybeSendAction));
+    _subscriptions.add(activeDomElement.onKeyDown.listen(_maybeSendAction));
 
     _subscriptions.add(html.document.onSelectionChange.listen(_handleChange));
 
-    _subscriptions.add(domElement.onBlur.listen((_) {
-      if (domRenderer.windowHasFocus!) {
+    _subscriptions.add(activeDomElement.onBlur.listen((_) {
+      if (domRenderer.windowHasFocus) {
         // Chrome on Android will hide the onscreen keyboard when you tap outside
         // the text box. Instead, we want the framework to tell us to hide the
         // keyboard via `TextInput.clearClient` or `TextInput.hide`. Therefore
         // refocus as long as [domRenderer.windowHasFocus] is true.
-        domElement.focus();
+        activeDomElement.focus();
       } else {
         owner.sendTextConnectionClosedToFrameworkIfAny();
       }
@@ -1206,8 +1242,8 @@
 
   @override
   void placeElement() {
-    domElement.focus();
-    _geometry?.applyToDomElement(domElement);
+    activeDomElement.focus();
+    _geometry?.applyToDomElement(activeDomElement);
   }
 }
 
@@ -1239,9 +1275,9 @@
     }
 
     // Subscribe to text and selection changes.
-    _subscriptions.add(domElement.onInput.listen(_handleChange));
+    _subscriptions.add(activeDomElement.onInput.listen(_handleChange));
 
-    _subscriptions.add(domElement.onKeyDown.listen(_maybeSendAction));
+    _subscriptions.add(activeDomElement.onKeyDown.listen(_maybeSendAction));
 
     // Detects changes in text selection.
     //
@@ -1256,18 +1292,18 @@
     //
     // After each keyup, the start/end values of the selection is compared to
     // the previously saved editing state.
-    _subscriptions.add(domElement.onKeyUp.listen((event) {
+    _subscriptions.add(activeDomElement.onKeyUp.listen((event) {
       _handleChange(event);
     }));
 
     // In Firefox the context menu item "Select All" does not work without
     // listening to onSelect. On the other browsers onSelectionChange is
     // enough for covering "Select All" functionality.
-    _subscriptions.add(domElement.onSelect.listen(_handleChange));
+    _subscriptions.add(activeDomElement.onSelect.listen(_handleChange));
 
-    // Refocus on the domElement after blur, so that user can keep editing the
+    // Refocus on the activeDomElement after blur, so that user can keep editing the
     // text field.
-    _subscriptions.add(domElement.onBlur.listen((_) {
+    _subscriptions.add(activeDomElement.onBlur.listen((_) {
       _postponeFocus();
     }));
 
@@ -1280,23 +1316,214 @@
     // Calling focus inside a Timer for `0` milliseconds guarantee that it is
     // called after blur event propagation is completed.
     Timer(const Duration(milliseconds: 0), () {
-      domElement.focus();
+      activeDomElement.focus();
     });
   }
 
   @override
   void placeElement() {
-    domElement.focus();
-    _geometry?.applyToDomElement(domElement);
+    activeDomElement.focus();
+    _geometry?.applyToDomElement(activeDomElement);
     // 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 (_lastEditingState != null) {
-      _lastEditingState!.applyToDomElement(domElement);
+      _lastEditingState!.applyToDomElement(activeDomElement);
     }
   }
 }
 
+/// Base class for all `TextInput` commands sent through the `flutter/textinput`
+/// channel.
+@immutable
+abstract class TextInputCommand {
+  const TextInputCommand();
+
+  /// Executes the logic for this command.
+  void run(HybridTextEditing textEditing);
+}
+
+/// Responds to the 'TextInput.setClient' message.
+class TextInputSetClient extends TextInputCommand {
+  TextInputSetClient({
+    required this.clientId,
+    required this.configuration,
+  });
+
+  final int clientId;
+  final InputConfiguration configuration;
+
+  void run(HybridTextEditing textEditing) {
+    final bool clientIdChanged = textEditing._clientId != null && textEditing._clientId != clientId;
+    if (clientIdChanged && textEditing.isEditing) {
+      // We're connecting a new client. Any pending command for the previous client
+      // are irrelevant at this point.
+      textEditing.stopEditing();
+    }
+    textEditing._clientId = clientId;
+    textEditing.configuration = configuration;
+  }
+}
+
+/// Creates the text editing strategy used in non-a11y mode.
+DefaultTextEditingStrategy createDefaultTextEditingStrategy(HybridTextEditing textEditing) {
+  DefaultTextEditingStrategy strategy;
+  if (browserEngine == BrowserEngine.webkit &&
+      operatingSystem == OperatingSystem.iOs) {
+    strategy = IOSTextEditingStrategy(textEditing);
+  } else if (browserEngine == BrowserEngine.webkit) {
+    strategy = SafariDesktopTextEditingStrategy(textEditing);
+  } else if (browserEngine == BrowserEngine.blink &&
+      operatingSystem == OperatingSystem.android) {
+    strategy = AndroidTextEditingStrategy(textEditing);
+  } else if (browserEngine == BrowserEngine.firefox) {
+    strategy = FirefoxTextEditingStrategy(textEditing);
+  } else {
+    strategy = GloballyPositionedTextEditingStrategy(textEditing);
+  }
+  return strategy;
+}
+
+/// Responds to the 'TextInput.updateConfig' message.
+class TextInputUpdateConfig extends TextInputCommand {
+  TextInputUpdateConfig();
+
+  void run(HybridTextEditing textEditing) {
+    textEditing.strategy._applyConfiguration(textEditing.configuration!);
+  }
+}
+
+/// Responds to the 'TextInput.setEditingState' message.
+class TextInputSetEditingState extends TextInputCommand {
+  TextInputSetEditingState({
+    required this.state,
+  });
+
+  final EditingState state;
+
+  void run(HybridTextEditing textEditing) {
+    textEditing.strategy.setEditingState(state);
+  }
+}
+
+/// Responds to the 'TextInput.show' message.
+class TextInputShow extends TextInputCommand {
+  const TextInputShow();
+
+  void run(HybridTextEditing textEditing) {
+    if (!textEditing.isEditing) {
+      textEditing._startEditing();
+    }
+  }
+}
+
+/// Responds to the 'TextInput.setEditableSizeAndTransform' message.
+class TextInputSetEditableSizeAndTransform extends TextInputCommand {
+  TextInputSetEditableSizeAndTransform({
+    required this.geometry,
+  });
+
+  final EditableTextGeometry geometry;
+
+  void run(HybridTextEditing textEditing) {
+    textEditing.strategy.updateElementPlacement(geometry);
+  }
+}
+
+/// Responds to the 'TextInput.setStyle' message.
+class TextInputSetStyle extends TextInputCommand {
+  TextInputSetStyle({
+    required this.style,
+  });
+
+  final EditableTextStyle style;
+
+  void run(HybridTextEditing textEditing) {
+    textEditing.strategy.updateElementStyle(style);
+  }
+}
+
+/// Responds to the 'TextInput.clearClient' message.
+class TextInputClearClient extends TextInputCommand {
+  const TextInputClearClient();
+
+  void run(HybridTextEditing textEditing) {
+    if (textEditing.isEditing) {
+      textEditing.stopEditing();
+    }
+  }
+}
+
+/// Responds to the 'TextInput.hide' message.
+class TextInputHide extends TextInputCommand {
+  const TextInputHide();
+
+  void run(HybridTextEditing textEditing) {
+    if (textEditing.isEditing) {
+      textEditing.stopEditing();
+    }
+  }
+}
+
+class TextInputSetMarkedTextRect extends TextInputCommand {
+  const TextInputSetMarkedTextRect();
+
+  void run(HybridTextEditing textEditing) {
+    // No-op: this message is currently only used on iOS to implement
+    // UITextInput.firstRecForRange.
+  }
+}
+
+class TextInputSetCaretRect extends TextInputCommand {
+  const TextInputSetCaretRect();
+
+  void run(HybridTextEditing textEditing) {
+    // No-op: not supported on this platform.
+  }
+}
+
+class TextInputFinishAutofillContext extends TextInputCommand {
+  TextInputFinishAutofillContext({
+    required this.saveForm,
+  });
+
+  final bool saveForm;
+
+  void run(HybridTextEditing textEditing) {
+    // Close the text editing connection. Form is finalizing.
+    textEditing.sendTextConnectionClosedToFrameworkIfAny();
+    if (saveForm) {
+      saveForms();
+    }
+    // Clean the forms from DOM after submitting them.
+    cleanForms();
+  }
+}
+
+/// Submits the forms currently attached to the DOM.
+///
+/// Browser will save the information entered to the form.
+///
+/// Called when the form is finalized with save option `true`.
+/// See: https://github.com/flutter/flutter/blob/bf9f3a3dcfea3022f9cf2dfc3ab10b120b48b19d/packages/flutter/lib/src/services/text_input.dart#L1277
+void saveForms() {
+  formsOnTheDom.forEach((String identifier, html.FormElement form) {
+    final html.InputElement submitBtn =
+        form.getElementsByClassName('submitBtn').first as html.InputElement;
+    submitBtn.click();
+  });
+}
+
+/// Removes the forms from the DOM.
+///
+/// Called when the form is finalized.
+void cleanForms() {
+  for (html.FormElement form in formsOnTheDom.values) {
+    form.remove();
+  }
+  formsOnTheDom.clear();
+}
+
 /// Translates the message-based communication between the framework and the
 /// engine [implementation].
 ///
@@ -1312,103 +1539,84 @@
       ByteData? data, ui.PlatformMessageResponseCallback? callback) {
     const JSONMethodCodec codec = JSONMethodCodec();
     final MethodCall call = codec.decodeMethodCall(data);
+    late final TextInputCommand command;
     switch (call.method) {
       case 'TextInput.setClient':
-        implementation.setClient(
-          call.arguments[0],
-          InputConfiguration.fromFrameworkMessage(call.arguments[1]),
+        command = TextInputSetClient(
+          clientId: call.arguments[0],
+          configuration: InputConfiguration.fromFrameworkMessage(call.arguments[1]),
         );
         break;
 
       case 'TextInput.updateConfig':
-        final config = InputConfiguration.fromFrameworkMessage(call.arguments);
-        implementation.updateConfig(config);
+        // Set configuration eagerly because it contains data about the text
+        // field used to flush the command queue. However, delaye applying the
+        // configuration because the strategy may not be available yet.
+        implementation.configuration = InputConfiguration.fromFrameworkMessage(call.arguments);
+        command = TextInputUpdateConfig();
         break;
 
       case 'TextInput.setEditingState':
-        implementation
-            .setEditingState(EditingState.fromFrameworkMessage(call.arguments));
+        command = TextInputSetEditingState(
+          state: EditingState.fromFrameworkMessage(call.arguments),
+        );
         break;
 
       case 'TextInput.show':
-        implementation.show();
+        command = const TextInputShow();
         break;
 
       case 'TextInput.setEditableSizeAndTransform':
-        implementation.setEditableSizeAndTransform(
-            EditableTextGeometry.fromFrameworkMessage(call.arguments));
+        command = TextInputSetEditableSizeAndTransform(
+          geometry: EditableTextGeometry.fromFrameworkMessage(call.arguments),
+        );
         break;
 
       case 'TextInput.setStyle':
-        implementation
-            .setStyle(EditableTextStyle.fromFrameworkMessage(call.arguments));
+        command = TextInputSetStyle(
+          style: EditableTextStyle.fromFrameworkMessage(call.arguments),
+        );
         break;
 
       case 'TextInput.clearClient':
-        implementation.clearClient();
+        command = const TextInputClearClient();
         break;
 
       case 'TextInput.hide':
-        implementation.hide();
+        command = const TextInputHide();
         break;
 
       case 'TextInput.requestAutofill':
-        // No-op:  This message is sent by the framework to requests the platform autofill UI to appear.
-        // Since autofill UI is a part of the browser, web engine does not need to utilize this method.
+        // There's no API to request autofill on the web. Instead we let the
+        // browser show autofill options automatically, if available. We
+        // therefore simply ignore this message.
         break;
 
       case 'TextInput.finishAutofillContext':
-        final bool saveForm = call.arguments as bool;
-        // Close the text editing connection. Form is finalizing.
-        implementation.sendTextConnectionClosedToFrameworkIfAny();
-        if (saveForm) {
-          saveForms();
-        }
-        // Clean the forms from DOM after submitting them.
-        cleanForms();
+        command = TextInputFinishAutofillContext(
+          saveForm: call.arguments as bool,
+        );
         break;
 
       case 'TextInput.setMarkedTextRect':
-        // No-op: this message is currently only used on iOS to implement
-        // UITextInput.firstRecForRange.
+        command = const TextInputSetMarkedTextRect();
         break;
 
       case 'TextInput.setCaretRect':
-        // No-op: not supported on this platform.
+        command = const TextInputSetCaretRect();
         break;
 
       default:
         EnginePlatformDispatcher.instance._replyToPlatformMessage(callback, null);
         return;
     }
-    EnginePlatformDispatcher.instance
-        ._replyToPlatformMessage(callback, codec.encodeSuccessEnvelope(true));
-  }
 
-  /// Used for submitting the forms attached on the DOM.
-  ///
-  /// Browser will save the information entered to the form.
-  ///
-  /// Called when the form is finalized with save option `true`.
-  /// See: https://github.com/flutter/flutter/blob/bf9f3a3dcfea3022f9cf2dfc3ab10b120b48b19d/packages/flutter/lib/src/services/text_input.dart#L1277
-  void saveForms() {
-    formsOnTheDom.forEach((String identifier, html.FormElement form) {
-      final html.InputElement submitBtn =
-          form.getElementsByClassName('submitBtn').first as html.InputElement;
-      submitBtn.click();
+    implementation.acceptCommand(command, () {
+      EnginePlatformDispatcher.instance
+          ._replyToPlatformMessage(callback, codec.encodeSuccessEnvelope(true));
     });
   }
 
-  /// Used for removing the forms on the DOM.
-  ///
-  /// Called when the form is finalized.
-  void cleanForms() {
-    for (html.FormElement form in formsOnTheDom.values) {
-      form.remove();
-    }
-    formsOnTheDom.clear();
-  }
-
   /// Sends the 'TextInputClient.updateEditingState' message to the framework.
   void updateEditingState(int? clientId, EditingState? editingState) {
     EnginePlatformDispatcher.instance.invokeOnPlatformMessage(
@@ -1478,118 +1686,15 @@
   /// The constructor also decides which text editing strategy to use depending
   /// on the operating system and browser engine.
   HybridTextEditing() {
-    if (browserEngine == BrowserEngine.webkit &&
-        operatingSystem == OperatingSystem.iOs) {
-      this._defaultEditingElement = IOSTextEditingStrategy(this);
-    } else if (browserEngine == BrowserEngine.webkit) {
-      this._defaultEditingElement = SafariDesktopTextEditingStrategy(this);
-    } else if ((browserEngine == BrowserEngine.blink ||
-        browserEngine == BrowserEngine.samsung) &&
-        operatingSystem == OperatingSystem.android) {
-      this._defaultEditingElement = AndroidTextEditingStrategy(this);
-    } else if (browserEngine == BrowserEngine.firefox) {
-      this._defaultEditingElement = FirefoxTextEditingStrategy(this);
-    } else {
-      this._defaultEditingElement = GloballyPositionedTextEditingStrategy(this);
-    }
     channel = TextEditingChannel(this);
   }
 
   late TextEditingChannel channel;
 
-  /// The text editing stategy used. It can change depending on the
-  /// formfactor/browser.
-  ///
-  /// It uses an HTML element to manage editing state when a custom element is
-  /// not provided via [useCustomEditableElement]
-  late final DefaultTextEditingStrategy _defaultEditingElement;
-
-  /// The HTML element used to manage editing state.
-  ///
-  /// This field is populated using [useCustomEditableElement]. If `null` the
-  /// [_defaultEditingElement] is used instead.
-  DefaultTextEditingStrategy? _customEditingElement;
-
-  DefaultTextEditingStrategy get editingElement {
-    return _customEditingElement ?? _defaultEditingElement;
-  }
-
-  /// Responds to the 'TextInput.setClient' message.
-  void setClient(int? clientId, InputConfiguration configuration) {
-    final bool clientIdChanged = _clientId != null && _clientId != clientId;
-    if (clientIdChanged && isEditing) {
-      stopEditing();
-    }
-    _clientId = clientId;
-    _configuration = configuration;
-  }
-
-  void updateConfig(InputConfiguration configuration) {
-    _configuration = configuration;
-    editingElement._applyConfiguration(_configuration);
-  }
-
-  /// Responds to the 'TextInput.setEditingState' message.
-  void setEditingState(EditingState state) {
-    editingElement.setEditingState(state);
-  }
-
-  /// Responds to the 'TextInput.show' message.
-  void show() {
-    if (!isEditing) {
-      _startEditing();
-    }
-  }
-
-  /// Responds to the 'TextInput.setEditableSizeAndTransform' message.
-  void setEditableSizeAndTransform(EditableTextGeometry geometry) {
-    editingElement.updateElementPlacement(geometry);
-  }
-
-  /// Responds to the 'TextInput.setStyle' message.
-  void setStyle(EditableTextStyle style) {
-    editingElement.updateElementStyle(style);
-  }
-
-  /// Responds to the 'TextInput.clearClient' message.
-  void clearClient() {
-    // We do not distinguish between "clearClient" and "hide" on the Web.
-    hide();
-  }
-
-  /// Responds to the 'TextInput.hide' message.
-  void hide() {
-    if (isEditing) {
-      stopEditing();
-    }
-  }
-
   /// A CSS class name used to identify all elements used for text editing.
   @visibleForTesting
   static const String textEditingClass = 'flt-text-editing';
 
-  static bool isEditingElement(html.Element element) {
-    return element.classes.contains(textEditingClass);
-  }
-
-  /// Requests that [customEditingElement] is used for managing text editing state
-  /// instead of the hidden default element.
-  ///
-  /// Use [stopUsingCustomEditableElement] to switch back to default element.
-  void useCustomEditableElement(
-      DefaultTextEditingStrategy? customEditingElement) {
-    if (isEditing && customEditingElement != _customEditingElement) {
-      stopEditing();
-    }
-    _customEditingElement = customEditingElement;
-  }
-
-  /// Switches back to using the built-in default element for managing text
-  /// editing state.
-  void stopUsingCustomEditableElement() {
-    useCustomEditableElement(null);
-  }
-
   int? _clientId;
 
   /// Flag which shows if there is an ongoing editing.
@@ -1598,13 +1703,30 @@
   @visibleForTesting
   bool isEditing = false;
 
-  late InputConfiguration _configuration;
+  InputConfiguration? configuration;
+
+  DefaultTextEditingStrategy? debugTextEditingStrategyOverride;
+
+  /// Supplies the DOM element used for editing.
+  late final DefaultTextEditingStrategy strategy =
+    debugTextEditingStrategyOverride ??
+    (EngineSemanticsOwner.instance.semanticsEnabled
+      ? SemanticsTextEditingStrategy.ensureInitialized(this)
+      : createDefaultTextEditingStrategy(this));
+
+  void acceptCommand(TextInputCommand command, ui.VoidCallback callback) {
+    if (_debugPrintTextInputCommands) {
+      print('flutter/textinput channel command: ${command.runtimeType}');
+    }
+    command.run(this);
+    callback();
+  }
 
   void _startEditing() {
     assert(!isEditing);
     isEditing = true;
-    editingElement.enable(
-      _configuration,
+    strategy.enable(
+      configuration!,
       onChange: (EditingState? editingState) {
         channel.updateEditingState(_clientId, editingState);
       },
@@ -1617,7 +1739,7 @@
   void stopEditing() {
     assert(isEditing);
     isEditing = false;
-    editingElement.disable();
+    strategy.disable();
   }
 
   void sendTextConnectionClosedToFrameworkIfAny() {
diff --git a/lib/web_ui/test/canvaskit/common.dart b/lib/web_ui/test/canvaskit/common.dart
index 45b3bbd..3bb74c9 100644
--- a/lib/web_ui/test/canvaskit/common.dart
+++ b/lib/web_ui/test/canvaskit/common.dart
@@ -9,8 +9,9 @@
 
 /// Whether the current browser is Safari on iOS.
 // TODO: https://github.com/flutter/flutter/issues/60040
-bool get isIosSafari => browserEngine == BrowserEngine.webkit &&
-          operatingSystem == OperatingSystem.iOs;
+bool get isIosSafari =>
+    browserEngine == BrowserEngine.webkit &&
+    operatingSystem == OperatingSystem.iOs;
 
 /// Whether the current browser is Firefox.
 bool get isFirefox => browserEngine == BrowserEngine.firefox;
@@ -24,8 +25,7 @@
 /// Common test setup for all CanvasKit unit-tests.
 void setUpCanvasKitTest() {
   setUpAll(() async {
-    expect(useCanvasKit, true,
-      reason: 'This test must run in CanvasKit mode.');
+    expect(useCanvasKit, true, reason: 'This test must run in CanvasKit mode.');
     debugResetBrowserSupportsFinalizationRegistry();
     await ui.webOnlyInitializePlatform(assetManager: WebOnlyMockAssetManager());
   });
@@ -81,8 +81,10 @@
 ///
 /// Tests should use [collectNow] and [collectAfterTest] to trigger collections.
 class TestCollector implements Collector {
-  final List<_TestFinalizerRegistration> _activeRegistrations = <_TestFinalizerRegistration>[];
-  final List<_TestFinalizerRegistration> _collectedRegistrations = <_TestFinalizerRegistration>[];
+  final List<_TestFinalizerRegistration> _activeRegistrations =
+      <_TestFinalizerRegistration>[];
+  final List<_TestFinalizerRegistration> _collectedRegistrations =
+      <_TestFinalizerRegistration>[];
 
   final List<_TestCollection> _pendingCollections = <_TestCollection>[];
   final List<_TestCollection> _completedCollections = <_TestCollection>[];
@@ -113,7 +115,8 @@
       }
       if (activeRegistration == null) {
         late final _TestFinalizerRegistration? collectedRegistration;
-        for (_TestFinalizerRegistration registration in _collectedRegistrations) {
+        for (_TestFinalizerRegistration registration
+            in _collectedRegistrations) {
           if (identical(registration.deletable, collection.deletable)) {
             collectedRegistration = registration;
             break;
@@ -121,16 +124,15 @@
         }
         if (collectedRegistration == null) {
           fail(
-            'Attempted to collect an object that was never registered for finalization.\n'
-            'The collection was requested here:\n'
-            '${collection.stackTrace}'
-          );
+              'Attempted to collect an object that was never registered for finalization.\n'
+              'The collection was requested here:\n'
+              '${collection.stackTrace}');
         } else {
-          final _TestCollection firstCollection = _completedCollections.firstWhere(
-            (_TestCollection completedCollection) {
-              return identical(completedCollection.deletable, collection.deletable);
-            }
-          );
+          final _TestCollection firstCollection = _completedCollections
+              .firstWhere((_TestCollection completedCollection) {
+            return identical(
+                completedCollection.deletable, collection.deletable);
+          });
           fail(
             'Attempted to collect an object that was previously collected.\n'
             'The object was registered for finalization here:\n'
@@ -138,7 +140,7 @@
             'The first collection was requested here:\n'
             '${firstCollection.stackTrace}\n\n'
             'The second collection was requested here:\n'
-            '${collection.stackTrace}'
+            '${collection.stackTrace}',
           );
         }
       } else {
diff --git a/lib/web_ui/test/canvaskit/embedded_views_test.dart b/lib/web_ui/test/canvaskit/embedded_views_test.dart
index 4788d61..970d595 100644
--- a/lib/web_ui/test/canvaskit/embedded_views_test.dart
+++ b/lib/web_ui/test/canvaskit/embedded_views_test.dart
@@ -28,6 +28,11 @@
       window.debugOverrideDevicePixelRatio(1);
     });
 
+    tearDown(() {
+      EnginePlatformDispatcher.instance.rasterizer?.surface.viewEmbedder
+          .debugCleanupSvgClipPaths();
+    });
+
     test('embeds interactive platform views', () async {
       ui.platformViewRegistry.registerViewFactory(
         'test-platform-view',
@@ -123,7 +128,7 @@
     List<String> getTransformChain(html.Element viewHost) {
       final List<String> chain = <String>[];
       html.Element? element = viewHost;
-      while(element != null && element.tagName.toLowerCase() != 'flt-scene') {
+      while (element != null && element.tagName.toLowerCase() != 'flt-scene') {
         chain.add(element.style.transform);
         element = element.parent;
       }
@@ -146,9 +151,8 @@
       sb.pushOffset(3, 3);
       sb.addPlatformView(0, width: 10, height: 10);
       dispatcher.rasterizer!.draw(sb.build().layerTree);
-      final html.Element viewHost = domRenderer.sceneElement!
-        .querySelectorAll('#view-0')
-        .single;
+      final html.Element viewHost =
+          domRenderer.sceneElement!.querySelectorAll('#view-0').single;
 
       expect(
         getTransformChain(viewHost),
@@ -174,9 +178,8 @@
       sb.pushOffset(9, 9);
       sb.addPlatformView(0, width: 10, height: 10);
       dispatcher.rasterizer!.draw(sb.build().layerTree);
-      final html.Element viewHost = domRenderer.sceneElement!
-        .querySelectorAll('#view-0')
-        .single;
+      final html.Element viewHost =
+          domRenderer.sceneElement!.querySelectorAll('#view-0').single;
 
       expect(
         getTransformChain(viewHost),
@@ -190,12 +193,10 @@
 
     test('renders overlays on top of platform views', () async {
       expect(OverlayCache.instance.debugLength, 0);
-      final CkPicture testPicture = paintPicture(
-        ui.Rect.fromLTRB(0, 0, 10, 10),
-        (CkCanvas canvas) {
-          canvas.drawCircle(ui.Offset(5, 5), 5, CkPaint());
-        }
-      );
+      final CkPicture testPicture =
+          paintPicture(ui.Rect.fromLTRB(0, 0, 10, 10), (CkCanvas canvas) {
+        canvas.drawCircle(ui.Offset(5, 5), 5, CkPaint());
+      });
 
       // Initialize all platform views to be used in the test.
       final List<int> platformViewIds = <int>[];
@@ -211,7 +212,7 @@
       final EnginePlatformDispatcher dispatcher =
           ui.window.platformDispatcher as EnginePlatformDispatcher;
 
-      void renderTestScene({ required int viewCount }) {
+      void renderTestScene({required int viewCount}) {
         LayerSceneBuilder sb = LayerSceneBuilder();
         sb.pushOffset(0, 0);
         for (int i = 0; i < viewCount; i++) {
@@ -361,6 +362,44 @@
         hasLength(0),
       );
     });
+
+    test(
+        'removes old SVG clip definitions from the DOM when the view is recomposited',
+        () async {
+      ui.platformViewRegistry.registerViewFactory(
+        'test-platform-view',
+        (viewId) => html.DivElement()..id = 'test-view',
+      );
+      await _createPlatformView(0, 'test-platform-view');
+
+      final EnginePlatformDispatcher dispatcher =
+          ui.window.platformDispatcher as EnginePlatformDispatcher;
+
+      void renderTestScene() {
+        LayerSceneBuilder sb = LayerSceneBuilder();
+        sb.pushOffset(0, 0);
+        sb.pushClipRRect(
+            ui.RRect.fromLTRBR(0, 0, 10, 10, ui.Radius.circular(3)));
+        sb.addPlatformView(0, width: 10, height: 10);
+        dispatcher.rasterizer!.draw(sb.build().layerTree);
+      }
+
+      final html.Node skPathDefs =
+          domRenderer.sceneElement!.querySelector('#sk_path_defs')!;
+
+      expect(skPathDefs.childNodes, hasLength(0));
+
+      renderTestScene();
+      expect(skPathDefs.childNodes, hasLength(1));
+
+      await Future<void>.delayed(Duration.zero);
+      renderTestScene();
+      expect(skPathDefs.childNodes, hasLength(1));
+
+      await Future<void>.delayed(Duration.zero);
+      renderTestScene();
+      expect(skPathDefs.childNodes, hasLength(1));
+    });
     // TODO: https://github.com/flutter/flutter/issues/60040
   }, skip: isIosSafari);
 }
diff --git a/lib/web_ui/test/engine/semantics/semantics_test.dart b/lib/web_ui/test/engine/semantics/semantics_test.dart
index 45b47f1..4b2aee5 100644
--- a/lib/web_ui/test/engine/semantics/semantics_test.dart
+++ b/lib/web_ui/test/engine/semantics/semantics_test.dart
@@ -17,7 +17,7 @@
 import 'package:ui/src/engine.dart';
 import 'package:ui/ui.dart' as ui;
 
-import '../../matchers.dart';
+import 'semantics_tester.dart';
 
 DateTime _testTime = DateTime(2018, 12, 17);
 
@@ -27,13 +27,8 @@
   internalBootstrapBrowserTest(() => testMain);
 }
 
-String rootSemanticStyle = '';
-
 void testMain() {
   setUp(() {
-    rootSemanticStyle = browserEngine != BrowserEngine.edge
-        ? 'filter: opacity(0%); color: rgba(0, 0, 0, 0)' :
-        'color: rgba(0, 0, 0, 0); filter: opacity(0%)';
     EngineSemanticsOwner.debugResetSemantics();
   });
 
@@ -1198,24 +1193,23 @@
       ..debugOverrideTimestampFunction(() => _testTime)
       ..semanticsEnabled = true;
 
-    final ui.SemanticsUpdateBuilder builder = ui.SemanticsUpdateBuilder();
-    updateNode(
-      builder,
+    final SemanticsTester tester = SemanticsTester(semantics());
+    tester.updateNode(
       id: 0,
-      actions: 0 | ui.SemanticsAction.tap.index,
-      flags: 0 |
-          ui.SemanticsFlag.hasEnabledState.index |
-          ui.SemanticsFlag.isEnabled.index |
-          ui.SemanticsFlag.isButton.index,
-      transform: Matrix4.identity().toFloat64(),
+      hasTap: true,
+      hasEnabledState: true,
+      isEnabled: true,
+      isButton: true,
       rect: const ui.Rect.fromLTRB(0, 0, 100, 50),
     );
+    tester.apply();
 
-    semantics().updateSemantics(builder.build());
     expectSemanticsTree('''
 <sem role="button" style="$rootSemanticStyle"></sem>
 ''');
 
+    expect(tester.getSemanticsObject(0).element.tabIndex, 0);
+
     semantics().semanticsEnabled = false;
   },
       // TODO(nurhan): https://github.com/flutter/flutter/issues/50754
@@ -1417,53 +1411,9 @@
   });
 }
 
-void expectSemanticsTree(String semanticsHtml) {
-  expect(
-    canonicalizeHtml(html.document.querySelector('flt-semantics').outerHtml),
-    canonicalizeHtml(semanticsHtml),
-  );
-}
-
-html.Element findScrollable() {
-  return html.document.querySelectorAll('flt-semantics').firstWhere(
-        (html.Element element) =>
-            element.style.overflow == 'hidden' ||
-            element.style.overflowY == 'scroll' ||
-            element.style.overflowX == 'scroll',
-        orElse: () => null,
-      );
-}
-
-class SemanticsActionLogger {
-  StreamController<int> idLogController;
-  StreamController<ui.SemanticsAction> actionLogController;
-  Stream<int> idLog;
-  Stream<ui.SemanticsAction> actionLog;
-
-  SemanticsActionLogger() {
-    idLogController = StreamController<int>();
-    actionLogController = StreamController<ui.SemanticsAction>();
-    idLog = idLogController.stream.asBroadcastStream();
-    actionLog = actionLogController.stream.asBroadcastStream();
-
-    // The browser kicks us out of the test zone when the browser event happens.
-    // We memorize the test zone so we can call expect when the callback is
-    // fired.
-    final Zone testZone = Zone.current;
-
-    ui.window.onSemanticsAction =
-        (int id, ui.SemanticsAction action, ByteData args) {
-      idLogController.add(id);
-      actionLogController.add(action);
-      testZone.run(() {
-        expect(args, null);
-      });
-    };
-  }
-}
-
 /// A facade in front of [ui.SemanticsUpdateBuilder.updateNode] that
 /// supplies default values for semantics attributes.
+// TODO(yjbanov): move this to TestSemanticsBuilder
 void updateNode(
   ui.SemanticsUpdateBuilder builder, {
   int id = 0,
diff --git a/lib/web_ui/test/engine/semantics/semantics_tester.dart b/lib/web_ui/test/engine/semantics/semantics_tester.dart
new file mode 100644
index 0000000..2b0b6fd
--- /dev/null
+++ b/lib/web_ui/test/engine/semantics/semantics_tester.dart
@@ -0,0 +1,388 @@
+// Copyright 2013 The Flutter Authors. All rights reserved.
+// Use of this source code is governed by a BSD-style license that can be
+// found in the LICENSE file.
+
+import 'dart:async';
+import 'dart:html' as html;
+import 'dart:typed_data';
+
+import 'package:test/test.dart';
+import 'package:ui/src/engine.dart';
+import 'package:ui/ui.dart' as ui;
+
+import '../../matchers.dart';
+
+/// CSS style applied to the root of the semantics tree.
+// TODO(yjbanov): this should be handled internally by [expectSemanticsTree].
+//                No need for every test to inject it.
+final String rootSemanticStyle = browserEngine != BrowserEngine.edge
+  ? 'filter: opacity(0%); color: rgba(0, 0, 0, 0)'
+  : 'color: rgba(0, 0, 0, 0); filter: opacity(0%)';
+
+/// A convenience wrapper of the semantics API for building and inspecting the
+/// semantics tree in unit tests.
+class SemanticsTester {
+  SemanticsTester(this.owner);
+
+  final EngineSemanticsOwner owner;
+  final List<SemanticsNodeUpdate> _nodeUpdates = <SemanticsNodeUpdate>[];
+
+  /// Updates one semantics node.
+  ///
+  /// Provides reasonable defaults for the missing attributes, and conveniences
+  /// for specifying flags, such as [isTextField].
+  SemanticsNodeUpdate updateNode({
+    required int id,
+
+    // Flags
+    int flags = 0,
+    bool? hasCheckedState,
+    bool? isChecked,
+    bool? isSelected,
+    bool? isButton,
+    bool? isLink,
+    bool? isTextField,
+    bool? isReadOnly,
+    bool? isFocusable,
+    bool? isFocused,
+    bool? hasEnabledState,
+    bool? isEnabled,
+    bool? isInMutuallyExclusiveGroup,
+    bool? isHeader,
+    bool? isObscured,
+    bool? scopesRoute,
+    bool? namesRoute,
+    bool? isHidden,
+    bool? isImage,
+    bool? isLiveRegion,
+    bool? hasToggledState,
+    bool? isToggled,
+    bool? hasImplicitScrolling,
+    bool? isMultiline,
+    bool? isSlider,
+    bool? isKeyboardKey,
+
+    // Actions
+    int actions = 0,
+    bool? hasTap,
+    bool? hasLongPress,
+    bool? hasScrollLeft,
+    bool? hasScrollRight,
+    bool? hasScrollUp,
+    bool? hasScrollDown,
+    bool? hasIncrease,
+    bool? hasDecrease,
+    bool? hasShowOnScreen,
+    bool? hasMoveCursorForwardByCharacter,
+    bool? hasMoveCursorBackwardByCharacter,
+    bool? hasSetSelection,
+    bool? hasCopy,
+    bool? hasCut,
+    bool? hasPaste,
+    bool? hasDidGainAccessibilityFocus,
+    bool? hasDidLoseAccessibilityFocus,
+    bool? hasCustomAction,
+    bool? hasDismiss,
+    bool? hasMoveCursorForwardByWord,
+    bool? hasMoveCursorBackwardByWord,
+    bool? hasSetText,
+
+    // Other attributes
+    int? maxValueLength,
+    int? currentValueLength,
+    int? textSelectionBase,
+    int? textSelectionExtent,
+    int? platformViewId,
+    int? scrollChildren,
+    int? scrollIndex,
+    double? scrollPosition,
+    double? scrollExtentMax,
+    double? scrollExtentMin,
+    double? elevation,
+    double? thickness,
+    ui.Rect? rect,
+    String? label,
+    String? hint,
+    String? value,
+    String? increasedValue,
+    String? decreasedValue,
+    ui.TextDirection? textDirection,
+    Float64List? transform,
+    Int32List? additionalActions,
+    List<SemanticsNodeUpdate>? children,
+  }) {
+    // Flags
+    if (hasCheckedState == true) {
+      flags |= ui.SemanticsFlag.hasCheckedState.index;
+    }
+    if (isChecked == true) {
+      flags |= ui.SemanticsFlag.isChecked.index;
+    }
+    if (isSelected == true) {
+      flags |= ui.SemanticsFlag.isSelected.index;
+    }
+    if (isButton == true) {
+      flags |= ui.SemanticsFlag.isButton.index;
+    }
+    if (isLink == true) {
+      flags |= ui.SemanticsFlag.isLink.index;
+    }
+    if (isTextField == true) {
+      flags |= ui.SemanticsFlag.isTextField.index;
+    }
+    if (isReadOnly == true) {
+      flags |= ui.SemanticsFlag.isReadOnly.index;
+    }
+    if (isFocusable == true) {
+      flags |= ui.SemanticsFlag.isFocusable.index;
+    }
+    if (isFocused == true) {
+      flags |= ui.SemanticsFlag.isFocused.index;
+    }
+    if (hasEnabledState == true) {
+      flags |= ui.SemanticsFlag.hasEnabledState.index;
+    }
+    if (isEnabled == true) {
+      flags |= ui.SemanticsFlag.isEnabled.index;
+    }
+    if (isInMutuallyExclusiveGroup == true) {
+      flags |= ui.SemanticsFlag.isInMutuallyExclusiveGroup.index;
+    }
+    if (isHeader == true) {
+      flags |= ui.SemanticsFlag.isHeader.index;
+    }
+    if (isObscured == true) {
+      flags |= ui.SemanticsFlag.isObscured.index;
+    }
+    if (scopesRoute == true) {
+      flags |= ui.SemanticsFlag.scopesRoute.index;
+    }
+    if (namesRoute == true) {
+      flags |= ui.SemanticsFlag.namesRoute.index;
+    }
+    if (isHidden == true) {
+      flags |= ui.SemanticsFlag.isHidden.index;
+    }
+    if (isImage == true) {
+      flags |= ui.SemanticsFlag.isImage.index;
+    }
+    if (isLiveRegion == true) {
+      flags |= ui.SemanticsFlag.isLiveRegion.index;
+    }
+    if (hasToggledState == true) {
+      flags |= ui.SemanticsFlag.hasToggledState.index;
+    }
+    if (isToggled == true) {
+      flags |= ui.SemanticsFlag.isToggled.index;
+    }
+    if (hasImplicitScrolling == true) {
+      flags |= ui.SemanticsFlag.hasImplicitScrolling.index;
+    }
+    if (isMultiline == true) {
+      flags |= ui.SemanticsFlag.isMultiline.index;
+    }
+    if (isSlider == true) {
+      flags |= ui.SemanticsFlag.isSlider.index;
+    }
+    if (isKeyboardKey == true) {
+      flags |= ui.SemanticsFlag.isKeyboardKey.index;
+    }
+
+    // Actions
+    if (hasTap == true) {
+      actions != ui.SemanticsAction.tap.index;
+    }
+    if (hasLongPress == true) {
+      actions != ui.SemanticsAction.longPress.index;
+    }
+    if (hasScrollLeft == true) {
+      actions != ui.SemanticsAction.scrollLeft.index;
+    }
+    if (hasScrollRight == true) {
+      actions != ui.SemanticsAction.scrollRight.index;
+    }
+    if (hasScrollUp == true) {
+      actions != ui.SemanticsAction.scrollUp.index;
+    }
+    if (hasScrollDown == true) {
+      actions != ui.SemanticsAction.scrollDown.index;
+    }
+    if (hasIncrease == true) {
+      actions != ui.SemanticsAction.increase.index;
+    }
+    if (hasDecrease == true) {
+      actions != ui.SemanticsAction.decrease.index;
+    }
+    if (hasShowOnScreen == true) {
+      actions != ui.SemanticsAction.showOnScreen.index;
+    }
+    if (hasMoveCursorForwardByCharacter == true) {
+      actions != ui.SemanticsAction.moveCursorForwardByCharacter.index;
+    }
+    if (hasMoveCursorBackwardByCharacter == true) {
+      actions != ui.SemanticsAction.moveCursorBackwardByCharacter.index;
+    }
+    if (hasSetSelection == true) {
+      actions != ui.SemanticsAction.setSelection.index;
+    }
+    if (hasCopy == true) {
+      actions != ui.SemanticsAction.copy.index;
+    }
+    if (hasCut == true) {
+      actions != ui.SemanticsAction.cut.index;
+    }
+    if (hasPaste == true) {
+      actions != ui.SemanticsAction.paste.index;
+    }
+    if (hasDidGainAccessibilityFocus == true) {
+      actions != ui.SemanticsAction.didGainAccessibilityFocus.index;
+    }
+    if (hasDidLoseAccessibilityFocus == true) {
+      actions != ui.SemanticsAction.didLoseAccessibilityFocus.index;
+    }
+    if (hasCustomAction == true) {
+      actions != ui.SemanticsAction.customAction.index;
+    }
+    if (hasDismiss == true) {
+      actions != ui.SemanticsAction.dismiss.index;
+    }
+    if (hasMoveCursorForwardByWord == true) {
+      actions != ui.SemanticsAction.moveCursorForwardByWord.index;
+    }
+    if (hasMoveCursorBackwardByWord == true) {
+      actions != ui.SemanticsAction.moveCursorBackwardByWord.index;
+    }
+    if (hasSetText == true) {
+      actions != ui.SemanticsAction.setText.index;
+    }
+
+    // Other attributes
+    ui.Rect childRect(SemanticsNodeUpdate child) {
+      return transformRect(Matrix4.fromFloat32List(child.transform), child.rect);
+    }
+
+    // If a rect is not provided, generate one than covers all children.
+    ui.Rect effectiveRect = rect ?? ui.Rect.zero;
+    if (children != null && children.isNotEmpty) {
+      effectiveRect = childRect(children.first);
+      for (SemanticsNodeUpdate child in children.skip(1)) {
+        effectiveRect = effectiveRect.expandToInclude(childRect(child));
+      }
+    }
+
+    final Int32List childIds = Int32List(children?.length ?? 0);
+    if (children != null) {
+      for (int i = 0; i < children.length; i++) {
+        childIds[i] = children[i].id;
+      }
+    }
+
+    final SemanticsNodeUpdate update = SemanticsNodeUpdate(
+      id: id,
+      flags: flags,
+      actions: actions,
+      maxValueLength: maxValueLength ?? 0,
+      currentValueLength: currentValueLength ?? 0,
+      textSelectionBase: textSelectionBase ?? 0,
+      textSelectionExtent: textSelectionExtent ?? 0,
+      platformViewId: platformViewId ?? 0,
+      scrollChildren: scrollChildren ?? 0,
+      scrollIndex: scrollIndex ?? 0,
+      scrollPosition: scrollPosition ?? 0,
+      scrollExtentMax: scrollExtentMax ?? 0,
+      scrollExtentMin: scrollExtentMin ?? 0,
+      rect: effectiveRect,
+      label: label ?? '',
+      hint: hint ?? '',
+      value: value ?? '',
+      increasedValue: increasedValue ?? '',
+      decreasedValue: decreasedValue ?? '',
+      transform: transform != null ? toMatrix32(transform) : Matrix4.identity().storage,
+      elevation: elevation ?? 0,
+      thickness: thickness ?? 0,
+      childrenInTraversalOrder: childIds,
+      childrenInHitTestOrder: childIds,
+      additionalActions: additionalActions ?? Int32List(0),
+    );
+    _nodeUpdates.add(update);
+    return update;
+  }
+
+  /// Updates the HTML tree from semantics updates accumulated by this builder.
+  ///
+  /// This builder forgets previous updates and may be reused in future updates.
+  Map<int, SemanticsObject> apply() {
+    owner.updateSemantics(SemanticsUpdate(nodeUpdates: _nodeUpdates));
+    _nodeUpdates.clear();
+    return owner.debugSemanticsTree!;
+  }
+
+  /// Locates the semantics object with the given [id].
+  SemanticsObject getSemanticsObject(int id) {
+    return owner.debugSemanticsTree![id]!;
+  }
+
+  /// Locates the role manager of the semantics object with the give [id].
+  RoleManager? getRoleManager(int id, Role role) {
+    return getSemanticsObject(id).debugRoleManagerFor(role);
+  }
+
+  /// Locates the [TextField] role manager of the semantics object with the give [id].
+  TextField getTextField(int id) {
+    return getRoleManager(id, Role.textField) as TextField;
+  }
+}
+
+/// Verifies the HTML structure of the current semantics tree.
+void expectSemanticsTree(String semanticsHtml) {
+  expect(
+    canonicalizeHtml(html.document.querySelector('flt-semantics')!.outerHtml!),
+    canonicalizeHtml(semanticsHtml),
+  );
+}
+
+/// Finds the first HTML element in the semantics tree used for scrolling.
+html.Element? findScrollable() {
+  return html.document.querySelectorAll('flt-semantics').cast<html.Element?>().firstWhere(
+        (html.Element? element) =>
+            element!.style.overflow == 'hidden' ||
+            element.style.overflowY == 'scroll' ||
+            element.style.overflowX == 'scroll',
+        orElse: () => null,
+      );
+}
+
+/// Logs semantics actions dispatched to [ui.window].
+class SemanticsActionLogger {
+  late StreamController<int> _idLogController;
+  late StreamController<ui.SemanticsAction> _actionLogController;
+
+  /// Semantics object ids that dispatched the actions.
+  Stream<int> get idLog => _idLog;
+  late Stream<int> _idLog;
+
+  /// The actions that were dispatched to [ui.window].
+  Stream<ui.SemanticsAction> get actionLog => _actionLog;
+  late Stream<ui.SemanticsAction> _actionLog;
+
+  SemanticsActionLogger() {
+    _idLogController = StreamController<int>();
+    _actionLogController = StreamController<ui.SemanticsAction>();
+    _idLog = _idLogController.stream.asBroadcastStream();
+    _actionLog = _actionLogController.stream.asBroadcastStream();
+
+    // The browser kicks us out of the test zone when the browser event happens.
+    // We memorize the test zone so we can call expect when the callback is
+    // fired.
+    final Zone testZone = Zone.current;
+
+    ui.window.onSemanticsAction =
+        (int id, ui.SemanticsAction action, ByteData? args) {
+      _idLogController.add(id);
+      _actionLogController.add(action);
+      testZone.run(() {
+        expect(args, null);
+      });
+    };
+  }
+}
diff --git a/lib/web_ui/test/engine/semantics/text_field_test.dart b/lib/web_ui/test/engine/semantics/text_field_test.dart
new file mode 100644
index 0000000..2d79d42
--- /dev/null
+++ b/lib/web_ui/test/engine/semantics/text_field_test.dart
@@ -0,0 +1,416 @@
+// Copyright 2013 The Flutter Authors. All rights reserved.
+// Use of this source code is governed by a BSD-style license that can be
+// found in the LICENSE file.
+
+@TestOn('chrome || safari || firefox')
+
+import 'dart:html' as html;
+
+import 'package:test/bootstrap/browser.dart';
+import 'package:test/test.dart';
+
+import 'package:ui/src/engine.dart' hide window;
+import 'package:ui/ui.dart' as ui;
+
+import 'semantics_tester.dart';
+
+final InputConfiguration singlelineConfig = InputConfiguration(
+  inputType: EngineInputType.text,
+);
+
+final InputConfiguration multilineConfig = InputConfiguration(
+  inputType: EngineInputType.multiline,
+  inputAction: 'TextInputAction.newline',
+);
+
+EngineSemanticsOwner semantics() => EngineSemanticsOwner.instance;
+
+const MethodCodec codec = JSONMethodCodec();
+
+DateTime _testTime = DateTime(2021, 4, 16);
+
+void main() {
+  internalBootstrapBrowserTest(() => testMain);
+}
+
+void testMain() {
+  setUp(() {
+    EngineSemanticsOwner.debugResetSemantics();
+  });
+
+  group('$SemanticsTextEditingStrategy', () {
+    late HybridTextEditing testTextEditing;
+    late SemanticsTextEditingStrategy strategy;
+
+    setUp(() {
+      testTextEditing = HybridTextEditing();
+      SemanticsTextEditingStrategy.ensureInitialized(testTextEditing);
+      strategy = SemanticsTextEditingStrategy.instance;
+      testTextEditing.debugTextEditingStrategyOverride = strategy;
+      testTextEditing.configuration = singlelineConfig;
+    });
+
+  test('renders a text field', () async {
+    semantics()
+      ..debugOverrideTimestampFunction(() => _testTime)
+      ..semanticsEnabled = true;
+
+    createTextFieldSemantics(value: 'hello');
+
+    expectSemanticsTree('''
+<sem style="$rootSemanticStyle">
+  <input value="hello" />
+</sem>''');
+
+    semantics().semanticsEnabled = false;
+  });
+
+  // TODO(yjbanov): this test will need to be adjusted for Safari when we add
+  //                Safari testing.
+  test('sends a tap action when browser requests focus', () async {
+    final SemanticsActionLogger logger = SemanticsActionLogger();
+    semantics()
+      ..debugOverrideTimestampFunction(() => _testTime)
+      ..semanticsEnabled = true;
+
+    createTextFieldSemantics(value: 'hello');
+
+    final html.Element textField = html.document
+        .querySelectorAll('input[data-semantics-role="text-field"]')
+        .single;
+
+    expect(html.document.activeElement, isNot(textField));
+
+    textField.focus();
+
+    expect(html.document.activeElement, textField);
+    expect(await logger.idLog.first, 0);
+    expect(await logger.actionLog.first, ui.SemanticsAction.tap);
+
+    semantics().semanticsEnabled = false;
+  },  // TODO(nurhan): https://github.com/flutter/flutter/issues/46638
+      // TODO(nurhan): https://github.com/flutter/flutter/issues/50590
+      // TODO(nurhan): https://github.com/flutter/flutter/issues/50754
+      skip: (browserEngine != BrowserEngine.blink));
+
+    test('Syncs editing state from framework', () async {
+      semantics()
+        ..debugOverrideTimestampFunction(() => _testTime)
+        ..semanticsEnabled = true;
+
+      expect(html.document.activeElement, html.document.body);
+
+      int changeCount = 0;
+      int actionCount = 0;
+      strategy.enable(
+        singlelineConfig,
+        onChange: (_) {
+          changeCount++;
+        },
+        onAction: (_) {
+          actionCount++;
+        },
+      );
+
+      // Create
+      SemanticsObject textFieldSemantics = createTextFieldSemantics(
+        value: 'hello',
+        label: 'greeting',
+        isFocused: true,
+        rect: ui.Rect.fromLTWH(0, 0, 10, 15),
+      );
+
+      TextField textField = textFieldSemantics.debugRoleManagerFor(Role.textField) as TextField;
+      expect(textField.editableElement, strategy.domElement);
+      expect(html.document.activeElement, strategy.domElement);
+      expect((textField.editableElement as dynamic).value, 'hello');
+      expect(textField.editableElement.getAttribute('aria-label'), 'greeting');
+      expect(textField.editableElement.style.width, '10px');
+      expect(textField.editableElement.style.height, '15px');
+
+      // Update
+      createTextFieldSemantics(
+        value: 'bye',
+        label: 'farewell',
+        isFocused: false,
+        rect: ui.Rect.fromLTWH(0, 0, 12, 17),
+      );
+
+      expect(html.document.activeElement, html.document.body);
+      expect(strategy.domElement, null);
+      expect((textField.editableElement as dynamic).value, 'bye');
+      expect(textField.editableElement.getAttribute('aria-label'), 'farewell');
+      expect(textField.editableElement.style.width, '12px');
+      expect(textField.editableElement.style.height, '17px');
+
+      strategy.disable();
+      semantics().semanticsEnabled = false;
+
+      // There was no user interaction with the <input> element,
+      // so we should expect no engine-to-framework feedback.
+      expect(changeCount, 0);
+      expect(actionCount, 0);
+    });
+
+    test('Gives up focus after DOM blur', () async {
+      semantics()
+        ..debugOverrideTimestampFunction(() => _testTime)
+        ..semanticsEnabled = true;
+
+      expect(html.document.activeElement, html.document.body);
+      strategy.enable(
+        singlelineConfig,
+        onChange: (_) {},
+        onAction: (_) {},
+      );
+      final SemanticsObject textFieldSemantics = createTextFieldSemantics(
+        value: 'hello',
+        isFocused: true,
+      );
+
+      final TextField textField = textFieldSemantics.debugRoleManagerFor(Role.textField) as TextField;
+      expect(textField.editableElement, strategy.domElement);
+      expect(html.document.activeElement, strategy.domElement);
+
+      // The input should not refocus after blur.
+      textField.editableElement.blur();
+      expect(html.document.activeElement, html.document.body);
+      strategy.disable();
+      semantics().semanticsEnabled = false;
+    });
+
+    test('Does not dispose and recreate dom elements in persistent mode', () {
+      semantics()
+        ..debugOverrideTimestampFunction(() => _testTime)
+        ..semanticsEnabled = true;
+
+      strategy.enable(
+        singlelineConfig,
+        onChange: (_) {},
+        onAction: (_) {},
+      );
+
+      // It doesn't create a new DOM element.
+      expect(strategy.domElement, isNull);
+
+      // During the semantics update the DOM element is created and is focused on.
+      final SemanticsObject textFieldSemantics = createTextFieldSemantics(
+        value: 'hello',
+        isFocused: true,
+      );
+      expect(strategy.domElement, isNotNull);
+      expect(html.document.activeElement, strategy.domElement);
+
+      strategy.disable();
+      expect(strategy.domElement, isNull);
+
+      // It doesn't remove the DOM element.
+      final TextField textField = textFieldSemantics.debugRoleManagerFor(Role.textField) as TextField;
+      expect(html.document.body!.contains(textField.editableElement), isTrue);
+      // Editing element is not enabled.
+      expect(strategy.isEnabled, isFalse);
+      expect(html.document.activeElement, html.document.body);
+      semantics().semanticsEnabled = false;
+    });
+
+    test('Refocuses when setting editing state', () {
+      semantics()
+        ..debugOverrideTimestampFunction(() => _testTime)
+        ..semanticsEnabled = true;
+
+      strategy.enable(
+        singlelineConfig,
+        onChange: (_) {},
+        onAction: (_) {},
+      );
+
+      createTextFieldSemantics(
+        value: 'hello',
+        isFocused: true,
+      );
+      expect(strategy.domElement, isNotNull);
+      expect(html.document.activeElement, strategy.domElement);
+
+      // Blur the element without telling the framework.
+      strategy.activeDomElement.blur();
+      expect(html.document.activeElement, html.document.body);
+
+      // The input will have focus after editing state is set and semantics updated.
+      strategy.setEditingState(EditingState(text: 'foo'));
+
+      // NOTE: at this point some browsers, e.g. some versions of Safari will
+      //       have set the focus on the editing element as a result of setting
+      //       the test selection range. Other browsers require an explicit call
+      //       to `element.focus()` for the element to acquire focus. So far,
+      //       this discrepancy hasn't caused issues, so we're not checking for
+      //       any particular focus state between setEditingState and
+      //       createTextFieldSemantics. However, this is something for us to
+      //       keep in mind in case this causes issues in the future.
+
+      createTextFieldSemantics(
+        value: 'hello',
+        isFocused: true,
+      );
+      expect(html.document.activeElement, strategy.domElement);
+
+      strategy.disable();
+      semantics().semanticsEnabled = false;
+    });
+
+    test('Works in multi-line mode', () {
+      semantics()
+        ..debugOverrideTimestampFunction(() => _testTime)
+        ..semanticsEnabled = true;
+
+      strategy.enable(
+        multilineConfig,
+        onChange: (_) {},
+        onAction: (_) {},
+      );
+      createTextFieldSemantics(
+        value: 'hello',
+        isFocused: true,
+        isMultiline: true,
+      );
+
+      final html.TextAreaElement textArea = strategy.domElement as html.TextAreaElement;
+      expect(html.document.activeElement, textArea);
+      strategy.enable(
+        singlelineConfig,
+        onChange: (_) {},
+        onAction: (_) {},
+      );
+
+      textArea.blur();
+      expect(html.document.activeElement, html.document.body);
+
+      strategy.disable();
+      // It doesn't remove the textarea from the DOM.
+      expect(html.document.body!.contains(textArea), isTrue);
+      // Editing element is not enabled.
+      expect(strategy.isEnabled, isFalse);
+      semantics().semanticsEnabled = false;
+    });
+
+    test('Does not position or size its DOM element', () {
+      semantics()
+        ..debugOverrideTimestampFunction(() => _testTime)
+        ..semanticsEnabled = true;
+
+      strategy.enable(
+        singlelineConfig,
+        onChange: (_) {},
+        onAction: (_) {},
+      );
+
+      // Send width and height that are different from semantics values on
+      // purpose.
+      final EditableTextGeometry geometry = EditableTextGeometry(
+        height: 12,
+        width: 13,
+        globalTransform: Matrix4.translationValues(14, 15, 0).storage,
+      );
+      final ui.Rect semanticsRect = ui.Rect.fromLTRB(0, 0, 100, 50);
+
+      testTextEditing.acceptCommand(
+        TextInputSetEditableSizeAndTransform(geometry: geometry),
+        () {},
+      );
+
+      createTextFieldSemantics(
+        value: 'hello',
+        isFocused: true,
+        rect: semanticsRect,
+      );
+
+      // Checks that the placement attributes come from semantics and not from
+      // EditableTextGeometry.
+      void checkPlacementIsSetBySemantics() {
+        expect(strategy.activeDomElement.style.transform, '');
+        expect(strategy.activeDomElement.style.width, '${semanticsRect.width}px');
+        expect(strategy.activeDomElement.style.height, '${semanticsRect.height}px');
+      }
+
+      checkPlacementIsSetBySemantics();
+      strategy.placeElement();
+      checkPlacementIsSetBySemantics();
+      semantics().semanticsEnabled = false;
+    });
+
+    Map<int, SemanticsObject> createTwoFieldSemantics(SemanticsTester builder, { int? focusFieldId }) {
+      builder.updateNode(
+        id: 0,
+        children: <SemanticsNodeUpdate>[
+          builder.updateNode(
+            id: 1,
+            isTextField: true,
+            value: 'Hello',
+            isFocused: focusFieldId == 1,
+            rect: ui.Rect.fromLTRB(0, 0, 50, 10),
+          ),
+          builder.updateNode(
+            id: 2,
+            isTextField: true,
+            value: 'World',
+            isFocused: focusFieldId == 2,
+            rect: ui.Rect.fromLTRB(0, 20, 50, 10),
+          ),
+        ],
+      );
+      return builder.apply();
+    }
+
+    test('Changes focus from one text field to another through a semantics update', () async {
+      semantics()
+        ..debugOverrideTimestampFunction(() => _testTime)
+        ..semanticsEnabled = true;
+
+      strategy.enable(
+        singlelineConfig,
+        onChange: (_) {},
+        onAction: (_) {},
+      );
+
+      // Switch between the two fields a few times.
+      for (int i = 0; i < 5; i++) {
+        final SemanticsTester tester = SemanticsTester(semantics());
+        createTwoFieldSemantics(tester, focusFieldId: 1);
+        expect(tester.apply().length, 3);
+        expect(html.document.activeElement, tester.getTextField(1).editableElement);
+        expect(strategy.domElement, tester.getTextField(1).editableElement);
+
+        createTwoFieldSemantics(tester, focusFieldId: 2);
+        expect(tester.apply().length, 3);
+        expect(html.document.activeElement, tester.getTextField(2).editableElement);
+        expect(strategy.domElement, tester.getTextField(2).editableElement);
+      }
+
+      semantics().semanticsEnabled = false;
+    });
+  },
+  // TODO(nurhan): https://github.com/flutter/flutter/issues/50769
+  skip: browserEngine == BrowserEngine.edge);
+}
+
+SemanticsObject createTextFieldSemantics({
+  required String value,
+  String label = '',
+  bool isFocused = false,
+  bool isMultiline = false,
+  ui.Rect rect = const ui.Rect.fromLTRB(0, 0, 100, 50),
+}) {
+  final SemanticsTester tester = SemanticsTester(semantics());
+  tester.updateNode(
+    id: 0,
+    label: label,
+    value: value,
+    isTextField: true,
+    isFocused: isFocused,
+    isMultiline: isMultiline,
+    hasTap: true,
+    rect: rect,
+    textDirection: ui.TextDirection.ltr,
+  );
+  tester.apply();
+  return tester.getSemanticsObject(0);
+}
diff --git a/lib/web_ui/test/text_editing_test.dart b/lib/web_ui/test/text_editing_test.dart
index 6c9d9a4..394604e 100644
--- a/lib/web_ui/test/text_editing_test.dart
+++ b/lib/web_ui/test/text_editing_test.dart
@@ -13,7 +13,6 @@
 
 import 'package:ui/src/engine.dart' hide window;
 
-import 'matchers.dart';
 import 'spy.dart';
 
 /// The `keyCode` of the "Enter" key.
@@ -24,7 +23,7 @@
 /// Add unit tests for [FirefoxTextEditingStrategy].
 /// TODO(nurhan): https://github.com/flutter/flutter/issues/46891
 
-DefaultTextEditingStrategy editingElement;
+DefaultTextEditingStrategy editingStrategy;
 EditingState lastEditingState;
 String lastInputAction;
 
@@ -57,7 +56,7 @@
   tearDown(() {
     lastEditingState = null;
     lastInputAction = null;
-    cleanTextEditingElement();
+    cleanTextEditingStrategy();
     cleanTestFlags();
     clearBackUpDomElementIfExists();
   });
@@ -67,8 +66,9 @@
 
     setUp(() {
       testTextEditing = HybridTextEditing();
-      editingElement = GloballyPositionedTextEditingStrategy(testTextEditing);
-      testTextEditing.useCustomEditableElement(editingElement);
+      editingStrategy = GloballyPositionedTextEditingStrategy(testTextEditing);
+      testTextEditing.debugTextEditingStrategyOverride = editingStrategy;
+      testTextEditing.configuration = singlelineConfig;
     });
 
     test('Creates element when enabled and removes it when disabled', () {
@@ -79,7 +79,7 @@
       // The focus initially is on the body.
       expect(document.activeElement, document.body);
 
-      editingElement.enable(
+      editingStrategy.enable(
         singlelineConfig,
         onChange: trackEditingState,
         onAction: trackInputAction,
@@ -91,14 +91,14 @@
       final InputElement input = document.getElementsByTagName('input')[0];
       // Now the editing element should have focus.
       expect(document.activeElement, input);
-      expect(editingElement.domElement, input);
+      expect(editingStrategy.domElement, input);
       expect(input.getAttribute('type'), null);
 
       // Input is appended to the glass pane.
-      expect(domRenderer.glassPaneElement.contains(editingElement.domElement),
+      expect(domRenderer.glassPaneElement.contains(editingStrategy.domElement),
           isTrue);
 
-      editingElement.disable();
+      editingStrategy.disable();
       expect(
         document.getElementsByTagName('input'),
         hasLength(0),
@@ -108,73 +108,81 @@
     });
 
     test('Respects read-only config', () {
-      final InputConfiguration config = InputConfiguration(readOnly: true);
-      editingElement.enable(
+      final InputConfiguration config = InputConfiguration(
+        readOnly: true,
+      );
+      editingStrategy.enable(
         config,
         onChange: trackEditingState,
         onAction: trackInputAction,
       );
       expect(document.getElementsByTagName('input'), hasLength(1));
       final InputElement input = document.getElementsByTagName('input')[0];
-      expect(editingElement.domElement, input);
+      expect(editingStrategy.domElement, input);
       expect(input.getAttribute('readonly'), 'readonly');
 
-      editingElement.disable();
+      editingStrategy.disable();
     });
 
     test('Knows how to create password fields', () {
-      final InputConfiguration config = InputConfiguration(obscureText: true);
-      editingElement.enable(
+      final InputConfiguration config = InputConfiguration(
+        obscureText: true,
+      );
+      editingStrategy.enable(
         config,
         onChange: trackEditingState,
         onAction: trackInputAction,
       );
       expect(document.getElementsByTagName('input'), hasLength(1));
       final InputElement input = document.getElementsByTagName('input')[0];
-      expect(editingElement.domElement, input);
+      expect(editingStrategy.domElement, input);
       expect(input.getAttribute('type'), 'password');
 
-      editingElement.disable();
+      editingStrategy.disable();
     });
 
     test('Knows to turn autocorrect off', () {
-      final InputConfiguration config = InputConfiguration(autocorrect: false);
-      editingElement.enable(
+      final InputConfiguration config = InputConfiguration(
+        autocorrect: false,
+      );
+      editingStrategy.enable(
         config,
         onChange: trackEditingState,
         onAction: trackInputAction,
       );
       expect(document.getElementsByTagName('input'), hasLength(1));
       final InputElement input = document.getElementsByTagName('input')[0];
-      expect(editingElement.domElement, input);
+      expect(editingStrategy.domElement, input);
       expect(input.getAttribute('autocorrect'), 'off');
 
-      editingElement.disable();
+      editingStrategy.disable();
     });
 
     test('Knows to turn autocorrect on', () {
-      final InputConfiguration config = InputConfiguration(autocorrect: true);
-      editingElement.enable(
+      final InputConfiguration config = InputConfiguration(
+        autocorrect: true,
+      );
+      editingStrategy.enable(
         config,
         onChange: trackEditingState,
         onAction: trackInputAction,
       );
       expect(document.getElementsByTagName('input'), hasLength(1));
       final InputElement input = document.getElementsByTagName('input')[0];
-      expect(editingElement.domElement, input);
+      expect(editingStrategy.domElement, input);
       expect(input.getAttribute('autocorrect'), 'on');
 
-      editingElement.disable();
+      editingStrategy.disable();
     });
 
     test('Can read editing state correctly', () {
-      editingElement.enable(
+      editingStrategy.enable(
         singlelineConfig,
         onChange: trackEditingState,
         onAction: trackInputAction,
       );
 
-      final InputElement input = editingElement.domElement;
+      final InputElement input = editingStrategy.domElement;
       input.value = 'foo bar';
       input.dispatchEvent(Event.eventType('Event', 'input'));
       expect(
@@ -194,15 +202,15 @@
     });
 
     test('Can set editing state correctly', () {
-      editingElement.enable(
+      editingStrategy.enable(
         singlelineConfig,
         onChange: trackEditingState,
         onAction: trackInputAction,
       );
-      editingElement.setEditingState(
+      editingStrategy.setEditingState(
           EditingState(text: 'foo bar baz', baseOffset: 2, extentOffset: 7));
 
-      checkInputEditingState(editingElement.domElement, 'foo bar baz', 2, 7);
+      checkInputEditingState(editingStrategy.domElement, 'foo bar baz', 2, 7);
 
       // There should be no input action.
       expect(lastInputAction, isNull);
@@ -211,7 +219,7 @@
     test('Multi-line mode also works', () {
       // The textarea element is created lazily.
       expect(document.getElementsByTagName('textarea'), hasLength(0));
-      editingElement.enable(
+      editingStrategy.enable(
         multilineConfig,
         onChange: trackEditingState,
         onAction: trackInputAction,
@@ -222,7 +230,7 @@
           document.getElementsByTagName('textarea')[0];
       // Now the textarea should have focus.
       expect(document.activeElement, textarea);
-      expect(editingElement.domElement, textarea);
+      expect(editingStrategy.domElement, textarea);
 
       textarea.value = 'foo\nbar';
       textarea.dispatchEvent(Event.eventType('Event', 'input'));
@@ -235,11 +243,11 @@
       );
 
       // Can set textarea state correctly (and preserves new lines).
-      editingElement.setEditingState(
+      editingStrategy.setEditingState(
           EditingState(text: 'bar\nbaz', baseOffset: 2, extentOffset: 7));
       checkTextAreaEditingState(textarea, 'bar\nbaz', 2, 7);
 
-      editingElement.disable();
+      editingStrategy.disable();
       // The textarea should be cleaned up.
       expect(document.getElementsByTagName('textarea'), hasLength(0));
       // The focus is back to the body.
@@ -255,7 +263,7 @@
       expect(document.getElementsByTagName('textarea'), hasLength(0));
 
       // Use single-line config and expect an `<input>` to be created.
-      editingElement.enable(
+      editingStrategy.enable(
         singlelineConfig,
         onChange: trackEditingState,
         onAction: trackInputAction,
@@ -264,12 +272,12 @@
       expect(document.getElementsByTagName('textarea'), hasLength(0));
 
       // Disable and check that all DOM elements were removed.
-      editingElement.disable();
+      editingStrategy.disable();
       expect(document.getElementsByTagName('input'), hasLength(0));
       expect(document.getElementsByTagName('textarea'), hasLength(0));
 
       // Use multi-line config and expect an `<textarea>` to be created.
-      editingElement.enable(
+      editingStrategy.enable(
         multilineConfig,
         onChange: trackEditingState,
         onAction: trackInputAction,
@@ -278,7 +286,7 @@
       expect(document.getElementsByTagName('textarea'), hasLength(1));
 
       // Disable again and check that all DOM elements were removed.
-      editingElement.disable();
+      editingStrategy.disable();
       expect(document.getElementsByTagName('input'), hasLength(0));
       expect(document.getElementsByTagName('textarea'), hasLength(0));
 
@@ -287,9 +295,10 @@
     });
 
     test('Triggers input action', () {
-      final InputConfiguration config =
-          InputConfiguration(inputAction: 'TextInputAction.done');
-      editingElement.enable(
+      final InputConfiguration config = InputConfiguration(
+        inputAction: 'TextInputAction.done',
+      );
+      editingStrategy.enable(
         config,
         onChange: trackEditingState,
         onAction: trackInputAction,
@@ -299,7 +308,7 @@
       expect(lastInputAction, isNull);
 
       dispatchKeyboardEvent(
-        editingElement.domElement,
+        editingStrategy.domElement,
         'keydown',
         keyCode: _kReturnKeyCode,
       );
@@ -313,7 +322,7 @@
         inputType: EngineInputType.multiline,
         inputAction: 'TextInputAction.done',
       );
-      editingElement.enable(
+      editingStrategy.enable(
         config,
         onChange: trackEditingState,
         onAction: trackInputAction,
@@ -323,7 +332,7 @@
       expect(lastInputAction, isNull);
 
       final KeyboardEvent event = dispatchKeyboardEvent(
-        editingElement.domElement,
+        editingStrategy.domElement,
         'keydown',
         keyCode: _kReturnKeyCode,
       );
@@ -335,270 +344,29 @@
     });
 
     test('globally positions and sizes its DOM element', () {
-      editingElement.enable(
+      editingStrategy.enable(
         singlelineConfig,
         onChange: trackEditingState,
         onAction: trackInputAction,
       );
-      expect(editingElement.isEnabled, isTrue);
+      expect(editingStrategy.isEnabled, isTrue);
 
       // No geometry should be set until setEditableSizeAndTransform is called.
-      expect(editingElement.domElement.style.transform, '');
-      expect(editingElement.domElement.style.width, '');
-      expect(editingElement.domElement.style.height, '');
+      expect(editingStrategy.domElement.style.transform, '');
+      expect(editingStrategy.domElement.style.width, '');
+      expect(editingStrategy.domElement.style.height, '');
 
-      testTextEditing.setEditableSizeAndTransform(EditableTextGeometry(
+      testTextEditing.acceptCommand(TextInputSetEditableSizeAndTransform(geometry: EditableTextGeometry(
         width: 13,
         height: 12,
         globalTransform: Matrix4.translationValues(14, 15, 0).storage,
-      ));
+      )), () {});
 
       // setEditableSizeAndTransform calls placeElement, so expecting geometry to be applied.
-      expect(editingElement.domElement.style.transform,
+      expect(editingStrategy.domElement.style.transform,
           'matrix(1, 0, 0, 1, 14, 15)');
-      expect(editingElement.domElement.style.width, '13px');
-      expect(editingElement.domElement.style.height, '12px');
-    });
-  });
-
-  group('$SemanticsTextEditingStrategy', () {
-    InputElement testInputElement;
-    HybridTextEditing testTextEditing;
-
-    final PlatformMessagesSpy spy = PlatformMessagesSpy();
-
-    /// Emulates sending of a message by the framework to the engine.
-    void sendFrameworkMessage(dynamic message) {
-      textEditing.channel.handleTextInput(message, (ByteData data) {});
-    }
-
-    setUp(() {
-      testInputElement = InputElement();
-      testTextEditing = HybridTextEditing();
-      editingElement = GloballyPositionedTextEditingStrategy(testTextEditing);
-    });
-
-    tearDown(() {
-      testInputElement = null;
-    });
-
-    test('autofill form lifecycle works', () async {
-      editingElement = SemanticsTextEditingStrategy(
-          SemanticsObject(5, null), testTextEditing, testInputElement);
-      // Create a configuration with an AutofillGroup of four text fields.
-      final Map<String, dynamic> flutterMultiAutofillElementConfig =
-          createFlutterConfig('text',
-              autofillHint: 'username',
-              autofillHintsForFields: [
-            'username',
-            'email',
-            'name',
-            'telephoneNumber'
-          ]);
-      final MethodCall setClient = MethodCall('TextInput.setClient',
-          <dynamic>[123, flutterMultiAutofillElementConfig]);
-      sendFrameworkMessage(codec.encodeMethodCall(setClient));
-
-      const MethodCall setEditingState1 =
-          MethodCall('TextInput.setEditingState', <String, dynamic>{
-        'text': 'abcd',
-        'selectionBase': 2,
-        'selectionExtent': 3,
-      });
-      sendFrameworkMessage(codec.encodeMethodCall(setEditingState1));
-
-      const MethodCall show = MethodCall('TextInput.show');
-      sendFrameworkMessage(codec.encodeMethodCall(show));
-
-      // The transform is changed. For example after a validation error, red
-      // line appeared under the input field.
-      final MethodCall setSizeAndTransform =
-          configureSetSizeAndTransformMethodCall(150, 50,
-              Matrix4.translationValues(10.0, 20.0, 30.0).storage.toList());
-      sendFrameworkMessage(codec.encodeMethodCall(setSizeAndTransform));
-
-      // Form is added to DOM.
-      expect(document.getElementsByTagName('form'), isNotEmpty);
-
-      const MethodCall clearClient = MethodCall('TextInput.clearClient');
-      sendFrameworkMessage(codec.encodeMethodCall(clearClient));
-
-      // Confirm that [HybridTextEditing] didn't send any messages.
-      expect(spy.messages, isEmpty);
-      // Form stays on the DOM until autofill context is finalized.
-      expect(document.getElementsByTagName('form'), isNotEmpty);
-      expect(formsOnTheDom, hasLength(1));
-
-      const MethodCall finishAutofillContext =
-          MethodCall('TextInput.finishAutofillContext', false);
-      sendFrameworkMessage(codec.encodeMethodCall(finishAutofillContext));
-
-      // Form element is removed from DOM.
-      expect(document.getElementsByTagName('form'), hasLength(0));
-      expect(formsOnTheDom, hasLength(0));
-    },
-        // TODO(nurhan): https://github.com/flutter/flutter/issues/50769
-        skip: browserEngine == BrowserEngine.edge);
-
-    test('Does not accept dom elements of a wrong type', () {
-      // A regular <span> shouldn't be accepted.
-      final HtmlElement span = SpanElement();
-      expect(
-        () => SemanticsTextEditingStrategy(
-            SemanticsObject(5, null), HybridTextEditing(), span),
-        throwsAssertionError,
-      );
-    });
-
-    test('Do not re-acquire focus', () {
-      editingElement = SemanticsTextEditingStrategy(
-          SemanticsObject(5, null), HybridTextEditing(), testInputElement);
-
-      expect(document.activeElement, document.body);
-
-      document.body.append(testInputElement);
-      editingElement.enable(
-        singlelineConfig,
-        onChange: trackEditingState,
-        onAction: trackInputAction,
-      );
-      expect(document.activeElement, testInputElement);
-
-      // The input should not refocus after blur.
-      editingElement.domElement.blur();
-      expect(document.activeElement, document.body);
-
-      editingElement.disable();
-    },
-        // TODO(nurhan): https://github.com/flutter/flutter/issues/50769
-        skip: browserEngine == BrowserEngine.edge);
-
-    test('Does not dispose and recreate dom elements in persistent mode', () {
-      editingElement = SemanticsTextEditingStrategy(
-          SemanticsObject(5, null), HybridTextEditing(), testInputElement);
-
-      // The DOM element should've been eagerly created.
-      expect(testInputElement, isNotNull);
-      // But doesn't have focus.
-      expect(document.activeElement, document.body);
-
-      // Can't enable before the input element is inserted into the DOM.
-      expect(
-        () => editingElement.enable(
-          singlelineConfig,
-          onChange: trackEditingState,
-          onAction: trackInputAction,
-        ),
-        throwsAssertionError,
-      );
-
-      document.body.append(testInputElement);
-      editingElement.enable(
-        singlelineConfig,
-        onChange: trackEditingState,
-        onAction: trackInputAction,
-      );
-      expect(document.activeElement, editingElement.domElement);
-      // It doesn't create a new DOM element.
-      expect(editingElement.domElement, testInputElement);
-
-      editingElement.disable();
-      // It doesn't remove the DOM element.
-      expect(editingElement.domElement, testInputElement);
-      expect(document.body.contains(editingElement.domElement), isTrue);
-      // Editing element is not enabled.
-      expect(editingElement.isEnabled, isFalse);
-      // For mobile browsers `blur` is called to close the onscreen keyboard.
-      if (operatingSystem == OperatingSystem.iOs &&
-          browserEngine == BrowserEngine.webkit) {
-        expect(document.activeElement, document.body);
-      } else {
-        expect(document.activeElement, editingElement.domElement);
-      }
-    },
-        // TODO(nurhan): https://github.com/flutter/flutter/issues/50769
-        skip: browserEngine == BrowserEngine.edge);
-
-    test('Refocuses when setting editing state', () {
-      editingElement = SemanticsTextEditingStrategy(
-          SemanticsObject(5, null), HybridTextEditing(), testInputElement);
-
-      document.body.append(testInputElement);
-      editingElement.enable(
-        singlelineConfig,
-        onChange: trackEditingState,
-        onAction: trackInputAction,
-      );
-      // The input will have focus after editing state is set.
-      editingElement.setEditingState(EditingState(text: 'foo'));
-      expect(document.activeElement, testInputElement);
-
-      editingElement.disable();
-    },
-        // TODO(nurhan): https://github.com/flutter/flutter/issues/50769
-        skip: browserEngine == BrowserEngine.edge);
-
-    test('Works in multi-line mode', () {
-      final TextAreaElement textarea = TextAreaElement();
-      editingElement = SemanticsTextEditingStrategy(
-          SemanticsObject(5, null), HybridTextEditing(), textarea);
-
-      expect(editingElement.domElement, textarea);
-      expect(document.activeElement, document.body);
-
-      // Can't enable before the textarea is inserted into the DOM.
-      expect(
-        () => editingElement.enable(
-          singlelineConfig,
-          onChange: trackEditingState,
-          onAction: trackInputAction,
-        ),
-        throwsAssertionError,
-      );
-
-      document.body.append(textarea);
-      editingElement.enable(
-        multilineConfig,
-        onChange: trackEditingState,
-        onAction: trackInputAction,
-      );
-      // Focuses the textarea.
-      expect(document.activeElement, textarea);
-
-      textarea.blur();
-      // The textArea loses focus.
-      expect(document.activeElement, document.body);
-
-      editingElement.disable();
-      // It doesn't remove the textarea from the DOM.
-      expect(document.body.contains(editingElement.domElement), isTrue);
-      // Editing element is not enabled.
-      expect(editingElement.isEnabled, isFalse);
-    },
-        // TODO(nurhan): https://github.com/flutter/flutter/issues/50769
-        skip: browserEngine == BrowserEngine.edge);
-
-    test('Does not position or size its DOM element', () {
-      editingElement.enable(
-        singlelineConfig,
-        onChange: trackEditingState,
-        onAction: trackInputAction,
-      );
-      testTextEditing.setEditableSizeAndTransform(EditableTextGeometry(
-        height: 12,
-        width: 13,
-        globalTransform: Matrix4.translationValues(14, 15, 0).storage,
-      ));
-
-      void checkPlacementIsEmpty() {
-        expect(editingElement.domElement.style.transform, '');
-        expect(editingElement.domElement.style.width, '');
-        expect(editingElement.domElement.style.height, '');
-      }
-
-      checkPlacementIsEmpty();
-      editingElement.placeElement();
-      checkPlacementIsEmpty();
+      expect(editingStrategy.domElement.style.width, '13px');
+      expect(editingStrategy.domElement.style.height, '12px');
     });
   });
 
@@ -644,7 +412,7 @@
     }
 
     String getEditingInputMode() {
-      return textEditing.editingElement.domElement.getAttribute('inputmode');
+      return textEditing.strategy.domElement.getAttribute('inputmode');
     }
 
     setUp(() {
@@ -671,7 +439,7 @@
       const MethodCall show = MethodCall('TextInput.show');
       sendFrameworkMessage(codec.encodeMethodCall(show));
 
-      checkInputEditingState(textEditing.editingElement.domElement, '', 0, 0);
+      checkInputEditingState(textEditing.strategy.domElement, '', 0, 0);
 
       const MethodCall setEditingState =
           MethodCall('TextInput.setEditingState', <String, dynamic>{
@@ -682,7 +450,7 @@
       sendFrameworkMessage(codec.encodeMethodCall(setEditingState));
 
       checkInputEditingState(
-          textEditing.editingElement.domElement, 'abcd', 2, 3);
+          textEditing.strategy.domElement, 'abcd', 2, 3);
 
       const MethodCall hide = MethodCall('TextInput.hide');
       sendFrameworkMessage(codec.encodeMethodCall(hide));
@@ -714,7 +482,7 @@
       sendFrameworkMessage(codec.encodeMethodCall(show));
 
       checkInputEditingState(
-          textEditing.editingElement.domElement, 'abcd', 2, 3);
+          textEditing.strategy.domElement, 'abcd', 2, 3);
 
       const MethodCall clearClient = MethodCall('TextInput.clearClient');
       sendFrameworkMessage(codec.encodeMethodCall(clearClient));
@@ -745,7 +513,7 @@
       const MethodCall show = MethodCall('TextInput.show');
       sendFrameworkMessage(codec.encodeMethodCall(show));
 
-      final HtmlElement element = textEditing.editingElement.domElement;
+      final HtmlElement element = textEditing.strategy.domElement;
       expect(element.getAttribute('readonly'), 'readonly');
 
       // Update the read-only config.
@@ -787,10 +555,11 @@
       sendFrameworkMessage(codec.encodeMethodCall(show));
 
       checkInputEditingState(
-          textEditing.editingElement.domElement, 'abcd', 2, 3);
+          textEditing.strategy.domElement, 'abcd', 2, 3);
+      expect(textEditing.isEditing, isTrue);
 
       // DOM element is blurred.
-      textEditing.editingElement.domElement.blur();
+      textEditing.strategy.domElement.blur();
 
       // For ios-safari the connection is closed.
       if (browserEngine == BrowserEngine.webkit &&
@@ -807,11 +576,11 @@
         expect(spy.messages, hasLength(0));
         await Future<void>.delayed(Duration.zero);
         // DOM element still keeps the focus.
-        expect(document.activeElement, textEditing.editingElement.domElement);
+        expect(document.activeElement, textEditing.strategy.domElement);
       }
     },
         // TODO(nurhan): https://github.com/flutter/flutter/issues/50769
-        skip: (browserEngine == BrowserEngine.edge));
+        skip: browserEngine == BrowserEngine.edge);
 
     test('finishAutofillContext closes connection no autofill element',
         () async {
@@ -834,7 +603,7 @@
       sendFrameworkMessage(codec.encodeMethodCall(show));
 
       checkInputEditingState(
-          textEditing.editingElement.domElement, 'abcd', 2, 3);
+          textEditing.strategy.domElement, 'abcd', 2, 3);
 
       const MethodCall finishAutofillContext =
           MethodCall('TextInput.finishAutofillContext', false);
@@ -859,14 +628,16 @@
     test('finishAutofillContext removes form from DOM', () async {
       // Create a configuration with an AutofillGroup of four text fields.
       final Map<String, dynamic> flutterMultiAutofillElementConfig =
-          createFlutterConfig('text',
-              autofillHint: 'username',
-              autofillHintsForFields: [
-            'username',
-            'email',
-            'name',
-            'telephoneNumber'
-          ]);
+          createFlutterConfig(
+            'text',
+            autofillHint: 'username',
+            autofillHintsForFields: [
+              'username',
+              'email',
+              'name',
+              'telephoneNumber'
+            ],
+          );
       final MethodCall setClient = MethodCall('TextInput.setClient',
           <dynamic>[123, flutterMultiAutofillElementConfig]);
       sendFrameworkMessage(codec.encodeMethodCall(setClient));
@@ -1044,7 +815,7 @@
       sendFrameworkMessage(codec.encodeMethodCall(show));
 
       checkInputEditingState(
-          textEditing.editingElement.domElement, 'abcd', 2, 3);
+          textEditing.strategy.domElement, 'abcd', 2, 3);
 
       final MethodCall setClient2 = MethodCall(
           'TextInput.setClient', <dynamic>[567, flutterSinglelineConfig]);
@@ -1086,7 +857,7 @@
 
       // The second [setEditingState] should override the first one.
       checkInputEditingState(
-          textEditing.editingElement.domElement, 'xyz', 0, 2);
+          textEditing.strategy.domElement, 'xyz', 0, 2);
 
       const MethodCall clearClient = MethodCall('TextInput.clearClient');
       sendFrameworkMessage(codec.encodeMethodCall(clearClient));
@@ -1123,7 +894,7 @@
 
       // The second [setEditingState] should override the first one.
       checkInputEditingState(
-          textEditing.editingElement.domElement, 'abcd', 2, 3);
+          textEditing.strategy.domElement, 'abcd', 2, 3);
 
       final FormElement formElement = document.getElementsByTagName('form')[0];
       // The form has one input element and one submit button.
@@ -1161,7 +932,7 @@
       sendFrameworkMessage(codec.encodeMethodCall(show));
 
       final InputElement inputElement =
-          textEditing.editingElement.domElement as InputElement;
+          textEditing.strategy.domElement as InputElement;
       expect(inputElement.value, 'abcd');
       if (!(browserEngine == BrowserEngine.webkit &&
           operatingSystem == OperatingSystem.macOs)) {
@@ -1181,11 +952,11 @@
       sendFrameworkMessage(codec.encodeMethodCall(setSizeAndTransform));
 
       // Check the element still has focus. User can keep editing.
-      expect(document.activeElement, textEditing.editingElement.domElement);
+      expect(document.activeElement, textEditing.strategy.domElement);
 
       // Check the cursor location is the same.
       checkInputEditingState(
-          textEditing.editingElement.domElement, 'abcd', 2, 3);
+          textEditing.strategy.domElement, 'abcd', 2, 3);
 
       const MethodCall clearClient = MethodCall('TextInput.clearClient');
       sendFrameworkMessage(codec.encodeMethodCall(clearClient));
@@ -1232,7 +1003,7 @@
 
       // The second [setEditingState] should override the first one.
       checkInputEditingState(
-          textEditing.editingElement.domElement, 'abcd', 2, 3);
+          textEditing.strategy.domElement, 'abcd', 2, 3);
 
       final FormElement formElement = document.getElementsByTagName('form')[0];
       // The form has 4 input elements and one submit button.
@@ -1274,12 +1045,12 @@
       if (browserEngine == BrowserEngine.webkit &&
           operatingSystem == OperatingSystem.iOs) {
         expect(
-            textEditing.editingElement.domElement
+            textEditing.strategy.domElement
                 .getAttribute('autocapitalize'),
             'off');
       } else {
         expect(
-            textEditing.editingElement.domElement
+            textEditing.strategy.domElement
                 .getAttribute('autocapitalize'),
             isNull);
       }
@@ -1313,7 +1084,7 @@
       if (browserEngine == BrowserEngine.webkit &&
           operatingSystem == OperatingSystem.iOs) {
         expect(
-            textEditing.editingElement.domElement
+            textEditing.strategy.domElement
                 .getAttribute('autocapitalize'),
             'characters');
       }
@@ -1349,7 +1120,7 @@
       const MethodCall show = MethodCall('TextInput.show');
       sendFrameworkMessage(codec.encodeMethodCall(show));
 
-      final HtmlElement domElement = textEditing.editingElement.domElement;
+      final HtmlElement domElement = textEditing.strategy.domElement;
 
       checkInputEditingState(domElement, 'abcd', 2, 3);
 
@@ -1360,7 +1131,7 @@
               const Point<double>(160.0, 70.0)));
       expect(domElement.style.transform,
           'matrix3d(1, 0, 0, 0, 0, 1, 0, 0, 0, 0, 1, 0, 10, 20, 30, 1)');
-      expect(textEditing.editingElement.domElement.style.font,
+      expect(textEditing.strategy.domElement.style.font,
           '500 12px sans-serif');
 
       const MethodCall clearClient = MethodCall('TextInput.clearClient');
@@ -1405,7 +1176,7 @@
       });
       sendFrameworkMessage(codec.encodeMethodCall(setEditingState));
 
-      final HtmlElement domElement = textEditing.editingElement.domElement;
+      final HtmlElement domElement = textEditing.strategy.domElement;
 
       checkInputEditingState(domElement, 'abcd', 2, 3);
 
@@ -1420,7 +1191,7 @@
         'matrix3d(1, 0, 0, 0, 0, 1, 0, 0, 0, 0, 1, 0, 10, 20, 30, 1)',
       );
       expect(
-        textEditing.editingElement.domElement.style.font,
+        textEditing.strategy.domElement.style.font,
         '500 12px sans-serif',
       );
 
@@ -1428,11 +1199,11 @@
       if (browserEngine == BrowserEngine.blink ||
           browserEngine == BrowserEngine.samsung ||
           browserEngine == BrowserEngine.webkit) {
-        expect(textEditing.editingElement.domElement.classes,
+        expect(textEditing.strategy.domElement.classes,
             contains('transparentTextEditing'));
       } else {
         expect(
-            textEditing.editingElement.domElement.classes.any(
+            textEditing.strategy.domElement.classes.any(
                 (element) => element.toString() == 'transparentTextEditing'),
             isFalse);
       }
@@ -1468,7 +1239,7 @@
       const MethodCall show = MethodCall('TextInput.show');
       sendFrameworkMessage(codec.encodeMethodCall(show));
 
-      final HtmlElement domElement = textEditing.editingElement.domElement;
+      final HtmlElement domElement = textEditing.strategy.domElement;
 
       checkInputEditingState(domElement, 'abcd', 2, 3);
 
@@ -1480,7 +1251,7 @@
       expect(domElement.style.transform,
           'matrix3d(1, 0, 0, 0, 0, 1, 0, 0, 0, 0, 1, 0, 10, 20, 30, 1)');
       expect(
-          textEditing.editingElement.domElement.style.font, '12px sans-serif');
+          textEditing.strategy.domElement.style.font, '12px sans-serif');
 
       hideKeyboard();
     },
@@ -1490,7 +1261,7 @@
     test('Canonicalizes font family', () {
       showKeyboard();
 
-      final HtmlElement input = textEditing.editingElement.domElement;
+      final HtmlElement input = textEditing.strategy.domElement;
 
       MethodCall setStyle;
 
@@ -1529,7 +1300,7 @@
 
       // Check if the selection range is correct.
       checkInputEditingState(
-          textEditing.editingElement.domElement, 'xyz', 1, 2);
+          textEditing.strategy.domElement, 'xyz', 1, 2);
 
       const MethodCall setEditingState2 =
           MethodCall('TextInput.setEditingState', <String, dynamic>{
@@ -1541,7 +1312,7 @@
 
       // The negative offset values are applied to the dom element as 0.
       checkInputEditingState(
-          textEditing.editingElement.domElement, 'xyz', 0, 0);
+          textEditing.strategy.domElement, 'xyz', 0, 0);
 
       hideKeyboard();
     });
@@ -1562,7 +1333,7 @@
       const MethodCall show = MethodCall('TextInput.show');
       sendFrameworkMessage(codec.encodeMethodCall(show));
 
-      final InputElement input = textEditing.editingElement.domElement;
+      final InputElement input = textEditing.strategy.domElement;
 
       input.value = 'something';
       input.dispatchEvent(Event.eventType('Event', 'input'));
@@ -1586,7 +1357,7 @@
       input.setSelectionRange(2, 5);
       if (browserEngine == BrowserEngine.firefox) {
         Event keyup = KeyboardEvent('keyup');
-        textEditing.editingElement.domElement.dispatchEvent(keyup);
+        textEditing.strategy.domElement.dispatchEvent(keyup);
       } else {
         document.dispatchEvent(Event.eventType('Event', 'selectionchange'));
       }
@@ -1644,7 +1415,7 @@
 
       // The second [setEditingState] should override the first one.
       checkInputEditingState(
-          textEditing.editingElement.domElement, 'abcd', 2, 3);
+          textEditing.strategy.domElement, 'abcd', 2, 3);
 
       final FormElement formElement = document.getElementsByTagName('form')[0];
       // The form has 4 input elements and one submit button.
@@ -1695,7 +1466,7 @@
       const MethodCall show = MethodCall('TextInput.show');
       sendFrameworkMessage(codec.encodeMethodCall(show));
 
-      final TextAreaElement textarea = textEditing.editingElement.domElement;
+      final TextAreaElement textarea = textEditing.strategy.domElement;
       checkTextAreaEditingState(textarea, '', 0, 0);
 
       // Can set editing state and preserve new lines.
@@ -1713,7 +1484,7 @@
       textarea.dispatchEvent(Event.eventType('Event', 'input'));
       textarea.setSelectionRange(2, 5);
       if (browserEngine == BrowserEngine.firefox) {
-        textEditing.editingElement.domElement
+        textEditing.strategy.domElement
             .dispatchEvent(KeyboardEvent('keyup'));
       } else {
         document.dispatchEvent(Event.eventType('Event', 'selectionchange'));
@@ -1837,7 +1608,7 @@
       expect(lastInputAction, isNull);
 
       dispatchKeyboardEvent(
-        textEditing.editingElement.domElement,
+        textEditing.strategy.domElement,
         'keydown',
         keyCode: _kReturnKeyCode,
       );
@@ -1860,7 +1631,7 @@
       );
 
       final KeyboardEvent event = dispatchKeyboardEvent(
-        textEditing.editingElement.domElement,
+        textEditing.strategy.domElement,
         'keydown',
         keyCode: _kReturnKeyCode,
       );
@@ -2088,9 +1859,9 @@
     EditingState _editingState;
 
     setUp(() {
-      editingElement =
+      editingStrategy =
           GloballyPositionedTextEditingStrategy(HybridTextEditing());
-      editingElement.enable(
+      editingStrategy.enable(
         singlelineConfig,
         onChange: trackEditingState,
         onAction: trackInputAction,
@@ -2128,8 +1899,8 @@
     });
 
     test('Configure text area element from the editing state', () {
-      cleanTextEditingElement();
-      editingElement.enable(
+      cleanTextEditingStrategy();
+      editingStrategy.enable(
         multilineConfig,
         onChange: trackEditingState,
         onAction: trackInputAction,
@@ -2161,8 +1932,8 @@
     });
 
     test('Get Editing State from text area element', () {
-      cleanTextEditingElement();
-      editingElement.enable(
+      cleanTextEditingStrategy();
+      editingStrategy.enable(
         multilineConfig,
         onChange: trackEditingState,
         onAction: trackInputAction,
@@ -2242,10 +2013,10 @@
 
 /// Will disable editing element which will also clean the backup DOM
 /// element from the page.
-void cleanTextEditingElement() {
-  if (editingElement != null && editingElement.isEnabled) {
+void cleanTextEditingStrategy() {
+  if (editingStrategy != null && editingStrategy.isEnabled) {
     // Clean up all the DOM elements and event listeners.
-    editingElement.disable();
+    editingStrategy.disable();
   }
 }