From 9e3798d7fa72c64a6867169051c1023859395597 Mon Sep 17 00:00:00 2001 From: Shagen Ogandzhanian Date: Mon, 8 Jun 2026 19:48:13 +0200 Subject: [PATCH 1/7] [web] in certain cases deleteContentBackward behaves as deleteWordBackward --- .../ui/platform/NativeInputEventsProcessor.kt | 58 ++++++++++--------- 1 file changed, 31 insertions(+), 27 deletions(-) diff --git a/compose/ui/ui/src/webMain/kotlin/androidx/compose/ui/platform/NativeInputEventsProcessor.kt b/compose/ui/ui/src/webMain/kotlin/androidx/compose/ui/platform/NativeInputEventsProcessor.kt index 052a708e74af7..247f5949a3aa0 100644 --- a/compose/ui/ui/src/webMain/kotlin/androidx/compose/ui/platform/NativeInputEventsProcessor.kt +++ b/compose/ui/ui/src/webMain/kotlin/androidx/compose/ui/platform/NativeInputEventsProcessor.kt @@ -22,6 +22,7 @@ import androidx.compose.ui.text.TextLayoutResult import androidx.compose.ui.text.input.BackspaceCommand import androidx.compose.ui.text.input.CommitTextCommand import androidx.compose.ui.text.input.DeleteSurroundingTextCommand +import androidx.compose.ui.text.input.EditCommand import androidx.compose.ui.text.input.OffsetMapping import androidx.compose.ui.text.input.SetComposingTextCommand import androidx.compose.ui.text.input.SetSelectionCommand @@ -156,6 +157,21 @@ internal abstract class NativeInputEventsProcessor( collectedEvents.clear() } + private fun InputEventExt.createDeleteWordCommand(): EditCommand? { + val shouldTriggerDelete = when { + lastProcessedKeydown?.isBackspace() != true -> false + lastProcessedKeydown?.repeat == true -> true + else -> false + } + + return if (shouldTriggerDelete) { + val layoutResult = composeSender.currentTextLayoutResult() ?: return null + + val offset = layoutResult.getPrevWordOffset(textRangeEnd) + DeleteSurroundingTextCommand((textRangeEnd - offset).coerceAtLeast(0), 0) + } else null + } + private fun InputEventExt.process(currentTextFieldValue: TextFieldValue) { val editCommands = when (inputType) { "deleteContentBackward" -> buildList { @@ -168,41 +184,29 @@ internal abstract class NativeInputEventsProcessor( // When Compose TextField has text selection, a good UX for deleteContentBackward would be to emulate Backspace. add(BackspaceCommand()) } - } else { // Empty selection case. - // This happens when an autocorrection is applied on mobile: - // The system first tells us to delete the old text, - // and then it would send the "insertText" event. - if (textRangeSize > 0) { - // deleteContentBackward can happen under very non-trivial circumstances: - // - for instance, when an input suggestion on Android Chrome is accepted, - // the browser then deletes space after the word just to add space again; - // - or when a browser performs Fast Delete; - add(SetSelectionCommand(textRangeStart, textRangeEnd)) - add(BackspaceCommand()) - } else if (textRangeSize == 0 && lastProcessedKeydown?.isBackspace() != true) { - // We skip this branch if the lastProcessedKeydown is Backspace, because Compose must have already processed this. - // Otherwise, under specific circumstance previous symbol can be deleted while inputting the new one - // see https://youtrack.jetbrains.com/issue/CMP-8773 + } else { + // We skip this branch if the lastProcessedKeydown is Backspace, because Compose must have already processed this. + // Otherwise, under specific circumstance previous symbol can be deleted while inputting the new one + // see https://youtrack.jetbrains.com/issue/CMP-8773 + if (lastProcessedKeydown?.isBackspace() != true) { + // This happens when an autocorrection is applied on mobile: + // The system first tells us to delete the old text, + // and then it would send the "insertText" event. + if (textRangeSize > 0) { + add(SetSelectionCommand(textRangeStart, textRangeEnd)) + } + add(BackspaceCommand()) + } else { + createDeleteWordCommand()?.let { add(it) } } } } "deleteWordBackward" -> buildList { - if (lastProcessedKeydown?.isBackspace() != true) return@buildList - - // This would mean event was triggered by long press on mobile device (iOS) - if (lastProcessedKeydown?.repeat == true) { - val layoutResult = composeSender.currentTextLayoutResult() ?: return@buildList - - - val offset = layoutResult.getPrevWordOffset(textRangeEnd) - val deleteCommand = DeleteSurroundingTextCommand((textRangeEnd - offset).coerceAtLeast(0), 0) - add(deleteCommand) - } + createDeleteWordCommand()?.let { add(it) } } - "insertReplacementText" -> buildList { if (data == null) return@buildList if (textRangeSize > 0) { From 919e3284a5420a0683eb0e652e9e048f0f2d9273 Mon Sep 17 00:00:00 2001 From: Shagen Ogandzhanian Date: Mon, 8 Jun 2026 19:51:55 +0200 Subject: [PATCH 2/7] Add relevant comment --- .../androidx/compose/ui/platform/NativeInputEventsProcessor.kt | 2 ++ 1 file changed, 2 insertions(+) diff --git a/compose/ui/ui/src/webMain/kotlin/androidx/compose/ui/platform/NativeInputEventsProcessor.kt b/compose/ui/ui/src/webMain/kotlin/androidx/compose/ui/platform/NativeInputEventsProcessor.kt index 247f5949a3aa0..df2a2e27fb714 100644 --- a/compose/ui/ui/src/webMain/kotlin/androidx/compose/ui/platform/NativeInputEventsProcessor.kt +++ b/compose/ui/ui/src/webMain/kotlin/androidx/compose/ui/platform/NativeInputEventsProcessor.kt @@ -198,6 +198,8 @@ internal abstract class NativeInputEventsProcessor( add(BackspaceCommand()) } else { + // certain keyboard layout trigger deleteContentBackward on fast delete + // https://youtrack.jetbrains.com/issue/CMP-10086 createDeleteWordCommand()?.let { add(it) } } } From 828397800e631d438f12e0c99d8a4f4e065f954d Mon Sep 17 00:00:00 2001 From: Shagen Ogandzhanian Date: Mon, 8 Jun 2026 20:10:11 +0200 Subject: [PATCH 3/7] Simplify InputEventExt.createDeleteWordCommand() expression - switch from when to early returns --- .../ui/platform/NativeInputEventsProcessor.kt | 16 +++++----------- 1 file changed, 5 insertions(+), 11 deletions(-) diff --git a/compose/ui/ui/src/webMain/kotlin/androidx/compose/ui/platform/NativeInputEventsProcessor.kt b/compose/ui/ui/src/webMain/kotlin/androidx/compose/ui/platform/NativeInputEventsProcessor.kt index df2a2e27fb714..9718e585d6314 100644 --- a/compose/ui/ui/src/webMain/kotlin/androidx/compose/ui/platform/NativeInputEventsProcessor.kt +++ b/compose/ui/ui/src/webMain/kotlin/androidx/compose/ui/platform/NativeInputEventsProcessor.kt @@ -158,18 +158,12 @@ internal abstract class NativeInputEventsProcessor( } private fun InputEventExt.createDeleteWordCommand(): EditCommand? { - val shouldTriggerDelete = when { - lastProcessedKeydown?.isBackspace() != true -> false - lastProcessedKeydown?.repeat == true -> true - else -> false - } - - return if (shouldTriggerDelete) { - val layoutResult = composeSender.currentTextLayoutResult() ?: return null + val keydown = lastProcessedKeydown ?: return null + if (!keydown.isBackspace() || !keydown.repeat) return null + val layoutResult = composeSender.currentTextLayoutResult() ?: return null - val offset = layoutResult.getPrevWordOffset(textRangeEnd) - DeleteSurroundingTextCommand((textRangeEnd - offset).coerceAtLeast(0), 0) - } else null + val offset = layoutResult.getPrevWordOffset(textRangeEnd) + return DeleteSurroundingTextCommand((textRangeEnd - offset).coerceAtLeast(0), 0) } private fun InputEventExt.process(currentTextFieldValue: TextFieldValue) { From d9634e952259f375465ce9ddea6edf1481413bf0 Mon Sep 17 00:00:00 2001 From: Shagen Ogandzhanian Date: Mon, 8 Jun 2026 20:18:57 +0200 Subject: [PATCH 4/7] Add dedicated test case --- .../ui/input/DeleteWordBackwardTests.kt | 31 +++++++++++++++++-- 1 file changed, 29 insertions(+), 2 deletions(-) diff --git a/compose/ui/ui/src/webTest/kotlin/androidx/compose/ui/input/DeleteWordBackwardTests.kt b/compose/ui/ui/src/webTest/kotlin/androidx/compose/ui/input/DeleteWordBackwardTests.kt index b27fe83516cfa..635b9e63f014f 100644 --- a/compose/ui/ui/src/webTest/kotlin/androidx/compose/ui/input/DeleteWordBackwardTests.kt +++ b/compose/ui/ui/src/webTest/kotlin/androidx/compose/ui/input/DeleteWordBackwardTests.kt @@ -27,7 +27,7 @@ import kotlin.test.Test class DeleteWordBackwardTests : TextFieldTestSpec, BasicTextFieldWithValue { - fun sendPhysicalDeleteWordBackward() { + private fun sendPhysicalDeleteWordBackward() { sendToHtmlInput( keyEvent( key = "Backspace", @@ -39,7 +39,7 @@ class DeleteWordBackwardTests : TextFieldTestSpec, BasicTextFieldWithValue { ) } - fun sendVirtualDeleteWordBackward() { + private fun sendVirtualDeleteWordBackward() { sendToHtmlInput( keyEvent( key = "Backspace", @@ -51,6 +51,18 @@ class DeleteWordBackwardTests : TextFieldTestSpec, BasicTextFieldWithValue { ) } + private fun sendVirtualFastDeleteAsContentBackward() { + sendToHtmlInput( + keyEvent( + key = "Backspace", + code = "Backspace", + type = "keydown", + repeat = true, + ), + beforeInput("deleteContentBackward", null) + ) + } + @Test fun deletePrevWordVirtualMiddle() = runApplicationTest { @@ -191,4 +203,19 @@ class DeleteWordBackwardTests : TextFieldTestSpec, BasicTextFieldWithValue { textFieldValue.awaitAndAssertTextEquals("천천히 ") } + @Test + fun deletePrevWordVirtualMiddle_viaDeleteContentBackward_CMP_10086() = runApplicationTest { + val textFieldValue = createApplicationWithHolder( + "here we go again!!!", + initialSelection = TextRange(14, 14) + ) + awaitAnimationFrame() + + sendVirtualFastDeleteAsContentBackward() + textFieldValue.awaitAndAssertTextEquals( + "here go again!!!", + "deleteContentBackward with repeating Backspace should behave as deleteWordBackward (CMP-10086)" + ) + } + } \ No newline at end of file From 9f69567b51234e0d8d248d5721e95d7e502ef83b Mon Sep 17 00:00:00 2001 From: Shagen Ogandzhanian Date: Tue, 9 Jun 2026 08:38:07 +0200 Subject: [PATCH 5/7] Add dedicated test case --- .../ui/platform/NativeInputEventsProcessorTest.kt | 11 +++-------- 1 file changed, 3 insertions(+), 8 deletions(-) diff --git a/compose/ui/ui/src/webTest/kotlin/androidx/compose/ui/platform/NativeInputEventsProcessorTest.kt b/compose/ui/ui/src/webTest/kotlin/androidx/compose/ui/platform/NativeInputEventsProcessorTest.kt index a2f67bb8a73dd..9b8edba0feee8 100644 --- a/compose/ui/ui/src/webTest/kotlin/androidx/compose/ui/platform/NativeInputEventsProcessorTest.kt +++ b/compose/ui/ui/src/webTest/kotlin/androidx/compose/ui/platform/NativeInputEventsProcessorTest.kt @@ -701,15 +701,10 @@ class NativeInputEventsProcessorTest { processor.manuallyRunCheckpoint(communicator.currentTextFieldValue()) + // The deleteContentBackward event should be ignored since Backspace key was pressed: + // Compose already processed the Backspace via the key event, so no edit commands should be produced. assertEquals(1, communicator.keyboardEvents.size) - assertEquals(2, communicator.editCommands.size) - - val selectionCommand = communicator.editCommands[0] - assertTrue(selectionCommand is SetSelectionCommand) - assertEquals(8, selectionCommand.start) - assertEquals(12, selectionCommand.end) - - assertEquals("example ", communicator.currentTextFieldValue().text) + assertEquals(0, communicator.editCommands.size) } @Test From e1b58255cadbf749d3ad247ba75c28919ee805e7 Mon Sep 17 00:00:00 2001 From: Shagen Ogandzhanian Date: Tue, 9 Jun 2026 09:23:03 +0200 Subject: [PATCH 6/7] Revert "Add dedicated test case" This reverts commit 9f69567b51234e0d8d248d5721e95d7e502ef83b. --- .../ui/platform/NativeInputEventsProcessorTest.kt | 11 ++++++++--- 1 file changed, 8 insertions(+), 3 deletions(-) diff --git a/compose/ui/ui/src/webTest/kotlin/androidx/compose/ui/platform/NativeInputEventsProcessorTest.kt b/compose/ui/ui/src/webTest/kotlin/androidx/compose/ui/platform/NativeInputEventsProcessorTest.kt index 9b8edba0feee8..a2f67bb8a73dd 100644 --- a/compose/ui/ui/src/webTest/kotlin/androidx/compose/ui/platform/NativeInputEventsProcessorTest.kt +++ b/compose/ui/ui/src/webTest/kotlin/androidx/compose/ui/platform/NativeInputEventsProcessorTest.kt @@ -701,10 +701,15 @@ class NativeInputEventsProcessorTest { processor.manuallyRunCheckpoint(communicator.currentTextFieldValue()) - // The deleteContentBackward event should be ignored since Backspace key was pressed: - // Compose already processed the Backspace via the key event, so no edit commands should be produced. assertEquals(1, communicator.keyboardEvents.size) - assertEquals(0, communicator.editCommands.size) + assertEquals(2, communicator.editCommands.size) + + val selectionCommand = communicator.editCommands[0] + assertTrue(selectionCommand is SetSelectionCommand) + assertEquals(8, selectionCommand.start) + assertEquals(12, selectionCommand.end) + + assertEquals("example ", communicator.currentTextFieldValue().text) } @Test From 678eb8706746bce136dfd61eef5c2228b810b7cb Mon Sep 17 00:00:00 2001 From: Shagen Ogandzhanian Date: Tue, 9 Jun 2026 09:24:55 +0200 Subject: [PATCH 7/7] Revert the "bold" new logic of "deleteContentBackward"- it never harms to be more conservative --- .../ui/platform/NativeInputEventsProcessor.kt | 27 ++++++++++--------- 1 file changed, 14 insertions(+), 13 deletions(-) diff --git a/compose/ui/ui/src/webMain/kotlin/androidx/compose/ui/platform/NativeInputEventsProcessor.kt b/compose/ui/ui/src/webMain/kotlin/androidx/compose/ui/platform/NativeInputEventsProcessor.kt index 9718e585d6314..e07ef7421a111 100644 --- a/compose/ui/ui/src/webMain/kotlin/androidx/compose/ui/platform/NativeInputEventsProcessor.kt +++ b/compose/ui/ui/src/webMain/kotlin/androidx/compose/ui/platform/NativeInputEventsProcessor.kt @@ -179,21 +179,22 @@ internal abstract class NativeInputEventsProcessor( add(BackspaceCommand()) } } else { - // We skip this branch if the lastProcessedKeydown is Backspace, because Compose must have already processed this. - // Otherwise, under specific circumstance previous symbol can be deleted while inputting the new one - // see https://youtrack.jetbrains.com/issue/CMP-8773 - if (lastProcessedKeydown?.isBackspace() != true) { - // This happens when an autocorrection is applied on mobile: - // The system first tells us to delete the old text, - // and then it would send the "insertText" event. - if (textRangeSize > 0) { - add(SetSelectionCommand(textRangeStart, textRangeEnd)) - } - + // This happens when an autocorrection is applied on mobile: + // The system first tells us to delete the old text, + // and then it would send the "insertText" event. + if (textRangeSize > 0) { + // deleteContentBackward can happen under very non-trivial circumstances: + // - for instance, when an input suggestion on Android Chrome is accepted, + // the browser then deletes space after the word just to add space again; + // - or when a browser performs Fast Delete; + add(SetSelectionCommand(textRangeStart, textRangeEnd)) + add(BackspaceCommand()) + } else if (textRangeSize == 0 && lastProcessedKeydown?.isBackspace() != true) { + // We skip this branch if the lastProcessedKeydown is Backspace, because Compose must have already processed this. + // Otherwise, under specific circumstance previous symbol can be deleted while inputting the new one + // see https://youtrack.jetbrains.com/issue/CMP-8773 add(BackspaceCommand()) } else { - // certain keyboard layout trigger deleteContentBackward on fast delete - // https://youtrack.jetbrains.com/issue/CMP-10086 createDeleteWordCommand()?.let { add(it) } } }