Skip to content

Commit 55acfe9

Browse files
github-actions[bot]StephaneDelcroixCopilot
authored
[release/10.0.1xx-sr5] [Regression] Fix TypedBinding nested property re-subscription (#34428) (#34450)
Backport of #34449 to release/10.0.1xx-sr5 /cc @StephaneDelcroix Co-authored-by: Stephane Delcroix <stephane@delcroix.org> Co-authored-by: Copilot <223556219+Copilot@users.noreply.github.com>
1 parent 2c4d4c6 commit 55acfe9

File tree

2 files changed

+85
-5
lines changed

2 files changed

+85
-5
lines changed

src/Controls/src/Core/TypedBinding.cs

Lines changed: 4 additions & 5 deletions
Original file line numberDiff line numberDiff line change
@@ -147,7 +147,6 @@ public TypedBinding(Func<TSource, (TProperty value, bool success)> getter, Actio
147147
List<WeakReference<Element>> _ancestryChain;
148148
bool _isBindingContextRelativeSource;
149149
BindingMode _cachedMode;
150-
bool _isSubscribed;
151150
bool _isTSource; // cached type check result
152151
object _cachedDefaultValue; // cached default value
153152
bool _hasDefaultValue;
@@ -289,7 +288,6 @@ internal override void Unapply(bool fromBindingContextChanged = false)
289288
if (_handlers != null)
290289
Unsubscribe();
291290

292-
_isSubscribed = false;
293291
_cachedMode = BindingMode.Default;
294292
_hasDefaultValue = false;
295293
_cachedDefaultValue = null;
@@ -332,11 +330,12 @@ internal void ApplyCore(object sourceObject, BindableObject target, BindableProp
332330

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

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

342341
if (needsGetter)

src/Controls/tests/Core.UnitTests/TypedBindingUnitTests.cs

Lines changed: 81 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -1773,6 +1773,87 @@ public string Title
17731773
}
17741774
}
17751775

1776+
[Fact]
1777+
//https://github.com/dotnet/maui/issues/34428
1778+
public void TypedBinding_NestedProperty_ResubscribesAfterNullIntermediateBecomesNonNull()
1779+
{
1780+
// Regression: when an intermediate object in the path starts as null and later becomes
1781+
// non-null, the binding must re-establish subscriptions to nested properties.
1782+
// Previously, the _isSubscribed flag prevented re-subscribing after the first Apply.
1783+
1784+
var vm = new ComplexMockViewModel
1785+
{
1786+
Model = null // Start with null intermediate
1787+
};
1788+
1789+
var property = BindableProperty.Create("Text", typeof(string), typeof(MockBindable), null);
1790+
1791+
var binding = new TypedBinding<ComplexMockViewModel, string>(
1792+
cvm => cvm.Model is { } m ? (m.Text, true) : (null, false),
1793+
(cvm, t) => { if (cvm.Model is { } m) m.Text = t; },
1794+
new[] {
1795+
new Tuple<Func<ComplexMockViewModel, object>, string>(cvm => cvm, "Model"),
1796+
new Tuple<Func<ComplexMockViewModel, object>, string>(cvm => cvm.Model, "Text")
1797+
})
1798+
{ Mode = BindingMode.OneWay };
1799+
1800+
var bindable = new MockBindable();
1801+
bindable.SetBinding(property, binding);
1802+
bindable.BindingContext = vm;
1803+
1804+
// Initially null model → binding returns null/default
1805+
Assert.Null(bindable.GetValue(property));
1806+
1807+
// Set Model to non-null → binding should pick up the value
1808+
vm.Model = new ComplexMockViewModel { Text = "Initial" };
1809+
Assert.Equal("Initial", (string)bindable.GetValue(property));
1810+
1811+
// Change nested property → binding MUST update (this was the regression)
1812+
vm.Model.Text = "Updated";
1813+
Assert.Equal("Updated", (string)bindable.GetValue(property));
1814+
}
1815+
1816+
[Fact]
1817+
//https://github.com/dotnet/maui/issues/34428
1818+
public void TypedBinding_NestedProperty_ResubscribesAfterIntermediateReplaced()
1819+
{
1820+
// When the intermediate object is replaced (non-null → different non-null object),
1821+
// the binding must switch subscriptions to the new object.
1822+
1823+
var child1 = new ComplexMockViewModel { Text = "Child1" };
1824+
var child2 = new ComplexMockViewModel { Text = "Child2" };
1825+
var vm = new ComplexMockViewModel { Model = child1 };
1826+
1827+
var property = BindableProperty.Create("Text", typeof(string), typeof(MockBindable), null);
1828+
1829+
var binding = new TypedBinding<ComplexMockViewModel, string>(
1830+
cvm => cvm.Model is { } m ? (m.Text, true) : (null, false),
1831+
(cvm, t) => { if (cvm.Model is { } m) m.Text = t; },
1832+
new[] {
1833+
new Tuple<Func<ComplexMockViewModel, object>, string>(cvm => cvm, "Model"),
1834+
new Tuple<Func<ComplexMockViewModel, object>, string>(cvm => cvm.Model, "Text")
1835+
})
1836+
{ Mode = BindingMode.OneWay };
1837+
1838+
var bindable = new MockBindable();
1839+
bindable.SetBinding(property, binding);
1840+
bindable.BindingContext = vm;
1841+
1842+
Assert.Equal("Child1", (string)bindable.GetValue(property));
1843+
1844+
// Replace intermediate with a different object
1845+
vm.Model = child2;
1846+
Assert.Equal("Child2", (string)bindable.GetValue(property));
1847+
1848+
// Changing the OLD intermediate should NOT fire the binding
1849+
child1.Text = "OldChildChanged";
1850+
Assert.Equal("Child2", (string)bindable.GetValue(property));
1851+
1852+
// Changing the NEW intermediate SHOULD fire the binding
1853+
child2.Text = "Child2Updated";
1854+
Assert.Equal("Child2Updated", (string)bindable.GetValue(property));
1855+
}
1856+
17761857
[Fact]
17771858
//https://github.com/xamarin/Microsoft.Maui.Controls/issues/3650
17781859
//https://github.com/xamarin/Microsoft.Maui.Controls/issues/3613

0 commit comments

Comments
 (0)