Skip to content

Report backward deletions that remove nothing - #194300

Draft
pdenert wants to merge 1 commit into
flutter:mainfrom
pdenert:detect-backspace-notification
Draft

pdenert wants to merge 1 commit into
flutter:mainfrom
pdenert:detect-backspace-notification

Conversation

@pdenert

@pdenert pdenert commented Oct 13, 2026

Copy link
Copy Markdown

Fixes a gap described in #191015: a backspace that deletes nothing is invisible to app code.

Such a press changes no text, so it produces no editing state update and cannot be observed through onChanged, a TextEditingController listener, or a TextInputFormatter. It is the press a PIN or one-time-code field needs in order to move focus to the previous box.

The signal exists on both platforms and is discarded

iOS. There was no iOS data in #191015, so I measured it. Against a bare UIView<UITextInput> replicating FlutterTextInputView — deliberately not a UITextField subclass — on a physical iPhone 16 running iOS 26.5 with the stock keyboard:

PROBE deleteBackward  hasText=false  len=0     <- 3 Backspace taps on an empty field, 3 dispatches
PROBE --- PROBE store="ab" caret@0 (hasText=true) ---
PROBE deleteBackward  hasText=true  len=2      <- caret at offset 0 in non-empty text

Reproduced on an iPhone 17 simulator (iOS 26.0). So UIKit dispatches -deleteBackward in both cases; the engine receives the call and drops it, because the gates leave _selectedTextRange empty, replaceRange: is never reached, and that call is the only thing that notifies the framework.

The harness is at https://github.com/pdenert/android_keyboard_check/tree/main/tool/ios_delete_backward_probe if you want to reproduce it.

Worth noting: the real condition is "nothing was deleted", not "the field was empty". A non-empty field with the caret at offset 0 is equally silent, and an API scoped to empty fields would miss it.

Android. The framework already reaches _DeleteTextAction for a backspace on an empty field; nothing surfaced it to app code.

What this adds

EditableText.onDeleteBackward, surfaced on TextField and CupertinoTextField, reporting the text that was removed and the empty string when nothing was.

Two legs, which never both fire for one press:

  • iOS — a new one-way TextInputClient.didDeleteBackward, sent from the end of -deleteBackward after all existing logic has run.
  • Everywhere else — from _DeleteTextAction. A bare backspace on iOS never reaches it, because _iOSDisablingTextShortcuts maps it to DoNothingAndStopPropagationTextIntent.

The engine keeps performing the deletion on iOS, including the Thai partial-cluster handling and the emoji workaround. The framework only observes, so the asynchronous framework/plugin boundary is unchanged — this does not move deletion into the framework, and so does not depend on making that communication synchronous.

The notification is sent after the deletion is applied, not before. The platform-to-framework leg is inline on the merged platform/UI thread, so a handler invoked from an earlier send would run inside -deleteBackward and could write to a half-updated editor.

Honest limits, documented on the callback

  • Does not fire while an input method has an active composing region, which covers most CJK input — the input method edits that region itself rather than reporting a deletion.
  • More generally on Android, fires only when the input method reports the press, which not every input method or version does.
  • May fire without a key press, for example from an assistive technology.
  • Does not fire on the web.
  • Does not fire on readOnly fields.

A call means a backward deletion happened; the absence of a call does not mean none did.

Breaking change

Adds a member to TextInputClient. It has an empty default body, so with TextInputClient users are unaffected, but it is source-breaking for anyone declaring implements TextInputClient or implements DeltaTextInputClient.

Tests

packages/flutter/test/widgets/editable_text_delete_backward_test.dart, 7 tests covering the empty field, the caret at offset 0, the removed-text payload, forward delete, readOnly, the embedder notification, and the iOS no-double-fire invariant. editable_text_test.dart (530) and default_text_editing_shortcuts_test.dart pass unchanged.

Open questions

  1. Is the shape right? onDeleteBackward follows the onAppPrivateCommand / onContentInserted precedent rather than the TextEditingController.deleteKeyEvents I originally proposed — @justinmc was right to doubt that, since a controller can outlive its field, exist with none, or be shared, and ValueNotifier cannot notify when the value is unchanged.
  2. Is the TextInputClient addition acceptable, or would you prefer reusing an existing transport?
  3. I have not built the engine locally. The ObjC is written against the real sources but is unverified by compilation, and FlutterTextInputPluginTest.mm coverage is not included for the same reason. Happy to add it with guidance on running those tests.

Pre-launch checklist

  • I read the Contributor Guide and followed the process outlined there for submitting PRs.
  • I listed at least one issue that this PR fixes in the description above.
  • I added new tests to check the change I am making.
  • I updated/added relevant documentation (doc comments with ///).
  • All existing and new tests are passing.

A backspace that deletes nothing changes no text, so it produces no editing
state update and cannot be observed through onChanged, a TextEditingController
listener, or a TextInputFormatter. That is the press a PIN or one-time-code
field needs in order to move focus to the previous box, and today there is no
way to see it. See flutter#191015.

The signal exists on both platforms and is discarded.

On iOS, UIKit dispatches -deleteBackward even when the view reports no text.
Measured on an iPhone 16 running iOS 26.5, against a bare UIView<UITextInput>
replicating FlutterTextInputView: three Backspace taps on an empty field
produced three dispatches, and a press with the caret at offset 0 in non-empty
text produced one more. The engine receives those calls and drops them, because
the gates in -deleteBackward leave _selectedTextRange empty, so replaceRange:
is never reached and nothing is sent to the framework.

Add EditableText.onDeleteBackward, surfaced on TextField and CupertinoTextField,
reporting the text that was removed and the empty string when nothing was. It
is fed by two legs that never both fire for one press: on iOS a new one-way
TextInputClient.didDeleteBackward message, sent from the end of -deleteBackward
after the existing logic has run; everywhere else from _DeleteTextAction, which
the framework already reaches on an empty field. A bare backspace on iOS never
reaches that action, because _iOSDisablingTextShortcuts maps it to
DoNothingAndStopPropagationTextIntent.

The engine keeps performing the deletion on iOS, including the Thai partial
cluster handling and the emoji workaround. The framework only observes, so the
asynchronous framework/plugin boundary is unchanged.

The notification is sent after the deletion is applied rather than before. The
platform to framework leg is inline on the merged platform/UI thread, so a
handler invoked from an earlier send would run inside -deleteBackward and could
write to a half-updated editor.
@github-actions

Copy link
Copy Markdown

This pull request targets the main branch, but the primary branch for this repository is master.

Please change the base branch of this pull request to master.

See: https://docs.github.com/en/pull-requests/collaborating-with-pull-requests/proposing-changes-to-your-work-with-pull-requests/changing-the-base-branch-of-a-pull-request

@github-actions github-actions Bot added a: text input Entering text in a text field or keyboard related problems platform-ios iOS applications specifically framework flutter/packages/flutter repository. See also f: labels. engine flutter/engine related. See also e: labels. p: material_ui material_ui package in flutter/packages team-ios Owned by iOS platform team f: cupertino labels Oct 13, 2026
Sign up for free to join this conversation on GitHub. Already have an account? Sign in to comment

Labels

a: text input Entering text in a text field or keyboard related problems engine flutter/engine related. See also e: labels. f: cupertino framework flutter/packages/flutter repository. See also f: labels. p: material_ui material_ui package in flutter/packages platform-ios iOS applications specifically team-ios Owned by iOS platform team

Projects

None yet

Development

Successfully merging this pull request may close these issues.

1 participant