From c3a5c496999432b9f7878026631d51de67ecbbde Mon Sep 17 00:00:00 2001 From: Nathan Nguyen Date: Mon, 20 Jul 2026 18:51:36 +1000 Subject: [PATCH] fix(binding): reapply cached source values during UpdateTarget (#21756) * Added failing test for #21746. * fix(binding): reapply cached source values during UpdateTarget UpdateTarget did not restore an uncommitted explicit two-way target value when the source still matched the binding expression's cached value. Equal-value suppression then prevented ValueStore from receiving the refresh.\n\nTrack the synchronous UpdateTarget rebuild and force publication only for that path, so the source value is reapplied without changing normal binding notification deduplication. The preceding regression test covers the observable behavior in both reflection and compiled binding paths. --------- Co-authored-by: grokys --- .../Data/Core/BindingExpression.cs | 20 +++++++++++--- ...dingExpressionTests.UpdateSourceTrigger.cs | 26 +++++++++++++++++++ 2 files changed, 42 insertions(+), 4 deletions(-) diff --git a/src/Avalonia.Base/Data/Core/BindingExpression.cs b/src/Avalonia.Base/Data/Core/BindingExpression.cs index 281e9f6b61..14c592b94c 100644 --- a/src/Avalonia.Base/Data/Core/BindingExpression.cs +++ b/src/Avalonia.Base/Data/Core/BindingExpression.cs @@ -31,6 +31,7 @@ internal class BindingExpression : UntypedBindingExpressionBase, IDescription, I private readonly List _nodes; private readonly TargetTypeConverter? _targetTypeConverter; private readonly UncommonFields? _uncommon; + private int _updateTargetDepth; private bool _shouldUpdateOneTimeBindingTarget; /// @@ -167,10 +168,19 @@ internal class BindingExpression : UntypedBindingExpressionBase, IDescription, I var source = _nodes[0].Source; - for (var i = 0; i < _nodes.Count; ++i) - _nodes[i].SetSource(AvaloniaProperty.UnsetValue, null); + ++_updateTargetDepth; + + try + { + for (var i = 0; i < _nodes.Count; ++i) + _nodes[i].SetSource(AvaloniaProperty.UnsetValue, null); - _nodes[0].SetSource(source, null); + _nodes[0].SetSource(source, null); + } + finally + { + --_updateTargetDepth; + } } /// @@ -206,7 +216,9 @@ internal class BindingExpression : UntypedBindingExpressionBase, IDescription, I new BindingError(dataValidationError, BindingErrorType.DataValidationError) : null; - var forceUpdate = _mode == BindingMode.OneWay; + // UpdateTarget must reapply the source value even if this expression already + // has it cached: a two-way target may contain an uncommitted local value. + var forceUpdate = _mode == BindingMode.OneWay || _updateTargetDepth > 0; ConvertAndPublishValue(value, error, forceUpdate); } } diff --git a/tests/Avalonia.Base.UnitTests/Data/Core/BindingExpressionTests.UpdateSourceTrigger.cs b/tests/Avalonia.Base.UnitTests/Data/Core/BindingExpressionTests.UpdateSourceTrigger.cs index 434a0c38ae..83d887e3a8 100644 --- a/tests/Avalonia.Base.UnitTests/Data/Core/BindingExpressionTests.UpdateSourceTrigger.cs +++ b/tests/Avalonia.Base.UnitTests/Data/Core/BindingExpressionTests.UpdateSourceTrigger.cs @@ -111,5 +111,31 @@ namespace Avalonia.Base.UnitTests.Data.Core Assert.Equal("bar", target.String); Assert.Equal("bar", data.StringValue); } + + [Fact] + public void TwoWay_Explicit_Should_Update_Target_On_Call_To_UpdateTarget() + { + var data = new ViewModel { StringValue = "foo" }; + var (target, expression) = CreateTargetAndExpression( + o => o.StringValue, + mode: BindingMode.TwoWay, + source: data, + updateSourceTrigger: UpdateSourceTrigger.Explicit); + + Assert.Equal("foo", target.String); + Assert.Equal("foo", data.StringValue); + + target.String = "bar"; + + Assert.Equal("bar", target.String); + Assert.Equal("foo", data.StringValue); + + // UpdateTarget forces a transfer from source to target, discarding the + // value set on the target. + expression.UpdateTarget(); + + Assert.Equal("foo", target.String); + Assert.Equal("foo", data.StringValue); + } } }