ProMvvm 0.1.0
dotnet add package ProMvvm --version 0.1.0
NuGet\Install-Package ProMvvm -Version 0.1.0
<PackageReference Include="ProMvvm" Version="0.1.0" />
<PackageVersion Include="ProMvvm" Version="0.1.0" />
<PackageReference Include="ProMvvm" />
paket add ProMvvm --version 0.1.0
#r "nuget: ProMvvm, 0.1.0"
#:package ProMvvm@0.1.0
#addin nuget:?package=ProMvvm&version=0.1.0
#tool nuget:?package=ProMvvm&version=0.1.0
ProMvvm
NuGet packages
| Package | Current version | Contents | Availability |
|---|---|---|---|
ProMvvm |
0.1.0 |
Runtime, WhenAnyValue extensions, typed property paths, and notification adapters; installs the generator automatically |
NuGet.org |
ProMvvm.SourceGenerators |
0.1.0 |
Compile-time generation of reflection-free property descriptors | NuGet.org |
Installing ProMvvm is the recommended path: it brings in the matching ProMvvm.SourceGenerators analyzer automatically. The generator is also published separately for advanced build setups that want an explicit analyzer reference. It has no runtime assets, but its generated descriptors reference the ProMvvm runtime API.
ProMvvm is a .NET 10-first, high-performance MVVM property-observation library. The 0.1.0 release focuses on WhenAnyValue: a cold IObservable<T> engine with an AOT-first typed API and a ReactiveUI-compatible expression migration API.
The runtime has no dependency on ReactiveUI, CommunityToolkit.Mvvm, or System.Reactive. It observes any compatible model through INotifyPropertyChanged by default, works with both ReactiveUI 24 and CommunityToolkit.Mvvm generated properties, and accepts explicit adapters for other notification mechanisms. Its BCL IObservable<T> results compose directly with System.Reactive 7.
See the architecture guide for the runtime state machines, typed and expression execution paths, performance decisions, AOT boundary, and design-level comparison with the ReactiveUI and ReactiveUI.Reactive 24 distributions.
Getting started
ProMvvm targets .NET 10. Install it from NuGet.org with:
dotnet add <your-project.csproj> package ProMvvm --version 0.1.0
This single reference installs the runtime and the matching source generator. No second package command is needed for normal applications.
System.Reactive is optional. Add it when you want Rx operators such as Where, Select, and the Subscribe(Action<T>) convenience overload:
dotnet add <your-project.csproj> package System.Reactive --version 7.0.0
Annotate a partial model to generate terse, reusable property descriptors. This CommunityToolkit.Mvvm model is one example; ordinary properties and ReactiveUI.SourceGenerators [Reactive] fields are supported too:
using CommunityToolkit.Mvvm.ComponentModel;
using ProMvvm;
[GeneratePropertyPaths]
public partial class SearchViewModel : ObservableObject
{
[ObservableProperty]
private string _searchText = string.Empty;
}
Observe the generated descriptor. Every subscription immediately emits the current value and then emits matching changes:
using ProMvvm;
using System.Reactive.Linq;
var viewModel = new SearchViewModel();
using var subscription = viewModel
.WhenAnyValue(SearchViewModelPropertyPaths.SearchText)
.Where(static text => !string.IsNullOrWhiteSpace(text))
.Subscribe(Console.WriteLine);
viewModel.SearchText = "NativeAOT";
Generated descriptors are the recommended default: their call sites are strongly typed, reflection-free, trim-safe, and NativeAOT-safe.
Usage
ProMvvm exposes the same observation shapes through three API styles. Choose typed paths for new code, expressions for source-compatible ReactiveUI migration, and property-name strings only where a migration boundary requires them.
| Observation | Typed/AOT API | Expression API | String-name API |
|---|---|---|---|
| One property | Generated or handwritten PropertyPath, or getter plus name |
Yes | Yes |
| Nested path | PropertyPath.Then(...) |
Yes | No |
| Constant indexer | A typed path using the indexer notification name | Yes | No |
| Single-value projection | Compose with Rx Select |
Fused selector overload | Fused selector overload |
| Multi-value projection | Arity 2–12 | Arity 2–12 | Arity 2–12 |
| Multi-value tuple | Arity 2–12 | Arity 2–12 | Arity 2–12 |
| Explicit notification adapter | Yes | Yes | No |
| Trimming and NativeAOT | Supported | RequiresUnreferencedCode |
RequiresUnreferencedCode |
Generated property descriptors
[GeneratePropertyPaths] produces a <ModelName>PropertyPaths class containing a descriptor for every accessible property:
[GeneratePropertyPaths]
public partial class SearchViewModel : ObservableObject
{
[ObservableProperty]
private string _searchText = string.Empty;
[ObservableProperty]
private SearchOptions? _options;
[ObservableProperty]
private int _minimumLength = 3;
public System.Collections.ObjectModel.ObservableCollection<string> Items { get; } =
["first", "second"];
}
public partial class SearchOptions : ObservableObject
{
[ObservableProperty]
private string _city = string.Empty;
}
var searchText = viewModel.WhenAnyValue(SearchViewModelPropertyPaths.SearchText);
The generator understands ordinary properties, CommunityToolkit.Mvvm [ObservableProperty] fields, and ReactiveUI.SourceGenerators [Reactive] fields. The main package installs the matching analyzer package automatically, and generated descriptors require no runtime reflection.
Explicit generator package reference
Most applications should reference only ProMvvm. Library authors and advanced build setups can make the analyzer reference explicit—for example, to mark it private or control it independently in central package management:
<ItemGroup>
<PackageReference Include="ProMvvm" Version="0.1.0" />
<PackageReference Include="ProMvvm.SourceGenerators"
Version="0.1.0"
PrivateAssets="all" />
</ItemGroup>
The explicit reference resolves to the same analyzer version already required by ProMvvm; NuGet loads it once. PrivateAssets="all" is useful in a library when its consumers should not inherit the analyzer. If the runtime is supplied through a project or assembly reference instead of NuGet, ProMvvm.SourceGenerators can likewise be installed as the only package reference.
The generator package is compile-time-only: it contains analyzers/dotnet/cs/ProMvvm.SourceGenerators.dll and no lib or runtime assembly. Generated descriptors still use ProMvvm.PropertyPath, so the runtime assembly must be available to the consuming compilation.
Direct typed getters
For a one-off property, pass an ahead-of-time compiled getter and its notification name:
var searchText = viewModel.WhenAnyValue(
static model => model.SearchText,
nameof(SearchViewModel.SearchText));
Use isDistinct: false to emit repeated values, or supply a comparer:
var allValues = viewModel.WhenAnyValue(
static model => model.SearchText,
nameof(SearchViewModel.SearchText),
isDistinct: false);
var ordinalIgnoreCase = viewModel.WhenAnyValue(
SearchViewModelPropertyPaths.SearchText,
comparer: StringComparer.OrdinalIgnoreCase);
Handwritten and nested typed paths
Create reusable paths when generated descriptors are not appropriate. Append Then segments for nested observation:
var city = PropertyPath
.Create<SearchViewModel, SearchOptions?>(
nameof(SearchViewModel.Options),
static model => model.Options)
.Then(
nameof(SearchOptions.City),
static options => options!.City);
using var subscription = viewModel.WhenAnyValue(city).Subscribe(Console.WriteLine);
Nested paths detach from the old object and attach to the new one whenever an intermediate property changes. A null intermediate suppresses output until the chain becomes valid again; a null final value is still a valid emission. Two- and three-segment paths use specialized sinks, while longer paths use the general watcher graph.
An indexer is a normal typed segment when its notification name is known:
var firstItem = SearchViewModelPropertyPaths.Items
.Then("Item[]", static items => items[0]);
var firstItemChanges = viewModel.WhenAnyValue(firstItem);
Expression compatibility
ReactiveUI-style expressions are available for gradual migration in ordinary JIT applications:
#pragma warning disable IL2026 // ProMvvm expression migration API
var direct = viewModel.WhenAnyValue(model => model.SearchText);
var nested = viewModel.WhenAnyValue(model => model.Options!.City);
var firstItem = viewModel.WhenAnyValue(model => model.Items[0]);
#pragma warning restore IL2026
The parser supports property and field chains, inherited properties, array Length, and constant indexer arguments such as [0] or ["key"]. It observes Item[], or the type's custom IndexerName, and rewires nested indexer paths. Captured or otherwise nonconstant indices and array-element expressions are rejected to preserve ReactiveUI 24 behavior.
Expression overloads are marked RequiresUnreferencedCode because they resolve reflected metadata. Cached, exact single-property expressions use a bound getter and the same optimized sink as typed calls after resolution, but typed descriptors remain the correct API for trimming and NativeAOT.
Property-name compatibility
Public properties can be observed by name for migration code that cannot supply an expression:
#pragma warning disable IL2026
var searchText = viewModel.WhenAnyValue<SearchViewModel, string>(
nameof(SearchViewModel.SearchText));
#pragma warning restore IL2026
Property metadata is cached, but string-name overloads are not trim-safe and cannot express nested paths.
Projections
Typed paths compose naturally with System.Reactive:
var length = viewModel
.WhenAnyValue(SearchViewModelPropertyPaths.SearchText)
.Select(static text => text.Length);
Expression and string-name migration APIs also provide fused arity-1 selector overloads:
#pragma warning disable IL2026
var expressionLength = viewModel.WhenAnyValue<SearchViewModel, int, string>(
model => model.SearchText,
static text => text.Length);
var stringLength = viewModel.WhenAnyValue<SearchViewModel, int, string>(
nameof(SearchViewModel.SearchText),
static text => text.Length);
#pragma warning restore IL2026
Multiple properties and tuples
Typed, expression, and string-name overloads combine 2 through 12 sources. Pass a selector to project the latest values:
var canSearch = viewModel.WhenAnyValue(
SearchViewModelPropertyPaths.SearchText,
SearchViewModelPropertyPaths.MinimumLength,
static (text, minimumLength) => text.Length >= minimumLength);
#pragma warning disable IL2026
var expressionCanSearch = viewModel.WhenAnyValue(
model => model.SearchText,
model => model.MinimumLength,
static (text, minimumLength) => text.Length >= minimumLength);
var stringCanSearch = viewModel.WhenAnyValue<
SearchViewModel, bool, string, int>(
nameof(SearchViewModel.SearchText),
nameof(SearchViewModel.MinimumLength),
static (text, minimumLength) => text.Length >= minimumLength);
#pragma warning restore IL2026
Omit the selector to receive a named ValueTuple; tuple overloads also cover arities 2 through 12:
var typedState = viewModel.WhenAnyValue(
SearchViewModelPropertyPaths.SearchText,
SearchViewModelPropertyPaths.MinimumLength);
#pragma warning disable IL2026
var expressionState = viewModel.WhenAnyValue(
model => model.SearchText,
model => model.MinimumLength);
var stringState = viewModel.WhenAnyValue<SearchViewModel, string, int>(
nameof(SearchViewModel.SearchText),
nameof(SearchViewModel.MinimumLength));
#pragma warning restore IL2026
For typed multi-source projections, isDistinct controls both input and projected-result distinctness and comparer can customize the result comparison. Expression and string compatibility overloads preserve ReactiveUI's input-only distinctness by default; an expression result comparer opts into projected-result distinctness. Use Rx DistinctUntilChanged after a string projection when output distinctness is required.
Notification adapters
By default ProMvvm listens directly to INotifyPropertyChanged. Custom event sources and IObservable<string?> change streams can be adapted explicitly at the call site without a registry, global provider, or service locator:
var customAdapter = PropertyNotificationAdapters.Create<CustomViewModel>(
static (model, onPropertyChanged) =>
model.SubscribePropertyChanges(onPropertyChanged));
var changesAdapter = PropertyNotificationAdapters.FromObservable<StreamViewModel>(
static model => model.ChangedPropertyNames);
var mixedAdapter = PropertyNotificationAdapters.FirstSupported(
customAdapter,
changesAdapter,
PropertyNotificationAdapters.Inpc);
var value = customViewModel.WhenAnyValue(
static model => model.Value,
nameof(CustomViewModel.Value),
mixedAdapter);
SubscribePropertyChanges in this example is an application method that attaches the custom event and returns an IDisposable. FirstSupported tries adapters in the supplied order for every object in a nested chain, so different levels can use different notification mechanisms. Single typed and expression observations accept adapters; multi-source typed and expression projections and tuples accept them from arity 2 through 12. String-name overloads do not.
ReactiveUI and CommunityToolkit.Mvvm models
ProMvvm observes behavior rather than requiring a base class. Add [GeneratePropertyPaths] to either source-generator style:
// CommunityToolkit.Mvvm
[GeneratePropertyPaths]
public partial class ToolkitViewModel : ObservableObject
{
[ObservableProperty] private string _name = string.Empty;
}
// ReactiveUI.SourceGenerators; ReactiveObject may come from either ReactiveUI 24 package.
[GeneratePropertyPaths]
public partial class ReactiveViewModel : ReactiveObject
{
[Reactive] private string _name = string.Empty;
}
Both produce ...PropertyPaths.Name, observed with the same ProMvvm call. See the complete CommunityToolkit.Mvvm sample, ReactiveUI 24 sample, and their expression-mode counterparts listed below.
ProMvvm compared with ReactiveUI usage
ReactiveUI 24 core and ReactiveUI.Reactive expose the same expression-oriented WhenAnyValue call shapes from different namespaces. ProMvvm expression mode intentionally keeps those shapes familiar, while typed descriptors provide its reflection-free fast path.
| Scenario | ProMvvm | ReactiveUI 24 |
|---|---|---|
| Namespace | using ProMvvm; |
using ReactiveUI; or using ReactiveUI.Reactive; |
| Model requirement | Any class using INPC or an explicit local adapter | ReactiveUI object/provider ecosystem, including INPC providers |
| Recommended single property | vm.WhenAnyValue(ModelPropertyPaths.Name) |
vm.WhenAnyValue(x => x.Name) |
| Expression migration | vm.WhenAnyValue(x => x.Name) |
vm.WhenAnyValue(x => x.Name) |
| Nested property | Typed .Then(...) or vm.WhenAnyValue(x => x.Child.Name) |
vm.WhenAnyValue(x => x.Child.Name) |
| Projection | Typed observation plus .Select(...), or compatible expression selector |
Expression selector |
| Multiple values | Typed paths, expressions, or names through arity 12 | Expressions or names through arity 12 |
| Tuple output | Built-in typed, expression, and name overloads through arity 12 | Use the available ReactiveUI overload or project a tuple |
| Constant indexer | Typed Item[] path or compatible expression |
Expression |
| Notification extension | Explicit per-call adapter | Globally configured observable-for-property providers/services |
| Trimming/NativeAOT | Typed APIs and generated descriptors are supported | Expression/provider pipeline is not ProMvvm's AOT-safe path |
| Observable type | BCL IObservable<T>; System.Reactive is optional |
System.Reactive-based observable pipeline |
The simplest expression migration is usually a namespace change:
// ReactiveUI
using ReactiveUI;
var oldStream = viewModel.WhenAnyValue(model => model.SearchText);
becomes:
// ProMvvm expression compatibility
using ProMvvm;
#pragma warning disable IL2026
var migratedStream = viewModel.WhenAnyValue(model => model.SearchText);
#pragma warning restore IL2026
For new ProMvvm code, replace the expression with a generated descriptor:
var optimizedStream = viewModel.WhenAnyValue(
SearchViewModelPropertyPaths.SearchText);
When both libraries are referenced, importing both extension namespaces can make identical expression overloads ambiguous. During incremental migration, call the ProMvvm extension explicitly or use the unambiguous typed getter/descriptor form:
#pragma warning disable IL2026
var proExpression = ProMvvm.WhenAnyValueExtensions.WhenAnyValue(
viewModel,
model => model.SearchText);
#pragma warning restore IL2026
var proTyped = viewModel.WhenAnyValue(
static model => model.SearchText,
nameof(SearchViewModel.SearchText));
ProMvvm does not require ReactiveUI builder initialization or global service configuration. It is currently a focused WhenAnyValue implementation, not a replacement for ReactiveUI binding, commands, activation, routing, or the rest of the framework.
Runtime semantics
- Emits the current value synchronously for each subscription.
- Produces a cold observable with independent subscriptions.
- Observes nested
INotifyPropertyChangedchains and rewires when an intermediate object changes. - Suppresses output while an intermediate object is
null; anullfinal value remains valid. - Applies final-value distinctness by default for single and typed paths, including across temporarily invalid nested chains.
- Supports custom equality comparers and disabling distinctness.
- Supports a ReactiveUI-compatible arity-1 selector plus typed, expression, and string-name selector overloads through arity 12.
- Supports typed, expression, and string-name tuple overloads from arity 2 through arity 12.
- Supports constant-argument expression indexers, custom indexer notification names, nested rewiring, and ReactiveUI-compatible array-length/error behavior.
- Accepts explicit notification adapters for custom events or
IObservable<string?>change streams, including mixed object chains, without global registration. - Serializes model event handling and observer notification per subscription.
- Detaches all handlers on disposal or terminal getter/selector error.
Samples
- ReactiveUI 24 typed sample uses generated ProMvvm descriptors with
ReactiveUI.Reactive24.0.0 andReactiveUI.SourceGenerators3.2.0. - CommunityToolkit.Mvvm typed sample uses generated ProMvvm descriptors with
[ObservableProperty]properties. - ReactiveUI 24 expression sample demonstrates direct, nested, fused single-selector, arity-2/3 selector, and tuple expression observations.
- CommunityToolkit.Mvvm expression sample demonstrates the same expression migration surface on Toolkit-generated properties.
The typed samples are trim-safe and NativeAOT-safe. The expression samples intentionally target ordinary JIT applications and keep the RequiresUnreferencedCode boundary visible at their call sites.
Run them with:
dotnet run --project samples/ProMvvm.Sample.ReactiveUI -c Release
dotnet run --project samples/ProMvvm.Sample.CommunityToolkit -c Release
dotnet run --project samples/ProMvvm.Sample.ReactiveUI.Expression -c Release
dotnet run --project samples/ProMvvm.Sample.CommunityToolkit.Expression -c Release
Verification
dotnet build ProMvvm.slnx -c Release
dotnet test tests/ProMvvm.Tests -c Release
dotnet test tests/ProMvvm.SourceGenerators.Tests -c Release
dotnet test tests/ProMvvm.IntegrationTests -c Release
dotnet test tests/ProMvvm.ReactiveUiCoreIntegrationTests -c Release
./eng/test-local-packages.sh Release
dotnet publish tests/ProMvvm.AotSmoke -c Release -r osx-arm64 --self-contained
./tests/ProMvvm.AotSmoke/bin/Release/net10.0/osx-arm64/publish/ProMvvm.AotSmoke
Release unit tests enforce 100% line coverage, at least 98% branch coverage, and 100% method coverage. Dedicated generator tests validate emitted descriptors and overloads; integration tests compile and execute both source-generator ecosystems.
CI builds and tests on Linux, Windows, and macOS, and full-trim and NativeAOT smoke executables run on all three platforms. Both framework samples are also NativeAOT-published and executed. A dedicated local-package CI job packs both NuGet packages and runs 12 dependency-isolated integration projects across plain INPC, explicit adapters, CommunityToolkit.Mvvm, ReactiveUI core, and ReactiveUI.Reactive, with separate typed, expression, and explicit-generator graphs. See the local NuGet integration matrix for the exact coverage and focused commands.
Releasing
Version tags drive the release workflow, which revalidates the complete test and local-package integration surface, publishes both packages to NuGet.org through trusted publishing, and creates a GitHub release containing the packages, runtime symbols, and SHA-256 checksums. See the release guide for repository setup and the release procedure.
Benchmarks
The BenchmarkDotNet suite compares typed, expression, and direct string-name ProMvvm paths with both ReactiveUI 24 distributions under the same notification models:
- ProMvvm typed/AOT paths.
- ProMvvm expression compatibility paths.
- ReactiveUI 24 core expression paths.
- ReactiveUI.Reactive 24 expression paths.
It covers cold construction, warmed end-to-end hot start, generated-descriptor hot start, direct and projected string-name calls, fused single selectors, subscribe + initial emission + disposal, leaf emission, burst throughput (1, 100, and 10,000 changes), two- and three-segment hot start, nested leaf emission and rewiring, constant-indexer hot start/emission/rewiring, arity-2 and arity-12 projection, explicit-adapter overhead, and allocation for every case.
Representative results from the latest verified Apple M3 Pro run on .NET 10 are shown below. Values are mean time / managed allocation per operation; benchmark results vary by machine and runtime.
| Scenario | ProMvvm typed | ProMvvm expression | ReactiveUI core | ReactiveUI.Reactive |
|---|---|---|---|---|
| Hot start | 45.194 ns / 232 B | 244.856 ns / 792 B | 362.789 ns / 1,440 B | 373.471 ns / 1,440 B |
| Single emission | 8.413 ns / 24 B | 10.261 ns / 24 B | 27.458 ns / 88 B | 27.490 ns / 88 B |
| Two-property selector | 23.75 ns / 24 B | 25.35 ns / 24 B | 47.71 ns / 88 B | 47.53 ns / 88 B |
| Two-segment hot start | 63.93 ns / 352 B | 335.99 ns / 1,104 B | 611.36 ns / 2,152 B | 620.88 ns / 2,152 B |
| Twelve-property selector | 206.7 ns / 24 B | 226.9 ns / 24 B | 552.3 ns / 792 B | 553.8 ns / 792 B |
dotnet run --project benchmarks/ProMvvm.Benchmarks -c Release -- --filter '*'
See benchmarks/README.md for the complete result tables, environment metadata, focused commands, and measurement rules.
Project status
ProMvvm 0.1.0 is the first public release and completes the planned WhenAnyValue compatibility milestone.
| Area | 0.1.0 status |
|---|---|
| Packages | Dependency-free ProMvvm runtime plus a compile-time-only ProMvvm.SourceGenerators analyzer package; the runtime package installs the analyzer automatically |
| Typed API | Generated and handwritten paths, direct getters, nested paths, selectors, and tuples through arity 12 |
| Migration API | ReactiveUI-compatible expression and string-name observation, including selectors and tuples through arity 12 |
| Notifications | Direct INPC plus explicit custom-event and observable-stream adapters; no service locator |
| Frameworks | Validated with CommunityToolkit.Mvvm, ReactiveUI 24, and ReactiveUI.Reactive 24 |
| Deployment | Trim-safe and NativeAOT-safe typed surface, validated on Linux, Windows, and macOS |
| Quality | Concurrent stress tests, generator/integration suites, 100% line and method coverage, and at least 98% branch coverage |
| Performance | BenchmarkDotNet coverage for cold construction, hot start, emissions, bursts, nested rewiring, indexers, adapters, and multi-source sinks |
The release includes the performance-driven specializations developed for direct typed observation, cached expression and string compatibility, two- and three-segment nested paths, constant indexers, fused arity-1 selectors, and multi-source sinks. The public compatibility namespace is ProMvvm, so both libraries can remain referenced during gradual migration. Generated descriptors and the getter + property-name overload avoid extension ambiguity when ReactiveUI is also imported.
The current scope is deliberately narrower than the whole ReactiveUI framework. Binding, commands, activation, routing, before-change observation, arbitrary dynamic paths, and nonconstant index expressions are not part of 0.1.0.
Next parity work
Future milestones will be evaluated in this order, with compatibility tests and benchmark evidence required for each addition:
- Before-change observation and the remaining property-observation shapes needed for broader ReactiveUI migration.
- Typed, descriptor-driven binding APIs that preserve the trimming and NativeAOT guarantees of the current core.
- Source-generator support for more generic, nested, and inherited model shapes with deterministic generated names.
- Additional platform execution coverage for mobile and browser targets as .NET 10 runners and NativeAOT support permit.
- ReactiveUI source-generated observation benchmarks and new specializations only where end-to-end measurements justify their runtime and code-size cost.
Commands, activation, and routing remain longer-term compatibility areas rather than commitments for the next release.
License
ProMvvm is licensed under the MIT License. Copyright © 2026 Wiesław Šoltés.
| Product | Versions Compatible and additional computed target framework versions. |
|---|---|
| .NET | net10.0 is compatible. net10.0-android was computed. net10.0-browser was computed. net10.0-ios was computed. net10.0-maccatalyst was computed. net10.0-macos was computed. net10.0-tvos was computed. net10.0-windows was computed. |
-
net10.0
- ProMvvm.SourceGenerators (>= 0.1.0)
NuGet packages
This package is not used by any NuGet packages.
GitHub repositories
This package is not used by any popular GitHub repositories.
| Version | Downloads | Last Updated |
|---|---|---|
| 0.1.0 | 91 | 8/19/2026 |