dotnet / dotnet/wpf

StringFormat or using Converter break the Undo / Redo of a textbox

Open
#11,408 0 comments 1 reaction 0 assignees View on GitHub
Investigate
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

Open the contributing guide

Assessment

This issue has not been assessed yet.

Get new issues in your inbox

A short digest of beginner-friendly GitHub issues.