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
Original file line number Diff line number Diff line change
Expand Up @@ -29,27 +29,20 @@
"This should never happen, your service locator is probably broken. Please make sure you have registered ICreatesObservableForProperty implementations.";

/// <summary>MRU cache that maps (sender type, property name, before-change flag) to the best <see cref="ICreatesObservableForProperty"/> implementation for that combination.</summary>
/// <remarks>
/// The entry is supplied through the context argument rather than looked up here, so only a
/// resolution that actually found an implementation is ever stored. A failure is deliberately not
/// stored: the locator can still be empty the first time a property is observed — an observation
/// running ahead of application startup, or a test suite whose registration belongs to a later
/// test — and a stored failure would outlive the registration that fixes it, leaving that sender
/// and property permanently unobservable.
/// </remarks>
private static readonly MemoizingMRUCache<
(Type senderType, string propertyName, bool beforeChange),
ICreatesObservableForProperty?>
ICreatesObservableForProperty>
NotifyFactoryCache =
new(
static (t, _) =>
{
var bestScore = 0;
ICreatesObservableForProperty? best = null;
foreach (var candidate in AppLocator.Current.GetServices<ICreatesObservableForProperty>())
{
var score = candidate.GetAffinityForObject(t.senderType, t.propertyName, t.beforeChange);
if (score > bestScore)
{
bestScore = score;
best = candidate;
}
}

return best;
},
static (_, resolved) => (ICreatesObservableForProperty)resolved!,
NotifyFactoryCacheSize);

/// <summary>Provides ObservableForProperty extension members for <paramref name="item"/>.</summary>
Expand Down Expand Up @@ -115,7 +108,7 @@
expr = parameter;
}

var factory = NotifyFactoryCache.Get((item!.GetType(), propertyName, beforeChange))
var factory = ResolveNotifyFactory((item!.GetType(), propertyName, beforeChange))

Check warning on line 111 in src/ReactiveUI.Binding.Shared/ObservableForProperty/ReactiveNotifyPropertyChangedMixins.cs

View workflow job for this annotation

GitHub Actions / sonarcloud / sonarcloud

Remove this null-forgiving operator; the compiler already knows this expression is not null here.

Check warning on line 111 in src/ReactiveUI.Binding.Shared/ObservableForProperty/ReactiveNotifyPropertyChangedMixins.cs

View workflow job for this annotation

GitHub Actions / sonarcloud / sonarcloud

Remove this null-forgiving operator; the compiler already knows this expression is not null here.
?? throw new InvalidOperationException(
$"Could not find a ICreatesObservableForProperty for {item.GetType()} property {propertyName}. {BrokenLocatorAdvice}");

Expand All @@ -141,12 +134,12 @@

// Single fused sink: emits the initial value (unless skipped), then re-reads and emits on each
// notification, applying the distinct gate inline.
var notifications = factory.GetNotificationForProperty(item!, expr, propertyName, beforeChange);

Check warning on line 137 in src/ReactiveUI.Binding.Shared/ObservableForProperty/ReactiveNotifyPropertyChangedMixins.cs

View workflow job for this annotation

GitHub Actions / sonarcloud / sonarcloud

Remove this null-forgiving operator; the compiler already knows this expression is not null here.

Check warning on line 137 in src/ReactiveUI.Binding.Shared/ObservableForProperty/ReactiveNotifyPropertyChangedMixins.cs

View workflow job for this annotation

GitHub Actions / sonarcloud / sonarcloud

Remove this null-forgiving operator; the compiler already knows this expression is not null here.
return new ObservableForPropertySink<TSender, TValue>(
item!,

Check warning on line 139 in src/ReactiveUI.Binding.Shared/ObservableForProperty/ReactiveNotifyPropertyChangedMixins.cs

View workflow job for this annotation

GitHub Actions / sonarcloud / sonarcloud

Remove this null-forgiving operator; the compiler already knows this expression is not null here.
expr,
notifications,
() => GetCurrentValue(item!, propertyName),

Check warning on line 142 in src/ReactiveUI.Binding.Shared/ObservableForProperty/ReactiveNotifyPropertyChangedMixins.cs

View workflow job for this annotation

GitHub Actions / sonarcloud / sonarcloud

Remove this null-forgiving operator; the compiler already knows this expression is not null here.
skipInitial,
isDistinct);
}
Expand Down Expand Up @@ -290,7 +283,7 @@
"The expression does not have valid member info",
nameof(expression));
var propertyName = memberInfo.Name;
var result = NotifyFactoryCache.Get((sender.GetType(), propertyName, beforeChange));
var result = ResolveNotifyFactory((sender.GetType(), propertyName, beforeChange));

return result switch
{
Expand All @@ -299,4 +292,33 @@
_ => result.GetNotificationForProperty(sender, expression, propertyName, beforeChange)
};
}

/// <summary>
/// Gets the highest-affinity <see cref="ICreatesObservableForProperty"/> for a sender type and
/// property, consulting the service locator only when the combination has not already resolved.
/// </summary>
/// <param name="key">The sender type, property name and before-change flag being resolved.</param>
/// <returns>The best implementation, or <see langword="null"/> when nothing bids a positive affinity.</returns>
private static ICreatesObservableForProperty? ResolveNotifyFactory(
(Type senderType, string propertyName, bool beforeChange) key)
{
if (NotifyFactoryCache.TryGet(key, out var memoized))
{
return memoized;
}

var bestScore = 0;
ICreatesObservableForProperty? best = null;
foreach (var candidate in AppLocator.Current.GetServices<ICreatesObservableForProperty>())
{
var score = candidate.GetAffinityForObject(key.senderType, key.propertyName, key.beforeChange);
if (score > bestScore)
{
bestScore = score;
best = candidate;
}
}

return best is null ? null : NotifyFactoryCache.Get(key, best);
}
}
Original file line number Diff line number Diff line change
Expand Up @@ -318,6 +318,65 @@ public async Task ObservableForProperty_ByName_GetCurrentValue_ValIsTValue_Succe
await Assert.That(receivedValue).IsEqualTo("Test");
}

/// <summary>
/// Verifies that a lookup made while no <see cref="ICreatesObservableForProperty"/> is registered
/// does not stop the same sender and property from resolving once registration has happened.
/// </summary>
/// <returns>A task representing the asynchronous test operation.</returns>
[Test]
public async Task ObservableForProperty_ByName_LookupBeforeRegistration_ResolvesAfterRegistration()
{
RxBindingBuilder.ResetForTesting();

var vm = new LateRegistrationFixture { Name = InitialValue };

await Assert.That(() => vm.ObservableForProperty<LateRegistrationFixture, string>(
nameof(LateRegistrationFixture.Name),
skipInitial: false))
.ThrowsExactly<InvalidOperationException>();

EnsureInitialized();

var values = new List<IObservedChange<LateRegistrationFixture, string>>();
using var sub = vm.ObservableForProperty<LateRegistrationFixture, string>(
nameof(LateRegistrationFixture.Name),
skipInitial: false)
.Subscribe(values.Add);

vm.Name = ChangedValue;

await Assert.That(values.Count).IsGreaterThanOrEqualTo(ExpectedTwoEmissions);
}

/// <summary>
/// Verifies that a nested-chain lookup made while no <see cref="ICreatesObservableForProperty"/>
/// is registered does not stop the same sender and property from resolving once registration
/// has happened.
/// </summary>
/// <returns>A task representing the asynchronous test operation.</returns>
[Test]
public async Task NotifyForProperty_LookupBeforeRegistration_ResolvesAfterRegistration()
{
RxBindingBuilder.ResetForTesting();

var vm = new LateRegistrationFixture { Title = InitialValue };
Expression<Func<LateRegistrationFixture, string>> expr = x => x.Title;
var body = Reflection.Rewrite(expr.Body);

await Assert.That(() => ReactiveNotifyPropertyChangedMixins.NotifyForProperty(vm, body, false))
.ThrowsExactly<InvalidOperationException>();

EnsureInitialized();

var results = new List<IObservedChange<object?, object?>>();
using var sub = ReactiveNotifyPropertyChangedMixins.NotifyForProperty(vm, body, false)
.Subscribe(results.Add);

vm.Title = ChangedValue;

await Assert.That(results.Count).IsGreaterThanOrEqualTo(1);
}

/// <summary>Resets and initializes the ReactiveUI binding infrastructure for testing.</summary>
private static void EnsureInitialized()
{
Expand Down
Original file line number Diff line number Diff line change
@@ -0,0 +1,61 @@
// Copyright (c) 2019-2026 ReactiveUI Association Incorporated. All rights reserved.
// ReactiveUI Association Incorporated 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;

namespace ReactiveUI.Binding.Tests.TestModels;

/// <summary>
/// A fixture observed once while the locator is still empty and again after registration.
/// It is used by no other test, so the factory-resolution cache entry for its properties is
/// created solely by the test that exercises that sequence.
/// </summary>
public class LateRegistrationFixture : INotifyPropertyChanged
{
/// <inheritdoc/>
public event PropertyChangedEventHandler? PropertyChanged;

/// <summary>Gets or sets the value observed through the by-name overload.</summary>
public string Name
{
get => field;
set
{
if (field == value)
{
return;
}

field = value;
OnPropertyChanged();
}
} = string.Empty;

/// <summary>Gets or sets the value observed through the expression-chain path.</summary>
/// <remarks>
/// A resolution that succeeds is memoized for the lifetime of the process, so each test that
/// observes this fixture before registration needs a property of its own to start from an
/// unresolved cache entry.
/// </remarks>
public string Title
{
get => field;
set
{
if (field == value)
{
return;
}

field = value;
OnPropertyChanged();
}
} = string.Empty;

/// <summary>Raises the PropertyChanged event.</summary>
/// <param name="propertyName">The property name.</param>
protected void OnPropertyChanged([CallerMemberName] string? propertyName = null) =>
PropertyChanged?.Invoke(this, new(propertyName));
}
Loading