Skip to content

feat!: leave [ObservableAsProperty] and view registration to ReactiveUI.Binding, and generate through an indentation-aware writer - #501

Merged
glennawatson merged 30 commits into
mainfrom
feature/binding-dedupe-perf
Sep 25, 2026
Merged

glennawatson merged 30 commits into
mainfrom
feature/binding-dedupe-perf

Conversation

@glennawatson

@glennawatson glennawatson commented Sep 25, 2026 •

Copy link
Copy Markdown
Contributor

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, ViewModelRegistrationType and SplatRegistrationType are gone. ReactiveUI.Binding's view locator registers views (ViewLocatorDispatchGenerator). [IViewFor] still generates the ViewModel members.
  • [IViewFor] implements the IViewFor<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 bare IViewFor<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's ViewLocator.GetCurrent() instead of ReactiveUI.ViewLocator.Current, which the new ReactiveUI no longer has.
  • RXUISG0012 and RXUISG0013 keep their IDs. They report an invalid forwarded attribute for every generator, with a message no longer tied to [ObservableAsProperty].
  • Every generator writes through 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 with ForAttributeWithMetadataName. Before, they bound every attribute of every attributed class.
  • Extraction does less work. Type information is built once per type, names are compared without building them, and XML documentation is only requested when a declaration has a doc comment.
  • Each type's file is written from its own output. An edit that changes one type's members rewrites only that type's file.
  • A generated [ReactiveCollection] property no longer leaks a handler every time it is set. Setting it to null while it is empty no longer throws.
  • A small in-repo snapshot helper replaces Verify.TUnit and Verify.SourceGenerators. Snapshot file names are abbreviated so they fit within the Windows path limit.
  • Hand-written polyfills replace the checked-in PolySharp output.
  • Packages are updated to their latest releases. The Roslyn API pins that select each analyzer band stay where they are.
  • Every file is UTF-8 without a byte order mark, with LF line endings, on every platform.
  • The Avalonia and MAUI test apps build again.

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 partial property marked [ObservableAsProperty] with no options, and it registers views that list IViewFor<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:

  • the PropertyName, ReadOnly, UseProtected, Inheritance and InitialValue options;
  • the field, method and observable-property forms, with InitializeOAPH();
  • attribute forwarding onto the generated property;
  • registering views declared only with [IViewFor], and views of ReactiveUI.Reactive projects;
  • the Splat registration options.

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 GCAllocationTick events on .NET 10:

  • Cold generation of the whole corpus: 17.3 MB to 11.6 MB a run (−32.9%).
  • Cold generation per generator:
    • [IReactiveObject] −45.9%
    • [Reactive] −39.5%
    • [ReactiveCommand] −38.1%
    • [ReactiveCollection] −35.8%
    • [IViewFor] −30.4%
    • [BindableDerivedList] −16.6%
  • Reruns in the IDE:
    • After an unrelated edit, −12.8% a run.
    • After renaming a [Reactive] field, −27.3% a run.

Breaking changes

  • [ObservableAsProperty] is gone. Reference ReactiveUI.Binding, mark a partial property [ObservableAsProperty], and assign its generated _nameHelper field with ToProperty. Without ReactiveUI.Binding, write the ObservableAsPropertyHelper<T> field and property by hand with ReactiveUI's ToProperty. The README has both forms.
  • RegisterViewsForViewModelsSourceGenerated(), RegistrationType, ViewModelRegistrationType and SplatRegistrationType are gone. With ReactiveUI.Binding, list IViewFor<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()).
  • The ReactiveUI.Binding replacements need a ReactiveUI release built on ReactiveUI.Binding. On ReactiveUI 24.3 and earlier, ReactiveUI's own ObservableAsPropertyHelper<T> and IViewFor<T> are in use, so use the hand-written helper and Splat registration there. The README says so.
  • RXUISG0014, RXUISG0017 and RXUISPR0002 are removed. RXUISG0012 and RXUISG0013 change category.
  • A generated [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

  • Generated code: every snapshot that changed was compared with its previous version with whitespace removed. Only [ReactiveCollection]'s, whose code changed on purpose, differ beyond whitespace.
  • New tests cover:
    • incremental caching per generator;
    • the generated collection property, compiled and run;
    • the forwarded-attribute diagnostics through [Reactive];
    • which IViewFor and view locator each set of references selects, [IViewFor] views compiling against both ReactiveUI.Binding flavours, and the WinForms hosts' view locator calls.
  • Allocations were measured on .NET 10 from EventPipe GCAllocationTick events. 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.
  • Sample apps: the four GUI test apps compile. None of the examples is headless, so none was run on Windows.

Notes for the reviewer

Where to start, and what deserves the most attention:

  • The core of the change is Core/CodeGeneration, one converted generator (Reactive/ReactiveGenerator.Execute.cs), and Core/Extensions/IncrementalPipelineExtensions.cs for the per-type outputs.
  • Needs the closest review: the ForAttributeWithMetadataName registration for the generic IViewForAttribute`1, the [ReactiveCollection] setter, and how GetViewApi in Core/Extensions/ContextExtensions.cs chooses between ReactiveUI's and ReactiveUI.Binding's IViewFor.
  • Safe to skim: the per-generator writer conversions (whitespace-only snapshot changes) and the byte order mark removal (327 files, bytes only).

Measured on .NET 10, EventPipe GCAllocationTick, bytes per operation. src/benchmarks/README.md has the method and every scenario.

Scenario Before After
Cold generation, whole corpus 17,330,975 11,636,366 (−32.9%)
Cold, [Reactive] 6,117,449 3,701,964 (−39.5%)
Cold, [IReactiveObject] 2,851,598 1,542,311 (−45.9%)
Rerun after an unrelated edit 8,984,317 7,838,332 (−12.8%)
Rerun after renaming a [Reactive] field 10,990,885 7,986,248 (−27.3%)
Generated collection: add and clear, after 20,000 replacements 640,392 161

For finding [Reactive] targets, ForAttributeWithMetadataName allocated the least of the three strategies benchmarked, both cold and on a rerun, so every generator uses it.

Also worth knowing:

  • The WinForms hosts' allocation did not change. Half of it is the text of the generated file.
  • Global-namespace types: for a type in the global namespace, the converted generators now write no namespace line. Before, they wrote an empty one, which does not compile. The two WinForms host generators keep the old behaviour.

Checklist

  • I have read the Contribute guide
  • The PR title follows Conventional Commits
  • Tests cover this change, or the summary says why they do not
  • New or changed public API has XML documentation

…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.
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

codecov Bot commented Sep 25, 2026 •

Copy link
Copy Markdown

Codecov Report

✅ All modified and coverable lines are covered by tests.
✅ Project coverage is 97.11%. Comparing base (e9795fb) to head (2b3fbec).
⚠️ Report is 2 commits behind head on main.

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.
📢 Have feedback on the report? Share it here.

🚀 New features to boost your workflow:
  • ❄️ Test Analytics: Detect flaky tests, report on failures, and find test suite problems.

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.
…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.
@github-actions

github-actions Bot commented Oct 9, 2026

Copy link
Copy Markdown

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.

@github-actions github-actions Bot locked as resolved and limited conversation to collaborators Oct 9, 2026
Sign up for free to subscribe to this conversation on GitHub. Already have an account? Sign in.

Labels

None yet

Projects

None yet

Development

Successfully merging this pull request may close these issues.

2 participants