Skip to content

fix(TextBox): clear undo history on external text updates - #21839

Merged
MrJul merged 3 commits into
AvaloniaUI:mainfrom
NathanDrake2406:nathan/fix-21578-textbox-binding-undo
Jul 24, 2026
Merged

fix(TextBox): clear undo history on external text updates#21839
MrJul merged 3 commits into
AvaloniaUI:mainfrom
NathanDrake2406:nathan/fix-21578-textbox-binding-undo

Conversation

@NathanDrake2406

@NathanDrake2406 NathanDrake2406 commented Jul 23, 2026

Copy link
Copy Markdown
Contributor

What does the pull request do?

This PR restores the TextBox undo boundary between user edits and external Text updates.

Mutations to TextProperty are now classified into three kinds, tracked internally as a TextMutationKind:

  • Edit — typing, paste, delete, Clear(), SelectedText replacement. Undoable, belongs to the current document's history.
  • Internal synchronization — presentation-only reformatting of the same document (e.g. MaskedTextBox toggling prompt characters on focus/blur, or refreshing its display after the mask configuration changes). Leaves undo/redo history untouched.
  • External replacement — anything else, including bindings and direct property sets. Clears undo/redo history and establishes the new value as the baseline, so a reused TwoWay-bound editor cannot replay text from a previously selected model into the current one.

This restores the behavior originally introduced for #336, which regressed when #9490 made every Text coercion create an undo snapshot.

What is the current behavior?

Every Text change is added to the same undo timeline, including values supplied by a binding.

In a master-detail editor that reuses one TwoWay-bound TextBox, changing the selected model leaves the previous model's text in the undo stack. Undo can then write that stale text into the newly selected model.

What is the updated/expected behavior with this PR?

External Text updates clear both undo and redo history and establish the new value as the baseline. Normal typing remains undoable, including the synchronous source notification produced by a TwoWay binding.

MaskedTextBox's own display-only reformatting (prompt characters shown/hidden on focus and blur, and the text re-parsed after Mask, PromptChar, Culture, etc. change) does not create undo entries, since it isn't an edit to the logical document. Actual masked edits (typing, paste, Delete/Space/Backspace) remain undoable, and Clear() / SelectedText replacement are deliberately kept undoable as editing commands.

Regression coverage verifies:

  • replacing the binding source value clears CanUndo and CanRedo
  • a user edit propagated through a TwoWay binding remains undoable and updates the source when undone
  • focusing and unfocusing a MaskedTextBox does not create an undo operation
  • an actual masked edit remains undoable
  • an external replacement after a masked edit clears history
  • Clear() and SelectedText replacement remain undoable
Validation
  • dotnet build src/Avalonia.Controls/Avalonia.Controls.csproj -p:AvsSkipBuildingLegacyTargetFrameworks=True --no-restore: succeeded with 0 warnings and 0 errors
  • dotnet run --project tests/Avalonia.Controls.UnitTests/Avalonia.Controls.UnitTests.csproj -p:AvsSkipBuildingLegacyTargetFrameworks=True -- --filter-class "Avalonia.Controls.UnitTests.TextBoxTests" "Avalonia.Controls.UnitTests.MaskedTextBoxTests": 225 passed
  • Full Avalonia.Controls.UnitTests run: 3653 passed, 0 failed, 1 pre-existing unrelated skip

How was the solution implemented (if it's not obvious)?

TextBox tracks a TextMutationKind (Edit / InternalSynchronization / ExternalReplacement) while calling SetCurrentValue(TextProperty, ...) through two internal entry points, SetTextFromEdit and SetTextFromInternalSynchronization. The kind stays set through synchronous TwoWay source notifications, so a binding echo of an edit is not mistaken for an external replacement. Any coercion that doesn't go through either entry point (bindings, direct property sets) is treated as an external replacement, clearing history and starting a fresh baseline.

MaskedTextBox routes its own mutations through whichever entry point matches their intent: SetTextFromEdit for keyboard editing and paste, SetTextFromInternalSynchronization for prompt-character display toggling on focus/blur and for reformatting after mask configuration changes.

This replaces an earlier single-bool version of this seam (_isTextChangeFromTextBox) that conflated "should this clear history" with "should this create an undo entry" and routed all MaskedTextBox mutations, including focus-only display changes, through the undoable path.

The behavior change is intentionally limited to undo/redo state. There are no public API or serialization changes. The compatibility risk is that programmatic Text assignments are no longer undoable; they now match binding updates and the non-user-input semantics established by #336.

Checklist

Breaking changes

None.

Obsoletions / Deprecations

None.

Fixed issues

Fixes #21578

TwoWay-bound TextBox instances retain prior user-edit snapshots when the binding source replaces the text, allowing undo to write values from a previous model context back into the current source. Cover the external replacement and the normal TwoWay source echo separately so a fix cannot clear valid user undo history after each edit.
Binding and direct property updates currently enter the same undo timeline as user edits, so a reused TwoWay-bound TextBox can write text from a previous model back into the current source. Track TextBox-initiated mutations through synchronous source notifications, preserve those edits in the undo timeline, and establish a fresh baseline for all external updates. MaskedTextBox routes its own edits through the same boundary so its existing undo behavior remains intact.
@NathanDrake2406
NathanDrake2406 marked this pull request as ready for review July 23, 2026 16:56
@avaloniaui-bot

Copy link
Copy Markdown

You can test this PR using the following package version. 12.2.999-cibuild0067673-alpha. (feed url: https://nuget-feed-all.avaloniaui.net/v3/index.json) [PRBUILDID]

@MrJul MrJul added bug backport-candidate-12.1.x Consider this PR for backporting to 12.1 branch labels Jul 24, 2026
Comment thread src/Avalonia.Controls/TextBox.cs Outdated
Comment on lines +378 to +379
private bool _needsUndoRedoBaseline;
private bool _needsUndoRedoSnapshotAfterTextChange;

Copy link
Copy Markdown
Member

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Are the two flags really necessary? They gate the exact same code path.

â€Ķ history

Replace the boolean text-mutation flag with a three-state TextMutationKind
(ExternalReplacement / Edit / InternalSynchronization) so MaskedTextBox's
prompt-character reformatting on focus/blur and mask-provider refreshes no
longer create undo entries, while actual keyboard edits remain undoable.
@NathanDrake2406
NathanDrake2406 force-pushed the nathan/fix-21578-textbox-binding-undo branch from 358ca9f to 652a008 Compare July 24, 2026 16:53
@avaloniaui-bot

Copy link
Copy Markdown

You can test this PR using the following package version. 12.2.999-cibuild0067735-alpha. (feed url: https://nuget-feed-all.avaloniaui.net/v3/index.json) [PRBUILDID]

@MrJul MrJul left a comment

Copy link
Copy Markdown
Member

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Nice change. LGTM!

@MrJul
MrJul enabled auto-merge July 24, 2026 18:24
@MrJul
MrJul added this pull request to the merge queue Jul 24, 2026
Merged via the queue into AvaloniaUI:main with commit 94f458d Jul 24, 2026
10 checks passed
MrJul pushed a commit that referenced this pull request Jul 29, 2026
* test(TextBox): reproduce stale undo history after binding update

TwoWay-bound TextBox instances retain prior user-edit snapshots when the binding source replaces the text, allowing undo to write values from a previous model context back into the current source. Cover the external replacement and the normal TwoWay source echo separately so a fix cannot clear valid user undo history after each edit.

* fix(TextBox): reset undo history for external text updates

Binding and direct property updates currently enter the same undo timeline as user edits, so a reused TwoWay-bound TextBox can write text from a previous model back into the current source. Track TextBox-initiated mutations through synchronous source notifications, preserve those edits in the undo timeline, and establish a fresh baseline for all external updates. MaskedTextBox routes its own edits through the same boundary so its existing undo behavior remains intact.

* fix(TextBox): distinguish edits from internal synchronization in undo history

Replace the boolean text-mutation flag with a three-state TextMutationKind
(ExternalReplacement / Edit / InternalSynchronization) so MaskedTextBox's
prompt-character reformatting on focus/blur and mask-provider refreshes no
longer create undo entries, while actual keyboard edits remain undoable.
@MrJul MrJul added backported-12.1.x and removed backport-candidate-12.1.x Consider this PR for backporting to 12.1 branch labels Jul 29, 2026
Sign up for free to join this conversation on GitHub. Already have an account? Sign in to comment

Projects

None yet

Development

Successfully merging this pull request may close these issues.

Regression - TextBox undo/redo stack is not cleared when the bound object changes

3 participants