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
71 changes: 63 additions & 8 deletions src/Controls/src/Core/TypedBinding.cs
Original file line number Diff line number Diff line change
Expand Up @@ -146,6 +146,12 @@ public TypedBinding(Func<TSource, (TProperty value, bool success)> getter, Actio
BindableProperty _targetProperty;
List<WeakReference<Element>> _ancestryChain;
bool _isBindingContextRelativeSource;
BindingMode _cachedMode;
bool _isSubscribed;
bool _isTSource; // cached type check result
object _cachedDefaultValue; // cached default value
bool _hasDefaultValue;
bool _skipConvert; // true when TProperty matches property.ReturnType

// Applies the binding to a previously set source and target.
internal override void Apply(bool fromTarget = false)
Expand Down Expand Up @@ -283,6 +289,13 @@ internal override void Unapply(bool fromBindingContextChanged = false)
if (_handlers != null)
Unsubscribe();

_isSubscribed = false;
_cachedMode = BindingMode.Default;
_hasDefaultValue = false;
_cachedDefaultValue = null;
_skipConvert = false;
Copy link

Copilot AI Jan 21, 2026

Choose a reason for hiding this comment

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

The field _isTSource should be reset in Unapply() along with the other cached values. Add _isTSource = false; to properly reset the cached type check result.

Suggested change
_skipConvert = false;
_skipConvert = false;
_isTSource = false;

Copilot uses AI. Check for mistakes.
_isTSource = false;

#if (!DO_NOT_CHECK_FOR_BINDING_REUSE)
_weakSource.SetTarget(null);
_weakTarget.SetTarget(null);
Expand All @@ -294,24 +307,58 @@ internal override void Unapply(bool fromBindingContextChanged = false)
// ApplyCore 100000 (w/o INPC, w/o unnapply) : 20ms.
internal void ApplyCore(object sourceObject, BindableObject target, BindableProperty property, bool fromTarget, SetterSpecificity specificity)
{
var isTSource = sourceObject is TSource;
if (!isTSource && sourceObject is not null)
// Use cached type check after first apply (source type doesn't change)
var isTSource = _isTSource;
if (!_hasDefaultValue)
{
isTSource = sourceObject is TSource;
_isTSource = isTSource;
if (!isTSource && sourceObject is not null)
{
BindingDiagnostics.SendBindingFailure(this, "Binding", $"Mismatch between the specified x:DataType ({typeof(TSource)}) and the current binding context ({sourceObject.GetType()}).");
}
}
Comment on lines +310 to +320
Copy link

Copilot AI Jan 21, 2026

Choose a reason for hiding this comment

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

The cached type check _isTSource is never reset in Unapply() (line 292-296 resets other cached values but not _isTSource). This causes incorrect behavior when ApplyCore is called with a different source object.

For example, line 520 calls ApplyCore(null, ...) directly without calling Unapply() first. In this case, the condition if (!_hasDefaultValue) on line 311 evaluates to false (since default value was already cached), so the type check is skipped entirely and the stale _isTSource value is used. This will cause incorrect behavior when the source changes from a valid TSource object to null.

Copilot uses AI. Check for mistakes.

// Use cached mode if available, otherwise compute and cache it
var mode = _cachedMode;
if (mode == BindingMode.Default)
{
BindingDiagnostics.SendBindingFailure(this, "Binding", $"Mismatch between the specified x:DataType ({typeof(TSource)}) and the current binding context ({sourceObject.GetType()}).");
mode = this.GetRealizedMode(property);
_cachedMode = mode;
}

var mode = this.GetRealizedMode(property);
if ((mode == BindingMode.OneWay || mode == BindingMode.OneTime) && fromTarget)
return;

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

if (isTSource && (mode == BindingMode.OneWay || mode == BindingMode.TwoWay) && _handlers != null)
// Only subscribe once per binding lifetime
if (!_isSubscribed && isTSource && (mode == BindingMode.OneWay || mode == BindingMode.TwoWay) && _handlers != null)
{
Subscribe((TSource)sourceObject);
_isSubscribed = true;
}

if (needsGetter)
{
var value = FallbackValue ?? property.GetDefaultValue(target);
// Use cached default value
object value;
if (FallbackValue != null)
{
value = FallbackValue;
}
else if (_hasDefaultValue)
{
value = _cachedDefaultValue;
}
else
{
value = property.GetDefaultValue(target);
_cachedDefaultValue = value;
_skipConvert = typeof(TProperty) == property.ReturnType && Converter == null;
_hasDefaultValue = true;
}

if (isTSource)
{
try
Expand All @@ -328,7 +375,8 @@ internal void ApplyCore(object sourceObject, BindableObject target, BindableProp
BindingDiagnostics.SendBindingFailure(this, sourceObject, target, property, "Binding", $"Exception thrown from getter: {ex.Message}");
}
}
if (!BindingExpressionHelper.TryConvert(ref value, property, property.ReturnType, true))
// Skip TryConvert when types match and no converter
if (!_skipConvert && !BindingExpressionHelper.TryConvert(ref value, property, property.ReturnType, true))
{
BindingDiagnostics.SendBindingFailure(this, sourceObject, target, property, "Binding", BindingExpression.CannotConvertTypeErrorMessage, value, property.ReturnType);
return;
Expand Down Expand Up @@ -520,15 +568,22 @@ public PropertyChangedProxy(Func<TSource, object> partGetter, string propertyNam
Listener = new BindingExpression.WeakPropertyChangedProxy();
//avoid GC collection, keep a ref to the OnPropertyChanged handler
handler = new PropertyChangedEventHandler(OnPropertyChanged);
//cache the Apply delegate to avoid allocation on every property change
_applyAction = () => _binding.Apply(false);
}

readonly Action _applyAction;

void OnPropertyChanged(object sender, PropertyChangedEventArgs e)
{
if (!string.IsNullOrEmpty(e.PropertyName) && string.CompareOrdinal(e.PropertyName, PropertyName) != 0)
return;

// Note: sender is typically a ViewModel (INotifyPropertyChanged), not a BindableObject,
// so (sender as BindableObject)?.Dispatcher usually returns null.
// DispatchIfRequired handles null dispatcher via EnsureDispatcher fallback.
IDispatcher dispatcher = (sender as BindableObject)?.Dispatcher;
dispatcher.DispatchIfRequired(() => _binding.Apply(false));
dispatcher.DispatchIfRequired(_applyAction);
}
}

Expand Down
120 changes: 120 additions & 0 deletions src/Core/tests/Benchmarks/Benchmarks/BindingComparisonBenchmarker.cs
Original file line number Diff line number Diff line change
@@ -0,0 +1,120 @@
using System;
using System.ComponentModel;
using System.Runtime.CompilerServices;
using BenchmarkDotNet.Attributes;
using Microsoft.Maui.Controls;
using Microsoft.Maui.Controls.Internals;
using Microsoft.Maui.Dispatching;

namespace Microsoft.Maui.Benchmarks
{
[MemoryDiagnoser]
public class BindingComparisonBenchmarker
{
public class NotifyingObject : INotifyPropertyChanged
{
string _name = "Initial";
public string Name
{
get => _name;
set { _name = value; OnPropertyChanged(); }
}

public event PropertyChangedEventHandler PropertyChanged;
void OnPropertyChanged([CallerMemberName] string name = null) =>
PropertyChanged?.Invoke(this, new PropertyChangedEventArgs(name));
}

public class MyObject : BindableObject
{
public static readonly BindableProperty NameProperty =
BindableProperty.Create(nameof(Name), typeof(string), typeof(MyObject));

public string Name
{
get => (string)GetValue(NameProperty);
set => SetValue(NameProperty, value);
}
}

class BenchmarkDispatcher : IDispatcher
{
public bool IsDispatchRequired => false;
public int ManagedThreadId => Environment.CurrentManagedThreadId;
public bool Dispatch(Action action) { action(); return true; }
public bool DispatchDelayed(TimeSpan delay, Action action) { action(); return true; }
public IDispatcherTimer CreateTimer() => throw new NotImplementedException();
}

class BenchmarkDispatcherProvider : IDispatcherProvider
{
readonly BenchmarkDispatcher _dispatcher = new();
public IDispatcher GetForCurrentThread() => _dispatcher;
}

NotifyingObject SourceBinding;
NotifyingObject SourceTypedBinding;
NotifyingObject SourceSourceGenBinding;
MyObject TargetSetValue;
MyObject TargetBinding;
MyObject TargetTypedBinding;
MyObject TargetSourceGenBinding;

[GlobalSetup]
public void Setup()
{
DispatcherProvider.SetCurrent(new BenchmarkDispatcherProvider());

TargetSetValue = new MyObject();

SourceBinding = new NotifyingObject { Name = "Initial" };
TargetBinding = new MyObject();
TargetBinding.SetBinding(MyObject.NameProperty, new Microsoft.Maui.Controls.Binding("Name", source: SourceBinding));

SourceTypedBinding = new NotifyingObject { Name = "Initial" };
TargetTypedBinding = new MyObject();
TargetTypedBinding.SetBinding(MyObject.NameProperty, new TypedBinding<NotifyingObject, string>(
o => (o.Name, true),
null,
handlers: new[] { Tuple.Create<Func<NotifyingObject, object>, string>(o => o, "Name") }
)
{ Source = SourceTypedBinding });

SourceSourceGenBinding = new NotifyingObject { Name = "Initial" };
TargetSourceGenBinding = new MyObject();
TargetSourceGenBinding.SetBinding(MyObject.NameProperty, static (NotifyingObject o) => o.Name, source: SourceSourceGenBinding, mode: BindingMode.OneWay);
}

[GlobalCleanup]
public void Cleanup()
{
DispatcherProvider.SetCurrent(null);
}

static int _counter;

[Benchmark(Baseline = true)]
public void SetValue()
{
TargetSetValue.SetValue(MyObject.NameProperty, (++_counter).ToString());
}

[Benchmark]
public void Binding()
{
SourceBinding.Name = (++_counter).ToString();
}

[Benchmark]
public void TypedBinding()
{
SourceTypedBinding.Name = (++_counter).ToString();
}

[Benchmark]
public void SourceGeneratedBinding()
{
SourceSourceGenBinding.Name = (++_counter).ToString();
}
}
}
Loading