Visitar URL original
fix(ime): Raise composition events after their text on browser soft keyboards by mikernet · Pull Request #26434 · unoplatform/uno · GitHub
Skip to content

fix(ime): Raise composition events after their text on browser soft keyboards - #26434

Open
mikernet wants to merge 9 commits into
unoplatform:masterfrom
mikernet:dev/mikernet/ime-composition-order
Open

mikernet wants to merge 9 commits into
unoplatform:masterfrom
mikernet:dev/mikernet/ime-composition-order

Conversation

@mikernet

@mikernet mikernet commented Nov 13, 2026 •

Copy link
Copy Markdown
Contributor

GitHub Issue: closes #26432

PR Type:

🐞 Bugfix

What changed? 🚀

This builds on #25804's IME design (the hidden input is the source of truth for composition text, and the TextBox follows it with textAlreadyApplied) and fixes what is left of the bugs #24502 fixed under its earlier design. It supersedes #24502, which I'll close. #25804's own body lists the first one as a known follow-up.

Each fix is its own commit, with its tests:

1. TextCompositionChanged is raised once its text is in Text (TextBox and RichEditBox, WebAssembly and Skia Android)
The platform applies the composition itself and reports it before the text sync that follows it (the browser's input event, Android's EndBatchEdit). The event was raised on that report, so its range pointed into text that wasn't there yet: a handler reading Text.Substring(e.StartIndex, e.Length) threw, and one moving focus sent the rest of the keystroke to the next TextBox. The event now waits for the sync, after TextChanging, as for text the TextBox applies itself. RichEditBox holds its Composition and ConversionTargetChanged automation events with it. An update whose text the control refused, and so isn't in Text, isn't raised. This changes the order of the events on every platform-applied update, so on Skia Android too, not only on WebAssembly.
On WebAssembly, a TextCompositionStarted or TextCompositionEnded handler that moves focus no longer gets the browser's text synced into the TextBox that took focus.
The ranges of TextCompositionChanged and of a composition ending without a commit (cancel, coercion, external change) are clamped to the text. A commit's TextCompositionEnded keeps the committed text's own range, since Skia Android raises it before syncing that text (the point morning4coffe-dev made on #24502).
Skia Android now passes where its composing region starts, as the browser does, so a composition opened on an existing word is placed there.
Tests: When_IME_Platform_Applied_Composition_Changed_After_Its_Text (TextBox) and When_Platform_Applied_Composition_Changed_After_Its_Text (RichEditBox), which replay Android's order with a fake extension on every Skia target; on WebAssembly When_Browser_Ime_Changed_Handler_Reads_Composition, When_Browser_Ime_Composition_Repeats_Existing_Character, When_Browser_Ime_Composition_Extends_Existing_Text, When_Browser_Ime_Caret_Moved_Inside_Preedit_Repeating_Text, When_Browser_Ime_Composition_Preedit_Emptied, When_Browser_Ime_Composition_Coerced_Shorter_Than_Its_Start, When_Browser_Ime_Changed_Handler_Moves_Focus, When_Browser_Ime_Started_Handler_Moves_Focus, When_Browser_Ime_Ended_Handler_Moves_Focus (each with a single-line and a multi-line target), two WebKit commit orders this already fixes (When_Browser_Ime_WebKit_Commit_Equal_To_Preedit_Ends_At_Once, When_Browser_Ime_WebKit_Preedit_Removed_Before_Cancel) and Given_RichEditBox.When_Wasm_Composition_Reopened_On_Existing_Text_Changed_Sees_The_Preedit.

2. A composition ends when the hidden input is replaced or removed (WebAssembly)
The browser's compositionend for a replaced or removed input is ignored on purpose, so when the same TextBox was re-entered with another focus state (a tap after keyboard focus) or accessibility detached the input while the TextBox kept focus, IsComposing stayed true and TextCompositionEnded never came. focus() and detachCore() now end it.
Tests: When_Browser_Ime_Same_TextBox_Refocused_During_Composition, When_Browser_Ime_Input_Detached_During_Composition.

3. A composition edit the control refuses, or keeps at another length, is written back at once (TextBox and RichEditBox, WebAssembly and Skia Android)
#25804 reconciles a control's edit made during a composition when the composition ends. Until then the refused text stayed in the hidden input, and the keyboard kept composing on it: typing a, an x a BeforeTextChanging handler rejects, then b left Text at a. Gboard composes every Latin word, so with MaxLength the letters typed past the limit stayed in the input for the whole word: the keyboard built its suggestions on them, and Backspace deleted them before it reached the TextBox's text.
Now, when the control keeps text of another length than the input's (MaxLength, a TextChanging length limit, a RichEditBox cutting the edit to its text limit), or refuses a change of the input's text and keeps its own previous text (BeforeTextChanging, read-only), its text goes back to the input at once and the composition ends, for the control too, with the caret pushed back to the input. The keyboard then builds its next keystrokes and suggestions on the text the control kept. Kana typed past a limit is cut at the limit rather than converted later (MaxLength="2": にほん leaves にほ). A change that keeps the length, such as an upper-case TextChanging filter on a TextBox or CharacterCasing on a RichEditBox, keeps #25804's reconciliation at the composition's end, so an IME can still convert it; the input reports its previous value with each edit, so the same text reported again while such a filter keeps the two texts apart (a caret move inside the preedit) isn't taken for a refusal.
On Skia Android, EndBatchEdit left such an edit in the native editable, since a refusal raises no text change to write back; the editable now takes the host's text at once, as one change, and loses its composing region, which ends the composition there too. A refused sync there also left the host's caret where the IME put it in the refused text, so the next key landed one place late; it now stays where the edit started.
This takes three commits: the first writes back a BeforeTextChanging or read-only refusal and keeps #25804's reconciliation for everything else; the second extends the write-back to MaxLength and length-changing edits, with RichEditBox, on WebAssembly; the third does the same on Skia Android.
Tests: When_Browser_Ime_Composition_Rejected_By_BeforeTextChanging, When_Browser_Ime_Same_Length_Replacement_Is_Refused, When_Browser_Ime_Composes_Word_Past_MaxLength_Then_Input_Keeps_The_Limit (MaxLength="3", "abcd" composed: the input holds "abc" right after the update, and one Backspace deletes the "c"), When_Browser_Ime_Kana_Composes_Past_MaxLength, When_Browser_Ime_Kana_Composes_Past_TextChanging_Limit, When_Browser_Ime_Filtered_Composition_Reported_Again_Stays_Open, Given_RichEditBox.When_Wasm_Composition_Past_MaxLength_Is_Written_Back, When_Wasm_Composition_Past_MaxLength_In_The_Middle_Keeps_The_Caret and When_Wasm_Composition_With_Upper_Casing_Stays_Open; on every Skia target When_IME_Platform_Sync_Refused_Then_Caret_Stays, and When_Native_Text_Is_Compared_To_The_Kept_Text, a table of the rule's cases (line breaks, casing, length, same-length refusal); When_Browser_Ime_Composes_Past_MaxLength and When_Browser_Ime_Composition_Coerced_Shorter_Than_Its_Start now check the input right after the update; #25804's When_Browser_Ime_Composes_With_TextChanging_Filter still passes unchanged.
I checked the MaxLength write-back by hand with Gboard in Chrome on an Android phone, on a 6.8 build.

4. The hidden input is read-only for a read-only TextBox (WebAssembly)
It stayed editable, so a mobile browser opened the soft keyboard and let the user type into it until each edit was reverted; WinUI doesn't show the touch keyboard for a read-only TextBox. The input now gets readOnly and follows IsReadOnly. A read-only RichEditBox already suppressed the soft keyboard (inputmode="none"); it gets readOnly too, for consistency.
Mobile browsers show or hide the keyboard when an element takes focus, not when readOnly flips on the focused one. So focus moving between a read-only and an editable TextBox now focuses a new input instead of reusing the focused one, and IsReadOnly changing on the focused TextBox enters a new input as focus does (except during a composition, which keeps its input).
A read-only input still takes focus, a caret and a selection, and still gets key events and copy; the browser only refuses edits to it. I checked that in Chrome 157 with trusted CDP input on a plain read-only input: focus, ArrowLeft and Shift+ArrowLeft move the caret and the selection, keydown/keyup and copy fire, and typing fires beforeinput but leaves the value unchanged. The runtime test uses synthetic key events, so it shows that the TextBox still handles the keys and the input follows its selection, not native copy.
#25804's When_Browser_Input_Edits_Read_Only_TextBox said the input stays editable under a read-only TextBox; it now checks the input is read-only, and still that an edit reaching it anyway is refused.
Re-applying the properties when IsReadOnly changes also re-applies the keyboard type, autocorrect and accessory view on Apple UIKit, which is harmless.
Tests: When_Browser_Read_Only_TextBox_Keeps_Keyboard_And_Selection, When_Browser_Focus_Moves_From_Read_Only_TextBox, When_Browser_Read_Only_Toggled_On_Focused_TextBox, When_Browser_Input_Edits_Read_Only_TextBox (updated), Given_RichEditBox.When_Wasm_Read_Only_Hidden_Input_Follows_IsReadOnly, and When_Browser_Ime_TextBox_Made_Read_Only_During_Composition (the composition still ends; that part already worked on master).

5. The caret is restored at once after a refused input (WebAssembly)
After the TextBox refused a plain edit, the caret went back to where the edit started a dispatcher tick later, so a key typed in between landed where the refused edit left it ("abc" with the caret after "a": a refused "x" then "y" gave "abyc"). It is now restored while the refused edit is applied, and the restore a tick later is skipped on this path.
Test: When_Browser_Input_Refused_Then_Next_Key_Follows_At_Once.

6. More soft keyboard sequences as regression guards (test only)
#24502's soft keyboard tests that already pass on master and have no equivalent there, adapted to master's helpers: When_Browser_Soft_Keyboard_Backspace_Has_No_Code_In_PasswordBox, When_Browser_Ime_Caret_Moved_During_Composition, When_Browser_Ime_Composition_Ends_On_Replaced_Input, When_Browser_Ime_Multiline_Line_Break_During_Composition, When_Browser_Multiline_Selection_Follows_Native, When_Browser_Ime_Text_Set_By_TextCompositionEnded_Handler, When_Browser_Ime_Text_Cleared_During_Composition.

Not in this PR: the rest of #24502's WebKit/iOS commit ordering, which I'm proposing separately in #26435 (stacked on this one).

Validation

  • Runtime, Skia WebAssembly (Chrome 157, the browser's event sequences replayed on the hidden input): Given_TextBox, Given_RichEditBox, Given_PasswordBox, Given_BrowserKeyboardInputSource, Given_AccessibleTextBox and Given_ImeSessionCoordinator*: 1391 of 1422 pass, 30 inconclusive. The one failure, When_Touch_LongPress_Drag_Release_Does_Not_Open_Flyout_iOS, fails the same way without these changes.
  • Runtime, Skia desktop (Windows): Given_TextBox, Given_RichEditBox, Given_PasswordBox and Given_ImeSessionCoordinator*: 1420 of 1422 pass, one inconclusive; the one failure is the same iOS touch test.
  • Compile: Uno.UI.Runtime.Android (net10.0-android).
  • Tests fail before the fix: on master's product code, every new or changed fix test here fails (on WebAssembly, and the three shared-code tests on desktop too), except these: the single-line rows of When_Browser_Ime_Started_Handler_Moves_Focus/When_Browser_Ime_Ended_Handler_Moves_Focus, where the reused input is emptied for the newly focused TextBox before the stale sync, so only the multi-line rows (a new textarea) show the bug; and When_Browser_Ime_Filtered_Composition_Reported_Again_Stays_Open and When_Wasm_Composition_With_Upper_Casing_Stays_Open, which pass on master too, as they check that fix 3 keeps a filtered composition open as master does. When_Native_Text_Is_Compared_To_The_Kept_Text calls the new rule directly, so it doesn't compile on master; the mutations below cover its rows. The 7 regression guards pass on master, and so does When_Browser_Ime_TextBox_Made_Read_Only_During_Composition without its readOnly checks. On the first commit of fix 3 alone, When_Browser_Ime_Composes_Word_Past_MaxLength_Then_Input_Keeps_The_Limit and both When_Browser_Ime_Kana_Composes_* fail; with every difference written back, When_Browser_Ime_Composes_With_TextChanging_Filter fails, and with only refusals written back, When_Browser_Ime_Composition_Coerced_Shorter_Than_Its_Start fails; without the RichEditBox change, When_Wasm_Composition_Past_MaxLength_Is_Written_Back fails, with every RichEditBox difference written back When_Wasm_Composition_With_Upper_Casing_Stays_Open fails, and without the caret push after its write-back When_Wasm_Composition_Past_MaxLength_In_The_Middle_Keeps_The_Caret fails; with the input's previous value ignored, When_Browser_Ime_Filtered_Composition_Reported_Again_Stays_Open fails, and with only length changes written back, When_Browser_Ime_Same_Length_Replacement_Is_Refused fails, each with the matching rows of When_Native_Text_Is_Compared_To_The_Kept_Text; without the caret restore, When_IME_Platform_Sync_Refused_Then_Caret_Stays fails; with the read-only check removed from the input reuse, or the re-entry on an IsReadOnly change, When_Browser_Focus_Moves_From_Read_Only_TextBox and When_Browser_Read_Only_Toggled_On_Focused_TextBox fail.

PR Checklist ✅

On WebAssembly and on Skia Android the platform applies the composition
text itself and reports it before syncing it to the TextBox (the input
event that follows compositionupdate, Android's EndBatchEdit). The
TextBox and the RichEditBox raised TextCompositionChanged on that
report, while Text still held the previous text: a handler reading
Text.Substring(e.StartIndex, e.Length) threw, and one that moved focus
made the rest of the update land in the next TextBox.

Hold the event back until the platform's text sync has landed, after
TextChanging, as for text the TextBox applies itself. RichEditBox holds
its Composition and ConversionTargetChanged automation events with it.
An update whose text the control refused, and so isn't in Text, is not
raised. This changes the order of the events on every platform-applied
update, on Android too.

A TextCompositionStarted or TextCompositionEnded handler can also move
focus before the browser's text is synced; the browser host no longer
syncs the old input's value into the TextBox that took focus.

The composition's range can also outlast its text, after a coercion or
a deletion, so TextCompositionChanged, and TextCompositionEnded for a
composition that ends without a commit, now get the part of the range
that is in the text. A commit's TextCompositionEnded keeps the committed
text's own range: Skia Android raises it before syncing that text. On
WebAssembly, a composition whose committed text the TextBox changed (a
filter, MaxLength) ends without a commit, with its range in the text.

Skia Android now reports where its composing region starts, as the
browser already does, so a composition the keyboard opens on an
existing word is placed there rather than at the caret.
A composition open on the hidden input never ended when that input was
replaced (the same TextBox re-entered with another focus state, as a
tap does after keyboard focus) or removed while the TextBox kept focus
(accessibility handing it over to its semantic element). The browser's
compositionend for the old input is ignored on purpose, so the TextBox
stayed composing and never raised TextCompositionEnded.

End it when the input is replaced or removed.
While a composition edits the hidden input, the browser owns its value,
so a TextBox text that differs from it is written back when the
composition ends. For an edit a BeforeTextChanging handler cancelled,
that left the refused character in the input while the keyboard went
on composing: the next keystrokes were built on it and refused too, so
typing "a", a rejected "x", then "b" left Text at "a".

Write the TextBox's text back at once when the TextBox rejects an edit
without its text changing at all (a BeforeTextChanging cancel, or a
read-only TextBox), which ends the browser's composition, and end it
for the TextBox too. Anything else is still reconciled when the
composition ends, so the keyboard keeps its composition: an edit a
TextChanging handler changes, even back to the previous text (a length
limit), and a refusal past MaxLength. A conversion can then bring the
composition back within the limit (にほん to 日本 with MaxLength 2).
The hidden input under a read-only TextBox stayed editable, so a mobile
browser opened the soft keyboard for it and let the user type until the
TextBox reverted each edit. WinUI doesn't show the touch keyboard for a
read-only TextBox.

Set readOnly on the hidden input when the TextBox or RichEditBox is
read-only, and update it when IsReadOnly changes. (A read-only
RichEditBox already suppressed the soft keyboard; it gets readOnly for
consistency.) A read-only input still takes focus, a caret and a
selection, and still gets key events and copy: the browser only
refuses edits to it. Keyboard navigation, selection and Ctrl+C keep
working, since the TextBox handles the keys and the browser copies the
input's selection.

Mobile browsers show or hide the soft keyboard when an element takes
focus, not when readOnly flips on the focused one. So focus moving
between a read-only and an editable TextBox focuses a new input instead
of reusing the focused one, and IsReadOnly changing on the focused
TextBox enters a new input as focus does, except during a composition.
When the TextBox refuses an edit the browser applied to the hidden
input (MaxLength, BeforeTextChanging, read-only), the caret went back to
where the edit started only a dispatcher tick later. Until then the
TextBox and the input both had it where the refused edit left it, so a
key typed in between landed there: typing "x" (refused) then "y" in the
middle of "abc" gave "abyc" instead of "aybc".

Put the caret back while the refused edit is being applied, and push it
to the input even when it didn't change, since writing the text back
moved the input's caret. The restore a tick later is skipped on this
path: it would now move the caret of a key typed in between.
Soft keyboard sequences from an earlier round of browser IME work that
master already handles, kept as regression guards: a code-less
Backspace in a PasswordBox, a caret moved during a composition, a
composition ending on an input that was replaced, a line break typed
during a composition, a caret move in a textarea, and text set or
cleared from code during a composition.
@azure-pipelines

Copy link
Copy Markdown
Azure Pipelines:
1 pipeline(s) were filtered out due to trigger conditions.
There may be pipelines that require an authorized user to comment /azp run to run.

@github-actions github-actions Bot added platform/wasm 🌐 Categorizes an issue or PR as relevant to the WebAssembly platform platform/android 🤖 Categorizes an issue or PR as relevant to the Android platform area/skia ✏️ Categorizes an issue or PR as relevant to Skia labels Nov 13, 2026
@mergify

mergify Bot commented Nov 13, 2026

Copy link
Copy Markdown
Contributor

Tick the box to add this pull request to the merge queue (same as @mergifyio queue). If it does not match the queue conditions yet, it enters the queue once it does.

  • Queue this pull request

mikernet added a commit to Singulink/uno that referenced this pull request Nov 14, 2026
Port of the TextBox part of master's e86befc: the selectionchange
handler only followed an <input>, so a caret move the keyboard made in
a multi-line TextBox's <textarea> never reached the TextBox.
When_Browser_Multiline_Selection_Follows_Native (unoplatform#26434, next commit)
covers it.
mikernet added a commit to Singulink/uno that referenced this pull request Nov 14, 2026
Port of unoplatform#26434 (for unoplatform#26432), commits
faba3ed, 096daef, 8e2644f, b8d2189, edce6fa and
6939ef4, onto the unoplatform#25804 IME design this branch takes
from servicing/6.8 (unoplatform#25984).

- TextCompositionChanged for text the platform applied (WebAssembly,
  Skia Android) waits for the platform's text sync, and isn't raised
  for an update the TextBox refused. The Changed and no-commit Ended
  ranges are clamped to the text. Skia Android reports where its
  composing region starts.
- A composition ends when the hidden input is replaced or detached.
- An edit a BeforeTextChanging handler or a read-only TextBox refuses
  is written back to the input at once, ending the composition.
  Unlike master, this branch does the same for an edit MaxLength
  refuses or a TextChanging handler changes (see below).
- The hidden input is read-only with the TextBox.
- The caret goes back at once after a refused edit.

6.8 differences:
- Master's TextBoxCore.IME.cs, .Input.cs and .cs changes go to
  TextBox.IME.skia.cs, TextBox.skia.cs and TextBox.cs; events are raised
  on the TextBox itself. There is no RichEditBox IME, partial commit or
  CompositionCanceled on 6.8, so those parts are left out.
- Skia Android syncs through a new TextBox.UpdateTextFromNative, the
  6.8 counterpart of master's IImeSessionHost.UpdateTextFromNative. It
  raises the held TextCompositionChanged, and compares the editable's
  \n line breaks as the TextBox's \r, as master's
  IsNativeEchoOfCurrentText does.
- The read-only input follows TextBox.IsReadOnly through
  OnIsReadonlyChangedPartial; UpdateProperties, which only sets the
  enter key hint on 6.8, now also sets readOnly or re-enters the input.

With this branch's port of unoplatform#26005 (be85699):
- A refused platform sync puts the composition range back on the text
  that is there (unoplatform#26005), and raises TextCompositionChanged
  for that range only when it isn't the last range raised. The refused
  update's own range is never raised (unoplatform#26434).
- While a composition holds text the TextBox refused past MaxLength
  (reconciled when it ends), the hidden input's selectionchange isn't
  reported: its offsets are in that text, not in the TextBox's. Without
  this, the input's caret replaced the one edce6fa restores at once.
- ApplyNativeInput keeps unoplatform#26005's handling of a refused
  edit's pending selection.

Like Android's own LengthFilter (and this branch before the catch-up),
a composition edit the TextBox doesn't keep as the keyboard applied it
(MaxLength, a TextChanging filter, as well as BeforeTextChanging and
read-only) writes the TextBox's text back at once and ends the
composition, on WebAssembly and on Skia Android. Master keeps a
MaxLength or TextChanging refusal in the input until the composition
ends, so a CJK conversion can bring it back within the limit. Gboard
composes every Latin word, though, so the refused letters stayed in the
input for the whole word: its suggestions were built on them, and
Backspace deleted them before it reached the TextBox's text. A
conversion past the limit is lost here, as with Android's LengthFilter.
When_Browser_Ime_Composes_With_TextChanging_Filter (unoplatform#25804)
and the two conversion tests of unoplatform#26434 now expect that,
and When_Browser_Ime_Composes_Word_Past_MaxLength_Then_Input_Keeps_The_Limit
covers the Gboard case.

Adds tests for a refused Skia Android sync, \n line breaks in its sync,
and a refused composition followed by another before the dispatcher
runs.
mikernet added a commit to Singulink/uno that referenced this pull request Nov 14, 2026
Port of unoplatform#26435 (stacked on unoplatform#26434), commit
6d7709a.

WebKit browsers commit a composition through input events of their own:
deleteCompositionText, insertFromComposition, then compositionend. The
removal of the preedit is now reported together with the insertion that
follows it, and a preedit put back unchanged isn't reported again. For
the orders where compositionend comes before the input event that
applies the commit, the composition ends with that event, after the text
change and TextCompositionChanged, or after the task when none comes.

6.8 differences: the browser's composition cancel is OnCompositionEnded
here (master: OnCompositionCanceled), so
When_Browser_Ime_Selected_Composition_Cancelled_Without_Input_Event
expects the composition to end with its range, as every composition
that ends without a commit does on 6.8.
mikernet added a commit to Singulink/uno that referenced this pull request Nov 14, 2026
Port of the review round of unoplatform#26434 (commits 0e475cdb9e
and 73279a2cf1 there) onto this branch's write-back.

- Narrower rule: the input or the native editable takes the TextBox's
  text back at once, ending the composition, only when the TextBox
  refused the edit or kept text of another length (MaxLength, a length
  limit). A change that keeps the length (an upper-case TextChanging
  filter) waits for the end of the composition again, as
  unoplatform#25804 made it, so an IME can still convert it:
  When_Browser_Ime_Composes_With_TextChanging_Filter is back to
  servicing/6.8's version. TextBox.MustReturnTextToNative holds the
  rule, and compares without allocating.
- Skia Android's EndBatchEdit no longer reads ActiveTextBox again after
  the sync, which runs app handlers that may move focus or clear it
  (a null reference in the IME callback): it writes back only to the
  TextBox it synced, and only after the IME changed the text. The
  write-back is one batch for the listeners, so a TextCompositionEnded
  handler that sets the text again is written after it.
- The comments no longer describe this as what Android's LengthFilter
  does, and the remaining ones about the end-of-composition
  reconciliation say which edits it still covers.
- When_Browser_Ime_Composes_Past_MaxLength and
  When_Browser_Ime_Composition_Coerced_Shorter_Than_Its_Start check the
  input right after the update, and that the browser's late
  compositionend changes nothing.

TextBox.UpdateTextFromNative already kept the TextBox's caret on a
refused sync (the applied check of unoplatform#26005), unlike master's;
When_IME_Platform_Sync_Refused_Then_Caret_Stays covers it here too.
6.8 has no RichEditBox IME, so the RichEditBox part doesn't apply.
A composition edit the TextBox refused stayed in the hidden input until
the composition ended, unless a BeforeTextChanging handler or a
read-only TextBox refused it: an edit MaxLength refused, or that a
length limit put back, was left for the end of the composition, so that
a CJK conversion could still bring it back within the limit. Gboard
composes every Latin word, though, so the letters typed past MaxLength
stayed in the input for the whole word: the keyboard built its
suggestions on them, and Backspace deleted them before it reached the
TextBox's text.

This replaces that trade-off. When the TextBox keeps text of another
length than the input's (MaxLength, a length limit), or refuses a change
of the input's text and keeps its own previous text, its text goes back
to the input at once and the composition ends, so the keyboard builds
its next keystrokes and suggestions on the text the TextBox kept. The
input now reports its value at the previous sync with each edit, so the
same text reported again (a caret move inside the preedit) doesn't count
as a refusal while a same-length filter keeps the two texts apart. Kana
typed past a limit is cut at the limit rather than converted later. A
change that keeps the length (an upper-case filter) still waits for the
end of the composition, as unoplatform#25804 made it, so an IME can
still convert it. ImeSessionCoordinator.MustReturnTextToNative holds the
rule.

RichEditBox gets the same rule, so an edit it refuses or cuts to its
text limit is written back, and its CharacterCasing waits for the end of
the composition. ApplyNativeInput no longer has to tell a refusal from a
handler's put-back text, so its result and the text change count go.

Tests: When_Browser_Ime_Converts_Past_MaxLength and
When_Browser_Ime_Converts_Past_TextChanging_Limit become
When_Browser_Ime_Kana_Composes_Past_MaxLength and
When_Browser_Ime_Kana_Composes_Past_TextChanging_Limit;
When_Browser_Ime_Composes_Word_Past_MaxLength_Then_Input_Keeps_The_Limit
covers the Gboard case and
Given_RichEditBox.When_Wasm_Composition_Past_MaxLength_Is_Written_Back
the RichEditBox, with
When_Wasm_Composition_Past_MaxLength_In_The_Middle_Keeps_The_Caret and
When_Wasm_Composition_With_Upper_Casing_Stays_Open.
When_Browser_Ime_Filtered_Composition_Reported_Again_Stays_Open,
When_Browser_Ime_Same_Length_Replacement_Is_Refused and the table test
When_Native_Text_Is_Compared_To_The_Kept_Text cover the rule's clauses.
When_Browser_Ime_Composes_Past_MaxLength and
When_Browser_Ime_Composition_Coerced_Shorter_Than_Its_Start now check
the input right after the update, and that the browser's late
compositionend changes nothing.
Skia Android's EndBatchEdit synced the IME's text to the host, but an
edit the host refused (MaxLength, BeforeTextChanging) stayed in the
editable, as a refusal raises no text change to write back, and a
TextChanging handler's change wasn't written back either while the
batch ended. The keyboard kept composing on text the host doesn't have.

With the WebAssembly host's rule
(ImeSessionCoordinator.MustReturnTextToNative, given the host's text
read before the sync and the editable's text at the start of the batch),
the editable now takes the host's text at once and drops its composing
region, which ends the composition, as one change for the listeners: a
TextCompositionEnded handler that sets the text again is written after
it. The check runs only after the IME changed the text, as a
RichEditBox's text costs the document, and only while the host it synced
is still the active one, as the handlers that ran may have moved focus.
UpdateEditableStateFromHost reads the host once for the same reason.

A refused sync also put the host's caret where the IME left it in the
refused text, which the editable then took back, so the next key landed
one place late. The caret now stays where the edit started
(When_IME_Platform_Sync_Refused_Then_Caret_Stays).
mikernet added a commit to Singulink/uno that referenced this pull request Nov 14, 2026
Port of the second review round of unoplatform#26434 (commits
d6480af and 71c75a7 there).

An upper-case TextChanging filter leaves the TextBox's text ("AB")
different from the composition's ("ab") until the composition ends.
The input reporting its text again unchanged (a caret move inside the
preedit) then looked like a refusal, as the TextBox kept its previous
text, and ended the composition. A refusal now also needs the input's
text to have changed: the browser passes the input's value at the
previous sync with each edit, and Skia Android compares with the
editable's text at the start of the batch and reads the TextBox's text
before the sync, instead of the editable's, for what the TextBox had.

UpdateEditableSelectionAndText reads the active TextBox once, as the
editable's listeners notified by Clear may change it. The selectionchange
comment no longer names MaxLength, which no longer stays in a
composition.

Tests: When_Browser_Ime_Filtered_Composition_Reported_Again_Stays_Open,
When_Browser_Ime_Same_Length_Replacement_Is_Refused and the table test
When_Native_Text_Is_Compared_To_The_Kept_Text.
The browser passed the input's previous value with each edit, so the
whole text crossed to .NET twice per keystroke, while the rule only
needs to know whether the input's text changed. The browser now
compares the input's value with the one at the previous sync or write
(both written by the input, so their line breaks match) and passes the
result, for TextBox and RichEditBox alike. An overload of
MustReturnTextToNative takes it; Android keeps passing the editable's
text from the start of the batch, compared only when the rule needs it.

The table test checks both overloads with the same rows.
mikernet added a commit to Singulink/uno that referenced this pull request Nov 14, 2026
Port of unoplatform#26434's 0effe84.

The browser passed the input's previous value with each edit, so the
whole text crossed to .NET twice per keystroke, while the rule only
needs to know whether the input's text changed. The browser now
compares the input's value with the one at the previous sync or write
(both written by the input, so their line breaks match) and passes the
result. An overload of TextBox.MustReturnTextToNative takes it; Skia
Android keeps passing the editable's text from the start of the batch,
compared only when the rule needs it.

The table test checks both overloads with the same rows.
Sign up for free to join this conversation on GitHub. Already have an account? Sign in to comment

Labels

area/skia ✏️ Categorizes an issue or PR as relevant to Skia platform/android 🤖 Categorizes an issue or PR as relevant to the Android platform platform/wasm 🌐 Categorizes an issue or PR as relevant to the WebAssembly platform

Projects

None yet

Development

Successfully merging this pull request may close these issues.

[WASM] TextCompositionChanged raised before its text is applied, and related composition bugs on browser soft keyboards

1 participant