diff --git a/src/Avalonia.Base/Data/Core/BindingExpression.cs b/src/Avalonia.Base/Data/Core/BindingExpression.cs index 635fa3e0960..566a3b6d71a 100644 --- a/src/Avalonia.Base/Data/Core/BindingExpression.cs +++ b/src/Avalonia.Base/Data/Core/BindingExpression.cs @@ -30,6 +30,7 @@ internal partial class BindingExpression : UntypedBindingExpressionBase, IDescri private readonly List _nodes; private readonly TargetTypeConverter? _targetTypeConverter; private readonly UncommonFields? _uncommon; + private bool _shouldUpdateOneTimeBindingTarget; /// /// Initializes a new instance of the class. @@ -83,6 +84,7 @@ public BindingExpression( _mode = mode; _nodes = nodes ?? s_emptyExpressionNodes; _targetTypeConverter = targetTypeConverter; + _shouldUpdateOneTimeBindingTarget = _mode == BindingMode.OneTime; if (converter is not null || converterCulture is not null || @@ -231,10 +233,14 @@ internal void OnNodeValueChanged(int nodeIndex, object? value, Exception? dataVa if (nodeIndex == _nodes.Count - 1) { - // If the binding source is a data context without any path and is currently null, treat it as an invalid - // value. This allows bindings to DataContext and DataContext.Property to share the same behavior. - if (value is null && _nodes[nodeIndex] is DataContextNodeBase) - value = AvaloniaProperty.UnsetValue; + if (_mode == BindingMode.OneTime) + { + // In OneTime mode, only changing the data context updates the binding. + if (!_shouldUpdateOneTimeBindingTarget && _nodes[nodeIndex] is not DataContextNodeBase) + return; + + _shouldUpdateOneTimeBindingTarget = false; + } // The leaf node has changed. If the binding mode is not OneWayToSource, publish the // value to the target. @@ -245,10 +251,6 @@ internal void OnNodeValueChanged(int nodeIndex, object? value, Exception? dataVa null; ConvertAndPublishValue(value, error); } - - // If the binding mode is OneTime, then stop the binding if a valid value was published. - if (_mode == BindingMode.OneTime && GetValue() != AvaloniaProperty.UnsetValue) - Stop(); } else if (_mode == BindingMode.OneWayToSource && nodeIndex == _nodes.Count - 2 && value is not null) { @@ -260,6 +262,9 @@ internal void OnNodeValueChanged(int nodeIndex, object? value, Exception? dataVa } else { + if (_mode == BindingMode.OneTime && _nodes[nodeIndex] is DataContextNodeBase) + _shouldUpdateOneTimeBindingTarget = true; + _nodes[nodeIndex + 1].SetSource(value, dataValidationError); } } diff --git a/tests/Avalonia.Base.UnitTests/Data/Core/BindingExpressionTests.GetValue.cs b/tests/Avalonia.Base.UnitTests/Data/Core/BindingExpressionTests.GetValue.cs index f34b5dc132a..789df31a779 100644 --- a/tests/Avalonia.Base.UnitTests/Data/Core/BindingExpressionTests.GetValue.cs +++ b/tests/Avalonia.Base.UnitTests/Data/Core/BindingExpressionTests.GetValue.cs @@ -95,13 +95,13 @@ public void TargetNullValue_Should_Be_Used_When_Source_String_Is_Null() } [Fact] - public void TargetNullValue_Should_Not_Be_Used_When_Source_Is_Data_Context_And_Null() + public void TargetNullValue_Should_Be_Used_When_Source_Is_Data_Context_And_Null() { var target = CreateTarget( o => o, targetNullValue: "bar"); - Assert.Equal(null, target.String); + Assert.Equal("bar", target.String); } [Fact] @@ -151,4 +151,16 @@ public void Should_Not_Pass_UnsetValue_To_Converter_Until_First_Value_Produced() Assert.Equal("fooBar", target.String); } + + [Fact] + public void Should_Use_Converter_For_Null_DataContext_Without_Path() + { + var converter = new PrefixConverter(); + var target = CreateTarget( + o => o, + converter: converter, + converterParameter: "foo"); + + Assert.Equal("foo", target.String); + } } diff --git a/tests/Avalonia.Base.UnitTests/Data/Core/BindingExpressionTests.Mode.cs b/tests/Avalonia.Base.UnitTests/Data/Core/BindingExpressionTests.Mode.cs index 0c5e23aa874..cf80b863860 100644 --- a/tests/Avalonia.Base.UnitTests/Data/Core/BindingExpressionTests.Mode.cs +++ b/tests/Avalonia.Base.UnitTests/Data/Core/BindingExpressionTests.Mode.cs @@ -1,6 +1,5 @@ using Avalonia.Data; using Xunit; -using Xunit.Sdk; #nullable enable @@ -9,21 +8,64 @@ namespace Avalonia.Base.UnitTests.Data.Core; public partial class BindingExpressionTests { [Fact] - public void OneTime_Binding_Sets_Target_Only_Once() + public void OneTime_Binding_Sets_Target_Only_Once_If_Data_Context_Does_Not_Change() { - var data = new ViewModel { StringValue = "foo" }; - var target = CreateTargetWithSource(data, x => x.StringValue, mode: BindingMode.OneTime); + var data = new ViewModel { Next = new ViewModel { StringValue = "foo" } }; + var target = CreateTarget(x => x.Next!.StringValue, mode: BindingMode.OneTime); + target.DataContext = data; Assert.Equal("foo", target.String); - data.StringValue = "bar"; + data.Next!.StringValue = "bar"; + Assert.Equal("foo", target.String); + + data.Next = new ViewModel { StringValue = "baz" }; + Assert.Equal("foo", target.String); + } + + [Fact] + public void OneTime_Binding_With_Simple_Path_Sets_Target_When_Data_Context_Changes() + { + var data1 = new ViewModel { StringValue = "foo" }; + var target = CreateTarget(x => x.StringValue, mode: BindingMode.OneTime); + target.DataContext = data1; + Assert.Equal("foo", target.String); + + var data2 = new ViewModel { StringValue = "bar" }; + target.DataContext = data2; + Assert.Equal("bar", target.String); + } + + [Fact] + public void OneTime_Binding_With_Complex_Path_Sets_Target_When_Data_Context_Changes() + { + var data1 = new ViewModel { Next = new ViewModel { StringValue = "foo" } }; + var target = CreateTarget(x => x.Next!.StringValue, mode: BindingMode.OneTime); + target.DataContext = data1; + + Assert.Equal("foo", target.String); + + var data2 = new ViewModel { Next = new ViewModel { StringValue = "bar" } }; + target.DataContext = data2; + Assert.Equal("bar", target.String); + } + + [Fact] + public void OneTime_Binding_Without_Path_Sets_Target_When_Data_Context_Changes() + { + var target = CreateTarget(x => x, mode: BindingMode.OneTime); + target.DataContext = "foo"; + + Assert.Equal("foo", target.String); + + target.DataContext = "bar"; + Assert.Equal("bar", target.String); } [Fact] public void OneTime_Binding_Waits_For_DataContext() { - var data = new ViewModel { StringValue = "foo" }; var target = CreateTarget( x => x.StringValue, mode: BindingMode.OneTime); diff --git a/tests/Avalonia.Markup.UnitTests/Data/BindingTests.cs b/tests/Avalonia.Markup.UnitTests/Data/BindingTests.cs index 9fb52fee388..6b9b2092089 100644 --- a/tests/Avalonia.Markup.UnitTests/Data/BindingTests.cs +++ b/tests/Avalonia.Markup.UnitTests/Data/BindingTests.cs @@ -151,21 +151,6 @@ public void OneTime_Binding_Should_Be_Set_Up() Assert.Equal("bar", source.Foo); } - [Fact] - public void OneTime_Binding_Releases_Subscription_If_DataContext_Set_Later() - { - var target = new TextBlock(); - var source = new Source { Foo = "foo" }; - - target.Bind(TextBlock.TextProperty, new Binding("Foo", BindingMode.OneTime)); - target.DataContext = source; - - // Forces WeakEvent compact - Dispatcher.UIThread.RunJobs(); - - Assert.Equal(0, source.SubscriberCount); - } - [Fact] public void OneWayToSource_Binding_Should_Be_Set_Up() {