Skip to content

Move to .NET 10 and ReactiveUI 23#34

Draft
michaelstonis wants to merge 25 commits into
developfrom
feature/net10-modernization
Draft

Move to .NET 10 and ReactiveUI 23#34
michaelstonis wants to merge 25 commits into
developfrom
feature/net10-modernization

Conversation

@michaelstonis

Copy link
Copy Markdown
Contributor

Retargets every project to .NET 10, takes the dependency upgrades that move forces, and adds the CI gate that was missing.

Stacked as five reviewable commits — reading them in order is much easier than reading the diff.

What's here

Commit
c184dc2 CI build + test gate, and a Stellar.slnf filter for the Linux job
2af3985 Central package management
ebca6e4 .NET 10 retarget, start of the ReactiveUI 23 migration
ce58735 ReactiveUI 23 migration for MAUI
7459a84 Avalonia bootstrap replacement

Why there was no choice about ReactiveUI

ReactiveUI 21 publishes no net10.0 target frameworks, so .NET 10 forces 21 → 23.2.28. That release removes RxApp, PlatformRegistrationManager and RegistrationNamespace outright; Splat 19 also removes IEnableLogger. Initialization moves to RxAppBuilder.

Two other bumps are likewise forced rather than chosen:

  • CommunityToolkit.Maui 12 → 14.2.2 — 12.x pins Microsoft.Maui.Essentials < 10.0.0
  • Android minSdk 21 → 24 — androidx.lifecycle.runtime now requires at least 23

Avalonia stays at 11.3.9. Avalonia itself has shipped 12.1.0, but Avalonia.ReactiveUI has no 12.x release, not even a prerelease.

Known degradation on Avalonia

Avalonia.ReactiveUI 11.3.9 is built against ReactiveUI 20.1.1. Paired with ReactiveUI 23 it compiles cleanly and fails at runtime, so this was measured by loading the assembly rather than by reading release notes.

Still works — ReactiveWindow<T>, ReactiveUserControl<T>, AvaloniaActivationForViewFetcher (still satisfies ReactiveUI 23's IActivationForViewFetcher), AvaloniaScheduler, AutoDataTemplateBindingHook.

TypeLoadExceptionRegistrations, AvaloniaMixins.UseReactiveUI, AutoSuspendHelper, RoutedViewHost, ViewModelViewHost, AvaloniaObjectObservableForProperty.

Stellar therefore performs the registrations itself instead of calling Avalonia's bootstrap. Consumers should know that RoutedViewHost, ViewModelViewHost and AutoSuspendHelper are unusable in this combination, and that property observation on Avalonia controls falls back to INotifyPropertyChanged.

Verification

All 13 projects build; the test suite passes on .NETCoreApp,Version=v10.0. Verified locally for net10.0 and net10.0-android.

iOS and MacCatalyst were not verified locally — the .NET 10 workload band 26.5.10301 requires Xcode 26.6 and the machine has 26.5. This PR's maui job is the first real check of those targets.

Not in this PR

Test coverage is still 3 tests over roughly 8,000 lines, so a green run here means "it compiles," not "it works." Raising that bar, the remaining optional major upgrades, the scheduled dependency-update agent, and the v2.0.0 release are separate follow-ups.

🤖 Generated with Claude Code

michaelstonis and others added 25 commits July 24, 2026 07:46
Provide a comprehensive framework guide and AI prompts to assist developers in maintaining StellarUI's reactive patterns and C# markup standards.
Adds a CI workflow that builds every project and runs the test suite on
pull requests and pushes to main/develop. Until now nuget.yml was the only
workflow, and it triggers on release tags -- so a broken dependency was not
discovered until it had already been packed and pushed.

Stellar.slnf carves out the nine projects that build without the MAUI
workload, which keeps that job on a Linux runner and under a minute. The
MAUI projects build separately on macOS, packages before samples so a
sample failure does not obscure package health.

Also moves nuget.yml off the deprecated v3 actions and onto macos-15.

Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>
Moves every package version into Directory.Packages.props. Versions were
previously duplicated across thirteen project files, which made a dependency
bump a thirteen-file diff and let versions drift apart unnoticed --
ReactiveUI.SourceGenerators was pinned to 2.0.17 in Stellar.AvaloniaSample
and 2.4.1 in the other two samples. Central management forces one version,
so the samples converge on 2.4.1.

Every project also carried a redundant re-pin of Microsoft.CodeAnalysis
.NetAnalyzers at the same version Directory.build.props already set; those
twelve lines are removed.

Transitive pinning is enabled so a vulnerable indirect dependency can be
patched centrally without waiting for its parent package to update.

No versions change here.

Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>
Retargets every project to net10.0 (Stellar.SourceGenerators stays on
netstandard2.0 for Roslyn) and moves global.json and both workflows to the
.NET 10 SDK.

ReactiveUI 21 publishes no net10.0 target frameworks, so .NET 10 forces the
jump to 23.2.28. That release removes RxApp, PlatformRegistrationManager and
RegistrationNamespace outright, replacing them with RxAppBuilder. Migrated so
far:

  Stellar        RxApp.{Taskpool,MainThread}Scheduler -> RxSchedulers
  Stellar.Blazor initialization -> RxAppBuilder.CreateReactiveUIBuilder()
                                    .WithBlazor()

Stellar, Stellar.Blazor, Stellar.BlazorSample, Stellar.DiskDataCache,
Stellar.FluentValidation, Stellar.SourceGenerators and Stellar.UnitTests all
build and the tests pass on net10.0.

Still broken, deliberately not papered over:
  Stellar.Maui     27 distinct errors across four files
  Stellar.Avalonia blocked on Avalonia.ReactiveUI, see next commit message

Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>
Finishes the MAUI half of the .NET 10 retarget. Stellar.Maui, Stellar.Maui
.PopUp, Stellar.MauiSample and Stellar.MauiBlazorHybridSample all build.

  * MAUI initialization moves to MauiAppBuilder.UseReactiveUI(b => b.WithMaui()),
    replacing SetRegistrationNamespaces and InitializeReactiveUI. The existing
    MauiSchedulerInitializer still assigns the schedulers, because IDispatcher
    cannot be resolved at builder time.
  * RxApp.{Taskpool,MainThread}Scheduler -> RxSchedulers throughout, including
    the samples. The CS8821 static-lambda errors turned out to be cascading from
    these and cleared on their own.
  * System.Reactive 6.1 moved the CompositeDisposable overload of DisposeWith
    into System.Reactive.Disposables.Fluent; without that namespace the call
    binds to a SerialDisposable overload. Added to Stellar.Maui's global usings.
  * CommunityToolkit.Maui 12.x pins Microsoft.Maui.Essentials below 10.0.0, so
    .NET 10 forces 14.2.2 and Markup 7.0.1.
  * Android minSdk raised from 21 to 24; androidx.lifecycle.runtime now requires
    at least 23, and Stellar.MauiBlazorHybridSample already declared 24.

Verified locally for net10.0 and net10.0-android. iOS and MacCatalyst are not
verifiable on this machine: the net10 workload band 26.5.10301 requires
Xcode 26.6 and 26.5 is installed. CI resolves those with latest-stable.

Stellar.Avalonia remains blocked on Avalonia.ReactiveUI.

Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>
Avalonia.ReactiveUI 11.3.9 is built against ReactiveUI 20.1.1 and there is no
12.x release, so it cannot be upgraded alongside ReactiveUI 23. Probing the
assembly against ReactiveUI 23.2.28 and Splat 19 shows the damage is confined
to its bootstrap: Registrations, AvaloniaMixins.UseReactiveUI, AutoSuspendHelper,
RoutedViewHost, ViewModelViewHost and AvaloniaObjectObservableForProperty all
fail to type-load, because they reference RxApp, PlatformRegistrationManager,
RegistrationNamespace or Splat's IEnableLogger.

Everything Stellar.Avalonia actually depends on still loads and works:
ReactiveWindow<T>, ReactiveUserControl<T>, AvaloniaActivationForViewFetcher
(which still satisfies ReactiveUI 23's IActivationForViewFetcher),
AvaloniaScheduler and AutoDataTemplateBindingHook.

So rather than calling Avalonia's bootstrap, Stellar registers those services
itself through RxAppBuilder.

Consumers should be aware that RoutedViewHost, ViewModelViewHost and
AutoSuspendHelper are unusable in this combination, and that with
AvaloniaObjectObservableForProperty gone, ReactiveUI property observation on
Avalonia controls falls back to INotifyPropertyChanged.

Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>
Package updates within their current majors:

  Avalonia (except .ReactiveUI)  11.3.9         -> 11.3.18
  CommunityToolkit.Mvvm          8.4.0          -> 8.4.2
  FluentValidation               12.0.0         -> 12.1.1
  Mopups                         1.3.2          -> 1.3.4
  Roslynator.Analyzers           4.14.0         -> 4.15.0
  xunit.runner.visualstudio      3.1.4          -> 3.1.5
  stylecop.analyzers             1.2.0-beta.435 -> 1.2.0-beta.556

Avalonia.ReactiveUI stays at 11.3.9 because that is still its newest release.

GitHub Actions were several majors behind and CI warned that checkout,
setup-dotnet and upload-artifact all target the deprecated Node.js 20 runtime:

  actions/checkout          v4     -> v7
  actions/setup-dotnet      v4     -> v6
  actions/upload-artifact   v4     -> v7
  maxim-lobanov/setup-xcode v1     -> v1.7.0
  release-kit/semver        v1.0.10 -> v2.0.7

The MAUI job also moves from macos-15 to macos-26. The first CI run failed
because the .NET 10 iOS workload band 26.5.10301 requires Xcode 26.6 and the
newest Xcode on macos-15 is 26.3; macos-26 ships 26.6. Library builds skip
that check, which is why Stellar.Maui passed there and Stellar.MauiSample
did not.

Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>
…enerator

Roslyn 4.12.0 -> 5.0.0, which forced the rewrite: with EnforceExtendedAnalyzerRules
already set, Roslyn 5 turns RS1035 and RS1042 into hard errors, and
ServiceRegistrationGenerator was still on the non-incremental ISourceGenerator API
via ISyntaxReceiver. That API also re-ran the whole generator on every keystroke
in consuming projects.

The pipeline now uses ForAttributeWithMetadataName and carries plain strings
rather than INamedTypeSymbol, so the model compares by value; holding symbols in
the pipeline would root the compilation and defeat caching entirely.

Roslyn is pinned to 5.0.0, not the newest 5.6.0, on purpose. A generator only
loads in a host whose Roslyn is at least the version it references, and the .NET 10
SDK feature bands ship 5.0 (10.0.1xx), 5.3 (10.0.2xx) and 5.6 (10.0.3xx).
Referencing 5.6 would silently stop the generator loading for anyone below
10.0.3xx. For analyzers the reference is a compatibility floor, not a version to
maximize.

Microsoft.CodeAnalysis.Workspaces.Common is dropped: it was never used, and RS1038
warns that a compiler extension should not reference Workspaces because it is
absent during command line compilation.

Verified behaviourally rather than by compilation alone. Generated output for
Stellar.MauiSample was captured from a clean net10.0-android build before and
after; the emitted registrations are the same set of lines. The only change is
ordering, which is now sorted and therefore no longer depends on the order the
driver visits syntax trees.

Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>
  Microsoft.NET.Test.Sdk  17.14.1 -> 18.8.1
  coverlet.collector      6.0.4   -> 10.0.1

Both build and the suite still runs, including the trx logger the CI workflow
depends on. xunit stays at 2.9.3, which is current for the v2 line; moving to
v3 means a different package id and is a migration rather than an upgrade.

Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>
Major bump from 2.4.1. The samples that use it -- Stellar.MauiSample,
Stellar.BlazorSample and Stellar.AvaloniaSample -- all still build, and the
[Reactive] partial properties they generate resolve unchanged.

Drops the note about reconciling 2.0.17 and 2.4.1; central package management
settled that and all three are now on one version by construction.

Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>
Adds 40 tests over WeakCompositeDisposable, WeakSerialDisposable and
WeakSingleAssignmentDisposable -- 714 lines that previously had none, and the
riskiest code in the repository: weak references plus disposal ordering, with
no UI framework in the way to make it hard to test.

Pins the contracts that are easy to break silently in a refactor:

  * Remove disposes the item it removes, Clear does not mark the composite
    disposed, and Dispose is idempotent without double-disposing contents.
  * The Serial and SingleAssignment getters return Disposable.Empty rather than
    null once disposed, so callers can keep dereferencing.
  * SingleAssignment keeps the first assignment and disposes the second, and
    ignores null entirely.
  * Anything handed to a container whose lifetime scope has been collected is
    disposed immediately rather than retained. This is the reason these types
    exist, so it is worth the GC-dependent test.

Verified the suite actually detects regressions rather than merely passing:
removing the item.Dispose() call from WeakCompositeDisposable.Remove fails
Remove_DisposesTheRemovedItem and nothing else.

Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>
Adds Microsoft.Reactive.Testing so the conflating behaviour can be asserted
deterministically: with a TestScheduler nothing drains until the test advances
time, which is exactly the window where values are meant to be superseded.

Covers coalescing, completion, error propagation, an error superseding a pending
value, and delivery after an earlier drain.

The important one is DeliversANullValueRatherThanDroppingIt. Commit fcaad8d
fixed the drain loop to consult a pending flag rather than null-checking the
stored value; reintroducing that null check fails this test and only this test.
Note the value-type sibling does not catch that particular regression, since
`latestValue is not null` is true for 0 -- it guards a different mistake.

Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>
ThrottleFirst gets virtual-time coverage of its leading-edge contract: the first
value passes straight through, the window stays shut right up to the delay
boundary, and values suppressed during the window are dropped rather than
flushed when it reopens.

The predicate helpers are individually trivial, but several pairs differ only by
name, so a mix-up would change behaviour without breaking the build. Pinned in
particular:

  WhereIsTrue / WhereIsFalse   filter
  ValueIsTrue                  projects, identity
  ValueIsFalse                 projects, inverting
  IsNotNull on strings         keeps string.Empty
  IsNotNullOrEmpty             does not
  WhereHasValue                keeps 0, which has a value

83 tests, up from 3 at the start of this work.

Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>
Seven tests over ValidatingViewModelBase, which had none despite its scheduler
calls being rewritten during the ReactiveUI 23 migration in this branch.

The pipeline hops through RxSchedulers.TaskpoolScheduler, which is global mutable
state, so the fixture swaps it for an immediate scheduler and restores it, and the
class sits in a non-parallel collection so that swap cannot race other tests.

Covers the initial valid state, a failing validation reaching IsValid and
ValidationErrors, recovery back to valid, growing the error set, and disposal
halting further validation.

The one worth having is SubsequentValidation_ReplacesErrorsPositionallyAndTrimsThe
Excess. UpdateValidationState deliberately overwrites in place and then trims the
tail rather than clearing and re-adding, to keep CollectionChanged noise down;
deleting the trim loop leaves stale errors visible and fails that test alone.

90 tests, up from 3.

Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>
Fifteen tests over the binding and lifecycle state machine, which had none.

Pins the behaviours that are easy to break without failing a build:

  * RegisterBindings is idempotent and emits Initialized once, not per call.
  * Maintain makes UnregisterBindings a no-op. This is what lets singleton and
    scoped views survive deactivation with their bindings intact; without it a
    long-lived view silently stops updating after the first navigation away.
  * Lifecycle and navigation streams each filter to their own event.
  * A view model opting into ILifecycleEventAware or INavigationEventAware is
    notified directly, independently of the observable streams.
  * After disposal the streams are empty and RegisterBindings throws, but
    OnLifecycle and OnNavigating stay silent, because the platform can deliver
    those during teardown.

Removing the Maintain check from UnregisterBindings fails
UnregisterBindings_WhenMaintainIsSet_KeepsTheBindings and nothing else.

105 tests, up from 3.

Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>
Eleven tests over the disk cache, which the test project did not even reference
before. Covers the store/retrieve round trip, default keying by type name,
distinct cache keys, group isolation, store-many/retrieve-many, removal and
clearing a single group without touching its neighbours.

Two things surfaced while writing these, both left as-is rather than changed
here, because they are public API on a shipping package:

  * StoreAsync's string and Func<T,string> overloads are both optional and
    nullable, so StoreAsync(item) is ambiguous and does not compile. The tests
    work around it with an explicit (string?) cast.
  * RemoveAsync returns true whenever no IOException occurs, so removing a key
    that was never stored also reports success. The test documents the real
    behaviour rather than the intuitive reading.

116 tests, up from 3.

Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>
Timing the first green run showed the MAUI job spent 24.6 of its 26.2 minutes
building the two sample heads:

  install maui workloads          0.85 min
  Stellar.Maui                    0.40 min
  Stellar.Maui.PopUp              0.15 min
  Stellar.MauiSample             13.73 min
  Stellar.MauiBlazorHybridSample 10.83 min

The packages are therefore verifiable in well under two minutes, and the samples
were running back to back inside one job for no reason.

Splitting them gives a maui job that reports in ~1.5 minutes and a samples matrix
whose two heads run concurrently, taking full coverage from ~26 minutes to ~14.
The samples job honours a skip-samples label so routine dependency bumps can opt
out of it entirely.

Skipping is a real trade, not free speed: during the .NET 10 upgrade the samples
and only the samples caught the CommunityToolkit.Maui / Microsoft.Maui.Essentials
conflict and the Android minSdk break, both of which the packages compiled
straight through. Reasonable to skip for a patch bump, not for a major.

Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>
…truthful

Two defects surfaced by the new DiskCache tests, both breaking changes to
IDataCache, so they land with v2.0.0 rather than in a patch.

StoreAsync and StoreManyAsync each had a string overload and a Func<T, string>
overload whose key parameters were both optional and nullable, so StoreAsync(item)
failed with CS0121 and callers had to write StoreAsync(item, (string?)null). The
selector parameter is now required, which is what distinguishes the overloads.
Giving it a default again reintroduces the ambiguity.

RemoveAsync returned true whenever no IOException occurred. File.Delete does not
throw for a missing path, so removing a key that was never stored reported
success. RemoveFile now returns whether it deleted anything and RemoveAsync
passes that through, so the bool means "an entry existed and was removed".

Tests drop the disambiguating casts and add coverage for the selector overload
and for both key-less calls binding through IDataCache, which is how consumers
resolve the cache from DI and where the ambiguity actually bit.

Also corrects the docs, which were wrong independently of this: the README showed
GetAsync<T>, which does not exist, and registered DiskCache by type despite it
having no parameterless constructor.

Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>
Adds a github/gh-aw agentic workflow that runs weekly, checks nuget.org for
newer stable releases of everything in Directory.Packages.props, verifies the
result builds and tests clean, and opens a single pull request. It never merges;
a human does.

Uses the Copilot engine, so it needs a COPILOT_GITHUB_TOKEN repository secret
before it will run. Nothing else is required.

The prompt encodes the constraints this branch discovered the hard way, because
an agent told only to "take the latest" would undo them silently:

  * Microsoft.CodeAnalysis.* stays at 5.0.0. A source generator only loads in a
    host whose Roslyn is at least the referenced version, and the .NET 10 SDK
    bands ship 5.0, 5.3 and 5.6. Taking 5.6 would stop StellarUI.SourceGenerators
    loading for anyone below SDK 10.0.3xx with no build failure anywhere.
  * Avalonia stays on 11.3.x while Avalonia.ReactiveUI has no 12.x release.
  * Stable versions only, except stylecop.analyzers which has no stable release.
  * xunit stays on v2; xunit.v3 is a different package id, not an upgrade.
  * Only Directory.Packages.props is edited, never a csproj or global.json.

Network access uses the dotnet ecosystem allowlist, which the firewall defaults
do not cover -- without it nuget.org is unreachable and restore fails. The dotnet
runtime is pinned to 10.x because global.json requires a .NET 10 SDK.

Labelling follows the agreed rule: every PR gets `dependencies`, and
`skip-samples` is applied only when the batch contains a major bump. Both labels
were created on the repository.

Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>
All thirteen projects now build with zero code warnings, down from roughly sixty.
The suite still passes at 118 tests.

Packages:

  * NavigationObservableExtensions had nine CS8602 warnings, all the same
    x.NavigationRoot.Navigation dereference. NavigationOptions has a parameterless
    constructor that leaves NavigationRoot null, so the compiler was right to
    complain. Added a RequiredNavigationRoot property that throws an explanatory
    InvalidOperationException, and routed all nine call sites plus the one existing
    null-forgiving use through it. Previously a missing root produced a bare
    NullReferenceException from somewhere inside the navigation stack.
  * ReactivePopupPage declared ViewModel non-nullable while IViewFor<T> declares it
    nullable. Aligned it with the nullable form already used by ReactiveGrid and
    ReactiveStackLayout, which also clears the CS8601 in OnBindingContextChanged.
  * PopupPageBase.OnPropertyChanged now takes string? to match the base signature.
  * One PopUp overload declared Func<TParameter, TPage> where every sibling in both
    navigation files uses Func<TParameter?, TPage>. Aligned.
  * ActivatableListView._cellActivatedAction and AvaloniaViewManager._view are both
    genuinely nullable and already null-checked; they are now declared that way.
  * Blazor's injected NavigationManager uses the standard [Inject] default! idiom.

ListView deprecation is suppressed rather than fixed, per the decision to keep
supporting ListView for as long as MAUI ships it. The suppressions are file-scoped
with the reasoning attached, not a blanket NoWarn.

Samples:

  * Both Counter components proxied a [Parameter] through a computed property, which
    is what BL0007 warns about -- Blazor assigns parameters directly. They are now
    auto-properties that sync to the view model in OnParametersSet.
  * Both dropped a redundant Navigation property that hid the one the base class
    already injects.
  * The hybrid sample's Bind created an Observable.Interval subscription and never
    disposed it, ignoring the disposables it was handed. That leak is fixed; it
    matters more than the warning, since samples get copied.
  * The hybrid App moved off the deprecated Application.MainPage to CreateWindow.
  * ViewLocator now matches IDataTemplate's nullable signatures.

Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>
Aimed at the framework's cost in allocation-heavy apps: what a view costs simply by
existing, and what runs on every validation pass. Verified by the existing suite,
now 120 tests.

Per-instance, paid by every view whether or not the feature is used:

  * ViewManager built fourteen Lazy instances and fourteen capturing closures in its
    constructor -- roughly twenty-eight heap allocations per view -- to expose twelve
    lifecycle and navigation streams that most views never subscribe to. The subjects
    and the projections are now created on first access via Interlocked.CompareExchange,
    so an untouched stream costs one null reference field. Observable.Empty is cached
    per element type rather than rebuilt on every read from a disposed manager.
  * ViewModelBase allocated a Lazy<bool> and its closure per view model to answer a
    question only asked when bindings are unregistered. It is a bool? computed on
    demand now; AttributeCache already memoises the reflection per type.

Per-validation, which runs on every change to a watched property:

  * The FluentValidation adapter built a LINQ projection and a List on every pass,
    including passes with no errors at all. The valid case now returns the shared
    DefaultValidationResult and allocates nothing; the failing case fills an
    exactly-sized array instead of growing a list through Select().ToList().
  * ValidationResult.ValidationInformation is IReadOnlyList rather than
    IReadOnlyCollection, which lets UpdateValidationState walk it by index instead of
    allocating an enumerator on every pass. This is a breaking API change, so it lands
    with v2.0.0; arrays and lists both satisfy the new type.

Elsewhere:

  * Three Select operators in IObservableExtensions existed only to launder a null
    annotation. T? and T are the same type at runtime, so the chain is null-forgiven
    instead -- one fewer operator allocated per call and one fewer delegate invoked
    per element.
  * Lambdas that capture nothing are marked static, in IObservableExtensions,
    ValidatingViewModelBase and AttributeCache. The rest were checked and genuinely
    capture.
  * Dropped an unused System.Runtime.Serialization using.

Not changed: the SelectConcurrent and SelectSequential family allocates a closure and
a delegate per element, because Observable.Start takes a parameterless Action and there
is nowhere to hang the state. Fixing it means a custom operator, and those methods have
no test coverage yet, so it is left for a deliberate pass rather than done blind.

Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>
…work

The concurrency limit and the sequential guarantee have never worked. Both
compose as .Select(x => Observable.Start(...)) followed by .Merge(n) or .Concat(),
but Observable.Start is hot: it launches the work at the moment Select evaluates
it, as elements stream past. By the time Merge or Concat subscribes, everything is
already running, so those operators were only ordering the results.

Measured by the new tests against the old code:

  SelectSequential over 8 elements            ran 8 callbacks at once, not 1
  SelectConcurrent(concurrentSubscriptions: 3)
    over 24 elements                          ran 13 at once, not 3

Wrapping each Observable.Start in Observable.Defer moves the work to subscription
time, which is what gives Concat its one-at-a-time behaviour and Merge its cap.
Fourteen call sites across the eight overloads.

The SelectManyConcurrent and SelectManySequential family was already correct, and
that is the clue that identified the bug: those compose with Observable.FromAsync,
which is deferred. Their equivalent tests passed against the old code while every
Observable.Start test failed.

This also makes the scheduler argument meaningful. .SubscribeOn(scheduler) had
nothing to move, because subscription was not what started the work.

Callers relying on the old behaviour were getting unbounded parallelism. Anyone who
picked SelectSequential specifically to serialise access to something now gets that,
where before they were silently racing.

Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>
…n down

ViewModelBase carried the full dispose pattern -- a public Dispose(), a virtual
Dispose(bool), an IsDisposed flag -- but never declared IDisposable. A
#pragma warning disable CA1001 at the top of the file suppressed the analyser
warning that says exactly this.

The consequence is in IViewForExtensions.DisposeViewModel:

    if (view.ViewModel is ViewModelBase vmb && !vmb.Maintain && vmb is IDisposable id)

`vmb is IDisposable` was always false, so no view model was ever disposed when its
view went away. Every binding subscription a view model registered stayed live and
kept reacting to events until the garbage collector eventually reclaimed it. The
WeakCompositeDisposable keys on the view model through a ConditionalWeakTable, so
this is not unbounded growth, but a torn-down screen kept responding to its sources
for as long as it survived.

Found by writing coverage for IViewForExtensions: the test asserting that
DisposeViewModel disposes the view model failed against the old code.

Also adds coverage for the untested core surface. 120 tests to 216, line coverage
51.6% to 77.7%:

  NotifyPropertyExtensions   event bridging, scheduler handling, and detaching on
                             unsubscribe, which is what leaks if it regresses
  IServiceCollectionExtensions  lifetimes, interface registration and the filtered
                             generic overload, resolved through a real container
  IStellarViewExtensions     the initialisation sequence, delayed binding, the
                             Maintain rules and teardown
  SelectionViewModel, AttributeCache, Schedulers, Observables, the disposal
  helpers, the string helpers and the exception types

Coverage is measured through a runsettings file that excludes ReactiveGenerator's
injected runtime types, which are generated into the Stellar assembly and are
neither written nor maintained here.

Two tests document behaviour rather than assert the intuitive reading: a disposed
ReactiveCommand still executes, and the ToggleSelected property still points at it
after Unregister.

Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>
Line coverage 51.6% to 90.2%, branch 47.2% to 81.5%, across 256 tests.

The gap that mattered most was the Select* concurrency family, which had no tests
at all and turned out to be broken. Those tests landed with the fix in the previous
commit. This one fills the rest:

  IObservableExtensions   the CancellationToken and explicit-scheduler overloads of
                          every Select* method, and the ThrottleFirst variant with
                          before and after callbacks
  ValidatingViewModelBase the default trigger path, where callers pass no trigger
                          and the base builds one from the view model's own
                          PropertyChanged. That is the path most consumers take and
                          none of it was covered
  ViewManager             each of the eight lifecycle streams asserted to see only
                          its own event, plus PropertyChanged and the disposed-state
                          guards

CI now fails when line coverage drops below 85%. The check parses the cobertura
report directly rather than using coverlet's Threshold setting: the XPlat data
collector accepts that setting and silently ignores it, so a run at 90% passed a
threshold of 99. Verified the replacement fails at 95 and passes at 85 before
committing it.

The floor is deliberately below the current figure so ordinary work is not blocked
by a percentage point of noise. It is a ratchet against regression, not a target.

Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>
…posable turned on

Making ViewModelBase implement IDisposable switched on two analysers for every
derived view model, and this repository configures both as errors in .editorconfig.
CI caught it on the sample apps; my local verification had rebuilt the core filter
and the MAUI packages but not the MAUI samples, which is where it surfaced.

CA1063 wanted the finalizers to hand off to Dispose(false) and do nothing else. The
finalizers exist so the samples can show a view model actually being collected, so
the logging moves into an override of Dispose(bool) on the not-disposing path and
the finalizer just delegates. That is the pattern the rule is asking for and it
keeps what the sample was demonstrating.

CA2213 reported every ReactiveCommand field in the hybrid sample as never disposed.
They are disposed: Bind hands each one to the WeakCompositeDisposable the framework
passes in. The analyser cannot trace disposal through that indirection, so the rule
is suppressed at file scope with the reasoning attached.

Worth knowing for consumers: a project with strict analysis enabled will see the
same two rules fire on its own view models after upgrading. CA2213 in particular is
a false positive for anything registered with the binding disposables.

Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>

Copilot AI left a comment

Copy link
Copy Markdown
Contributor

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Pull request overview

Retargets StellarUI to .NET 10 and completes the ReactiveUI 23 migration across core, MAUI, Blazor, and Avalonia, including bootstrap/initialization updates required by ReactiveUI/Splat breaking removals. It also introduces central package management and adds a CI build/test gate (including code coverage collection and a coverage floor check).

Changes:

  • Retarget projects to net10.0 / net10.0-* and upgrade dependencies (ReactiveUI 23, MAUI/tooling bumps, centralized versions via Directory.Packages.props).
  • Replace removed ReactiveUI initialization surfaces with RxAppBuilder/builder-based initialization and migrate scheduler usage to RxSchedulers.
  • Add/expand unit tests substantially and introduce CI workflows (core + MAUI packages + samples) with coverage reporting.

Reviewed changes

Copilot reviewed 93 out of 94 changed files in this pull request and generated 8 comments.

Show a summary per file
File Description
Stellar/ViewModel/ViewModelBase.cs Implements IDisposable explicitly and optimizes Maintain lookup caching.
Stellar/ViewModel/ValidatingViewModelBase.cs Updates scheduler usage and validation pipeline performance characteristics.
Stellar/ValidationResult.cs Changes validation result collection type to IReadOnlyList.
Stellar/Stellar.csproj Retargets to net10 and moves package versions to central management.
Stellar/IDataCache.cs Clarifies overloads and makes key-selector overload non-optional; adds XML docs.
Stellar/Extensions/IObservableExtensions.cs Null-filter perf tweaks; defers Observable.Start work creation.
Stellar/Extensions/AttributeCache.cs Avoids captures with static lambdas in cache factory.
Stellar.UnitTests/ViewModelBaseTests.cs Adds disposal regression guards for ViewModels.
Stellar.UnitTests/ViewModel/ValidatingViewModelBaseTests.cs New end-to-end validation pipeline tests.
Stellar.UnitTests/ViewModel/SelectionViewModelTests.cs New tests documenting SelectionViewModel lifecycle/binding behavior.
Stellar.UnitTests/ViewModel/MonitorValidationInformationTests.cs New tests for per-property validation projection.
Stellar.UnitTests/TrackingDisposable.cs New helper disposable for disposal-count assertions.
Stellar.UnitTests/Stellar.UnitTests.csproj Retargets to net10; centralizes packages; adds DiskDataCache reference.
Stellar.UnitTests/GcHelpers.cs New GC helpers for weak-reference behavior tests.
Stellar.UnitTests/Extensions/ThrottleFirstTests.cs New tests for leading-edge throttling operator.
Stellar.UnitTests/Extensions/StellarViewExtensionsTests.cs New tests covering Stellar view lifecycle extension methods.
Stellar.UnitTests/Extensions/ServiceCollectionExtensionsTests.cs New tests for attribute-based DI registration.
Stellar.UnitTests/Extensions/SelectConcurrencyTests.cs New tests for Select* concurrency helpers.
Stellar.UnitTests/Extensions/ObserveLatestOnTests.cs New tests for conflating ObserveLatestOn behavior.
Stellar.UnitTests/Extensions/ObservablePredicateExtensionsTests.cs New tests for predicate/filter extension semantics.
Stellar.UnitTests/Extensions/ObservableOverloadTests.cs New tests for scheduler/token overloads and throttling callbacks.
Stellar.UnitTests/Extensions/NotifyPropertyExtensionsTests.cs New tests for INotify* observable wrappers.
Stellar.UnitTests/Disposables/WeakSingleAssignmentDisposableTests.cs New tests for weak single-assignment disposable behavior.
Stellar.UnitTests/Disposables/WeakSerialDisposableTests.cs New tests for weak serial disposable behavior.
Stellar.UnitTests/Disposables/WeakCompositeDisposableTests.cs New tests for weak composite disposable behavior.
Stellar.UnitTests/DiskCacheTests.cs New tests for DiskCache and IDataCache overload binding.
Stellar.UnitTests/coverlet.runsettings New coverage configuration (include/exclude rules).
Stellar.UnitTests/CoreUtilityTests.cs New tests for core helpers, schedulers, attribute cache, exceptions.
Stellar.SourceGenerators/Stellar.SourceGenerators.csproj Removes unused Workspaces reference; centralizes package versioning.
Stellar.SourceGenerators/ServiceRegistrationGenerator.cs Migrates generator to IIncrementalGenerator; deterministic output ordering.
Stellar.slnf Adds solution filter for Linux/core CI subset.
Stellar.MauiSample/ViewModels/SampleViewModel.cs Finalizer/dispose pattern change to satisfy analyzer expectations.
Stellar.MauiSample/UserInterface/Pages/SimpleSamplePage.cs Suppresses ListView obsoletion warnings for sample.
Stellar.MauiSample/UserInterface/Pages/SamplePopupPage.cs Suppresses ListView obsoletion warnings for sample.
Stellar.MauiSample/UserInterface/Pages/SamplePage.cs Suppresses ListView obsoletion warnings for sample.
Stellar.MauiSample/UserInterface/Pages/SampleModalPage.cs Suppresses ListView obsoletion warnings for sample.
Stellar.MauiSample/Stellar.MauiSample.csproj Retargets to net10; bumps Android min SDK; centralizes packages.
Stellar.MauiBlazorHybridSample/ViewModels/SampleViewModel.cs NRT fixes, CA suppression rationale, finalizer/dispose compliance.
Stellar.MauiBlazorHybridSample/ViewModels/CounterViewModel.cs Scheduler migration (but subscription lifecycle needs disposal).
Stellar.MauiBlazorHybridSample/Stellar.MauiBlazorHybridSample.csproj Retargets to net10; centralizes packages.
Stellar.MauiBlazorHybridSample/Components/Pages/Counter.razor.cs Fixes Blazor parameter pattern (BL0007) and disposes interval subscription.
Stellar.MauiBlazorHybridSample/Components/Pages/Counter.razor NRT-safety on ViewModel deref in markup.
Stellar.MauiBlazorHybridSample/App.xaml.cs Migrates from MainPage to overriding CreateWindow.
Stellar.Maui/Views/ViewCellBase.cs Suppresses ListView obsoletion warnings for maintained surface.
Stellar.Maui/Views/ActivatableListView.cs NRT cleanup for activation callback field; suppresses ListView obsoletion warnings.
Stellar.Maui/Stellar.Maui.csproj Retargets to net10; bumps Android min SDK; centralizes packages.
Stellar.Maui/GlobalUsings.cs Imports System.Reactive.Disposables.Fluent for moved DisposeWith overloads.
Stellar.Maui/Extensions/NavigationObservableExtensions.cs Scheduler migration + safer navigation-root handling via RequiredNavigationRoot.
Stellar.Maui/Extensions/MauiAppBuilderExtensions.cs Switches MAUI ReactiveUI init to builder-based flow; assigns RxSchedulers.
Stellar.Maui/Extensions/ListViewExtensions.cs Suppresses ListView obsoletion warnings for extension surface.
Stellar.Maui.PopUp/Stellar.Maui.PopUp.csproj Retargets to net10; centralizes packages.
Stellar.Maui.PopUp/ReactivePopupPage.cs NRT-friendly IViewFor implementation and ViewModel assignment.
Stellar.Maui.PopUp/PopupPageBase.cs NRT update for OnPropertyChanged parameter.
Stellar.Maui.PopUp/Extensions/PopupNavigationObservableExtensions.cs Scheduler migration and signature adjustments for nullable parameter creation.
Stellar.FluentValidation/Stellar.FluentValidation.csproj Retargets to net10; centralizes FluentValidation versioning.
Stellar.FluentValidation/FluentValidatorFor.cs Reduces allocations in validation conversion path.
Stellar.DiskDataCache/Stellar.DiskDataCache.csproj Retargets to net10; centralizes RateLimiting package.
Stellar.DiskDataCache/DiskCache.cs Makes key-selector overload non-optional and fixes RemoveAsync semantics.
Stellar.BlazorSample/Stellar.BlazorSample.csproj Retargets to net10; centralizes packages.
Stellar.BlazorSample/Pages/Index.razor NRT-safe ViewModel deref.
Stellar.BlazorSample/Pages/Counter.razor.cs Fixes BL0007 parameter pattern and pushes route values in OnParametersSet.
Stellar.BlazorSample/Pages/Counter.razor NRT-safe ViewModel deref in markup.
Stellar.Blazor/Stellar.Blazor.csproj Retargets to net10; centralizes packages.
Stellar.Blazor/Extensions/BuilderExtensions.cs Updates ReactiveUI init to RxAppBuilder builder-based flow.
Stellar.Blazor/ComponentBase.cs NRT-safe injected NavigationManager initialization.
Stellar.AvaloniaSample/Views/StellarMainWindow.cs NRT fixes + scheduler migration for UI binding.
Stellar.AvaloniaSample/ViewLocator.cs NRT-correct Avalonia IDataTemplate signatures.
Stellar.AvaloniaSample/Stellar.AvaloniaSample.csproj Retargets to net10; centralizes Avalonia deps.
Stellar.Avalonia/Stellar.Avalonia.csproj Retargets to net10; centralizes deps.
Stellar.Avalonia/Extensions/AppBuilderExtensions.cs Replaces Avalonia ReactiveUI bootstrap with manual registrations via RxAppBuilder.
Stellar.Avalonia/AvaloniaViewManager.cs NRT fixes on view reference handling.
README.md Updates DiskCache registration and usage examples to new IDataCache API.
global.json Pins SDK to 10.0.100.
Directory.Packages.props Enables central package management and pins all package versions.
Directory.build.props Removes per-project analyzer version pins (now centrally managed).
.gitignore Adds .vscode/ and .junie/.
.github/workflows/nuget.yml Updates CI tooling versions and runner/os/tooling to .NET 10 era.
.github/workflows/dependency-update.md Adds Copilot-driven dependency update workflow instructions/config.
.github/workflows/ci.yml Adds CI gate: core build/test/coverage + MAUI packages + sample builds.
.github/prompts/new-viewmodel.prompt.md Adds prompt template for new ViewModels (needs RxSchedulers updates).
.github/prompts/new-validating-viewmodel.prompt.md Adds prompt template for validating VMs (needs RxSchedulers updates).
.github/prompts/new-service.prompt.md Adds prompt template for services (needs RxSchedulers updates).
.github/prompts/new-maui-viewcell.prompt.md Adds prompt template for MAUI ViewCells.
.github/prompts/new-maui-view.prompt.md Adds prompt template for MAUI ContentViews.
.github/prompts/new-maui-page.prompt.md Adds prompt template for MAUI Pages + ViewModels.
.github/prompts/new-blazor-component.prompt.md Adds prompt template for Blazor components (needs RxSchedulers updates).
.github/prompts/new-avalonia-view.prompt.md Adds prompt template for Avalonia views.
.github/prompts/navigation-setup.prompt.md Adds navigation setup prompt (needs RxSchedulers wording update).
.github/aw/actions-lock.json Adds locked action SHAs metadata.
.gitattributes Marks workflow lock files as generated + merge strategy.
Comments suppressed due to low confidence (1)

.github/prompts/new-viewmodel.prompt.md:119

  • ReactiveUI 23 removes RxApp; these prompt snippets should use RxSchedulers.* to match the current codebase APIs and avoid generating non-compiling examples.
            .ObserveOn(RxApp.MainThreadScheduler)

💡 Add Copilot custom instructions for smarter, more guided reviews. Learn how to get started.

Comment on lines 13 to 16
Observable
.Interval(TimeSpan.FromSeconds(2), RxApp.TaskpoolScheduler)
.Interval(TimeSpan.FromSeconds(2), RxSchedulers.TaskpoolScheduler)
.Do(i => Count *= i)
.Subscribe();
Comment on lines 1 to 6
using System.Reflection;
using Microsoft.Extensions.DependencyInjection;
using ReactiveUI.Builder;
using Splat;

namespace Stellar.Blazor;
Comment on lines 5 to 12
public bool IsValid { get; }

public IReadOnlyCollection<ValidationInformation> ValidationInformation { get; }
public IReadOnlyList<ValidationInformation> ValidationInformation { get; }

public ValidationResult(IReadOnlyCollection<ValidationInformation> validationInformation, bool isValid)
public ValidationResult(IReadOnlyList<ValidationInformation> validationInformation, bool isValid)
{
ValidationInformation = validationInformation;
IsValid = isValid;
// React to property changes
this.WhenAnyValue(static x => x.Title)
.Where(static x => !string.IsNullOrEmpty(x))
.ObserveOn(RxApp.MainThreadScheduler)
{
this.WhenAnyValue(static x => x.SomeProperty)
.SelectMany(x => _service.GetAsync(x).ToObservable())
.ObserveOn(RxApp.MainThreadScheduler)
- All navigation is **extension methods on `IObservable<T>`** — not service method calls
- Navigation is **automatically throttled** (~204ms) to prevent double-tap
- Target pages are resolved from **DI** — they must be registered with `[ServiceRegistration]`
- Navigation runs on `RxApp.MainThreadScheduler`; page creation on background thread
- Constructor must call `base(validator)`
- `RegisterValidation()` must be called inside `Bind()` and `.DisposeWith(disposables)`
- `IsValid` and `ValidationErrors` are provided by the base class — do not redeclare them
- Validation runs on `RxApp.TaskpoolScheduler` with `ThrottleFirst` debouncing
static vm => vm.IsLoading,
static vm => vm.Title,
static vm => vm.Items)
.ObserveOn(RxApp.MainThreadScheduler)
Sign up for free to join this conversation on GitHub. Already have an account? Sign in to comment

Labels

None yet

Development

Successfully merging this pull request may close these issues.

2 participants