Repository navigation
Conversation
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: 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. |
2 of 5 tasks
2 of 5 tasks
Contributor
|
Tick the box to add this pull request to the merge queue (same as
|
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.
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
force-pushed
the
dev/mikernet/ime-webkit-composition-order
branch
from
November 14, 2026 06:41
6d7709a to
fa6ca7b
Compare
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.
Safari and other WebKit browsers commit a composition through input
events of their own, with no compositionupdate for the committed text.
Current WebKit (Editor::setComposition) dispatches them synchronously,
in one call: a commit is deleteCompositionText, insertFromComposition,
then compositionend with the committed text (the order seen on an
iPhone); an emptied preedit is deleteCompositionText, then
compositionend(""); a cancel is compositionend with the preedit alone,
keeping the text.
TextBox and RichEditBox synced the removal as a composition step of its
own: a TextCompositionChanged with the preedit gone (Text briefly
without it) or a range outside the text, then another one when the
commit came back. The removal is now reported with the insertion that
follows it, and a preedit put back unchanged isn't reported again.
Orders where compositionend comes before the input event that applies
the commit, with the preedit still selected for it or just removed,
aren't sent by current WebKit; they were asked to be covered when unoplatform#24502
was reviewed, and older WebKit versions may send them. On master, TextCompositionEnded then came while Text still
held the preedit. Such a composition now ends with the input event that
applies the commit, after the text change and TextCompositionChanged,
the order WinUI documents. Only these orders wait, so only they need
what follows: when no such event comes in the same task (Blink cancels
a composition whose text is selected with a compositionend alone), the
composition ends after the task as it would have at compositionend,
cancelled if the preedit was removed meanwhile; a next keystroke or
composition in the same task ends it first. The next composition input
event completes the composition whether or not it applied the
announced commit: no recorded order sends one that doesn't, so this
doesn't check that the commit is in place as unoplatform#24502 did.
mikernet
force-pushed
the
dev/mikernet/ime-webkit-composition-order
branch
from
November 14, 2026 08:07
fa6ca7b to
3051cca
Compare
This file contains hidden or bidirectional Unicode text that may be interpreted or compiled differently than what appears below. To review, open the file in an editor that reveals hidden Unicode characters.
Learn more about bidirectional Unicode characters
Sign up for free
to join this conversation on GitHub.
Already have an account?
Sign in to comment
Add this suggestion to a batch that can be applied as a single commit.This suggestion is invalid because no changes were made to the code.Suggestions cannot be applied while the pull request is closed.Suggestions cannot be applied while viewing a subset of changes.Only one suggestion per line can be applied in a batch.Add this suggestion to a batch that can be applied as a single commit.Applying suggestions on deleted lines is not supported.You must change the existing code in this line in order to create a valid suggestion.Outdated suggestions cannot be applied.This suggestion has been applied or marked resolved.Suggestions cannot be applied from pending reviews.Suggestions cannot be applied on multi-line comments.Suggestions cannot be applied while the pull request is queued to merge.Suggestion cannot be applied right now. Please check back later.
GitHub Issue: closes #26433
PR Type:
🐞 Bugfix
What changed? 🚀
Safari and other WebKit browsers commit an IME composition through input events of their own, with no
compositionupdatefor the committed text. Current WebKit (Editor::setComposition) dispatches them synchronously, in one call:deleteCompositionText,insertFromComposition, thencompositionendwith the committed text. This is also the order I recorded on an iPhone (iOS Safari, Japanese kana keyboard) for fix(wasm): Keep the TextBox in sync with Android soft keyboard input #24502;deleteCompositionText, thencompositionend("");compositionendwith the preedit alone, keeping the text.On master, with #25804's design, TextBox and RichEditBox sync the removal as a composition step of its own: a
TextCompositionChangedwith the preedit gone (Textbriefly without it), or with a range outside the text, then another one when the commit comes back. This PR reports the removal together with the insertion that follows it, and doesn't report a preedit removed and put back unchanged again.The rest covers orders where
compositionendcomes before the input event that applies the commit, with the preedit still selected for it or just removed. Current WebKit doesn't send these. They are covered because morning4coffe-dev asked for them when reviewing #24502 (older WebKit versions may send them) (#24502 (review)): "Please defer completion until the committed/restored value has been synchronized, or otherwise preserve that contract, and cover candidate text differing from preedit plus cancellation." On master,TextCompositionEndedthen comes whileTextstill holds the preedit, against WinUI's documented order (the text change andTextCompositionChangedbefore TextCompositionEnded).compositionendleaves the preedit selected for a different commit, or follows its removal, the composition now ends with the input event that applies the commit (or restores the text, for a cancel), after the text change andTextCompositionChanged.compositionendon master (cancelled if the preedit was removed meanwhile). That also covers Blink's cancel of a composition whose text is selected, which is acompositionend("")alone, keeping the text. A next keystroke or composition in the same task ends it first.compositionend, as on master.Tests (WebAssembly, the browser's event sequences replayed on the hidden input, with each
compositionendand its applying input event in one script, as one task):When_Browser_Ime_WebKit_Candidate_Committed_Before_Input_Event,When_Browser_Ime_WebKit_Composition_Cancelled_Before_Input_Event,When_Browser_Ime_WebKit_Preedit_Removed_Before_Candidate_Committed,When_Browser_Ime_WebKit_Preedit_Removed_After_Candidate_Committed,When_Browser_Ime_WebKit_Repeated_Preedit_Removed_Before_Commit(the orders asked for in that review), andWhen_Browser_Ime_WebKit_Candidate_Committed_Before_CompositionendandWhen_Browser_Ime_WebKit_Candidate_Applied_Then_Committed(current WebKit and the iPhone);When_Browser_Ime_Selected_Composition_Cancelled_Without_Input_Event(Blink's cancel),When_Browser_Ime_Announced_Commit_Never_Applied_Then_Key,When_Browser_Ime_Announced_Commit_Never_Applied_Then_Composition,When_Browser_Ime_Announced_Commit_Then_Preedit_Removed_Only.Two more orders, a commit equal to the preedit and current WebKit's emptied preedit (removal, then
compositionend("")), are already fixed by #26434 and tested there.Validation
Given_TextBox,Given_RichEditBox,Given_PasswordBox,Given_BrowserKeyboardInputSource,Given_AccessibleTextBoxandGiven_ImeSessionCoordinator*: 1402 of 1433 pass, 30 inconclusive, and the one failure (When_Touch_LongPress_Drag_Release_Does_Not_Open_Flyout_iOS) fails the same way on master.PR Checklist ✅
Screenshots Compare Test Runresults.