Repository navigation
[web] Keep semantics editing state while the text input connection is open - #195141
auto-submit[bot] merged 5 commits into
Conversation
There was a problem hiding this comment.
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.
mdebbar
left a comment
There was a problem hiding this comment.
I think the underlying issue is that SemanticsTextEditingStrategy.disable() is currently used for two different lifecycles:
- The
TextInputconnection lifecycle (enable()/disable(), driven byHybridTextEditing._startEditing()/stopEditing()), which ownsinputConfig,onChange,onAction,lastEditingState, and_queuedStyle. - The DOM element lifecycle (
activate()/deactivate(), driven bySemanticTextField), which attaches and detacheseditableElement(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); | ||
| }); |
There was a problem hiding this comment.
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);
});There was a problem hiding this comment.
Thanks, agreed. Splitting the two lifecycles is cleaner. Added tests for the style surviving the round trip and for that hide case.
mdebbar
left a comment
There was a problem hiding this comment.
LGTM! Can you update the PR description? It still describes the isEditing check in disable().
Great. Updated. |
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
… 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.
Fixes #194636
Problem
On web with semantics enabled, a focused
TextFieldloses its text when its semantics node leaves the tree and comes back, for example after scrolling it out of aListViewand back. The framework keeps the connection open, butSemanticsTextEditingStrategy.deactivate()calleddisable(), which cleared connection state when the DOM<input>was disposed. The re-created element came back empty and unstyled, and its firstselectionchangesent""to the framework as an edit.Fix
SemanticsTextEditingStrategynow keeps the element lifecycle separate from the connection lifecycle.deactivate()only detaches the<input>through a new_detachElement(), anddisable()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 fromactivate().activate()also returns early when no connection is open, so a field that stays focused afterTextInput.hideis 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 typeworld. 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 readscontroller.text = "". After: the field still shows the text and the bottom bar matches.