Visitar URL original
fix(wasm): End a WebKit composition after its commit by mikernet · Pull Request #26435 · unoplatform/uno · GitHub
Skip to content

fix(wasm): End a WebKit composition after its commit - #26435

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

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

Conversation

@mikernet

@mikernet mikernet commented Nov 13, 2026 •

Copy link
Copy Markdown
Contributor

GitHub Issue: closes #26433

Stacked on #26434: the first commits here are that PR's, and this one can merge after it. This PR's own change is the last commit, fix(wasm): End a WebKit composition after its commit.

PR Type:

🐞 Bugfix

What changed? 🚀

Safari and other WebKit browsers commit an IME 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. 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;
  • an emptied preedit is deleteCompositionText, then compositionend("");
  • a cancel is compositionend with 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 TextCompositionChanged with the preedit gone (Text briefly 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 compositionend comes 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, TextCompositionEnded then comes while Text still holds the preedit, against WinUI's documented order (the text change and TextCompositionChanged before TextCompositionEnded).

  • When compositionend leaves 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 and TextCompositionChanged.
  • Only these orders wait, and the rest of the code serves only them: when no such event comes in the same task, the composition ends after the task as it would have at compositionend on master (cancelled if the preedit was removed meanwhile). That also covers Blink's cancel of a composition whose text is selected, which is a compositionend("") alone, keeping the text. A next keystroke or composition in the same task ends it first.
  • A commit no input event will apply, with the preedit not selected, still ends at compositionend, as on master.

Tests (WebAssembly, the browser's event sequences replayed on the hidden input, with each compositionend and its applying input event in one script, as one task):

  • fail on master and on fix(ime): Raise composition events after their text on browser soft keyboards #26434 alone: 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), and When_Browser_Ime_WebKit_Candidate_Committed_Before_Compositionend and When_Browser_Ime_WebKit_Candidate_Applied_Then_Committed (current WebKit and the iPhone);
  • guards for the ways a waiting composition still ends; they fail without the fallback and release paths (and on master, only through the event order fix(ime): Raise composition events after their text on browser soft keyboards #26434 fixes): 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

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 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
mikernet force-pushed the dev/mikernet/ime-webkit-composition-order branch from 6d7709a to fa6ca7b Compare November 14, 2026 06:41
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
mikernet force-pushed the dev/mikernet/ime-webkit-composition-order branch from fa6ca7b to 3051cca Compare November 14, 2026 08:07
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] TextCompositionEnded raised before the committed text on Safari/WebKit

1 participant