StringFormat or using Converter break the Undo / Redo of a textbox
- Dominant language
- C#
- Stars
- 7.7k
- Forks
- 1.3k
- Avg merge
- 1d 11h
- Merged PRs (30d)
- 61
Description
### Description
Undo / Redo functionality of System.Windows.Controls.TextBox stops working correctly when the Text property is data-bound using either StringFormat or a Converter.
This issue occurs even when using UpdateSourceTrigger=LostFocus, not only PropertyChanged.
### Reproduction Steps
Just create a Window like that and run tests
```xaml
```
and that's the converter
```c#
internal class StringFormatConverter : IValueConverter
{
public object Convert(object value, Type targetType, object parameter, System.Globalization.CultureInfo culture)
{
if (value is null) return string.Empty;
var amount = value.ToString();
if (decimal.TryParse(amount, NumberStyles.Any, CultureInfo.InvariantCulture, out decimal result))
{
return result.ToString("N2");
}
return value!;
}
public object ConvertBack(object value, Type targetType, object parameter, System.Globalization.CultureInfo culture)
{
return value ?? Binding.DoNothing;
}
}
```
and that the tests
```c#
public class DefaultTextBoxTests
{
[WpfFact]
public async Task DefaultTextBox_Should_UndoAndRedoFullValueChanges()
{
var window = new DecimalWindow();
window.Show();
var box = window.DefaultTextBox;
var vm = (TestViewModel)window.DataContext;
box.Focus();
box.ApplyTemplate();
box.UndoLimit = 100; // ensure undo stack works
// Step 1: Initial value
box.Text = "7000";
box.MoveFocus(new TraversalRequest(FocusNavigationDirection.Next));
await Task.Delay(200);
Assert.Equal("7000", box.Text);
Assert.Equal(7000m, vm.DefaultTextBox);
// Step 2: Change value (simulate typing so undo is recorded)
box.Focus();
box.SelectAll();
box.SelectedText = "5000";
box.MoveFocus(new TraversalRequest(FocusNavigationDirection.Next));
await Task.Delay(200);
Assert.Equal("5000", box.Text);
Assert.Equal(5000m, vm.DefaultTextBox);
// Step 3: Undo → back to 7000
box.Focus();
Assert.True(box.CanUndo);
box.Undo();
box.MoveFocus(new TraversalRequest(FocusNavigationDirection.Next));
await Task.Delay(200);
Assert.Equal("7000", box.Text);
Assert.Equal(7000m, vm.DefaultTextBox);
// Step 4: Redo → back to 5000
box.Focus();
Assert.True(box.CanRedo);
box.Redo();
box.MoveFocus(new TraversalRequest(FocusNavigationDirection.Next));
await Task.Delay(200);
Assert.Equal("5000", box.Text);
Assert.Equal(5000m, vm.DefaultTextBox);
window.Close();
}
[WpfFact]
public async Task StringFormatTextBox_Should_UndoAndRedoFullValueChanges()
{
var window = new DecimalWindow();
window.Show();
var box = window.StringFormatTextBox;
var vm = (TestViewModel)window.DataContext;
box.Focus();
box.ApplyTemplate();
box.UndoLimit = 100; // ensure undo stack works
// Step 1: Initial value
box.Text = "7000";
box.MoveFocus(new TraversalRequest(FocusNavigationDirection.Next));
await Task.Delay(200);
Assert.Equal("7,000.00", box.Text);
Assert.Equal(7000m, vm.DefaultTextBox);
// Step 2: Change value (simulate typing so undo is recorded)
box.Focus();
box.SelectAll();
box.SelectedText = "5000";
box.MoveFocus(new TraversalRequest(FocusNavigationDirection.Next));
await Task.Delay(200);
Assert.Equal("5,000.00", box.Text);
Assert.Equal(5000m, vm.DefaultTextBox);
// Step 3: Undo → back to 7000
box.Focus();
Assert.True(box.CanUndo);
box.Undo();
box.MoveFocus(new TraversalRequest(FocusNavigationDirection.Next));
await Task.Delay(200);
Assert.Equal("7,000.00", box.Text);
Assert.Equal(7000m, vm.DefaultTextBox);
// Step 4: Redo → back to 5000
box.Focus();
Assert.True(box.CanRedo);
box.Redo();
box.MoveFocus(new TraversalRequest(FocusNavigationDirection.Next));
await Task.Delay(200);
Assert.Equal("5,000.00", box.Text);
Assert.Equal(5000m, vm.DefaultTextBox);
window.Close();
}
[WpfFact]
public async Task ConverterTextBox_Should_UndoAndRedoFullValueChanges()
{
var window = new DecimalWindow();
window.Show();
var box = window.ConverterTextBox;
var vm = (TestViewModel)window.DataContext;
box.Focus();
box.ApplyTemplate();
box.UndoLimit = 100; // ensure undo stack works
// Step 1: Initial value
box.Text = "7000";
box.MoveFocus(new TraversalRequest(FocusNavigationDirection.Next));
await Task.Delay(200);
Assert.Equal("7,000.00", box.Text);
Assert.Equal(7000m, vm.DefaultTextBox);
// Step 2: Change value (simulate typing so undo is recorded)
box.Focus();
box.SelectAll();
box.SelectedText = "5000";
box.MoveFocus(new TraversalRequest(FocusNavigationDirection.Next));
await Task.Delay(200);
Assert.Equal("5,000.00", box.Text);
Assert.Equal(5000m, vm.DefaultTextBox);
// Step 3: Undo → back to 7000
box.Focus();
Assert.True(box.CanUndo);
box.Undo();
box.MoveFocus(new TraversalRequest(FocusNavigationDirection.Next));
await Task.Delay(200);
Assert.Equal("7,000.00", box.Text);
Assert.Equal(7000m, vm.DefaultTextBox);
// Step 4: Redo → back to 5000
box.Focus();
Assert.True(box.CanRedo);
box.Redo();
box.MoveFocus(new TraversalRequest(FocusNavigationDirection.Next));
await Task.Delay(200);
Assert.Equal("5,000.00", box.Text);
Assert.Equal(5000m, vm.DefaultTextBox);
window.Close();
}
}
```
### Expected behavior
User typing should be recorded in the undo stack
Ctrl + Z / Ctrl + Y should undo/redo user input
Formatting should not invalidate Undo/Redo history
### Actual behavior
Undo stack is cleared after binding updates
Ctrl + Z either does nothing
Happens consistently when:
StringFormat is used
OR a Converter is used
Even with UpdateSourceTrigger=LostFocus
### Regression?
_No response_
### Known Workarounds
_No response_
### Impact
DefaultTextBox_Should_UndoAndRedoFullValueChanges is pass while the others not
### Configuration
dotnet 10
### Other information
_No response_
Contributor guide
Assessment
This issue has not been assessed yet.