Browse Source

Add typed binding expressions (#21552)

* Update ncrunch config.

* Initial groundwork for typed binding expressions.

- Moved a bunch of stuff from `UntypedBindingExpressionBase` to `BindingExpressionBase`
- Make various APIs accept `BindingExpressionBase` instead of `UntypedBindingExpressionBase`
- Added a typed `IPropertyInfo`
- Added a method on the `CompiledBindingPathBuilder` to build typed property accessors
- Initial basic implementation of `TypedBindingExpression`

* Track values in typed binding expression.

* Support binding mode in typed binding expression.

* Add typed binding expression benchmarks.

Add Setup/Values benchmarks comparing the typed binding expression
against the untyped CompiledBinding (as emitted by the XAML compiler
today) and the reflection-based Binding.

Writing the benchmarks surfaced that TypedBindingExpression never
detached: it had no Dispose override, so disposing a binding (or
rebinding the same property) leaked its PropertyChanged subscriptions
and left it registered in the value store. Wire up disposal mirroring
UntypedBindingExpressionBase: stop, detach handlers, remove from the
value store and frame. This required widening
IBindingExpressionSink.OnCompleted from UntypedBindingExpressionBase to
BindingExpressionBase. Also fix a nullable warning that broke the
Release build.

Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
@

* Don't box value in IBindingExpressionSink.OnChanged.

Instead notify the sink of what's changed and let the sink read the value boxed or unboxed.

* Move `IValueEntry` to `BindingExpressionBase`

* Fall back to BindingExpression.

Make `TypedPropertyElement` derive from `PropertyElement` and fall back to creating a standard boxing `BindingExpression` if any of the requirements for a typed binding expression are not met.

* Don't use typed expressions for DataContext.

* Emit typed binding expression from XAML compiler.

When a compiled binding path is shape-eligible (single CLR property on a
reference-type source, no transforms, instance getter) the compiler now
emits a call to the typed CompiledBindingPathBuilder.Property<TSource,
TResult> overload, producing a TypedPropertyElement that the runtime
turns into a non-boxing TypedBindingExpression<TSource, TValue>. Other
shapes continue to use the existing untyped emission path, and runtime
fallback to BindingExpression still kicks in when modifiers (Source,
Converter, StringFormat, etc.) make the typed expression unsuitable.

Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>

* Add failing tests for TwoWay typed binding source echo.

TypedBindingExpression writes the source value back to the source both on
attach and whenever the source raises a change: pushing the value to the
target re-enters OnTargetPropertyChanged, which (in TwoWay mode) calls
WriteValueToSource with the value that just arrived from the source.

Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>

* Don't echo source value back to source in TwoWay typed binding.

When TypedBindingExpression pushed the source value to the target, the
resulting target PropertyChanged re-entered OnTargetPropertyChanged which,
in TwoWay mode, wrote the value straight back to the source - a redundant
round-trip on every attach and every source-originated change.

Guard the source->target push with a flag and skip WriteValueToSource while
it is set, so only genuine target changes are written back to the source.

Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>

* Address typed binding code-review follow-ups.

- Reset _isRunning when a typed binding is unsubscribed so the expression
  can be restarted (and re-subscribe to its source) if the value store
  reactivates the entry later, matching UntypedBindingExpressionBase.Stop().
- Remove a stray `using static PropertySetSnapshot` import.
- Extract the duplicated IValueEntry value-unwrapping logic (shared by
  EffectiveValue<T> and DirectPropertyBase) into IValueEntry.TryGetValue.
- Drop the unreachable UpdateSourceTrigger NotSupportedException in
  CreateTypedExpression; CanUseTypedBindingExpression already constrains it.

Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>

* Fix TypedBinding_Values benchmark build error.

The typed binding path was built with a single-argument `Property(propertyInfo)`
call, which has no matching overload, so the benchmark project did not compile.
Use the same typed three-argument overload as TypedBinding_Setup so the benchmark
exercises the TypedPropertyElement path.

Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>

* Skip typed binding expression for data-validation properties.

TypedBindingExpression does not support data validation, but the eligibility
check didn't account for it, so a directly-assignable single-property DataContext
binding to a validation-enabled target (e.g. TextBox.Text, NumericUpDown.Value)
would take the typed path and silently drop validation errors.

Fall back to the untyped BindingExpression when the target property enables data
validation. Data validation support in the typed expression can be added as a
follow-up.

Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>

* Clarify that data validation is not supported.

* Address typed binding review comments.

- React to null/empty PropertyChanged.PropertyName ("all properties
  changed") in the typed expression, matching the untyped path.
- Swallow source getter exceptions raised during PropertyChanged.
- Fall back to the untyped path for non-StyledElement targets, read-only
  sources and wider target types in TwoWay/OneWayToSource modes.
- Fix a stray space in an exception message.

Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>

* Share TypedBindingExpression.Attach validation across instantiations.

Factor the validation logic (most notably the exception string
formatting) out of the generic Attach method into a non-generic static
helper. It only uses typeof(TValue), not TValue, so sharing it avoids
duplicating the code per generic instantiation, a meaningful NativeAOT
size saving (~3.3 KB => ~2.5 KB per instantiation).

Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>

* Update API suppressions.

These APIs are in a `[PrivateApi]`.

* Use cached boolean boxes in typed binding expression.

Merging main brought in the cached boxed booleans emitted for compiled
binding property getters (#21065), but bindings that now take the typed
binding expression path don't go through that getter: the value is boxed
in TypedBindingExpression.GetUntypedValue when the target property isn't
strongly typed (e.g. binding a bool to TextBlock.Tag). Box booleans via a
shared cache there too, restoring the no-allocation-per-read behaviour.

Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01QkxawATDHa6rbPaDmPCLHZ

* Extract the whole TypedBindingExpression.Attach body into AttachCore.

3198196b4c only factored out the validation, leaving the field
assignments in the generic method. As MrJul pointed out, none of those
assignments depend on TSource or TValue either, so the entire body can
move into a method that doesn't reference the generic parameters and be
shared across instantiations rather than duplicated per instantiation.

Attach is now just a forwarder passing typeof(TValue) as a Type, and
AttachCore reads _sink/TargetProperty directly instead of having them
threaded through as parameters, which reads better than the previous
split.

Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01QkxawATDHa6rbPaDmPCLHZ

* Move the method alongside the other privates.

* Add failing tests for typed binding expression breaking changes.

Each test binds the same property twice: once via a path which produces a
TypedBindingExpression and once via a path which produces an untyped
BindingExpression. The assertions describe the untyped behaviour, so the
typed cases fail where the typed expression diverges:

- A binding with no value (null or incompatible DataContext) publishes the
  target property's default value at the binding's priority instead of not
  contributing a value, overriding style setters and breaking inheritance.
- Writing a value of another type, or null, to an object-typed target
  property throws from OnTargetPropertyChanged.
- No binding error is logged when the DataContext is of the wrong type.
- A throwing source getter leaves the stale value in place.

Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01B66teLaFQBr9GJe4a1aTZm

* Fix typed binding expression divergences from the untyped path.

- Don't notify when the expression has no value and had none before: doing
  so pushed the target property's default value at the binding's priority,
  overriding style setters and breaking property inheritance.
- Read the target property's new value defensively. The binding value type
  only needs to be assignable to the target property type, so the property
  can hold a value which isn't a TValue; casting it threw out of the caller's
  SetValue.
- Log a binding error when the DataContext can't be converted to the source
  type, and when the source getter throws. The getter also now clears the
  value rather than leaving the stale one in place.

Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01B66teLaFQBr9GJe4a1aTZm

* Pass correct priority to child bindings.

The priority here doesn't actually have any effect as the binding isn't in a value store, but there's no reason to not use the correct one.

* Use the typed overload.

* Set initial priority to default priority.

Makes `TypedBindingExpressionBase` and `UntypedBindingExpressionBase` have the same behavior here.

* Remove unused vars.

* We now have a typed value here.

* Publish typed binding values on the UI thread.

The source's PropertyChanged event can be raised on any thread, so marshal
the notification to the UI thread as UntypedBindingExpressionBase does.

Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01B1ZwmCJkThPSeViLnRxoYs

* Share the property info cache lookup.

Emit and EmitTyped had identical cache lookups, differing only in which
dictionary they searched. Also delay constructing the generic types in
EmitTyped until a cache miss, as they aren't needed on a hit.

Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01B1ZwmCJkThPSeViLnRxoYs

* Share the property accessor delegate emission.

EmitFunc was duplicated as a local function in both Emit and EmitTyped.

Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01B1ZwmCJkThPSeViLnRxoYs

---------

Co-authored-by: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
pull/22083/head
Steven Kirk 1 month ago
committed by GitHub
parent
commit
15a9c99fc6
No known key found for this signature in database GPG Key ID: B5690EEEBB952194
  1. 24
      api/Avalonia.nupkg.xml
  2. 4
      src/Avalonia.Base/AvaloniaObject.cs
  3. 4
      src/Avalonia.Base/AvaloniaProperty.cs
  4. 79
      src/Avalonia.Base/Data/BindingExpressionBase.cs
  5. 103
      src/Avalonia.Base/Data/CompiledBinding.cs
  6. 59
      src/Avalonia.Base/Data/CompiledBindingPath.cs
  7. 40
      src/Avalonia.Base/Data/Core/ClrPropertyInfo`2.cs
  8. 20
      src/Avalonia.Base/Data/Core/IBindingExpressionSink.cs
  9. 12
      src/Avalonia.Base/Data/Core/IPropertyInfo`2.cs
  10. 17
      src/Avalonia.Base/Data/Core/MultiBindingExpression.cs
  11. 417
      src/Avalonia.Base/Data/Core/TypedBindingExpression.cs
  12. 153
      src/Avalonia.Base/Data/Core/UntypedBindingExpressionBase.cs
  13. 10
      src/Avalonia.Base/DirectPropertyBase.cs
  14. 14
      src/Avalonia.Base/PropertyStore/EffectiveValue.cs
  15. 18
      src/Avalonia.Base/PropertyStore/EffectiveValue`1.cs
  16. 19
      src/Avalonia.Base/PropertyStore/IValueEntry.cs
  17. 2
      src/Avalonia.Base/PropertyStore/ImmediateValueFrame.cs
  18. 51
      src/Avalonia.Base/PropertyStore/ValueStore.cs
  19. 17
      src/Avalonia.Base/Utilities/BooleanBoxes.cs
  20. 4
      src/Markup/Avalonia.Markup.Xaml.Loader/CompilerExtensions/Transformers/AvaloniaXamlIlWellKnownTypes.cs
  21. 53
      src/Markup/Avalonia.Markup.Xaml.Loader/CompilerExtensions/XamlIlBindingPathHelper.cs
  22. 182
      src/Markup/Avalonia.Markup.Xaml.Loader/CompilerExtensions/XamlIlClrPropertyInfoHelper.cs
  23. 261
      tests/Avalonia.Base.UnitTests/Data/Core/TypedBindingExpressionTests.Compatibility.cs
  24. 515
      tests/Avalonia.Base.UnitTests/Data/Core/TypedBindingExpressionTests.cs
  25. 143
      tests/Avalonia.Benchmarks/Data/TypedBinding_Setup.cs
  26. 162
      tests/Avalonia.Benchmarks/Data/TypedBinding_Values.cs
  27. 71
      tests/Avalonia.Controls.UnitTests/TextBoxTests_DataValidation.cs
  28. 202
      tests/Avalonia.Markup.Xaml.UnitTests/MarkupExtensions/CompiledBindingExtensionTests.cs

24
api/Avalonia.nupkg.xml

@ -1,6 +1,18 @@
<?xml version="1.0" encoding="utf-8"?>
<!-- https://learn.microsoft.com/dotnet/fundamentals/package-validation/diagnostic-ids -->
<Suppressions xmlns:xsi="http://www.w3.org/2001/XMLSchema-instance" xmlns:xsd="http://www.w3.org/2001/XMLSchema">
<Suppression>
<DiagnosticId>CP0002</DiagnosticId>
<Target>M:Avalonia.Data.Core.UntypedBindingExpressionBase.get_IsDataValidationEnabled</Target>
<Left>baseline/Avalonia/lib/net10.0/Avalonia.Base.dll</Left>
<Right>current/Avalonia/lib/net10.0/Avalonia.Base.dll</Right>
</Suppression>
<Suppression>
<DiagnosticId>CP0002</DiagnosticId>
<Target>M:Avalonia.Data.Core.UntypedBindingExpressionBase.Start</Target>
<Left>baseline/Avalonia/lib/net10.0/Avalonia.Base.dll</Left>
<Right>current/Avalonia/lib/net10.0/Avalonia.Base.dll</Right>
</Suppression>
<Suppression>
<DiagnosticId>CP0002</DiagnosticId>
<Target>M:Avalonia.Input.PointerEventArgs.#ctor(Avalonia.Interactivity.RoutedEvent,System.Object,Avalonia.Input.IPointer,Avalonia.Visual,Avalonia.Point,System.UInt64,Avalonia.Input.PointerPointProperties,Avalonia.Input.KeyModifiers,System.Lazy{System.Collections.Generic.IReadOnlyList{Avalonia.Input.Raw.RawPointerPoint}})</Target>
@ -55,6 +67,18 @@
<Left>baseline/Avalonia/lib/net10.0/Avalonia.Base.dll</Left>
<Right>current/Avalonia/lib/net10.0/Avalonia.Base.dll</Right>
</Suppression>
<Suppression>
<DiagnosticId>CP0002</DiagnosticId>
<Target>M:Avalonia.Data.Core.UntypedBindingExpressionBase.get_IsDataValidationEnabled</Target>
<Left>baseline/Avalonia/lib/net8.0/Avalonia.Base.dll</Left>
<Right>current/Avalonia/lib/net8.0/Avalonia.Base.dll</Right>
</Suppression>
<Suppression>
<DiagnosticId>CP0002</DiagnosticId>
<Target>M:Avalonia.Data.Core.UntypedBindingExpressionBase.Start</Target>
<Left>baseline/Avalonia/lib/net8.0/Avalonia.Base.dll</Left>
<Right>current/Avalonia/lib/net8.0/Avalonia.Base.dll</Right>
</Suppression>
<Suppression>
<DiagnosticId>CP0002</DiagnosticId>
<Target>M:Avalonia.Controls.Embedding.Offscreen.OffscreenTopLevelImplBase.SetFrameThemeVariant(Avalonia.Platform.PlatformThemeVariant)</Target>

4
src/Avalonia.Base/AvaloniaObject.cs

@ -605,9 +605,7 @@ namespace Avalonia
/// </returns>
internal BindingExpressionBase Bind(AvaloniaProperty property, BindingBase binding, object? anchor)
{
if (binding.CreateInstance(this, property, anchor) is not UntypedBindingExpressionBase expression)
throw new NotSupportedException($"Binding returned unsupported {nameof(BindingExpressionBase)}.");
var expression = binding.CreateInstance(this, property, anchor);
return GetValueStore().AddBinding(property, expression);
}

4
src/Avalonia.Base/AvaloniaProperty.cs

@ -610,8 +610,8 @@ namespace Avalonia
/// Routes an untyped SetDirectValueUnchecked call to a typed call.
/// </summary>
/// <param name="o">The object instance.</param>
/// <param name="value">The value.</param>
internal virtual void RouteSetDirectValueUnchecked(AvaloniaObject o, object? value) =>
/// <param name="entry">The value entry containing the value.</param>
internal virtual void RouteSetDirectValueUnchecked(AvaloniaObject o, IValueEntry entry) =>
throw new NotSupportedException();
/// <summary>

79
src/Avalonia.Base/Data/BindingExpressionBase.cs

@ -1,15 +1,58 @@
using System;
using Avalonia.Data.Core;
using Avalonia.PropertyStore;
using Avalonia.Styling;
namespace Avalonia.Data;
public abstract class BindingExpressionBase : IDisposable, ISetterInstance
/// <summary>
/// Represents the base class for binding expressions.
/// </summary>
/// <remarks>
/// A binding expression represents an instantiation of a binding on an object.
/// </remarks>
public abstract class BindingExpressionBase : IDisposable, ISetterInstance, IValueEntry
{
private protected BindingExpressionBase()
private protected BindingExpressionBase(BindingPriority defaultPriority)
{
Priority = DefaultPriority = defaultPriority;
}
/// <summary>
/// Gets the priority of the binding expression.
/// </summary>
public BindingPriority Priority { get; private protected set; }
/// <summary>
/// Gets the <see cref="AvaloniaProperty"/> that the binding expression is targeting.
/// </summary>
public AvaloniaProperty? TargetProperty { get; private protected set; }
/// <summary>
/// Gets the default priority of the binding expression.
/// </summary>
/// <remarks>
/// This property describes the preferred priority of the binding expression; the priority
/// passed to the <see cref="Attach"/> method may differ if the binding targets a direct
/// property, in which case the priority will be elevated to
/// <see cref="BindingPriority.LocalValue"/>.
/// </remarks>
internal BindingPriority DefaultPriority { get; }
/// <summary>
/// Gets a value indicating whether data validation is enabled for the binding expression.
/// </summary>
internal bool IsDataValidationEnabled { get; private protected set; }
AvaloniaProperty IValueEntry.Property => TargetProperty ??
throw new InvalidOperationException("The binding expression is not attached.");
bool IValueEntry.HasValue() => HasValue();
object? IValueEntry.GetValue() => GetUntypedValue();
bool IValueEntry.GetDataValidationState(out BindingValueType state, out Exception? error) =>
GetDataValidationState(out state, out error);
void IValueEntry.Unsubscribe() => Unsubscribe();
public virtual void Dispose()
{
GC.SuppressFinalize(this);
@ -39,15 +82,43 @@ public abstract class BindingExpressionBase : IDisposable, ISetterInstance
/// When overridden in a derived class, attaches the binding expression to a value store but
/// does not start it.
/// </summary>
/// <param name="valueStore">The value store to attach to.</param>
/// <param name="sink">The binding expression sink to attach to.</param>
/// <param name="frame">The immediate value frame to attach to, if any.</param>
/// <param name="target">The target object.</param>
/// <param name="targetProperty">The target property.</param>
/// <param name="priority">The priority of the binding.</param>
internal abstract void Attach(
ValueStore valueStore,
IBindingExpressionSink sink,
ImmediateValueFrame? frame,
AvaloniaObject target,
AvaloniaProperty targetProperty,
BindingPriority priority);
/// <summary>
/// Starts the binding expression.
/// </summary>
/// <param name="produceValue">
/// Indicates whether the binding expression should produce an initial value.
/// </param>
internal abstract void Start(bool produceValue);
/// <summary>
/// Checks whether the binding expression has a value, starting it if necessary.
/// </summary>
private protected abstract bool HasValue();
/// <summary>
/// Gets the current value of the binding expression as a boxed object.
/// </summary>
private protected abstract object? GetUntypedValue();
/// <summary>
/// Gets the data validation state of the binding expression, if supported.
/// </summary>
private protected abstract bool GetDataValidationState(out BindingValueType state, out Exception? error);
/// <summary>
/// Called when the binding expression is removed from the value store as a value entry.
/// </summary>
private protected abstract void Unsubscribe();
}

103
src/Avalonia.Base/Data/CompiledBinding.cs

@ -196,6 +196,109 @@ public class CompiledBinding : BindingBase
AvaloniaObject target,
AvaloniaProperty? targetProperty,
object? anchor)
{
if (CanUseTypedBindingExpression(target, targetProperty, out var typed))
{
return CreateTypedExpression(typed, target, targetProperty, anchor);
}
else
{
return CreateUntypedExpression(target, targetProperty, anchor);
}
}
private bool CanUseTypedBindingExpression(
AvaloniaObject target,
AvaloniaProperty? targetProperty,
[NotNullWhen(true)] out TypedPropertyElement? element)
{
element = null;
// We need a path with a single TypedPropertyElement
if (Path?.Elements.Count != 1 || Path.Elements[0] is not TypedPropertyElement typed)
return false;
// We need a DataContext binding.
if (Source != AvaloniaProperty.UnsetValue)
return false;
// It cannot have a Converter, Delay, FallbackValue. StringFormat, TargetNullValue or
// UpdateSourceTrigger != PropertyChanged.
if (Converter is not null ||
Delay != 0 ||
FallbackValue != AvaloniaProperty.UnsetValue ||
StringFormat != null ||
TargetNullValue != AvaloniaProperty.UnsetValue ||
UpdateSourceTrigger is not (UpdateSourceTrigger.Default or UpdateSourceTrigger.PropertyChanged))
{
return false;
}
// The value must be directly assignable to the target property.
if (targetProperty is null ||
!typed.Property.PropertyType.IsAssignableTo(targetProperty.PropertyType))
{
return false;
}
// TypedBindingExpression only supports StyledElement targets (it listens for
// StyledElement.DataContextProperty changes). Other AvaloniaObjects that expose a
// DataContext (e.g. Application, which is an IDataContextProvider but not a StyledElement)
// must use the untyped path.
if (target is not StyledElement)
return false;
// DataContext bindings need to read their source value from the parent of the target
// instead of the DataContext; TypedBindingExpression does not support this (and it would
// probably not be worth doing so as DataContexts are usually reference types).
if (targetProperty == StyledElement.DataContextProperty)
return false;
// TypedBindingExpression does not support data validation, so fall back to the untyped
// path when the target property enables it.
if (targetProperty.GetMetadata(target).EnableDataValidation == true)
return false;
// For modes that write back to the source, the source property must be settable and the
// target value must be assignable back to the source property type. The untyped path
// handles read-only or wider-typed sources gracefully (failing silently), so fall back to
// it rather than throwing during write-back.
var (mode, _) = ResolveDefaultsFromMetadata(target, targetProperty);
if (mode is BindingMode.TwoWay or BindingMode.OneWayToSource)
{
if (!typed.Property.CanSet ||
!targetProperty.PropertyType.IsAssignableTo(typed.Property.PropertyType))
{
return false;
}
}
element = typed;
return true;
}
private BindingExpressionBase CreateTypedExpression(
TypedPropertyElement element,
AvaloniaObject target,
AvaloniaProperty? targetProperty,
object? anchor)
{
// The UpdateSourceTrigger has already been constrained to PropertyChanged by
// CanUseTypedBindingExpression, so only the mode needs to be resolved here.
var (mode, _) = ResolveDefaultsFromMetadata(target, targetProperty);
return element.CreateExpression(
target,
targetProperty,
anchor,
mode,
Priority);
}
private BindingExpression CreateUntypedExpression(
AvaloniaObject target,
AvaloniaProperty? targetProperty,
object? anchor)
{
var enableDataValidation = targetProperty?.GetMetadata(target).EnableDataValidation ?? false;
var nodes = new List<ExpressionNode>();

59
src/Avalonia.Base/Data/CompiledBindingPath.cs

@ -7,6 +7,7 @@ using Avalonia.Data.Core;
using Avalonia.Data.Core.ExpressionNodes;
using Avalonia.Data.Core.Parsers;
using Avalonia.Data.Core.Plugins;
using Avalonia.Styling;
namespace Avalonia.Data
{
@ -93,7 +94,7 @@ namespace Avalonia.Data
result.Add(new LogicalNotNode());
}
internal IEnumerable<ICompiledBindingPathElement> Elements => _elements;
internal IReadOnlyList<ICompiledBindingPathElement> Elements => _elements;
/// <inheritdoc />
public override string ToString()
@ -129,6 +130,20 @@ namespace Avalonia.Data
return this;
}
public CompiledBindingPathBuilder Property<TSource, TResult>(
IPropertyInfo<TSource, TResult> info,
Func<WeakReference<object?>, IPropertyInfo, IPropertyAccessor> accessorFactory,
bool acceptsNull)
where TSource : class
{
_elements.Add(new TypedPropertyElement<TSource, TResult>(
info,
accessorFactory,
_elements.Count == 0,
acceptsNull));
return this;
}
public CompiledBindingPathBuilder Method(RuntimeMethodHandle handle, RuntimeTypeHandle delegateType)
{
Method(handle, delegateType, acceptsNull: false);
@ -264,6 +279,48 @@ namespace Avalonia.Data
=> _isFirstElement ? Property.Name : $".{Property.Name}";
}
internal abstract class TypedPropertyElement : PropertyElement
{
protected TypedPropertyElement(
IPropertyInfo property,
Func<WeakReference<object?>, IPropertyInfo, IPropertyAccessor> accessorFactory,
bool isFirstElement,
bool acceptsNull) : base(property, accessorFactory, isFirstElement, acceptsNull)
{
}
public abstract BindingExpressionBase CreateExpression(
AvaloniaObject target,
AvaloniaProperty? targetProperty,
object? anchor,
BindingMode mode,
BindingPriority priority);
}
internal sealed class TypedPropertyElement<TSource, TValue> : TypedPropertyElement
where TSource : class
{
public TypedPropertyElement(
IPropertyInfo<TSource, TValue> property,
Func<WeakReference<object?>, IPropertyInfo, IPropertyAccessor> accessorFactory,
bool isFirstElement,
bool acceptsNull)
: base(property, accessorFactory, isFirstElement, acceptsNull)
{
}
public override BindingExpressionBase CreateExpression(
AvaloniaObject target,
AvaloniaProperty? targetProperty,
object? anchor,
BindingMode mode,
BindingPriority priority)
{
var property = (IPropertyInfo<TSource, TValue>)Property;
return new TypedBindingExpression<TSource, TValue>(property, mode, priority);
}
}
internal class MethodAsDelegateElement : ICompiledBindingPathElement
{
public MethodAsDelegateElement(

40
src/Avalonia.Base/Data/Core/ClrPropertyInfo`2.cs

@ -0,0 +1,40 @@
using System;
namespace Avalonia.Data.Core;
public class ClrPropertyInfo<TSource, TValue> : IPropertyInfo<TSource, TValue>
where TSource : class
{
private readonly Func<TSource, TValue>? _getter;
private readonly Action<TSource, TValue>? _setter;
public ClrPropertyInfo(string name, Func<TSource, TValue>? getter, Action<TSource, TValue>? setter)
{
_getter = getter;
_setter = setter;
PropertyType = typeof(TValue);
Name = name;
}
public bool CanSet => _setter != null;
public bool CanGet => _getter != null;
public string Name { get; }
public Type PropertyType { get; }
public TValue Get(TSource target)
{
if (_getter == null)
throw new NotSupportedException("Property " + Name + " doesn't have a getter");
return _getter(target);
}
public void Set(TSource target, TValue value)
{
if (_setter == null)
throw new NotSupportedException("Property " + Name + " doesn't have a setter");
_setter(target, value);
}
object? IPropertyInfo.Get(object target) => Get((TSource)target);
void IPropertyInfo.Set(object target, object? value) => Set((TSource)target, (TValue)value!);
}

20
src/Avalonia.Base/Data/Core/IBindingExpressionSink.cs

@ -8,27 +8,19 @@ internal interface IBindingExpressionSink
/// </summary>
/// <param name="instance">The binding expression.</param>
/// <param name="hasValueChanged">
/// Indicates whether <paramref name="value"/> represents a new value produced by the binding.
/// Indicates whether the binding has produced a new value.
/// </param>
/// <param name="hasErrorChanged">
/// Indicates whether <paramref name="error"/> represents a new error produced by the binding.
/// </param>
/// <param name="value">
/// The new binding value; if <paramref name="hasValueChanged"/> is true.
/// </param>
/// <param name="error">
/// The new binding error; if <paramref name="hasErrorChanged"/> is true.
/// Indicates whether the binding has produced a new error.
/// </param>
void OnChanged(
UntypedBindingExpressionBase instance,
BindingExpressionBase instance,
bool hasValueChanged,
bool hasErrorChanged,
object? value,
BindingError? error);
bool hasErrorChanged);
/// <summary>
/// Called when an <see cref="UntypedBindingExpressionBase"/> completes.
/// Called when a <see cref="BindingExpressionBase"/> completes.
/// </summary>
/// <param name="instance">The binding expression.</param>
void OnCompleted(UntypedBindingExpressionBase instance);
void OnCompleted(BindingExpressionBase instance);
}

12
src/Avalonia.Base/Data/Core/IPropertyInfo`2.cs

@ -0,0 +1,12 @@
using System;
using Avalonia.Metadata;
namespace Avalonia.Data.Core;
[NotClientImplementable]
public interface IPropertyInfo<TSource, TValue> : IPropertyInfo
where TSource : class
{
TValue Get(TSource target);
void Set(TSource target, TValue value);
}

17
src/Avalonia.Base/Data/Core/MultiBindingExpression.cs

@ -4,6 +4,7 @@ using System.Collections.ObjectModel;
using System.Diagnostics;
using System.Globalization;
using Avalonia.Data.Converters;
using Avalonia.PropertyStore;
namespace Avalonia.Data.Core;
@ -69,7 +70,8 @@ internal class MultiBindingExpression : UntypedBindingExpressionBase, IBindingEx
throw new NotSupportedException($"Unsupported BindingExpressionBase implementation '{expression}'.");
_expressions[i] = e;
e.AttachAndStart(this, target, null, Priority);
e.Attach(this, null, target, null, Priority);
e.Start(produceValue: true);
}
}
@ -84,20 +86,21 @@ internal class MultiBindingExpression : UntypedBindingExpressionBase, IBindingEx
}
void IBindingExpressionSink.OnChanged(
UntypedBindingExpressionBase instance,
BindingExpressionBase instance,
bool hasValueChanged,
bool hasErrorChanged,
object? value,
BindingError? error)
bool hasErrorChanged)
{
var i = Array.IndexOf(_expressions, instance);
Debug.Assert(i != -1);
_values[i] = BindingNotification.ExtractValue(value);
var entry = (IValueEntry)instance;
_values[i] = entry.HasValue() ?
BindingNotification.ExtractValue(entry.GetValue()) :
AvaloniaProperty.UnsetValue;
PublishValue();
}
void IBindingExpressionSink.OnCompleted(UntypedBindingExpressionBase instance)
void IBindingExpressionSink.OnCompleted(BindingExpressionBase instance)
{
// Nothing to do here.
}

417
src/Avalonia.Base/Data/Core/TypedBindingExpression.cs

@ -0,0 +1,417 @@
using System;
using System.ComponentModel;
using System.Diagnostics.CodeAnalysis;
using System.Runtime.CompilerServices;
using Avalonia.Logging;
using Avalonia.PropertyStore;
using Avalonia.Threading;
using Avalonia.Utilities;
namespace Avalonia.Data.Core;
/// <summary>
/// A binding expression which does not box.
/// </summary>
/// <typeparam name="TSource">The type of the source object.</typeparam>
/// <typeparam name="TValue">The type of the value.</typeparam>
/// <remarks>
/// A typed binding expression has the following limitations:
///
/// - It must be a DataContext binding
/// - It can only be used with a single source property, i.e. `{Binding Foo}` can be represented as
/// a typed binding but `{Binding Foo.Bar}` cannot.
/// - It cannot have a Converter, Delay, FallbackValue. StringFormat, TargetNullValue or
/// UpdateSourceTrigger != PropertyChanged.
/// - The value must be directly assignable to the target property, i.e. no type conversion is
/// performed.
/// - The target property must not enable data validation.
/// </remarks>
internal class TypedBindingExpression<TSource, TValue> : BindingExpressionBase,
IDescription,
IValueEntry<TValue>,
IWeakEventSubscriber<PropertyChangedEventArgs>
where TSource : class
{
private readonly IPropertyInfo<TSource, TValue> _propertyInfo;
private readonly BindingMode _mode;
private bool _isRunning;
private bool _produceValue;
private bool _writingValueToTarget;
private IBindingExpressionSink? _sink;
private ImmediateValueFrame? _frame;
private WeakReference<TSource?>? _source;
private WeakReference<StyledElement>? _target;
private Optional<TValue> _sourceValue;
private Optional<TValue> _targetValue;
private bool _shouldUpdateOneTimeBindingTarget;
public TypedBindingExpression(
IPropertyInfo<TSource, TValue> propertyInfo,
BindingMode mode,
BindingPriority defaultPriority)
: base(defaultPriority)
{
_propertyInfo = propertyInfo;
_mode = mode;
_shouldUpdateOneTimeBindingTarget = mode is BindingMode.OneTime;
}
public string Description => _propertyInfo.Name;
// The whole method body is factored out into AttachCore, which takes the value type as a
// Type parameter instead of reading it from TValue. Because AttachCore doesn't reference the
// generic parameters, its code is shared across all generic instantiations instead of being
// duplicated for each one, which is a meaningful NativeAOT size saving.
internal override void Attach(
IBindingExpressionSink sink,
ImmediateValueFrame? frame,
AvaloniaObject target,
AvaloniaProperty targetProperty,
BindingPriority priority)
=> AttachCore(sink, frame, target, targetProperty, priority, typeof(TValue));
public override void Dispose()
{
if (_sink is null)
return;
// Null the sink before stopping so that the unsubscribe doesn't push a final value to a
// value store that's about to clear this entry anyway.
var sink = _sink;
var frame = _frame;
_sink = null;
_frame = null;
StopCore();
sink.OnCompleted(this);
frame?.OnEntryDisposed(this);
}
internal override void Start(bool produceValue)
{
if (_isRunning)
return;
_isRunning = true;
try
{
_produceValue = produceValue;
StartCore();
}
finally
{
_produceValue = true;
}
}
private protected override bool GetDataValidationState(out BindingValueType state, out Exception? error)
{
// Data validation is not supported by the typed expression: bindings whose target
// property enables it are excluded in CompiledBinding.CanUseTypedBindingExpression and
// use the untyped BindingExpression instead. Could be implemented here as a follow-up.
state = BindingValueType.Value;
error = null;
return false;
}
private protected override object? GetUntypedValue()
{
Start(produceValue: false);
if (!_sourceValue.HasValue)
throw new AvaloniaInternalException("The binding expression has no value.");
return Box(_sourceValue.Value);
}
TValue IValueEntry<TValue>.GetValue()
{
Start(produceValue: false);
if (!_sourceValue.HasValue)
throw new AvaloniaInternalException("The binding expression has no value.");
return _sourceValue.Value;
}
private protected override bool HasValue()
{
Start(produceValue: false);
return _sourceValue.HasValue;
}
private protected override void Unsubscribe()
{
// Reset _isRunning so the expression can be restarted (and re-subscribe to its source) if
// the value store reactivates this entry later.
StopCore();
_isRunning = false;
}
void IWeakEventSubscriber<PropertyChangedEventArgs>.OnEvent(object? sender, WeakEvent ev, PropertyChangedEventArgs e)
{
OnSourcePropertyChanged(sender, e);
}
private void AttachCore(
IBindingExpressionSink sink,
ImmediateValueFrame? frame,
AvaloniaObject target,
AvaloniaProperty targetProperty,
BindingPriority priority,
Type valueType)
{
if (_sink is not null)
throw new InvalidOperationException("TypedBindingExpression was already attached.");
if (target is not StyledElement element)
throw new InvalidOperationException("TypedBindingExpression may only target StyledElements");
if (TargetProperty is not null && TargetProperty != targetProperty)
throw new InvalidOperationException("TypedBindingExpression was already attached to a different property.");
if (!valueType.IsAssignableTo(targetProperty.PropertyType))
{
throw new InvalidOperationException(
$"TypedBindingExpression of type '{valueType}' cannot be bound " +
$"to a property of type '{targetProperty.PropertyType}'.");
}
_sink = sink;
_frame = frame;
_target = new(element);
TargetProperty = targetProperty;
Priority = priority;
}
private void StartCore()
{
if (TryGetTarget(out var target) && TargetProperty is not null)
{
target.PropertyChanged += OnTargetPropertyChanged;
UpdateSource(target.DataContext);
}
}
private void StopCore()
{
if (TryGetTarget(out var target))
{
target.PropertyChanged -= OnTargetPropertyChanged;
UpdateSource(null);
}
}
private void UpdateSource(object? dataContext)
{
var source = dataContext as TSource;
if (dataContext is not null && source is null)
{
Log($"Could not convert DataContext of type '{dataContext.GetType()}' " +
$"to '{typeof(TSource)}'.");
}
if (TryGetSource(out var oldSource))
{
if (oldSource is INotifyPropertyChanged oldInpc)
WeakEvents.ThreadSafePropertyChanged.Unsubscribe(oldInpc, this);
}
_source = new(source);
_shouldUpdateOneTimeBindingTarget = true;
if (source is INotifyPropertyChanged inpc)
WeakEvents.ThreadSafePropertyChanged.Subscribe(inpc, this);
if (_mode is BindingMode.OneWayToSource)
{
if (TryGetTargetValue(out var value))
WriteValueToSource(value!);
}
else
{
WriteSourceValueToTarget(source);
}
}
private void WriteValueToSource(TValue value)
{
if (TargetProperty is not null && TryGetTarget(out var target))
{
if (TryGetSource(out var source))
_propertyInfo.Set(source, value);
}
}
private void WriteSourceValueToTarget()
{
if (TryGetSource(out var source))
WriteSourceValueToTarget(source);
}
private void WriteSourceValueToTarget(TSource? source)
{
if (_mode is BindingMode.OneTime && !_shouldUpdateOneTimeBindingTarget)
return;
var oldValue = _sourceValue;
if (source is null)
{
_sourceValue = default;
}
else
{
try
{
_sourceValue = new(_propertyInfo.Get(source));
}
catch (Exception e)
{
// Getter exceptions must not escape into the source's PropertyChanged event and
// crash the UI thread, so log the error and clear the value, as the untyped
// binding path does.
Log($"Error getting '{_propertyInfo.Name}': {e.Message}");
_sourceValue = default;
}
}
if (_produceValue && _mode is not BindingMode.OneWayToSource)
{
// An expression which has no value, and had no value before, must not notify: doing so
// would push the target property's default value, overriding values from styles or
// property inheritance. Otherwise always notify, even if the value is unchanged, as
// the target may hold an uncommitted value written by SetCurrentValue.
if (oldValue.HasValue || _sourceValue.HasValue)
PublishValue();
if (_mode is BindingMode.OneTime)
_shouldUpdateOneTimeBindingTarget = false;
}
}
private void PublishValue()
{
// The source's PropertyChanged event may be raised on any thread, but the target can only
// be updated on the UI thread.
if (Dispatcher.UIThread.CheckAccess())
PublishCore(this);
else
Dispatcher.UIThread.Post(PublishCore, this);
static void PublishCore(object? state)
{
var expression = (TypedBindingExpression<TSource, TValue>)state!;
// Flag that we're pushing the source value to the target so that the resulting target
// PropertyChanged isn't echoed straight back to the source in TwoWay mode.
expression._writingValueToTarget = true;
try
{
expression._sink?.OnChanged(expression, true, false);
}
finally
{
expression._writingValueToTarget = false;
}
}
}
private void OnSourcePropertyChanged(object? sender, PropertyChangedEventArgs e)
{
// A null or empty PropertyName means "all properties changed" per the
// INotifyPropertyChanged contract, so we must re-read the source value in that case too.
if (string.IsNullOrEmpty(e.PropertyName) || e.PropertyName == _propertyInfo.Name)
WriteSourceValueToTarget();
}
private void OnTargetPropertyChanged(object? sender, AvaloniaPropertyChangedEventArgs e)
{
if (e.Property == StyledElement.DataContextProperty)
{
UpdateSource(((StyledElement?)sender)?.DataContext);
}
else if (e.Property == TargetProperty)
{
_targetValue = ReadTargetValue(e);
// Don't write back to the source if this change is the binding pushing the source
// value to the target; that would be a redundant round-trip.
if (_targetValue.HasValue &&
!_writingValueToTarget &&
_mode is BindingMode.TwoWay or BindingMode.OneWayToSource)
{
WriteValueToSource(_targetValue.Value);
}
}
}
private static Optional<TValue> ReadTargetValue(AvaloniaPropertyChangedEventArgs e)
{
// The binding value type only needs to be assignable to the target property type, so the
// target property can hold values which cannot be represented as a TValue; for example a
// string binding on an object-typed property whose value is set to an int. Such values
// are reported as absent rather than throwing.
if (e is AvaloniaPropertyChangedEventArgs<TValue> typedArgs)
return typedArgs.NewValue.Value;
if (e.NewValue is TValue value)
return value;
if (e.NewValue is null && default(TValue) is null)
return new Optional<TValue>(default!);
return default;
}
/// <summary>
/// Converts a value to <see cref="object"/>, using cached boxes for booleans so that reading
/// a boolean-valued binding into an object-typed target property does not allocate on every
/// read (#21065).
/// </summary>
private static object? Box(TValue value)
{
if (typeof(TValue) == typeof(bool))
return BooleanBoxes.Box(Unsafe.As<TValue, bool>(ref value));
return value;
}
private void Log(string error, LogEventLevel level = LogEventLevel.Warning)
{
if (!Logger.TryGet(level, LogArea.Binding, out var log) || !TryGetTarget(out var target))
return;
log.Log(
target,
"An error occurred binding {Property} to {Expression}: {Message}",
(object?)TargetProperty ?? "(unknown)",
Description,
error);
}
private bool TryGetSource([NotNullWhen(true)] out TSource? source)
{
if (_source?.TryGetTarget(out source) == true)
return true;
source = null;
return false;
}
private bool TryGetTarget([NotNullWhen(true)] out StyledElement? target)
{
if (_target?.TryGetTarget(out target) == true)
return true;
target = null;
return false;
}
private bool TryGetTargetValue(out TValue? value)
{
if (TargetProperty is not null && TryGetTarget(out var target))
{
value = TargetProperty switch
{
StyledProperty<TValue> s => target.GetValue(s),
DirectPropertyBase<TValue> d => target.GetValue(d),
_ => (TValue)target.GetValue(TargetProperty)!
};
return true;
}
value = default;
return false;
}
}

153
src/Avalonia.Base/Data/Core/UntypedBindingExpressionBase.cs

@ -18,11 +18,9 @@ namespace Avalonia.Data.Core;
[PrivateApi]
public abstract class UntypedBindingExpressionBase : BindingExpressionBase,
IDisposable,
IDescription,
IValueEntry
IDescription
{
protected static readonly object UnchangedValue = new();
private readonly bool _isDataValidationEnabled;
private object? _defaultValue;
private BindingError? _error;
private ImmediateValueFrame? _frame;
@ -45,11 +43,11 @@ public abstract class UntypedBindingExpressionBase : BindingExpressionBase,
BindingPriority defaultPriority,
AvaloniaProperty? targetProperty = null,
bool isDataValidationEnabled = false)
: base(defaultPriority)
{
Priority = defaultPriority;
TargetProperty = targetProperty;
TargetType = targetProperty?.PropertyType ?? typeof(object);
_isDataValidationEnabled = isDataValidationEnabled;
IsDataValidationEnabled = isDataValidationEnabled;
}
/// <summary>
@ -62,38 +60,17 @@ public abstract class UntypedBindingExpressionBase : BindingExpressionBase,
/// </summary>
public BindingErrorType ErrorType => _error?.ErrorType ?? BindingErrorType.None;
/// <summary>
/// Gets a value indicating whether data validation is enabled for the binding expression.
/// </summary>
public bool IsDataValidationEnabled => _isDataValidationEnabled;
/// <summary>
/// Gets a value indicating whether the binding expression is currently running.
/// </summary>
public bool IsRunning => _isRunning;
/// <summary>
/// Gets the priority of the binding expression.
/// </summary>
/// <remarks>
/// Before being attached to a value store, this property describes the default priority of the
/// binding expression; this may change when the expression is attached to a value store.
/// </remarks>
public BindingPriority Priority { get; private set; }
/// <summary>
/// Gets the <see cref="AvaloniaProperty"/> which the binding expression is targeting.
/// </summary>
public AvaloniaProperty? TargetProperty { get; private set; }
/// <summary>
/// Gets the target type of the binding expression; that is, the type that values produced by
/// the expression should be converted to.
/// </summary>
public Type TargetType { get; private set; }
AvaloniaProperty IValueEntry.Property => TargetProperty ?? throw new Exception();
/// <summary>
/// Terminates the binding.
/// </summary>
@ -143,12 +120,7 @@ public abstract class UntypedBindingExpressionBase : BindingExpressionBase,
return result;
}
/// <summary>
/// Starts the binding expression following a call to <see cref="AttachCore"/>.
/// </summary>
public void Start() => Start(produceValue: true);
bool IValueEntry.GetDataValidationState(out BindingValueType state, out Exception? error)
private protected override bool GetDataValidationState(out BindingValueType state, out Exception? error)
{
if (_error is not null)
{
@ -169,46 +141,56 @@ public abstract class UntypedBindingExpressionBase : BindingExpressionBase,
return IsDataValidationEnabled;
}
bool IValueEntry.HasValue()
private protected override bool HasValue()
{
Start(produceValue: false);
return true;
}
object? IValueEntry.GetValue()
private protected override object? GetUntypedValue()
{
Start(produceValue: false);
return GetValueOrDefault();
}
void IValueEntry.Unsubscribe() => Stop();
private protected override void Unsubscribe() => Stop();
internal override void Attach(
ValueStore valueStore,
IBindingExpressionSink sink,
ImmediateValueFrame? frame,
AvaloniaObject target,
AvaloniaProperty targetProperty,
AvaloniaProperty? targetProperty,
BindingPriority priority)
{
AttachCore(valueStore, frame, target, targetProperty, priority);
if (_sink is not null)
throw new InvalidOperationException("BindingExpression was already attached.");
if (TargetProperty is not null && TargetProperty != targetProperty)
throw new InvalidOperationException("BindingExpression was already attached to a different property.");
_sink = sink;
_frame = frame;
_target = new(target);
TargetProperty = targetProperty;
TargetType = targetProperty?.PropertyType ?? typeof(object);
Priority = priority;
}
/// <summary>
/// Initializes the binding expression with the specified subscriber and target property and
/// starts it.
/// </summary>
/// <param name="subscriber">The subscriber.</param>
/// <param name="target">The target object.</param>
/// <param name="targetProperty">The target property.</param>
/// <param name="priority">The priority of the binding.</param>
internal void AttachAndStart(
IBindingExpressionSink subscriber,
AvaloniaObject target,
AvaloniaProperty? targetProperty,
BindingPriority priority)
internal override void Start(bool produceValue)
{
AttachCore(subscriber, null, target, targetProperty, priority);
Start(produceValue: true);
if (_isRunning)
return;
_isRunning = true;
try
{
_produceValue = produceValue;
StartCore();
}
finally
{
_produceValue = true;
}
}
/// <summary>
@ -256,27 +238,6 @@ public abstract class UntypedBindingExpressionBase : BindingExpressionBase,
/// </returns>
internal virtual bool WriteValueToSource(object? value) => false;
private void AttachCore(
IBindingExpressionSink sink,
ImmediateValueFrame? frame,
AvaloniaObject target,
AvaloniaProperty? targetProperty,
BindingPriority priority)
{
if (_sink is not null)
throw new InvalidOperationException("BindingExpression was already attached.");
if (TargetProperty is not null && TargetProperty != targetProperty)
throw new InvalidOperationException("BindingExpression was already attached to a different property.");
_sink = sink;
_frame = frame;
_target = new(target);
TargetProperty = targetProperty;
TargetType = targetProperty?.PropertyType ?? typeof(object);
Priority = priority;
}
/// <summary>
/// Converts a value using a value converter, logging a warning if necessary.
/// </summary>
@ -437,7 +398,7 @@ public abstract class UntypedBindingExpressionBase : BindingExpressionBase,
if (Dispatcher.UIThread.CheckAccess())
{
_sink.OnChanged(this, hasValueChanged, hasErrorChanged, GetValueOrDefault(), _error);
_sink.OnChanged(this, hasValueChanged, hasErrorChanged);
}
else
{
@ -446,9 +407,7 @@ public abstract class UntypedBindingExpressionBase : BindingExpressionBase,
var sink = _sink;
var vc = hasValueChanged;
var ec = hasErrorChanged;
var v = GetValueOrDefault();
var e = _error;
Dispatcher.UIThread.Post(() => sink.OnChanged(this, vc, ec, v, e));
Dispatcher.UIThread.Post(() => sink.OnChanged(this, vc, ec));
}
}
@ -465,30 +424,6 @@ public abstract class UntypedBindingExpressionBase : BindingExpressionBase,
return TryGetTarget(out target);
}
/// <summary>
/// Starts the binding expression by calling <see cref="StartCore"/>.
/// </summary>
/// <param name="produceValue">
/// Indicates whether the binding expression should produce an initial value.
/// </param>
protected void Start(bool produceValue)
{
if (_isRunning)
return;
_isRunning = true;
try
{
_produceValue = produceValue;
StartCore();
}
finally
{
_produceValue = true;
}
}
/// <summary>
/// When overridden in a derived class, starts the binding expression.
/// </summary>
@ -562,13 +497,15 @@ public abstract class UntypedBindingExpressionBase : BindingExpressionBase,
public ObservableSink(UntypedBindingExpressionBase expression) => _expression = expression;
void IBindingExpressionSink.OnChanged(
UntypedBindingExpressionBase instance,
BindingExpressionBase instance,
bool hasValueChanged,
bool hasErrorChanged,
object? value,
BindingError? error)
bool hasErrorChanged)
{
if (instance.IsDataValidationEnabled || error is not null)
var expression = (UntypedBindingExpressionBase)instance;
var error = expression._error;
var value = expression.GetValueOrDefault();
if (expression.IsDataValidationEnabled || error is not null)
{
BindingNotification notification;
@ -587,7 +524,7 @@ public abstract class UntypedBindingExpressionBase : BindingExpressionBase,
}
}
void IBindingExpressionSink.OnCompleted(UntypedBindingExpressionBase instance) => PublishCompleted();
void IBindingExpressionSink.OnCompleted(BindingExpressionBase instance) => PublishCompleted();
void IObserver<object?>.OnCompleted() { }
void IObserver<object?>.OnError(Exception error) { }

10
src/Avalonia.Base/DirectPropertyBase.cs

@ -177,10 +177,11 @@ namespace Avalonia
return null;
}
internal override void RouteSetDirectValueUnchecked(AvaloniaObject o, object? value)
internal override void RouteSetDirectValueUnchecked(AvaloniaObject o, IValueEntry entry)
{
var bindingValue = BindingValue<TValue>.FromUntypedStrict(value);
o.SetDirectValueUnchecked<TValue>(this, bindingValue);
var value = GetValue(entry, o);
var bindingValue = new BindingValue<TValue>(value);
o.SetDirectValueUnchecked(this, bindingValue);
}
internal override void RouteSetCurrentValue(AvaloniaObject o, object? value)
@ -201,5 +202,8 @@ namespace Avalonia
{
return o.Bind(this, source);
}
private TValue GetValue(IValueEntry entry, AvaloniaObject o)
=> IValueEntry.TryGetValue<TValue>(entry, out var value) ? value : GetMetadata(o).UnsetValue;
}
}

14
src/Avalonia.Base/PropertyStore/EffectiveValue.cs

@ -140,7 +140,7 @@ namespace Avalonia.PropertyStore
BindingPriority priority);
/// <summary>
/// Sets the value and base value for a LocalValue priority, raising
/// Sets the value and base value for a LocalValue priority, raising
/// <see cref="AvaloniaObject.PropertyChanged"/> where necessary.
/// </summary>
/// <param name="owner">The associated value store.</param>
@ -151,6 +151,18 @@ namespace Avalonia.PropertyStore
AvaloniaProperty property,
object? value);
/// <summary>
/// Sets the value and base value for a LocalValue priority, raising
/// <see cref="AvaloniaObject.PropertyChanged"/> where necessary.
/// </summary>
/// <param name="owner">The associated value store.</param>
/// <param name="property">The property being changed.</param>
/// <param name="entry">The value entry containing the value.</param>
public abstract void SetLocalValueAndRaise(
ValueStore owner,
AvaloniaProperty property,
IValueEntry entry);
/// <summary>
/// Raises <see cref="AvaloniaObject.PropertyChanged"/> in response to an inherited value
/// change.

18
src/Avalonia.Base/PropertyStore/EffectiveValue`1.cs

@ -83,6 +83,15 @@ namespace Avalonia.PropertyStore
SetAndRaiseCore(owner, property, value, BindingPriority.LocalValue);
}
public override void SetLocalValueAndRaise(
ValueStore owner,
AvaloniaProperty property,
IValueEntry entry)
{
var value = GetValue(entry);
SetLocalValueAndRaise(owner, (StyledProperty<T>)property, value!);
}
public void SetCurrentValueAndRaise(
ValueStore owner,
StyledProperty<T> property,
@ -193,13 +202,8 @@ namespace Avalonia.PropertyStore
protected override object? GetBoxedValue() => Value;
private static T GetValue(IValueEntry entry)
{
if (entry is IValueEntry<T> typed)
return typed.GetValue();
else
return (T)entry.GetValue()!;
}
private T GetValue(IValueEntry entry)
=> IValueEntry.TryGetValue<T>(entry, out var value) ? value : _metadata.DefaultValue;
private void SetAndRaiseCore(
ValueStore owner,

19
src/Avalonia.Base/PropertyStore/IValueEntry.cs

@ -40,5 +40,24 @@ namespace Avalonia.PropertyStore
/// Called when the value entry is removed from the value store.
/// </summary>
void Unsubscribe();
/// <summary>
/// Reads the value of an entry as <typeparamref name="T"/>, preferring the unboxed
/// <see cref="IValueEntry{T}"/> path when available.
/// </summary>
/// <param name="entry">The entry.</param>
/// <param name="value">The value, if the entry has one.</param>
/// <returns>True if the entry has a value, otherwise false.</returns>
static bool TryGetValue<T>(IValueEntry entry, out T value)
{
if (entry.HasValue())
{
value = entry is IValueEntry<T> typed ? typed.GetValue() : (T)entry.GetValue()!;
return true;
}
value = default!;
return false;
}
}
}

2
src/Avalonia.Base/PropertyStore/ImmediateValueFrame.cs

@ -15,7 +15,7 @@ namespace Avalonia.PropertyStore
{
}
public IValueEntry AddBinding(UntypedBindingExpressionBase source)
public IValueEntry AddBinding(IValueEntry source)
{
Add(source);
return source;

51
src/Avalonia.Base/PropertyStore/ValueStore.cs

@ -47,15 +47,17 @@ namespace Avalonia.PropertyStore
public BindingExpressionBase AddBinding(
AvaloniaProperty property,
UntypedBindingExpressionBase source)
BindingExpressionBase source)
{
var priority = source.Priority;
var priority = source.DefaultPriority;
if (priority == BindingPriority.LocalValue || property.IsDirect)
{
DisposeExistingLocalValueBinding(property);
_localValueBindings ??= new();
_localValueBindings[property.Id] = source;
source.AttachAndStart(this, Owner, property, BindingPriority.LocalValue);
source.Attach(this, null, Owner, property, BindingPriority.LocalValue);
source.Start(produceValue: true);
return source;
}
else
@ -67,7 +69,7 @@ namespace Avalonia.PropertyStore
frame.AddBinding(source);
if (effective is null || priority <= effective.Priority)
source.Start();
source.Start(produceValue: true);
return source;
}
@ -273,6 +275,20 @@ namespace Avalonia.PropertyStore
}
}
public void SetLocalValue(AvaloniaProperty property, IValueEntry entry)
{
if (TryGetEffectiveValue(property, out var existing))
{
existing.SetLocalValueAndRaise(this, property, entry);
}
else
{
var effectiveValue = property.CreateEffectiveValue(Owner);
AddEffectiveValue(property, effectiveValue);
effectiveValue.SetLocalValueAndRaise(this, property, entry);
}
}
public object? GetValue(AvaloniaProperty property)
{
if (_effectiveValues.TryGetValue(property, out var v))
@ -787,21 +803,20 @@ namespace Avalonia.PropertyStore
}
void IBindingExpressionSink.OnChanged(
UntypedBindingExpressionBase instance,
BindingExpressionBase instance,
bool hasValueChanged,
bool hasErrorChanged,
object? value,
BindingError? error)
bool hasErrorChanged)
{
Dispatcher.UIThread.VerifyAccess();
Debug.Assert(instance.TargetProperty is not null);
var property = instance.TargetProperty;
IValueEntry entry = instance;
if (property.IsDirect)
{
if (hasValueChanged)
property.RouteSetDirectValueUnchecked(Owner, value);
property.RouteSetDirectValueUnchecked(Owner, entry);
}
else
{
@ -811,32 +826,26 @@ namespace Avalonia.PropertyStore
{
if (priority == BindingPriority.LocalValue)
{
if (value != AvaloniaProperty.UnsetValue)
SetLocalValue(property, value);
else if (property == StyledElement.DataContextProperty)
SetLocalValue(property, null);
else
ClearValue(property);
SetLocalValue(property, entry);
}
else
{
if (TryGetEffectiveValue(property, out var existing))
{
if (priority <= existing.BasePriority)
ReevaluateEffectiveValue(property, existing, changedValueEntry: instance);
ReevaluateEffectiveValue(property, existing, changedValueEntry: entry);
}
else
{
AddEffectiveValueAndRaise(property, instance, priority);
AddEffectiveValueAndRaise(property, entry, priority);
}
}
}
}
if (hasErrorChanged && instance.IsDataValidationEnabled)
if (hasErrorChanged && entry.GetDataValidationState(out var errorType, out var error))
{
var e = error?.ErrorType.ToBindingValueType() ?? BindingValueType.Value;
Owner.OnUpdateDataValidation(property, e, error?.Exception);
Owner.OnUpdateDataValidation(property, errorType, error);
}
}
@ -844,7 +853,7 @@ namespace Avalonia.PropertyStore
/// Called by a binding expression when the binding produces completes.
/// </summary>
/// <param name="instance">The binding expression.</param>
void IBindingExpressionSink.OnCompleted(UntypedBindingExpressionBase instance)
void IBindingExpressionSink.OnCompleted(BindingExpressionBase instance)
{
Dispatcher.UIThread.VerifyAccess();
Debug.Assert(instance.TargetProperty is not null);

17
src/Avalonia.Base/Utilities/BooleanBoxes.cs

@ -0,0 +1,17 @@
using System.Runtime.CompilerServices;
namespace Avalonia.Utilities
{
/// <summary>
/// Holds cached boxes for the two boolean values, so that converting a boolean to
/// <see cref="object"/> does not allocate (#21065).
/// </summary>
internal static class BooleanBoxes
{
public static readonly object True = true;
public static readonly object False = false;
[MethodImpl(MethodImplOptions.AggressiveInlining)]
public static object Box(bool value) => value ? True : False;
}
}

4
src/Markup/Avalonia.Markup.Xaml.Loader/CompilerExtensions/Transformers/AvaloniaXamlIlWellKnownTypes.cs

@ -50,6 +50,8 @@ namespace Avalonia.Markup.Xaml.XamlIl.CompilerExtensions.Transformers
public IXamlMethod INameScopeComplete { get; }
public IXamlType IPropertyInfo { get; }
public IXamlType ClrPropertyInfo { get; }
public IXamlType IPropertyInfoT { get; }
public IXamlType ClrPropertyInfoT { get; }
public IXamlType IPropertyAccessor { get; }
public IXamlType PropertyInfoAccessorFactory { get; }
public IXamlType CompiledBinding { get; }
@ -248,6 +250,8 @@ namespace Avalonia.Markup.Xaml.XamlIl.CompilerExtensions.Transformers
false, AvaloniaProperty, XamlIlTypes.Object, BindingPriority);
IPropertyInfo = typeSystem.GetType("Avalonia.Data.Core.IPropertyInfo");
ClrPropertyInfo = typeSystem.GetType("Avalonia.Data.Core.ClrPropertyInfo");
IPropertyInfoT = typeSystem.GetType("Avalonia.Data.Core.IPropertyInfo`2");
ClrPropertyInfoT = typeSystem.GetType("Avalonia.Data.Core.ClrPropertyInfo`2");
IPropertyAccessor = typeSystem.GetType("Avalonia.Data.Core.Plugins.IPropertyAccessor");
PropertyInfoAccessorFactory = typeSystem.GetType("Avalonia.Markup.Xaml.MarkupExtensions.CompiledBindings.PropertyInfoAccessorFactory");
CompiledBinding = typeSystem.GetType("Avalonia.Data.CompiledBinding");

53
src/Markup/Avalonia.Markup.Xaml.Loader/CompilerExtensions/XamlIlBindingPathHelper.cs

@ -783,8 +783,34 @@ namespace Avalonia.Markup.Xaml.XamlIl.CompilerExtensions
_acceptsNull = acceptsNull;
}
public IXamlProperty Property => _property;
public bool EmitTyped { get; set; }
[UnconditionalSuppressMessage("Trimming", "IL2122", Justification = TrimmingMessages.TypesInCoreOrAvaloniaAssembly)]
public void Emit(XamlIlEmitContext context, IXamlILEmitter codeGen)
{
if (EmitTyped)
{
var types = context.GetAvaloniaTypes();
var sourceType = _property.Getter?.DeclaringType ?? _property.Setter!.DeclaringType;
var valueType = _property.PropertyType;
context.Configuration.GetExtra<XamlIlClrPropertyInfoEmitter>()
.EmitTyped(context, codeGen, _property);
context.Configuration.GetExtra<XamlIlPropertyInfoAccessorFactoryEmitter>()
.EmitLoadInpcPropertyAccessorFactory(context, codeGen);
codeGen.Ldc_I4(_acceptsNull ? 1 : 0);
var typedPropertyMethod = types.CompiledBindingPathBuilder.GetMethod(m =>
m is { Name: "Property", IsGenericMethod: true } &&
m.Parameters.Count == 3);
codeGen.EmitCall(typedPropertyMethod.MakeGenericMethod(new[] { sourceType, valueType }));
return;
}
context.Configuration.GetExtra<XamlIlClrPropertyInfoEmitter>()
.Emit(context, codeGen, _property);
@ -1079,6 +1105,8 @@ namespace Avalonia.Markup.Xaml.XamlIl.CompilerExtensions
{
var types = context.GetAvaloniaTypes();
TryEnableTypedEmission(_transformElements, Elements);
codeGen.Newobj(types.CompiledBindingPathBuilder.GetConstructor());
foreach (var transform in _transformElements)
@ -1095,6 +1123,31 @@ namespace Avalonia.Markup.Xaml.XamlIl.CompilerExtensions
return XamlILNodeEmitResult.Type(0, types.CompiledBindingPath);
}
private static void TryEnableTypedEmission(
List<IXamlIlBindingPathElementNode> transformElements,
List<IXamlIlBindingPathElementNode> elements)
{
if (transformElements.Count != 0)
return;
if (elements.Count != 1)
return;
if (elements[0] is not XamlIlClrPropertyPathElementNode clr)
return;
var property = clr.Property;
var declaringType = property.Getter?.DeclaringType ?? property.Setter?.DeclaringType;
// TypedBindingExpression<TSource, TValue> requires TSource : class.
if (declaringType is null || declaringType.IsValueType)
return;
// We need an instance getter so the source value can be read.
if (property.Getter is null || property.Getter.IsStatic)
return;
clr.EmitTyped = true;
}
public override void VisitChildren(IXamlAstVisitor visitor)
{
for (int i = 0; i < _transformElements.Count; i++)

182
src/Markup/Avalonia.Markup.Xaml.Loader/CompilerExtensions/XamlIlClrPropertyInfoHelper.cs

@ -16,6 +16,9 @@ namespace Avalonia.Markup.Xaml.XamlIl.CompilerExtensions
private Dictionary<string, List<(IXamlProperty prop, IXamlMethod get)>> _fields
= new Dictionary<string, List<(IXamlProperty prop, IXamlMethod get)>>();
private Dictionary<string, List<(IXamlProperty prop, IXamlMethod get)>> _typedFields
= new Dictionary<string, List<(IXamlProperty prop, IXamlMethod get)>>();
private IXamlField? _boxedTrue;
private IXamlField? _boxedFalse;
@ -65,6 +68,70 @@ namespace Avalonia.Markup.Xaml.XamlIl.CompilerExtensions
return baseKey + $"[{indexerArgumentsKey}]";
}
/// <summary>
/// Searches a property info cache for a method which was already generated for a property.
/// </summary>
/// <param name="fields">The cache to search.</param>
/// <param name="key">The cache key for the property, as returned by <see cref="GetKey"/>.</param>
/// <param name="property">The property.</param>
/// <param name="cached">
/// When the method returns, contains the cache entries for <paramref name="key"/>. The list is
/// created and added to <paramref name="fields"/> if it doesn't yet exist.
/// </param>
/// <returns>
/// The method which returns the property info for <paramref name="property"/>, or null if no
/// such method has been generated yet.
/// </returns>
static IXamlMethod? GetCachedPropertyInfoMethod(
Dictionary<string, List<(IXamlProperty prop, IXamlMethod get)>> fields,
string key,
IXamlProperty property,
out List<(IXamlProperty prop, IXamlMethod get)> cached)
{
if (!fields.TryGetValue(key, out cached!))
fields[key] = cached = new List<(IXamlProperty prop, IXamlMethod get)>();
foreach (var entry in cached)
{
if (
((entry.prop.Getter == null && property.Getter == null) ||
entry.prop.Getter?.Equals(property.Getter) == true) &&
((entry.prop.Setter == null && property.Setter == null) ||
entry.prop.Setter?.Equals(property.Setter) == true)
)
return entry.get;
}
return null;
}
/// <summary>
/// Emits a delegate which invokes a property accessor, or a null reference if the property
/// has no such accessor.
/// </summary>
/// <param name="context">The emit context.</param>
/// <param name="emitter">The emitter to write to.</param>
/// <param name="method">The accessor to wrap, or null if the property has no accessor.</param>
/// <param name="del">The delegate type to construct.</param>
static void EmitFunc(
XamlEmitContext<IXamlILEmitter, XamlILNodeEmitResult> context,
IXamlILEmitter emitter,
IXamlMethod? method,
IXamlType del)
{
if (method == null)
emitter.Ldnull();
else
{
emitter
.Ldnull()
.Ldftn(method)
.Newobj(del.Constructors.First(c =>
c.Parameters.Count == 2 &&
c.Parameters[0].Equals(context.Configuration.WellKnownTypes.Object)));
}
}
public IXamlType Emit(
XamlEmitContext<IXamlILEmitter, XamlILNodeEmitResult> context,
IXamlILEmitter codeGen,
@ -77,19 +144,9 @@ namespace Avalonia.Markup.Xaml.XamlIl.CompilerExtensions
IXamlMethod Get()
{
var key = GetKey(property, indexerArgumentsKey);
if (!_fields.TryGetValue(key, out var lst))
_fields[key] = lst = new List<(IXamlProperty prop, IXamlMethod get)>();
foreach (var cached in lst)
{
if (
((cached.prop.Getter == null && property.Getter == null) ||
cached.prop.Getter?.Equals(property.Getter) == true) &&
((cached.prop.Setter == null && property.Setter == null) ||
cached.prop.Setter?.Equals(property.Setter) == true)
)
return cached.get;
}
if (GetCachedPropertyInfoMethod(_fields, key, property, out var lst) is { } cached)
return cached;
var name = lst.Count == 0 ? key : key + "_" + context.Configuration.IdentifierGenerator.GenerateIdentifierPart();
@ -175,25 +232,94 @@ namespace Avalonia.Markup.Xaml.XamlIl.CompilerExtensions
.MarkLabel(cacheMiss)
.Ldstr(property.Name);
void EmitFunc(IXamlILEmitter emitter, IXamlMethod? method, IXamlType del)
EmitFunc(context, get.Generator, getter, ctor.Parameters[1]);
EmitFunc(context, get.Generator, setter, ctor.Parameters[2]);
get.Generator
.Ldtype(property.PropertyType)
.Newobj(ctor)
.Stsfld(field)
.Ldsfld(field)
.Ret();
lst.Add((property, get));
return get;
}
codeGen.EmitCall(Get());
return types.IPropertyInfo;
}
[System.Diagnostics.CodeAnalysis.UnconditionalSuppressMessage("Trimming", "IL2122", Justification = TrimmingMessages.TypesInCoreOrAvaloniaAssembly)]
public IXamlType EmitTyped(
XamlEmitContext<IXamlILEmitter, XamlILNodeEmitResult> context,
IXamlILEmitter codeGen,
IXamlProperty property)
{
var types = context.GetAvaloniaTypes();
var sourceType = property.Getter?.DeclaringType ?? property.Setter?.DeclaringType
?? throw new InvalidOperationException($"Couldn't get declaring type for property {property}");
var valueType = property.PropertyType;
var typedPropertyInfoType = types.IPropertyInfoT.MakeGenericType(sourceType, valueType);
IXamlMethod Get()
{
var key = GetKey(property, null);
if (GetCachedPropertyInfoMethod(_typedFields, key, property, out var lst) is { } cached)
return cached;
// Only construct the generic types on a cache miss: they're not needed when an
// existing property info method is reused.
var typedClrPropertyInfoType = types.ClrPropertyInfoT.MakeGenericType(sourceType, valueType);
var funcType = context.Configuration.WellKnownTypes.GetFuncOfT(2).MakeGenericType(sourceType, valueType);
var actionType = context.Configuration.WellKnownTypes.GetActionOfT(2).MakeGenericType(sourceType, valueType);
var name = lst.Count == 0
? key + "!Typed"
: key + "!Typed_" + context.Configuration.IdentifierGenerator.GenerateIdentifierPart();
var field = _builder.DefineField(typedPropertyInfoType, name + "!Field", XamlVisibility.Private, true);
var getter = property.Getter == null
? null
: _builder.DefineMethod(valueType, new[] { sourceType }, name + "!Getter", XamlVisibility.Private, true, false);
if (getter != null)
{
if (method == null)
emitter.Ldnull();
else
{
emitter
.Ldnull()
.Ldftn(method)
.Newobj(del.Constructors.First(c =>
c.Parameters.Count == 2 &&
c.Parameters[0].Equals(context.Configuration.WellKnownTypes.Object)));
}
if (!property.Getter!.IsStatic)
getter.Generator.Ldarg_0();
getter.Generator.EmitCall(property.Getter).Ret();
}
EmitFunc(get.Generator, getter, ctor.Parameters[1]);
EmitFunc(get.Generator, setter, ctor.Parameters[2]);
var setter = property.Setter == null
? null
: _builder.DefineMethod(types.XamlIlTypes.Void, new[] { sourceType, valueType }, name + "!Setter", XamlVisibility.Private, true, false);
if (setter != null)
{
if (!property.Setter!.IsStatic)
setter.Generator.Ldarg_0();
setter.Generator.Ldarg(1);
setter.Generator.EmitCall(property.Setter, true).Ret();
}
var get = _builder.DefineMethod(typedPropertyInfoType, Array.Empty<IXamlType>(),
name + "!Property", XamlVisibility.Public, true, false);
var ctor = typedClrPropertyInfoType.Constructors.First(c =>
c.Parameters.Count == 3 && !c.IsStatic);
var cacheMiss = get.Generator.DefineLabel();
get.Generator
.Ldsfld(field)
.Brfalse(cacheMiss)
.Ldsfld(field)
.Ret()
.MarkLabel(cacheMiss)
.Ldstr(property.Name);
EmitFunc(context, get.Generator, getter, funcType);
EmitFunc(context, get.Generator, setter, actionType);
get.Generator
.Ldtype(property.PropertyType)
.Newobj(ctor)
.Stsfld(field)
.Ldsfld(field)
@ -204,7 +330,7 @@ namespace Avalonia.Markup.Xaml.XamlIl.CompilerExtensions
}
codeGen.EmitCall(Get());
return types.IPropertyInfo;
return typedPropertyInfoType;
}
}
}

261
tests/Avalonia.Base.UnitTests/Data/Core/TypedBindingExpressionTests.Compatibility.cs

@ -0,0 +1,261 @@
using System;
using System.Collections.Generic;
using Avalonia.Controls;
using Avalonia.Data;
using Avalonia.Data.Core;
using Avalonia.Diagnostics;
using Avalonia.Logging;
using Avalonia.Markup.Xaml.MarkupExtensions.CompiledBindings;
using Avalonia.Styling;
using Avalonia.UnitTests;
using Xunit;
namespace Avalonia.Base.UnitTests.Data.Core;
/// <summary>
/// Tests which compare the behaviour of <see cref="TypedBindingExpression{TSource, TValue}"/> with
/// the untyped <see cref="BindingExpression"/> for bindings which are eligible for the typed path.
/// </summary>
/// <remarks>
/// Each test is run twice: once with a binding which produces a typed expression and once with an
/// equivalent binding which produces an untyped expression. The assertions describe the behaviour
/// of the untyped expression, i.e. the behaviour of the binding before typed binding expressions
/// were introduced, so a failure in the <c>typed: true</c> case is a user-visible breaking change.
/// </remarks>
public partial class TypedBindingExpressionTests
{
[Theory]
[InlineData(true)]
[InlineData(false)]
public void Null_DataContext_Should_Not_Override_Style_Setter(bool typed)
{
// A binding which has no value must not contribute a value to the target property,
// otherwise the property's default value is applied at LocalValue priority, hiding the
// value from the style setter.
var target = new TextBlock();
var root = new TestRoot
{
Styles =
{
new Style(x => x.OfType<TextBlock>())
{
Setters = { new Setter(TextBlock.TextProperty, "styled") },
},
},
Child = target
};
AssertExpressionType(typed, target.Bind(TextBlock.TextProperty, CreateStringBinding(typed)));
Assert.Equal("styled", target.Text);
Assert.Equal(BindingPriority.Style, target.GetDiagnostic(TextBlock.TextProperty).Priority);
}
[Theory]
[InlineData(true)]
[InlineData(false)]
public void Null_DataContext_Should_Not_Break_Property_Inheritance(bool typed)
{
// As above, but for an inherited property: applying the property's default value at
// LocalValue priority stops the value being inherited from the parent.
var target = new TextBlock();
var root = new TestRoot
{
Child = target,
[TextBlock.FontSizeProperty] = 30.0,
};
AssertExpressionType(typed, target.Bind(TextBlock.FontSizeProperty, CreateDoubleBinding(typed)));
Assert.Equal(30.0, target.FontSize);
Assert.Equal(BindingPriority.Inherited, target.GetDiagnostic(TextBlock.FontSizeProperty).Priority);
}
[Theory]
[InlineData(true)]
[InlineData(false)]
public void Style_Priority_Binding_With_Null_DataContext_Should_Not_Break_Property_Inheritance(bool typed)
{
// The same problem occurs for bindings at a priority other than LocalValue, such as a
// binding in a style setter.
var target = new TextBlock();
var root = new TestRoot
{
Child = target,
[TextBlock.FontSizeProperty] = 30.0,
};
var binding = CreateDoubleBinding(typed);
binding.Priority = BindingPriority.Style;
AssertExpressionType(typed, target.Bind(TextBlock.FontSizeProperty, binding));
Assert.Equal(30.0, target.FontSize);
Assert.Equal(BindingPriority.Inherited, target.GetDiagnostic(TextBlock.FontSizeProperty).Priority);
}
[Theory]
[InlineData(true)]
[InlineData(false)]
public void Setting_Object_Target_Property_To_A_Different_Type_Should_Not_Throw(bool typed)
{
// The binding value type only needs to be assignable to the target property type, so a
// string can be bound to an object-typed property. Writing a value of any other type to
// that property must not throw when the binding reads the new target value.
var data = new ViewModel { StringValue = "foo" };
var target = new TextBlock { DataContext = data };
var root = new TestRoot
{
Child = target
};
AssertExpressionType(typed, target.Bind(TextBlock.TagProperty, CreateStringBinding(typed)));
Assert.Equal("foo", target.Tag);
var ex = Record.Exception(() => target.Tag = 5);
Assert.Null(ex);
Assert.Equal(5, target.Tag);
}
[Theory]
[InlineData(true)]
[InlineData(false)]
public void Setting_Object_Target_Property_To_Null_Should_Not_Throw(bool typed)
{
// As above, but with a value-typed binding: writing null to the object-typed target
// property must not throw when the binding reads the new target value.
var data = new ViewModel { DoubleValue = 1.0 };
var target = new TextBlock { DataContext = data };
var root = new TestRoot
{
Child = target
};
AssertExpressionType(typed, target.Bind(TextBlock.TagProperty, CreateDoubleBinding(typed)));
Assert.Equal(1.0, target.Tag);
var ex = Record.Exception(() => target.SetValue(TextBlock.TagProperty, null));
Assert.Null(ex);
Assert.Null(target.Tag);
}
[Theory]
[InlineData(true)]
[InlineData(false)]
public void Incompatible_DataContext_Should_Log_A_Binding_Error(bool typed)
{
// When the DataContext isn't of the expected type the untyped expression logs a binding
// error; the typed expression silently produces no value.
var errors = new List<string>();
using var sink = TestLogSink.Start((level, area, source, template, values) =>
{
if (level >= LogEventLevel.Warning && area == LogArea.Binding)
errors.Add(template);
});
var target = new TextBlock { DataContext = new ViewModel { StringValue = "foo" } };
var root = new TestRoot
{
Child = target
};
AssertExpressionType(typed, target.Bind(TextBlock.TextProperty, CreateStringBinding(typed)));
Assert.Equal("foo", target.Text);
target.DataContext = new object();
Assert.NotEmpty(errors);
}
[Theory]
[InlineData(true)]
[InlineData(false)]
public void Source_Getter_Exception_Should_Clear_The_Target_Value(bool typed)
{
// When the source getter throws, the untyped expression reports a binding error and
// reverts the target to its default value; the typed expression silently leaves the stale
// value in place.
var errors = new List<string>();
using var sink = TestLogSink.Start((level, area, source, template, values) =>
{
if (level >= LogEventLevel.Warning && area == LogArea.Binding)
errors.Add(template);
});
var data = new ViewModel { StringValue = "foo" };
var target = new TextBlock { DataContext = data };
var root = new TestRoot
{
Child = target
};
AssertExpressionType(typed, target.Bind(TextBlock.TextProperty, CreateStringBinding(typed)));
Assert.Equal("foo", target.Text);
data.ThrowOnGet = true;
data.RaisePropertyChanged(nameof(ViewModel.StringValue));
Assert.Null(target.Text);
Assert.NotEmpty(errors);
}
private static void AssertExpressionType(bool typed, BindingExpressionBase expression)
{
if (typed)
Assert.IsNotType<BindingExpression>(expression);
else
Assert.IsType<BindingExpression>(expression);
}
private static CompiledBinding CreateStringBinding(bool typed, BindingMode mode = BindingMode.OneWay)
{
if (typed)
return CreateBinding(mode);
var path = new CompiledBindingPathBuilder().Property(
new ClrPropertyInfo(
nameof(ViewModel.StringValue),
o => ((ViewModel)o).StringValue,
(o, v) => ((ViewModel)o).StringValue = (string?)v,
typeof(string)),
PropertyInfoAccessorFactory.CreateInpcPropertyAccessor).Build();
return new CompiledBinding(path) { Mode = mode };
}
private static CompiledBinding CreateDoubleBinding(bool typed, BindingMode mode = BindingMode.OneWay)
{
var builder = new CompiledBindingPathBuilder();
if (typed)
{
builder.Property<ViewModel, double>(
new ClrPropertyInfo<ViewModel, double>(
nameof(ViewModel.DoubleValue),
o => o.DoubleValue,
(o, v) => o.DoubleValue = v),
PropertyInfoAccessorFactory.CreateInpcPropertyAccessor,
false);
}
else
{
builder.Property(
new ClrPropertyInfo(
nameof(ViewModel.DoubleValue),
o => ((ViewModel)o).DoubleValue,
(o, v) => ((ViewModel)o).DoubleValue = (double)v!,
typeof(double)),
PropertyInfoAccessorFactory.CreateInpcPropertyAccessor);
}
return new CompiledBinding(builder.Build()) { Mode = mode };
}
}

515
tests/Avalonia.Base.UnitTests/Data/Core/TypedBindingExpressionTests.cs

@ -0,0 +1,515 @@
using System;
using Avalonia.Controls;
using Avalonia.Data;
using Avalonia.Data.Converters;
using Avalonia.Data.Core;
using Avalonia.Markup.Xaml.MarkupExtensions.CompiledBindings;
using Avalonia.UnitTests;
using Xunit;
namespace Avalonia.Base.UnitTests.Data.Core;
public partial class TypedBindingExpressionTests : ScopedTestBase
{
[Fact]
public void Should_Produce_TypedBindingExpression()
{
var binding = CreateBinding();
var target = new TextBlock();
BindAndAssert(target, binding);
}
[Fact]
public void Should_Bind_String_Value()
{
var data = new ViewModel { StringValue = "Hello" };
var target = CreateTarget(data);
Assert.Equal("Hello", target.Text);
}
[Fact]
public void OneWay_Binding_Should_Track_String_Value()
{
var data = new ViewModel { StringValue = "Hello" };
var target = CreateTarget(data, mode: BindingMode.OneWay);
Assert.Equal("Hello", target.Text);
data.StringValue = "World";
Assert.Equal("World", target.Text);
}
[Fact]
public void OneWay_Binding_Should_Track_DataContext()
{
var data1 = new ViewModel { StringValue = "Hello" };
var data2 = new ViewModel { StringValue = "World" };
var target = CreateTarget(data1, mode: BindingMode.OneWay);
Assert.Equal("Hello", target.Text);
target.DataContext = data2;
Assert.Equal("World", target.Text);
}
// The name of this test makes no sense in English but keeping it as it matches the name of
// the test in BindingExpressionTests.
[Fact]
public void OneWay_Binding_Updates_Target_When_Changes_And_Source_Raises_PropertyChanged()
{
var data = new ViewModel { StringValue = "foo" };
var target = CreateTarget(data, mode: BindingMode.OneWay);
Assert.Equal("foo", target.Text);
target.SetCurrentValue(TextBlock.TextProperty, "bar");
Assert.Equal("bar", target.Text);
data.RaisePropertyChanged(nameof(data.StringValue));
Assert.Equal("foo", target.Text);
}
[Fact]
public void TwoWay_Binding_Writes_Value_To_Source()
{
var source = new ViewModel { StringValue = "Hello" };
var target = CreateTarget(source, mode: BindingMode.TwoWay);
Assert.Equal("Hello", target.Text);
source.StringValue = "World";
Assert.Equal("World", target.Text);
target.Text = "Goodbye";
Assert.Equal("Goodbye", source.StringValue);
}
[Fact]
public void TwoWay_Binding_Does_Not_Write_Back_To_Source_On_Attach()
{
var source = new ViewModel { StringValue = "Hello" };
var setsAfterConstruction = source.StringValueSetCount;
var target = CreateTarget(source, mode: BindingMode.TwoWay);
Assert.Equal("Hello", target.Text);
// Pushing the source value to the target must not echo it straight back to the source.
Assert.Equal(setsAfterConstruction, source.StringValueSetCount);
}
[Fact]
public void TwoWay_Binding_Does_Not_Echo_Source_Change_Back_To_Source()
{
var source = new ViewModel { StringValue = "Hello" };
var target = CreateTarget(source, mode: BindingMode.TwoWay);
var before = source.StringValueSetCount;
source.StringValue = "World"; // One setter call: this assignment.
Assert.Equal("World", target.Text);
Assert.Equal(before + 1, source.StringValueSetCount);
}
[Fact]
public void OneTime_Binding_Sets_Target_Only_Once_If_Data_Context_Does_Not_Change()
{
var data = new ViewModel { StringValue = "foo" };
var target = CreateTarget(data, mode: BindingMode.OneTime);
Assert.Equal("foo", target.Text);
data.StringValue = "bar";
Assert.Equal("foo", target.Text);
}
[Fact]
public void OneTime_Binding_Sets_Target_When_Data_Context_Changes()
{
var data = new ViewModel { StringValue = "foo" };
var target = CreateTarget(data, mode: BindingMode.OneTime);
Assert.Equal("foo", target.Text);
target.DataContext = new ViewModel { StringValue = "bar" };
Assert.Equal("bar", target.Text);
}
[Fact]
public void OneTime_Binding_Waits_For_DataContext()
{
var target = CreateTarget(null, mode: BindingMode.OneTime);
Assert.Null(target.Text);
}
[Fact]
public void OneTime_Binding_Waits_For_DataContext_With_Matching_Property_Name()
{
var data1 = new { Baz = "baz" };
var data2 = new ViewModel { StringValue = "foo" };
var target = CreateTarget(null, mode: BindingMode.OneTime);
target.DataContext = data1;
Assert.Null(target.Text);
target.DataContext = data2;
Assert.Equal("foo", target.Text);
data2.StringValue = "bar";
Assert.Equal("foo", target.Text);
}
[Fact]
public void OneTime_Binding_Waits_For_DataContext_With_Matching_Property_Type()
{
var data1 = new { StringValue = 1.5 };
var data2 = new ViewModel { StringValue = "foo" };
var target = CreateTarget(null, mode: BindingMode.OneTime);
target.DataContext = data1;
Assert.Null(target.Text);
target.DataContext = data2;
Assert.Equal("foo", target.Text);
data2.StringValue = "bar";
Assert.Equal("foo", target.Text);
}
[Fact]
public void OneWayToSource_Binding_Updates_Source_When_Target_Changes()
{
var data = new ViewModel();
var target = CreateTarget(data, mode: BindingMode.OneWayToSource);
Assert.Null(data.StringValue);
target.Text = "foo";
Assert.Equal("foo", data.StringValue);
}
[Fact]
public void OneWayToSource_Binding_Does_Not_Update_Target_When_Source_Changes()
{
var data = new ViewModel();
var target = CreateTarget(data, mode: BindingMode.OneWayToSource);
target.Text = "foo";
Assert.Equal("foo", data.StringValue);
data.StringValue = "bar";
Assert.Equal("foo", target.Text);
}
[Fact]
public void OneWayToSource_Binding_Updates_Source_When_DataContext_Changes()
{
var data1 = new ViewModel();
var data2 = new ViewModel();
var target = CreateTarget(data1, mode: BindingMode.OneWayToSource);
target.Text = "foo";
Assert.Equal("foo", data1.StringValue);
target.DataContext = data2;
Assert.Equal("foo", data2.StringValue);
}
[Fact]
public void Can_Bind_Readonly_Property_OneWayToSource()
{
var data = new ViewModel();
var target = new SelectableTextBlock
{
DataContext = data,
Text = "foobar",
SelectionStart = 0,
SelectionEnd = 3
};
Assert.Equal("foo", target.SelectedText);
var binding = CreateBinding(mode: BindingMode.OneWayToSource);
target.Bind(SelectableTextBlock.SelectedTextProperty, binding);
Assert.Equal("foo", data.StringValue);
target.SelectionEnd = 4;
// TODO: Uncomment when https://github.com/AvaloniaUI/Avalonia/issues/21461 fixed.
//Assert.Equal("foob", data.StringValue);
}
[Fact]
public void Can_Bind_String_To_Object()
{
var log = string.Empty;
using var logger = TestLogSink.Start((_, _, _, m, _) => log += m);
var source = new ViewModel { StringValue = "Hello" };
var binding = CreateBinding();
var target = new TextBlock { DataContext = source };
var expression = target.Bind(TextBlock.TagProperty, binding);
Assert.IsType<TypedBindingExpression<ViewModel, string?>>(expression);
}
[Fact]
public void Disposing_Binding_Unsubscribes_From_Source()
{
var data = new ViewModel { StringValue = "foo" };
var target = new TextBlock { DataContext = data };
var binding = CreateBinding();
var expression = target.Bind(TextBlock.TextProperty, binding);
Assert.Equal("foo", target.Text);
Assert.Equal(1, data.PropertyChangedSubscriptionCount);
expression.Dispose();
Assert.Equal(0, data.PropertyChangedSubscriptionCount);
// Source changes no longer propagate to the (now unbound) target.
data.StringValue = "bar";
Assert.NotEqual("bar", target.Text);
}
[Fact]
public void Rebinding_Same_Property_Unsubscribes_Previous_Binding()
{
var data = new ViewModel { StringValue = "foo" };
var target = new TextBlock { DataContext = data };
target.Bind(TextBlock.TextProperty, CreateBinding());
target.Bind(TextBlock.TextProperty, CreateBinding());
// The first binding should have been disposed when the second was applied, leaving a
// single subscription rather than two.
Assert.Equal(1, data.PropertyChangedSubscriptionCount);
}
[Fact]
public void Should_Not_Produce_TypedBindingExpression_When_Binding_String_To_Double()
{
var log = string.Empty;
using var logger = TestLogSink.Start((_, _, _, m, _) => log += m);
var source = new ViewModel { StringValue = "Hello" };
var binding = CreateBinding();
var target = new TextBlock { DataContext = source };
var expression = target.Bind(TextBlock.OpacityProperty, binding);
Assert.IsType<BindingExpression>(expression);
}
[Fact]
public void Should_Not_Produce_TypedBindingExpression_When_Converter_Is_Present()
{
var binding = CreateBinding();
binding.Converter = new FuncValueConverter<string?, string?>(s => s);
var target = new TextBlock();
var expression = target.Bind(TextBlock.TextProperty, binding);
Assert.IsType<BindingExpression>(expression);
}
[Fact]
public void Should_Not_Produce_TypedBindingExpression_When_Binding_DataContext()
{
var log = string.Empty;
using var logger = TestLogSink.Start((_, _, _, m, _) => log += m);
var source = new ViewModel { StringValue = "Hello" };
var binding = CreateBinding();
var target = new TextBlock();
var expression = target.Bind(TextBlock.DataContextProperty, binding);
Assert.IsType<BindingExpression>(expression);
}
[Theory]
[InlineData(null)]
[InlineData("")]
public void OneWay_Binding_Updates_Target_When_Source_Raises_PropertyChanged_For_All_Properties(
string? allPropertiesName)
{
// A null or empty PropertyName means "all properties changed" per the INotifyPropertyChanged
// contract, so the binding must re-read its source value.
var data = new ViewModel { StringValue = "foo" };
var target = CreateTarget(data, mode: BindingMode.OneWay);
Assert.Equal("foo", target.Text);
data.SetStringValueWithoutNotification("bar");
data.RaisePropertyChanged(allPropertiesName);
Assert.Equal("bar", target.Text);
}
[Fact]
public void Getter_Exception_Does_Not_Propagate_When_Source_Raises_PropertyChanged()
{
// The untyped binding path swallows getter exceptions to avoid crashing the UI thread; the
// typed path must do the same rather than letting them escape into the event handler.
var data = new ViewModel { StringValue = "foo" };
var target = CreateTarget(data, mode: BindingMode.OneWay);
Assert.Equal("foo", target.Text);
data.ThrowOnGet = true;
var ex = Record.Exception(() => data.RaisePropertyChanged(nameof(ViewModel.StringValue)));
Assert.Null(ex);
}
[Fact]
public void Should_Not_Produce_TypedBindingExpression_For_ReadOnly_Source_In_TwoWay()
{
// A read-only source property cannot be written back to in TwoWay/OneWayToSource modes, so
// the untyped path (which fails silently) must be used instead.
var propertyInfo = new ClrPropertyInfo<ViewModel, string?>(
nameof(ViewModel.StringValue),
v => v.StringValue,
setter: null);
var binding = CreateBinding(propertyInfo, mode: BindingMode.TwoWay);
var target = new TextBlock { DataContext = new ViewModel() };
var expression = target.Bind(TextBlock.TextProperty, binding);
Assert.IsType<BindingExpression>(expression);
}
[Fact]
public void Should_Not_Produce_TypedBindingExpression_When_Target_Type_Is_Wider_In_TwoWay()
{
// The source is a string but the target property is object. The forward assignment is valid
// but writing an arbitrary object back to the string source could throw, so the untyped path
// must be used.
var source = new ViewModel { StringValue = "Hello" };
var binding = CreateBinding(mode: BindingMode.TwoWay);
var target = new TextBlock { DataContext = source };
var expression = target.Bind(TextBlock.TagProperty, binding);
Assert.IsType<BindingExpression>(expression);
}
[Fact]
public void Should_Not_Produce_TypedBindingExpression_For_Non_StyledElement_Target()
{
// TypedBindingExpression only supports StyledElement targets; other AvaloniaObjects (e.g.
// Application, which is an IDataContextProvider but not a StyledElement) must use the untyped
// path rather than throwing at runtime.
var binding = CreateBinding();
var target = new NonStyledTarget { DataContext = new ViewModel { StringValue = "Hello" } };
var expression = target.Bind(NonStyledTarget.ValueProperty, binding);
Assert.IsType<BindingExpression>(expression);
}
private static TypedBindingExpression<ViewModel, string?> BindAndAssert(StyledElement target, BindingBase binding)
{
var expression = target.Bind(TextBlock.TextProperty, binding);
return Assert.IsType<TypedBindingExpression<ViewModel, string?>>(expression);
}
private static CompiledBinding CreateBinding(BindingMode mode = BindingMode.OneWay)
{
var propertyInfo = new ClrPropertyInfo<ViewModel, string?>(
nameof(ViewModel.StringValue),
v => v.StringValue,
(o, v) => o.StringValue = v);
return CreateBinding(propertyInfo, mode);
}
private static CompiledBinding CreateBinding(
IPropertyInfo<ViewModel, string?> propertyInfo,
BindingMode mode = BindingMode.OneWay)
{
var path = new CompiledBindingPathBuilder().Property<ViewModel, string?>(
propertyInfo,
PropertyInfoAccessorFactory.CreateInpcPropertyAccessor,
false).Build();
return new CompiledBinding(path) { Mode = mode, };
}
private static TextBlock CreateTarget(ViewModel? data, BindingMode mode = BindingMode.OneWay)
{
var result = new TextBlock { DataContext = data };
var binding = CreateBinding(mode);
BindAndAssert(result, binding);
return result;
}
private class ViewModel : NotifyingBase
{
private string? _stringValue;
private double _doubleValue;
// Counts every setter invocation so tests can assert the binding doesn't write spurious
// values back to the source. PropertyChanged is only raised on a real change.
public int StringValueSetCount { get; private set; }
// When set, the getter throws so tests can verify getter exceptions don't escape the
// binding's PropertyChanged handler.
public bool ThrowOnGet { get; set; }
public string? StringValue
{
get
{
if (ThrowOnGet)
throw new InvalidOperationException("Getter failed.");
return _stringValue;
}
set
{
++StringValueSetCount;
SetField(ref _stringValue, value);
}
}
public double DoubleValue
{
get => _doubleValue;
set => SetField(ref _doubleValue, value);
}
// Mutates the backing field without raising PropertyChanged, so tests can then raise an
// "all properties changed" notification (null/empty name) and observe the binding react.
public void SetStringValueWithoutNotification(string? value) => _stringValue = value;
}
private class NonStyledTarget : AvaloniaObject, IDataContextProvider
{
public static readonly StyledProperty<object?> DataContextProperty =
StyledElement.DataContextProperty.AddOwner<NonStyledTarget>();
public static readonly StyledProperty<string?> ValueProperty =
AvaloniaProperty.Register<NonStyledTarget, string?>(nameof(Value));
public object? DataContext
{
get => GetValue(DataContextProperty);
set => SetValue(DataContextProperty, value);
}
public string? Value
{
get => GetValue(ValueProperty);
set => SetValue(ValueProperty, value);
}
}
}

143
tests/Avalonia.Benchmarks/Data/TypedBinding_Setup.cs

@ -0,0 +1,143 @@
using System.ComponentModel;
using System.Runtime.CompilerServices;
using Avalonia.Controls;
using Avalonia.Data;
using Avalonia.Data.Core;
using Avalonia.Markup.Xaml.MarkupExtensions.CompiledBindings;
using BenchmarkDotNet.Attributes;
#nullable enable
namespace Avalonia.Benchmarks.Data;
// Compares the setup (create instance + attach + dispose) cost of the three kinds of binding to
// a DataContext property:
//
// - Typed: the new TypedBindingExpression (built here by hand as the XAML compiler does
// not yet produce it). Strongly typed, does not box.
// - CompiledBinding: the untyped CompiledBinding that the XAML compiler produces today for
// `{Binding Value}` (uses an InpcPropertyAccessor + boxed values).
// - Reflection: the classic reflection-based `Binding`.
//
// An `int` property is used as boxing avoidance is the main selling point of the typed expression.
// The binding instances are created once and reused across the loop (as they are in real XAML)
// so that only the per-bind cost is measured, not path/expression construction.
[MemoryDiagnoser]
public class TypedBinding_Setup
{
private readonly TestData _data = new();
private readonly TestControl _target = new();
private readonly CompiledBinding _typedOneWay = CreateTypedBinding(BindingMode.OneWay);
private readonly CompiledBinding _typedTwoWay = CreateTypedBinding(BindingMode.TwoWay);
private readonly CompiledBinding _compiledOneWay = CreateCompiledBinding(BindingMode.OneWay);
private readonly CompiledBinding _compiledTwoWay = CreateCompiledBinding(BindingMode.TwoWay);
private readonly Binding _reflectionOneWay = new(nameof(TestData.IntValue)) { Mode = BindingMode.OneWay };
private readonly Binding _reflectionTwoWay = new(nameof(TestData.IntValue)) { Mode = BindingMode.TwoWay };
public TypedBinding_Setup()
{
_target.DataContext = _data;
}
[Benchmark]
public void Setup_Typed_OneWay()
{
for (var i = 0; i < 100; ++i)
using (_target.Bind(TestControl.IntValueProperty, _typedOneWay)) { }
}
[Benchmark]
public void Setup_CompiledBinding_OneWay()
{
for (var i = 0; i < 100; ++i)
using (_target.Bind(TestControl.IntValueProperty, _compiledOneWay)) { }
}
[Benchmark]
public void Setup_Reflection_OneWay()
{
for (var i = 0; i < 100; ++i)
using (_target.Bind(TestControl.IntValueProperty, _reflectionOneWay)) { }
}
[Benchmark]
public void Setup_Typed_TwoWay()
{
for (var i = 0; i < 100; ++i)
using (_target.Bind(TestControl.IntValueProperty, _typedTwoWay)) { }
}
[Benchmark]
public void Setup_CompiledBinding_TwoWay()
{
for (var i = 0; i < 100; ++i)
using (_target.Bind(TestControl.IntValueProperty, _compiledTwoWay)) { }
}
[Benchmark]
public void Setup_Reflection_TwoWay()
{
for (var i = 0; i < 100; ++i)
using (_target.Bind(TestControl.IntValueProperty, _reflectionTwoWay)) { }
}
private static CompiledBinding CreateTypedBinding(BindingMode mode)
{
var propertyInfo = new ClrPropertyInfo<TestData, int>(
nameof(TestData.IntValue),
v => v.IntValue,
(o, v) => o.IntValue = v);
var path = new CompiledBindingPathBuilder().Property(
propertyInfo,
PropertyInfoAccessorFactory.CreateInpcPropertyAccessor,
false).Build();
return new CompiledBinding(path) { Mode = mode };
}
// Builds the untyped CompiledBinding the same way the XAML compiler does today: a
// CompiledBindingPath with an (object-typed) ClrPropertyInfo accessed via an
// InpcPropertyAccessor. Differs from the typed binding only in the path element used.
private static CompiledBinding CreateCompiledBinding(BindingMode mode)
{
var propertyInfo = new ClrPropertyInfo(
nameof(TestData.IntValue),
o => ((TestData)o).IntValue,
(o, v) => ((TestData)o).IntValue = (int)v!,
typeof(int));
var path = new CompiledBindingPathBuilder()
.Property(propertyInfo, PropertyInfoAccessorFactory.CreateInpcPropertyAccessor)
.Build();
return new CompiledBinding(path) { Mode = mode };
}
private class TestControl : Control
{
public static readonly StyledProperty<int> IntValueProperty =
AvaloniaProperty.Register<TestControl, int>(nameof(IntValue));
public int IntValue
{
get => GetValue(IntValueProperty);
set => SetValue(IntValueProperty, value);
}
}
private class TestData : INotifyPropertyChanged
{
private int _intValue;
public event PropertyChangedEventHandler? PropertyChanged;
public int IntValue
{
get => _intValue;
set
{
if (_intValue == value)
return;
_intValue = value;
PropertyChanged?.Invoke(this, new PropertyChangedEventArgs(nameof(IntValue)));
}
}
}
}

162
tests/Avalonia.Benchmarks/Data/TypedBinding_Values.cs

@ -0,0 +1,162 @@
using System.ComponentModel;
using Avalonia.Controls;
using Avalonia.Data;
using Avalonia.Data.Core;
using Avalonia.Markup.Xaml.MarkupExtensions.CompiledBindings;
using BenchmarkDotNet.Attributes;
#nullable enable
namespace Avalonia.Benchmarks.Data;
// Compares the steady-state cost of pushing values through the three kinds of binding to a
// DataContext property: see TypedBinding_Setup for a description of each kind.
//
// The binding is attached once and then 100 value changes are pushed through it. An `int` property
// is used so that the OneWay benchmarks highlight the per-value boxing that the typed expression
// avoids; the allocation column (from MemoryDiagnoser) is the interesting one here.
[MemoryDiagnoser]
public class TypedBinding_Values
{
private TestData _data = null!;
private TestControl _target = null!;
[GlobalSetup]
public void Setup()
{
_data = new TestData();
_target = new TestControl { DataContext = _data };
}
[Benchmark]
public void Produce_Typed_OneWay()
{
_data.IntValue = -1;
using var d = _target.Bind(TestControl.IntValueProperty, CreateTypedBinding(BindingMode.OneWay));
for (var i = 0; i < 100; ++i)
_data.IntValue = i;
}
[Benchmark]
public void Produce_CompiledBinding_OneWay()
{
_data.IntValue = -1;
using var d = _target.Bind(TestControl.IntValueProperty, CreateCompiledBinding(BindingMode.OneWay));
for (var i = 0; i < 100; ++i)
_data.IntValue = i;
}
[Benchmark]
public void Produce_Reflection_OneWay()
{
_data.IntValue = -1;
using var d = _target.Bind(TestControl.IntValueProperty, CreateReflectionBinding(BindingMode.OneWay));
for (var i = 0; i < 100; ++i)
_data.IntValue = i;
}
[Benchmark]
public void Produce_Typed_TwoWay()
{
_data.IntValue = -1;
using var d = _target.Bind(TestControl.IntValueProperty, CreateTypedBinding(BindingMode.TwoWay));
for (var i = 0; i < 100; ++i)
{
_data.IntValue = i * 2;
_target.IntValue = (i * 2) + 1;
}
}
[Benchmark]
public void Produce_CompiledBinding_TwoWay()
{
_data.IntValue = -1;
using var d = _target.Bind(TestControl.IntValueProperty, CreateCompiledBinding(BindingMode.TwoWay));
for (var i = 0; i < 100; ++i)
{
_data.IntValue = i * 2;
_target.IntValue = (i * 2) + 1;
}
}
[Benchmark]
public void Produce_Reflection_TwoWay()
{
_data.IntValue = -1;
using var d = _target.Bind(TestControl.IntValueProperty, CreateReflectionBinding(BindingMode.TwoWay));
for (var i = 0; i < 100; ++i)
{
_data.IntValue = i * 2;
_target.IntValue = (i * 2) + 1;
}
}
private static CompiledBinding CreateTypedBinding(BindingMode mode)
{
var propertyInfo = new ClrPropertyInfo<TestData, int>(
nameof(TestData.IntValue),
v => v.IntValue,
(o, v) => o.IntValue = v);
var path = new CompiledBindingPathBuilder().Property(
propertyInfo,
PropertyInfoAccessorFactory.CreateInpcPropertyAccessor,
false).Build();
return new CompiledBinding(path) { Mode = mode };
}
// Builds the untyped CompiledBinding the same way the XAML compiler does today: a
// CompiledBindingPath with an (object-typed) ClrPropertyInfo accessed via an
// InpcPropertyAccessor. Differs from the typed binding only in the path element used.
private static CompiledBinding CreateCompiledBinding(BindingMode mode)
{
var propertyInfo = new ClrPropertyInfo(
nameof(TestData.IntValue),
o => ((TestData)o).IntValue,
(o, v) => ((TestData)o).IntValue = (int)v!,
typeof(int));
var path = new CompiledBindingPathBuilder()
.Property(propertyInfo, PropertyInfoAccessorFactory.CreateInpcPropertyAccessor)
.Build();
return new CompiledBinding(path) { Mode = mode };
}
private static Binding CreateReflectionBinding(BindingMode mode)
=> new(nameof(TestData.IntValue)) { Mode = mode };
private class TestControl : Control
{
public static readonly StyledProperty<int> IntValueProperty =
AvaloniaProperty.Register<TestControl, int>(nameof(IntValue));
public int IntValue
{
get => GetValue(IntValueProperty);
set => SetValue(IntValueProperty, value);
}
}
private class TestData : INotifyPropertyChanged
{
private int _intValue;
public event PropertyChangedEventHandler? PropertyChanged;
public int IntValue
{
get => _intValue;
set
{
if (_intValue == value)
return;
_intValue = value;
PropertyChanged?.Invoke(this, new PropertyChangedEventArgs(nameof(IntValue)));
}
}
}
}

71
tests/Avalonia.Controls.UnitTests/TextBoxTests_DataValidation.cs

@ -149,6 +149,46 @@ namespace Avalonia.Controls.UnitTests
}
}
[Fact]
public void CompiledBinding_To_DataValidation_Property_Reports_Data_Validation_Errors()
{
// This binding is shape-eligible for the typed binding expression (a directly
// assignable single-property DataContext binding), which does not support data
// validation. Because TextBox.Text enables data validation it must fall back to the
// untyped BindingExpression and still surface validation errors.
var path = new CompiledBindingPathBuilder()
.Property(
new ClrPropertyInfo<IndeiStringTest, string?>(
nameof(IndeiStringTest.Value),
o => o.Value,
(o, v) => o.Value = v),
PropertyInfoAccessorFactory.CreateInpcPropertyAccessor,
acceptsNull: true)
.Build();
using (UnitTestApplication.Start(Services))
{
var target = new TextBox
{
DataContext = new IndeiStringTest(),
[!TextBox.TextProperty] = new CompiledBindingExtension
{
Path = path,
Mode = BindingMode.TwoWay,
},
Template = CreateTemplate(),
};
target.ApplyTemplate();
Assert.False(DataValidationErrors.GetHasErrors(target));
target.Text = "bad";
Assert.True(DataValidationErrors.GetHasErrors(target));
target.Text = "good";
Assert.False(DataValidationErrors.GetHasErrors(target));
}
}
private static TestServices Services => TestServices.MockThreadingInterface.With(
standardCursorFactory: Mock.Of<ICursorFactory>(),
textShaperImpl: new HarfBuzzTextShaper(),
@ -226,5 +266,36 @@ namespace Avalonia.Controls.UnitTests
return Array.Empty<string?>();
}
}
private class IndeiStringTest : INotifyDataErrorInfo
{
private readonly Dictionary<string, IList<string>> _errors = new();
private string? _value;
public string? Value
{
get => _value;
set
{
_value = value;
if (value == "bad")
_errors[nameof(Value)] = new[] { "Invalid" };
else
_errors.Remove(nameof(Value));
ErrorsChanged?.Invoke(this, new DataErrorsChangedEventArgs(nameof(Value)));
}
}
public bool HasErrors => _errors.Count > 0;
public event EventHandler<DataErrorsChangedEventArgs>? ErrorsChanged;
public IEnumerable GetErrors(string? propertyName)
{
if (propertyName is not null && _errors.TryGetValue(propertyName, out var result))
return result;
return Array.Empty<string?>();
}
}
}
}

202
tests/Avalonia.Markup.Xaml.UnitTests/MarkupExtensions/CompiledBindingExtensionTests.cs

@ -640,7 +640,7 @@ namespace Avalonia.Markup.Xaml.UnitTests.MarkupExtensions
// Assert DataGridLikeColumn.Binding data type.
var compiledPath = ((CompiledBinding)column.Binding!).Path;
var node = Assert.IsType<PropertyElement>(Assert.Single(compiledPath!.Elements));
var node = Assert.IsAssignableFrom<PropertyElement>(Assert.Single(compiledPath!.Elements));
Assert.Equal(typeof(string), node.Property.PropertyType);
Assert.Equal(nameof(TestData.StringProperty), node.Property.Name);
@ -683,7 +683,7 @@ namespace Avalonia.Markup.Xaml.UnitTests.MarkupExtensions
// Assert DataGridLikeColumn.Binding data type.
var compiledPath = ((CompiledBinding)column.Binding!).Path;
var node = Assert.IsType<PropertyElement>(Assert.Single(compiledPath!.Elements));
var node = Assert.IsAssignableFrom<PropertyElement>(Assert.Single(compiledPath!.Elements));
Assert.Equal(typeof(int), node.Property.PropertyType);
// Assert DataGridLikeColumn.Template data type by evaluating the template.
@ -728,7 +728,7 @@ namespace Avalonia.Markup.Xaml.UnitTests.MarkupExtensions
// Assert DataGridLikeColumn.Binding data type.
var compiledPath = ((CompiledBinding)column.Binding!).Path;
var node = Assert.IsType<PropertyElement>(Assert.Single(compiledPath!.Elements));
var node = Assert.IsAssignableFrom<PropertyElement>(Assert.Single(compiledPath!.Elements));
Assert.Equal(typeof(int), node.Property.PropertyType);
// Assert DataGridLikeColumn.Template data type by evaluating the template.
@ -2235,7 +2235,7 @@ namespace Avalonia.Markup.Xaml.UnitTests.MarkupExtensions
var control = (AssignBindingControl)AvaloniaRuntimeXamlLoader.Load(xaml);
var compiledPath = ((CompiledBinding)control.X!).Path;
var node = Assert.IsType<PropertyElement>(Assert.Single(compiledPath!.Elements));
var node = Assert.IsAssignableFrom<PropertyElement>(Assert.Single(compiledPath!.Elements));
Assert.Equal(typeof(string), node.Property.PropertyType);
}
}
@ -2253,7 +2253,7 @@ namespace Avalonia.Markup.Xaml.UnitTests.MarkupExtensions
var control = (AssignBindingControl)AvaloniaRuntimeXamlLoader.Load(xaml);
var compiledPath = ((CompiledBinding)control.X!).Path;
var node = Assert.IsType<PropertyElement>(Assert.Single(compiledPath!.Elements));
var node = Assert.IsAssignableFrom<PropertyElement>(Assert.Single(compiledPath!.Elements));
Assert.Equal(typeof(string), node.Property.PropertyType);
}
}
@ -2272,7 +2272,7 @@ namespace Avalonia.Markup.Xaml.UnitTests.MarkupExtensions
new RuntimeXamlLoaderConfiguration { UseCompiledBindingsByDefault = true });
var compiledPath = ((CompiledBinding)control.X!).Path;
var node = Assert.IsType<PropertyElement>(Assert.Single(compiledPath!.Elements));
var node = Assert.IsAssignableFrom<PropertyElement>(Assert.Single(compiledPath!.Elements));
Assert.Equal(typeof(string), node.Property.PropertyType);
}
}
@ -2573,6 +2573,196 @@ namespace Avalonia.Markup.Xaml.UnitTests.MarkupExtensions
}
}
[Fact]
public void Emits_TypedBindingExpression_For_Simple_DataContext_Binding()
{
using (UnitTestApplication.Start(TestServices.StyledWindow))
{
var window = (Window)AvaloniaRuntimeXamlLoader.Load(@"
<Window xmlns='https://github.com/avaloniaui'
xmlns:x='http://schemas.microsoft.com/winfx/2006/xaml'
xmlns:local='clr-namespace:Avalonia.Markup.Xaml.UnitTests.MarkupExtensions;assembly=Avalonia.Markup.Xaml.UnitTests'
x:DataType='local:TestDataContext'>
<TextBlock Text='{CompiledBinding StringProperty}' Name='textBlock' />
</Window>");
var textBlock = window.GetControl<TextBlock>("textBlock");
window.DataContext = new TestDataContext { StringProperty = "hello" };
var expression = BindingOperations.GetBindingExpressionBase(textBlock, TextBlock.TextProperty);
Assert.IsType<TypedBindingExpression<TestDataContext, string>>(expression);
Assert.Equal("hello", textBlock.Text);
}
}
[Theory]
[InlineData("OneWay")]
[InlineData("TwoWay")]
[InlineData("OneWayToSource")]
[InlineData("OneTime")]
public void Emits_TypedBindingExpression_For_All_Standard_Modes(string mode)
{
using (UnitTestApplication.Start(TestServices.StyledWindow))
{
var window = (Window)AvaloniaRuntimeXamlLoader.Load($@"
<Window xmlns='https://github.com/avaloniaui'
xmlns:x='http://schemas.microsoft.com/winfx/2006/xaml'
xmlns:local='clr-namespace:Avalonia.Markup.Xaml.UnitTests.MarkupExtensions;assembly=Avalonia.Markup.Xaml.UnitTests'
x:DataType='local:TestDataContext'>
<TextBlock Text='{{CompiledBinding StringProperty, Mode={mode}}}' Name='textBlock' />
</Window>");
var textBlock = window.GetControl<TextBlock>("textBlock");
window.DataContext = new TestDataContext { StringProperty = "x" };
var expression = BindingOperations.GetBindingExpressionBase(textBlock, TextBlock.TextProperty);
Assert.IsType<TypedBindingExpression<TestDataContext, string>>(expression);
}
}
[Fact]
public void Emits_TypedBindingExpression_For_Binding_With_CompileBindings_True()
{
using (UnitTestApplication.Start(TestServices.StyledWindow))
{
var window = (Window)AvaloniaRuntimeXamlLoader.Load(@"
<Window xmlns='https://github.com/avaloniaui'
xmlns:x='http://schemas.microsoft.com/winfx/2006/xaml'
xmlns:local='clr-namespace:Avalonia.Markup.Xaml.UnitTests.MarkupExtensions;assembly=Avalonia.Markup.Xaml.UnitTests'
x:DataType='local:TestDataContext'
x:CompileBindings='True'>
<TextBlock Text='{Binding StringProperty}' Name='textBlock' />
</Window>");
var textBlock = window.GetControl<TextBlock>("textBlock");
window.DataContext = new TestDataContext { StringProperty = "hi" };
var expression = BindingOperations.GetBindingExpressionBase(textBlock, TextBlock.TextProperty);
Assert.IsType<TypedBindingExpression<TestDataContext, string>>(expression);
}
}
[Fact]
public void Falls_Back_To_BindingExpression_For_Nested_Path()
{
using (UnitTestApplication.Start(TestServices.StyledWindow))
{
var window = (Window)AvaloniaRuntimeXamlLoader.Load(@"
<Window xmlns='https://github.com/avaloniaui'
xmlns:x='http://schemas.microsoft.com/winfx/2006/xaml'
xmlns:local='clr-namespace:Avalonia.Markup.Xaml.UnitTests.MarkupExtensions;assembly=Avalonia.Markup.Xaml.UnitTests'
x:DataType='local:TestDataContext'>
<TextBlock Text='{CompiledBinding NestedGenericString.Value}' Name='textBlock' />
</Window>");
var textBlock = window.GetControl<TextBlock>("textBlock");
window.DataContext = new TestDataContext { NestedGenericString = new TestDataContext.NestedGeneric<string> { Value = "v" } };
var expression = BindingOperations.GetBindingExpressionBase(textBlock, TextBlock.TextProperty);
Assert.IsType<BindingExpression>(expression);
}
}
[Fact]
public void Falls_Back_To_BindingExpression_When_Converter_Set()
{
using (UnitTestApplication.Start(TestServices.StyledWindow))
{
var window = (Window)AvaloniaRuntimeXamlLoader.Load(@"
<Window xmlns='https://github.com/avaloniaui'
xmlns:x='http://schemas.microsoft.com/winfx/2006/xaml'
xmlns:local='clr-namespace:Avalonia.Markup.Xaml.UnitTests.MarkupExtensions;assembly=Avalonia.Markup.Xaml.UnitTests'
x:DataType='local:TestDataContext'>
<TextBlock Text='{CompiledBinding StringProperty, Converter={x:Static local:AppendConverter.Instance}, ConverterParameter=suffix}' Name='textBlock' />
</Window>");
var textBlock = window.GetControl<TextBlock>("textBlock");
window.DataContext = new TestDataContext { StringProperty = "x" };
var expression = BindingOperations.GetBindingExpressionBase(textBlock, TextBlock.TextProperty);
Assert.IsType<BindingExpression>(expression);
}
}
[Fact]
public void Falls_Back_To_BindingExpression_For_DataValidation_Enabled_Property()
{
using (UnitTestApplication.Start(TestServices.StyledWindow))
{
// TextBox.Text enables data validation, which TypedBindingExpression does not
// support, so the binding must fall back to the untyped BindingExpression even
// though it is otherwise eligible for the typed path.
var window = (Window)AvaloniaRuntimeXamlLoader.Load(@"
<Window xmlns='https://github.com/avaloniaui'
xmlns:x='http://schemas.microsoft.com/winfx/2006/xaml'
xmlns:local='clr-namespace:Avalonia.Markup.Xaml.UnitTests.MarkupExtensions;assembly=Avalonia.Markup.Xaml.UnitTests'
x:DataType='local:TestDataContext'>
<TextBox Text='{CompiledBinding StringProperty}' Name='textBox' />
</Window>");
var textBox = window.GetControl<TextBox>("textBox");
window.DataContext = new TestDataContext { StringProperty = "hello" };
var expression = BindingOperations.GetBindingExpressionBase(textBox, TextBox.TextProperty);
Assert.IsType<BindingExpression>(expression);
Assert.Equal("hello", textBox.Text);
}
}
[Fact]
public void Falls_Back_To_BindingExpression_When_StringFormat_Set()
{
using (UnitTestApplication.Start(TestServices.StyledWindow))
{
var window = (Window)AvaloniaRuntimeXamlLoader.Load(@"
<Window xmlns='https://github.com/avaloniaui'
xmlns:x='http://schemas.microsoft.com/winfx/2006/xaml'
xmlns:local='clr-namespace:Avalonia.Markup.Xaml.UnitTests.MarkupExtensions;assembly=Avalonia.Markup.Xaml.UnitTests'
x:DataType='local:TestDataContext'>
<TextBlock Text='{CompiledBinding DecimalValue, StringFormat=c2}' Name='textBlock' />
</Window>");
var textBlock = window.GetControl<TextBlock>("textBlock");
window.DataContext = new TestDataContext();
var expression = BindingOperations.GetBindingExpressionBase(textBlock, TextBlock.TextProperty);
Assert.IsType<BindingExpression>(expression);
}
}
[Fact]
public void Falls_Back_To_BindingExpression_For_Negated_Binding()
{
using (UnitTestApplication.Start(TestServices.StyledWindow))
{
var window = (Window)AvaloniaRuntimeXamlLoader.Load(@"
<Window xmlns='https://github.com/avaloniaui'
xmlns:x='http://schemas.microsoft.com/winfx/2006/xaml'
xmlns:local='clr-namespace:Avalonia.Markup.Xaml.UnitTests.MarkupExtensions;assembly=Avalonia.Markup.Xaml.UnitTests'
x:DataType='local:TestDataContext'>
<TextBlock Tag='{CompiledBinding !BoolProperty}' Name='textBlock' />
</Window>");
var textBlock = window.GetControl<TextBlock>("textBlock");
window.DataContext = new TestDataContext { BoolProperty = true };
var expression = BindingOperations.GetBindingExpressionBase(textBlock, TextBlock.TagProperty);
Assert.IsType<BindingExpression>(expression);
}
}
[Fact]
public void Falls_Back_To_BindingExpression_For_DataContext_Target()
{
using (UnitTestApplication.Start(TestServices.StyledWindow))
{
var window = (Window)AvaloniaRuntimeXamlLoader.Load(@"
<Window xmlns='https://github.com/avaloniaui'
xmlns:x='http://schemas.microsoft.com/winfx/2006/xaml'
xmlns:local='clr-namespace:Avalonia.Markup.Xaml.UnitTests.MarkupExtensions;assembly=Avalonia.Markup.Xaml.UnitTests'
x:DataType='local:TestDataContext'>
<TextBlock DataContext='{CompiledBinding StringProperty}' Name='textBlock' />
</Window>");
var textBlock = window.GetControl<TextBlock>("textBlock");
window.DataContext = new TestDataContext { StringProperty = "x" };
var expression = BindingOperations.GetBindingExpressionBase(textBlock, StyledElement.DataContextProperty);
Assert.IsType<BindingExpression>(expression);
}
}
static void Throws(string type, Action cb)
{
try

Loading…
Cancel
Save