Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
9 changes: 4 additions & 5 deletions src/Controls/src/Core/TypedBinding.cs
Original file line number Diff line number Diff line change
Expand Up @@ -147,7 +147,6 @@ public TypedBinding(Func<TSource, (TProperty value, bool success)> getter, Actio
List<WeakReference<Element>> _ancestryChain;
bool _isBindingContextRelativeSource;
BindingMode _cachedMode;
bool _isSubscribed;
bool _isTSource; // cached type check result
object _cachedDefaultValue; // cached default value
bool _hasDefaultValue;
Expand Down Expand Up @@ -289,7 +288,6 @@ internal override void Unapply(bool fromBindingContextChanged = false)
if (_handlers != null)
Unsubscribe();

_isSubscribed = false;
_cachedMode = BindingMode.Default;
_hasDefaultValue = false;
_cachedDefaultValue = null;
Expand Down Expand Up @@ -332,11 +330,12 @@ internal void ApplyCore(object sourceObject, BindableObject target, BindableProp

var needsGetter = (mode == BindingMode.TwoWay && !fromTarget) || mode == BindingMode.OneWay || mode == BindingMode.OneTime;

// Only subscribe once per binding lifetime
if (!_isSubscribed && isTSource && (mode == BindingMode.OneWay || mode == BindingMode.TwoWay) && _handlers != null)
// Subscribe on every Apply so that intermediate objects that changed are re-subscribed.
// Subscribe() is idempotent: it diffs old vs new subscription targets and only
// updates what changed, so calling this repeatedly is safe.
if (isTSource && (mode == BindingMode.OneWay || mode == BindingMode.TwoWay) && _handlers != null)
{
Subscribe((TSource)sourceObject);
_isSubscribed = true;
}

if (needsGetter)
Expand Down
81 changes: 81 additions & 0 deletions src/Controls/tests/Core.UnitTests/TypedBindingUnitTests.cs
Original file line number Diff line number Diff line change
Expand Up @@ -1773,6 +1773,87 @@ public string Title
}
}

[Fact]
//https://github.com/dotnet/maui/issues/34428
public void TypedBinding_NestedProperty_ResubscribesAfterNullIntermediateBecomesNonNull()
{
// Regression: when an intermediate object in the path starts as null and later becomes
// non-null, the binding must re-establish subscriptions to nested properties.
// Previously, the _isSubscribed flag prevented re-subscribing after the first Apply.

var vm = new ComplexMockViewModel
{
Model = null // Start with null intermediate
};

var property = BindableProperty.Create("Text", typeof(string), typeof(MockBindable), null);

var binding = new TypedBinding<ComplexMockViewModel, string>(
cvm => cvm.Model is { } m ? (m.Text, true) : (null, false),
(cvm, t) => { if (cvm.Model is { } m) m.Text = t; },
new[] {
new Tuple<Func<ComplexMockViewModel, object>, string>(cvm => cvm, "Model"),
new Tuple<Func<ComplexMockViewModel, object>, string>(cvm => cvm.Model, "Text")
})
{ Mode = BindingMode.OneWay };

var bindable = new MockBindable();
bindable.SetBinding(property, binding);
bindable.BindingContext = vm;

// Initially null model → binding returns null/default
Assert.Null(bindable.GetValue(property));

// Set Model to non-null → binding should pick up the value
vm.Model = new ComplexMockViewModel { Text = "Initial" };
Assert.Equal("Initial", (string)bindable.GetValue(property));

// Change nested property → binding MUST update (this was the regression)
vm.Model.Text = "Updated";
Assert.Equal("Updated", (string)bindable.GetValue(property));
}

[Fact]
//https://github.com/dotnet/maui/issues/34428
public void TypedBinding_NestedProperty_ResubscribesAfterIntermediateReplaced()
{
// When the intermediate object is replaced (non-null → different non-null object),
// the binding must switch subscriptions to the new object.

var child1 = new ComplexMockViewModel { Text = "Child1" };
var child2 = new ComplexMockViewModel { Text = "Child2" };
var vm = new ComplexMockViewModel { Model = child1 };

var property = BindableProperty.Create("Text", typeof(string), typeof(MockBindable), null);

var binding = new TypedBinding<ComplexMockViewModel, string>(
cvm => cvm.Model is { } m ? (m.Text, true) : (null, false),
(cvm, t) => { if (cvm.Model is { } m) m.Text = t; },
new[] {
new Tuple<Func<ComplexMockViewModel, object>, string>(cvm => cvm, "Model"),
new Tuple<Func<ComplexMockViewModel, object>, string>(cvm => cvm.Model, "Text")
})
{ Mode = BindingMode.OneWay };

var bindable = new MockBindable();
bindable.SetBinding(property, binding);
bindable.BindingContext = vm;

Assert.Equal("Child1", (string)bindable.GetValue(property));

// Replace intermediate with a different object
vm.Model = child2;
Assert.Equal("Child2", (string)bindable.GetValue(property));

// Changing the OLD intermediate should NOT fire the binding
child1.Text = "OldChildChanged";
Assert.Equal("Child2", (string)bindable.GetValue(property));

// Changing the NEW intermediate SHOULD fire the binding
child2.Text = "Child2Updated";
Assert.Equal("Child2Updated", (string)bindable.GetValue(property));
}

[Fact]
//https://github.com/xamarin/Microsoft.Maui.Controls/issues/3650
//https://github.com/xamarin/Microsoft.Maui.Controls/issues/3613
Expand Down
Loading