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
4 changes: 0 additions & 4 deletions src/Directory.Build.props
Original file line number Diff line number Diff line change
Expand Up @@ -131,10 +131,6 @@
<PackageReference Include="Microsoft.SourceLink.GitHub" PrivateAssets="All"/>
</ItemGroup>

<PropertyGroup Condition="$([MSBuild]::IsTargetFrameworkCompatible('$(TargetFramework)', 'net11.0'))">
<Features>$(Features);runtime-async=on</Features>
</PropertyGroup>

<!-- Alias Lock to the dedicated System.Threading.Lock on .NET 9+ (faster EnterScope fast path),
and to a plain object elsewhere (the lock statement falls back to Monitor). -->
<ItemGroup Condition="$([MSBuild]::IsTargetFrameworkCompatible('$(TargetFramework)', 'net9.0'))">
Expand Down
2 changes: 1 addition & 1 deletion src/Directory.Packages.props
Original file line number Diff line number Diff line change
Expand Up @@ -16,7 +16,7 @@

<ItemGroup>
<!-- Testing Framework -->
<PackageVersion Include="TUnit" Version="1.72.4"/>
<PackageVersion Include="TUnit" Version="1.72.16"/>

<!-- Roslyn Framework -->
<PackageVersion Include="Microsoft.CodeAnalysis.Analyzers" Version="5.9.0"/>
Expand Down
Original file line number Diff line number Diff line change
Expand Up @@ -61,7 +61,7 @@ public static IDisposable BindCommand<
return EmptyDisposable.Instance;
}

var binding = new MutableDisposable();
var binding = new SwapDisposable();
var commands = RuntimeObservationFallback.WhenAnyValue(viewModel, commandProperty);
var controls = RuntimeObservationFallback.WhenAnyValue(view, controlProperty);

Expand Down
Original file line number Diff line number Diff line change
Expand Up @@ -50,7 +50,7 @@ public static IDisposable BindInteraction<TViewModel, TInput, TOutput>(
return EmptyDisposable.Instance;
}

var registration = new MutableDisposable();
var registration = new SwapDisposable();
var observation = BindingErrors.Subscribe(
RuntimeObservationFallback.WhenAnyValue(viewModel, interactionProperty),
interaction => registration.Disposable = interaction is null
Expand Down
Original file line number Diff line number Diff line change
Expand Up @@ -102,14 +102,14 @@ protected virtual void Dispose(bool disposing)
private void OnPropertyChanged(object? sender, PropertyChangedEventArgs args) => _notifications++;

/// <summary>A ReactiveObject whose derived property is backed by ReactiveUI's helper.</summary>
private sealed class CountViewModel : ReactiveObject, IDisposable
internal sealed class CountViewModel : ReactiveObject, IDisposable
{
/// <summary>Backs <see cref="Count"/>.</summary>
private readonly ObservableAsPropertyHelper<int> _count;

/// <summary>Initializes a new instance of the <see cref="CountViewModel"/> class.</summary>
/// <param name="counts">The values <see cref="Count"/> takes.</param>
public CountViewModel(IObservable<int> counts) => _count = counts.ToProperty(this, x => x.Count);
public CountViewModel(IObservable<int> counts) => _count = counts.ToProperty(this, static x => x.Count);

/// <summary>Gets the latest count.</summary>
public int Count => _count.Value;
Expand Down
Original file line number Diff line number Diff line change
Expand Up @@ -8,7 +8,6 @@
using System.Linq.Expressions;
using System.Runtime.CompilerServices;
using BenchmarkDotNet.Attributes;
using ReactiveUI;
using ReactiveUI.Builder;
using BenchmarkVm = ReactiveUI.Binding.Benchmarks.Mocks.BenchmarkViewModel;

Expand Down
Original file line number Diff line number Diff line change
Expand Up @@ -2,6 +2,8 @@
// 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 System.Windows.Input;
using ReactiveUI.Binding.Fallback;
using ReactiveUI.Binding.Tests.TestModels;
Expand Down Expand Up @@ -90,4 +92,90 @@ public async Task BindCommand_WithANamedEventAndAControlNoBinderReaches_BindsNot

await Assert.That(command.LastParameter).IsNull();
}

/// <summary>A change notification on a link of the control chain rebinds without leaving the old binding attached.</summary>
/// <returns>A task representing the asynchronous test operation.</returns>
[Test]
public async Task BindCommand_WhenAChainLinkRaisesChanged_KeepsOneActiveBinding()
{
RuntimeObservationFallbackTests.EnsureInitialized();
Locator.CurrentMutable.RegisterConstant<ICreatesCommandBinding>(new ClickCommandBinder());
var command = new RecordingStubCommand();
var viewModel = new DispatchStubViewModel { Run = command };
var view = new ChainedView();

using var binding = RuntimeCommandBindingFallback.BindCommand(
view,
viewModel,
x => x.Run,
x => x.Editor.Properties.Button,
new ManualObservable<object?>(),
null,
BindingExpression);

view.Editor.RaisePropertiesChanged();
view.Editor.RaisePropertiesChanged();
view.Editor.Properties.Button.PerformClick();

await Assert.That(command.ExecuteCount).IsEqualTo(1);
}

/// <summary>Disposing the binding detaches the command even after a link rebound it.</summary>
/// <returns>A task representing the asynchronous test operation.</returns>
[Test]
public async Task BindCommand_WhenDisposedAfterARebind_DetachesTheCommand()
{
RuntimeObservationFallbackTests.EnsureInitialized();
Locator.CurrentMutable.RegisterConstant<ICreatesCommandBinding>(new ClickCommandBinder());
var command = new RecordingStubCommand();
var viewModel = new DispatchStubViewModel { Run = command };
var view = new ChainedView();

var binding = RuntimeCommandBindingFallback.BindCommand(
view,
viewModel,
x => x.Run,
x => x.Editor.Properties.Button,
new ManualObservable<object?>(),
null,
BindingExpression);

view.Editor.RaisePropertiesChanged();
view.Editor.RaisePropertiesChanged();
binding.Dispose();
view.Editor.Properties.Button.PerformClick();

await Assert.That(command.ExecuteCount).IsEqualTo(0);
}

/// <summary>A view whose control sits behind a notifying chain.</summary>
private sealed class ChainedView : IViewFor
{
/// <inheritdoc/>
public object? ViewModel { get; set; }

/// <summary>Gets the editor holding the control.</summary>
public ChainedEditor Editor { get; } = new();
}

/// <summary>An editor that reports its properties changed without replacing them.</summary>
private sealed class ChainedEditor : INotifyPropertyChanged
{
/// <inheritdoc/>
public event PropertyChangedEventHandler? PropertyChanged;

/// <summary>Gets the properties holding the control.</summary>
public ChainedProperties Properties { get; } = new();

/// <summary>Raises a change notification for <see cref="Properties"/>.</summary>
[MethodImpl(MethodImplOptions.AggressiveInlining)]
public void RaisePropertiesChanged() => PropertyChanged?.Invoke(this, new(nameof(Properties)));
}

/// <summary>Holds the control a command binds to.</summary>
private sealed class ChainedProperties
{
/// <summary>Gets the control.</summary>
public DispatchStubControl Button { get; } = new();
}
}
Original file line number Diff line number Diff line change
Expand Up @@ -2,6 +2,7 @@
// 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 ReactiveUI.Binding.Fallback;
using ReactiveUI.Binding.Tests.TestModels;

Expand All @@ -13,6 +14,9 @@ public class RuntimeInteractionFallbackTests
/// <summary>The expression text reported when the observation faults.</summary>
private const string BindingExpression = "x => x.Confirm";

/// <summary>The registrations made after the interaction is replaced once: the original and the replacement.</summary>
private const int RegistrationsAfterReplacement = 2;

/// <summary>A null view model holds no interaction, so the handler is never registered.</summary>
/// <returns>A task representing the asynchronous test operation.</returns>
[Test]
Expand Down Expand Up @@ -56,6 +60,60 @@ public async Task BindInteraction_WhenThePropertyHoldsNoInteraction_RegistersNot
await Assert.That(registrations).IsEqualTo(0);
}

/// <summary>Replacing the interaction disposes the registration made on the one it displaced.</summary>
/// <returns>A task representing the asynchronous test operation.</returns>
[Test]
public async Task BindInteraction_WhenTheInteractionIsReplaced_DisposesTheDisplacedRegistration()
{
RuntimeObservationFallbackTests.EnsureInitialized();
List<TrackedHandler> handlers = [];
var viewModel = new NotifyingViewModel { Confirm = new Interaction<string, bool>() };

using var binding = RuntimeInteractionFallback.BindInteraction(
viewModel,
x => x.Confirm,
_ =>
{
var handler = new TrackedHandler();
handlers.Add(handler);
return handler;
},
BindingExpression);

viewModel.Confirm = new Interaction<string, bool>();

await Assert.That(handlers.Count).IsEqualTo(RegistrationsAfterReplacement);
await Assert.That(handlers[0].Disposals).IsEqualTo(1);
}

/// <summary>A view model that raises a change notification when its interaction is replaced.</summary>
private sealed class NotifyingViewModel : INotifyPropertyChanged
{
/// <inheritdoc/>
public event PropertyChangedEventHandler? PropertyChanged;

/// <summary>Gets or sets the interaction a binding names.</summary>
public IInteraction<string, bool> Confirm
{
get => field;
set
{
field = value;
PropertyChanged?.Invoke(this, new(nameof(Confirm)));
}
} = null!;
}

/// <summary>Counts how often it is disposed.</summary>
private sealed class TrackedHandler : IDisposable
{
/// <summary>Gets how many times the handler has been disposed.</summary>
public int Disposals { get; private set; }

/// <inheritdoc/>
public void Dispose() => Disposals++;
}

/// <summary>Stands in for the registration a handler would hand back.</summary>
private sealed class UnregisteredHandler : IDisposable
{
Expand Down
Original file line number Diff line number Diff line change
Expand Up @@ -21,11 +21,18 @@ public event EventHandler? CanExecuteChanged
/// <summary>Gets the parameter the most recent execution carried.</summary>
public object? LastParameter { get; private set; }

/// <summary>Gets how many times the command has executed.</summary>
public int ExecuteCount { get; private set; }

/// <inheritdoc/>
[MethodImpl(MethodImplOptions.AggressiveInlining)]
public bool CanExecute(object? parameter) => true;

/// <inheritdoc/>
[MethodImpl(MethodImplOptions.AggressiveInlining)]
public void Execute(object? parameter) => LastParameter = parameter;
public void Execute(object? parameter)
{
ExecuteCount++;
LastParameter = parameter;
}
}
Loading