Repository navigation
Description
Steps to reproduce
- Build the app below for the web and open it in Chrome. It has a
TextFieldwhose input formatter rewrites text, here by uppercasing it. - Start composing text that the formatter changes. For example, with the macOS Chinese Pinyin input source, type
n: the preedit is a Latin letter, which the formatter uppercases. - After the formatter has rewritten the composition, type more text without composing.
Verified in headless Chrome 154 with the DevTools Protocol:
Input.imeSetComposition({ text: 'a', selectionStart: 1, selectionEnd: 1 });- then
Input.insertText('b'); - then
Input.insertText('c').
Not yet confirmed by hand with a physical input method.
Expected results
When the formatter rewrites the composed a to A, the browser abandons the composition. From then on, the field's value has no composing range: after b and c it is ABC with composing: TextRange.empty.
Actual results
The browser abandons the composition but sends no compositionend; only compositionstart and compositionupdate "a" are dispatched. From then on, every value the engine sends carries the old composing range:
VALUE TextEditingValue(text: ┤A├, selection: ...(offset: 1), composing: TextRange(start: 0, end: 1))
VALUE TextEditingValue(text: ┤AB├, selection: ...(offset: 2), composing: TextRange(start: 0, end: 1))
VALUE TextEditingValue(text: ┤AB├, selection: ...(offset: 2), composing: TextRange(start: -1, end: -1))
VALUE TextEditingValue(text: ┤AB├, selection: ...(offset: 2), composing: TextRange(start: 0, end: 1))
VALUE TextEditingValue(text: ┤ABC├, selection: ...(offset: 3), composing: TextRange(start: 0, end: 1))
VALUE TextEditingValue(text: ┤ABC├, selection: ...(offset: 3), composing: TextRange(start: -1, end: -1))
VALUE TextEditingValue(text: ┤ABC├, selection: ...(offset: 3), composing: TextRange(start: 0, end: 1))
The text after it is plain typing, yet the field keeps marking A as composing, and the framework and the engine trade the range back and forth on every edit. This lasts until the next compositionstart. Code that treats composing text specially (input formatters, maxLength, custom TextInputClients) then sees text as composing that is not. This may be the cause of #138136: a digits-only formatter with a Chinese input method, where existing digits get deleted.
Code sample
import 'package:flutter/material.dart';
import 'package:flutter/services.dart';
void main() => runApp(const MaterialApp(home: Scaffold(body: Repro())));
class Repro extends StatefulWidget {
const Repro({super.key});
@override
State<Repro> createState() => _ReproState();
}
class _ReproState extends State<Repro> {
final controller = TextEditingController();
@override
void initState() {
super.initState();
controller.addListener(() => debugPrint('VALUE ${controller.value}'));
}
@override
void dispose() {
controller.dispose();
super.dispose();
}
@override
Widget build(BuildContext context) => TextField(
controller: controller,
autofocus: true,
// Any formatter that changes the text while it is composed.
inputFormatters: [
TextInputFormatter.withFunction(
(_, value) => value.copyWith(text: value.text.toUpperCase()),
),
],
);
}What seems to happen
CompositionAwareMixin (lib/web_ui/lib/src/engine/text_editing/composition_aware_mixin.dart) clears composingText and composingBase only in its compositionstart and compositionend handlers. Here is what happens instead:
- When the framework sends an editing state that changes the composed text, the engine writes it into the input element.
- Chrome then drops the composition without dispatching
compositionend. composingTextstays'a'andcomposingBasestays0, sodetermineCompositionState()keeps addingcomposingBaseOffset: 0, composingExtentOffset: 1to every laterEditingState.
The browser itself reports that no composition is in progress. The input events after the dropped composition have isComposing: false, while the engine still adds the range:
DOM compositionstart ""
DOM compositionupdate "a"
DOM input "a" isComposing=true value="a"
DOM input "b" isComposing=false value="Ab" <- engine still reports composing 0..1
DOM input "c" isComposing=false value="ABc" <- engine still reports composing 0..1
A small fix: in DefaultTextEditingStrategy.handleChange, an input event whose isComposing is false clears composingText and composingBase before determineCompositionState runs. Clearing them in setEditingState, when it writes a changed value during a composition, would also cover the moment of the rewrite.
Related: #183078 has the same symptom, a stale composing range, after a window blur during a Korean composition.
Flutter Doctor output
Flutter 3.44.4 (stable tag) • framework ad70ec4617 (2026-06-24) • engine a10d8ac38d • Dart 3.12.2
macOS 26.2 25C56 darwin-arm64 • Google Chrome 154.0.8037.93
Reproduced with the default web build (CanvasKit, JS). Not yet checked on the current stable or master.
Activity
mbcorona commentedon Oct 6, 2026
Hi @danReynolds, thanks for the CDP verified repro and the precise root cause.
Confirmed on current master: composingText and composingBase are cleared only in the compositionstart and compositionend handlers, so when the browser drops a composition without dispatching compositionend, determineCompositionState keeps applying the stale composing range to every later edit:
flutter/engine/src/flutter/lib/web_ui/lib/src/engine/text_editing/composition_aware_mixin.dart
Lines 67 to 89 in 19946f9
Related to #183078 (same stale-range symptom from a different trigger). Routing to team-text-input for further investigation.
flutter-zl commentedon Oct 13, 2026
This is fixed by #194096. The formatter’s rewrite changes the DOM text during composition, so the patch clears the engine’s cached composition before later edits are reported.
[web] Drop stale composing range after text is replaced mid-compositi…
github-actions commentedon Oct 28, 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.[web] Drop stale composing range after text is replaced mid-compositi…