Skip to content

feat(observation): add WhenAnyDynamic and cover the runtime overloads - #86

Merged
glennawatson merged 5 commits into
mainfrom
feat/when-any-dynamic
Sep 9, 2026
Merged

feat(observation): add WhenAnyDynamic and cover the runtime overloads#86
glennawatson merged 5 commits into
mainfrom
feat/when-any-dynamic

Conversation

@glennawatson

@glennawatson glennawatson commented Sep 9, 2026

Copy link
Copy Markdown
Contributor

What kind of change does this PR introduce?

Feature, plus a test fix.

What is the new behavior?

WhenAnyDynamic observes a property chain the caller built at run time, across arities 1 to 12.

  • Takes the chain as a System.Linq.Expressions.Expression rather than a lambda the compiler could read, so
    the chain can come from a property name, a configuration entry, or a caller. Two overloads per arity, with
    and without the distinct gate. Functionally equivalent to ReactiveUI's overloads of the same name.
  • Every overload carries [RequiresUnreferencedCode]. There is nothing for a generator to resolve in an
    expression built at run time, so the chain is walked by reflection - this is the one part of the observation
    surface that is not trimming- or AOT-safe, and a PublishAot build reports each call site.
  • Every arity observes each of its chains through one shared helper and differs only in how many it combines.

The runtime observation and binding overloads are covered.

  • ReactiveUIBindingExtensions no longer carries a class-level [ExcludeFromCodeCoverage].
  • Every arity of WhenChanged, WhenChanging, WhenAnyValue, WhenAny and WhenAnyObservable is exercised,
    along with each binding stub's refusal to bind without a generated overload.
  • The overloads are reached on the declaring class rather than as extension methods. Written as an extension
    call the generated dispatch wins overload resolution and the runtime overload never runs, so the shape is
    load-bearing and is noted in each test file.

What is the current behavior?

WhenAnyDynamic has no overload, so a chain only known at run time cannot be observed.

A class-level [ExcludeFromCodeCoverage] on one partial of ReactiveUIBindingExtensions applies to the merged
type, so it covers all fifteen partials rather than the four holding the refusing dispatch stubs it was written
for. 154 methods are excluded from the coverage report.

What might this PR break?

None. WhenAnyDynamic is new API, and the coverage attribute has no effect on emitted behaviour.

Checklist

  • I have read the Contribute guide
  • Tests have been added or updated (for bug fixes / features)
  • Docs have been added or updated (for bug fixes / features)
  • Changes target the main branch
  • PR title follows Conventional Commits

Additional information

WhenAnyDynamic is level with ReactiveUI's engine rather than faster, which is what a reflection walk on both
sides predicts. Single chain, 1,000 changes, .NET 10.0:

Single chain, .NET 10.0 Mean Allocated
Generated (WhenChanged) 175 us 63.4 KB
WhenAnyDynamic 278 us 102.7 KB
ReactiveUI WhenAnyDynamic 299 us 102.6 KB

Most of the gap to the generated path is the IObservedChange handed to the selector - 40 bytes per
notification, which the signature has to produce.

RxUiDynamicChainBaseline sits outside the ReactiveUI.Binding namespace and types its selectors against
ReactiveUI's IObservedChange, so a call that resolved to this library's overload of the same name would fail
to compile rather than quietly benchmark the wrong engine.

Most of the diff is generated test bodies, one per arity. The hand-written files are
ReactiveUIBindingExtensions.WhenAnyDynamic.WideArity.cs, BindingDispatchStubTests.cs, the two benchmark
classes, and the README.md sections.

- Observe a property chain given as an Expression the caller built, across
  arities 1 to 12, with and without the distinct gate.
- The chain is walked by reflection, so every overload carries
  [RequiresUnreferencedCode]. This is the one part of the observation surface
  that is not trimming- or AOT-safe.
- Every arity observes each of its chains through one shared helper and differs
  only in how many it combines.
- Drop the class-level ExcludeFromCodeCoverage from ReactiveUIBindingExtensions.
  A partial's attribute applies to the merged type, so one attribute on the file
  holding the class declaration hid all fifteen partials - 154 real methods, of
  which only 17 are the refusing dispatch stubs it was written for.
- Cover every arity of WhenChanged, WhenChanging, WhenAnyValue, WhenAny and
  WhenAnyObservable, and each binding stub's refusal.
- Reach the overloads on the declaring class rather than as extension methods:
  written as an extension call the generated dispatch wins overload resolution
  and the runtime overload never runs.
- RequiresUnreferencedCode is a .NET 5+ type. On net462 the only one in reach
  is the runtime library's internal polyfill, which another assembly cannot
  apply, and no trim analyzer runs there to need the annotation.
- One annotation per class covers every benchmark it holds, so the framework
  guard is a single conditional in each file.
… helper

- Each overload is its signature and two lines: argument checks and the
  subscriptions move into a shared helper, so the reflection and the refusals
  are written once rather than once per arity.
- Name the file for the arity-expanded surface it is, alongside the other
  *.WideArity.cs mixins. The repetition left is the twelve public signatures,
  which is what an arity-expanded API is.
@codecov

codecov Bot commented Sep 9, 2026

Copy link
Copy Markdown

Codecov Report

✅ All modified and coverable lines are covered by tests.
✅ Project coverage is 99.48%. Comparing base (8b5efc6) to head (8480e09).

Additional details and impacted files
@@            Coverage Diff             @@
##             main      #86      +/-   ##
==========================================
+ Coverage   99.28%   99.48%   +0.19%     
==========================================
  Files         232      246      +14     
  Lines        7597    10411    +2814     
  Branches     1080     1124      +44     
==========================================
+ Hits         7543    10357    +2814     
  Misses         42       42              
  Partials       12       12              

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

@sonarqubecloud

sonarqubecloud Bot commented Sep 9, 2026

Copy link
Copy Markdown

@glennawatson
glennawatson merged commit 4adab24 into main Sep 9, 2026
12 checks passed
@glennawatson
glennawatson deleted the feat/when-any-dynamic branch September 9, 2026 06:37
Sign up for free to join this conversation on GitHub. Already have an account? Sign in to comment

Labels

None yet

Projects

None yet

Development

Successfully merging this pull request may close these issues.

1 participant