Repository navigation
Description
Steps to reproduce
flutter create repro --platforms weband replacelib/main.dartwith the code sample below.flutter run -d chrome(orflutter build weband servebuild/web). Semantics is turned on bySemanticsBinding.instance.ensureSemantics(). Clicking the "Enable accessibility" placeholder or running a screen reader does the same; both were tried, with the same result.- Click the text field (it holds
hello) and typeworld. - 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.
ExcludeSemanticstoggled 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,focusoutandselectionchangelisteners ondocumentin the capture phase, aMutationObserveron<body>, and an element-identity check; - the controller's own listener, logging each change.
What is inferred is marked as such.
- While scrolled away, the field's
EditableTextis 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-semanticshost 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
hasFocusand activates the new field, andHybridTextEditing.isEditingis still true.
- Trace: the field's
- Removal update. The detached node is permanently removed in
_finalizeTree, which callsremoval.dispose()(semantics/semantics.dart#L3187).SemanticsObject.dispose()callssafeRemoveSync(element)(semantics.dart:2716), which moves DOM focus to the view root. Trace:focusouton the<input>, thenfocusinonFLUTTER-VIEW.- Both the
<input>'sblurlistener (semantics/text_field.dart#L306-L311) andSemanticTextField.dispose()(text_field.dart:401-405) callSemanticsTextEditingStrategy.deactivate(this). deactivateleads todisable()(text_field.dart:100-121), which setslastEditingState = null(text_field.dart:116) and dropsdomElement.- The framework is not told anything: no
onConnectionClosed, andisEditingstays true.
- 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_updateRolecreates a newSemanticTextField(semantics.dart:2379).- Its constructor creates a new, empty
<input>(text_field.dart:233-235,253-256). Nothing ever copiessemanticsObject.valueinto it (#194548). - Trace: the
<input>after the round trip is a different element from the one before, and itsvalueis"". update()seessemanticsObject.hasFocusand callsactivate(this)(text_field.dart:323-330). That callsDefaultTextEditingStrategy.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-sendTextInput.setEditingState, because from its side the connection never closed. moveFocusToActiveDomElement()then focuses the empty element. Trace:focusinon the new<input>withvalue="".
- Its constructor creates a new, empty
- Chrome fires
selectionchangefor the newly focused input (trace:selectionchangeon the new<input>,value="").- The strategy's
documentselectionchangesubscription (text_field.dart:137) runshandleChange()(text_editing.dart:1617-1642). handleChange()buildsEditingState(text: '', base: 0, extent: 0)from the DOM. That differs fromlastEditingState(null), so it callsonChange.onChangesendschannel.updateEditingState(text_editing.dart:2623) toTextInputClient.updateEditingState(packages/flutter/lib/src/services/text_input.dart:2260), then toEditableText.updateEditingValue(editable_text.dart:3653).- Result: the controller's text becomes
""(the controller log shows the""entry).
- The strategy's
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
blurguard (if (owner.phase != idle && hasFocus) return;,text_field.dart:418on master). SemanticTextField.dispose()still callsdeactivate(:529), anddisable()still nullslastEditingState(: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.isEditingis true, re-activation of a new element for the same client would re-apply the last state, asenable()already does whenlastEditingStateis non-null. - Seed a new semantics
<input>withsemanticsObject.valueand 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 nolastEditingState, 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, revisiondeb287481e. Also reproduced on master84a33147ed(engine hash9167aea3dfb738a43023437bc037f5f1a905753a). - Chrome: 154.0.8037.97, Linux x86_64.
Activity
mbcorona commentedon Oct 19, 2026
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.
flutter-zl commentedon Oct 21, 2026
hello, and optionally typeworld. Scroll the list down with the mouse wheel until the field is off-screen, then scroll back up.controller.text = "". The field is still focused, so the next keystroke continues from the empty value. Fixed: the field still showshello worldand the bottom bar matches.MutationObserveron<body>shows the<input>after the round trip is a new element withvalue="", matching the issue's trace.[web] Keep semantics editing state while the text input connection is…
[web] Keep semantics editing state while the text input connection is…
github-actions commentedon Nov 13, 2026
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 -vand a minimal reproduction of the issue.