Move to .NET 10 and ReactiveUI 23#34
Draft
michaelstonis wants to merge 25 commits into
Draft
Conversation
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>
Contributor
There was a problem hiding this comment.
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 viaDirectory.Packages.props). - Replace removed ReactiveUI initialization surfaces with
RxAppBuilder/builder-based initialization and migrate scheduler usage toRxSchedulers. - 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) |
This file contains hidden or bidirectional Unicode text that may be interpreted or compiled differently than what appears below. To review, open the file in an editor that reveals hidden Unicode characters.
Learn more about bidirectional Unicode characters
Sign up for free
to join this conversation on GitHub.
Already have an account?
Sign in to comment
Add this suggestion to a batch that can be applied as a single commit.This suggestion is invalid because no changes were made to the code.Suggestions cannot be applied while the pull request is closed.Suggestions cannot be applied while viewing a subset of changes.Only one suggestion per line can be applied in a batch.Add this suggestion to a batch that can be applied as a single commit.Applying suggestions on deleted lines is not supported.You must change the existing code in this line in order to create a valid suggestion.Outdated suggestions cannot be applied.This suggestion has been applied or marked resolved.Suggestions cannot be applied from pending reviews.Suggestions cannot be applied on multi-line comments.Suggestions cannot be applied while the pull request is queued to merge.Suggestion cannot be applied right now. Please check back later.
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
c184dc2Stellar.slnffilter for the Linux job2af3985ebca6e4ce587357459a84Why there was no choice about ReactiveUI
ReactiveUI 21 publishes no
net10.0target frameworks, so .NET 10 forces 21 → 23.2.28. That release removesRxApp,PlatformRegistrationManagerandRegistrationNamespaceoutright; Splat 19 also removesIEnableLogger. Initialization moves toRxAppBuilder.Two other bumps are likewise forced rather than chosen:
CommunityToolkit.Maui12 → 14.2.2 — 12.x pinsMicrosoft.Maui.Essentials < 10.0.0minSdk21 → 24 —androidx.lifecycle.runtimenow requires at least 23Avalonia stays at 11.3.9. Avalonia itself has shipped 12.1.0, but
Avalonia.ReactiveUIhas no 12.x release, not even a prerelease.Known degradation on Avalonia
Avalonia.ReactiveUI 11.3.9is 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'sIActivationForViewFetcher),AvaloniaScheduler,AutoDataTemplateBindingHook.TypeLoadException—Registrations,AvaloniaMixins.UseReactiveUI,AutoSuspendHelper,RoutedViewHost,ViewModelViewHost,AvaloniaObjectObservableForProperty.Stellar therefore performs the registrations itself instead of calling Avalonia's bootstrap. Consumers should know that
RoutedViewHost,ViewModelViewHostandAutoSuspendHelperare unusable in this combination, and that property observation on Avalonia controls falls back toINotifyPropertyChanged.Verification
All 13 projects build; the test suite passes on
.NETCoreApp,Version=v10.0. Verified locally fornet10.0andnet10.0-android.iOS and MacCatalyst were not verified locally — the .NET 10 workload band
26.5.10301requires Xcode 26.6 and the machine has 26.5. This PR'smauijob 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