Skip to content

[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) #194636

Closed

Description

@aerotech-vadb

Steps to reproduce

  1. flutter create repro --platforms web and replace lib/main.dart with the code sample below.
  2. flutter run -d chrome (or flutter build web and serve build/web). Semantics is turned on by SemanticsBinding.instance.ensureSemantics(). Clicking the "Enable accessibility" placeholder or running a screen reader does the same; both were tried, with the same result.
  3. Click the text field (it holds hello) and type world.
  4. Scroll the list down with the mouse wheel until the text field is off-screen, then scroll back up.

Expected results

The field still shows hello world, and controller.text (printed at the bottom) is hello world. With semantics off, that is what happens.

Actual results

The field is empty and controller.text is "". The framework received an editing-state update with empty text, as if the user had deleted everything. The field is still focused, and the next keystroke continues from the empty value.

Any change that removes a focused text field's semantics node for one semantics update and brings it back in a later one does the same. Two other triggers we hit:

  • A layout change on resize. In our app, a multi-pane layout switches to a single pane, and the editing pane's semantics subtree is detached for one frame.
  • ExcludeSemantics toggled on for one frame.

The field's State, controller and focus all survive in every case. Only the engine's DOM <input> is re-created.

Code sample

// Flutter web, semantics enabled: a focused TextField loses its text when its
// semantics node leaves the tree and comes back. Here: the field is scrolled
// out of view (a focused EditableText keeps itself alive off-screen) and back.
//
//   flutter run -d chrome
//
// 1. Click the field (it holds "hello"), optionally type.
// 2. Scroll the list down with the mouse wheel until the field is off-screen.
// 3. Scroll back up: the field is empty, and so is controller.text below.
import 'package:flutter/material.dart';
import 'package:flutter/semantics.dart';

void main() {
  runApp(const MaterialApp(home: Repro()));
  // What a screen reader, or clicking "Enable accessibility", does.
  SemanticsBinding.instance.ensureSemantics();
}

class Repro extends StatefulWidget {
  const Repro({super.key});

  @override
  State<Repro> createState() => _ReproState();
}

class _ReproState extends State<Repro> {
  final _controller = TextEditingController(text: 'hello');

  @override
  Widget build(BuildContext context) {
    return Scaffold(
      body: ListView(
        children: [
          Padding(
            padding: const EdgeInsets.all(16),
            child: TextField(controller: _controller),
          ),
          for (var i = 0; i < 100; i++) ListTile(title: Text('Row $i')),
        ],
      ),
      bottomNavigationBar: ValueListenableBuilder(
        valueListenable: _controller,
        builder: (context, value, _) => Padding(
          padding: const EdgeInsets.all(16),
          child: Text('controller.text = "${value.text}"'),
        ),
      ),
    );
  }
}

What happens in the engine

Line references are to 3.47.7 stable. That is framework abaf9c5237, whose web_ui sources are identical to engine revision deb287481e (empty git diff). Paths are under engine/src/flutter/lib/web_ui/lib/src/engine/.

What is proven comes from three sources:

  • the source, read at the lines given;
  • a DOM trace: focusin, focusout and selectionchange listeners on document in the capture phase, a MutationObserver on <body>, and an element-identity check;
  • the controller's own listener, logging each change.

What is inferred is marked as such.

  1. While scrolled away, the field's EditableText is kept alive because it has focus (wantKeepAlive => widget.focusNode.hasFocus, packages/flutter/lib/src/widgets/editable_text.dart:2638). Its semantics node is no longer part of the semantics tree.
    • Trace: the field's flt-semantics host is removed from the DOM.
    • Trace: the controller is unchanged while away.
    • The framework keeps focus and keeps the text-input connection open. Inferred from the next steps: on return the engine sees hasFocus and activates the new field, and HybridTextEditing.isEditing is still true.
  2. Removal update. The detached node is permanently removed in _finalizeTree, which calls removal.dispose() (semantics/semantics.dart#L3187).
    • SemanticsObject.dispose() calls safeRemoveSync(element) (semantics.dart:2716), which moves DOM focus to the view root. Trace: focusout on the <input>, then focusin on FLUTTER-VIEW.
    • Both the <input>'s blur listener (semantics/text_field.dart#L306-L311) and SemanticTextField.dispose() (text_field.dart:401-405) call SemanticsTextEditingStrategy.deactivate(this).
    • deactivate leads to disable() (text_field.dart:100-121), which sets lastEditingState = null (text_field.dart:116) and drops domElement.
    • The framework is not told anything: no onConnectionClosed, and isEditing stays true.
  3. Re-add update. The node comes back under the same id. The engine has already disposed its SemanticsObject, so it builds a new one, and _updateRole creates a new SemanticTextField (semantics.dart:2379).
    • Its constructor creates a new, empty <input> (text_field.dart:233-235, 253-256). Nothing ever copies semanticsObject.value into it (#194548).
    • Trace: the <input> after the round trip is a different element from the one before, and its value is "".
    • update() sees semanticsObject.hasFocus and calls activate(this) (text_field.dart:323-330). That calls DefaultTextEditingStrategy.enable() (text_editing/text_editing.dart#L1759-L1782).
    • In enable(), if (lastEditingState != null) setEditingState(...) (text_editing.dart:1770) is skipped, because step 2 nulled it. The framework does not re-send TextInput.setEditingState, because from its side the connection never closed.
    • moveFocusToActiveDomElement() then focuses the empty element. Trace: focusin on the new <input> with value="".
  4. Chrome fires selectionchange for the newly focused input (trace: selectionchange on the new <input>, value="").
    • The strategy's document selectionchange subscription (text_field.dart:137) runs handleChange() (text_editing.dart:1617-1642).
    • handleChange() builds EditingState(text: '', base: 0, extent: 0) from the DOM. That differs from lastEditingState (null), so it calls onChange.
    • onChange sends channel.updateEditingState (text_editing.dart:2623) to TextInputClient.updateEditingState (packages/flutter/lib/src/services/text_input.dart:2260), then to EditableText.updateEditingValue (editable_text.dart:3653).
    • Result: the controller's text becomes "" (the controller log shows the "" entry).

Proof that step 4 is what delivers the empty value: with a capturing selectionchange listener on window that stops propagation, the controller kept its text through the round trip. But the first input event afterwards (value set to 4 on the now-empty <input>, plus an input event) made the controller "4". Suppressing the event only postpones the loss. The root cause is steps 2–3: an empty element, and no lastEditingState to restore, while the framework believes the connection is intact.

The same sequence was also checked against master 84a33147ed (3.50.0-1.0.pre-115) by reading the source:

  • #192963 added a blur guard (if (owner.phase != idle && hasFocus) return;, text_field.dart:418 on master).
  • SemanticTextField.dispose() still calls deactivate (:529), and disable() still nulls lastEditingState (:163).
  • enable() still restores only a non-null state (text_editing.dart:1993), and the new element is still empty (:336).

The minimal repro reproduces on that master build too.

Variants tried

Chrome 154 on Linux. Release builds unless noted. "Lost" means the controller received "".

Variant Result
Code sample above, 3.47.7 stable, trusted click + trusted typing (CDP), wheel scroll away and back lost
Code sample above, master 84a33147ed lost
Focused field with ExcludeSemantics(excluding: true) for exactly one frame (placeholder-enabled semantics) lost
Same, triggered by a viewport width change (1200 → 380 via device-metrics override) lost
Same, focused but never typed into (initial controller text) lost
Same, flutter build web --profile lost
Same, field edited and then unfocused before the one-frame exclusion kept: the <input> is still re-created empty, but no edit is sent
Same, semantics off kept
ListView scroll away and back, semantics off kept
Lone focused TextField, width change 500 → 380 kept: same semantics node, same <input>
Field moved between Row and Column under a GlobalKey on a width change kept: same semantics node
Field with an OverlayPortal popup shown beside it, width change 1200 → 380 kept

Not tried: debug (DDC) build, --wasm, Firefox, Safari, Windows, macOS, a real screen reader (NVDA, VoiceOver). The wheel events in the scroll variant were dispatched from JS on flt-glass-pane; the click and typing were trusted CDP input.

Possible directions

Suggestions only, not tested.

  • Keep the editing state across a deactivate while the framework connection is still open. When HybridTextEditing.isEditing is true, re-activation of a new element for the same client would re-apply the last state, as enable() already does when lastEditingState is non-null.
  • Seed a new semantics <input> with semanticsObject.value and its text selection (the #194548 fix). This alone keeps the text. It may still send a selection change if the seeded selection differs from the framework's.
  • Don't report the DOM state as a user edit when nothing changed it. In handleChange, with no lastEditingState, the first state read is the element's initial state, not an edit.

Flutter doctor

[✓] Flutter (Channel stable, 3.47.7, on Manjaro Linux 7.2.3-2-MANJARO, locale en_US.UTF-8) [40ms]
    • Flutter version 3.47.7 on channel stable at /home/vadbal/.flutter-sdk/flutter
    • Upstream repository https://github.com/flutter/flutter.git
    • Framework revision abaf9c5237 (9 days ago), 2026-10-08 09:51:21 -0700
    • Engine revision deb287481e
    • Dart version 3.13.5
    • DevTools version 2.60.0
    • Feature flags: enable-web, enable-linux-desktop, enable-macos-desktop, enable-windows-desktop, enable-android, enable-ios, cli-animations, enable-native-assets, enable-record-use, enable-swift-package-manager, omit-legacy-version-file, enable-lldb-debugging, enable-uiscene-migration

[!] Android toolchain - develop for Android devices (Android SDK version 36.0.0) [2.5s]
    • Android SDK at /home/vadbal/.android/sdk
    • Emulator version unknown
    • Platform android-36, build-tools 36.0.0
    • ANDROID_HOME = /home/vadbal/.android/sdk
    ! Multiple adb binaries found. This can cause conflicts and device detection issues:
        - /home/vadbal/.android/sdk/platform-tools/adb
        - /usr/bin/adb
    • Java binary at: /usr/bin/java
      This JDK was found in the system PATH.
      To manually set the JDK path, use: `flutter config --jdk-dir="path/to/jdk"`.
    • Java version OpenJDK Runtime Environment (build 17.0.20.1+1)
    ! Some Android licenses not accepted. To resolve this, run: flutter doctor --android-licenses

[✓] Chrome - develop for the web [8ms]
    • CHROME_EXECUTABLE = /usr/bin/google-chrome-stable

[✓] Linux toolchain - develop for Linux desktop [447ms]
    • clang version 22.1.8
    • cmake version 4.4.3
    • ninja version 1.13.2
    • pkg-config version 3.0.7
    • OpenGL core renderer: Mesa Intel(R) UHD Graphics 630 (CFL GT2)
    • OpenGL core version: 4.6 (Core Profile) Mesa 26.2.2-arch1.1
    • OpenGL core shading language version: 4.60
    • OpenGL ES renderer: Mesa Intel(R) UHD Graphics 630 (CFL GT2)
    • OpenGL ES version: OpenGL ES 3.2 Mesa 26.2.2-arch1.1
    • OpenGL ES shading language version: OpenGL ES GLSL ES 3.20
    • GL_EXT_framebuffer_blit: yes
    • GL_EXT_texture_format_BGRA8888: yes

[✓] Connected device (2 available) [216ms]
    • Linux (desktop) • linux  • linux-x64      • Manjaro Linux 7.2.3-2-MANJARO
    • Chrome (web)    • chrome • web-javascript • Google Chrome 154.0.8037.97

[✓] Network resources [592ms]
    • All expected network resources are available.

! Doctor found issues in 1 category.
  • Engine: hash d8a2d6c768c334d53e16277f837970ffa1e2de81, revision deb287481e. Also reproduced on master 84a33147ed (engine hash 9167aea3dfb738a43023437bc037f5f1a905753a).
  • Chrome: 154.0.8037.97, Linux x86_64.

Activity

  1. added
    a: text inputEntering text in a text field or keyboard related problems
    a: accessibilityAccessibility, e.g. VoiceOver or TalkBack. (aka a11y)
    platform-webWeb applications specifically
    has reproducible stepsThe issue has been confirmed reproducible and is ready to work on
    fyi-webFor the attention of Web platform team
    team-accessibilityOwned by Framework Accessibility team (i.e. responsible for accessibility code in flutter/flutter)
    on Oct 19, 2026
  2. mbcorona commented on Oct 19, 2026

    @mbcorona
    Member

    Hi @aerotech-vadb, thanks for the thorough trace. Confirmed on master: disable() nulls lastEditingState (text_field.dart:163) and enable() only restores it when it is non null (text_editing.dart:1993), so the re-created empty input is never corrected, which matches your steps 2 and 3.

    This is in the same web semantics text field area as #194548 and #194186, but it is a distinct data loss rather than just an empty value exposed to AT, so keeping it separate. Routing to team-accessibility.

  3. self-assigned this
    on Oct 21, 2026
  4. added
    P1High-priority issues at the top of the work list
    on Oct 21, 2026
  5. flutter-zl commented on Oct 21, 2026

    @flutter-zl
    Contributor
    • Demo: https://flutter-demo-07-before.web.app. The reporter's sample with a label and app bar added. Semantics is on at startup, no placeholder click needed.
      • Steps: 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.
      • Bug: the field is empty and the bottom bar reads controller.text = "". The field is still focused, so the next keystroke continues from the empty value. Fixed: the field still shows hello world and the bottom bar matches.
      • Control: the same steps with semantics off keep the text. A MutationObserver on <body> shows the <input> after the round trip is a new element with value="", matching the issue's trace.
  6. added a commit that references this issue on Oct 30, 2026
    a3bcce1
  7. added a commit that references this issue on Nov 3, 2026
    20d519c
  8. github-actions commented on Nov 13, 2026

    @github-actions

    This thread has been automatically locked since there has not been any recent activity after it was closed. If you are still experiencing a similar issue, please open a new bug, including the output of flutter doctor -v and a minimal reproduction of the issue.

  9. locked as resolved and limited conversation to collaborators on Nov 13, 2026
Sign up for free to join this conversation on GitHub. Already have an account? Sign in to comment

Metadata

Metadata

Assignees

Labels

P1High-priority issues at the top of the work lista: accessibilityAccessibility, e.g. VoiceOver or TalkBack. (aka a11y)a: text inputEntering text in a text field or keyboard related problemshas reproducible stepsThe issue has been confirmed reproducible and is ready to work onplatform-webWeb applications specificallyteam-accessibilityOwned by Framework Accessibility team (i.e. responsible for accessibility code in flutter/flutter)

Type

No type

Projects

No projects

    Milestone

    No milestone

    Relationships

    None yet

    Development

    No branches or pull requests

    Issue actions