Visitar URL original
fix(textbox): Limit only user input to MaxLength by mikernet · Pull Request #26005 · unoplatform/uno · GitHub
Skip to content

fix(textbox): Limit only user input to MaxLength - #26005

Open
mikernet wants to merge 3 commits into
unoplatform:masterfrom
mikernet:dev/mikernet/textbox-maxlength-programmatic
Open

mikernet wants to merge 3 commits into
unoplatform:masterfrom
mikernet:dev/mikernet/textbox-maxlength-programmatic

Conversation

@mikernet

@mikernet mikernet commented Nov 3, 2026 •

Copy link
Copy Markdown
Contributor

GitHub Issue: closes #25997

PR Type:

🐞 Bugfix

What changed? 🚀

Before: a TextBox with MaxLength refused any new Text longer than MaxLength, even one set from code, a Binding or x:Bind, and kept its previous text. An editor that reuses one TextBox for several items could show the previous item's text for an item with a long value, and the next edit wrote that text into the current item. Once the text was longer than MaxLength, the user also couldn't delete characters one by one, and Undo couldn't put the text back. PasswordBox.Password behaved the same way.

After: MaxLength limits only user input, as on WinUI. The PR has three commits:

1. fix(textbox): Limit only user input to MaxLength

  • Text set from code is kept whole and raises the usual events: Text, bindings, SelectedText, PasswordBox.Password, and text an app sets inside a TextChanging or SelectionChanged handler while the user types.
  • User input that would leave the text longer than MaxLength is refused, with no BeforeTextChanging, as before. A paste still inserts only what fits.
  • An edit that only deletes text goes in, even while the text is still longer than MaxLength. Backspace from the browser or the Android keyboard in multi-line text works too.
  • Undo and Redo aren't limited: they put back text the TextBox already held, as on WinUI.
  • UI Automation: setting a value through the TextBox or PasswordBox automation peer refuses a value longer than MaxLength and keeps the text, as WinUI's UI Automation does; a value that fits is applied like text set from code. On the Win32 host, the error reaches the client as UIA_E_INVALIDOPERATION, where WinUI returns E_FAIL.
  • WebAssembly semantic text box: typing on a physical keyboard already went through key events. What changes is input that only the browser sees: phone keyboards, IME, dictation and autocorrect. It now takes the user-input path, so MaxLength applies to it and it's reported as user input (an AutoSuggestBox sees UserInput). When the TextBox refuses such input, the semantic element shows the TextBox's text again. A read-only TextBox used to throw there, and a disabled one accepted the input; both now refuse it.
  • macOS accessibility: the same rule as on WinUI. Setting the whole value (setAccessibilityValue) is like UI Automation's set-value, so a value longer than MaxLength is refused. Replacing the selected text (setAccessibilitySelectedText, which dictation uses) is user input, like typing. When the TextBox refuses either, the native element shows its text again; a PasswordBox gets no text there.
  • Android autofill is cut to fit MaxLength, as Android's own text fields do, and goes in as user input. A read-only or disabled box ignores it.
  • Native text views (the browser's hidden input, the Android and iOS keyboards): when the TextBox refuses their text, it keeps its own caret instead of taking the native one. In the browser, the input path from fix(textbox): Fix IME and touch editing on Android browsers #25804 now selects only when the edit is applied, so the selection doesn't go to the refused caret and back, and raises no SelectionChanged.
  • iOS: the keyboard's Backspace is let through in over-long text; before, the native input refused it. A PasswordBox whose password is longer than MaxLength keeps it when it gets focus: the native field puts its text back then, and that is no longer refused.
  • Docs: a new Uno Platform 7.1 section in the migration notes describes the change. 7.0 has branched and master is on 7.1, so the 7.0 guide stays as it is: it describes what 7.0 does.

2. fix(textbox): Record undo steps only once applied

  • A deletion (Backspace, Delete, Ctrl+Backspace, Ctrl+Delete) or a spelling suggestion that MaxLength or a BeforeTextChanging handler refuses no longer leaves an undo step. Before, Redo could then apply a refused suggestion past MaxLength.
  • Typed keys that MaxLength or a handler all refuse no longer become an empty undo step that clears Redo, as on WinUI.
  • Ctrl+Backspace is one undo step. Before, it was recorded twice (once while working out whether the key is handled), so the first Undo did nothing visible.
  • Redo while a typing run is open no longer throws ArgumentOutOfRangeException. When there is something to redo, the run is recorded first, which ends what Redo could put back, as typing does on WinUI. With nothing to redo, Redo leaves the typing run open, as before.
  • A handler that throws during a deletion, or calls Undo() from KeyDown, no longer leaves a step behind for the next edit.
  • A handler that throws during a spelling suggestion no longer stops text set from code from clearing the undo history afterwards. An Undo() that a TextChanging handler runs in the middle of a deletion or a paste no longer makes that edit clear the history.

3. fix(textbox): Keep IME ranges on the applied text

  • When MaxLength or a BeforeTextChanging handler refuses an IME composition update, the composition keeps its range. Before a composition has put any text in, it has no text of its own: emptying, cancelling or committing an empty composition no longer deletes the selection it was going to replace, and the composition events report no length. Before, a refused first update followed by a cancel deleted the selection, also at exactly MaxLength.
  • A refused commit reports the range of the composition text that is really there.
  • A partial commit whose committed part fits on its own keeps that part when the rest doesn't fit.
  • The browser and Android apply composition text themselves and report each update before syncing its text, so a refused update has already been reported. The range now goes back to the text that is really there: TextCompositionChanged reports it a second time, and TextCompositionEnded reports that range instead of the length of the refused text.

Which input counts as user input: typing and deleting, paste, input methods, spelling suggestions, native text views (the browser's hidden input, the Android and iOS keyboards), the WebAssembly semantic text box, macOS selected-text replacement, and autofill. UI Automation's set-value and macOS's set-value aren't user input: WinUI refuses an over-long value there, and Uno now does too. The WebAssembly semantic text box differs from them on purpose: it receives the browser's input events, which are the user's own keystrokes, IME and dictation, so it follows the typing rule. The WebAssembly, macOS and iOS changes belong here because these paths used to rely on Text refusing long values: without them, phone-keyboard input on WebAssembly would go past MaxLength, and iOS would block Backspace in text that can now be longer than MaxLength.

The check now runs on the user-input path instead of in the Text coercion, so the coercion no longer needs to tell input from code. One line-break helper serves both, so the check compares the text as the coercion will store it.

Cost: for text within MaxLength, or with no MaxLength, the check returns at once. Only when a user edit would leave the text longer than MaxLength does it normalize the new text's line breaks and compare it with the old text once. Multi-line text with no \n now skips the line-break pass of the coercion, which used to copy it every time.

Not changed here:

  • On WinUI, CanRedo is false as soon as typing starts after an Undo; Uno still reports true until the typing run ends.
  • Pastes and cuts: a separate PR builds on this one.

Intentional difference from WinUI: a cancelled edit keeps the undo history

When a BeforeTextChanging handler cancels an edit, WinUI clears the whole undo history, both Undo and Redo. I measured this for a cancelled Backspace, typing, paste and cut. Uno keeps the history, as it does today; the cancelled edit just adds no undo step. A MaxLength refusal keeps the history on both.

I left this as it is because:

  • Clearing hurts users. Cancelling in BeforeTextChanging is the usual way to filter input. In a digits-only box, a user who types 12345 and hits a letter by accident could no longer undo any of it.
  • WinUI's reason doesn't apply to Uno. On WinUI, RichEdit has already changed the text when BeforeTextChanging is raised, so WinUI sets the old text back and clears the undo stack; a comment in TextBox.cpp describes this. Uno raises the event before changing anything, so it has nothing to set back.
  • Nothing changes for apps. Uno doesn't clear the history on a cancel today either.

WinUI does come out ahead in one edge case: a handler that refuses an Undo or Redo. Uno moves its place in the history before it applies the step and doesn't move back when the step is cancelled, so the step is used up and the next Undo goes back two steps. Nothing is corrupted, because each step restores its full recorded text. This PR doesn't change that. From its source, WinUI clears the history here too, but I didn't measure this case. Moving the place only once the step is applied would fix it without clearing anything.

If you'd prefer to match WinUI and clear the history on a cancelled edit, I can do that in a follow-up PR.

Validation:

  • Base: current master at 09b5f7a, which includes fix(textbox): Fix IME and touch editing on Android browsers #25804. Its browser input and composition tests still pass, among them the ones that rely on input past MaxLength being refused during a composition.
  • WinUI: the expected values come from a WinUI 3 app (Windows App SDK 1.8) that drives a TextBox through its own APIs, called the WinUI recording app in the tests. It set text from code, typed through key messages posted to its own window, and set values through UI Automation from a second process. Values that can't be recorded on WinUI are marked in the tests: the native-view, input-method, autofill, accessibility-input, browser and WebAssembly semantic-input cases, and Shift and Ctrl key combinations.
  • New tests: Given_TextBox_MaxLength, IME and browser-input tests in Given_TextBox, and semantic-input tests in Given_AccessibleTextBox.
  • Skia Desktop (Windows, Win32 host), each commit against the one before it (on 445304d, four commits earlier, none of them about text input):
    • commit 1: 18 of the 28 desktop cases that build against the old code fail there; 7 more call methods the old code doesn't have, and 1 runs only in the browser. All 35 desktop cases pass after it.
    • commit 2: 14 of its 16 new cases fail before it; the other two pass before and after it: a cancelled text set from code inside a deletion (commit 1's rule) and Redo with nothing to redo. For that run, ReplaceWithSuggestion was made internal so the tests could call it. All 51 Given_TextBox_MaxLength desktop cases pass after it.
    • commit 3: its 14 new cases fail before it; all 34 IME cases pass after it.
    • regression, with the paste PR on top: Given_TextBox*, Given_PasswordBox, Given_ImeSessionCoordinator*, Given_AccessibleTextBox, Given_RichEditBox* and Given_BrowserKeyboardInputSource, 1382 cases, 0 failures, without the clipboard tests.
    • the 142 tests (147 cases) in those classes that use the system clipboard, which the 1382 leave out, ran on their own, in a separate window station with its own clipboard: 140 cases pass, 5 are skipped and 1 is inconclusive on this platform, and When_Touch_LongPress_Drag_Release_Does_Not_Open_Flyout_iOS fails, the same way as on master without these changes.
  • WebAssembly (headless Chrome): against the old code, 37 of 114 cases fail, among them the semantic deletions, the disabled TextBox, and deleting from over-long text and keeping the selection in the hidden input (the tests that call methods the old code doesn't have were left out). Against commit 2, the 2 browser tests of commit 3's composition ranges fail. At the head of this PR, the MaxLength, IME, browser-input and Given_AccessibleTextBox tests pass, with fix(textbox): Fix IME and touch editing on Android browsers #25804's Given_RichEditBox, Given_ImeSessionCoordinator* and Given_BrowserKeyboardInputSource tests: 220 cases, 1 skipped (mobile only). With the paste PR on top, 272 cases pass, the paste tests included. The composition tests replay Chromium's event order, as fix(textbox): Fix IME and touch editing on Android browsers #25804's do; no real IME or phone keyboard was run.
  • Android, iOS and macOS runtimes: on this branch, compiled, not run. The macOS native change (a flag that tells a value set from a selected-text replacement) needs the native library rebuilt on a Mac.
  • iOS 27 simulator, on the 6.8 port of this PR: a test app sent key input to the native text views. Backspace in over-long text works in single-line and multi-line boxes, typing past MaxLength is refused, and a PasswordBox keeps an over-long password when it gets focus.

PR Checklist ✅

Behavior change (described in the new migration entry): a value set from code that's longer than MaxLength is now kept whole, as on WinUI. Apps that relied on MaxLength to refuse such values should check the length themselves before setting them. UI Automation now refuses an over-long value with an error instead of ignoring it.

@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 platform/macos 🍏 Categorizes an issue or PR as relevant to the macOS platform platform/ios 🍎 Categorizes an issue or PR as relevant to the iOS platform area/skia ✏️ Categorizes an issue or PR as relevant to Skia kind/documentation labels Nov 3, 2026
@unodevops

Copy link
Copy Markdown
Contributor

🤖 Your Docs stage site is ready! Visit it here: https://unodocsprstaging.z13.web.core.windows.net/pr-26005/docs/index.html

@mikernet
mikernet force-pushed the dev/mikernet/textbox-maxlength-programmatic branch from ba9bbb6 to be85699 Compare November 3, 2026 23:05
@unodevops

Copy link
Copy Markdown
Contributor

🤖 Your Docs stage site is ready! Visit it here: https://unodocsprstaging.z13.web.core.windows.net/pr-26005/docs/index.html

@mergify

mergify Bot commented Nov 10, 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
Takes Uno's servicing/6.8 from 6951c2c up to 5254ace (42 pull
requests). Conflicts, by area:

IME and TextBox (unoplatform#25984, backport of unoplatform#25804; unoplatform#25952, backport of unoplatform#25619;
unoplatform#25976, backport of unoplatform#25818)
- Takes upstream's composition design (unoplatform#25804): the browser's input is
  the source of truth for the text, compositions included. This drops
  the composition parts of the unoplatform#24502 port (90746b8): its
  TypeScript composition handling, the StartIndex/TextChangePending
  event arguments, the IsReadOnly forwarding to the hidden input, and
  its Given_TextBox.BrowserSoftKeyboard tests.
- Keeps the non-IME browser fixes on top of it, in the form their
  master pull requests have on unoplatform#25804: Enter raised once per press and
  tracked by key code (unoplatform#25174, with Given_TextBox.BrowserEnterKey from
  master in place of the Enter tests that lived in the dropped file),
  the iOS refocus on tap (unoplatform#25185), pastes without text and isIOS from
  BrowserInputHelper.
- Moves the unoplatform#26005 MaxLength fixes onto unoplatform#25804's native input path, as
  master has them: a refused browser edit keeps the TextBox's selection,
  TextBoxView.UpdateTextFromNative reports whether the edit went in, and
  the IME keeps its range on the applied text (master be85699, which
  d226897 had ported onto the unoplatform#24502 IME). Adds that commit's browser
  and platform-applied IME tests.
- TextBox.pointers.skia.cs: upstream's file (unoplatform#25619 iOS touch editing,
  unoplatform#25818, unoplatform#25804), with the unoplatform#25126 port (the hold acts when it starts,
  a touch right-tap keeps the caret) in the form master's unoplatform#25126 branch
  has it on unoplatform#25619, including focusing before the iOS hold timer acts
  and its iOS not-focusable test.

Pointers (unoplatform#25976, backport of unoplatform#25818)
- Tapped and RightTapped are raised once the PointerReleased bubbled
  (unoplatform#25818), with the unoplatform#25732 port's window coordinates (2-argument
  routed event args, RawPosition for ContextRequested), as master's
  unoplatform#25732 branch has them.
- The release wrapper of unoplatform#25818 keeps the unoplatform#25126 port's
  StopContextMenuTimer at the start of the release.

DependencyObject (unoplatform#26142, backport of unoplatform#26123)
- Takes upstream, then the code of unoplatform#25839 (97a2f17), which replaces
  this branch's unoplatform#25102 ports (800247f, be41da6, fd40bf9,
  7546379). DependencyObjectStore.Binder.cs, .alc.cs,
  Application.Alc.cs, ResourceDictionary.alc.cs and their tests now
  match unoplatform#25839; this drops 800247f's own
  SingleParentNonFE_Parent_Removes_Value_Then_Next_Parent_Starts_Clean
  test, which unoplatform#25839 doesn't have.

Imaging (unoplatform#25597, unoplatform#25915, unoplatform#25911, unoplatform#25897)
- Keeps this branch's side: it has the later forms of these fixes
  (unoplatform#25124, unoplatform#25559, unoplatform#25633 and unoplatform#26370 ports), where upstream has the
  6.8-only unoplatform#25597 design and earlier snapshots. Upstream's shared
  download, IgnoreImageCache and failed-load fixes are covered by
  SharedImageDecode. Adds upstream's When_Source_Outlives_Brushes test.

ToolTip (unoplatform#26098, backport of unoplatform#25137 and unoplatform#26096)
- Keeps this branch's unoplatform#25137 port and adds unoplatform#26096 in master's form: the
  service releases only a ToolTip it opened, and leaves one the app
  opened during the show delay to the app. Keeps both sides' tests.

Flyout (unoplatform#26062, backport of unoplatform#26060)
- This branch had the same commits already; keeps the "Keep
  non-resizable flyouts in window" port and its tests.

Also removes a helper pair in Given_AccessibleTextBox that both sides
added.
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 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.
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 kind/documentation platform/android 🤖 Categorizes an issue or PR as relevant to the Android platform platform/ios 🍎 Categorizes an issue or PR as relevant to the iOS platform platform/macos 🍏 Categorizes an issue or PR as relevant to the macOS 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.

TextBox ignores a Text value set from code when it is longer than MaxLength

2 participants