Skip to content

[web] Improve faste delete experience#2954

Open
Schahen wants to merge 1 commit intojb-mainfrom
sh/web_fast_delete_second_iteration
Open

[web] Improve faste delete experience#2954
Schahen wants to merge 1 commit intojb-mainfrom
sh/web_fast_delete_second_iteration

Conversation

@Schahen
Copy link
Copy Markdown

@Schahen Schahen commented Apr 8, 2026

  • Fix the exceptions thrown on attempting fast delete in empty strings
  • ComposeCommandCommunicator is TextLayoutResult-agnostic

Testing

manual + ./gradlew testWeb

Release Notes

Fixes - Web

  • CMP-9971 [Web] Mobile. iOS. Crash when using 'Fast delete'. Cannot coerce value to an empty range: maximum -1 is less than minimum 0

 * Fix the exceptions thrown on attempting faste delete in empty strings
 * ComposeCommandCommunicator is TextLayoutResult-agnostic
@Schahen Schahen requested review from Copilot and eymar April 8, 2026 16:17
Copy link
Copy Markdown

Copilot AI left a comment

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Pull request overview

This PR targets the Web text input pipeline to prevent crashes during “fast delete” on empty strings, and refactors the event-processing stack so the command-sending interface is no longer coupled to TextLayoutResult.

Changes:

  • Refactors native input event processing to use a separate TextLayoutProvider (instead of adding layout access to ComposeCommandCommunicator).
  • Updates Web text input session/service wiring to pass initial TextFieldValue + IME callbacks and to provide a text layout result accessor.
  • Moves DOM element creation (ImeOptions.createDomElement) from DomInputStrategy into BackingDomInput.

Reviewed changes

Copilot reviewed 6 out of 6 changed files in this pull request and generated 6 comments.

Show a summary per file
File Description
compose/ui/ui/src/webTest/kotlin/androidx/compose/ui/platform/NativeInputEventsProcessorTest.kt Updates unit test scaffolding for the refactored NativeInputEventsProcessor API.
compose/ui/ui/src/webMain/kotlin/androidx/compose/ui/window/WebTextInputSession.kt Adapts session startup to the new startInput signature and wires a layout-result provider into the service.
compose/ui/ui/src/webMain/kotlin/androidx/compose/ui/platform/WebTextInputService.kt Implements the new startInput override and routes layout-result requests via a stored provider lambda.
compose/ui/ui/src/webMain/kotlin/androidx/compose/ui/platform/NativeInputEventsProcessor.kt Implements the fast-delete behavior changes and decouples command dispatch via a sender context + layout provider.
compose/ui/ui/src/webMain/kotlin/androidx/compose/ui/platform/DomInputStrategy.kt Makes DOM input strategy layout-aware via TextLayoutProvider and updates event processing construction.
compose/ui/ui/src/webMain/kotlin/androidx/compose/ui/platform/BackingDomInput.kt Introduces TextLayoutProvider, makes backing input layout-aware, and relocates DOM element creation.

💡 Add Copilot custom instructions for smarter, more guided reviews. Learn how to get started.

Comment on lines 64 to 73
if (currentOffset <= 0) {
return 0
}
val text = layoutInput.text
val currentWord = getWordBoundary(currentOffset.coerceIn(0, text.length - 1))
val currentWord = getWordBoundary(currentOffset.coerceAtMost(text.length - 1))
return if (currentWord.start >= currentOffset) {
getPrevWordOffset(currentOffset - 1)
} else {
OffsetMapping.Identity.transformedToOriginal(currentWord.start)
offsetMapping.transformedToOriginal(currentWord.start)
}
Copy link

Copilot AI Apr 8, 2026

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

getPrevWordOffset can still crash when the text is empty. When text.length == 0, text.length - 1 is -1, so currentOffset.coerceAtMost(-1) yields -1 and getWordBoundary(-1) is invalid. Add an explicit empty-text guard (e.g., return 0 when text.isEmpty()) before calling getWordBoundary.

Copilot uses AI. Check for mistakes.
val currentWord = getWordBoundary(currentOffset.coerceIn(0, text.length - 1))
val currentWord = getWordBoundary(currentOffset.coerceAtMost(text.length - 1))
return if (currentWord.start >= currentOffset) {
getPrevWordOffset(currentOffset - 1)
Copy link

Copilot AI Apr 8, 2026

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

getPrevWordOffset drops the provided offsetMapping on the recursive call (getPrevWordOffset(currentOffset - 1)), which makes the result inconsistent when a non-identity mapping is passed. Pass the same offsetMapping through the recursion to keep the mapping behavior correct.

Suggested change
getPrevWordOffset(currentOffset - 1)
getPrevWordOffset(currentOffset - 1, offsetMapping)

Copilot uses AI. Check for mistakes.
Comment on lines 52 to +61
suspendCancellableCoroutine<Nothing> { continuation ->
webTextInputService.startInput(
request,
onEditCommand = request.onEditCommand
value = request.value(),
imeOptions = request.imeOptions,
onEditCommand = request.onEditCommand,
onImeActionPerformed = request.onImeAction ?: {}
)

webTextInputService.currentTextLayoutResult = request.textLayoutResult

Copy link

Copilot AI Apr 8, 2026

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

currentTextLayoutResult is set only after startInput(...) returns. Since startInput focuses/registers the backing element, input events could be delivered before this property is set, making fast-delete word handling a no-op (or relying on timing). Consider setting webTextInputService.currentTextLayoutResult before calling startInput(...).

Copilot uses AI. Check for mistakes.
Comment on lines 70 to 103
@@ -79,11 +83,13 @@ internal abstract class WebTextInputService :
override fun sendEditCommand(commands: List<EditCommand>) {
onEditCommand(commands)
}

override fun currentTextLayoutResult() = request.textLayoutResult()
},
inputContainer = backingDomInputContainer,
)
) {
override fun currentTextLayoutResult(): TextLayoutResult? =
this@WebTextInputService.currentTextLayoutResult?.invoke()
}

backingDomInput?.register()

if (currentTouchOffset != null) {
@@ -96,21 +102,12 @@ internal abstract class WebTextInputService :
showSoftwareKeyboard()
}
Copy link

Copilot AI Apr 8, 2026

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

startInput receives an initial value: TextFieldValue but never applies it to the backing DOM input. This can leave the backing element briefly out of sync until updateState runs, and it also makes the value parameter effectively unused. Consider calling backingDomInput?.updateState(value) (or equivalent initialization) before focusing/showing the keyboard.

Copilot uses AI. Check for mistakes.
Comment on lines +165 to +166
setProperty("width", "calc(var(--compose-internal-web-backing-input-width) * 1px")
setProperty("height", "calc(var(--compose-internal-web-backing-input-height) * 1px")
Copy link

Copilot AI Apr 8, 2026

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

The CSS calc(...) strings for width/height are missing a closing ) (e.g., "calc(var(--compose-internal-web-backing-input-width) * 1px"). This makes the style value invalid and may prevent the backing input from being sized/positioned correctly. Add the missing closing parenthesis to both properties.

Suggested change
setProperty("width", "calc(var(--compose-internal-web-backing-input-width) * 1px")
setProperty("height", "calc(var(--compose-internal-web-backing-input-height) * 1px")
setProperty("width", "calc(var(--compose-internal-web-backing-input-width) * 1px)")
setProperty("height", "calc(var(--compose-internal-web-backing-input-height) * 1px)")

Copilot uses AI. Check for mistakes.
}

override fun currentTextLayoutResult(): TextLayoutResult? {
TODO("currentTextLayoutResult is tested in integration tests")
Copy link

Copilot AI Apr 8, 2026

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Using TODO(...) for currentTextLayoutResult() in the unit-test processor can cause unrelated tests to crash if any new/updated test triggers the deleteWordBackward path. Returning null (or a minimal stub TextLayoutResult) would keep the tests resilient while still deferring full coverage to integration tests.

Suggested change
TODO("currentTextLayoutResult is tested in integration tests")
return null

Copilot uses AI. Check for mistakes.
Schahen added a commit that referenced this pull request Apr 9, 2026
…2961)

This is minimal version of the
[PR-2954](#2954)

## Testing
manual and `./gradlew testWeb`

## Release Notes
### Fixes - Web
- [CMP-9971](https://youtrack.jetbrains.com/issue/CMP-9971) [Web]
Mobile. iOS. Crash when using 'Fast delete'. Cannot coerce value to an
empty range: maximum -1 is less than minimum 0
Schahen added a commit that referenced this pull request Apr 9, 2026
…2961)

This is minimal version of the
[PR-2954](#2954)

## Testing
manual and `./gradlew testWeb`

## Release Notes
### Fixes - Web
- [CMP-9971](https://youtrack.jetbrains.com/issue/CMP-9971) [Web]
Mobile. iOS. Crash when using 'Fast delete'. Cannot coerce value to an
empty range: maximum -1 is less than minimum 0
Sign up for free to join this conversation on GitHub. Already have an account? Sign in to comment

Labels

None yet

Projects

None yet

Development

Successfully merging this pull request may close these issues.

2 participants