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

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
2 changes: 1 addition & 1 deletion src/Directory.Packages.props
Original file line number Diff line number Diff line change
Expand Up @@ -10,7 +10,7 @@
</PropertyGroup>

<PropertyGroup>
<RoslynCommonAnalyzersVersion>5.1.2</RoslynCommonAnalyzersVersion>
<RoslynCommonAnalyzersVersion>5.1.3</RoslynCommonAnalyzersVersion>
<ReactiveUIPrimitivesVersion>9.0.0</ReactiveUIPrimitivesVersion>
</PropertyGroup>

Expand Down
36 changes: 34 additions & 2 deletions src/ReactiveUI.Binding.Shared/Fallback/RuntimePropertyRaiser.cs
Original file line number Diff line number Diff line change
Expand Up @@ -150,7 +150,12 @@ private static Members FindMembers(Type type) =>
private static RaiseMember? FindReactiveObjectExtension(Type type, bool changing)
{
var reactiveObject = type.GetInterface("ReactiveUI.IReactiveObject");
var extensions = reactiveObject?.Assembly.GetType("ReactiveUI.IReactiveObjectExtensions");
if (reactiveObject is null)
{
return null;
}

var extensions = FindReactiveObjectExtensions(type, reactiveObject);
if (extensions is null)
{
return null;
Expand All @@ -167,12 +172,39 @@ private static Members FindMembers(Type type) =>
}

// A static method closed over its first argument binds to Action<string> like an instance method.
return ByName(method.MakeGenericMethod(reactiveObject!));
return ByName(method.MakeGenericMethod(reactiveObject));
}

return null;
}

/// <summary>Finds the extension class that owns the object's ReactiveUI notification state.</summary>
/// <param name="type">The object's type.</param>
/// <param name="reactiveObject">The ReactiveUI interface it implements.</param>
/// <returns>The extension class, or null when the object's assemblies declare neither flavour.</returns>
[RequiresUnreferencedCode(RequiresUnreferencedCodeMessage)]
private static Type? FindReactiveObjectExtensions(Type type, Type reactiveObject)
{
// The interface can live in ReactiveUI.Core; the base class identifies the flavour holding the state.
for (var current = type; current is not null; current = current.BaseType)
{
if (FindReactiveObjectExtensions(current.Assembly) is { } extensions)
{
return extensions;
}
}

return FindReactiveObjectExtensions(reactiveObject.Assembly);
}

/// <summary>Finds either ReactiveUI flavour's raise extensions in an assembly.</summary>
/// <param name="assembly">The assembly to inspect.</param>
/// <returns>The extension class, or null when the assembly declares neither flavour.</returns>
[RequiresUnreferencedCode(RequiresUnreferencedCodeMessage)]
private static Type? FindReactiveObjectExtensions(Assembly assembly) =>
assembly.GetType("ReactiveUI.IReactiveObjectExtensions")
?? assembly.GetType("ReactiveUI.Reactive.IReactiveObjectExtensions");

/// <summary>Finds a raise method, preferring an overload that takes event args over one that takes a name.</summary>
/// <param name="type">The type.</param>
/// <param name="changing">Whether to find a before-change raise method.</param>
Expand Down
Original file line number Diff line number Diff line change
Expand Up @@ -23,18 +23,26 @@ public sealed class ReactiveObjectViewModel : global::ReactiveUI.ReactiveObject
/// <summary>Backs <see cref="Label"/>.</summary>
private readonly ObservableAsPropertyHelper<string> _labelHelper;

/// <summary>Backs the reflection-based property.</summary>
private readonly ObservableAsPropertyHelper<int> _unsafeCountHelper;

/// <summary>Initializes a new instance of the <see cref="ReactiveObjectViewModel"/> class.</summary>
/// <param name="item">The item the view model follows.</param>
public ReactiveObjectViewModel(SourceItem item)
{
_countHelper = item.WhenChanged(static x => x.Count).ToProperty(this, static x => x.Count);

_ = item.WhenChanged(static x => x.Label).ToProperty(this, static x => x.Label, out _labelHelper, InitialLabel);

_unsafeCountHelper = item.WhenChanged(static x => x.Count).ToPropertyUnsafe(this, static x => x.UnsafeCount);
}

/// <summary>Gets the followed item's count.</summary>
public int Count => _countHelper.Value;

/// <summary>Gets the followed item's label.</summary>
public string Label => _labelHelper.Value;

/// <summary>Gets the followed count through the reflection-based overload.</summary>
public int UnsafeCount => _unsafeCountHelper.Value;
}
6 changes: 4 additions & 2 deletions src/examples/Platforms/ToPropertyVerification/Program.cs
Original file line number Diff line number Diff line change
Expand Up @@ -19,7 +19,7 @@

failures += Run("Deferred+scheduler combo, and out combined with initial value / initial-value factory", CombinedOverloadsScenario);

failures += Run("ReactiveObject (IReactiveObject raise mechanism), selector + out overloads", ReactiveObjectScenario);
failures += Run("ReactiveObject (IReactiveObject raise mechanism), generated + unsafe overloads", ReactiveObjectScenario);

failures += Run("[ObservableAsProperty] partial properties", AttributeScenario);

Expand Down Expand Up @@ -194,20 +194,22 @@ static void ReactiveObjectScenario()
const string InitialLabel = "x";
const int UpdatedCount = 2;
const string UpdatedLabel = "y";
const int ExpectedNotificationCount = 2;
const int ExpectedNotificationCount = 3;

SourceItem item = new() { Count = InitialCount, Label = InitialLabel };
ReactiveObjectViewModel vm = new(item);

var changes = TrackChanges(vm);

AssertEqual(InitialCount, vm.Count, "initial Count");
AssertEqual(InitialCount, vm.UnsafeCount, "initial UnsafeCount");

// WhenChanged delivers the item's current label as soon as the helper subscribes, replacing the initial value.
AssertEqual(InitialLabel, vm.Label, "initial Label from the item's current value");

item.Count = UpdatedCount;
AssertEqual(UpdatedCount, vm.Count, "updated Count");
AssertEqual(UpdatedCount, vm.UnsafeCount, "updated UnsafeCount");

item.Label = UpdatedLabel;
AssertEqual(UpdatedLabel, vm.Label, "updated Label");
Expand Down
Original file line number Diff line number Diff line change
@@ -0,0 +1,150 @@
// Copyright (c) 2019-2026 ReactiveUI and Contributors. All rights reserved.
// ReactiveUI and Contributors licenses this file to you under the MIT license.
// See the LICENSE file in the project root for full license information.

using System.ComponentModel;
using System.Runtime.CompilerServices;
using ReactiveUI.Binding.Fallback;

namespace ReactiveUI.Binding.Tests.Bindings;

/// <summary>Verifies runtime notification methods and optional before-change dispatch.</summary>
public sealed class RuntimePropertyRaiserTests
{
/// <summary>Event-argument methods that return a value still deliver both cached notification arguments.</summary>
/// <returns>A task representing the asynchronous test.</returns>
[Test]
public async Task ReturningEventArgsMethods_RaiseBothNotifications()
{
var model = new ReturningArgsModel();
var raiser = RuntimePropertyRaiser.Create(model, "Name");
raiser.RaiseChanging();
raiser.RaiseChanged();
raiser.RaiseChanging();
raiser.RaiseChanged();

await Assert.That(raiser.RaisesChanging).IsTrue();
await Assert.That(string.Join(",", model.Raised)).IsEqualTo("changing:Name,changed:Name,changing:Name,changed:Name");
await Assert.That(ReferenceEquals(model.ChangedArgs[0], model.ChangedArgs[1])).IsTrue();
await Assert.That(ReferenceEquals(model.ChangingArgs[0], model.ChangingArgs[1])).IsTrue();
}

/// <summary>Name-based methods with return values remain usable as notification methods.</summary>
/// <returns>A task representing the asynchronous test.</returns>
[Test]
public async Task ReturningNameMethods_RaiseBothNotifications()
{
var model = new ReturningNameModel();
var raiser = RuntimePropertyRaiser.Create(model, "Name");
raiser.RaiseChanging();
raiser.RaiseChanged();

await Assert.That(string.Join(",", model.Raised)).IsEqualTo("changing:Name,changed:Name");
}

/// <summary>A model supporting only after-change safely ignores before-change dispatch.</summary>
/// <returns>A task representing the asynchronous test.</returns>
[Test]
public async Task NoChangingMethod_IgnoresBeforeChange()
{
var model = new ToPropertyUnsafeTests.PublicArgsModel();
var raiser = RuntimePropertyRaiser.Create(model, "Name");
raiser.RaiseChanging();
raiser.RaiseChanged();

await Assert.That(raiser.RaisesChanging).IsFalse();
await Assert.That(model.Raised).IsEquivalentTo(["Name"]);
}

/// <summary>Void event-argument methods bind both notification phases directly to the owner.</summary>
/// <returns>A task representing the asynchronous test.</returns>
[Test]
public async Task VoidEventArgsMethods_RaiseBothNotifications()
{
var model = new VoidArgsModel();
var raiser = RuntimePropertyRaiser.Create(model, "Name");
raiser.RaiseChanging();
raiser.RaiseChanged();
raiser.RaiseChanging();
raiser.RaiseChanged();

await Assert.That(string.Join(",", model.Raised)).IsEqualTo("changing:Name,changed:Name,changing:Name,changed:Name");
}

/// <summary>Records argument-based notifications and returns the count for ordinary callers.</summary>
public sealed class ReturningArgsModel
{
/// <summary>Gets the notification order.</summary>
public List<string> Raised { get; } = [];

/// <summary>Gets after-change arguments.</summary>
public List<PropertyChangedEventArgs> ChangedArgs { get; } = [];

/// <summary>Gets before-change arguments.</summary>
public List<PropertyChangingEventArgs> ChangingArgs { get; } = [];

/// <summary>Records an after-change notification.</summary>
/// <param name="args">The notification arguments.</param>
/// <returns>The number of notifications.</returns>
public int RaisePropertyChanged(PropertyChangedEventArgs args)
{
ChangedArgs.Add(args);
Raised.Add($"changed:{args.PropertyName}");
return Raised.Count;
}

/// <summary>Records a before-change notification.</summary>
/// <param name="args">The notification arguments.</param>
/// <returns>The number of notifications.</returns>
public int RaisePropertyChanging(PropertyChangingEventArgs args)
{
ChangingArgs.Add(args);
Raised.Add($"changing:{args.PropertyName}");
return Raised.Count;
}
}

/// <summary>Records name-based notifications with a return value.</summary>
public sealed class ReturningNameModel
{
/// <summary>Gets the notification order.</summary>
public List<string> Raised { get; } = [];

/// <summary>Records an after-change notification.</summary>
/// <param name="propertyName">The property name.</param>
/// <returns>The name supplied to the notification method.</returns>
[MethodImpl(MethodImplOptions.AggressiveInlining)]
public string RaisePropertyChanged(string propertyName)
{
Raised.Add($"changed:{propertyName}");
return propertyName;
}

/// <summary>Records a before-change notification.</summary>
/// <param name="propertyName">The property name.</param>
/// <returns>The name supplied to the notification method.</returns>
[MethodImpl(MethodImplOptions.AggressiveInlining)]
public string RaisePropertyChanging(string propertyName)
{
Raised.Add($"changing:{propertyName}");
return propertyName;
}
}

/// <summary>Records notifications through void methods taking event arguments.</summary>
public sealed class VoidArgsModel
{
/// <summary>Gets the notification order.</summary>
public List<string> Raised { get; } = [];

/// <summary>Records an after-change notification.</summary>
/// <param name="args">The notification arguments.</param>
[MethodImpl(MethodImplOptions.AggressiveInlining)]
public void RaisePropertyChanged(PropertyChangedEventArgs args) => Raised.Add($"changed:{args.PropertyName}");

/// <summary>Records a before-change notification.</summary>
/// <param name="args">The notification arguments.</param>
[MethodImpl(MethodImplOptions.AggressiveInlining)]
public void RaisePropertyChanging(PropertyChangingEventArgs args) => Raised.Add($"changing:{args.PropertyName}");
}
}
Loading
Loading