Skip to content

[web] Keep semantics editing state while the text input connection is open - #195141

Merged
auto-submit[bot] merged 5 commits into
flutter:masterfrom
flutter-zl:web-semantics-keep-editing-state
Oct 30, 2026
Merged

auto-submit[bot] merged 5 commits into
flutter:masterfrom
flutter-zl:web-semantics-keep-editing-state

Conversation

@flutter-zl

@flutter-zl flutter-zl commented Oct 23, 2026 •

Copy link
Copy Markdown
Contributor

Fixes #194636

Problem

On web with semantics enabled, a focused TextField loses its text when its semantics node leaves the tree and comes back, for example after scrolling it out of a ListView and back. The framework keeps the connection open, but SemanticsTextEditingStrategy.deactivate() called disable(), which cleared connection state when the DOM <input> was disposed. The re-created element came back empty and unstyled, and its first selectionchange sent "" to the framework as an edit.

Fix

SemanticsTextEditingStrategy now keeps the element lifecycle separate from the connection lifecycle. deactivate() only detaches the <input> through a new _detachElement(), and disable() detaches and then clears the connection state, which is the editing state, queued style, config and callbacks. A re-created element therefore gets its text and style back from activate(). activate() also returns early when no connection is open, so a field that stays focused after TextInput.hide is not attached with a null config.

Demo

Before: https://flutter-demo-07-before.web.app
After: https://flutter-demo-07-after.web.app

In desktop Chrome, click the text field, which holds hello, and optionally type world. Scroll the list down with the mouse wheel until the field is off-screen, then scroll back up. Before: the field is empty and the bottom bar reads controller.text = "". After: the field still shows the text and the bottom bar matches.

@flutter-dashboard flutter-dashboard Bot added the CICD Run CI/CD label Oct 23, 2026
@github-actions github-actions Bot added a: text input Entering text in a text field or keyboard related problems engine flutter/engine related. See also e: labels. a: accessibility Accessibility, e.g. VoiceOver or TalkBack. (aka a11y) platform-web Web applications specifically team-web Owned by Web platform team labels Oct 23, 2026

@gemini-code-assist gemini-code-assist Bot left a comment

Copy link
Copy Markdown
Contributor

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Code Review

This pull request modifies the disable method in SemanticsTextEditingStrategy to conditionally clear lastEditingState only when owner.isEditing is false, preserving the editing state when a focused text field's semantics node temporarily leaves the tree while the framework connection remains open. Additionally, two new tests are added in text_field_test.dart to verify that the editing state is restored in a re-created field when the connection is open, and that it is dropped when the connection is closed. There are no review comments, and I have no feedback to provide.

@flutter-zl
flutter-zl requested a review from mdebbar October 23, 2026 18:00
@flutter-zl flutter-zl changed the title [web] Keep semantics editing state while the text input connection is… [web] Keep semantics editing state while the text input connection is open Oct 26, 2026

@mdebbar mdebbar left a comment

Copy link
Copy Markdown
Contributor

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

I think the underlying issue is that SemanticsTextEditingStrategy.disable() is currently used for two different lifecycles:

  1. The TextInput connection lifecycle (enable() / disable(), driven by HybridTextEditing._startEditing() / stopEditing()), which owns inputConfig, onChange, onAction, lastEditingState, and _queuedStyle.
  2. The DOM element lifecycle (activate() / deactivate(), driven by SemanticTextField), which attaches and detaches editableElement (activeTextField, domElement, isEnabled, subscriptions, _formIsActive).

Because deactivate() calls disable(), detaching a <flt-semantics> node's <input> also wipes connection-scoped state. With the current PR, disable() still clears _queuedStyle = null on line 198, and since the framework keeps the connection open on scroll-away, it never re-sends TextInput.setStyle — so when the field scrolls back and activate() calls _syncStyle(), _queuedStyle is null and the replacement <input> loses its style.

What if we keep deactivate() scoped to detaching the DOM element, and only call disable() when the connection itself closes? For example:

  void activate(SemanticTextField textField) {
    assert(
      inputConfig != null && onChange != null && onAction != null,
      '"enable" should be called before "enableFromSemantics" and initialize input configuration',
    );

    if (activeTextField == textField) {
      return;
    } else if (activeTextField != null) {
      deactivate(activeTextField!);
    }

    activeTextField = textField;
    domElement = textField.editableElement;
    super.enable(inputConfig!, onChange: onChange!, onAction: onAction!);
    _syncStyle();
  }

  void deactivate(SemanticTextField textField) {
    if (activeTextField == textField) {
      _detachElement();
    }
  }

  void _detachElement() {
    if (!isEnabled) {
      return;
    }

    isEnabled = false;
    style = null;
    geometry = null;

    for (var i = 0; i < subscriptions.length; i++) {
      subscriptions[i].cancel();
    }
    subscriptions.clear();

    if (_formIsActive && inputConfiguration.autofillGroup != null) {
      final EngineAutofillForm group = inputConfiguration.autofillGroup!;
      if (inputConfiguration.autofill != null) {
        group.demoteFocusedToSynthetic(activeDomElement, inputConfiguration.autofill!);
      }
      if (group.formElement != null) {
        group.goDormant();
      }
      _formIsActive = false;
    }

    EnginePlatformDispatcher.instance.viewManager.safeBlur(activeDomElement);
    domElement = null;
    activeTextField = null;
  }

  @override
  void disable() {
    _detachElement();
    lastEditingState = null;
    _queuedStyle = null;
    inputConfig = null;
    onChange = null;
    onAction = null;
  }

testTextEditing.acceptCommand(const TextInputClearClient(), () {});
expect(testTextEditing.isEditing, isFalse);
expect(strategy.lastEditingState, isNull);
});

Copy link
Copy Markdown
Contributor

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Can we also cover the case where the field goes away first and the connection closes while it's gone (e.g. scroll away, then unfocus)? That's the case that relies on clearing lastEditingState before the isEnabled early return:

    test('Drops the editing state when the connection closes while the field is gone', () {
      testTextEditing.acceptCommand(
        TextInputSetClient(clientId: 123, configuration: singlelineConfig),
        () {},
      );
      testTextEditing.acceptCommand(const TextInputShow(), () {});
      testTextEditing.acceptCommand(
        TextInputSetEditingState(
          state: EditingState(text: 'hello world', baseOffset: 11, extentOffset: 11),
        ),
        () {},
      );
      createTextFieldSemantics(value: 'hello world', isFocused: true);

      // The element goes away while the connection is still open.
      strategy.deactivate(strategy.activeTextField!);
      expect(strategy.isEnabled, isFalse);
      expect(strategy.lastEditingState, isNotNull);

      testTextEditing.acceptCommand(const TextInputClearClient(), () {});
      expect(testTextEditing.isEditing, isFalse);
      expect(strategy.lastEditingState, isNull);
    });

Copy link
Copy Markdown
Contributor Author

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Thanks, agreed. Splitting the two lifecycles is cleaner. Added tests for the style surviving the round trip and for that hide case.

@flutter-zl
flutter-zl requested a review from mdebbar October 27, 2026 22:35

@mdebbar mdebbar left a comment

Copy link
Copy Markdown
Contributor

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

LGTM! Can you update the PR description? It still describes the isEditing check in disable().

@flutter-zl

Copy link
Copy Markdown
Contributor Author

LGTM! Can you update the PR description? It still describes the isEditing check in disable().

Great. Updated.

@flutter-zl flutter-zl added the autosubmit Merge PR when tree becomes green via auto submit App label Oct 30, 2026
@auto-submit
auto-submit Bot added this pull request to the merge queue Oct 30, 2026
Merged via the queue into flutter:master with commit a3bcce1 Oct 30, 2026
24 checks passed
@flutter-dashboard flutter-dashboard Bot removed the autosubmit Merge PR when tree becomes green via auto submit App label Oct 30, 2026
auto-submit Bot pushed a commit to flutter/packages that referenced this pull request Oct 30, 2026
flutter/flutter@c091098...5e805bf

2026-10-30 [email protected] Fix/shared thread merger expired lease (flutter/flutter#194200)
2026-10-30 [email protected] [FGP] Report the real error when applying kotlin-android fails (flutter/flutter#195080)
2026-10-30 [email protected] [Impeller] Minify GLSL shaders (flutter/flutter#195240)
2026-10-30 [email protected] Roll Packages from 0b72e92 to 3742a4e (12 revisions) (flutter/flutter#195686)
2026-10-30 [email protected] Roll Dart SDK from 52c908c00267 to fe609902984b (2 revisions) (flutter/flutter#195679)
2026-10-30 [email protected] Roll Skia from 0789f2ab93b8 to c0c46ee9f938 (1 revision) (flutter/flutter#195671)
2026-10-30 [email protected] Roll Fuchsia Linux SDK from SORPfa9tgFn83M1CR... to x2SqGbVUkOm4d6vk_... (flutter/flutter#195670)
2026-10-30 [email protected] Roll Skia from 4470b8abaad6 to 0789f2ab93b8 (2 revisions) (flutter/flutter#195665)
2026-10-30 [email protected] Roll Dart SDK from 89f7a35480d6 to 52c908c00267 (1 revision) (flutter/flutter#195659)
2026-10-30 [email protected] Roll Skia from 9f4e3117465e to 4470b8abaad6 (4 revisions) (flutter/flutter#195658)
2026-10-30 [email protected] Roll Skia from ec13cd547e98 to 9f4e3117465e (1 revision) (flutter/flutter#195650)
2026-10-30 [email protected] [web] Keep semantics editing state while the text input connection is open (flutter/flutter#195141)
2026-10-30 [email protected] [web_paragraph] Fix multi-span BiDi span order and region direction (flutter/flutter#194980)
2026-10-30 [email protected] [web] Unskip web tests that pass on CanvasKit and Skwasm: accessibility_inspector_test, color_filter_test (flutter/flutter#195541)
2026-10-30 [email protected] Roll Skia from 4ecd22a4a1d0 to ec13cd547e98 (10 revisions) (flutter/flutter#195647)

If this roll has caused a breakage, revert this CL and set the roller
to dry run mode using the controls here:
https://autoroll.skia.org/r/flutter-packages
Please CC [email protected],[email protected] on the revert to ensure that a human
is aware of the problem.

To file a bug in Packages: https://github.com/flutter/flutter/issues/new/choose

To report a problem with the AutoRoller itself, please file a bug:
https://issues.skia.org/issues/new?component=1389291&template=1850622

Documentation for the AutoRoller is here:
https://skia.googlesource.com/buildbot/+doc/main/autoroll/README.md
gmackall pushed a commit to gmackall/flutter that referenced this pull request Nov 3, 2026
… open (flutter#195141)

Fixes flutter#194636

 ## Problem
On web with semantics enabled, a focused `TextField` loses its text when
its semantics node leaves the tree and comes back, for example after
scrolling it out of a `ListView` and back. The framework keeps the
connection open, but `SemanticsTextEditingStrategy.deactivate()` called
`disable()`, which cleared connection state when the DOM `<input>` was
disposed. The re-created element came back empty and unstyled, and its
first `selectionchange` sent `""` to the framework as an edit.

## Fix
`SemanticsTextEditingStrategy` now keeps the element lifecycle separate
from the connection lifecycle. `deactivate()` only detaches the
`<input>` through a new `_detachElement()`, and `disable()` detaches and
then clears the connection state, which is the editing state, queued
style, config and callbacks. A re-created element therefore gets its
text and style back from `activate()`. `activate()` also returns early
when no connection is open, so a field that stays focused after
`TextInput.hide` is not attached with a null config.

## Demo
Before: https://flutter-demo-07-before.web.app
After: https://flutter-demo-07-after.web.app

In desktop Chrome, click the text field, which holds `hello`, and
optionally type ` world`. Scroll the list down with the mouse wheel
until the field is off-screen, then scroll back up. Before: the field is
empty and the bottom bar reads `controller.text = ""`. After: the field
still shows the text and the bottom bar matches.
Sign up for free to join this conversation on GitHub. Already have an account? Sign in to comment

Labels

a: accessibility Accessibility, e.g. VoiceOver or TalkBack. (aka a11y) a: text input Entering text in a text field or keyboard related problems CICD Run CI/CD engine flutter/engine related. See also e: labels. platform-web Web applications specifically team-web Owned by Web platform team

Projects

None yet

Development

Successfully merging this pull request may close these issues.

[web][a11y] Focused TextField's text is erased when its semantics node leaves the tree and comes back (e.g. scrolled out of view and back)

2 participants