Skip to content

Instantly share code, notes, and snippets.

@maxkatz6
Created October 7, 2025 01:30
Show Gist options
  • Select an option

  • Save maxkatz6/d6ad3c5c8dad9d9e67483185fc9c4e79 to your computer and use it in GitHub Desktop.

Select an option

Save maxkatz6/d6ad3c5c8dad9d9e67483185fc9c4e79 to your computer and use it in GitHub Desktop.
Generic WhenAnyValue over INotifyPropertyChanged
using System;
using System.Collections.Generic;
using System.ComponentModel;
using System.Runtime.CompilerServices;
namespace AvaloniaUI.Mvvm;
public static class PropertyChangeTrackerExtensions
{
public static PropertyChangeTracker<TRes> WhenAnyValue<TIn, TRes>(
this TIn vm,
Func<TIn, TRes> selector,
[CallerArgumentExpression(nameof(selector))]
string? expression = null)
where TIn : INotifyPropertyChanged
{
var tracker = new PropertyChangeTracker<TRes>();
tracker.AddSegment(vm, o => selector((TIn)o), PropertyChangeTracker<TRes>.ParsePropertyName(expression));
return tracker;
}
internal class PropertySegment
{
public PropertySegment(INotifyPropertyChanged? source,
Func<object, object?> selector,
string propertyName)
{
if (string.IsNullOrWhiteSpace(propertyName))
throw new ArgumentException("Property name cannot be null or whitespace.", nameof(propertyName));
Source = source;
Selector = selector ?? throw new ArgumentNullException(nameof(selector));
PropertyName = propertyName;
}
public INotifyPropertyChanged? Source { get; }
public Func<object, object?> Selector { get; }
public string PropertyName { get; }
}
}
public class PropertyChangeTracker<TRes>
{
private readonly List<PropertyChangeTrackerExtensions.PropertySegment> _segments = [];
internal void AddSegment(INotifyPropertyChanged source, Func<object, object?> selector, string propertyName)
{
_segments.Add(new PropertyChangeTrackerExtensions.PropertySegment(source, selector, propertyName));
}
public PropertyChangeTracker<TResNested> WhenAnyValue<TResNested>(
Func<TRes, TResNested> selector,
[CallerArgumentExpression(nameof(selector))]
string? expression = null)
{
var propertyName = ParsePropertyName(expression);
var nestedTracker = new PropertyChangeTracker<TResNested>();
// Copy existing segments
foreach (var segment in _segments)
{
nestedTracker._segments.Add(segment);
}
// Add new segment (source will be determined during subscription)
nestedTracker._segments.Add(
new PropertyChangeTrackerExtensions.PropertySegment(null!, o => selector((TRes)o), propertyName));
return nestedTracker;
}
public IDisposable Subscribe(Action<TRes?> onNext)
{
var subscription = new ChainSubscription<TRes>(_segments, onNext);
subscription.Initialize();
return subscription;
}
internal static string ParsePropertyName(string? expression)
{
if (string.IsNullOrWhiteSpace(expression))
throw new ArgumentException("Expression cannot be null or empty", nameof(expression));
var parts = expression.Split(["=>"], StringSplitOptions.RemoveEmptyEntries);
if (parts.Length != 2)
throw new ArgumentException($"Invalid expression format: {expression}", nameof(expression));
var propertyPath = parts[1].Trim();
var lastDot = propertyPath.LastIndexOf('.');
var propertyName = lastDot >= 0 ? propertyPath.Substring(lastDot + 1) : propertyPath;
if (string.IsNullOrWhiteSpace(propertyName))
throw new ArgumentException($"Could not extract property name from: {expression}", nameof(expression));
return propertyName;
}
private class ChainSubscription<T>(
List<PropertyChangeTrackerExtensions.PropertySegment> segments,
Action<T?> onNext)
: IDisposable
{
private readonly List<SegmentSubscription> _activeSubscriptions = new();
private bool _isDisposed;
private T? _lastValue;
public void Initialize()
{
ReevaluateChain();
}
private void ReevaluateChain()
{
if (_isDisposed) return;
object? currentValue = null;
lock (_activeSubscriptions)
{
if (_isDisposed) return;
// Unsubscribe from all previous subscriptions
foreach (var sub in _activeSubscriptions)
{
sub.Dispose();
}
_activeSubscriptions.Clear();
if (_isDisposed || segments.Count == 0)
return;
var currentSource = segments[0].Source;
for (var i = 0; i < segments.Count; i++)
{
var segment = segments[i];
// For segments after the first, source comes from previous value
if (i > 0)
{
currentSource = currentValue as INotifyPropertyChanged;
}
if (currentSource == null)
{
// Chain is broken, notify with null and stop
onNext(default(T));
return;
}
var subscription = new SegmentSubscription(
currentSource,
segment.PropertyName,
this);
subscription.Initialize();
_activeSubscriptions.Add(subscription);
// Get the next value in the chain
currentValue = segment.Selector(currentSource);
}
}
// Notify with the final value
var currentTyped = (T?)currentValue;
if (!EqualityComparer<T>.Default.Equals(_lastValue, currentTyped))
{
_lastValue = currentTyped;
onNext(currentTyped);
}
}
public void Dispose()
{
lock (_activeSubscriptions)
{
if (_isDisposed)
return;
_isDisposed = true;
foreach (var sub in _activeSubscriptions)
{
sub.Dispose();
}
_activeSubscriptions.Clear();
}
}
private class SegmentSubscription(
INotifyPropertyChanged source,
string propertyName,
ChainSubscription<T> chain)
: IDisposable
{
public void Initialize()
{
source.PropertyChanged += OnPropertyChanged;
}
private void OnPropertyChanged(object? sender, PropertyChangedEventArgs args)
{
if (args.PropertyName == propertyName || string.IsNullOrEmpty(args.PropertyName))
{
chain.ReevaluateChain();
}
}
public void Dispose() => source.PropertyChanged -= OnPropertyChanged;
}
}
}
using System.ComponentModel;
using AvaloniaUI.Mvvm;
using Xunit;
namespace AvaloniaUI.Core.UnitTests;
public class PropertyChangeTrackerTests
{
private class TestViewModel : INotifyPropertyChanged
{
public string? Name
{
get;
set
{
field = value;
PropertyChanged?.Invoke(this, new PropertyChangedEventArgs(nameof(Name)));
}
}
public NestedViewModel? Nested
{
get;
set
{
field = value;
PropertyChanged?.Invoke(this, new PropertyChangedEventArgs(nameof(Nested)));
}
}
public event PropertyChangedEventHandler? PropertyChanged;
}
private class NestedViewModel : INotifyPropertyChanged
{
public string? Value
{
get;
set
{
field = value;
PropertyChanged?.Invoke(this, new PropertyChangedEventArgs(nameof(Value)));
}
}
public DeeplyNestedViewModel? Deep
{
get;
set
{
field = value;
PropertyChanged?.Invoke(this, new PropertyChangedEventArgs(nameof(Deep)));
}
}
public event PropertyChangedEventHandler? PropertyChanged;
}
private class DeeplyNestedViewModel : INotifyPropertyChanged
{
public int Count
{
get;
set
{
field = value;
PropertyChanged?.Invoke(this, new PropertyChangedEventArgs(nameof(Count)));
}
}
public event PropertyChangedEventHandler? PropertyChanged;
}
private class ViewModelWithSubscriptionCheck : INotifyPropertyChanged
{
private PropertyChangedEventHandler? _propertyChangedInternal;
public ViewModelWithSubscriptionCheck? Nested { get; set; }
public int PropertyChangedSubscribers { get; private set; } = 0;
public event PropertyChangedEventHandler? PropertyChanged
{
add
{
PropertyChangedSubscribers++;
_propertyChangedInternal += value;
}
remove
{
PropertyChangedSubscribers--;
_propertyChangedInternal -= value;
}
}
public void OnPropertyChanged(string propertyName)
{
_propertyChangedInternal?.Invoke(this, new PropertyChangedEventArgs(propertyName));
}
}
[Fact]
public void Subscribe_CallsCallbackWithInitialValue()
{
var vm = new TestViewModel { Name = "Initial" };
string? receivedValue = null;
var sub = vm.WhenAnyValue(x => x.Name)
.Subscribe(value => receivedValue = value);
Assert.Equal("Initial", receivedValue);
sub.Dispose();
}
[Fact]
public void Subscribe_CallsCallbackWhenPropertyChanges()
{
var vm = new TestViewModel { Name = "Initial" };
var callCount = 0;
string? receivedValue = null;
var sub = vm.WhenAnyValue(x => x.Name)
.Subscribe(value =>
{
receivedValue = value;
callCount++;
});
vm.Name = "Updated";
Assert.Equal("Updated", receivedValue);
Assert.Equal(2, callCount); // Initial + change
sub.Dispose();
}
[Fact]
public void NestedProperty_TracksChanges()
{
var vm = new TestViewModel { Nested = new NestedViewModel { Value = "Initial" } };
string? receivedValue = null;
var sub = vm.WhenAnyValue(x => x.Nested)
.WhenAnyValue(x => x!.Value)
.Subscribe(value => receivedValue = value);
Assert.Equal("Initial", receivedValue);
vm.Nested.Value = "Updated";
Assert.Equal("Updated", receivedValue);
sub.Dispose();
}
[Fact]
public void NestedProperty_TracksParentChange()
{
var vm = new TestViewModel { Nested = new NestedViewModel { Value = "First" } };
var callCount = 0;
string? receivedValue = null;
var sub = vm.WhenAnyValue(x => x.Nested)
.WhenAnyValue(x => x!.Value)
.Subscribe(value =>
{
receivedValue = value;
callCount++;
});
vm.Nested = new NestedViewModel { Value = "Second" };
Assert.Equal("Second", receivedValue);
Assert.Equal(2, callCount);
sub.Dispose();
}
[Fact]
public void Subscribe_DoesNotCallbackWhenValueIsSame()
{
var vm = new TestViewModel { Name = "Same" };
var callCount = 0;
var sub = vm.WhenAnyValue(x => x.Name)
.Subscribe(_ => callCount++);
vm.Name = "Same";
Assert.Equal(1, callCount); // Only initial
sub.Dispose();
}
[Fact]
public void DeeplyNestedProperty_TracksChanges()
{
var vm = new TestViewModel { Nested = new NestedViewModel { Deep = new DeeplyNestedViewModel { Count = 1 } } };
var receivedValue = 0;
var sub = vm.WhenAnyValue(x => x.Nested)
.WhenAnyValue(x => x!.Deep)
.WhenAnyValue(x => x!.Count)
.Subscribe(value => receivedValue = value);
Assert.Equal(1, receivedValue);
vm.Nested.Deep.Count = 42;
Assert.Equal(42, receivedValue);
sub.Dispose();
}
[Fact]
public void Dispose_UnsubscribesFromPropertyChanged()
{
var vm = new TestViewModel { Name = "Test" };
var callCount = 0;
var sub = vm.WhenAnyValue(x => x.Name)
.Subscribe(_ => callCount++);
sub.Dispose();
vm.Name = "Updated";
Assert.Equal(1, callCount); // Only initial
}
[Fact]
public void NullNestedProperty_HandlesGracefully()
{
var vm = new TestViewModel { Nested = null };
string? receivedValue = "NotNull";
var sub = vm.WhenAnyValue(x => x.Nested)
.WhenAnyValue(x => x!.Value)
.Subscribe(value => receivedValue = value);
Assert.Null(receivedValue);
vm.Nested = new NestedViewModel { Value = "Now has value" };
Assert.Equal("Now has value", receivedValue);
sub.Dispose();
}
[Fact]
public void NestedPropertyBecomesNull_HandlesGracefully()
{
var vm = new TestViewModel { Nested = new NestedViewModel { Value = "Initial" } };
string? receivedValue = null;
var sub = vm.WhenAnyValue(x => x.Nested)
.WhenAnyValue(x => x!.Value)
.Subscribe(value => receivedValue = value);
vm.Nested = null;
Assert.Null(receivedValue);
sub.Dispose();
}
[Fact]
public void MultipleSubscriptions_WorkIndependently()
{
var vm = new TestViewModel { Name = "Test" };
var count1 = 0;
var count2 = 0;
var sub1 = vm.WhenAnyValue(x => x.Name).Subscribe(_ => count1++);
var sub2 = vm.WhenAnyValue(x => x.Name).Subscribe(_ => count2++);
vm.Name = "Updated";
Assert.Equal(2, count1);
Assert.Equal(2, count2);
sub1.Dispose();
vm.Name = "Again";
Assert.Equal(2, count1); // Stopped after dispose
Assert.Equal(3, count2); // Still tracking
sub2.Dispose();
}
[Fact]
public void Dispose_UnsubscribesFromSingleProperty()
{
var vm = new ViewModelWithSubscriptionCheck();
var sub = vm.WhenAnyValue(x => x.Nested)
.Subscribe(_ => { });
Assert.Equal(1, vm.PropertyChangedSubscribers);
sub.Dispose();
Assert.Equal(0, vm.PropertyChangedSubscribers);
}
[Fact]
public void Dispose_UnsubscribesFromNestedProperties()
{
var nested = new ViewModelWithSubscriptionCheck();
var vm = new ViewModelWithSubscriptionCheck { Nested = nested };
var sub = vm.WhenAnyValue(x => x.Nested)
.WhenAnyValue(x => x!.Nested)
.Subscribe(_ => { });
Assert.Equal(1, vm.PropertyChangedSubscribers);
Assert.Equal(1, nested.PropertyChangedSubscribers);
sub.Dispose();
Assert.Equal(0, vm.PropertyChangedSubscribers);
Assert.Equal(0, nested.PropertyChangedSubscribers);
}
[Fact]
public void Dispose_UnsubscribesFromDeeplyNestedProperties()
{
var deepNested = new ViewModelWithSubscriptionCheck();
var nested = new ViewModelWithSubscriptionCheck { Nested = deepNested };
var vm = new ViewModelWithSubscriptionCheck { Nested = nested };
var sub = vm.WhenAnyValue(x => x.Nested)
.WhenAnyValue(x => x!.Nested)
.WhenAnyValue(x => x!.Nested)
.Subscribe(_ => { });
Assert.Equal(1, vm.PropertyChangedSubscribers);
Assert.Equal(1, nested.PropertyChangedSubscribers);
Assert.Equal(1, deepNested.PropertyChangedSubscribers);
sub.Dispose();
Assert.Equal(0, vm.PropertyChangedSubscribers);
Assert.Equal(0, nested.PropertyChangedSubscribers);
Assert.Equal(0, deepNested.PropertyChangedSubscribers);
}
[Fact]
public void ParentChange_UnsubscribesFromOldNested()
{
var oldNested = new ViewModelWithSubscriptionCheck();
var newNested = new ViewModelWithSubscriptionCheck();
var vm = new ViewModelWithSubscriptionCheck { Nested = oldNested };
var sub = vm.WhenAnyValue(x => x.Nested)
.WhenAnyValue(x => x!.Nested)
.Subscribe(_ => { });
Assert.Equal(1, vm.PropertyChangedSubscribers);
Assert.Equal(1, oldNested.PropertyChangedSubscribers);
Assert.Equal(0, newNested.PropertyChangedSubscribers);
// Change parent property - should unsubscribe from old, subscribe to new
vm.Nested = newNested;
vm.OnPropertyChanged(nameof(ViewModelWithSubscriptionCheck.Nested));
Assert.Equal(1, vm.PropertyChangedSubscribers);
Assert.Equal(0, oldNested.PropertyChangedSubscribers); // Unsubscribed from old
Assert.Equal(1, newNested.PropertyChangedSubscribers); // Subscribed to new
sub.Dispose();
Assert.Equal(0, vm.PropertyChangedSubscribers);
Assert.Equal(0, oldNested.PropertyChangedSubscribers);
Assert.Equal(0, newNested.PropertyChangedSubscribers);
}
[Fact]
public void NestedChange_UnsubscribesFromOldDeepNested()
{
var oldDeep = new ViewModelWithSubscriptionCheck();
var newDeep = new ViewModelWithSubscriptionCheck();
var nested = new ViewModelWithSubscriptionCheck { Nested = oldDeep };
var vm = new ViewModelWithSubscriptionCheck { Nested = nested };
var sub = vm.WhenAnyValue(x => x.Nested)
.WhenAnyValue(x => x!.Nested)
.WhenAnyValue(x => x!.Nested)
.Subscribe(_ => { });
Assert.Equal(1, oldDeep.PropertyChangedSubscribers);
Assert.Equal(0, newDeep.PropertyChangedSubscribers);
// Change nested property - should resubscribe entire chain
nested.Nested = newDeep;
nested.OnPropertyChanged(nameof(ViewModelWithSubscriptionCheck.Nested));
Assert.Equal(0, oldDeep.PropertyChangedSubscribers); // Unsubscribed from old
Assert.Equal(1, newDeep.PropertyChangedSubscribers); // Subscribed to new
sub.Dispose();
Assert.Equal(0, vm.PropertyChangedSubscribers);
Assert.Equal(0, nested.PropertyChangedSubscribers);
Assert.Equal(0, newDeep.PropertyChangedSubscribers);
}
[Fact]
public void NullNestedProperty_UnsubscribesFromPreviousValue()
{
var nested = new ViewModelWithSubscriptionCheck();
var vm = new ViewModelWithSubscriptionCheck { Nested = nested };
var sub = vm.WhenAnyValue(x => x.Nested)
.WhenAnyValue(x => x!.Nested)
.Subscribe(_ => { });
Assert.Equal(1, vm.PropertyChangedSubscribers);
Assert.Equal(1, nested.PropertyChangedSubscribers);
// Set to null - should unsubscribe from nested
vm.Nested = null;
vm.OnPropertyChanged(nameof(ViewModelWithSubscriptionCheck.Nested));
Assert.Equal(1, vm.PropertyChangedSubscribers); // Still subscribed to root
Assert.Equal(0, nested.PropertyChangedSubscribers); // Unsubscribed from nested
sub.Dispose();
Assert.Equal(0, vm.PropertyChangedSubscribers);
}
[Fact]
public void MultipleSubscriptions_DoNotInterfereWithEachOther()
{
var nested = new ViewModelWithSubscriptionCheck();
var vm = new ViewModelWithSubscriptionCheck { Nested = nested };
var sub1 = vm.WhenAnyValue(x => x.Nested)
.WhenAnyValue(x => x!.Nested)
.Subscribe(_ => { });
var sub2 = vm.WhenAnyValue(x => x.Nested)
.WhenAnyValue(x => x!.Nested)
.Subscribe(_ => { });
Assert.Equal(2, vm.PropertyChangedSubscribers);
Assert.Equal(2, nested.PropertyChangedSubscribers);
sub1.Dispose();
Assert.Equal(1, vm.PropertyChangedSubscribers); // One subscription remains
Assert.Equal(1, nested.PropertyChangedSubscribers);
sub2.Dispose();
Assert.Equal(0, vm.PropertyChangedSubscribers);
Assert.Equal(0, nested.PropertyChangedSubscribers);
}
}
var vm = new TestViewModel { Nested = new NestedViewModel { Value = "Initial" } };
string? receivedValue = null;
using var sub = vm.WhenAnyValue(static x => x.Nested)
.WhenAnyValue(static x => x!.Value)
.Subscribe(value => receivedValue = value);
Assert.Equal("Initial", receivedValue);
vm.Nested.Value = "Updated";
Assert.Equal("Updated", receivedValue);
Sign up for free to join this conversation on GitHub. Already have an account? Sign in to comment