Skip to content

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

@alraseensaad

Description

@alraseensaad

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

<Window
    x:Class="UIX.Tests.ControlTests.DecimalTextBoxTests.DecimalWindow"
    xmlns="http://schemas.microsoft.com/winfx/2006/xaml/presentation"
    xmlns:x="http://schemas.microsoft.com/winfx/2006/xaml"
    xmlns:d="http://schemas.microsoft.com/expression/blend/2008"
    xmlns:mc="http://schemas.openxmlformats.org/markup-compatibility/2006"
    mc:Ignorable="d"
    xmlns:local="clr-namespace:UIX.Tests.ControlTests.DecimalTextBoxTests"
    xmlns:control="clr-namespace:AppControl;assembly=AppControl"
    d:DataContext="{d:DesignInstance Type=local:TestViewModel}">
	<Window.Resources>
		<local:StringFormatConverter x:Key="DecimalConverter" />
	</Window.Resources>
    <Grid>
        <Grid.RowDefinitions>
            <RowDefinition />
            <RowDefinition
                Height="auto" />
            <RowDefinition />
        </Grid.RowDefinitions>
        <Grid.ColumnDefinitions>
            <ColumnDefinition />
            <ColumnDefinition />
            <ColumnDefinition />
        </Grid.ColumnDefinitions>
        <StackPanel
            Grid.Row="1"
            Grid.Column="1">
            
            <Button
                Grid.Row="2"
                Margin="0 10"
                Grid.Column="1"
                Content="save" />
            <TextBox
                x:Name="DefaultTextBox"
					 Text="{Binding DefaultTextBox, Mode=TwoWay, UpdateSourceTrigger=PropertyChanged, ValidatesOnNotifyDataErrors=True}" />
			<TextBox x:Name="StringFormatTextBox"
					 Text="{Binding DefaultTextBox, Mode=TwoWay, UpdateSourceTrigger=LostFocus, ValidatesOnNotifyDataErrors=True,StringFormat=N2}" />
			<TextBox x:Name="ConverterTextBox"
					 Text="{Binding DefaultTextBox, Mode=TwoWay, UpdateSourceTrigger=LostFocus, ValidatesOnNotifyDataErrors=True,Converter={StaticResource DecimalConverter}}" />
		</StackPanel>
    </Grid>
</Window>

and that's the converter

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

 	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

Metadata

Metadata

Assignees

No one assigned

    Labels

    InvestigateRequires further investigation by the WPF team.

    Type

    No type

    Projects

    No projects

    Milestone

    No milestone

    Relationships

    None yet

    Development

    No branches or pull requests

    Issue actions