Conversation
* Fix the exceptions thrown on attempting faste delete in empty strings * ComposeCommandCommunicator is TextLayoutResult-agnostic
There was a problem hiding this comment.
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 toComposeCommandCommunicator). - 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) fromDomInputStrategyintoBackingDomInput.
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.
| 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) | ||
| } |
There was a problem hiding this comment.
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.
| 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) |
There was a problem hiding this comment.
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.
| getPrevWordOffset(currentOffset - 1) | |
| getPrevWordOffset(currentOffset - 1, offsetMapping) |
| 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 | ||
|
|
There was a problem hiding this comment.
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(...).
| @@ -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() | |||
| } | |||
There was a problem hiding this comment.
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.
| setProperty("width", "calc(var(--compose-internal-web-backing-input-width) * 1px") | ||
| setProperty("height", "calc(var(--compose-internal-web-backing-input-height) * 1px") |
There was a problem hiding this comment.
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.
| 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)") |
| } | ||
|
|
||
| override fun currentTextLayoutResult(): TextLayoutResult? { | ||
| TODO("currentTextLayoutResult is tested in integration tests") |
There was a problem hiding this comment.
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.
| TODO("currentTextLayoutResult is tested in integration tests") | |
| return null |
…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
…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
Testing
manual +
./gradlew testWebRelease Notes
Fixes - Web