Repository navigation
feat!: leave [ObservableAsProperty] and view registration to ReactiveUI.Binding, and generate through an indentation-aware writer - #501
Merged
Conversation
…now provided by ReactiveUI.Binding ReactiveUI.Binding ships [ObservableAsProperty] with ToProperty, and a view locator that registers every IViewFor<T> at compile time. The copies here are removed: - the [ObservableAsProperty] generator for fields, methods, observable properties and partial properties, with InitializeOAPH - RXUISG0014, RXUISG0017 and the RXUISPR0002 suppressor; RXUISPR0003 now covers [ReactiveCommand] only - RegisterViewsForViewModelsSourceGenerated, the RegistrationType and ViewModelRegistrationType options, and SplatRegistrationType RXUISG0012 and RXUISG0013 were reported for an invalid forwarded attribute by every generator; they keep their IDs with a generic message. The examples write ObservableAsPropertyHelper by hand and register views with Splat, with notes pointing at ReactiveUI.Binding.
A small in-repo helper compares each generated file with its stored .verified.cs snapshot, writes .received.cs on a mismatch, reports snapshots no longer produced, and accepts new output when ACCEPT_SNAPSHOTS is set. The stored snapshots are unchanged. This removes the Verify.TUnit and Verify.SourceGenerators packages.
…path limit Snapshot names use the test class's capitals and shortened common words in the method and hint segments, so the longest path under the test project drops from 145 to 94 characters. The snapshot contents are unchanged.
Avalonia 12.1.3, ReactiveUI 24.3.0, TUnit 1.69.16, the StyleSharp, PerformanceSharp and SecuritySharp analyzers 5.1.2, and Microsoft.CodeAnalysis.Analyzers 5.9.0. The Roslyn API pins that pick each analyzer band stay where they are. PolySharp is dropped; nothing references it. SST1534 is off, as in the other ReactiveUI repositories: it contradicts SST1525's braces-on policy for switch sections. The sample apps opt out of SST1449 because they report to the console.
The generators and the code fixes compile the same hand-written polyfills from ReactiveUI.SourceGenerators.Roslyn/Polyfills, in the form the ReactiveUI.Binding generators use. Only the polyfills the code needs are kept: IsExternalInit, Index, Range, NotNullWhenAttribute and UnscopedRefAttribute.
…iter SourceWriter and PooledBuilder come from the ReactiveUI.Binding generators: the writer indents each line from its level, so nested types, properties and accessors land at the right depth without spelling out whitespace, and it rents its StringBuilder from a per-thread free list instead of building every fragment as its own string. SourceWriterExtensions names the constructs the generators share: file header, namespace, containing types, partial type, doc comments. The generated properties are token-for-token the same; only whitespace changes, most visibly in nested types, which are now indented by depth.
- IsTargetTypeValid stops at the first check that holds; the attribute check, which makes the compiler bind every base type's attributes, runs last instead of always. - HasFullyQualifiedMetadataName compares segment by segment instead of building the name for every base type and interface it walks. - TargetInfo.From uses a plain type's name directly and only rewrites the hint name when it has angle brackets. - Generated property and field names are cased in one pooled buffer. - [Reactive] reads AlsoNotify names in place, and its syntax fallback skips named arguments rather than evaluating them. - Each injected attribute's source text is built once per process instead of on every access.
…ion-aware writer Emission now appends straight into a rented SourceWriter, opening the containing types once per type group instead of building raw strings and joined declarations. Generated output is token-identical; snapshots change in whitespace only.
…on-aware writer Emission now appends straight into a rented SourceWriter, opening the containing types once per type group instead of building raw strings and joined declarations. Generated output is token-identical; snapshots change in whitespace only.
…are writer Commands are emitted straight into SourceWriter instead of nested raw strings and StringBuilders; command and field names are written from the method name without intermediate strings, and XML docs are stored unindented. Generated output is token-identical; only whitespace changes.
…re writer The routed control host is emitted through SourceWriter instead of one interpolated raw string: fixed stretches are written with Lines, and the type name, base type, namespace and exception handler are appended between them, so no intermediate strings are built. The GeneratedCode attribute is built once per generator, and the renderer delegate becomes GenerateSource split into Write* methods. Generated output is token-identical; only whitespace changed (the trailing spaces after the final pragma are gone and the file ends with a newline).
…aware writer The view-model control host is emitted through SourceWriter instead of one interpolated raw string: fixed stretches are written with Lines, and the type name, view-model base type, namespace and exception handler are appended between them, so no intermediate strings are built. The GeneratedCode attribute is built once per generator, and the renderer delegate becomes GenerateSource split into Write* methods. Generated output is token-identical; only whitespace changed (the file now ends with a newline).
…e writer Each UI platform's view file (WPF/WinUI/Uno, WinForms, Avalonia, MAUI) is now written through SourceWriter instead of raw-string templates and StringBuilder fragments, so containing types nest at their depth and no intermediate string is built per member. Output is token-for-token identical; only whitespace changes.
…on-aware writer The file header, namespace and partial type are written through SourceWriter and the fixed members through Lines, replacing the raw-string template and the per-character re-indenting copy. Output is token-for-token identical; only whitespace changes.
Every generator now opens containing types through the writer, so the declaration and closing-brace strings TargetInfo built are unused.
…I.Binding The README drops the removed [ObservableAsProperty] forms, InitializeOAPH and the IViewFor Splat registration options, and adds a 'Moved to ReactiveUI.Binding' section with the replacement for each, including hand-written equivalents for projects without ReactiveUI.Binding. CLAUDE.md now describes writing through SourceWriter, the snapshot helper that replaced Verify, the benchmarks, and the current Roslyn bands.
…MetadataName, and bind less in extraction - [IViewFor] and [IReactiveObject] used a syntax provider that bound every attribute of every attributed class to find their own; they now use ForAttributeWithMetadataName, one registration per IViewFor attribute form (IViewForAttribute and IViewForAttribute`1), and their predicates reject non-partial classes from syntax. - Transforms read the attribute ForAttributeWithMetadataName matched instead of searching the symbol's attributes again. - TargetInfo is built once per type symbol rather than once per member. - Task, observable and scheduler checks compare symbols structurally instead of building display strings for every base type. - XML documentation is only requested when the declaration has a documentation comment. - The fully qualified, nullable-annotated display format is built once.
…tay cached Each generator collected every model and wrote every file from one callback, so a change to any model rewrote every file. Models are now grouped per type into value-equatable arrays and each type is written by its own source output; the driver compares a type's models with the last run's and skips it when they are equal. Diagnostics are reported by an output of their own. Pipeline steps carry tracking names, and tests assert that an unrelated edit reuses every type and that an edit to one type reruns only that type.
… on every set The generated setter built a new CollectionChanged handler for every subscribe and unsubscribe, so the unsubscribe never matched: each set left one more handler on the new collection, never removed the handler from the collection being replaced, allocated three delegates, and threw a NullReferenceException when set to null while empty. The setter now creates one handler per property, kept in a field, unsubscribes it from the old collection and subscribes it to the new one. Each generated property carries the GeneratedCode attribute, rather than only the first. Tests compile and run the generated property to prove a replaced collection no longer notifies and that setting null does not throw.
…l reruns and generated code The generation benchmarks gain an EventPipe harness: each scenario runs a fixed number of times inside a GC-verbose EventPipe session on its own process, and GCAllocationTick amounts divided by the run count give the allocation per run, with the trace kept for breakdowns. Scenarios cover a cold run per corpus, warm reruns after an unrelated edit and after edits to a [Reactive] class, and three attribute-discovery strategies against each other. Every corpus includes attributed code no generator acts on. A runtime benchmark project measures the code the generators write. The README records the method and the before/after figures.
… fast paths in CLAUDE.md
Both referenced the packaging project as an analyzer, which ships no generator assembly, so neither found the attributes; they now reference the Roslyn 5.0 generator as the WPF and WinForms apps do. The MAUI app targets the MAUI heads Directory.Build.props lists instead of the out-of-support net8.0 ones, overrides CreateWindow instead of setting the obsolete MainPage, moves its counter into the view model with [Reactive] and [ReactiveCommand], and targets Android 23, which its AndroidX dependencies need. The apps' code now meets the repository's analyzer rules.
…ine endings .editorconfig sets charset = utf-8 and end_of_line = lf for every file, and .gitattributes checks text out with LF on every platform. The byte order marks are removed from the snapshots, project files, XAML and manifests. The generators hand their attribute sources to the compiler as UTF-8 without a byte order mark, and the snapshot helper writes without one.
Codecov Report✅ All modified and coverable lines are covered by tests. Additional details and impacted files@@ Coverage Diff @@
## main #501 +/- ##
==========================================
+ Coverage 95.30% 97.11% +1.80%
==========================================
Files 78 72 -6
Lines 5051 4121 -930
Branches 628 537 -91
==========================================
- Hits 4814 4002 -812
- Misses 115 119 +4
+ Partials 122 0 -122 ☔ View full report in Codecov by Harness. 🚀 New features to boost your workflow:
|
glennawatson
added a commit
to reactiveui/website
that referenced
this pull request
Sep 25, 2026
….Binding ReactiveUI.SourceGenerators no longer generates [ObservableAsProperty] helpers or IViewFor Splat registrations (reactiveui/ReactiveUI.SourceGenerators#501). - Use ReactiveUI.Binding's [ObservableAsProperty] on a partial get-only property, assigned with ToProperty, in the installation, guidelines, boilerplate-code and Visual Studio pages. Note the C# 13 requirement and link the hand-written ObservableAsPropertyHelper form. - Add a ReactiveUI.SourceGenerators section to the ReactiveUI.Binding migration guide: removed APIs and diagnostics, before/after for OAPH and view registration. - Update the Fody to SourceGenerators guide to the new attribute. - Fix [IViewFor(typeof(VM))] to [IViewFor<TViewModel>], and note that [IViewFor] does not register views. - Add a dated update note to the 2025 source generators article.
State that the ReactiveUI.Binding replacements need a ReactiveUI release built on it, keep shipped analyzer release notes unchanged, describe the benchmark baseline by content, and make snapshot names that collide fail the test.
Describe which diagnostics generators report, the .slnx solution, running tests from src, accepting snapshots in PowerShell, PerfView for traces, and the C# 12 output rule.
ChrisPulman
approved these changes
Sep 25, 2026
…ct references [IViewFor] implements, and the WinForms hosts resolve views through, ReactiveUI's own IViewFor and ViewLocator on releases not built on ReactiveUI.Binding, and otherwise ReactiveUI.Binding's or ReactiveUI.Binding.Reactive's, named in full so no using is needed.
|
This pull request has been automatically locked since there has not been any recent activity after it was closed. Please open a new issue for related bugs. |
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 subscribe to this conversation on GitHub.
Already have an account?
Sign in.
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.
Summary
Removes the two features ReactiveUI.Binding now provides. The generators now write their output through an indentation-aware writer and allocate about a third less; EventPipe traces measure this. The tests no longer need Verify.
[ObservableAsProperty]is removed in all its forms (field, method, observable property, partial property,InitializeOAPH), with RXUISG0014, RXUISG0017 and the RXUISPR0002 suppression. ReactiveUI.Binding's[ObservableAsProperty]replaces it (README).[IViewFor]no longer generates Splat registration:RegisterViewsForViewModelsSourceGenerated,RegistrationType,ViewModelRegistrationTypeandSplatRegistrationTypeare gone. ReactiveUI.Binding's view locator registers views (ViewLocatorDispatchGenerator).[IViewFor]still generates theViewModelmembers.[IViewFor]implements theIViewFor<T>of the ReactiveUI the project references, named in full: ReactiveUI's own on releases not built on ReactiveUI.Binding, otherwise ReactiveUI.Binding's or ReactiveUI.Binding.Reactive's. Before, it wrote a bareIViewFor<T>, which on a ReactiveUI built on ReactiveUI.Binding only bound through the implicit global using. The WinForms[RoutedControlHost]and[ViewModelControlHost]likewise use that ReactiveUI.Binding flavour'sViewLocator.GetCurrent()instead ofReactiveUI.ViewLocator.Current, which the new ReactiveUI no longer has.[ObservableAsProperty].SourceWriter, the indentation-aware writer from the ReactiveUI.Binding generators (SourceWriter.cs). Generated code has the same tokens as before; nested types are now indented by depth.[IViewFor]and[IReactiveObject]find their targets withForAttributeWithMetadataName. Before, they bound every attribute of every attributed class.[ReactiveCollection]property no longer leaks a handler every time it is set. Setting it tonullwhile it is empty no longer throws.Why
ReactiveUI's binding engine, ReactiveUI.Binding, now ships
[ObservableAsProperty]and compile-time view registration, so this package should not keep its own copies. Keeping both would give a project two[ObservableAsProperty]attributes and two ways to register views.ReactiveUI.Binding does not yet cover everything these features did. Today it handles a
partialproperty marked[ObservableAsProperty]with no options, and it registers views that listIViewFor<T>in their own declaration. It generates only for partial properties because other generators cannot see members that exist only in generated code; that reasoning is in its[ObservableAsProperty]generator's remarks. The rest has been specified to the ReactiveUI.Binding generators so they can take it on:PropertyName,ReadOnly,UseProtected,InheritanceandInitialValueoptions;InitializeOAPH();[IViewFor], and views of ReactiveUI.Reactive projects;Until then, the README's hand-written forms cover what ReactiveUI.Binding does not.
The rest came from measuring generation. Per build, most of what the generators allocated went to building strings.
[IViewFor]bound every attribute on every attributed class, and a collected pipeline rewrote every file on any change.Fixing those cuts what generation allocates by 12% to 46%, measured from EventPipe
GCAllocationTickevents on .NET 10:[IReactiveObject]−45.9%[Reactive]−39.5%[ReactiveCommand]−38.1%[ReactiveCollection]−35.8%[IViewFor]−30.4%[BindableDerivedList]−16.6%[Reactive]field, −27.3% a run.Breaking changes
[ObservableAsProperty]is gone. Reference ReactiveUI.Binding, mark apartialproperty[ObservableAsProperty], and assign its generated_nameHelperfield withToProperty. Without ReactiveUI.Binding, write theObservableAsPropertyHelper<T>field and property by hand with ReactiveUI'sToProperty. The README has both forms.RegisterViewsForViewModelsSourceGenerated(),RegistrationType,ViewModelRegistrationTypeandSplatRegistrationTypeare gone. With ReactiveUI.Binding, listIViewFor<TViewModel>on the view's declaration as well as[IViewFor<TViewModel>]. Its view locator only sees interfaces written in source. Without ReactiveUI.Binding, register each view with Splat:AppLocator.CurrentMutable.Register<IViewFor<TViewModel>>(() => new TView()).ObservableAsPropertyHelper<T>andIViewFor<T>are in use, so use the hand-written helper and Splat registration there. The README says so.[ReactiveCollection]property now adds a private handler field. Its setter subscribes that handler to the new collection and unsubscribes it from the old one.How this was verified
[ReactiveCollection]'s, whose code changed on purpose, differ beyond whitespace.[Reactive];IViewForand view locator each set of references selects,[IViewFor]views compiling against both ReactiveUI.Binding flavours, and the WinForms hosts' view locator calls.GCAllocationTickevents. The "before" side is the generators with[ObservableAsProperty]and view registration removed, Verify replaced and packages updated, but before any performance change, measured with the same harness.Notes for the reviewer
Where to start, and what deserves the most attention:
Core/CodeGeneration, one converted generator (Reactive/ReactiveGenerator.Execute.cs), andCore/Extensions/IncrementalPipelineExtensions.csfor the per-type outputs.ForAttributeWithMetadataNameregistration for the genericIViewForAttribute`1, the[ReactiveCollection]setter, and howGetViewApiinCore/Extensions/ContextExtensions.cschooses between ReactiveUI's and ReactiveUI.Binding'sIViewFor.Measured on .NET 10, EventPipe
GCAllocationTick, bytes per operation.src/benchmarks/README.mdhas the method and every scenario.[Reactive][IReactiveObject][Reactive]fieldFor finding
[Reactive]targets,ForAttributeWithMetadataNameallocated the least of the three strategies benchmarked, both cold and on a rerun, so every generator uses it.Also worth knowing:
namespaceline. Before, they wrote an empty one, which does not compile. The two WinForms host generators keep the old behaviour.Checklist