From 31d6979377892b81377bf4964038afae1858331c Mon Sep 17 00:00:00 2001 From: Steven Kirk Date: Mon, 31 Aug 2026 11:13:24 +0000 Subject: [PATCH] Fix data validation errors not showing for OneWayToSource bindings (#22103) * Add failing tests for OneWayToSource validation (#8235) Data validation errors from validation attributes (e.g. [MaxLength]) and INotifyDataErrorInfo are not displayed when binding with Mode=OneWayToSource. Add tests covering both the DataAnnotations and INDEI cases when writing to the source. Co-Authored-By: Claude Opus 4.8 Claude-Session: https://claude.ai/code/session_013gwdaykrFyoMZFeVmWMydb * Publish data validation errors for OneWayToSource bindings (#8235) In OneWayToSource mode, BindingExpression.OnNodeValueChanged discarded any data validation error produced when writing the value to the source, so validation attributes and INotifyDataErrorInfo errors were never displayed. Publish (and clear) the error via UnchangedValue so that the error state reaches the target without pushing a value back to it, preserving the one-way-to-source semantics. Co-Authored-By: Claude Opus 4.8 Claude-Session: https://claude.ai/code/session_013gwdaykrFyoMZFeVmWMydb * Added passing test for #15378. Co-Authored-By: Claude Opus 4.8 --------- Co-authored-by: Claude Opus 4.8 --- .../Data/Core/BindingExpression.cs | 12 ++ .../BindingExpressionTests.DataValidation.cs | 105 ++++++++++++++++++ 2 files changed, 117 insertions(+) diff --git a/src/Avalonia.Base/Data/Core/BindingExpression.cs b/src/Avalonia.Base/Data/Core/BindingExpression.cs index d36e2d1d1b..31b8338dd6 100644 --- a/src/Avalonia.Base/Data/Core/BindingExpression.cs +++ b/src/Avalonia.Base/Data/Core/BindingExpression.cs @@ -221,6 +221,18 @@ internal class BindingExpression : UntypedBindingExpressionBase, IDescription, I var forceUpdate = _mode == BindingMode.OneWay || _updateTargetDepth > 0; ConvertAndPublishValue(value, error, forceUpdate); } + else if (IsDataValidationEnabled) + { + // In OneWayToSource mode the value must not be published to the target, but any + // data validation error produced when writing to the source still has to be + // published (or cleared) so that it can be displayed (issue #8235). Publishing + // UnchangedValue leaves the target's value untouched. + var error = dataValidationError is not null ? + new BindingError(dataValidationError, BindingErrorType.DataValidationError) : + null; + + PublishValue(UnchangedValue, error); + } } else if (_mode == BindingMode.OneWayToSource && nodeIndex == _nodes.Count - 2 && value is not null) { diff --git a/tests/Avalonia.Base.UnitTests/Data/Core/BindingExpressionTests.DataValidation.cs b/tests/Avalonia.Base.UnitTests/Data/Core/BindingExpressionTests.DataValidation.cs index 1575bf6ad3..9889a380cc 100644 --- a/tests/Avalonia.Base.UnitTests/Data/Core/BindingExpressionTests.DataValidation.cs +++ b/tests/Avalonia.Base.UnitTests/Data/Core/BindingExpressionTests.DataValidation.cs @@ -271,6 +271,102 @@ public partial class BindingExpressionTests GC.KeepAlive(data); } + [Fact] + public void Indei_Validation_Updates_Data_Validation_When_Writing_To_Source_OneWayToSource() + { + // Issue #8235: validation errors should be displayed for OneWayToSource bindings. + var data = new IndeiViewModel(); + var target = CreateTargetWithSource( + data, + o => o.MustBePositive, + enableDataValidation: true, + mode: BindingMode.OneWayToSource); + + Assert.Equal(0, data.MustBePositive); + AssertNoError(target, TargetClass.IntProperty); + + target.Int = 5; + + Assert.Equal(5, data.MustBePositive); + AssertNoError(target, TargetClass.IntProperty); + + target.Int = -5; + + Assert.Equal(-5, data.MustBePositive); + AssertBindingError(target, TargetClass.IntProperty, new DataValidationException("Must be positive"), BindingErrorType.DataValidationError); + + target.Int = 5; + + Assert.Equal(5, data.MustBePositive); + AssertNoError(target, TargetClass.IntProperty); + + GC.KeepAlive(data); + } + + [Fact] + public void DataAnnotations_Validation_Updates_Data_Validation_When_Writing_To_Source_OneWayToSource() + { + // Issue #8235: validation attributes should be displayed for OneWayToSource bindings. + if (!BindingPlugins.DataValidators.Any(x => x is DataAnnotationsValidationPlugin)) + BindingPlugins.DataValidators.Insert(0, new DataAnnotationsValidationPlugin()); + + var data = new DataAnnotationsViewModel(); + var target = CreateTargetWithSource( + data, + o => o.MaxLengthString, + enableDataValidation: true, + mode: BindingMode.OneWayToSource); + + target.String = "1234"; + + Assert.Equal("1234", data.MaxLengthString); + AssertNoError(target, TargetClass.StringProperty); + + target.String = "123456"; + + Assert.Equal("123456", data.MaxLengthString); + AssertBindingError( + target, + TargetClass.StringProperty, + new DataValidationException("Too long!"), + BindingErrorType.DataValidationError); + + GC.KeepAlive(data); + } + + [Fact] + public void Conversion_Error_Is_Cleared_When_Value_Becomes_Valid_OneWayToSource() + { + // Issue #15378. + var data = new ViewModel(); + var target = CreateTargetWithSource( + data, + o => o.DoubleValue, + targetProperty: TargetClass.ObjectProperty, + enableDataValidation: true, + mode: BindingMode.OneWayToSource); + + target.Object = 5.0; + + Assert.Equal(5.0, data.DoubleValue); + AssertNoError(target, TargetClass.ObjectProperty); + + target.Object = null; + + AssertBindingError( + target, + TargetClass.ObjectProperty, + new InvalidCastException("Could not convert '(null)' (null) to System.Double."), + BindingErrorType.DataValidationError); + + target.Object = 5.0; + + Assert.Equal(5.0, data.DoubleValue); + AssertNoError(target, TargetClass.ObjectProperty); + + GC.KeepAlive(data); + } + [Fact] public void Does_Not_Subscribe_To_Indei_Of_Intermediate_Object_In_Chain() { @@ -445,6 +541,15 @@ public partial class BindingExpressionTests get { return _requiredString; } set { _requiredString = value; RaisePropertyChanged(); } } + + private string? _maxLengthString; + + [MaxLength(5, ErrorMessage = "Too long!")] + public string? MaxLengthString + { + get { return _maxLengthString; } + set { _maxLengthString = value; RaisePropertyChanged(); } + } } private class IndeiDataAnnotationsViewModel : IndeiBase