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)
Copy link

Copilot AI Mar 12, 2026

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Subscribe() is now called on every ApplyCore, including when fromTarget == true for BindingMode.TwoWay (i.e., target-driven updates). That means each UI-originated change will re-run the handler chain and subscription diffing, which can be a measurable regression compared to the _isSubscribed optimization, even though subscriptions likely don’t need to change on target updates. Consider gating the Subscribe(...) call to only run when fromTarget is false (or when needsGetter is true), so intermediate re-subscription still happens for source/context changes without adding per-keystroke overhead for TwoWay bindings.

Suggested change
if (isTSource && (mode == BindingMode.OneWay || mode == BindingMode.TwoWay) && _handlers != null)
if (isTSource && (mode == BindingMode.OneWay || mode == BindingMode.TwoWay) && _handlers != null && (!fromTarget || needsGetter))

Copilot uses AI. Check for mistakes.
{
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