Skip to content

feat(LinqMixins): System.Reactive/LINQ name layer + dedicated sinks#22

Merged
glennawatson merged 8 commits into
mainfrom
feat/rx-linq-name-layer
Jun 3, 2026
Merged

feat(LinqMixins): System.Reactive/LINQ name layer + dedicated sinks#22
glennawatson merged 8 commits into
mainfrom
feat/rx-linq-name-layer

Conversation

@glennawatson
Copy link
Copy Markdown
Contributor

What

Adds the familiar System.Reactive / LINQ operator names as a first-class surface alongside the Primitives vocabulary, and removes the closure-piggyback patterns behind several operators.

Name layer (33 operators)

Select, SelectWith, Where, WhereWith, WhereNotNull, Do, DoWith, Scan, Aggregate, DistinctUntilChanged (+By), IgnoreElements, SelectMany (×2), Merge, Concat (×2), Amb, Switch, Zip, CombineLatest, WithLatestFrom, Delay (×2), Timeout (×2), Sample (×2), Retry, Materialize, Dematerialize.

Each name constructs the same sink directly as its Primitives-named counterpart (SelectMap, WhereKeep, ScanFold, MergeBlend, ZipPair, …) — no forwarding, no redirect, identical behaviour and allocation profile, including the int-range fast paths. Both name sets are fully supported and interchangeable.

Dedicated sinks (kill per-call closures)

Operators that were implemented as source.Map(closure) / source.Recover(_ => fallback) etc. now build a purpose-built sink:

  • MapWith/SelectWithMapWithSignal, KeepWith/WhereWithKeepWithSignal, TapWith/DoWithTapWithSignal (internal, state stored on the sink).
  • Tap/Do reuse the existing TapSignal.
  • Resume → new internal ResumeSignal (holds the fallback directly; shared with a future Catch(fallback)).
  • Timestamp non-range path → new internal TimestampSignal.

All new sinks are internal (no public-API churn; the 3-param MapWith sink avoids CA1005).

Tests

RxNameParityTestsgenericified: each operator pair is one [MethodDataSource] row consumed by a single test body that asserts (a) each name matches the expected sequence and (b) the two names are behaviorally identical. Four families: unary IObservable<int>→IObservable<int>, higher-order source-of-sources, binary (manual-subject drive scripts), and time-based (virtual TestClock). 24 cases, green on net8/net9/net10. Full suite 265/265 on net9; core builds clean on net8.0/net9.0/net462.

Housekeeping

  • CPD exclusions in sonarcloud.yml for the deliberately-duplicated Rx-name/sink files.
  • 3 API-approval snapshots updated (only the new public LinqMixins methods; no internal sinks leaked).
  • README gains a "System.Reactive / LINQ name layer" section with the full mapping table and the dual-reference collision caveat.

Add 33 familiar System.Reactive/LINQ operator names (Select, Where, Scan,
Aggregate, Merge, Concat, Amb, Switch, Zip, CombineLatest, WithLatestFrom, Do,
DoWith, SelectWith, WhereWith, WhereNotNull, DistinctUntilChanged(+By),
IgnoreElements, SelectMany, Delay, Timeout, Sample, Retry, Materialize,
Dematerialize) as first-class operators. Each builds the SAME sink as its
Primitives-named counterpart directly (no forwarding/redirect), so the two names
are interchangeable with identical behaviour and allocation profile. Both name
sets are fully supported.

Replace closure-piggyback operator bodies with dedicated sinks so no per-call
closure is allocated: MapWith/KeepWith/TapWith get internal *WithSignal sinks;
Tap/Do reuse the existing TapSignal; Resume gets an internal ResumeSignal
(shared with a future Catch(fallback)); Timestamp gets an internal
TimestampSignal for the non-range path.

Tests: genericified parity tests (RxNameParityTests) drive each operator pair
once via TUnit [MethodDataSource] and assert each name matches the expected
sequence and is behaviorally identical to its twin (unary, higher-order, binary,
and virtual-clock time families). Add CPD exclusions for the deliberately
duplicated Rx-name/sink files. Update the 3 API-approval snapshots and document
the name layer in the README.
…are a sink

The binary Concat (Rx name) was calling Signal.Chain (a Primitives-vocabulary
factory) rather than constructing a sink directly. Add a two-source ChainSignal
constructor and a matching ChainCoordinator.Run(first, second) that enqueues both
inners directly, and have both the binary Concat and binary Chain build it — no
FromEnumerable wrapper, no cross-vocabulary call. Every Rx name now constructs its
sink directly.
…nd CPD exclusions

Extract the interlocked two-slot subscription management that RecoverSignal and
ResumeSignal both copied (Release/Assign + a per-class DisposedMarker) into a
shared internal static SubscriptionSlots helper, reusing the existing public
DisposedMarker sentinel. Eliminate TimestampSignal entirely: Timestamp is a
stateful map, so it now builds MapWithSignal with a non-capturing CreateMoment
selector. Remove the sink CPD exclusions added earlier (RecoverSignal/ResumeSignal
no longer duplicate; TimestampSignal is gone) — only the deliberately-duplicated
Rx-name operator-body file remains excluded.
…ayer collides

The benchmark namespace nests under ReactiveUI.Primitives, so the new LinqMixins
Rx-named extensions are preferred over the imported System.Reactive.Linq ones. Two
SystemReactive competitor calls broke the build: Materialize (Spark vs Notification
return type) and a Where whose lambda IDE0200 wanted removed (a false positive once
our single-overload Where was selected). Call both via the RxObservable static alias
so they unambiguously measure System.Reactive.
…titors

The benchmark namespace nests under ReactiveUI.Primitives, so instance-style calls
of the new Rx name-layer operators (Select/Where/Do/Scan/SelectMany/Materialize/
Dematerialize/IgnoreElements/Retry/Zip/CombineLatest, plus Buffer in one chain)
silently bound to Primitives instead of System.Reactive — making the SystemReactive*
benchmarks measure the wrong library. Rewrite those competitor calls in the explicit
static form (RxObservable.Op(source, ...)) so they bind to System.Reactive. That
static-call style is what RCS1196 flags, so it is disabled for the benchmark project
via a scoped .editorconfig with a justification.
@codecov
Copy link
Copy Markdown

codecov Bot commented Jun 3, 2026

Codecov Report

✅ All modified and coverable lines are covered by tests.
✅ Project coverage is 91.93%. Comparing base (3994051) to head (9070f23).

Additional details and impacted files
@@            Coverage Diff             @@
##             main      #22      +/-   ##
==========================================
+ Coverage   91.70%   91.93%   +0.23%     
==========================================
  Files         401      407       +6     
  Lines       15725    16019     +294     
  Branches     2276     2363      +87     
==========================================
+ Hits        14420    14727     +307     
+ Misses        985      971      -14     
- Partials      320      321       +1     

☔ View full report in Codecov by Sentry.
📢 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.

… and Resume

New-code coverage on the name layer was 60% (gate is 80%). Add: null-argument and
out-of-range guard tests across all Rx names (covers the throw branches in
RxNames, the bulk of the gap); stateful-sink value-then-error and throwing-
projection tests (MapWith/KeepWith/TapWith OnError + catch paths); Resume
fallback-on-error, plain-completion, and dispose tests (ResumeSignal); a
Sample/Probe identity test and a 3-arg SelectMany/FlatMap parity test. New files
now 83-100% covered.
Cover the remaining branches: the int-range fast paths (Zip/CombineLatest/
WithLatestFrom/Switch/Delay over ranges), the default-sequencer time overloads,
Retry's happy path, and the rest of the null/right-operand guards in RxNames; the
stateful sinks' null-observer rejection, post-terminal drop guards (via a manual
source), and current-thread-requirement propagation (constructed directly); and
ResumeSignal's null-observer, scheduled subscription path, and current-thread
flag. RxNames/MapWith/KeepWith/TapWith/ResumeSignal/SubscriptionSlots are now 100%.
Codecov flagged the binary Chain first/second null checks (the Rx Concat nulls
were tested, the deviant Chain ones were not). Adds both, bringing the patch to
full coverage.
@sonarqubecloud
Copy link
Copy Markdown

sonarqubecloud Bot commented Jun 3, 2026

@glennawatson glennawatson merged commit ad00bbd into main Jun 3, 2026
15 checks passed
@glennawatson glennawatson deleted the feat/rx-linq-name-layer branch June 3, 2026 06:24
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