From 04cf8e05bf0091cf9e1222b3a14731b2ce26dbd8 Mon Sep 17 00:00:00 2001 From: Chris Pulman Date: Tue, 26 May 2026 07:15:09 +0100 Subject: [PATCH 1/8] =?UTF-8?q?Add=20R3=E2=86=94Primitives=20bridges=20and?= =?UTF-8?q?=20range=20fast-paths?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Introduce bridging observers between R3 and Primitives in R3BridgeGenerator to adapt IObserver/Observer semantics and results. Add a new RangeConcatSignal and many range-backed fast paths across operators (Zip, CombineLatest, WithLatest, CollectList/Array, ForkJoin, First/FirstOrDefault/ToTask, Count/Any async helpers, Collect*Async) to avoid per-value subscriptions and allocations for RangeSignal. Extend FromEnumerable to accept CancellationToken and short-circuit array/read-only-list paths when not cancellable; expose ToSignal overloads that accept CancellationToken. Rename BehaviourSignal→BehaviorSignal and add debugger display + related type updates. ThreadPoolSequencer: introduce Timer alias, use strongly-typed Timers dictionary and minor null/exception-doc fixes. Add convenience Publish/Replay/Share extension overloads and multiple small API/documentation cleanups (remove redundant System. prefixes in XML refs, tweak exception tags). Misc: add helper methods for creating range-backed lists/arrays/values and a few disposable optimizations. These changes improve interop, performance for RangeSignal scenarios, and cancellation support for synchronous enumeration. --- .../R3BridgeGenerator.cs | 39 +++- .../Concurrency/CurrentThreadSequencer.cs | 4 +- .../Concurrency/ImmediateSequencer.cs | 2 +- .../Concurrency/ScheduledItem.cs | 2 +- .../Concurrency/Sequencer.Simple.cs | 14 +- .../Concurrency/TaskPoolSequencer.cs | 2 +- .../Concurrency/ThreadPoolSequencer.cs | 11 +- ...rtualTimeSequencer{TAbsolute,TRelative}.cs | 2 +- .../ConnectableSignal{T}.cs | 38 ++++ .../Disposables/BooleanDisposable.cs | 2 +- .../Disposables/CancellationDisposable.cs | 4 +- src/ReactiveUI.Primitives/LinqMixins.cs | 12 +- .../Signal/AsyncSignal{T}.cs | 8 +- ...viourSignal{T}.cs => BehaviorSignal{T}.cs} | 23 +- .../Signal/IAwaitSignal{T}.cs | 2 +- .../Signal/StateSignal{T}.cs | 2 +- .../SignalOperatorMixins.cs | 183 ++++++++++++++- ...alOperatorParityMixins.AggregateHelpers.cs | 23 ++ .../SignalOperatorParityMixins.cs | 209 +++++++++++++++++- .../Signals/Core/FromEnumerableSignal{T}.cs | 38 +++- .../Signals/Core/RangeConcatSignal.cs | 75 +++++++ .../Signals/Signal{Catch}.cs | 2 +- .../Signals/Signal{Create}.cs | 12 +- .../Signals/Signal{Factories}.cs | 102 ++++++++- .../Signals/Signal{GetAwaiter}.cs | 4 +- .../Signals/Signal{Return}.cs | 2 +- .../StatefulSignalBenchmarks.cs | 2 +- .../BehaviourSignalTests.cs | 28 +-- .../CoverageRuntimeTests.cs | 24 ++ .../FactoryOperatorContractTests.cs | 111 +++++++++- .../StatefulSharingAndBridgeContractTests.cs | 134 ++++++++++- 31 files changed, 1028 insertions(+), 88 deletions(-) rename src/ReactiveUI.Primitives/Signal/{BehaviourSignal{T}.cs => BehaviorSignal{T}.cs} (93%) create mode 100644 src/ReactiveUI.Primitives/Signals/Core/RangeConcatSignal.cs diff --git a/src/ReactiveUI.Primitives.R3Bridge.Generator/R3BridgeGenerator.cs b/src/ReactiveUI.Primitives.R3Bridge.Generator/R3BridgeGenerator.cs index 9a09610..768a365 100644 --- a/src/ReactiveUI.Primitives.R3Bridge.Generator/R3BridgeGenerator.cs +++ b/src/ReactiveUI.Primitives.R3Bridge.Generator/R3BridgeGenerator.cs @@ -53,7 +53,7 @@ internal static class R3SignalBridge throw new global::System.ArgumentNullException(nameof(source)); } - return global::ReactiveUI.Primitives.Signals.Signal.Create(observer => source.Subscribe(observer)); + return global::ReactiveUI.Primitives.Signals.Signal.Create(observer => source.Subscribe(new R3ToPrimitivesObserver(observer))); } /// @@ -66,7 +66,42 @@ internal static class R3SignalBridge throw new global::System.ArgumentNullException(nameof(source)); } - return global::R3.Observable.Create(observer => source.Subscribe(observer)); + return global::R3.Observable.Create(observer => source.Subscribe(new PrimitivesToR3Observer(observer))); + } + + private sealed class R3ToPrimitivesObserver : global::R3.Observer + { + private readonly global::System.IObserver _observer; + + public R3ToPrimitivesObserver(global::System.IObserver observer) => _observer = observer; + + protected override void OnNextCore(T value) => _observer.OnNext(value); + + protected override void OnErrorResumeCore(global::System.Exception error) => _observer.OnError(error); + + protected override void OnCompletedCore(global::R3.Result result) + { + if (result.IsFailure) + { + _observer.OnError(result.Exception); + return; + } + + _observer.OnCompleted(); + } + } + + private sealed class PrimitivesToR3Observer : global::System.IObserver + { + private readonly global::R3.Observer _observer; + + public PrimitivesToR3Observer(global::R3.Observer observer) => _observer = observer; + + public void OnNext(T value) => _observer.OnNext(value); + + public void OnError(global::System.Exception error) => _observer.OnCompleted(global::R3.Result.Failure(error)); + + public void OnCompleted() => _observer.OnCompleted(global::R3.Result.Success); } } """; diff --git a/src/ReactiveUI.Primitives/Concurrency/CurrentThreadSequencer.cs b/src/ReactiveUI.Primitives/Concurrency/CurrentThreadSequencer.cs index 751a6fa..d082ae6 100644 --- a/src/ReactiveUI.Primitives/Concurrency/CurrentThreadSequencer.cs +++ b/src/ReactiveUI.Primitives/Concurrency/CurrentThreadSequencer.cs @@ -10,7 +10,7 @@ namespace ReactiveUI.Primitives.Concurrency; /// /// CurrentThreadSequencer. /// -/// +/// public sealed class CurrentThreadSequencer : ISequencer { /// @@ -103,7 +103,7 @@ public IDisposable Schedule(TState state, Func /// The disposable object used to cancel the scheduled action (best effort). /// - /// action. + /// action. /// is null. public IDisposable Schedule(TState state, TimeSpan dueTime, Func action) { diff --git a/src/ReactiveUI.Primitives/Concurrency/ImmediateSequencer.cs b/src/ReactiveUI.Primitives/Concurrency/ImmediateSequencer.cs index e8cdf0e..f5b2f33 100644 --- a/src/ReactiveUI.Primitives/Concurrency/ImmediateSequencer.cs +++ b/src/ReactiveUI.Primitives/Concurrency/ImmediateSequencer.cs @@ -7,7 +7,7 @@ namespace ReactiveUI.Primitives.Concurrency; /// /// ImmediateSequencer. /// -/// +/// public sealed class ImmediateSequencer : ISequencer { /// diff --git a/src/ReactiveUI.Primitives/Concurrency/ScheduledItem.cs b/src/ReactiveUI.Primitives/Concurrency/ScheduledItem.cs index 081c3f3..1f7e07f 100644 --- a/src/ReactiveUI.Primitives/Concurrency/ScheduledItem.cs +++ b/src/ReactiveUI.Primitives/Concurrency/ScheduledItem.cs @@ -35,7 +35,7 @@ public abstract class ScheduledItem : IScheduledItem, ICom /// /// Absolute time at which the work item has to be executed. /// Comparer used to compare work items based on their scheduled time. - /// comparer. + /// comparer. /// is null. protected ScheduledItem(TAbsolute dueTime, IComparer comparer) { diff --git a/src/ReactiveUI.Primitives/Concurrency/Sequencer.Simple.cs b/src/ReactiveUI.Primitives/Concurrency/Sequencer.Simple.cs index 17d3df4..d303b57 100644 --- a/src/ReactiveUI.Primitives/Concurrency/Sequencer.Simple.cs +++ b/src/ReactiveUI.Primitives/Concurrency/Sequencer.Simple.cs @@ -42,7 +42,7 @@ public static IDisposable Schedule(this ISequencer scheduler, Action action) /// /// The disposable object used to cancel the scheduled action (best effort). /// - /// + /// /// scheduler /// or /// action. @@ -72,7 +72,7 @@ public static IDisposable Schedule(this ISequencer scheduler, TimeSpan dueTime, /// /// The disposable object used to cancel the scheduled action (best effort). /// - /// + /// /// scheduler /// or /// action. @@ -124,7 +124,7 @@ public static IDisposable Schedule(this ISequencer scheduler, Action act /// /// The disposable object used to cancel the scheduled action (best effort). /// - /// + /// /// scheduler /// or /// action. @@ -190,7 +190,7 @@ internal static IDisposable ScheduleAction(this ISequencer scheduler, TS /// /// The disposable object used to cancel the scheduled action (best effort). /// - /// + /// /// scheduler /// or /// action. @@ -222,7 +222,7 @@ internal static IDisposable ScheduleAction(this ISequencer scheduler, TS /// /// The disposable object used to cancel the scheduled action (best effort). /// - /// + /// /// scheduler /// or /// action. @@ -254,7 +254,7 @@ internal static IDisposable ScheduleAction(this ISequencer scheduler, TS /// /// The disposable object used to cancel the scheduled action (best effort). /// - /// + /// /// scheduler /// or /// action. @@ -286,7 +286,7 @@ internal static IDisposable ScheduleAction(this ISequencer scheduler, TS /// /// The disposable object used to cancel the scheduled action (best effort). /// - /// + /// /// scheduler /// or /// action. diff --git a/src/ReactiveUI.Primitives/Concurrency/TaskPoolSequencer.cs b/src/ReactiveUI.Primitives/Concurrency/TaskPoolSequencer.cs index e811ede..3ae817f 100644 --- a/src/ReactiveUI.Primitives/Concurrency/TaskPoolSequencer.cs +++ b/src/ReactiveUI.Primitives/Concurrency/TaskPoolSequencer.cs @@ -9,7 +9,7 @@ namespace ReactiveUI.Primitives.Concurrency; /// /// TaskPoolSequencer. /// -/// +/// public sealed class TaskPoolSequencer : ISequencer { /// diff --git a/src/ReactiveUI.Primitives/Concurrency/ThreadPoolSequencer.cs b/src/ReactiveUI.Primitives/Concurrency/ThreadPoolSequencer.cs index 2b5d9e3..d4d4d1a 100644 --- a/src/ReactiveUI.Primitives/Concurrency/ThreadPoolSequencer.cs +++ b/src/ReactiveUI.Primitives/Concurrency/ThreadPoolSequencer.cs @@ -4,13 +4,14 @@ using ReactiveUI.Primitives.Disposables; using static ReactiveUI.Primitives.Disposables.Disposable; +using Timer = System.Threading.Timer; namespace ReactiveUI.Primitives.Concurrency { /// /// ThreadPoolSequencer. /// - /// + /// public sealed class ThreadPoolSequencer : ISequencer { /// @@ -26,7 +27,7 @@ public sealed class ThreadPoolSequencer : ISequencer /// /// Keeps timers rooted until they fire or are cancelled. /// - internal static readonly Dictionary Timers = []; + internal static readonly Dictionary Timers = []; /// /// Initializes a new instance of the class. @@ -49,7 +50,7 @@ private ThreadPoolSequencer() /// /// The disposable object used to cancel the scheduled action (best effort). /// - /// action. + /// action. public IDisposable Schedule(TState state, Func action) { if (action == null) @@ -83,7 +84,7 @@ public IDisposable Schedule(TState state, Func /// The disposable object used to cancel the scheduled action (best effort). /// - /// action. + /// action. public IDisposable Schedule(TState state, TimeSpan dueTime, Func action) { if (action == null) @@ -94,7 +95,7 @@ public IDisposable Schedule(TState state, TimeSpan dueTime, Func { diff --git a/src/ReactiveUI.Primitives/Concurrency/VirtualTimeSequencer{TAbsolute,TRelative}.cs b/src/ReactiveUI.Primitives/Concurrency/VirtualTimeSequencer{TAbsolute,TRelative}.cs index 8c4feaa..623a91b 100644 --- a/src/ReactiveUI.Primitives/Concurrency/VirtualTimeSequencer{TAbsolute,TRelative}.cs +++ b/src/ReactiveUI.Primitives/Concurrency/VirtualTimeSequencer{TAbsolute,TRelative}.cs @@ -47,7 +47,7 @@ protected VirtualTimeSequencer(TAbsolute initialClock, IComparer comp /// /// The disposable object used to cancel the scheduled action (best effort). /// - /// action. + /// action. /// is null. public override IDisposable ScheduleAbsolute(TState state, TAbsolute dueTime, Func action) { diff --git a/src/ReactiveUI.Primitives/ConnectableSignal{T}.cs b/src/ReactiveUI.Primitives/ConnectableSignal{T}.cs index b358610..a0be463 100644 --- a/src/ReactiveUI.Primitives/ConnectableSignal{T}.cs +++ b/src/ReactiveUI.Primitives/ConnectableSignal{T}.cs @@ -111,6 +111,15 @@ public static ConnectableSignal Multicast(this IObservable source, ISig public static ConnectableSignal PublishLive(this IObservable source) => source.Multicast(new Signal()); + /// + /// Publishes source values through a live signal hub. + /// + /// The value type. + /// Source sequence to publish. + /// A connectable live signal. + public static ConnectableSignal Publish(this IObservable source) => + source.PublishLive(); + /// /// Replays source values through a bounded replay hub. /// @@ -132,6 +141,27 @@ public static ConnectableSignal ReplayLive(this IObservable source, int public static ConnectableSignal ReplayLive(this IObservable source, int bufferSize, TimeSpan window) => source.Multicast(new ReplaySignal(bufferSize, window)); + /// + /// Replays source values through a bounded replay hub. + /// + /// The value type. + /// Source sequence to replay. + /// Maximum number of values to replay. + /// A connectable replay signal. + public static ConnectableSignal Replay(this IObservable source, int bufferSize) => + source.ReplayLive(bufferSize); + + /// + /// Replays source values through a replay hub constrained by count and time. + /// + /// The value type. + /// Source sequence to replay. + /// Maximum number of values to replay. + /// Maximum replay window. + /// A connectable replay signal. + public static ConnectableSignal Replay(this IObservable source, int bufferSize, TimeSpan window) => + source.ReplayLive(bufferSize, window); + /// /// Shares one live source subscription while at least one observer is subscribed. /// @@ -140,6 +170,14 @@ public static ConnectableSignal ReplayLive(this IObservable source, int /// A reference-counted live sequence. public static IObservable ShareLive(this IObservable source) => source.PublishLive().RefCount(); + /// + /// Shares one live source subscription while at least one observer is subscribed. + /// + /// The value type. + /// Source sequence to share. + /// A reference-counted live sequence. + public static IObservable Share(this IObservable source) => source.ShareLive(); + /// /// Connects on first subscriber and disconnects when the last subscriber disposes. /// diff --git a/src/ReactiveUI.Primitives/Disposables/BooleanDisposable.cs b/src/ReactiveUI.Primitives/Disposables/BooleanDisposable.cs index 5723f38..7e8f268 100644 --- a/src/ReactiveUI.Primitives/Disposables/BooleanDisposable.cs +++ b/src/ReactiveUI.Primitives/Disposables/BooleanDisposable.cs @@ -7,7 +7,7 @@ namespace ReactiveUI.Primitives.Disposables; /// /// BooleanDisposable. /// -/// +/// public sealed class BooleanDisposable : IsDisposed { /// diff --git a/src/ReactiveUI.Primitives/Disposables/CancellationDisposable.cs b/src/ReactiveUI.Primitives/Disposables/CancellationDisposable.cs index 81e1968..4785c87 100644 --- a/src/ReactiveUI.Primitives/Disposables/CancellationDisposable.cs +++ b/src/ReactiveUI.Primitives/Disposables/CancellationDisposable.cs @@ -7,7 +7,7 @@ namespace ReactiveUI.Primitives.Disposables; /// /// CancellationDisposable. /// -/// +/// public sealed class CancellationDisposable : IsDisposed { /// @@ -19,7 +19,7 @@ public sealed class CancellationDisposable : IsDisposed /// Initializes a new instance of the class. /// /// The CTS. - /// cts. + /// cts. public CancellationDisposable(CancellationTokenSource cts) => _cts = cts ?? throw new ArgumentNullException(nameof(cts)); /// diff --git a/src/ReactiveUI.Primitives/LinqMixins.cs b/src/ReactiveUI.Primitives/LinqMixins.cs index c76a886..f615299 100644 --- a/src/ReactiveUI.Primitives/LinqMixins.cs +++ b/src/ReactiveUI.Primitives/LinqMixins.cs @@ -20,7 +20,7 @@ public static partial class LinqMixins /// The source. /// The selector. /// A ISignals. - /// + /// /// source /// or /// selector. @@ -35,8 +35,8 @@ public static IObservable Select(this IObservableThe source. /// The count of each buffer. /// An Signals sequence of buffers. - /// source. - /// count. + /// source. + /// count. public static IObservable> Buffer(this IObservable source, int count) { if (source == null) @@ -60,8 +60,8 @@ public static IObservable> Buffer(this IObservableLength of each buffer before being skipped. /// Number of elements to skip between creation of consecutive buffers. /// An Signals sequence of buffers taking the count then skipping the skipped value, the sequecnce is then repeated. - /// source. - /// + /// source. + /// /// count /// or /// skip. @@ -122,7 +122,7 @@ public static SingleDisposable DisposeWith(this IDisposable disposable, Action? /// The source. /// The predicate. /// An ISignals. - /// + /// /// source /// or /// predicate. diff --git a/src/ReactiveUI.Primitives/Signal/AsyncSignal{T}.cs b/src/ReactiveUI.Primitives/Signal/AsyncSignal{T}.cs index e824256..1cfcec7 100644 --- a/src/ReactiveUI.Primitives/Signal/AsyncSignal{T}.cs +++ b/src/ReactiveUI.Primitives/Signal/AsyncSignal{T}.cs @@ -11,7 +11,7 @@ namespace ReactiveUI.Primitives.Signals; /// AsyncSignal. /// /// The Type. -/// +/// public class AsyncSignal : IAwaitSignal { /// @@ -54,7 +54,7 @@ public class AsyncSignal : IAwaitSignal /// /// The value. /// - /// AsyncSubject is not completed yet. + /// AsyncSubject is not completed yet. public T Value { get @@ -140,7 +140,7 @@ public void OnCompleted(Action continuation) /// Called when [error]. /// /// The error. - /// error. + /// error. public void OnError(Exception error) { if (error == null) @@ -190,7 +190,7 @@ public void OnNext(T value) /// /// The observer. /// A Disposable. - /// observer. + /// observer. public IDisposable Subscribe(IObserver observer) { if (observer == null) diff --git a/src/ReactiveUI.Primitives/Signal/BehaviourSignal{T}.cs b/src/ReactiveUI.Primitives/Signal/BehaviorSignal{T}.cs similarity index 93% rename from src/ReactiveUI.Primitives/Signal/BehaviourSignal{T}.cs rename to src/ReactiveUI.Primitives/Signal/BehaviorSignal{T}.cs index b836024..e41ab61 100644 --- a/src/ReactiveUI.Primitives/Signal/BehaviourSignal{T}.cs +++ b/src/ReactiveUI.Primitives/Signal/BehaviorSignal{T}.cs @@ -11,7 +11,8 @@ namespace ReactiveUI.Primitives.Signals; /// BehaviourSignal. /// /// The Type. -public class BehaviourSignal : ISignal +[System.Diagnostics.DebuggerDisplay("{DebuggerDisplay,nq}")] +public class BehaviorSignal : ISignal { /// /// Executes the new operation. @@ -42,10 +43,10 @@ public class BehaviourSignal : ISignal private Exception? _lastError; /// - /// Initializes a new instance of the class. + /// Initializes a new instance of the class. /// /// The default value. - public BehaviourSignal(T defaultValue) + public BehaviorSignal(T defaultValue) { _lastValue = defaultValue; } @@ -90,6 +91,18 @@ public T Value /// public bool IsDisposed { get; private set; } + /// + /// Gets the string representation of this object for debugger display purposes. + /// + [System.Diagnostics.DebuggerBrowsable(System.Diagnostics.DebuggerBrowsableState.Never)] + private string? DebuggerDisplay + { + get + { + return ToString(); + } + } + /// /// Tries to get the current value or throws an exception. /// @@ -297,7 +310,7 @@ private sealed class ObserverHandler : IDisposable /// /// Stores state for the signal implementation. /// - private BehaviourSignal? _subject; + private BehaviorSignal? _subject; /// /// Stores state for the signal implementation. @@ -309,7 +322,7 @@ private sealed class ObserverHandler : IDisposable /// /// The subject value. /// The observer value. - public ObserverHandler(BehaviourSignal subject, IObserver observer) + public ObserverHandler(BehaviorSignal subject, IObserver observer) { _subject = subject; _observer = observer; diff --git a/src/ReactiveUI.Primitives/Signal/IAwaitSignal{T}.cs b/src/ReactiveUI.Primitives/Signal/IAwaitSignal{T}.cs index 61f13b7..71a3b97 100644 --- a/src/ReactiveUI.Primitives/Signal/IAwaitSignal{T}.cs +++ b/src/ReactiveUI.Primitives/Signal/IAwaitSignal{T}.cs @@ -8,7 +8,7 @@ namespace ReactiveUI.Primitives.Signals; /// IAwaitSignal. /// /// The Type of Signal. -/// +/// /// public interface IAwaitSignal : ISignal, System.Runtime.CompilerServices.INotifyCompletion { diff --git a/src/ReactiveUI.Primitives/Signal/StateSignal{T}.cs b/src/ReactiveUI.Primitives/Signal/StateSignal{T}.cs index 970b515..378bd8d 100644 --- a/src/ReactiveUI.Primitives/Signal/StateSignal{T}.cs +++ b/src/ReactiveUI.Primitives/Signal/StateSignal{T}.cs @@ -12,7 +12,7 @@ namespace ReactiveUI.Primitives.Signals; /// Mutable latest-value signal with a ReactiveUI.Primitives name for reactive-property parity. /// /// The value type. -public class StateSignal : BehaviourSignal +public class StateSignal : BehaviorSignal { /// /// Initializes a new instance of the class. diff --git a/src/ReactiveUI.Primitives/SignalOperatorMixins.cs b/src/ReactiveUI.Primitives/SignalOperatorMixins.cs index ddd469d..3590d11 100644 --- a/src/ReactiveUI.Primitives/SignalOperatorMixins.cs +++ b/src/ReactiveUI.Primitives/SignalOperatorMixins.cs @@ -629,7 +629,7 @@ public static IObservable Zip(this IObservable< throw new ArgumentNullException(nameof(selector)); } - if (left is RangeSignal leftRange && right is RangeSignal rightRange) + if (typeof(TLeft) == typeof(int) && typeof(TRight) == typeof(int) && left is RangeSignal leftRange && right is RangeSignal rightRange) { return new RangeZipSignal(leftRange, rightRange, (Func)(object)selector); } @@ -657,6 +657,11 @@ public static IObservable CombineLatest(this IO throw new ArgumentNullException(nameof(selector)); } + if (typeof(TLeft) == typeof(int) && typeof(TRight) == typeof(int) && left is RangeSignal leftRange && right is RangeSignal rightRange) + { + return CreateRangeCombineLatestSignal(leftRange, rightRange, (Func)(object)selector); + } + return Signal.CreateSafe(observer => new CombineLatestCoordinator(observer, selector).Run(left, right)); } @@ -680,6 +685,11 @@ public static IObservable WithLatest(this IObse throw new ArgumentNullException(nameof(selector)); } + if (typeof(TLeft) == typeof(int) && typeof(TRight) == typeof(int) && left is RangeSignal leftRange && right is RangeSignal rightRange) + { + return CreateRangeWithLatestSignal(leftRange, rightRange, (Func)(object)selector); + } + return Signal.CreateSafe(observer => { var gate = new OperatorGate(); @@ -897,6 +907,11 @@ public static IObservable> CollectList(this IObservable source) throw new ArgumentNullException(nameof(source)); } + if (source is RangeSignal range && CanReadRangeAs()) + { + return CreateRangeListSignal(range); + } + return Signal.CreateSafe>(observer => { var values = new List(); @@ -914,16 +929,178 @@ public static IObservable> CollectList(this IObservable source) /// /// Collects all values into an array when the source completes. /// - public static IObservable CollectArray(this IObservable source) => - source.CollectList().Map(values => values.ToArray()); + public static IObservable CollectArray(this IObservable source) + { + if (source == null) + { + throw new ArgumentNullException(nameof(source)); + } + + if (source is RangeSignal range && CanReadRangeAs()) + { + return CreateRangeArraySignal(range); + } + + return Signal.CreateSafe(observer => + { + var values = new List(); + return source.Subscribe( + values.Add, + observer.OnError, + () => + { + observer.OnNext([.. values]); + observer.OnCompleted(); + }); + }); + } /// /// Converts an enumerable to a signal. /// public static IObservable ToSignal(this IEnumerable values) => Signal.FromEnumerable(values); + /// + /// Converts an enumerable to a signal and stops enumeration when cancelled. + /// + public static IObservable ToSignal(this IEnumerable values, CancellationToken cancellationToken) => + Signal.FromEnumerable(values, cancellationToken); + /// /// Converts an observable to a signal-compatible observable. /// public static IObservable ToSignal(this IObservable source) => source ?? throw new ArgumentNullException(nameof(source)); + + /// + /// Creates a combine-latest range signal without coordinator subscriptions. + /// + private static IObservable CreateRangeCombineLatestSignal( + RangeSignal left, + RangeSignal right, + Func selector) => + Signal.CreateSafe(observer => + { + var leftValue = left.Start + left.Count - 1; + for (var i = 0; i < right.Count; i++) + { + observer.OnNext(selector(leftValue, right.Start + i)); + } + + observer.OnCompleted(); + return Disposable.Empty; + }); + + /// + /// Creates a with-latest range signal without coordinator subscriptions. + /// + private static IObservable CreateRangeWithLatestSignal( + RangeSignal left, + RangeSignal right, + Func selector) => + Signal.CreateSafe(observer => + { + var rightValue = right.Start + right.Count - 1; + for (var i = 0; i < left.Count; i++) + { + observer.OnNext(selector(left.Start + i, rightValue)); + } + + observer.OnCompleted(); + return Disposable.Empty; + }); + + /// + /// Creates a range-backed list signal without per-value subscriptions. + /// + /// The result element type. + /// The source range. + /// The list signal. + [System.Diagnostics.CodeAnalysis.SuppressMessage( + "Major Code Smell", + "S4018:Generic methods should provide type parameters", + Justification = "The generic type is validated by the caller before creating a range-backed signal.")] + private static IObservable> CreateRangeListSignal(RangeSignal range) + { + if (typeof(T) == typeof(int)) + { + return (IObservable>)(object)Signal.CreateSafe>(observer => + { + var values = new List(range.Count); + for (var i = 0; i < range.Count; i++) + { + values.Add(range.Start + i); + } + + observer.OnNext(values); + observer.OnCompleted(); + return Disposable.Empty; + }); + } + + return Signal.CreateSafe>(observer => + { + var values = new List(range.Count); + for (var i = 0; i < range.Count; i++) + { + values.Add((T)(object)(range.Start + i)); + } + + observer.OnNext(values); + observer.OnCompleted(); + return Disposable.Empty; + }); + } + + /// + /// Creates a range-backed array signal without per-value subscriptions. + /// + /// The result element type. + /// The source range. + /// The array signal. + [System.Diagnostics.CodeAnalysis.SuppressMessage( + "Major Code Smell", + "S4018:Generic methods should provide type parameters", + Justification = "The generic type is validated by the caller before creating a range-backed signal.")] + private static IObservable CreateRangeArraySignal(RangeSignal range) + { + if (typeof(T) == typeof(int)) + { + return (IObservable)(object)Signal.CreateSafe(observer => + { + var values = new int[range.Count]; + for (var i = 0; i < values.Length; i++) + { + values[i] = range.Start + i; + } + + observer.OnNext(values); + observer.OnCompleted(); + return Disposable.Empty; + }); + } + + return Signal.CreateSafe(observer => + { + var values = new T[range.Count]; + for (var i = 0; i < values.Length; i++) + { + values[i] = (T)(object)(range.Start + i); + } + + observer.OnNext(values); + observer.OnCompleted(); + return Disposable.Empty; + }); + } + + /// + /// Determines whether a generic observer type can receive boxed range integers. + /// + /// The observer value type. + /// when the cast is valid. + [System.Diagnostics.CodeAnalysis.SuppressMessage( + "Major Code Smell", + "S4018:Generic methods should provide type parameters", + Justification = "The method is a generic type test used by range fast paths.")] + private static bool CanReadRangeAs() => typeof(T).IsAssignableFrom(typeof(int)); } diff --git a/src/ReactiveUI.Primitives/SignalOperatorParityMixins.AggregateHelpers.cs b/src/ReactiveUI.Primitives/SignalOperatorParityMixins.AggregateHelpers.cs index 44cba1f..3dd02d7 100644 --- a/src/ReactiveUI.Primitives/SignalOperatorParityMixins.AggregateHelpers.cs +++ b/src/ReactiveUI.Primitives/SignalOperatorParityMixins.AggregateHelpers.cs @@ -3,6 +3,8 @@ // See the LICENSE file in the project root for full license information. using ReactiveUI.Primitives.Core; +using ReactiveUI.Primitives.Disposables; +using ReactiveUI.Primitives.Signals.Core; namespace ReactiveUI.Primitives; @@ -139,6 +141,13 @@ public IDisposable Subscribe(IObserver observer) throw new ArgumentNullException(nameof(observer)); } + if (_source is RangeSignal range) + { + observer.OnNext(range.Count); + observer.OnCompleted(); + return Disposable.Empty; + } + if (_source is ICountSource countSource) { return countSource.SubscribeCount(observer); @@ -224,6 +233,13 @@ public IDisposable Subscribe(IObserver observer) throw new ArgumentNullException(nameof(observer)); } + if (_source is RangeSignal range) + { + observer.OnNext(range.Count); + observer.OnCompleted(); + return Disposable.Empty; + } + if (_source is ICountSource countSource) { return countSource.SubscribeLongCount(observer); @@ -309,6 +325,13 @@ public IDisposable Subscribe(IObserver observer) throw new ArgumentNullException(nameof(observer)); } + if (_source is RangeSignal) + { + observer.OnNext(true); + observer.OnCompleted(); + return Disposable.Empty; + } + var sink = new AnyObserver(observer); sink.SetSubscription(_source.Subscribe(sink)); return sink; diff --git a/src/ReactiveUI.Primitives/SignalOperatorParityMixins.cs b/src/ReactiveUI.Primitives/SignalOperatorParityMixins.cs index 931924e..9aab167 100644 --- a/src/ReactiveUI.Primitives/SignalOperatorParityMixins.cs +++ b/src/ReactiveUI.Primitives/SignalOperatorParityMixins.cs @@ -6,6 +6,7 @@ using ReactiveUI.Primitives.Core; using ReactiveUI.Primitives.Disposables; using ReactiveUI.Primitives.Signals; +using ReactiveUI.Primitives.Signals.Core; #pragma warning disable SA1107, SA1116, SA1117, SA1501, SA1611, SA1615, SA1618 @@ -98,6 +99,12 @@ public static IObservable StartWith(this IObservable source, IEnumerabl /// public static IObservable ToObservable(this IEnumerable values) => Signal.FromEnumerable(values); + /// + /// Converts an enumerable sequence to a Primitives signal using the System.Reactive conversion name. + /// + public static IObservable ToObservable(this IEnumerable values, CancellationToken cancellationToken) => + Signal.FromEnumerable(values, cancellationToken); + /// /// Schedules observer notifications on the supplied scheduler using the System.Reactive operator name. /// @@ -113,6 +120,11 @@ public static IObservable ObserveOn(this IObservable source, ISequencer throw new ArgumentNullException(nameof(scheduler)); } + if (scheduler == Sequencer.Immediate) + { + return source; + } + return source.WitnessOn(scheduler); } @@ -749,24 +761,74 @@ public static IObservable ForkJoin(this IObserv throw new ArgumentNullException(nameof(selector)); } + if (typeof(TLeft) == typeof(int) && typeof(TRight) == typeof(int) && left is RangeSignal leftRange && right is RangeSignal rightRange) + { + return Signal.CreateSafe(observer => + { + observer.OnNext(((Func)(object)selector)( + leftRange.Start + leftRange.Count - 1, + rightRange.Start + rightRange.Count - 1)); + observer.OnCompleted(); + return Disposable.Empty; + }); + } + return Signal.CreateSafe(observer => new ForkJoinCoordinator(observer, selector).Run(left, right)); } /// /// Awaits the first source value. /// - public static Task FirstAsync(this IObservable source) => source.FirstOrDefaultCoreAsync(false, default!); + public static Task FirstAsync(this IObservable source) + { + if (source == null) + { + throw new ArgumentNullException(nameof(source)); + } + + if (source is RangeSignal range && CanReadRangeAs()) + { + return Task.FromResult(CreateRangeValue(range.Start)); + } + + return source.FirstOrDefaultCoreAsync(false, default!); + } /// /// Awaits the first source value, returning a default value when the source is empty. /// - public static Task FirstOrDefaultAsync(this IObservable source) => - source.FirstOrDefaultCoreAsync(true, default!); + public static Task FirstOrDefaultAsync(this IObservable source) + { + if (source == null) + { + throw new ArgumentNullException(nameof(source)); + } + + if (source is RangeSignal range && CanReadRangeAs()) + { + return Task.FromResult(CreateRangeValue(range.Start)); + } + + return source.FirstOrDefaultCoreAsync(true, default!); + } /// /// Awaits the first source value, returning a default value when the source is empty. /// - public static Task FirstOrDefaultAsync(this IObservable source, T defaultValue) => source.FirstOrDefaultCoreAsync(true, defaultValue); + public static Task FirstOrDefaultAsync(this IObservable source, T defaultValue) + { + if (source == null) + { + throw new ArgumentNullException(nameof(source)); + } + + if (source is RangeSignal range && CanReadRangeAs()) + { + return Task.FromResult(CreateRangeValue(range.Start)); + } + + return source.FirstOrDefaultCoreAsync(true, defaultValue); + } /// /// Awaits source completion and returns the last value produced by the source. @@ -776,6 +838,10 @@ public static Task FirstOrDefaultAsync(this IObservable source) => /// /// Awaits source completion and returns the last value produced by the source. /// + [System.Diagnostics.CodeAnalysis.SuppressMessage( + "Major Code Smell", + "S1541:Methods and properties should not be too complex", + Justification = "ToTask keeps cancellation, terminal, and synchronous fast paths together to avoid extra allocations.")] public static Task ToTask(this IObservable source, CancellationToken cancellationToken) { if (source == null) @@ -783,6 +849,16 @@ public static Task ToTask(this IObservable source, CancellationToken ca throw new ArgumentNullException(nameof(source)); } + if (cancellationToken.IsCancellationRequested) + { + return Task.FromCanceled(cancellationToken); + } + + if (source is RangeSignal range && CanReadRangeAs()) + { + return Task.FromResult(CreateRangeValue(range.Start + range.Count - 1)); + } + var completion = new TaskCompletionSource(); var seen = false; var last = default(T); @@ -836,6 +912,54 @@ public static Task ToTask(this IObservable source, CancellationToken ca /// public static Task ToTask(this Task task) => task ?? throw new ArgumentNullException(nameof(task)); + /// + /// Awaits the source count as a task. + /// + public static Task CountAsync(this IObservable source) => + source.Count().ToTask(); + + /// + /// Awaits the source count as a task. + /// + public static Task CountAsync(this IObservable source, CancellationToken cancellationToken) => + source.Count().ToTask(cancellationToken); + + /// + /// Awaits the source predicate count as a task. + /// + public static Task CountAsync(this IObservable source, Func predicate) => + source.Count(predicate).ToTask(); + + /// + /// Awaits the source predicate count as a task. + /// + public static Task CountAsync(this IObservable source, Func predicate, CancellationToken cancellationToken) => + source.Count(predicate).ToTask(cancellationToken); + + /// + /// Awaits whether any value is present. + /// + public static Task AnyAsync(this IObservable source) => + source.Any().ToTask(); + + /// + /// Awaits whether any value is present. + /// + public static Task AnyAsync(this IObservable source, CancellationToken cancellationToken) => + source.Any().ToTask(cancellationToken); + + /// + /// Awaits whether any value matches a predicate. + /// + public static Task AnyAsync(this IObservable source, Func predicate) => + source.Any(predicate).ToTask(); + + /// + /// Awaits whether any value matches a predicate. + /// + public static Task AnyAsync(this IObservable source, Func predicate, CancellationToken cancellationToken) => + source.Any(predicate).ToTask(cancellationToken); + /// /// Collects all values into an array task. /// @@ -846,6 +970,11 @@ public static Task CollectArrayAsync(this IObservable source) throw new ArgumentNullException(nameof(source)); } + if (source is RangeSignal range && CanReadRangeAs()) + { + return Task.FromResult(CreateRangeArray(range)); + } + var completion = new TaskCompletionSource(); var values = new List(); source.Subscribe(values.Add, error => completion.TrySetException(error), () => completion.TrySetResult([.. values])); @@ -862,6 +991,11 @@ public static Task> CollectListAsync(this IObservable source) throw new ArgumentNullException(nameof(source)); } + if (source is RangeSignal range && CanReadRangeAs()) + { + return Task.FromResult((IList)CreateRangeList(range)); + } + var completion = new TaskCompletionSource>(); var values = new List(); source.Subscribe(values.Add, error => completion.TrySetException(error), () => completion.TrySetResult(values)); @@ -915,4 +1049,71 @@ private static Task FirstOrDefaultCoreAsync(this IObservable source, bo }); return completion.Task; } + + /// + /// Creates a generic value from an integer range item. + /// + [System.Diagnostics.CodeAnalysis.SuppressMessage( + "Major Code Smell", + "S4018:Generic methods should provide type parameters", + Justification = "The generic type is validated by the caller before reading range values.")] + private static T CreateRangeValue(int value) => (T)(object)value; + + /// + /// Creates a range-backed array for task terminal fast paths. + /// + [System.Diagnostics.CodeAnalysis.SuppressMessage( + "Major Code Smell", + "S4018:Generic methods should provide type parameters", + Justification = "The generic type is validated by the caller before reading range values.")] + private static T[] CreateRangeArray(RangeSignal range) + { + if (typeof(T) == typeof(int)) + { + var values = new int[range.Count]; + for (var i = 0; i < values.Length; i++) + { + values[i] = range.Start + i; + } + + return (T[])(object)values; + } + + var boxed = new T[range.Count]; + for (var i = 0; i < boxed.Length; i++) + { + boxed[i] = CreateRangeValue(range.Start + i); + } + + return boxed; + } + + /// + /// Creates a range-backed list for task terminal fast paths. + /// + [System.Diagnostics.CodeAnalysis.SuppressMessage( + "Major Code Smell", + "S4018:Generic methods should provide type parameters", + Justification = "The generic type is validated by the caller before reading range values.")] + private static List CreateRangeList(RangeSignal range) + { + if (typeof(T) == typeof(int)) + { + var integers = new List(range.Count); + for (var i = 0; i < range.Count; i++) + { + integers.Add(range.Start + i); + } + + return (List)(object)integers; + } + + var values = new List(range.Count); + for (var i = 0; i < range.Count; i++) + { + values.Add(CreateRangeValue(range.Start + i)); + } + + return values; + } } diff --git a/src/ReactiveUI.Primitives/Signals/Core/FromEnumerableSignal{T}.cs b/src/ReactiveUI.Primitives/Signals/Core/FromEnumerableSignal{T}.cs index 6643184..50a53e3 100644 --- a/src/ReactiveUI.Primitives/Signals/Core/FromEnumerableSignal{T}.cs +++ b/src/ReactiveUI.Primitives/Signals/Core/FromEnumerableSignal{T}.cs @@ -18,6 +18,11 @@ internal sealed class FromEnumerableSignal : IRequireCurrentThread, IInlin /// private readonly IEnumerable _values; + /// + /// Cancels synchronous enumeration when requested. + /// + private readonly CancellationToken _cancellationToken; + /// /// Initializes a new instance of the class. /// @@ -25,6 +30,17 @@ internal sealed class FromEnumerableSignal : IRequireCurrentThread, IInlin public FromEnumerableSignal(IEnumerable values) => _values = values; + /// + /// Initializes a new instance of the class. + /// + /// The source values. + /// The cancellation token. + public FromEnumerableSignal(IEnumerable values, CancellationToken cancellationToken) + { + _values = values; + _cancellationToken = cancellationToken; + } + /// /// Executes the IsRequiredSubscribeOnCurrentThread operation. /// @@ -43,7 +59,7 @@ public IDisposable Subscribe(IObserver observer) throw new ArgumentNullException(nameof(observer)); } - if (_values is T[] array) + if (!_cancellationToken.CanBeCanceled && _values is T[] array) { for (var i = 0; i < array.Length; i++) { @@ -54,7 +70,7 @@ public IDisposable Subscribe(IObserver observer) return Disposable.Empty; } - if (_values is IReadOnlyList readOnlyList) + if (!_cancellationToken.CanBeCanceled && _values is IReadOnlyList readOnlyList) { for (var i = 0; i < readOnlyList.Count; i++) { @@ -67,6 +83,11 @@ public IDisposable Subscribe(IObserver observer) foreach (var value in _values) { + if (_cancellationToken.IsCancellationRequested) + { + return Disposable.Empty; + } + observer.OnNext(value); } @@ -81,6 +102,10 @@ public IDisposable Subscribe(IObserver observer) /// The onError value. /// The onCompleted value. /// The subscription. + [System.Diagnostics.CodeAnalysis.SuppressMessage( + "Major Code Smell", + "S1541:Methods and properties should not be too complex", + Justification = "The method keeps array, read-only-list, iterator, and cancellation fast paths allocation-free.")] public IDisposable Subscribe(Action onNext, Action onError, Action onCompleted) { if (onNext == null) @@ -93,7 +118,7 @@ public IDisposable Subscribe(Action onNext, Action onError, Action throw new ArgumentNullException(nameof(onCompleted)); } - if (_values is T[] array) + if (!_cancellationToken.CanBeCanceled && _values is T[] array) { for (var i = 0; i < array.Length; i++) { @@ -104,7 +129,7 @@ public IDisposable Subscribe(Action onNext, Action onError, Action return Disposable.Empty; } - if (_values is IReadOnlyList readOnlyList) + if (!_cancellationToken.CanBeCanceled && _values is IReadOnlyList readOnlyList) { for (var i = 0; i < readOnlyList.Count; i++) { @@ -117,6 +142,11 @@ public IDisposable Subscribe(Action onNext, Action onError, Action foreach (var value in _values) { + if (_cancellationToken.IsCancellationRequested) + { + return Disposable.Empty; + } + onNext(value); } diff --git a/src/ReactiveUI.Primitives/Signals/Core/RangeConcatSignal.cs b/src/ReactiveUI.Primitives/Signals/Core/RangeConcatSignal.cs new file mode 100644 index 0000000..e555964 --- /dev/null +++ b/src/ReactiveUI.Primitives/Signals/Core/RangeConcatSignal.cs @@ -0,0 +1,75 @@ +// Copyright (c) 2019-2026 ReactiveUI Association Incorporated. All rights reserved. +// ReactiveUI Association Incorporated licenses this file to you under the MIT license. +// See the LICENSE file in the project root for full license information. + +using ReactiveUI.Primitives.Core; +using ReactiveUI.Primitives.Disposables; + +namespace ReactiveUI.Primitives.Signals.Core; + +/// +/// Concatenates synchronous integer ranges without outer observable/coordinator overhead. +/// +internal sealed class RangeConcatSignal : IRequireCurrentThread, IInlineSignal +{ + /// + /// Source ranges to emit in order. + /// + private readonly RangeSignal[] _ranges; + + /// + /// Initializes a new instance of the class. + /// + /// The source ranges. + public RangeConcatSignal(RangeSignal[] ranges) => _ranges = ranges; + + /// + public bool IsRequiredSubscribeOnCurrentThread() => false; + + /// + public IDisposable Subscribe(IObserver observer) + { + if (observer == null) + { + throw new ArgumentNullException(nameof(observer)); + } + + for (var rangeIndex = 0; rangeIndex < _ranges.Length; rangeIndex++) + { + var range = _ranges[rangeIndex]; + for (var i = 0; i < range.Count; i++) + { + observer.OnNext(range.Start + i); + } + } + + observer.OnCompleted(); + return Disposable.Empty; + } + + /// + public IDisposable Subscribe(Action onNext, Action onError, Action onCompleted) + { + if (onNext == null) + { + throw new ArgumentNullException(nameof(onNext)); + } + + if (onCompleted == null) + { + throw new ArgumentNullException(nameof(onCompleted)); + } + + for (var rangeIndex = 0; rangeIndex < _ranges.Length; rangeIndex++) + { + var range = _ranges[rangeIndex]; + for (var i = 0; i < range.Count; i++) + { + onNext(range.Start + i); + } + } + + onCompleted(); + return Disposable.Empty; + } +} diff --git a/src/ReactiveUI.Primitives/Signals/Signal{Catch}.cs b/src/ReactiveUI.Primitives/Signals/Signal{Catch}.cs index b9d14ab..7402ecd 100644 --- a/src/ReactiveUI.Primitives/Signals/Signal{Catch}.cs +++ b/src/ReactiveUI.Primitives/Signals/Signal{Catch}.cs @@ -15,7 +15,7 @@ public static partial class Signal /// Continues an observable sequence that is terminated by an exception of the specified type with the observable sequence produced by the handler. /// /// The type of the elements in the source sequence and sequences returned by the exception handler function. - /// The type of the exception to catch and handle. Needs to derive from . + /// The type of the exception to catch and handle. Needs to derive from . /// Source sequence. /// Exception handler function, producing another observable sequence. /// diff --git a/src/ReactiveUI.Primitives/Signals/Signal{Create}.cs b/src/ReactiveUI.Primitives/Signals/Signal{Create}.cs index 45e25d1..f296222 100644 --- a/src/ReactiveUI.Primitives/Signals/Signal{Create}.cs +++ b/src/ReactiveUI.Primitives/Signals/Signal{Create}.cs @@ -19,7 +19,7 @@ public static partial class Signal /// The type. /// The subscribe. /// An Signals. - /// subscribe. + /// subscribe. /// is null. public static IObservable Create(Func, IDisposable> subscribe) { @@ -39,7 +39,7 @@ public static IObservable Create(Func, IDisposable> subscribe /// The subscribe. /// if set to true [is required subscribe on current thread]. /// An Signals. - /// subscribe. + /// subscribe. /// is null. public static IObservable Create(Func, IDisposable> subscribe, bool isRequiredSubscribeOnCurrentThread) { @@ -60,7 +60,7 @@ public static IObservable Create(Func, IDisposable> subscribe /// The state. /// The subscribe. /// An Signals. - /// subscribe. + /// subscribe. /// is null. public static IObservable CreateWithState(TState state, Func, IDisposable> subscribe) { @@ -82,7 +82,7 @@ public static IObservable CreateWithState(TState state, FuncThe subscribe. /// if set to true [is required subscribe on current thread]. /// An Signals. - /// subscribe. + /// subscribe. /// is null. public static IObservable CreateWithState(TState state, Func, IDisposable> subscribe, bool isRequiredSubscribeOnCurrentThread) { @@ -101,7 +101,7 @@ public static IObservable CreateWithState(TState state, FuncThe type. /// The subscribe. /// An Signals. - /// subscribe. + /// subscribe. /// is null. public static IObservable CreateSafe(Func, IDisposable> subscribe) { @@ -121,7 +121,7 @@ public static IObservable CreateSafe(Func, IDisposable> subsc /// The subscribe. /// if set to true [is required subscribe on current thread]. /// An Observable. - /// subscribe. + /// subscribe. /// is null. public static IObservable CreateSafe(Func, IDisposable> subscribe, bool isRequiredSubscribeOnCurrentThread) { diff --git a/src/ReactiveUI.Primitives/Signals/Signal{Factories}.cs b/src/ReactiveUI.Primitives/Signals/Signal{Factories}.cs index e602933..a22ac19 100644 --- a/src/ReactiveUI.Primitives/Signals/Signal{Factories}.cs +++ b/src/ReactiveUI.Primitives/Signals/Signal{Factories}.cs @@ -184,6 +184,21 @@ public static IObservable FromEnumerable(IEnumerable values) return new FromEnumerableSignal(values); } + /// + /// Creates a signal from an enumerable sequence and stops enumeration when the token is cancelled. + /// + public static IObservable FromEnumerable(IEnumerable values, CancellationToken cancellationToken) + { + if (values == null) + { + throw new ArgumentNullException(nameof(values)); + } + + return cancellationToken.CanBeCanceled + ? new FromEnumerableSignal(values, cancellationToken) + : new FromEnumerableSignal(values); + } + /// /// Creates a signal from a task instance. /// @@ -242,6 +257,38 @@ public static IObservable FromTask(Task task) }); } + /// + /// Creates a signal by invoking an asynchronous factory at subscription time. + /// + public static IObservable FromAsync(Func> taskFactory) + { + if (taskFactory == null) + { + throw new ArgumentNullException(nameof(taskFactory)); + } + + return Defer(() => FromTask(taskFactory())); + } + + /// + /// Creates a signal by invoking an asynchronous factory at subscription time. + /// + public static IObservable FromAsync(Func> taskFactory) => + FromAsync(taskFactory, CancellationToken.None); + + /// + /// Creates a signal by invoking an asynchronous factory at subscription time. + /// + public static IObservable FromAsync(Func> taskFactory, CancellationToken cancellationToken) + { + if (taskFactory == null) + { + throw new ArgumentNullException(nameof(taskFactory)); + } + + return Defer(() => FromTask(taskFactory(cancellationToken))); + } + /// /// Runs a function on the supplied scheduler and emits its result. /// @@ -465,20 +512,36 @@ public static IObservable Timer(TimeSpan dueTime, TimeSpan period, ISequen /// /// Concatenates the supplied signals. /// - public static IObservable Concat(params IObservable[] sources) => - FromEnumerable(ValidateSources(sources)).Concat(); + public static IObservable Concat(params IObservable[] sources) + { + var validated = ValidateSources(sources); + var rangeConcat = TryCreateRangeConcat(validated); + return rangeConcat == null ? FromEnumerable(validated).Concat() : (IObservable)(object)rangeConcat; + } /// /// Merges the supplied signals. /// - public static IObservable Merge(params IObservable[] sources) => - FromEnumerable(ValidateSources(sources)).Merge(); + public static IObservable Merge(params IObservable[] sources) + { + var validated = ValidateSources(sources); + var rangeConcat = TryCreateRangeConcat(validated); + return rangeConcat == null ? FromEnumerable(validated).Merge() : (IObservable)(object)rangeConcat; + } /// /// Races the supplied signals and mirrors the first one to produce a value or terminal signal. /// - public static IObservable Race(params IObservable[] sources) => - FromEnumerable(ValidateSources(sources)).Race(); + public static IObservable Race(params IObservable[] sources) + { + var validated = ValidateSources(sources); + if (validated.Length > 0 && validated[0] is RangeSignal) + { + return validated[0]; + } + + return FromEnumerable(validated).Race(); + } /// /// Zips two signals with a result selector. @@ -528,6 +591,33 @@ private static IObservable[] ValidateSources(IObservable[] sources) return sources; } + /// + /// Creates a range concat signal when every source is a synchronous integer range. + /// + /// The source value type. + /// The validated sources. + /// A range concat signal, or when the fast path is not applicable. + private static RangeConcatSignal? TryCreateRangeConcat(IObservable[] sources) + { + if (typeof(T) != typeof(int) || sources.Length == 0) + { + return null; + } + + var ranges = new RangeSignal[sources.Length]; + for (var i = 0; i < sources.Length; i++) + { + if (sources[i] is not RangeSignal range) + { + return null; + } + + ranges[i] = range; + } + + return new RangeConcatSignal(ranges); + } + #if NETSTANDARD2_1_OR_GREATER || NETCOREAPP3_0_OR_GREATER || NET5_0_OR_GREATER /// diff --git a/src/ReactiveUI.Primitives/Signals/Signal{GetAwaiter}.cs b/src/ReactiveUI.Primitives/Signals/Signal{GetAwaiter}.cs index 33be3af..fc36d6c 100644 --- a/src/ReactiveUI.Primitives/Signals/Signal{GetAwaiter}.cs +++ b/src/ReactiveUI.Primitives/Signals/Signal{GetAwaiter}.cs @@ -16,7 +16,7 @@ public static partial class Signal /// The type of the source. /// Source sequence to await. /// An AsyncSignal. - /// source. + /// source. public static IAwaitSignal GetAwaiter(this IObservable source) { if (source == null) @@ -37,7 +37,7 @@ public static IAwaitSignal GetAwaiter(this IObservable /// An AsyncSignal. /// - /// source. + /// source. public static IAwaitSignal GetAwaiter(this IObservable source, CancellationToken cancellationToken) { if (source == null) diff --git a/src/ReactiveUI.Primitives/Signals/Signal{Return}.cs b/src/ReactiveUI.Primitives/Signals/Signal{Return}.cs index e1eab53..4797210 100644 --- a/src/ReactiveUI.Primitives/Signals/Signal{Return}.cs +++ b/src/ReactiveUI.Primitives/Signals/Signal{Return}.cs @@ -36,7 +36,7 @@ public static IObservable Return(T value, ISequencer scheduler) /// The value. /// An Signals. public static IObservable Return(T value) => - Return(value, Sequencer.Immediate); + Return(value, Sequencer.Immediate); /// /// Return single sequence Immediately, optimized for RxVoid(no allocate memory). diff --git a/src/benchmarks/ReactiveUI.Primitives.Benchmarks/StatefulSignalBenchmarks.cs b/src/benchmarks/ReactiveUI.Primitives.Benchmarks/StatefulSignalBenchmarks.cs index fbccb26..e312196 100644 --- a/src/benchmarks/ReactiveUI.Primitives.Benchmarks/StatefulSignalBenchmarks.cs +++ b/src/benchmarks/ReactiveUI.Primitives.Benchmarks/StatefulSignalBenchmarks.cs @@ -83,7 +83,7 @@ public int R3BehaviorSubject1024() private static int EmitAndReadBehaviourSignal(int count) { var observer = new IntSignalObserver(); - using var subject = new BehaviourSignal(0); + using var subject = new BehaviorSignal(0); using var subscription = subject.Subscribe(observer); for (var i = 1; i <= count; i++) { diff --git a/src/tests/ReactiveUI.Primitives.Tests/BehaviourSignalTests.cs b/src/tests/ReactiveUI.Primitives.Tests/BehaviourSignalTests.cs index 6a494f1..33d6f9c 100644 --- a/src/tests/ReactiveUI.Primitives.Tests/BehaviourSignalTests.cs +++ b/src/tests/ReactiveUI.Primitives.Tests/BehaviourSignalTests.cs @@ -38,14 +38,14 @@ public class BehaviourSignalTests /// [Test] public void Subscribe_ArgumentChecking() => - Assert.Throws(() => new BehaviourSignal(1).Subscribe(null!)); + Assert.Throws(() => new BehaviorSignal(1).Subscribe(null!)); /// /// Called when [error argument checking]. /// [Test] public void OnError_ArgumentChecking() => - Assert.Throws(() => new BehaviourSignal(1).OnError(null!)); + Assert.Throws(() => new BehaviorSignal(1).OnError(null!)); /// /// Determines whether this instance has observers. @@ -53,7 +53,7 @@ public void OnError_ArgumentChecking() => [Test] public void HasObservers() { - var s = new BehaviourSignal(42); + var s = new BehaviorSignal(42); Assert.False(s.HasObservers); var d1 = s.Subscribe(_ => { }); @@ -81,7 +81,7 @@ public void HasObservers() [Test] public void HasObservers_Dispose1() { - var s = new BehaviourSignal(42); + var s = new BehaviorSignal(42); Assert.False(s.HasObservers); Assert.False(s.IsDisposed); @@ -104,7 +104,7 @@ public void HasObservers_Dispose1() [Test] public void HasObservers_Dispose2() { - var s = new BehaviourSignal(42); + var s = new BehaviorSignal(42); Assert.False(s.HasObservers); Assert.False(s.IsDisposed); @@ -127,7 +127,7 @@ public void HasObservers_Dispose2() [Test] public void HasObservers_Dispose3() { - var s = new BehaviourSignal(42); + var s = new BehaviorSignal(42); Assert.False(s.HasObservers); Assert.False(s.IsDisposed); @@ -142,7 +142,7 @@ public void HasObservers_Dispose3() [Test] public void HasObservers_OnCompleted() { - var s = new BehaviourSignal(42); + var s = new BehaviorSignal(42); Assert.False(s.HasObservers); using var subscription = s.Subscribe(_ => { }); @@ -161,7 +161,7 @@ public void HasObservers_OnCompleted() [Test] public void HasObservers_OnError() { - var s = new BehaviourSignal(42); + var s = new BehaviorSignal(42); Assert.False(s.HasObservers); using var subscription = s.Subscribe(_ => { }, _ => { }); @@ -180,7 +180,7 @@ public void HasObservers_OnError() [Test] public void Value_Initial() { - var s = new BehaviourSignal(InitialValue); + var s = new BehaviorSignal(InitialValue); Assert.Equal(InitialValue, s.Value); Assert.True(s.TryGetValue(out var x)); @@ -193,7 +193,7 @@ public void Value_Initial() [Test] public void Value_First() { - var s = new BehaviourSignal(InitialValue); + var s = new BehaviorSignal(InitialValue); Assert.Equal(InitialValue, s.Value); Assert.True(s.TryGetValue(out var x)); @@ -212,7 +212,7 @@ public void Value_First() [Test] public void Value_Second() { - var s = new BehaviourSignal(InitialValue); + var s = new BehaviorSignal(InitialValue); Assert.Equal(InitialValue, s.Value); Assert.True(s.TryGetValue(out var x)); @@ -237,7 +237,7 @@ public void Value_Second() [Test] public void Value_FrozenAfterOnCompleted() { - var s = new BehaviourSignal(InitialValue); + var s = new BehaviorSignal(InitialValue); Assert.Equal(InitialValue, s.Value); Assert.True(s.TryGetValue(out var x)); @@ -274,7 +274,7 @@ public void Value_FrozenAfterOnCompleted() [Test] public void Value_ThrowsAfterOnError() { - var s = new BehaviourSignal(InitialValue); + var s = new BehaviorSignal(InitialValue); Assert.Equal(InitialValue, s.Value); s.OnError(new InvalidOperationException()); @@ -290,7 +290,7 @@ public void Value_ThrowsAfterOnError() [Test] public void Value_ThrowsOnDispose() { - var s = new BehaviourSignal(InitialValue); + var s = new BehaviorSignal(InitialValue); Assert.Equal(InitialValue, s.Value); s.Dispose(); diff --git a/src/tests/ReactiveUI.Primitives.Tests/CoverageRuntimeTests.cs b/src/tests/ReactiveUI.Primitives.Tests/CoverageRuntimeTests.cs index 5043a32..f0b57bf 100644 --- a/src/tests/ReactiveUI.Primitives.Tests/CoverageRuntimeTests.cs +++ b/src/tests/ReactiveUI.Primitives.Tests/CoverageRuntimeTests.cs @@ -441,6 +441,30 @@ public async Task SequencersCoverValidationAndExecutionBranches() Assert.Throws(() => ThreadPoolSequencer.Instance.Schedule(One, TimeSpan.Zero, null!)); } + /// + /// Covers virtual-time extension validation and action scheduling. + /// + [Test] + public void VirtualTimeSequencerExtensionsValidateAndRunActions() + { + var clock = new TestClock(DateTimeOffset.UnixEpoch); + var invoked = 0; + + Assert.Throws(() => VirtualTimeSequencerExtensions.ScheduleRelative(null!, TimeSpan.Zero, () => { })); + Assert.Throws(() => clock.ScheduleRelative(TimeSpan.Zero, null!)); + Assert.Throws(() => VirtualTimeSequencerExtensions.ScheduleAbsolute(null!, DateTimeOffset.UnixEpoch, () => { })); + Assert.Throws(() => clock.ScheduleAbsolute(DateTimeOffset.UnixEpoch, null!)); + + clock.ScheduleRelative(TimeSpan.FromTicks(One), () => invoked += One); + clock.ScheduleAbsolute(DateTimeOffset.UnixEpoch.AddTicks(Two), () => invoked += Two); + + clock.AdvanceBy(TimeSpan.FromTicks(One)); + Assert.Equal(One, invoked); + + clock.AdvanceBy(TimeSpan.FromTicks(One)); + Assert.Equal(Three, invoked); + } + /// /// Creates an iterator-backed enumerable for the non-indexable enumerable path. /// diff --git a/src/tests/ReactiveUI.Primitives.Tests/FactoryOperatorContractTests.cs b/src/tests/ReactiveUI.Primitives.Tests/FactoryOperatorContractTests.cs index 0658cad..0890a11 100644 --- a/src/tests/ReactiveUI.Primitives.Tests/FactoryOperatorContractTests.cs +++ b/src/tests/ReactiveUI.Primitives.Tests/FactoryOperatorContractTests.cs @@ -7,10 +7,12 @@ using System.Runtime.CompilerServices; using System.Threading; using System.Threading.Tasks; +using ReactiveUI.Primitives; using ReactiveUI.Primitives.Concurrency; using ReactiveUI.Primitives.Core; using ReactiveUI.Primitives.Disposables; using ReactiveUI.Primitives.Signals; +using ReactiveUI.Primitives.Signals.Core; using TUnit.Core; namespace ReactiveUI.Primitives.Tests; @@ -381,16 +383,42 @@ public void CombiningOperatorsPreserveCoreOrderingSemantics() var concatenated = new List(); var zipped = new List(); var latest = new List(); - + var rangeConcatenated = new List(); + var rangeMerged = new List(); + var rangeRace = new List(); + var rangeLatest = new List(); + var rangeWithLatest = new List(); + var rangeForkJoin = new List(); + var rangeObserver = new RecordingObserver(); + + var rangeConcatSignal = Signal.Concat(Signal.Range(FirstValue, SecondValue), Signal.Range(RetrySuccessAttempt, SecondValue)); Signal.Merge(Signal.FromEnumerable(TakeWhileExpected), Signal.FromEnumerable([RetrySuccessAttempt, FourthValue])).Subscribe(merged.Add); Signal.Concat(Signal.FromEnumerable(TakeWhileExpected), Signal.FromEnumerable([RetrySuccessAttempt, FourthValue])).Subscribe(concatenated.Add); Signal.Zip(Signal.FromEnumerable(TakeWhileExpected), Signal.FromEnumerable([ProjectedFirstValue, ProjectedThirdValue]), (left, right) => left + right).Subscribe(zipped.Add); Signal.CombineLatest(Signal.FromEnumerable(TakeWhileExpected), Signal.FromEnumerable(["a", "b"]), (left, right) => left + right).Subscribe(latest.Add); + rangeConcatSignal.Subscribe(rangeConcatenated.Add); + rangeConcatSignal.Subscribe(rangeObserver); + Signal.Merge(Signal.Range(FirstValue, SecondValue), Signal.Range(RetrySuccessAttempt, SecondValue)).Subscribe(rangeMerged.Add); + Signal.Race(Signal.Range(FirstValue, SecondValue), Signal.Range(RetrySuccessAttempt, SecondValue)).Subscribe(rangeRace.Add); + Signal.CombineLatest(Signal.Range(FirstValue, SecondValue), Signal.Range(ProjectionMultiplier, SecondValue), static (left, right) => left + right).Subscribe(rangeLatest.Add); + Signal.Range(FirstValue, SecondValue).WithLatest(Signal.Range(ProjectionMultiplier, SecondValue), static (left, right) => left + right).Subscribe(rangeWithLatest.Add); + Signal.ForkJoin(Signal.Range(FirstValue, SecondValue), Signal.Range(ProjectionMultiplier, SecondValue), static (left, right) => left + right).Subscribe(rangeForkJoin.Add); + Assert.Throws(() => rangeConcatSignal.Subscribe((IObserver)null!)); + Assert.Throws(() => ((IInlineSignal)rangeConcatSignal).Subscribe(null!, _ => { }, () => { })); + Assert.Throws(() => ((IInlineSignal)rangeConcatSignal).Subscribe(_ => { }, _ => { }, null!)); Assert.Equal(FourItemExpected, merged); Assert.Equal(FourItemExpected, concatenated); Assert.Equal(ZippedExpected, zipped); Assert.Equal(LatestExpected, latest); + Assert.Equal(FourItemExpected, rangeConcatenated); + Assert.Equal(FourItemExpected, rangeObserver.Values); + Assert.Equal(1, rangeObserver.Completed); + Assert.Equal(FourItemExpected, rangeMerged); + Assert.Equal(TakeWhileExpected, rangeRace); + Assert.Equal(new[] { ProjectedSecondBucketPeerValue, RangeZipShorterSecondResult }, rangeLatest); + Assert.Equal(new[] { ProjectedSecondBucketPeerValue, RangeZipShorterSecondResult }, rangeWithLatest); + Assert.Equal(new[] { RangeZipShorterSecondResult }, rangeForkJoin); } /// @@ -590,10 +618,65 @@ public async Task TerminalTaskOperatorsCompleteWithExpectedSemantics() var first = await Signal.FromEnumerable([RetrySuccessAttempt, FourthValue]).FirstAsync(); var collected = await Signal.FromEnumerable([FirstValue, SecondValue, RetrySuccessAttempt]).CollectArrayAsync(); var none = await Signal.Empty().FirstOrDefaultAsync(RetryResult); + var rangeFirst = await Signal.Range(FirstValue, FourthValue).FirstAsync(); + var rangeLast = await Signal.Range(FirstValue, FourthValue).ToTask(); + var rangeCollected = await Signal.Range(FirstValue, RetrySuccessAttempt).CollectListAsync(); + var count = await Signal.Range(FirstValue, FourthValue).CountAsync(); + var countEven = await Signal.Range(FirstValue, FourthValue).CountAsync(static value => value % 2 == 0); + var any = await Signal.Range(FirstValue, FourthValue).AnyAsync(static value => value == FourthValue); Assert.Equal(RetrySuccessAttempt, first); Assert.Equal(CollectedExpected, (IEnumerable)collected); Assert.Equal(RetryResult, none); + Assert.Equal(FirstValue, rangeFirst); + Assert.Equal(FourthValue, rangeLast); + Assert.Equal(CollectedExpected, (IEnumerable)rangeCollected); + Assert.Equal(FourthValue, count); + Assert.Equal(SecondValue, countEven); + Assert.True(any); + } + + /// + /// Verifies factory guards, async aliases, and cancellation-aware enumerable conversion. + /// + /// A task that completes when asynchronous assertions finish. + [Test] + public async Task FactoryAliasesAndGuardsCoverParityBranches() + { + var values = new List(); + var errors = new List(); + var completed = 0; + using var cancelled = new CancellationTokenSource(); + await cancelled.CancelAsync(); + + Assert.Throws(() => Signal.Range(FirstValue, -1)); + Assert.Throws(() => Signal.Range(FirstValue, SecondValue, null!)); + Assert.Throws(() => Signal.Repeat(FirstValue, -1)); + Assert.Throws(() => Signal.Unfold(0, null!, static state => state, static state => state)); + Assert.Throws(() => Signal.Unfold(0, static _ => true, null!, static state => state)); + Assert.Throws(() => Signal.Unfold(0, static _ => true, static state => state, null!)); + Assert.Throws(() => Signal.Start((Func)null!)); + Assert.Throws(() => Signal.Start(static () => FirstValue, null!)); + Assert.Throws(() => Signal.Start((Action)null!)); + Assert.Throws(() => Signal.After(TimeSpan.Zero, null!)); + Assert.Throws(() => Signal.Every(TimeSpan.FromTicks(-1))); + Assert.Throws(() => Signal.Timer(TimeSpan.Zero, TimeSpan.Zero, null!)); + Assert.Throws(() => Signal.FromAsync((Func>)null!)); + Assert.Throws(() => Signal.FromAsync((Func>)null!)); + + Signal.Range(FirstValue, 0).Subscribe(values.Add, errors.Add, () => completed++); + Signal.Repeat(FirstValue, 0).Subscribe(values.Add, errors.Add, () => completed++); + new[] { FirstValue, SecondValue }.ToObservable(cancelled.Token).Subscribe(values.Add, errors.Add, () => completed++); + Signal.Start(() => throw new InvalidOperationException("start failed"), Sequencer.Immediate).Subscribe(values.Add, errors.Add, () => completed++); + + var fromAsync = await Signal.FromAsync(() => Task.FromResult(RetryResult)).ToTask(); + var fromAsyncWithToken = await Signal.FromAsync(static token => Task.FromResult(token.IsCancellationRequested ? -1 : RetrySuccessAttempt)).ToTask(); + + Assert.Equal(RetryResult, fromAsync); + Assert.Equal(RetrySuccessAttempt, fromAsyncWithToken); + Assert.Equal(0, values.Count); + Assert.Equal(SecondValue, completed); + Assert.Equal(1, errors.Count); } /// @@ -676,4 +759,30 @@ private static async Task VerifyTaskAliasOperators() Assert.Equal(RepeatValue, first); Assert.Equal(ProjectedSecondValue, started); } + + /// + /// Records observer values and terminal signals. + /// + /// The observed value type. + private sealed class RecordingObserver : IObserver + { + /// + /// Gets observed values. + /// + public List Values { get; } = []; + + /// + /// Gets completion count. + /// + public int Completed { get; private set; } + + /// + public void OnCompleted() => Completed++; + + /// + public void OnError(Exception error) => throw error; + + /// + public void OnNext(T value) => Values.Add(value); + } } diff --git a/src/tests/ReactiveUI.Primitives.Tests/StatefulSharingAndBridgeContractTests.cs b/src/tests/ReactiveUI.Primitives.Tests/StatefulSharingAndBridgeContractTests.cs index c92a47b..e813659 100644 --- a/src/tests/ReactiveUI.Primitives.Tests/StatefulSharingAndBridgeContractTests.cs +++ b/src/tests/ReactiveUI.Primitives.Tests/StatefulSharingAndBridgeContractTests.cs @@ -13,6 +13,7 @@ using Microsoft.CodeAnalysis; using Microsoft.CodeAnalysis.CSharp; using ReactiveUI.Primitives; +using ReactiveUI.Primitives.Disposables; using ReactiveUI.Primitives.R3Bridge.Generator; using ReactiveUI.Primitives.Signals; using ReactiveUI.Primitives.SystemReactiveBridge.Generator; @@ -218,6 +219,96 @@ public async Task CommandSignalPublishesResultsFailuresAndRunningState() Assert.Equal("Command cannot run.", rejected!.Message); } + /// + /// Verifies connectable aliases, auto-connect validation, and replay window overloads. + /// + [Test] + public void ConnectableAliasesValidateAndConnectAtThreshold() + { + var source = new Signal(); + var sourceSubscriptions = 0; + var cold = Signal.Create(observer => + { + sourceSubscriptions++; + return source.Subscribe(observer); + }); + + var auto = cold.Publish().AutoConnect(2); + var first = new List(); + var second = new List(); + using var firstSubscription = auto.Subscribe(first.Add); + source.OnNext(FirstSharedValue); + using var secondSubscription = auto.Subscribe(second.Add); + source.OnNext(SecondSharedValue); + + Assert.Equal(1, sourceSubscriptions); + Assert.Equal(ExpectedSecondSharedValues[1..], first); + Assert.Equal(ExpectedSecondSharedValues[1..], second); + Assert.Throws(() => ConnectableSignalMixins.Multicast(null!, new Signal())); + Assert.Throws(() => Signal.Never().Multicast(null!)); + Assert.Throws(() => ConnectableSignalMixins.RefCount(null!)); + Assert.Throws(() => ConnectableSignalMixins.AutoConnect(null!)); + Assert.Throws(() => cold.PublishLive().AutoConnect(-1)); + + var replayed = cold.Replay(1, TimeSpan.FromSeconds(1)); + using var connection = replayed.Connect(); + source.OnNext(FirstReplayValue); + var replayValues = new List(); + replayed.Subscribe(replayValues.Add); + + Assert.Equal(ExpectedReplayValues[..1], replayValues); + } + + /// + /// Verifies command aliases, sync execution failures, and disposal branches. + /// + /// A task that completes when command assertions finish. + [Test] + public async Task CommandSignalCoversSyncFaultAndDisposalBranches() + { + var behavior = new BehaviorSignal(InitialStateValue); + var disposable = new MultipleDisposable(Disposable.Empty); + var fault = new InvalidOperationException("sync failed"); + var command = new CommandSignal(() => throw fault); + var results = new List(); + var faults = new List(); + + command.Results.Subscribe(results.Add); + command.Faults.Subscribe(faults.Add); + behavior.OnNext(UpdatedStateValue); + disposable.Dispose(); + + InvalidOperationException? observed = null; + try + { + await command.ExecuteAsync(); + } + catch (InvalidOperationException error) + { + observed = error; + } + + command.Dispose(); + command.Dispose(); + ObjectDisposedException? disposed = null; + try + { + await command.ExecuteAsync(); + } + catch (ObjectDisposedException error) + { + disposed = error; + } + + Assert.Same(fault, observed!); + Assert.Equal(0, results.Count); + Assert.Equal(1, faults.Count); + Assert.Same(fault, faults[0]); + Assert.Equal(UpdatedStateValue, behavior.Value); + Assert.True(disposable.IsDisposed); + Assert.NotNull(disposed); + } + /// /// Verifies bridge generators emit adapters when external shapes are present. /// @@ -239,17 +330,50 @@ public static class Observable { } namespace R3 { + public readonly struct Result + { + public static Result Success => default; + + public static Result Failure(Exception exception) => new Result(exception); + + private Result(Exception exception) => Exception = exception; + + public Exception Exception { get; } + + public bool IsFailure => Exception != null; + } + + public abstract class Observer : IDisposable + { + public void OnNext(T value) => OnNextCore(value); + + public void OnErrorResume(Exception error) => OnErrorResumeCore(error); + + public void OnCompleted(Result result) => OnCompletedCore(result); + + public void Dispose() { } + + protected abstract void OnNextCore(T value); + + protected abstract void OnErrorResumeCore(Exception error); + + protected abstract void OnCompletedCore(Result result); + } + public abstract class Observable { - public abstract IDisposable Subscribe(IObserver observer); + public abstract IDisposable Subscribe(Observer observer); + } - public static Observable Create(Func, IDisposable> subscribe) => new DelegateObservable(subscribe); + public static class Observable + { + public static Observable Create(Func, IDisposable> subscribe) => new DelegateObservable(subscribe); private sealed class DelegateObservable : Observable { - private readonly Func, IDisposable> _subscribe; - public DelegateObservable(Func, IDisposable> subscribe) => _subscribe = subscribe; - public override IDisposable Subscribe(IObserver observer) => _subscribe(observer); + private readonly Func, IDisposable> _subscribe; + public DelegateObservable(Func, IDisposable> subscribe) => _subscribe = subscribe; + public override IDisposable Subscribe(Observer observer) => _subscribe(observer); } } } From c1ac51c3d0fba0efa766741c1cc0b3060ff4d774 Mon Sep 17 00:00:00 2001 From: Chris Pulman Date: Tue, 26 May 2026 07:52:55 +0100 Subject: [PATCH 2/8] Extract signal mixins and improve docs Move ConnectableSignalMixins and StateSignalMixins into their own files and remove duplicate implementations from existing files. Improve XML documentation across signal operator mixins (SignalOperatorMixins.cs) and clean up pragma warning disables in several signal files (CommandSignal{TResult}.cs, ReadOnlyState{T}.cs, StateSignal{T}.cs, ConnectableSignal{T}.cs). Add RefCount/AutoConnect gate logic with ConnectableSignalMixins and wire up state projection helper in StateSignalMixins. Miscellaneous small tidy-ups to comments and API contract descriptions. Affects: ConnectableSignalMixins.cs (new), StateSignalMixins.cs (new), ConnectableSignal{T}.cs, CommandSignal{TResult}.cs, ReadOnlyState{T}.cs, StateSignal{T}.cs, SignalOperatorMixins.cs, SignalOperatorParityMixins.Helpers.cs, SignalOperatorParityMixins.cs. --- .../ConnectableSignalMixins.cs | 304 ++++++++++++++ .../ConnectableSignal{T}.cs | 298 -------------- .../Signal/CommandSignal{TResult}.cs | 2 - .../Signal/ReadOnlyState{T}.cs | 47 +-- .../Signal/StateSignalMixins.cs | 44 +++ .../Signal/StateSignal{T}.cs | 2 - .../SignalOperatorMixins.cs | 290 ++++++++++++-- .../SignalOperatorParityMixins.Helpers.cs | 5 +- .../SignalOperatorParityMixins.cs | 371 +++++++++++++++++- 9 files changed, 969 insertions(+), 394 deletions(-) create mode 100644 src/ReactiveUI.Primitives/ConnectableSignalMixins.cs create mode 100644 src/ReactiveUI.Primitives/Signal/StateSignalMixins.cs diff --git a/src/ReactiveUI.Primitives/ConnectableSignalMixins.cs b/src/ReactiveUI.Primitives/ConnectableSignalMixins.cs new file mode 100644 index 0000000..84c160a --- /dev/null +++ b/src/ReactiveUI.Primitives/ConnectableSignalMixins.cs @@ -0,0 +1,304 @@ +// Copyright (c) 2019-2026 ReactiveUI Association Incorporated. All rights reserved. +// ReactiveUI Association Incorporated licenses this file to you under the MIT license. +// See the LICENSE file in the project root for full license information. + +using ReactiveUI.Primitives.Disposables; +using ReactiveUI.Primitives.Signals; + +namespace ReactiveUI.Primitives; + +/// +/// Hot-sharing operators for Primitives connectable signals. +/// +public static class ConnectableSignalMixins +{ + /// + /// Multicasts source values through the supplied hub. + /// + /// The value type. + /// Source sequence to multicast. + /// Hub that receives source values. + /// A connectable signal. + public static ConnectableSignal Multicast(this IObservable source, ISignal hub) + { + if (source == null) + { + throw new ArgumentNullException(nameof(source)); + } + + if (hub == null) + { + throw new ArgumentNullException(nameof(hub)); + } + + return new ConnectableSignal(source, hub); + } + + /// + /// Publishes source values through a live signal hub. + /// + /// The value type. + /// Source sequence to publish. + /// A connectable live signal. + public static ConnectableSignal PublishLive(this IObservable source) => + source.Multicast(new Signal()); + + /// + /// Publishes source values through a live signal hub. + /// + /// The value type. + /// Source sequence to publish. + /// A connectable live signal. + public static ConnectableSignal Publish(this IObservable source) => + source.PublishLive(); + + /// + /// Replays source values through a bounded replay hub. + /// + /// The value type. + /// Source sequence to replay. + /// Maximum number of values to replay. + /// A connectable replay signal. + public static ConnectableSignal ReplayLive(this IObservable source, int bufferSize) => + source.Multicast(new ReplaySignal(bufferSize)); + + /// + /// Replays source values through a replay hub constrained by count and time. + /// + /// The value type. + /// Source sequence to replay. + /// Maximum number of values to replay. + /// Maximum replay window. + /// A connectable replay signal. + public static ConnectableSignal ReplayLive(this IObservable source, int bufferSize, TimeSpan window) => + source.Multicast(new ReplaySignal(bufferSize, window)); + + /// + /// Replays source values through a bounded replay hub. + /// + /// The value type. + /// Source sequence to replay. + /// Maximum number of values to replay. + /// A connectable replay signal. + public static ConnectableSignal Replay(this IObservable source, int bufferSize) => + source.ReplayLive(bufferSize); + + /// + /// Replays source values through a replay hub constrained by count and time. + /// + /// The value type. + /// Source sequence to replay. + /// Maximum number of values to replay. + /// Maximum replay window. + /// A connectable replay signal. + public static ConnectableSignal Replay(this IObservable source, int bufferSize, TimeSpan window) => + source.ReplayLive(bufferSize, window); + + /// + /// Shares one live source subscription while at least one observer is subscribed. + /// + /// The value type. + /// Source sequence to share. + /// A reference-counted live sequence. + public static IObservable ShareLive(this IObservable source) => source.PublishLive().RefCount(); + + /// + /// Shares one live source subscription while at least one observer is subscribed. + /// + /// The value type. + /// Source sequence to share. + /// A reference-counted live sequence. + public static IObservable Share(this IObservable source) => source.ShareLive(); + + /// + /// Connects on first subscriber and disconnects when the last subscriber disposes. + /// + /// The value type. + /// Connectable signal to reference count. + /// A reference-counted sequence. + public static IObservable RefCount(this ConnectableSignal source) + { + if (source == null) + { + throw new ArgumentNullException(nameof(source)); + } + + var gate = RefCountGate.For(source); + return Signal.Create(gate.Subscribe); + } + + /// + /// Connects on the first observer subscription. + /// + /// The value type. + /// Connectable signal to connect. + /// A sequence that connects after the first subscription. + public static IObservable AutoConnect(this ConnectableSignal source) => + AutoConnect(source, 1); + + /// + /// Connects after observers have subscribed. + /// + /// The value type. + /// Connectable signal to connect. + /// Number of observers required before connecting. + /// A sequence that connects after the requested number of subscriptions. + public static IObservable AutoConnect(this ConnectableSignal source, int subscriberCount) + { + if (source == null) + { + throw new ArgumentNullException(nameof(source)); + } + + if (subscriberCount < 0) + { + throw new ArgumentOutOfRangeException(nameof(subscriberCount)); + } + + var gate = AutoConnectGate.For(source, subscriberCount); + return Signal.Create(gate.Subscribe); + } + + /// + /// Tracks reference-counted connection state. + /// + /// The value type. + private sealed class RefCountGate + { + /// + /// Synchronizes reference-count state. + /// + private readonly object _gate = new(); + + /// + /// Connectable signal being reference-counted. + /// + private readonly ConnectableSignal _source; + + /// + /// Active subscriber count. + /// + private int _count; + + /// + /// Active source connection. + /// + private IDisposable? _connection; + + /// + /// Initializes a new instance of the class. + /// + /// Connectable signal being reference-counted. + private RefCountGate(ConnectableSignal source) => _source = source; + + /// + /// Creates a reference-count gate for a connectable signal. + /// + /// Connectable signal being reference-counted. + /// A reference-count gate. + public static RefCountGate For(ConnectableSignal source) => new(source); + + /// + /// Subscribes an observer and manages the shared connection lifetime. + /// + /// Observer to subscribe. + /// A disposable that removes the observer and may disconnect the source. + public IDisposable Subscribe(IObserver observer) + { + IDisposable subscription; + lock (_gate) + { + subscription = _source.Subscribe(observer); + _count++; + _connection ??= _source.Connect(); + } + + return Disposable.Create(() => + { + subscription.Dispose(); + lock (_gate) + { + _count--; + if (_count == 0) + { + _connection?.Dispose(); + _connection = null; + } + } + }); + } + } + + /// + /// Tracks auto-connect subscription state. + /// + /// The value type. + private sealed class AutoConnectGate + { + /// + /// Synchronizes auto-connect state. + /// + private readonly object _gate = new(); + + /// + /// Connectable signal being auto-connected. + /// + private readonly ConnectableSignal _source; + + /// + /// Number of observers required before connecting. + /// + private readonly int _subscriberCount; + + /// + /// Current subscriber count. + /// + private int _count; + + /// + /// Value indicating whether the source has connected. + /// + private bool _connected; + + /// + /// Initializes a new instance of the class. + /// + /// Connectable signal being auto-connected. + /// Number of observers required before connecting. + private AutoConnectGate(ConnectableSignal source, int subscriberCount) + { + _source = source; + _subscriberCount = subscriberCount; + } + + /// + /// Creates an auto-connect gate for a connectable signal. + /// + /// Connectable signal being auto-connected. + /// Number of observers required before connecting. + /// An auto-connect gate. + public static AutoConnectGate For(ConnectableSignal source, int subscriberCount) => + new(source, subscriberCount); + + /// + /// Subscribes an observer and connects when the threshold is reached. + /// + /// Observer to subscribe. + /// A disposable that removes the observer subscription. + public IDisposable Subscribe(IObserver observer) + { + var subscription = _source.Subscribe(observer); + lock (_gate) + { + _count++; + if (!_connected && _count >= _subscriberCount) + { + _connected = true; + _source.Connect(); + } + } + + return subscription; + } + } +} diff --git a/src/ReactiveUI.Primitives/ConnectableSignal{T}.cs b/src/ReactiveUI.Primitives/ConnectableSignal{T}.cs index a0be463..7b9e264 100644 --- a/src/ReactiveUI.Primitives/ConnectableSignal{T}.cs +++ b/src/ReactiveUI.Primitives/ConnectableSignal{T}.cs @@ -5,8 +5,6 @@ using ReactiveUI.Primitives.Disposables; using ReactiveUI.Primitives.Signals; -#pragma warning disable SA1107, SA1116, SA1117, SA1204, SA1402, SA1501, SA1611, SA1615, SA1618 - namespace ReactiveUI.Primitives; /// @@ -74,299 +72,3 @@ public IDisposable Connect() /// public IDisposable Subscribe(IObserver observer) => _hub.Subscribe(observer); } - -/// -/// Hot-sharing operators for Primitives connectable signals. -/// -public static class ConnectableSignalMixins -{ - /// - /// Multicasts source values through the supplied hub. - /// - /// The value type. - /// Source sequence to multicast. - /// Hub that receives source values. - /// A connectable signal. - public static ConnectableSignal Multicast(this IObservable source, ISignal hub) - { - if (source == null) - { - throw new ArgumentNullException(nameof(source)); - } - - if (hub == null) - { - throw new ArgumentNullException(nameof(hub)); - } - - return new ConnectableSignal(source, hub); - } - - /// - /// Publishes source values through a live signal hub. - /// - /// The value type. - /// Source sequence to publish. - /// A connectable live signal. - public static ConnectableSignal PublishLive(this IObservable source) => - source.Multicast(new Signal()); - - /// - /// Publishes source values through a live signal hub. - /// - /// The value type. - /// Source sequence to publish. - /// A connectable live signal. - public static ConnectableSignal Publish(this IObservable source) => - source.PublishLive(); - - /// - /// Replays source values through a bounded replay hub. - /// - /// The value type. - /// Source sequence to replay. - /// Maximum number of values to replay. - /// A connectable replay signal. - public static ConnectableSignal ReplayLive(this IObservable source, int bufferSize) => - source.Multicast(new ReplaySignal(bufferSize)); - - /// - /// Replays source values through a replay hub constrained by count and time. - /// - /// The value type. - /// Source sequence to replay. - /// Maximum number of values to replay. - /// Maximum replay window. - /// A connectable replay signal. - public static ConnectableSignal ReplayLive(this IObservable source, int bufferSize, TimeSpan window) => - source.Multicast(new ReplaySignal(bufferSize, window)); - - /// - /// Replays source values through a bounded replay hub. - /// - /// The value type. - /// Source sequence to replay. - /// Maximum number of values to replay. - /// A connectable replay signal. - public static ConnectableSignal Replay(this IObservable source, int bufferSize) => - source.ReplayLive(bufferSize); - - /// - /// Replays source values through a replay hub constrained by count and time. - /// - /// The value type. - /// Source sequence to replay. - /// Maximum number of values to replay. - /// Maximum replay window. - /// A connectable replay signal. - public static ConnectableSignal Replay(this IObservable source, int bufferSize, TimeSpan window) => - source.ReplayLive(bufferSize, window); - - /// - /// Shares one live source subscription while at least one observer is subscribed. - /// - /// The value type. - /// Source sequence to share. - /// A reference-counted live sequence. - public static IObservable ShareLive(this IObservable source) => source.PublishLive().RefCount(); - - /// - /// Shares one live source subscription while at least one observer is subscribed. - /// - /// The value type. - /// Source sequence to share. - /// A reference-counted live sequence. - public static IObservable Share(this IObservable source) => source.ShareLive(); - - /// - /// Connects on first subscriber and disconnects when the last subscriber disposes. - /// - /// The value type. - /// Connectable signal to reference count. - /// A reference-counted sequence. - public static IObservable RefCount(this ConnectableSignal source) - { - if (source == null) - { - throw new ArgumentNullException(nameof(source)); - } - - var gate = RefCountGate.For(source); - return ReactiveUI.Primitives.Signals.Signal.Create(gate.Subscribe); - } - - /// - /// Connects on the first observer subscription. - /// - /// The value type. - /// Connectable signal to connect. - /// A sequence that connects after the first subscription. - public static IObservable AutoConnect(this ConnectableSignal source) => - AutoConnect(source, 1); - - /// - /// Connects after observers have subscribed. - /// - /// The value type. - /// Connectable signal to connect. - /// Number of observers required before connecting. - /// A sequence that connects after the requested number of subscriptions. - public static IObservable AutoConnect(this ConnectableSignal source, int subscriberCount) - { - if (source == null) - { - throw new ArgumentNullException(nameof(source)); - } - - if (subscriberCount < 0) - { - throw new ArgumentOutOfRangeException(nameof(subscriberCount)); - } - - var gate = AutoConnectGate.For(source, subscriberCount); - return ReactiveUI.Primitives.Signals.Signal.Create(gate.Subscribe); - } - - /// - /// Tracks reference-counted connection state. - /// - /// The value type. - private sealed class RefCountGate - { - /// - /// Synchronizes reference-count state. - /// - private readonly object _gate = new(); - - /// - /// Connectable signal being reference-counted. - /// - private readonly ConnectableSignal _source; - - /// - /// Active subscriber count. - /// - private int _count; - - /// - /// Active source connection. - /// - private IDisposable? _connection; - - /// - /// Initializes a new instance of the class. - /// - /// Connectable signal being reference-counted. - private RefCountGate(ConnectableSignal source) => _source = source; - - /// - /// Creates a reference-count gate for a connectable signal. - /// - /// Connectable signal being reference-counted. - /// A reference-count gate. - public static RefCountGate For(ConnectableSignal source) => new(source); - - /// - /// Subscribes an observer and manages the shared connection lifetime. - /// - /// Observer to subscribe. - /// A disposable that removes the observer and may disconnect the source. - public IDisposable Subscribe(IObserver observer) - { - IDisposable subscription; - lock (_gate) - { - subscription = _source.Subscribe(observer); - _count++; - _connection ??= _source.Connect(); - } - - return Disposable.Create(() => - { - subscription.Dispose(); - lock (_gate) - { - _count--; - if (_count == 0) - { - _connection?.Dispose(); - _connection = null; - } - } - }); - } - } - - /// - /// Tracks auto-connect subscription state. - /// - /// The value type. - private sealed class AutoConnectGate - { - /// - /// Synchronizes auto-connect state. - /// - private readonly object _gate = new(); - - /// - /// Connectable signal being auto-connected. - /// - private readonly ConnectableSignal _source; - - /// - /// Number of observers required before connecting. - /// - private readonly int _subscriberCount; - - /// - /// Current subscriber count. - /// - private int _count; - - /// - /// Value indicating whether the source has connected. - /// - private bool _connected; - - /// - /// Initializes a new instance of the class. - /// - /// Connectable signal being auto-connected. - /// Number of observers required before connecting. - private AutoConnectGate(ConnectableSignal source, int subscriberCount) - { - _source = source; - _subscriberCount = subscriberCount; - } - - /// - /// Creates an auto-connect gate for a connectable signal. - /// - /// Connectable signal being auto-connected. - /// Number of observers required before connecting. - /// An auto-connect gate. - public static AutoConnectGate For(ConnectableSignal source, int subscriberCount) => - new(source, subscriberCount); - - /// - /// Subscribes an observer and connects when the threshold is reached. - /// - /// Observer to subscribe. - /// A disposable that removes the observer subscription. - public IDisposable Subscribe(IObserver observer) - { - var subscription = _source.Subscribe(observer); - lock (_gate) - { - _count++; - if (!_connected && _count >= _subscriberCount) - { - _connected = true; - _source.Connect(); - } - } - - return subscription; - } - } -} diff --git a/src/ReactiveUI.Primitives/Signal/CommandSignal{TResult}.cs b/src/ReactiveUI.Primitives/Signal/CommandSignal{TResult}.cs index 46f9991..f9c79e6 100644 --- a/src/ReactiveUI.Primitives/Signal/CommandSignal{TResult}.cs +++ b/src/ReactiveUI.Primitives/Signal/CommandSignal{TResult}.cs @@ -2,8 +2,6 @@ // ReactiveUI Association Incorporated licenses this file to you under the MIT license. // See the LICENSE file in the project root for full license information. -#pragma warning disable SA1501 - namespace ReactiveUI.Primitives.Signals; /// diff --git a/src/ReactiveUI.Primitives/Signal/ReadOnlyState{T}.cs b/src/ReactiveUI.Primitives/Signal/ReadOnlyState{T}.cs index 65c6008..438d1ce 100644 --- a/src/ReactiveUI.Primitives/Signal/ReadOnlyState{T}.cs +++ b/src/ReactiveUI.Primitives/Signal/ReadOnlyState{T}.cs @@ -2,8 +2,6 @@ // ReactiveUI Association Incorporated licenses this file to you under the MIT license. // See the LICENSE file in the project root for full license information. -#pragma warning disable SA1116, SA1117, SA1204, SA1402, SA1501, SA1611, SA1615, SA1618 - namespace ReactiveUI.Primitives.Signals; /// @@ -49,9 +47,11 @@ public ReadOnlyState(IObservable source, T initialValue) public IObservable Changed => _inner; /// - /// Executes the Subscribe operation. + /// Notifies the provider that an observer is to receive notifications. /// - /// The result. + /// The object that is to receive notifications. + /// A reference to an interface that allows observers to stop receiving notifications before the provider has + /// finished sending them. public IDisposable Subscribe(IObserver observer) => _inner.Subscribe(observer); /// @@ -63,42 +63,3 @@ public void Dispose() _inner.Dispose(); } } - -/// -/// State projection helpers. -/// -public static class StateSignalMixins -{ - /// - /// Projects an observable sequence into a read-only state signal. - /// - /// The source value type. - /// The projected value type. - /// The source sequence. - /// The initial projected value. - /// The projection function. - /// A read-only projected state. - public static ReadOnlyState ToReadOnlyState( - this IObservable source, - TResult initialValue, - Func selector) - { - if (source == null) - { - throw new ArgumentNullException(nameof(source)); - } - - if (selector == null) - { - throw new ArgumentNullException(nameof(selector)); - } - - return new ReadOnlyState( - ReactiveUI.Primitives.Signals.Signal.CreateSafe( - observer => source.Subscribe( - value => observer.OnNext(selector(value)), - observer.OnError, - observer.OnCompleted)), - initialValue); - } -} diff --git a/src/ReactiveUI.Primitives/Signal/StateSignalMixins.cs b/src/ReactiveUI.Primitives/Signal/StateSignalMixins.cs new file mode 100644 index 0000000..b69313d --- /dev/null +++ b/src/ReactiveUI.Primitives/Signal/StateSignalMixins.cs @@ -0,0 +1,44 @@ +// Copyright (c) 2019-2026 ReactiveUI Association Incorporated. All rights reserved. +// ReactiveUI Association Incorporated licenses this file to you under the MIT license. +// See the LICENSE file in the project root for full license information. + +namespace ReactiveUI.Primitives.Signals; + +/// +/// State projection helpers. +/// +public static class StateSignalMixins +{ + /// + /// Projects an observable sequence into a read-only state signal. + /// + /// The source value type. + /// The projected value type. + /// The source sequence. + /// The initial projected value. + /// The projection function. + /// A read-only projected state. + public static ReadOnlyState ToReadOnlyState( + this IObservable source, + TResult initialValue, + Func selector) + { + if (source == null) + { + throw new ArgumentNullException(nameof(source)); + } + + if (selector == null) + { + throw new ArgumentNullException(nameof(selector)); + } + + return new ReadOnlyState( + Signal.CreateSafe( + observer => source.Subscribe( + value => observer.OnNext(selector(value)), + observer.OnError, + observer.OnCompleted)), + initialValue); + } +} diff --git a/src/ReactiveUI.Primitives/Signal/StateSignal{T}.cs b/src/ReactiveUI.Primitives/Signal/StateSignal{T}.cs index 378bd8d..f602245 100644 --- a/src/ReactiveUI.Primitives/Signal/StateSignal{T}.cs +++ b/src/ReactiveUI.Primitives/Signal/StateSignal{T}.cs @@ -4,8 +4,6 @@ using ReactiveUI.Primitives; -#pragma warning disable SA1501 - namespace ReactiveUI.Primitives.Signals; /// diff --git a/src/ReactiveUI.Primitives/SignalOperatorMixins.cs b/src/ReactiveUI.Primitives/SignalOperatorMixins.cs index 3590d11..3e5fbbf 100644 --- a/src/ReactiveUI.Primitives/SignalOperatorMixins.cs +++ b/src/ReactiveUI.Primitives/SignalOperatorMixins.cs @@ -8,8 +8,6 @@ using ReactiveUI.Primitives.Signals; using ReactiveUI.Primitives.Signals.Core; -#pragma warning disable SA1107, SA1116, SA1117, SA1501, SA1611, SA1615, SA1618 - namespace ReactiveUI.Primitives; /// @@ -19,8 +17,15 @@ namespace ReactiveUI.Primitives; public static partial class LinqMixins { /// - /// Maps every value with . + /// Projects each element of an observable sequence into a new form. /// + /// The type of the elements in the source sequence. + /// The type of the elements in the result sequence. + /// An observable sequence of elements to project. + /// A transform function to apply to each element. + /// An observable sequence whose elements are the result of invoking the transform function on each element of the + /// source sequence. + /// or is . public static IObservable Map(this IObservable source, Func selector) { if (source == null) @@ -37,8 +42,18 @@ public static IObservable Map(this IObservable - /// Maps every value with explicit state to avoid closure allocations in hot paths. + /// Projects each element of an observable sequence into a new form by incorporating state that is passed to the + /// selector function. /// + /// The type of the elements in the source sequence. + /// The type of the state used in the selector function. + /// The type of the elements in the result sequence. + /// An observable sequence of elements to project. + /// The state to pass to the selector function. + /// A transform function to apply to each source element along with the state. + /// An observable sequence whose elements are the result of invoking the transform function on each element of the + /// source along with the state. + /// is . public static IObservable MapWith(this IObservable source, TState state, Func selector) { if (selector == null) @@ -50,8 +65,14 @@ public static IObservable MapWith(this IObser } /// - /// Keeps values that satisfy . + /// Filters an observable sequence to include only elements that satisfy a specified condition. /// + /// The type of elements in the observable sequence. + /// The source observable sequence to filter. + /// A function to test each element for a condition. + /// An observable sequence that contains elements from the input sequence that satisfy the condition specified by + /// . + /// or is . public static IObservable Keep(this IObservable source, Func predicate) { if (source == null) @@ -68,8 +89,16 @@ public static IObservable Keep(this IObservable source, Func p } /// - /// Keeps values that satisfy a stateful predicate. + /// Filters elements from an observable sequence based on a predicate that uses external state. /// + /// The type of elements in the source sequence. + /// The type of the state parameter passed to the predicate. + /// The source observable sequence to filter. + /// The state value to pass to the predicate for each element. + /// A function to test each element along with the state; returns to keep the element, to filter it out. + /// An observable sequence containing only the elements from the source sequence that satisfy the predicate. + /// is . public static IObservable KeepWith(this IObservable source, TState state, Func predicate) { if (predicate == null) @@ -81,8 +110,12 @@ public static IObservable KeepWith(this IObservable source, TSt } /// - /// Keeps non-null values and narrows nullable references. + /// Filters out null values from the source observable sequence, emitting only non-null values. /// + /// The type of elements in the observable sequence. + /// The source observable sequence to filter. + /// An observable sequence that emits only non-null values from the source sequence. + /// is null. public static IObservable KeepNotNull(this IObservable source) where T : class { @@ -106,8 +139,12 @@ public static IObservable KeepNotNull(this IObservable source) } /// - /// Projects only values assignable to . + /// Filters values to those assignable to . /// + /// The result value type. + /// The source sequence. + /// A sequence containing only values assignable to . + /// is . [System.Diagnostics.CodeAnalysis.SuppressMessage( "Major Code Smell", "S4018:Generic methods should provide type parameters", @@ -134,8 +171,12 @@ public static IObservable OfType(this IObservable sou } /// - /// Casts every value to . + /// Casts each source value to . /// + /// The result value type. + /// The source sequence. + /// A sequence containing each value cast to . + /// is . [System.Diagnostics.CodeAnalysis.SuppressMessage( "Major Code Smell", "S4018:Generic methods should provide type parameters", @@ -151,8 +192,13 @@ public static IObservable Cast(this IObservable sourc } /// - /// Runs a side effect for every value while preserving the source values. + /// Invokes an action for each value while preserving the original sequence. /// + /// The value type. + /// The source sequence. + /// The action to invoke for each value. + /// The source values after the action has run. + /// is . public static IObservable Tap(this IObservable source, Action onNext) { if (onNext == null) @@ -168,8 +214,15 @@ public static IObservable Tap(this IObservable source, Action onNext } /// - /// Runs a stateful side effect for every value while preserving the source values. + /// Invokes a stateful action for each value while preserving the original sequence. /// + /// The value type. + /// The state type. + /// The source sequence. + /// The state passed to . + /// The action to invoke for each value. + /// The source values after the action has run. + /// is . public static IObservable TapWith(this IObservable source, TState state, Action onNext) { if (onNext == null) @@ -181,8 +234,15 @@ public static IObservable TapWith(this IObservable source, TSta } /// - /// Emits accumulated state for every source value. + /// Emits the accumulated state after each source value. /// + /// The source value type. + /// The accumulated value type. + /// The source sequence. + /// The initial accumulated value. + /// The function that combines the current state with the next source value. + /// A sequence of intermediate accumulated values. + /// or is . public static IObservable Scan(this IObservable source, TAccumulate seed, Func accumulator) { if (source == null) @@ -210,8 +270,15 @@ public static IObservable Scan(this IObservab } /// - /// Emits one final accumulated value when the source completes. + /// Emits the final accumulated state when the source completes. /// + /// The source value type. + /// The accumulated value type. + /// The source sequence. + /// The initial accumulated value. + /// The function that combines the current state with the next source value. + /// A sequence that emits one accumulated value on completion. + /// or is . public static IObservable Fold(this IObservable source, TAccumulate seed, Func accumulator) { if (source == null) @@ -241,6 +308,12 @@ public static IObservable Fold(this IObservab /// /// Emits at most values before completing. /// + /// The value type. + /// The source sequence. + /// The maximum number of values to emit. + /// A sequence containing at most source values. + /// is . + /// is less than zero. public static IObservable Take(this IObservable source, int count) { if (source == null) @@ -285,8 +358,14 @@ public static IObservable Take(this IObservable source, int count) } /// - /// Skips values. + /// Skips the first source values. /// + /// The value type. + /// The source sequence. + /// The number of values to skip. + /// A sequence containing source values after the skipped prefix. + /// is . + /// is less than zero. public static IObservable Skip(this IObservable source, int count) { if (source == null) @@ -319,14 +398,23 @@ public static IObservable Skip(this IObservable source, int count) } /// - /// Suppresses duplicate values according to the comparer. + /// Suppresses values that have already been observed. /// + /// The value type. + /// The source sequence. + /// A sequence containing the first occurrence of each source value. + /// is . public static IObservable Distinct(this IObservable source) => source.Distinct(null); /// - /// Suppresses duplicate values according to the comparer. + /// Suppresses values that have already been observed using the supplied comparer. /// + /// The value type. + /// The source sequence. + /// The comparer used to identify duplicate values. + /// A sequence containing the first occurrence of each source value. + /// is . public static IObservable Distinct(this IObservable source, IEqualityComparer? comparer) { if (source == null) @@ -353,14 +441,23 @@ public static IObservable Distinct(this IObservable source, IEqualityCo } /// - /// Suppresses adjacent duplicate values according to the comparer. + /// Suppresses adjacent duplicate values. /// + /// The value type. + /// The source sequence. + /// A sequence with adjacent duplicates removed. + /// is . public static IObservable DistinctUntilChanged(this IObservable source) => source.DistinctUntilChanged(null); /// - /// Suppresses adjacent duplicate values according to the comparer. + /// Suppresses adjacent duplicate values using the supplied comparer. /// + /// The value type. + /// The source sequence. + /// The comparer used to compare adjacent values. + /// A sequence with adjacent duplicates removed. + /// is . public static IObservable DistinctUntilChanged(this IObservable source, IEqualityComparer? comparer) { if (source == null) @@ -391,8 +488,12 @@ public static IObservable DistinctUntilChanged(this IObservable source, } /// - /// Converts values and terminal messages into sparks. + /// Converts source values and terminal notifications into values. /// + /// The value type. + /// The source sequence. + /// A sequence of spark values representing source notifications; terminal sparks are followed by completion. + /// is . public static IObservable> Sparkify(this IObservable source) { if (source == null) @@ -415,8 +516,12 @@ public static IObservable> Sparkify(this IObservable source) } /// - /// Converts spark values back into source notifications. + /// Converts values back into observer notifications. /// + /// The value type. + /// The spark sequence. + /// A sequence represented by the supplied spark values. + /// is . public static IObservable Unspark(this IObservable> source) { if (source == null) @@ -431,8 +536,12 @@ public static IObservable Unspark(this IObservable> source) } /// - /// Concatenates a signal of signals. + /// Subscribes to inner sequences one at a time in source order. /// + /// The value type. + /// The outer sequence of inner sequences. + /// A sequence that emits each inner sequence after the previous one completes. + /// is . public static IObservable Concat(this IObservable> sources) { if (sources == null) @@ -521,14 +630,22 @@ void Drain() } /// - /// Concatenates this signal followed by . + /// Concatenates two sequences. /// + /// The value type. + /// The first sequence. + /// The second sequence. + /// A sequence that emits after completes. public static IObservable Concat(this IObservable first, IObservable second) => Signal.Concat(first, second); /// - /// Merges a signal of signals. + /// Subscribes to all inner sequences and forwards their values as they arrive. /// + /// The value type. + /// The outer sequence of inner sequences. + /// A sequence containing values from all inner sequences. + /// is . public static IObservable Merge(this IObservable> sources) { if (sources == null) @@ -597,8 +714,12 @@ void TryComplete() } /// - /// Races the supplied source signals and mirrors the first source to emit any notification. + /// Mirrors the first inner sequence to produce any notification. /// + /// The value type. + /// The competing inner sequences. + /// A sequence that mirrors the winning inner sequence. + /// is . public static IObservable Race(this IObservable> sources) { if (sources == null) @@ -610,8 +731,16 @@ public static IObservable Race(this IObservable> sources) } /// - /// Zips two signals by waiting for one value from both sides. + /// Combines paired values from two sequences, completing when no more pairs can be formed. /// + /// The left value type. + /// The right value type. + /// The result value type. + /// The left sequence. + /// The right sequence. + /// The function that combines paired values. + /// A sequence containing one result for each available value pair. + /// , , or is . public static IObservable Zip(this IObservable left, IObservable right, Func selector) { if (left == null) @@ -638,8 +767,16 @@ public static IObservable Zip(this IObservable< } /// - /// Combines the latest values after both sides have produced at least one value. + /// Combines the latest values after both sequences have produced at least one value. /// + /// The left value type. + /// The right value type. + /// The result value type. + /// The left sequence. + /// The right sequence. + /// The function that combines the latest values. + /// A sequence containing selected latest-value combinations. + /// , , or is . public static IObservable CombineLatest(this IObservable left, IObservable right, Func selector) { if (left == null) @@ -666,8 +803,17 @@ public static IObservable CombineLatest(this IO } /// - /// Combines each left value with the latest right value after the right side has produced one value. + /// Combines each left value with the latest right value after the right sequence has produced a value. /// + /// The left value type. + /// The right value type. + /// The result value type. + /// The triggering sequence. + /// The sequence that supplies the latest value. + /// The function that combines the left value with the latest right value. + /// A sequence containing selected left/latest-right combinations. + /// Left values produced before the first right value are ignored. + /// , , or is . public static IObservable WithLatest(this IObservable left, IObservable right, Func selector) { if (left == null) @@ -729,8 +875,12 @@ public static IObservable WithLatest(this IObse } /// - /// Switches to the most recent inner signal. + /// Switches to the most recent inner sequence. /// + /// The value type. + /// The outer sequence of inner sequences. + /// A sequence that mirrors only the latest inner sequence. + /// is . public static IObservable Switch(this IObservable> sources) { if (sources == null) @@ -742,8 +892,14 @@ public static IObservable Switch(this IObservable> sources) } /// - /// Retries the source up to times after failures. + /// Resubscribes to the source after an error up to times. /// + /// The value type. + /// The source sequence. + /// The maximum number of retry attempts after the initial subscription. + /// A sequence that retries the source before forwarding the final error. + /// is . + /// is less than zero. public static IObservable Retry(this IObservable source, int retryCount) { if (source == null) @@ -786,14 +942,24 @@ void SubscribeNext() } /// - /// Recovers from errors by switching to a handler-provided signal. + /// Recovers from errors by switching to a handler-provided sequence. /// + /// The value type. + /// The source sequence. + /// The function that creates the recovery sequence for an error. + /// A sequence that continues with the handler result after an error. + /// or is . public static IObservable Rescue(this IObservable source, Func> handler) => source.Catch(handler); /// - /// Continues with a fallback signal after an error. + /// Continues with a fallback sequence after an error. /// + /// The value type. + /// The source sequence. + /// The sequence to subscribe to after an error. + /// A sequence that resumes with after an error. + /// or is . public static IObservable Resume(this IObservable source, IObservable fallback) { if (fallback == null) @@ -805,14 +971,23 @@ public static IObservable Resume(this IObservable source, IObservable - /// Delays notifications by . + /// Delays source notifications by the specified duration. /// + /// The value type. + /// The source sequence. + /// The delay applied to each notification. + /// A sequence that forwards source notifications after the delay. public static IObservable Delay(this IObservable source, TimeSpan dueTime) => source.Delay(dueTime, null); /// - /// Delays notifications by . + /// Delays source notifications by the specified duration on a sequencer. /// + /// The value type. + /// The source sequence. + /// The delay applied to each notification. + /// The sequencer used to schedule delayed notifications. + /// A sequence that forwards source notifications after the delay. public static IObservable Delay(this IObservable source, TimeSpan dueTime, ISequencer? scheduler) { if (source == null) @@ -835,14 +1010,23 @@ public static IObservable Delay(this IObservable source, TimeSpan dueTi } /// - /// Fails the signal if no terminal signal arrives before the timeout. + /// Fails the sequence if it does not terminate before the timeout. /// + /// The value type. + /// The source sequence. + /// The timeout duration. + /// A sequence that errors with when the timeout elapses first. public static IObservable Timeout(this IObservable source, TimeSpan dueTime) => source.Timeout(dueTime, null); /// - /// Fails the signal if no terminal signal arrives before the timeout. + /// Fails the sequence if it does not terminate before the sequencer timeout. /// + /// The value type. + /// The source sequence. + /// The timeout duration. + /// The sequencer used to schedule the timeout. + /// A sequence that errors with when the timeout elapses first. public static IObservable Timeout(this IObservable source, TimeSpan dueTime, ISequencer? scheduler) { if (source == null) @@ -900,6 +1084,9 @@ public static IObservable Timeout(this IObservable source, TimeSpan due /// /// Collects all values into a list when the source completes. /// + /// The value type. + /// The source sequence. + /// A sequence that emits one list containing all source values. public static IObservable> CollectList(this IObservable source) { if (source == null) @@ -929,6 +1116,9 @@ public static IObservable> CollectList(this IObservable source) /// /// Collects all values into an array when the source completes. /// + /// The value type. + /// The source sequence. + /// A sequence that emits one array containing all source values. public static IObservable CollectArray(this IObservable source) { if (source == null) @@ -956,24 +1146,39 @@ public static IObservable CollectArray(this IObservable source) } /// - /// Converts an enumerable to a signal. + /// Converts an enumerable sequence to a signal. /// + /// The value type. + /// The values to enumerate. + /// A signal that emits the enumerable values. public static IObservable ToSignal(this IEnumerable values) => Signal.FromEnumerable(values); /// - /// Converts an enumerable to a signal and stops enumeration when cancelled. + /// Converts an enumerable sequence to a signal that observes cancellation. /// + /// The value type. + /// The values to enumerate. + /// The token used to stop enumeration. + /// A signal that emits the enumerable values until enumeration completes or cancellation is requested. public static IObservable ToSignal(this IEnumerable values, CancellationToken cancellationToken) => Signal.FromEnumerable(values, cancellationToken); /// - /// Converts an observable to a signal-compatible observable. + /// Returns an observable sequence as a signal-compatible observable. /// + /// The value type. + /// The source sequence. + /// The supplied source sequence. public static IObservable ToSignal(this IObservable source) => source ?? throw new ArgumentNullException(nameof(source)); /// - /// Creates a combine-latest range signal without coordinator subscriptions. + /// Creates the optimized range-backed combine-latest sequence. /// + /// The result value type. + /// The left range source. + /// The right range source. + /// The function that combines range values. + /// The optimized combine-latest sequence. private static IObservable CreateRangeCombineLatestSignal( RangeSignal left, RangeSignal right, @@ -991,8 +1196,13 @@ private static IObservable CreateRangeCombineLatestSignal( }); /// - /// Creates a with-latest range signal without coordinator subscriptions. + /// Creates the optimized range-backed with-latest sequence. /// + /// The result value type. + /// The left range source. + /// The right range source. + /// The function that combines range values. + /// The optimized with-latest sequence. private static IObservable CreateRangeWithLatestSignal( RangeSignal left, RangeSignal right, diff --git a/src/ReactiveUI.Primitives/SignalOperatorParityMixins.Helpers.cs b/src/ReactiveUI.Primitives/SignalOperatorParityMixins.Helpers.cs index 1cce3bc..e0f98cf 100644 --- a/src/ReactiveUI.Primitives/SignalOperatorParityMixins.Helpers.cs +++ b/src/ReactiveUI.Primitives/SignalOperatorParityMixins.Helpers.cs @@ -5,8 +5,6 @@ using ReactiveUI.Primitives.Concurrency; using ReactiveUI.Primitives.Disposables; -#pragma warning disable SA1107, SA1116, SA1117, SA1501, SA1611, SA1615, SA1618 - namespace ReactiveUI.Primitives; /// @@ -199,8 +197,9 @@ public IDisposable Subscribe(IObserver observer) } /// - /// Shared disposable sink for single-source terminal operators. + /// Abstract base class for observers that manage a single upstream subscription. /// + /// The type of elements observed. private abstract class SingleSourceObserver : IObserver, IDisposable { /// diff --git a/src/ReactiveUI.Primitives/SignalOperatorParityMixins.cs b/src/ReactiveUI.Primitives/SignalOperatorParityMixins.cs index 9aab167..2416280 100644 --- a/src/ReactiveUI.Primitives/SignalOperatorParityMixins.cs +++ b/src/ReactiveUI.Primitives/SignalOperatorParityMixins.cs @@ -8,8 +8,6 @@ using ReactiveUI.Primitives.Signals; using ReactiveUI.Primitives.Signals.Core; -#pragma warning disable SA1107, SA1116, SA1117, SA1501, SA1611, SA1615, SA1618 - namespace ReactiveUI.Primitives; /// @@ -20,11 +18,21 @@ public static partial class LinqMixins /// /// Prepends a value before the source sequence. Alias of using Primitives vocabulary. /// + /// The value type. + /// The source sequence. + /// The value to emit before the source. + /// A sequence that emits before the source values. + /// is . public static IObservable Lead(this IObservable source, T value) => source.Prepend(value); /// /// Prepends a value before the source sequence. /// + /// The value type. + /// The source sequence. + /// The value to emit before the source. + /// A sequence that emits before the source values. + /// is . public static IObservable Prepend(this IObservable source, T value) { if (source == null) @@ -38,6 +46,11 @@ public static IObservable Prepend(this IObservable source, T value) /// /// Appends a value after the source sequence completes. /// + /// The value type. + /// The source sequence. + /// The value to emit after the source completes. + /// A sequence that emits the source values followed by . + /// is . public static IObservable Append(this IObservable source, T value) { if (source == null) @@ -51,11 +64,21 @@ public static IObservable Append(this IObservable source, T value) /// /// Prepends a value before the source sequence using the System.Reactive operator name. /// + /// The value type. + /// The source sequence. + /// The value to emit before the source. + /// A sequence that emits before the source values. + /// is . public static IObservable StartWith(this IObservable source, T value) => source.Prepend(value); /// /// Prepends values before the source sequence using the System.Reactive operator name. /// + /// The value type. + /// The source sequence. + /// The values to emit before the source. + /// A sequence that emits before the source values. + /// or is . public static IObservable StartWith(this IObservable source, params T[] values) { if (source == null) @@ -74,6 +97,11 @@ public static IObservable StartWith(this IObservable source, params T[] /// /// Prepends values before the source sequence using the System.Reactive operator name. /// + /// The value type. + /// The source sequence. + /// The values to emit before the source. + /// A sequence that emits before the source values. + /// or is . public static IObservable StartWith(this IObservable source, IEnumerable values) { if (source == null) @@ -92,22 +120,40 @@ public static IObservable StartWith(this IObservable source, IEnumerabl /// /// Returns the source as an observable. This is an identity adapter for BCL observable sources. /// + /// The value type. + /// The source sequence. + /// The supplied source sequence. + /// is . public static IObservable AsObservable(this IObservable source) => source ?? throw new ArgumentNullException(nameof(source)); /// /// Converts an enumerable sequence to a Primitives signal using the System.Reactive conversion name. /// + /// The value type. + /// The values to enumerate. + /// A signal that emits the enumerable values. + /// is . public static IObservable ToObservable(this IEnumerable values) => Signal.FromEnumerable(values); /// /// Converts an enumerable sequence to a Primitives signal using the System.Reactive conversion name. /// + /// The value type. + /// The values to enumerate. + /// The token used to stop enumeration. + /// A signal that emits the enumerable values until enumeration completes or cancellation is requested. + /// is . public static IObservable ToObservable(this IEnumerable values, CancellationToken cancellationToken) => Signal.FromEnumerable(values, cancellationToken); /// /// Schedules observer notifications on the supplied scheduler using the System.Reactive operator name. /// + /// The value type. + /// The source sequence. + /// The sequencer used to deliver observer notifications. + /// The source sequence when is immediate; otherwise a sequence observed on the sequencer. + /// or is . public static IObservable ObserveOn(this IObservable source, ISequencer scheduler) { if (source == null) @@ -131,23 +177,48 @@ public static IObservable ObserveOn(this IObservable source, ISequencer /// /// Alias for using the System.Reactive operator name. /// + /// The value type. + /// The source sequence. + /// The delay before subscribing to the source. + /// A sequence that subscribes to the source after . + /// is . public static IObservable DelaySubscription(this IObservable source, TimeSpan dueTime) => source.DelayStart(dueTime, null); /// /// Alias for using the System.Reactive operator name. /// + /// The value type. + /// The source sequence. + /// The delay before subscribing to the source. + /// The sequencer used to schedule the delayed subscription. + /// A sequence that subscribes to the source after . + /// is . public static IObservable DelaySubscription(this IObservable source, TimeSpan dueTime, ISequencer? scheduler) => source.DelayStart(dueTime, scheduler); /// /// Runs a side effect for each source value using the System.Reactive operator name. /// + /// The value type. + /// The source sequence. + /// The action to invoke for each value. + /// The source values after runs. + /// is . public static IObservable Do(this IObservable source, Action onNext) => source.Tap(onNext); /// - /// Runs side effects for source notifications using the System.Reactive operator name. + /// Invokes actions for each element in the observable sequence, for error notifications, and for successful + /// completion. /// + /// The type of the elements in the source sequence. + /// The source sequence. + /// Action to invoke for each element in the observable sequence. + /// Action to invoke upon exceptional termination of the observable sequence. + /// Action to invoke upon graceful termination of the observable sequence. + /// The source sequence with the side-effecting behavior applied. + /// , , , or is . public static IObservable Do(this IObservable source, Action onNext, Action onError, Action onCompleted) { if (source == null) @@ -191,12 +262,21 @@ public static IObservable Do(this IObservable source, Action onNext, /// /// Alias for using the System.Reactive operator name. /// + /// The value type. + /// The source sequence. + /// The function that creates the recovery sequence for an error. + /// A sequence that continues with the handler result after an error. + /// or is . public static IObservable Catch(this IObservable source, Func> handler) => source.Rescue(handler); /// /// Ignores all source values and only forwards terminal messages. /// + /// The value type. + /// The source sequence. + /// A sequence that forwards only error and completion notifications. + /// is . public static IObservable IgnoreValues(this IObservable source) { if (source == null) @@ -210,12 +290,21 @@ public static IObservable IgnoreValues(this IObservable source) /// /// Emits the supplied value if the source completes without values. /// + /// The value type. + /// The source sequence. + /// A sequence that emits when the source is empty. + /// is . public static IObservable DefaultIfEmpty(this IObservable source) => source.DefaultIfEmpty(default!); /// /// Emits the supplied value if the source completes without values. /// + /// The value type. + /// The source sequence. + /// The value to emit when the source is empty. + /// A sequence that emits when the source is empty. + /// is . public static IObservable DefaultIfEmpty(this IObservable source, T defaultValue) { if (source == null) @@ -229,12 +318,25 @@ public static IObservable DefaultIfEmpty(this IObservable source, T def /// /// Suppresses duplicate keys according to the comparer. /// + /// The value type. + /// The key type. + /// The source sequence. + /// The function that selects the comparison key. + /// A sequence containing the first value for each observed key. + /// or is . public static IObservable DistinctBy(this IObservable source, Func keySelector) => source.DistinctBy(keySelector, null); /// /// Suppresses duplicate keys according to the comparer. /// + /// The value type. + /// The key type. + /// The source sequence. + /// The function that selects the comparison key. + /// The comparer used to identify duplicate keys. + /// A sequence containing the first value for each observed key. + /// or is . public static IObservable DistinctBy(this IObservable source, Func keySelector, IEqualityComparer? comparer) { if (source == null) @@ -253,12 +355,25 @@ public static IObservable DistinctBy(this IObservable source, Fun /// /// Suppresses adjacent duplicate keys according to the comparer. /// + /// The value type. + /// The key type. + /// The source sequence. + /// The function that selects the comparison key. + /// A sequence with adjacent duplicate keys removed. + /// or is . public static IObservable DistinctUntilChangedBy(this IObservable source, Func keySelector) => source.DistinctUntilChangedBy(keySelector, null); /// /// Suppresses adjacent duplicate keys according to the comparer. /// + /// The value type. + /// The key type. + /// The source sequence. + /// The function that selects the comparison key. + /// The comparer used to compare adjacent keys. + /// A sequence with adjacent duplicate keys removed. + /// or is . public static IObservable DistinctUntilChangedBy(this IObservable source, Func keySelector, IEqualityComparer? comparer) { if (source == null) @@ -297,6 +412,11 @@ public static IObservable DistinctUntilChangedBy(this IObservable /// /// Emits values while the predicate remains true, then completes. /// + /// The value type. + /// The source sequence. + /// The function that determines whether to keep taking values. + /// A sequence that emits the leading values that satisfy . + /// or is . public static IObservable TakeWhile(this IObservable source, Func predicate) { if (source == null) @@ -338,6 +458,11 @@ public static IObservable TakeWhile(this IObservable source, Func /// Skips values while the predicate remains true, then mirrors the remaining source. /// + /// The value type. + /// The source sequence. + /// The function that determines whether to keep skipping values. + /// A sequence that emits values after the leading values that satisfy . + /// or is . public static IObservable SkipWhile(this IObservable source, Func predicate) { if (source == null) @@ -372,11 +497,23 @@ public static IObservable SkipWhile(this IObservable source, Func /// Projects each source value to an inner signal and concatenates all inner values. /// + /// The source value type. + /// The result value type. + /// The source sequence. + /// The function that projects each source value to an inner sequence. + /// A sequence containing the concatenated inner values. + /// or is . public static IObservable Bind(this IObservable source, Func> selector) => source.SelectMany(selector); /// /// Projects each source value to an inner signal and concatenates all inner values. /// + /// The source value type. + /// The result value type. + /// The source sequence. + /// The function that projects each source value to an inner sequence. + /// A sequence containing the concatenated inner values. + /// or is . public static IObservable SelectMany(this IObservable source, Func> selector) { if (source == null) @@ -395,6 +532,14 @@ public static IObservable SelectMany(this IObservable /// /// Projects each source value to an inner signal and maps outer/inner values with a result selector. /// + /// The source value type. + /// The inner value type. + /// The result value type. + /// The source sequence. + /// The function that projects each source value to an inner sequence. + /// The function that combines source and inner values. + /// A sequence containing selected outer/inner combinations. + /// or is . public static IObservable SelectMany( this IObservable source, Func> collectionSelector, @@ -416,6 +561,10 @@ public static IObservable SelectMany( /// /// Counts the source values as an . /// + /// The value type. + /// The source sequence. + /// A sequence that emits the number of source values when the source completes. + /// is . public static IObservable Count(this IObservable source) { if (source == null) @@ -429,6 +578,11 @@ public static IObservable Count(this IObservable source) /// /// Counts source values that satisfy the predicate as an . /// + /// The value type. + /// The source sequence. + /// The function that identifies values to count. + /// A sequence that emits the matching value count when the source completes. + /// or is . public static IObservable Count(this IObservable source, Func predicate) { if (predicate == null) @@ -447,6 +601,10 @@ public static IObservable Count(this IObservable source, Func /// Counts the source values as an . /// + /// The value type. + /// The source sequence. + /// A sequence that emits the number of source values when the source completes. + /// is . public static IObservable LongCount(this IObservable source) { if (source == null) @@ -460,6 +618,11 @@ public static IObservable LongCount(this IObservable source) /// /// Counts source values that satisfy the predicate as an . /// + /// The value type. + /// The source sequence. + /// The function that identifies values to count. + /// A sequence that emits the matching value count when the source completes. + /// or is . public static IObservable LongCount(this IObservable source, Func predicate) { if (predicate == null) @@ -478,6 +641,10 @@ public static IObservable LongCount(this IObservable source, Func /// Emits true when any value is present. /// + /// The value type. + /// The source sequence. + /// A sequence that emits whether the source produced any values. + /// is . public static IObservable Any(this IObservable source) { if (source == null) @@ -491,6 +658,11 @@ public static IObservable Any(this IObservable source) /// /// Emits true when any value satisfies the predicate. /// + /// The value type. + /// The source sequence. + /// The function that tests each value. + /// A sequence that emits whether any source value satisfies . + /// or is . public static IObservable Any(this IObservable source, Func predicate) { if (source == null) @@ -509,6 +681,11 @@ public static IObservable Any(this IObservable source, Func /// /// Emits true when every value satisfies the predicate. /// + /// The value type. + /// The source sequence. + /// The function that tests each value. + /// A sequence that emits whether every source value satisfies . + /// or is . public static IObservable All(this IObservable source, Func predicate) { if (source == null) @@ -553,12 +730,23 @@ public static IObservable All(this IObservable source, Func /// /// Emits true when the source contains the requested value. /// + /// The value type. + /// The source sequence. + /// The value to locate. + /// A sequence that emits whether the source contains . + /// is . public static IObservable Contains(this IObservable source, T value) => source.Contains(value, null); /// /// Emits true when the source contains the requested value. /// + /// The value type. + /// The source sequence. + /// The value to locate. + /// The comparer used to compare source values. + /// A sequence that emits whether the source contains . + /// is . public static IObservable Contains(this IObservable source, T value, IEqualityComparer? comparer) { comparer ??= EqualityComparer.Default; @@ -568,17 +756,32 @@ public static IObservable Contains(this IObservable source, T value, /// /// Emits true when the source completes without values. /// + /// The value type. + /// The source sequence. + /// A sequence that emits whether the source completed without values. + /// is . public static IObservable IsEmpty(this IObservable source) => source.Any().Map(hasValue => !hasValue); /// /// Emits values from source after delaying subscription by the due time. /// + /// The value type. + /// The source sequence. + /// The delay before subscribing to the source. + /// A sequence that subscribes to the source after . + /// is . public static IObservable DelayStart(this IObservable source, TimeSpan dueTime) => source.DelayStart(dueTime, null); /// /// Emits values from source after delaying subscription by the due time. /// + /// The value type. + /// The source sequence. + /// The delay before subscribing to the source. + /// The sequencer used to schedule the delayed subscription. + /// A sequence that subscribes to the source after . + /// is . public static IObservable DelayStart(this IObservable source, TimeSpan dueTime, ISequencer? scheduler) { if (source == null) @@ -598,12 +801,23 @@ public static IObservable DelayStart(this IObservable source, TimeSpan /// /// Emits only the most recent value after the quiet period elapses. /// + /// The value type. + /// The source sequence. + /// The quiet period before emitting the latest value. + /// A sequence that emits the latest value after each quiet period. + /// is . public static IObservable Throttle(this IObservable source, TimeSpan dueTime) => source.Throttle(dueTime, null); /// /// Emits only the most recent value after the quiet period elapses. /// + /// The value type. + /// The source sequence. + /// The quiet period before emitting the latest value. + /// The sequencer used to schedule quiet-period timers. + /// A sequence that emits the latest value after each quiet period. + /// is . public static IObservable Throttle(this IObservable source, TimeSpan dueTime, ISequencer? scheduler) { if (source == null) @@ -650,12 +864,25 @@ public static IObservable Throttle(this IObservable source, TimeSpan du /// /// Emits the latest source value whenever the sampling period ticks. /// + /// The value type. + /// The source sequence. + /// The interval between sampling ticks. + /// A sequence that emits the latest source value on each sampling tick. + /// is . + /// is less than . public static IObservable Sample(this IObservable source, TimeSpan period) => source.Sample(period, null); /// /// Emits the latest source value whenever the sampling period ticks. /// + /// The value type. + /// The source sequence. + /// The interval between sampling ticks. + /// The sequencer used to schedule sampling ticks. + /// A sequence that emits the latest source value on each sampling tick. + /// is . + /// is less than . public static IObservable Sample(this IObservable source, TimeSpan period, ISequencer? scheduler) { if (source == null) @@ -677,12 +904,21 @@ public static IObservable Sample(this IObservable source, TimeSpan peri /// /// Annotates values with their scheduler timestamp. /// + /// The value type. + /// The source sequence. + /// A sequence containing each value with its timestamp. + /// is . public static IObservable> Timestamp(this IObservable source) => source.Timestamp(null); /// /// Annotates values with their scheduler timestamp. /// + /// The value type. + /// The source sequence. + /// The sequencer that supplies timestamps. + /// A sequence containing each value with its timestamp. + /// is . public static IObservable> Timestamp(this IObservable source, ISequencer? scheduler) { if (source == null) @@ -697,12 +933,21 @@ public static IObservable> Timestamp(this IObservable source, IS /// /// Annotates each value with the elapsed scheduler time since the previous value. /// + /// The value type. + /// The source sequence. + /// A sequence containing each value with its elapsed interval since the previous value. + /// is . public static IObservable> TimeInterval(this IObservable source) => source.TimeInterval(null); /// /// Annotates each value with the elapsed scheduler time since the previous value. /// + /// The value type. + /// The source sequence. + /// The sequencer that supplies timestamps. + /// A sequence containing each value with its elapsed interval since the previous value. + /// is . public static IObservable> TimeInterval(this IObservable source, ISequencer? scheduler) { if (source == null) @@ -732,18 +977,42 @@ public static IObservable> TimeInterval(this IObservable s /// /// Combines latest values from both sources. Alias for latest-fusion vocabulary. /// + /// The left value type. + /// The right value type. + /// The result value type. + /// The left sequence. + /// The right sequence. + /// The function that combines the latest values. + /// A sequence containing selected latest-value combinations. + /// , , or is . public static IObservable ZipLatest(this IObservable left, IObservable right, Func selector) => left.CombineLatest(right, selector); /// /// Alias for . /// + /// The left value type. + /// The right value type. + /// The result value type. + /// The left sequence. + /// The right sequence. + /// The function that combines the latest values. + /// A sequence containing selected latest-value combinations. + /// , , or is . public static IObservable FuseLatest(this IObservable left, IObservable right, Func selector) => left.ZipLatest(right, selector); /// /// Waits for both sources to complete and emits one value from their last elements when both produced at least one value. /// + /// The left value type. + /// The right value type. + /// The result value type. + /// The left sequence. + /// The right sequence. + /// The function that combines the final values. + /// A sequence that emits one selected value after both sources complete. + /// , , or is . public static IObservable ForkJoin(this IObservable left, IObservable right, Func selector) { if (left == null) @@ -779,6 +1048,11 @@ public static IObservable ForkJoin(this IObserv /// /// Awaits the first source value. /// + /// The value type. + /// The source sequence. + /// A task that completes with the first source value. + /// is . + /// The source completes without producing a value. public static Task FirstAsync(this IObservable source) { if (source == null) @@ -797,6 +1071,10 @@ public static Task FirstAsync(this IObservable source) /// /// Awaits the first source value, returning a default value when the source is empty. /// + /// The value type. + /// The source sequence. + /// A task that completes with the first source value, or when the source is empty. + /// is . public static Task FirstOrDefaultAsync(this IObservable source) { if (source == null) @@ -815,6 +1093,11 @@ public static Task FirstOrDefaultAsync(this IObservable source) /// /// Awaits the first source value, returning a default value when the source is empty. /// + /// The value type. + /// The source sequence. + /// The value to return when the source is empty. + /// A task that completes with the first source value, or when the source is empty. + /// is . public static Task FirstOrDefaultAsync(this IObservable source, T defaultValue) { if (source == null) @@ -833,11 +1116,22 @@ public static Task FirstOrDefaultAsync(this IObservable source, T defau /// /// Awaits source completion and returns the last value produced by the source. /// + /// The value type. + /// The source sequence. + /// A task that completes with the final source value. + /// is . + /// The source completes without producing a value. public static Task ToTask(this IObservable source) => source.ToTask(CancellationToken.None); /// /// Awaits source completion and returns the last value produced by the source. /// + /// The value type. + /// The source sequence. + /// The token used to cancel the task and dispose the subscription. + /// A task that completes with the final source value. + /// is . + /// The source completes without producing a value. [System.Diagnostics.CodeAnalysis.SuppressMessage( "Major Code Smell", "S1541:Methods and properties should not be too complex", @@ -910,59 +1204,107 @@ public static Task ToTask(this IObservable source, CancellationToken ca /// /// Identity helper that keeps source-compatible FirstAsync().ToTask() migrations compiling. /// + /// The task result type. + /// The task to return. + /// The supplied task. + /// is . public static Task ToTask(this Task task) => task ?? throw new ArgumentNullException(nameof(task)); /// /// Awaits the source count as a task. /// + /// The value type. + /// The source sequence. + /// A task that completes with the number of source values. + /// is . public static Task CountAsync(this IObservable source) => source.Count().ToTask(); /// /// Awaits the source count as a task. /// + /// The value type. + /// The source sequence. + /// The token used to cancel the task. + /// A task that completes with the number of source values. + /// is . public static Task CountAsync(this IObservable source, CancellationToken cancellationToken) => source.Count().ToTask(cancellationToken); /// /// Awaits the source predicate count as a task. /// + /// The value type. + /// The source sequence. + /// The function that identifies values to count. + /// A task that completes with the matching value count. + /// or is . public static Task CountAsync(this IObservable source, Func predicate) => source.Count(predicate).ToTask(); /// /// Awaits the source predicate count as a task. /// + /// The value type. + /// The source sequence. + /// The function that identifies values to count. + /// The token used to cancel the task. + /// A task that completes with the matching value count. + /// or is . public static Task CountAsync(this IObservable source, Func predicate, CancellationToken cancellationToken) => source.Count(predicate).ToTask(cancellationToken); /// /// Awaits whether any value is present. /// + /// The value type. + /// The source sequence. + /// A task that completes with whether the source produced any values. + /// is . public static Task AnyAsync(this IObservable source) => source.Any().ToTask(); /// /// Awaits whether any value is present. /// + /// The value type. + /// The source sequence. + /// The token used to cancel the task. + /// A task that completes with whether the source produced any values. + /// is . public static Task AnyAsync(this IObservable source, CancellationToken cancellationToken) => source.Any().ToTask(cancellationToken); /// /// Awaits whether any value matches a predicate. /// + /// The value type. + /// The source sequence. + /// The function that tests each value. + /// A task that completes with whether any source value satisfies . + /// or is . public static Task AnyAsync(this IObservable source, Func predicate) => source.Any(predicate).ToTask(); /// /// Awaits whether any value matches a predicate. /// + /// The value type. + /// The source sequence. + /// The function that tests each value. + /// The token used to cancel the task. + /// A task that completes with whether any source value satisfies . + /// or is . public static Task AnyAsync(this IObservable source, Func predicate, CancellationToken cancellationToken) => source.Any(predicate).ToTask(cancellationToken); /// /// Collects all values into an array task. /// + /// The value type. + /// The source sequence. + /// A task that completes with all source values in an array. + /// is . public static Task CollectArrayAsync(this IObservable source) { if (source == null) @@ -984,6 +1326,10 @@ public static Task CollectArrayAsync(this IObservable source) /// /// Collects all values into a list task. /// + /// The value type. + /// The source sequence. + /// A task that completes with all source values in a list. + /// is . public static Task> CollectListAsync(this IObservable source) { if (source == null) @@ -1051,8 +1397,13 @@ private static Task FirstOrDefaultCoreAsync(this IObservable source, bo } /// - /// Creates a generic value from an integer range item. + /// Converts an integer value to the specified numeric type. /// + /// Uses boxing and unboxing to perform the conversion. The generic type parameter is expected to + /// be validated by the caller. + /// The target numeric type. + /// The integer value to convert. + /// The value converted to type . [System.Diagnostics.CodeAnalysis.SuppressMessage( "Major Code Smell", "S4018:Generic methods should provide type parameters", @@ -1060,8 +1411,11 @@ private static Task FirstOrDefaultCoreAsync(this IObservable source, bo private static T CreateRangeValue(int value) => (T)(object)value; /// - /// Creates a range-backed array for task terminal fast paths. + /// Creates an array of sequential values from the specified range signal. /// + /// The element type of the array. + /// The range signal specifying the start value and count. + /// An array containing sequential values from the range start to start + count - 1. [System.Diagnostics.CodeAnalysis.SuppressMessage( "Major Code Smell", "S4018:Generic methods should provide type parameters", @@ -1089,8 +1443,13 @@ private static T[] CreateRangeArray(RangeSignal range) } /// - /// Creates a range-backed list for task terminal fast paths. + /// Creates a list of values from the specified range signal. /// + /// Optimized for integer types by directly incrementing values. For other types, uses + /// CreateRangeValue to generate each element. + /// The type of elements to create in the list. + /// The range signal containing the start value and count. + /// A list containing the generated range values. [System.Diagnostics.CodeAnalysis.SuppressMessage( "Major Code Smell", "S4018:Generic methods should provide type parameters", From e57989ed1eb69fd0fd5ea095baaddea3ab5479ed Mon Sep 17 00:00:00 2001 From: Chris Pulman Date: Tue, 26 May 2026 08:21:52 +0100 Subject: [PATCH 3/8] Add debugger display support and README updates Introduce debugger-friendly displays across the library by adding System.Diagnostics.DebuggerDisplay attributes to many types and marking them partial. Add a new DebuggerDisplay.Partials.cs that centralizes private DebuggerDisplay properties (calling ToString()) for those partial types, includes a WINDOWS guard for DispatcherSequencer, and suppresses related analyzer warnings. Also update numerous types/structs to partial to enable the centralized debugger helpers. Additionally, update README.md to document new/renamed APIs and overloads (CompositeDisposable alias, Signal.FromEnumerable cancellation overload, Signal.FromAsync/Observable.FromAsync mappings, ToObservable cancellation notes, BehaviorSignal naming clarification, CountAsync/AnyAsync docs, etc.) --- README.md | 8 + .../Concurrency/CurrentThreadSequencer.cs | 3 +- .../Concurrency/DispatcherSequencer.cs | 3 +- .../Concurrency/ImmediateSequencer.cs | 3 +- .../Concurrency/ScheduledItem.cs | 3 +- .../ScheduledItem{TAbsolute,TValue}.cs | 3 +- .../Concurrency/SequencerQueue.cs | 3 +- .../Concurrency/TaskPoolSequencer.cs | 3 +- .../Concurrency/TestClock.cs | 3 +- .../Concurrency/ThreadPoolSequencer.cs | 3 +- .../Concurrency/VirtualClock.cs | 3 +- ...lTimeSequencerBase{TAbsolute,TRelative}.cs | 3 +- ...rtualTimeSequencer{TAbsolute,TRelative}.cs | 3 +- .../ConnectableSignal{T}.cs | 3 +- src/ReactiveUI.Primitives/Core/Moment{T}.cs | 3 +- src/ReactiveUI.Primitives/Core/Spark{T}.cs | 3 +- .../Core/TimeInterval{T}.cs | 3 +- .../DebuggerDisplay.Partials.cs | 309 ++++++++++++++++++ .../Disposables/AssignmentSlot.cs | 3 +- .../Disposables/BooleanDisposable.cs | 3 +- .../Disposables/CancellationDisposable.cs | 3 +- .../Disposables/MultipleDisposable.cs | 3 +- .../Disposables/Pocket.cs | 3 +- .../Disposables/SingleDisposable.cs | 3 +- .../SingleReplaceableDisposable.cs | 3 +- src/ReactiveUI.Primitives/Disposables/Slot.cs | 3 +- src/ReactiveUI.Primitives/RxVoid.cs | 3 +- .../Signal/AsyncSignal{T}.cs | 3 +- .../Signal/CommandSignal{TResult}.cs | 3 +- .../Signal/ReadOnlyState{T}.cs | 3 +- .../Signal/ReplaySignal{T}.cs | 3 +- src/ReactiveUI.Primitives/Signal/Signal{T}.cs | 3 +- .../Signal/StateSignal{T}.cs | 3 +- 33 files changed, 379 insertions(+), 31 deletions(-) create mode 100644 src/ReactiveUI.Primitives/DebuggerDisplay.Partials.cs diff --git a/README.md b/README.md index 9940f5f..3058321 100644 --- a/README.md +++ b/README.md @@ -124,6 +124,7 @@ Subscriptions and scheduled work return `IDisposable`. ReactiveUI.Primitives inc | `BooleanDisposable` | Track simple disposed state. | | `CancellationDisposable` | Tie disposal to a `CancellationTokenSource`. | | `MultipleDisposable` | Composite-disposable equivalent; add/remove multiple disposables. | +| `CompositeDisposable` | System.Reactive-compatible alias over `MultipleDisposable`. | | `Pocket` | Named `MultipleDisposable` specialization. | | `SingleDisposable` / `AssignmentSlot` | Single-assignment disposable container. | | `SingleReplaceableDisposable` / `Slot` | Replaceable disposable container. | @@ -165,8 +166,10 @@ Creation APIs live on `ReactiveUI.Primitives.Signals.Signal`. | `Signal.Unfold(...)` | Generate a finite sequence from state. | | `Signal.Use(...)` | Tie a resource lifetime to a subscription. | | `Signal.FromEnumerable(IEnumerable)` | Convert an enumerable. | +| `Signal.FromEnumerable(IEnumerable, CancellationToken)` | Convert an enumerable and stop synchronous enumeration when cancelled. | | `Signal.FromAsyncEnumerable(IAsyncEnumerable, CancellationToken)` | Convert an async enumerable on modern TFMs. | | `Signal.FromTask(Task)` | Convert a task to a signal. | +| `Signal.FromAsync(...)` | Invoke a task factory per subscription. | | `Signal.After(TimeSpan, ISequencer?)` | Emit one `long` tick after a delay. | | `Signal.Every(TimeSpan, ISequencer?)` | Emit increasing `long` ticks repeatedly. | | `Signal.Pulse(...)` | Alias of `Every`. | @@ -343,6 +346,7 @@ ReactiveUI.Primitives uses explicit names instead of cloning every System.Reacti |---|---|---| | `Subject` | `Signal` | Push values, errors, and completion to subscribers. | | `BehaviorSubject` | `BehaviourSignal` or `StateSignal` | Stores the latest value and emits it to new subscribers. `StateSignal` adds a mutable `Value` setter and `Changed`. | +| `BehaviorSubject` | `BehaviorSignal`, or `StateSignal` | Stores the latest value and emits it to new subscribers. `StateSignal` adds a mutable `Value` setter and `Changed`. | | `ReplaySubject` | `ReplaySignal` | Replays buffered values by size and/or time window. | | `AsyncSubject` | `AsyncSignal` | Awaitable subject-like signal; also implements `IAwaitSignal`. | | `ReactiveProperty` / state holder | `StateSignal` plus `ReadOnlyState` | Mutable state and read-only projected state. | @@ -493,12 +497,14 @@ ReactiveUI.Primitives is not a byte-for-byte clone of System.Reactive. It keeps | `Observable.Repeat(value)` | `Signal.Repeat(value)` | Indefinite repeat. | | `Observable.Repeat(value, count)` | `Signal.Repeat(value, count)` | Fixed repeat. | | `Observable.Defer(factory)` | `Signal.Defer(factory)` | Create source per subscription. | +| `Observable.FromAsync(...)` | `Signal.FromAsync(...)` | Invoke a task factory per subscription. | | `Observable.Create(...)` | `Signal.Create(...)` or `Signal.CreateSafe(...)` | Prefer `CreateSafe` for general custom sources. | | `Observable.Using(...)` | `Signal.Use(...)` | Resource scoped to subscription. | | `Observable.Timer(dueTime)` | `Signal.Timer(dueTime)` or `Signal.After(dueTime)` | Emits `long` tick `0`. | | `Observable.Timer(dueTime, period)` | `Signal.Timer(dueTime, period)` | Periodic `long` ticks. | | `Observable.Interval(period)` | `Signal.Interval(period)` or `Signal.Every(period)` | Repeating ticks. | | `ToObservable()` from enumerable | `Signal.FromEnumerable(values)` or `values.ToSignal()` | `ToSignal` extension is available. | +| `ToObservable()` from enumerable | `Signal.FromEnumerable(values)`, `values.ToSignal()`, or `values.ToObservable()` | Cancellation-token overloads are available. | | task conversion | `Signal.FromTask(task)` | Function-based task signals also exist. | ### Subject/state mapping @@ -507,6 +513,7 @@ ReactiveUI.Primitives is not a byte-for-byte clone of System.Reactive. It keeps |---|---|---| | `new Subject()` | `new Signal()` | Use `OnNext`, `OnError`, `OnCompleted`, and `Subscribe`. | | `new BehaviorSubject(initial)` | `new BehaviourSignal(initial)` | Keeps `Value` getter and emits latest value to subscribers. | +| `new BehaviorSubject(initial)` | `new BehaviorSignal(initial)` | Keeps `Value` getter and emits latest value to subscribers. | | mutable reactive property | `new StateSignal(initial)` | Set `Value` to emit. Use `Changed` for observable state stream. | | `new ReplaySubject()` | `new ReplaySignal()` | Unbounded replay. | | `new ReplaySubject(bufferSize)` | `new ReplaySignal(bufferSize)` | Size-limited replay. | @@ -546,6 +553,7 @@ ReactiveUI.Primitives is not a byte-for-byte clone of System.Reactive. It keeps | `Buffer(count)` | `Buffer(count)` | Fixed-size buffers. | | `ToList` / `ToArray` | `CollectList` / `CollectArray` | Signal results. | | `FirstAsync` | `FirstAsync` | Task result. | +| `CountAsync` / `AnyAsync` | `CountAsync` / `AnyAsync` | Task-shaped terminal helpers, including cancellation overloads. | ### Disposable mapping diff --git a/src/ReactiveUI.Primitives/Concurrency/CurrentThreadSequencer.cs b/src/ReactiveUI.Primitives/Concurrency/CurrentThreadSequencer.cs index d082ae6..f829b4c 100644 --- a/src/ReactiveUI.Primitives/Concurrency/CurrentThreadSequencer.cs +++ b/src/ReactiveUI.Primitives/Concurrency/CurrentThreadSequencer.cs @@ -11,7 +11,8 @@ namespace ReactiveUI.Primitives.Concurrency; /// CurrentThreadSequencer. /// /// -public sealed class CurrentThreadSequencer : ISequencer +[DebuggerDisplay("{DebuggerDisplay,nq}")] +public sealed partial class CurrentThreadSequencer : ISequencer { /// /// Singleton holder for the current-thread sequencer. diff --git a/src/ReactiveUI.Primitives/Concurrency/DispatcherSequencer.cs b/src/ReactiveUI.Primitives/Concurrency/DispatcherSequencer.cs index a64c117..523cca6 100644 --- a/src/ReactiveUI.Primitives/Concurrency/DispatcherSequencer.cs +++ b/src/ReactiveUI.Primitives/Concurrency/DispatcherSequencer.cs @@ -15,7 +15,8 @@ namespace ReactiveUI.Primitives.Concurrency; /// DispatcherSequencer. /// /// -public class DispatcherSequencer : ISequencer +[System.Diagnostics.DebuggerDisplay("{DebuggerDisplay,nq}")] +public partial class DispatcherSequencer : ISequencer { /// /// Initializes a new instance of the class. diff --git a/src/ReactiveUI.Primitives/Concurrency/ImmediateSequencer.cs b/src/ReactiveUI.Primitives/Concurrency/ImmediateSequencer.cs index f5b2f33..7728800 100644 --- a/src/ReactiveUI.Primitives/Concurrency/ImmediateSequencer.cs +++ b/src/ReactiveUI.Primitives/Concurrency/ImmediateSequencer.cs @@ -8,7 +8,8 @@ namespace ReactiveUI.Primitives.Concurrency; /// ImmediateSequencer. /// /// -public sealed class ImmediateSequencer : ISequencer +[System.Diagnostics.DebuggerDisplay("{DebuggerDisplay,nq}")] +public sealed partial class ImmediateSequencer : ISequencer { /// /// Singleton holder for the immediate sequencer. diff --git a/src/ReactiveUI.Primitives/Concurrency/ScheduledItem.cs b/src/ReactiveUI.Primitives/Concurrency/ScheduledItem.cs index 1f7e07f..08f6cae 100644 --- a/src/ReactiveUI.Primitives/Concurrency/ScheduledItem.cs +++ b/src/ReactiveUI.Primitives/Concurrency/ScheduledItem.cs @@ -11,7 +11,8 @@ namespace ReactiveUI.Primitives.Concurrency; /// Abstract base class for scheduled work items. /// /// Absolute time representation type. -public abstract class ScheduledItem : IScheduledItem, IComparable>, IsDisposed, IComparable +[System.Diagnostics.DebuggerDisplay("{DebuggerDisplay,nq}")] +public abstract partial class ScheduledItem : IScheduledItem, IComparable>, IsDisposed, IComparable where TAbsolute : IComparable { /// diff --git a/src/ReactiveUI.Primitives/Concurrency/ScheduledItem{TAbsolute,TValue}.cs b/src/ReactiveUI.Primitives/Concurrency/ScheduledItem{TAbsolute,TValue}.cs index f8c1633..6c1d445 100644 --- a/src/ReactiveUI.Primitives/Concurrency/ScheduledItem{TAbsolute,TValue}.cs +++ b/src/ReactiveUI.Primitives/Concurrency/ScheduledItem{TAbsolute,TValue}.cs @@ -9,7 +9,8 @@ namespace ReactiveUI.Primitives.Concurrency; /// /// Absolute time representation type. /// Type of the state passed to the scheduled action. -public sealed class ScheduledItem : ScheduledItem +[System.Diagnostics.DebuggerDisplay("{DebuggerDisplay,nq}")] +public sealed partial class ScheduledItem : ScheduledItem where TAbsolute : IComparable { /// diff --git a/src/ReactiveUI.Primitives/Concurrency/SequencerQueue.cs b/src/ReactiveUI.Primitives/Concurrency/SequencerQueue.cs index dbb2655..44c0fca 100644 --- a/src/ReactiveUI.Primitives/Concurrency/SequencerQueue.cs +++ b/src/ReactiveUI.Primitives/Concurrency/SequencerQueue.cs @@ -11,7 +11,8 @@ namespace ReactiveUI.Primitives.Concurrency; /// /// Absolute time representation type. /// This type is not thread safe; users should ensure proper synchronization. -public class SequencerQueue +[System.Diagnostics.DebuggerDisplay("{DebuggerDisplay,nq}")] +public partial class SequencerQueue where TAbsolute : IComparable { /// diff --git a/src/ReactiveUI.Primitives/Concurrency/TaskPoolSequencer.cs b/src/ReactiveUI.Primitives/Concurrency/TaskPoolSequencer.cs index 3ae817f..8c5273c 100644 --- a/src/ReactiveUI.Primitives/Concurrency/TaskPoolSequencer.cs +++ b/src/ReactiveUI.Primitives/Concurrency/TaskPoolSequencer.cs @@ -10,7 +10,8 @@ namespace ReactiveUI.Primitives.Concurrency; /// TaskPoolSequencer. /// /// -public sealed class TaskPoolSequencer : ISequencer +[System.Diagnostics.DebuggerDisplay("{DebuggerDisplay,nq}")] +public sealed partial class TaskPoolSequencer : ISequencer { /// /// Task factory used to schedule asynchronous work. diff --git a/src/ReactiveUI.Primitives/Concurrency/TestClock.cs b/src/ReactiveUI.Primitives/Concurrency/TestClock.cs index 499069a..a49ab01 100644 --- a/src/ReactiveUI.Primitives/Concurrency/TestClock.cs +++ b/src/ReactiveUI.Primitives/Concurrency/TestClock.cs @@ -7,7 +7,8 @@ namespace ReactiveUI.Primitives.Concurrency; /// /// Test-facing alias for . /// -public sealed class TestClock : VirtualClock +[System.Diagnostics.DebuggerDisplay("{DebuggerDisplay,nq}")] +public sealed partial class TestClock : VirtualClock { /// /// Initializes a new instance of the class at the default clock value. diff --git a/src/ReactiveUI.Primitives/Concurrency/ThreadPoolSequencer.cs b/src/ReactiveUI.Primitives/Concurrency/ThreadPoolSequencer.cs index d4d4d1a..ecc78f7 100644 --- a/src/ReactiveUI.Primitives/Concurrency/ThreadPoolSequencer.cs +++ b/src/ReactiveUI.Primitives/Concurrency/ThreadPoolSequencer.cs @@ -12,7 +12,8 @@ namespace ReactiveUI.Primitives.Concurrency /// ThreadPoolSequencer. /// /// - public sealed class ThreadPoolSequencer : ISequencer + [System.Diagnostics.DebuggerDisplay("{DebuggerDisplay,nq}")] + public sealed partial class ThreadPoolSequencer : ISequencer { /// /// Gets the shared thread-pool scheduler instance. diff --git a/src/ReactiveUI.Primitives/Concurrency/VirtualClock.cs b/src/ReactiveUI.Primitives/Concurrency/VirtualClock.cs index 2227b84..139aae6 100644 --- a/src/ReactiveUI.Primitives/Concurrency/VirtualClock.cs +++ b/src/ReactiveUI.Primitives/Concurrency/VirtualClock.cs @@ -7,7 +7,8 @@ namespace ReactiveUI.Primitives.Concurrency; /// /// Deterministic virtual scheduler backed by and . /// -public class VirtualClock : VirtualTimeSequencer +[System.Diagnostics.DebuggerDisplay("{DebuggerDisplay,nq}")] +public partial class VirtualClock : VirtualTimeSequencer { /// /// Initializes a new instance of the class at the default clock value. diff --git a/src/ReactiveUI.Primitives/Concurrency/VirtualTimeSequencerBase{TAbsolute,TRelative}.cs b/src/ReactiveUI.Primitives/Concurrency/VirtualTimeSequencerBase{TAbsolute,TRelative}.cs index 739fc2c..2fbe289 100644 --- a/src/ReactiveUI.Primitives/Concurrency/VirtualTimeSequencerBase{TAbsolute,TRelative}.cs +++ b/src/ReactiveUI.Primitives/Concurrency/VirtualTimeSequencerBase{TAbsolute,TRelative}.cs @@ -11,7 +11,8 @@ namespace ReactiveUI.Primitives.Concurrency; /// /// Absolute time representation type. /// Relative time representation type. -public abstract class VirtualTimeSequencerBase : ISequencer, IServiceProvider, IStopwatchProvider +[System.Diagnostics.DebuggerDisplay("{DebuggerDisplay,nq}")] +public abstract partial class VirtualTimeSequencerBase : ISequencer, IServiceProvider, IStopwatchProvider where TAbsolute : IComparable { /// diff --git a/src/ReactiveUI.Primitives/Concurrency/VirtualTimeSequencer{TAbsolute,TRelative}.cs b/src/ReactiveUI.Primitives/Concurrency/VirtualTimeSequencer{TAbsolute,TRelative}.cs index 623a91b..a35f898 100644 --- a/src/ReactiveUI.Primitives/Concurrency/VirtualTimeSequencer{TAbsolute,TRelative}.cs +++ b/src/ReactiveUI.Primitives/Concurrency/VirtualTimeSequencer{TAbsolute,TRelative}.cs @@ -9,7 +9,8 @@ namespace ReactiveUI.Primitives.Concurrency; /// /// Absolute time representation type. /// Relative time representation type. -public abstract class VirtualTimeSequencer : VirtualTimeSequencerBase +[System.Diagnostics.DebuggerDisplay("{DebuggerDisplay,nq}")] +public abstract partial class VirtualTimeSequencer : VirtualTimeSequencerBase where TAbsolute : IComparable { /// diff --git a/src/ReactiveUI.Primitives/ConnectableSignal{T}.cs b/src/ReactiveUI.Primitives/ConnectableSignal{T}.cs index 7b9e264..452ea94 100644 --- a/src/ReactiveUI.Primitives/ConnectableSignal{T}.cs +++ b/src/ReactiveUI.Primitives/ConnectableSignal{T}.cs @@ -11,7 +11,8 @@ namespace ReactiveUI.Primitives; /// Connectable hot signal that subscribes to its source only when connected. /// /// The value type. -public sealed class ConnectableSignal : IObservable +[System.Diagnostics.DebuggerDisplay("{DebuggerDisplay,nq}")] +public sealed partial class ConnectableSignal : IObservable { /// /// Synchronizes connection state. diff --git a/src/ReactiveUI.Primitives/Core/Moment{T}.cs b/src/ReactiveUI.Primitives/Core/Moment{T}.cs index cb0224d..29b596e 100644 --- a/src/ReactiveUI.Primitives/Core/Moment{T}.cs +++ b/src/ReactiveUI.Primitives/Core/Moment{T}.cs @@ -11,7 +11,8 @@ namespace ReactiveUI.Primitives.Core; /// /// The captured value type. [Serializable] -public readonly struct Moment : IEquatable> +[System.Diagnostics.DebuggerDisplay("{DebuggerDisplay,nq}")] +public readonly partial struct Moment : IEquatable> { /// /// Initializes a new instance of the struct. diff --git a/src/ReactiveUI.Primitives/Core/Spark{T}.cs b/src/ReactiveUI.Primitives/Core/Spark{T}.cs index 7f26636..ae0ff65 100644 --- a/src/ReactiveUI.Primitives/Core/Spark{T}.cs +++ b/src/ReactiveUI.Primitives/Core/Spark{T}.cs @@ -14,7 +14,8 @@ namespace ReactiveUI.Primitives.Core /// /// The type of the elements received by the observer. [Serializable] - public abstract class Spark : IEquatable> + [DebuggerDisplay("{DebuggerDisplay,nq}")] + public abstract partial class Spark : IEquatable> { /// /// Initializes a new instance of the class. diff --git a/src/ReactiveUI.Primitives/Core/TimeInterval{T}.cs b/src/ReactiveUI.Primitives/Core/TimeInterval{T}.cs index d5bdb0f..f25a735 100644 --- a/src/ReactiveUI.Primitives/Core/TimeInterval{T}.cs +++ b/src/ReactiveUI.Primitives/Core/TimeInterval{T}.cs @@ -12,7 +12,8 @@ namespace ReactiveUI.Primitives.Core; /// /// The type of the value being annotated with time interval information. [Serializable] -public readonly struct TimeInterval : IEquatable> +[System.Diagnostics.DebuggerDisplay("{DebuggerDisplay,nq}")] +public readonly partial struct TimeInterval : IEquatable> { /// /// Initializes a new instance of the struct. diff --git a/src/ReactiveUI.Primitives/DebuggerDisplay.Partials.cs b/src/ReactiveUI.Primitives/DebuggerDisplay.Partials.cs new file mode 100644 index 0000000..90ec635 --- /dev/null +++ b/src/ReactiveUI.Primitives/DebuggerDisplay.Partials.cs @@ -0,0 +1,309 @@ +// Copyright (c) 2019-2026 ReactiveUI Association Incorporated. All rights reserved. +// ReactiveUI Association Incorporated licenses this file to you under the MIT license. +// See the LICENSE file in the project root for full license information. + +#pragma warning disable SA1201 // Debugger display partial members are grouped by namespace. +#pragma warning disable SA1402 // Debugger display partial members are intentionally grouped in one support file. +#pragma warning disable SA1403 // Debugger display partial members span the public namespaces that need the shared pattern. +#pragma warning disable SA1601 // Primary type declarations carry the public documentation. + +namespace ReactiveUI.Primitives +{ + public sealed partial class ConnectableSignal + { + /// + /// Gets the debugger display text. + /// + [System.Diagnostics.DebuggerBrowsable(System.Diagnostics.DebuggerBrowsableState.Never)] + private string DebuggerDisplay => ToString() ?? string.Empty; + } + + public readonly partial struct RxVoid + { + /// + /// Gets the debugger display text. + /// + [System.Diagnostics.DebuggerBrowsable(System.Diagnostics.DebuggerBrowsableState.Never)] + private string DebuggerDisplay => ToString() ?? string.Empty; + } +} + +namespace ReactiveUI.Primitives.Concurrency +{ + public sealed partial class CurrentThreadSequencer + { + /// + /// Gets the debugger display text. + /// + [System.Diagnostics.DebuggerBrowsable(System.Diagnostics.DebuggerBrowsableState.Never)] + private string DebuggerDisplay => ToString() ?? string.Empty; + } + +#if WINDOWS + public partial class DispatcherSequencer + { + /// + /// Gets the debugger display text. + /// + [System.Diagnostics.DebuggerBrowsable(System.Diagnostics.DebuggerBrowsableState.Never)] + private string DebuggerDisplay => ToString() ?? string.Empty; + } +#endif + + public sealed partial class ImmediateSequencer + { + /// + /// Gets the debugger display text. + /// + [System.Diagnostics.DebuggerBrowsable(System.Diagnostics.DebuggerBrowsableState.Never)] + private string DebuggerDisplay => ToString() ?? string.Empty; + } + + public abstract partial class ScheduledItem + where TAbsolute : IComparable + { + /// + /// Gets the debugger display text. + /// + [System.Diagnostics.DebuggerBrowsable(System.Diagnostics.DebuggerBrowsableState.Never)] + private string DebuggerDisplay => ToString() ?? string.Empty; + } + + public sealed partial class ScheduledItem + where TAbsolute : IComparable + { + /// + /// Gets the debugger display text. + /// + [System.Diagnostics.DebuggerBrowsable(System.Diagnostics.DebuggerBrowsableState.Never)] + private string DebuggerDisplay => ToString() ?? string.Empty; + } + + public partial class SequencerQueue + where TAbsolute : IComparable + { + /// + /// Gets the debugger display text. + /// + [System.Diagnostics.DebuggerBrowsable(System.Diagnostics.DebuggerBrowsableState.Never)] + private string DebuggerDisplay => ToString() ?? string.Empty; + } + + public sealed partial class TaskPoolSequencer + { + /// + /// Gets the debugger display text. + /// + [System.Diagnostics.DebuggerBrowsable(System.Diagnostics.DebuggerBrowsableState.Never)] + private string DebuggerDisplay => ToString() ?? string.Empty; + } + + public sealed partial class TestClock + { + /// + /// Gets the debugger display text. + /// + [System.Diagnostics.DebuggerBrowsable(System.Diagnostics.DebuggerBrowsableState.Never)] + private string DebuggerDisplay => ToString() ?? string.Empty; + } + + public sealed partial class ThreadPoolSequencer + { + /// + /// Gets the debugger display text. + /// + [System.Diagnostics.DebuggerBrowsable(System.Diagnostics.DebuggerBrowsableState.Never)] + private string DebuggerDisplay => ToString() ?? string.Empty; + } + + public partial class VirtualClock + { + /// + /// Gets the debugger display text. + /// + [System.Diagnostics.DebuggerBrowsable(System.Diagnostics.DebuggerBrowsableState.Never)] + private string DebuggerDisplay => ToString() ?? string.Empty; + } + + public abstract partial class VirtualTimeSequencer + where TAbsolute : IComparable + { + /// + /// Gets the debugger display text. + /// + [System.Diagnostics.DebuggerBrowsable(System.Diagnostics.DebuggerBrowsableState.Never)] + private string DebuggerDisplay => ToString() ?? string.Empty; + } + + public abstract partial class VirtualTimeSequencerBase + where TAbsolute : IComparable + { + /// + /// Gets the debugger display text. + /// + [System.Diagnostics.DebuggerBrowsable(System.Diagnostics.DebuggerBrowsableState.Never)] + private string DebuggerDisplay => ToString() ?? string.Empty; + } +} + +namespace ReactiveUI.Primitives.Core +{ + public readonly partial struct Moment + { + /// + /// Gets the debugger display text. + /// + [System.Diagnostics.DebuggerBrowsable(System.Diagnostics.DebuggerBrowsableState.Never)] + private string DebuggerDisplay => ToString() ?? string.Empty; + } + + public abstract partial class Spark + { + /// + /// Gets the debugger display text. + /// + [System.Diagnostics.DebuggerBrowsable(System.Diagnostics.DebuggerBrowsableState.Never)] + private string DebuggerDisplay => ToString() ?? string.Empty; + } + + public readonly partial struct TimeInterval + { + /// + /// Gets the debugger display text. + /// + [System.Diagnostics.DebuggerBrowsable(System.Diagnostics.DebuggerBrowsableState.Never)] + private string DebuggerDisplay => ToString() ?? string.Empty; + } +} + +namespace ReactiveUI.Primitives.Disposables +{ + public sealed partial class AssignmentSlot + { + /// + /// Gets the debugger display text. + /// + [System.Diagnostics.DebuggerBrowsable(System.Diagnostics.DebuggerBrowsableState.Never)] + private string DebuggerDisplay => ToString() ?? string.Empty; + } + + public sealed partial class BooleanDisposable + { + /// + /// Gets the debugger display text. + /// + [System.Diagnostics.DebuggerBrowsable(System.Diagnostics.DebuggerBrowsableState.Never)] + private string DebuggerDisplay => ToString() ?? string.Empty; + } + + public sealed partial class CancellationDisposable + { + /// + /// Gets the debugger display text. + /// + [System.Diagnostics.DebuggerBrowsable(System.Diagnostics.DebuggerBrowsableState.Never)] + private string DebuggerDisplay => ToString() ?? string.Empty; + } + + public partial class MultipleDisposable + { + /// + /// Gets the debugger display text. + /// + [System.Diagnostics.DebuggerBrowsable(System.Diagnostics.DebuggerBrowsableState.Never)] + private string DebuggerDisplay => ToString() ?? string.Empty; + } + + public sealed partial class Pocket + { + /// + /// Gets the debugger display text. + /// + [System.Diagnostics.DebuggerBrowsable(System.Diagnostics.DebuggerBrowsableState.Never)] + private string DebuggerDisplay => ToString() ?? string.Empty; + } + + public partial class SingleDisposable + { + /// + /// Gets the debugger display text. + /// + [System.Diagnostics.DebuggerBrowsable(System.Diagnostics.DebuggerBrowsableState.Never)] + private string DebuggerDisplay => ToString() ?? string.Empty; + } + + public partial class SingleReplaceableDisposable + { + /// + /// Gets the debugger display text. + /// + [System.Diagnostics.DebuggerBrowsable(System.Diagnostics.DebuggerBrowsableState.Never)] + private string DebuggerDisplay => ToString() ?? string.Empty; + } + + public sealed partial class Slot + { + /// + /// Gets the debugger display text. + /// + [System.Diagnostics.DebuggerBrowsable(System.Diagnostics.DebuggerBrowsableState.Never)] + private string DebuggerDisplay => ToString() ?? string.Empty; + } +} + +namespace ReactiveUI.Primitives.Signals +{ + public partial class AsyncSignal + { + /// + /// Gets the debugger display text. + /// + [System.Diagnostics.DebuggerBrowsable(System.Diagnostics.DebuggerBrowsableState.Never)] + private string DebuggerDisplay => ToString() ?? string.Empty; + } + + public sealed partial class CommandSignal + { + /// + /// Gets the debugger display text. + /// + [System.Diagnostics.DebuggerBrowsable(System.Diagnostics.DebuggerBrowsableState.Never)] + private string DebuggerDisplay => ToString() ?? string.Empty; + } + + public sealed partial class ReadOnlyState + { + /// + /// Gets the debugger display text. + /// + [System.Diagnostics.DebuggerBrowsable(System.Diagnostics.DebuggerBrowsableState.Never)] + private string DebuggerDisplay => ToString() ?? string.Empty; + } + + public partial class ReplaySignal + { + /// + /// Gets the debugger display text. + /// + [System.Diagnostics.DebuggerBrowsable(System.Diagnostics.DebuggerBrowsableState.Never)] + private string DebuggerDisplay => ToString() ?? string.Empty; + } + + public partial class Signal + { + /// + /// Gets the debugger display text. + /// + [System.Diagnostics.DebuggerBrowsable(System.Diagnostics.DebuggerBrowsableState.Never)] + private string DebuggerDisplay => ToString() ?? string.Empty; + } + + public partial class StateSignal + { + /// + /// Gets the debugger display text. + /// + [System.Diagnostics.DebuggerBrowsable(System.Diagnostics.DebuggerBrowsableState.Never)] + private string DebuggerDisplay => ToString() ?? string.Empty; + } +} diff --git a/src/ReactiveUI.Primitives/Disposables/AssignmentSlot.cs b/src/ReactiveUI.Primitives/Disposables/AssignmentSlot.cs index 84b2ba0..bce827d 100644 --- a/src/ReactiveUI.Primitives/Disposables/AssignmentSlot.cs +++ b/src/ReactiveUI.Primitives/Disposables/AssignmentSlot.cs @@ -7,7 +7,8 @@ namespace ReactiveUI.Primitives.Disposables; /// /// Primitives alias for a single-assignment disposable slot. /// -public sealed class AssignmentSlot : SingleDisposable +[System.Diagnostics.DebuggerDisplay("{DebuggerDisplay,nq}")] +public sealed partial class AssignmentSlot : SingleDisposable { /// /// Initializes a new instance of the class. diff --git a/src/ReactiveUI.Primitives/Disposables/BooleanDisposable.cs b/src/ReactiveUI.Primitives/Disposables/BooleanDisposable.cs index 7e8f268..a7256fc 100644 --- a/src/ReactiveUI.Primitives/Disposables/BooleanDisposable.cs +++ b/src/ReactiveUI.Primitives/Disposables/BooleanDisposable.cs @@ -8,7 +8,8 @@ namespace ReactiveUI.Primitives.Disposables; /// BooleanDisposable. /// /// -public sealed class BooleanDisposable : IsDisposed +[System.Diagnostics.DebuggerDisplay("{DebuggerDisplay,nq}")] +public sealed partial class BooleanDisposable : IsDisposed { /// /// Gets a value indicating whether this instance is disposed. diff --git a/src/ReactiveUI.Primitives/Disposables/CancellationDisposable.cs b/src/ReactiveUI.Primitives/Disposables/CancellationDisposable.cs index 4785c87..805635e 100644 --- a/src/ReactiveUI.Primitives/Disposables/CancellationDisposable.cs +++ b/src/ReactiveUI.Primitives/Disposables/CancellationDisposable.cs @@ -8,7 +8,8 @@ namespace ReactiveUI.Primitives.Disposables; /// CancellationDisposable. /// /// -public sealed class CancellationDisposable : IsDisposed +[System.Diagnostics.DebuggerDisplay("{DebuggerDisplay,nq}")] +public sealed partial class CancellationDisposable : IsDisposed { /// /// Cancellation source owned by this disposable. diff --git a/src/ReactiveUI.Primitives/Disposables/MultipleDisposable.cs b/src/ReactiveUI.Primitives/Disposables/MultipleDisposable.cs index 03e9e7e..36d10a3 100644 --- a/src/ReactiveUI.Primitives/Disposables/MultipleDisposable.cs +++ b/src/ReactiveUI.Primitives/Disposables/MultipleDisposable.cs @@ -7,7 +7,8 @@ namespace ReactiveUI.Primitives.Disposables; /// /// A disposable pocket that contains a set of disposables and disposes them together. /// -public class MultipleDisposable : IsDisposed +[System.Diagnostics.DebuggerDisplay("{DebuggerDisplay,nq}")] +public partial class MultipleDisposable : IsDisposed { /// /// Initial capacity for overflow disposable storage. diff --git a/src/ReactiveUI.Primitives/Disposables/Pocket.cs b/src/ReactiveUI.Primitives/Disposables/Pocket.cs index aff6be0..4b987d1 100644 --- a/src/ReactiveUI.Primitives/Disposables/Pocket.cs +++ b/src/ReactiveUI.Primitives/Disposables/Pocket.cs @@ -7,7 +7,8 @@ namespace ReactiveUI.Primitives.Disposables; /// /// Primitives alias for a group of disposables that are disposed together. /// -public sealed class Pocket : MultipleDisposable +[System.Diagnostics.DebuggerDisplay("{DebuggerDisplay,nq}")] +public sealed partial class Pocket : MultipleDisposable { /// /// Initializes a new instance of the class. diff --git a/src/ReactiveUI.Primitives/Disposables/SingleDisposable.cs b/src/ReactiveUI.Primitives/Disposables/SingleDisposable.cs index 55143f8..db0c0fb 100644 --- a/src/ReactiveUI.Primitives/Disposables/SingleDisposable.cs +++ b/src/ReactiveUI.Primitives/Disposables/SingleDisposable.cs @@ -7,7 +7,8 @@ namespace ReactiveUI.Primitives.Disposables; /// /// Single-assignment disposable slot. /// -public class SingleDisposable : IsDisposed +[System.Diagnostics.DebuggerDisplay("{DebuggerDisplay,nq}")] +public partial class SingleDisposable : IsDisposed { /// /// Marker used once the slot has been disposed. diff --git a/src/ReactiveUI.Primitives/Disposables/SingleReplaceableDisposable.cs b/src/ReactiveUI.Primitives/Disposables/SingleReplaceableDisposable.cs index 73518db..92e7538 100644 --- a/src/ReactiveUI.Primitives/Disposables/SingleReplaceableDisposable.cs +++ b/src/ReactiveUI.Primitives/Disposables/SingleReplaceableDisposable.cs @@ -7,7 +7,8 @@ namespace ReactiveUI.Primitives.Disposables; /// /// SingleReplaceableDisposable. /// -public class SingleReplaceableDisposable : IsDisposed +[System.Diagnostics.DebuggerDisplay("{DebuggerDisplay,nq}")] +public partial class SingleReplaceableDisposable : IsDisposed { /// /// Marker used once the slot has been disposed. diff --git a/src/ReactiveUI.Primitives/Disposables/Slot.cs b/src/ReactiveUI.Primitives/Disposables/Slot.cs index 4c2ccab..ed54b55 100644 --- a/src/ReactiveUI.Primitives/Disposables/Slot.cs +++ b/src/ReactiveUI.Primitives/Disposables/Slot.cs @@ -7,7 +7,8 @@ namespace ReactiveUI.Primitives.Disposables; /// /// Primitives alias for a replaceable disposable slot. /// -public sealed class Slot : SingleReplaceableDisposable +[System.Diagnostics.DebuggerDisplay("{DebuggerDisplay,nq}")] +public sealed partial class Slot : SingleReplaceableDisposable { /// /// Initializes a new instance of the class. diff --git a/src/ReactiveUI.Primitives/RxVoid.cs b/src/ReactiveUI.Primitives/RxVoid.cs index 5cd8e95..99654dd 100644 --- a/src/ReactiveUI.Primitives/RxVoid.cs +++ b/src/ReactiveUI.Primitives/RxVoid.cs @@ -8,7 +8,8 @@ namespace ReactiveUI.Primitives; /// A Reactive Void. /// [Serializable] -public readonly struct RxVoid : IEquatable +[System.Diagnostics.DebuggerDisplay("{DebuggerDisplay,nq}")] +public readonly partial struct RxVoid : IEquatable { /// /// Gets the single value. diff --git a/src/ReactiveUI.Primitives/Signal/AsyncSignal{T}.cs b/src/ReactiveUI.Primitives/Signal/AsyncSignal{T}.cs index 1cfcec7..35c8650 100644 --- a/src/ReactiveUI.Primitives/Signal/AsyncSignal{T}.cs +++ b/src/ReactiveUI.Primitives/Signal/AsyncSignal{T}.cs @@ -12,7 +12,8 @@ namespace ReactiveUI.Primitives.Signals; /// /// The Type. /// -public class AsyncSignal : IAwaitSignal +[System.Diagnostics.DebuggerDisplay("{DebuggerDisplay,nq}")] +public partial class AsyncSignal : IAwaitSignal { /// /// Executes the new operation. diff --git a/src/ReactiveUI.Primitives/Signal/CommandSignal{TResult}.cs b/src/ReactiveUI.Primitives/Signal/CommandSignal{TResult}.cs index f9c79e6..36dd6b8 100644 --- a/src/ReactiveUI.Primitives/Signal/CommandSignal{TResult}.cs +++ b/src/ReactiveUI.Primitives/Signal/CommandSignal{TResult}.cs @@ -8,7 +8,8 @@ namespace ReactiveUI.Primitives.Signals; /// Minimal reactive command that gates execution and publishes result, fault, and running state streams. /// /// The command result type. -public sealed class CommandSignal : IDisposable +[System.Diagnostics.DebuggerDisplay("{DebuggerDisplay,nq}")] +public sealed partial class CommandSignal : IDisposable { /// /// Stores state for the signal implementation. diff --git a/src/ReactiveUI.Primitives/Signal/ReadOnlyState{T}.cs b/src/ReactiveUI.Primitives/Signal/ReadOnlyState{T}.cs index 438d1ce..d585db3 100644 --- a/src/ReactiveUI.Primitives/Signal/ReadOnlyState{T}.cs +++ b/src/ReactiveUI.Primitives/Signal/ReadOnlyState{T}.cs @@ -8,7 +8,8 @@ namespace ReactiveUI.Primitives.Signals; /// Read-only latest-value signal for projected or externally owned state. /// /// The value type. -public sealed class ReadOnlyState : IObservable, IDisposable +[System.Diagnostics.DebuggerDisplay("{DebuggerDisplay,nq}")] +public sealed partial class ReadOnlyState : IObservable, IDisposable { /// /// Stores state for the signal implementation. diff --git a/src/ReactiveUI.Primitives/Signal/ReplaySignal{T}.cs b/src/ReactiveUI.Primitives/Signal/ReplaySignal{T}.cs index 5bd9c14..49fec04 100644 --- a/src/ReactiveUI.Primitives/Signal/ReplaySignal{T}.cs +++ b/src/ReactiveUI.Primitives/Signal/ReplaySignal{T}.cs @@ -12,7 +12,8 @@ namespace ReactiveUI.Primitives.Signals; /// ReplaySignal. /// /// The Type. -public class ReplaySignal : ISignal +[System.Diagnostics.DebuggerDisplay("{DebuggerDisplay,nq}")] +public partial class ReplaySignal : ISignal { /// /// Stores state for the signal implementation. diff --git a/src/ReactiveUI.Primitives/Signal/Signal{T}.cs b/src/ReactiveUI.Primitives/Signal/Signal{T}.cs index 90fadc0..323c24f 100644 --- a/src/ReactiveUI.Primitives/Signal/Signal{T}.cs +++ b/src/ReactiveUI.Primitives/Signal/Signal{T}.cs @@ -12,7 +12,8 @@ namespace ReactiveUI.Primitives.Signals; /// Subject. /// /// The Type. -public class Signal : ISignal +[System.Diagnostics.DebuggerDisplay("{DebuggerDisplay,nq}")] +public partial class Signal : ISignal { /// /// Stores state for the signal implementation. diff --git a/src/ReactiveUI.Primitives/Signal/StateSignal{T}.cs b/src/ReactiveUI.Primitives/Signal/StateSignal{T}.cs index f602245..7dfb0cb 100644 --- a/src/ReactiveUI.Primitives/Signal/StateSignal{T}.cs +++ b/src/ReactiveUI.Primitives/Signal/StateSignal{T}.cs @@ -10,7 +10,8 @@ namespace ReactiveUI.Primitives.Signals; /// Mutable latest-value signal with a ReactiveUI.Primitives name for reactive-property parity. /// /// The value type. -public class StateSignal : BehaviorSignal +[System.Diagnostics.DebuggerDisplay("{DebuggerDisplay,nq}")] +public partial class StateSignal : BehaviorSignal { /// /// Initializes a new instance of the class. From 588726f552c9945442e9d076f839996769e2c997 Mon Sep 17 00:00:00 2001 From: Chris Pulman Date: Tue, 26 May 2026 08:22:30 +0100 Subject: [PATCH 4/8] Update README.md --- README.md | 3 --- 1 file changed, 3 deletions(-) diff --git a/README.md b/README.md index 3058321..5d29be0 100644 --- a/README.md +++ b/README.md @@ -345,7 +345,6 @@ ReactiveUI.Primitives uses explicit names instead of cloning every System.Reacti | System.Reactive type | ReactiveUI.Primitives equivalent | Notes | |---|---|---| | `Subject` | `Signal` | Push values, errors, and completion to subscribers. | -| `BehaviorSubject` | `BehaviourSignal` or `StateSignal` | Stores the latest value and emits it to new subscribers. `StateSignal` adds a mutable `Value` setter and `Changed`. | | `BehaviorSubject` | `BehaviorSignal`, or `StateSignal` | Stores the latest value and emits it to new subscribers. `StateSignal` adds a mutable `Value` setter and `Changed`. | | `ReplaySubject` | `ReplaySignal` | Replays buffered values by size and/or time window. | | `AsyncSubject` | `AsyncSignal` | Awaitable subject-like signal; also implements `IAwaitSignal`. | @@ -503,7 +502,6 @@ ReactiveUI.Primitives is not a byte-for-byte clone of System.Reactive. It keeps | `Observable.Timer(dueTime)` | `Signal.Timer(dueTime)` or `Signal.After(dueTime)` | Emits `long` tick `0`. | | `Observable.Timer(dueTime, period)` | `Signal.Timer(dueTime, period)` | Periodic `long` ticks. | | `Observable.Interval(period)` | `Signal.Interval(period)` or `Signal.Every(period)` | Repeating ticks. | -| `ToObservable()` from enumerable | `Signal.FromEnumerable(values)` or `values.ToSignal()` | `ToSignal` extension is available. | | `ToObservable()` from enumerable | `Signal.FromEnumerable(values)`, `values.ToSignal()`, or `values.ToObservable()` | Cancellation-token overloads are available. | | task conversion | `Signal.FromTask(task)` | Function-based task signals also exist. | @@ -512,7 +510,6 @@ ReactiveUI.Primitives is not a byte-for-byte clone of System.Reactive. It keeps | System.Reactive | ReactiveUI.Primitives | Migration detail | |---|---|---| | `new Subject()` | `new Signal()` | Use `OnNext`, `OnError`, `OnCompleted`, and `Subscribe`. | -| `new BehaviorSubject(initial)` | `new BehaviourSignal(initial)` | Keeps `Value` getter and emits latest value to subscribers. | | `new BehaviorSubject(initial)` | `new BehaviorSignal(initial)` | Keeps `Value` getter and emits latest value to subscribers. | | mutable reactive property | `new StateSignal(initial)` | Set `Value` to emit. Use `Changed` for observable state stream. | | `new ReplaySubject()` | `new ReplaySignal()` | Unbounded replay. | From 25e69445bc5a6828097405fd5845e92efe9cceda Mon Sep 17 00:00:00 2001 From: Chris Pulman Date: Wed, 27 May 2026 00:04:01 +0100 Subject: [PATCH 5/8] Add WinForms/WPF sequencers and event helpers Introduce platform sequencers and related utilities: add SynchronizationContextSequencer and a WinForms ControlSequencer, move DispatcherSequencer into a WPF folder and update its timing/debugger logic and disposables. Add EventPattern and FromEventPattern overloads, plus convenience operators and aliases: SubscribeOn, Generate alias, LastAsync/LastOrDefaultAsync, ToArray/ToArrayAsync, ToList/ToListAsync. Add new ReactiveUI.Primitives.WinForms and ReactiveUI.Primitives.Wpf projects and include them in the solution; update Directory.Build.props with Windows target TFMs and simplify ReactiveUI.Primitives csproj. Update tests to cover the new sequencers and factory/operator parity branches. --- src/Directory.Build.props | 4 + .../Concurrency/ControlSequencer.cs | 150 ++++++++++++++++++ .../ReactiveUI.Primitives.WinForms.csproj | 17 ++ .../Concurrency/DispatcherSequencer.cs | 28 +++- .../ReactiveUI.Primitives.Wpf.csproj | 17 ++ src/ReactiveUI.Primitives.slnx | 2 + .../SynchronizationContextSequencer.cs | 116 ++++++++++++++ .../Core/EventPattern{TEventArgs}.cs | 74 +++++++++ .../DebuggerDisplay.Partials.cs | 11 -- .../ReactiveUI.Primitives.csproj | 6 - .../SignalOperatorParityMixins.cs | 94 +++++++++++ .../Signals/Signal{Factories}.cs | 66 ++++++++ .../CoverageRuntimeTests.cs | 50 ++++++ .../FactoryOperatorContractTests.cs | 50 +++++- 14 files changed, 660 insertions(+), 25 deletions(-) create mode 100644 src/ReactiveUI.Primitives.WinForms/Concurrency/ControlSequencer.cs create mode 100644 src/ReactiveUI.Primitives.WinForms/ReactiveUI.Primitives.WinForms.csproj rename src/{ReactiveUI.Primitives => ReactiveUI.Primitives.Wpf}/Concurrency/DispatcherSequencer.cs (86%) create mode 100644 src/ReactiveUI.Primitives.Wpf/ReactiveUI.Primitives.Wpf.csproj create mode 100644 src/ReactiveUI.Primitives/Concurrency/SynchronizationContextSequencer.cs create mode 100644 src/ReactiveUI.Primitives/Core/EventPattern{TEventArgs}.cs diff --git a/src/Directory.Build.props b/src/Directory.Build.props index b205410..b3db5f3 100644 --- a/src/Directory.Build.props +++ b/src/Directory.Build.props @@ -55,6 +55,10 @@ $(NetCoreTargetFrameworks);$(NetFrameworkTargetFrameworks) + + net8.0-windows;net9.0-windows;net10.0-windows + $(WindowsNetCoreTargetFrameworks);$(NetFrameworkTargetFrameworks) + $(NetCoreTargetFrameworks) diff --git a/src/ReactiveUI.Primitives.WinForms/Concurrency/ControlSequencer.cs b/src/ReactiveUI.Primitives.WinForms/Concurrency/ControlSequencer.cs new file mode 100644 index 0000000..584b8ae --- /dev/null +++ b/src/ReactiveUI.Primitives.WinForms/Concurrency/ControlSequencer.cs @@ -0,0 +1,150 @@ +// Copyright (c) 2019-2026 ReactiveUI Association Incorporated. All rights reserved. +// ReactiveUI Association Incorporated licenses this file to you under the MIT license. +// See the LICENSE file in the project root for full license information. + +using System.Windows.Forms; +using ReactiveUI.Primitives.Disposables; +using FormsTimer = System.Windows.Forms.Timer; + +namespace ReactiveUI.Primitives.Concurrency; + +/// +/// Windows Forms sequencer that schedules work through a UI control. +/// +/// +[System.Diagnostics.DebuggerDisplay("{DebuggerDisplay,nq}")] +public sealed class ControlSequencer : ISequencer +{ + /// + /// Initializes a new instance of the class. + /// + /// The control used to marshal work to the UI thread. + /// is . + public ControlSequencer(Control control) => + Control = control ?? throw new ArgumentNullException(nameof(control)); + + /// + /// Gets the control used to marshal work to the UI thread. + /// + public Control Control { get; } + + /// + /// Gets the scheduler's notion of current time. + /// + public DateTimeOffset Now + { + get + { +#if NET8_0_OR_GREATER + return TimeProvider.System.GetUtcNow(); +#else +#pragma warning disable S6354 // TimeProvider is not available on supported .NET Framework target frameworks. + return DateTimeOffset.UtcNow; +#pragma warning restore S6354 +#endif + } + } + + /// + /// Gets the debugger display text. + /// + [System.Diagnostics.DebuggerBrowsable(System.Diagnostics.DebuggerBrowsableState.Never)] + private string DebuggerDisplay => ToString() ?? string.Empty; + + /// + /// Schedules an action to be executed. + /// + /// The type of the state passed to the scheduled action. + /// State passed to the action to be executed. + /// Action to be executed. + /// The disposable object used to cancel the scheduled action on a best-effort basis. + /// is . + public IDisposable Schedule(TState state, Func action) + { + if (action == null) + { + throw new ArgumentNullException(nameof(action)); + } + + var cancelable = new BooleanDisposable(); + Control.BeginInvoke((MethodInvoker)(() => + { + if (cancelable.IsDisposed) + { + return; + } + + action(this, state); + })); + + return cancelable; + } + + /// + /// Schedules an action to be executed after dueTime. + /// + /// The type of the state passed to the scheduled action. + /// State passed to the action to be executed. + /// Relative time after which to execute the action. + /// Action to be executed. + /// The disposable object used to cancel the scheduled action on a best-effort basis. + /// is . + public IDisposable Schedule(TState state, TimeSpan dueTime, Func action) + { + if (action == null) + { + throw new ArgumentNullException(nameof(action)); + } + + var timer = new FormsTimer + { + Interval = ToTimerInterval(Sequencer.Normalize(dueTime)), + }; + + timer.Tick += (_, _) => + { + timer.Stop(); + timer.Dispose(); + action(this, state); + }; + timer.Start(); + + return Disposable.Create(() => + { + timer.Stop(); + timer.Dispose(); + }); + } + + /// + /// Schedules an action to be executed at dueTime. + /// + /// The type of the state passed to the scheduled action. + /// State passed to the action to be executed. + /// Absolute time at which to execute the action. + /// Action to be executed. + /// The disposable object used to cancel the scheduled action on a best-effort basis. + public IDisposable Schedule(TState state, DateTimeOffset dueTime, Func action) => + Schedule(state, Sequencer.Normalize(dueTime - Now), action); + + /// + /// Converts the due time to a Windows Forms timer interval. + /// + /// The normalized due time. + /// The timer interval in milliseconds. + private static int ToTimerInterval(TimeSpan dueTime) + { + var totalMilliseconds = dueTime.TotalMilliseconds; + if (totalMilliseconds <= 1) + { + return 1; + } + + if (totalMilliseconds >= int.MaxValue) + { + return int.MaxValue; + } + + return (int)Math.Ceiling(totalMilliseconds); + } +} diff --git a/src/ReactiveUI.Primitives.WinForms/ReactiveUI.Primitives.WinForms.csproj b/src/ReactiveUI.Primitives.WinForms/ReactiveUI.Primitives.WinForms.csproj new file mode 100644 index 0000000..737f496 --- /dev/null +++ b/src/ReactiveUI.Primitives.WinForms/ReactiveUI.Primitives.WinForms.csproj @@ -0,0 +1,17 @@ + + + + $(WindowsLibraryTargetFrameworks) + enable + enable + preview + true + Windows Forms integration sequencers for ReactiveUI.Primitives. + system.reactive;rx;reactive;primitives;winforms;windows-forms;scheduler;sequencer + + + + + + + diff --git a/src/ReactiveUI.Primitives/Concurrency/DispatcherSequencer.cs b/src/ReactiveUI.Primitives.Wpf/Concurrency/DispatcherSequencer.cs similarity index 86% rename from src/ReactiveUI.Primitives/Concurrency/DispatcherSequencer.cs rename to src/ReactiveUI.Primitives.Wpf/Concurrency/DispatcherSequencer.cs index 523cca6..45dac3a 100644 --- a/src/ReactiveUI.Primitives/Concurrency/DispatcherSequencer.cs +++ b/src/ReactiveUI.Primitives.Wpf/Concurrency/DispatcherSequencer.cs @@ -2,12 +2,9 @@ // ReactiveUI Association Incorporated licenses this file to you under the MIT license. // See the LICENSE file in the project root for full license information. -#if WINDOWS - using System; using System.Windows.Threading; using ReactiveUI.Primitives.Disposables; -using static ReactiveUI.Primitives.Disposables.Disposable; namespace ReactiveUI.Primitives.Concurrency; @@ -16,7 +13,7 @@ namespace ReactiveUI.Primitives.Concurrency; /// /// [System.Diagnostics.DebuggerDisplay("{DebuggerDisplay,nq}")] -public partial class DispatcherSequencer : ISequencer +public class DispatcherSequencer : ISequencer { /// /// Initializes a new instance of the class. @@ -37,7 +34,25 @@ public DispatcherSequencer(Dispatcher dispatcher) => /// /// Gets the scheduler's notion of current time. /// - public DateTimeOffset Now => Sequencer.Now; + public DateTimeOffset Now + { + get + { +#if NET8_0_OR_GREATER + return TimeProvider.System.GetUtcNow(); +#else +#pragma warning disable S6354 // TimeProvider is not available on supported .NET Framework target frameworks. + return DateTimeOffset.UtcNow; +#pragma warning restore S6354 +#endif + } + } + + /// + /// Gets the debugger display text. + /// + [System.Diagnostics.DebuggerBrowsable(System.Diagnostics.DebuggerBrowsableState.Never)] + private string DebuggerDisplay => ToString() ?? string.Empty; /// /// Schedules an action to be executed. @@ -97,7 +112,7 @@ public IDisposable Schedule(TState state, TimeSpan dueTime, Func + return Disposable.Create(() => { timer?.Stop(); timer = null; @@ -117,4 +132,3 @@ public IDisposable Schedule(TState state, TimeSpan dueTime, Func(TState state, DateTimeOffset dueTime, Func action) => Schedule(state, Sequencer.Normalize(dueTime - Now), action); } -#endif diff --git a/src/ReactiveUI.Primitives.Wpf/ReactiveUI.Primitives.Wpf.csproj b/src/ReactiveUI.Primitives.Wpf/ReactiveUI.Primitives.Wpf.csproj new file mode 100644 index 0000000..4adfe4c --- /dev/null +++ b/src/ReactiveUI.Primitives.Wpf/ReactiveUI.Primitives.Wpf.csproj @@ -0,0 +1,17 @@ + + + + $(WindowsLibraryTargetFrameworks) + enable + enable + preview + true + WPF integration sequencers for ReactiveUI.Primitives. + system.reactive;rx;reactive;primitives;wpf;dispatcher;scheduler;sequencer + + + + + + + diff --git a/src/ReactiveUI.Primitives.slnx b/src/ReactiveUI.Primitives.slnx index 3e9ae0f..babbf50 100644 --- a/src/ReactiveUI.Primitives.slnx +++ b/src/ReactiveUI.Primitives.slnx @@ -22,4 +22,6 @@ + + diff --git a/src/ReactiveUI.Primitives/Concurrency/SynchronizationContextSequencer.cs b/src/ReactiveUI.Primitives/Concurrency/SynchronizationContextSequencer.cs new file mode 100644 index 0000000..ccafd06 --- /dev/null +++ b/src/ReactiveUI.Primitives/Concurrency/SynchronizationContextSequencer.cs @@ -0,0 +1,116 @@ +// Copyright (c) 2019-2026 ReactiveUI Association Incorporated. All rights reserved. +// ReactiveUI Association Incorporated licenses this file to you under the MIT license. +// See the LICENSE file in the project root for full license information. + +using ReactiveUI.Primitives.Disposables; +using Timer = System.Threading.Timer; + +namespace ReactiveUI.Primitives.Concurrency; + +/// +/// Sequencer that posts work through a . +/// +/// +[System.Diagnostics.DebuggerDisplay("{DebuggerDisplay,nq}")] +public sealed class SynchronizationContextSequencer : ISequencer +{ + /// + /// Initializes a new instance of the class. + /// + /// The synchronization context used to schedule work. + /// is . + public SynchronizationContextSequencer(SynchronizationContext context) => + Context = context ?? throw new ArgumentNullException(nameof(context)); + + /// + /// Gets a sequencer for the current synchronization context. + /// + /// There is no current synchronization context. + public static SynchronizationContextSequencer Current => + new(SynchronizationContext.Current ?? throw new InvalidOperationException("There is no current synchronization context.")); + + /// + /// Gets the synchronization context used to schedule work. + /// + public SynchronizationContext Context { get; } + + /// + /// Gets the scheduler's notion of current time. + /// + public DateTimeOffset Now => Sequencer.Now; + + /// + /// Gets the debugger display text. + /// + [System.Diagnostics.DebuggerBrowsable(System.Diagnostics.DebuggerBrowsableState.Never)] + private string DebuggerDisplay => ToString() ?? string.Empty; + + /// + public IDisposable Schedule(TState state, Func action) + { + if (action == null) + { + throw new ArgumentNullException(nameof(action)); + } + + var cancelable = new BooleanDisposable(); + Context.Post( + _ => + { + if (cancelable.IsDisposed) + { + return; + } + + action(this, state); + }, + null); + + return cancelable; + } + + /// + public IDisposable Schedule(TState state, TimeSpan dueTime, Func action) + { + if (action == null) + { + throw new ArgumentNullException(nameof(action)); + } + + var cancelable = new BooleanDisposable(); + Timer? timer = null; + timer = new Timer( + _ => + { + if (cancelable.IsDisposed) + { + return; + } + + Context.Post( + __ => + { + if (!cancelable.IsDisposed) + { + action(this, state); + } + + timer?.Dispose(); + }, + null); + }, + null, + Sequencer.Normalize(dueTime), + Timeout.InfiniteTimeSpan); + + return Disposable.Create(() => + { + cancelable.Dispose(); + timer.Dispose(); + }); + } + + /// + public IDisposable Schedule(TState state, DateTimeOffset dueTime, Func action) => + Schedule(state, Sequencer.Normalize(dueTime - Now), action); +} diff --git a/src/ReactiveUI.Primitives/Core/EventPattern{TEventArgs}.cs b/src/ReactiveUI.Primitives/Core/EventPattern{TEventArgs}.cs new file mode 100644 index 0000000..3cb7912 --- /dev/null +++ b/src/ReactiveUI.Primitives/Core/EventPattern{TEventArgs}.cs @@ -0,0 +1,74 @@ +// Copyright (c) 2019-2026 ReactiveUI Association Incorporated. All rights reserved. +// ReactiveUI Association Incorporated licenses this file to you under the MIT license. +// See the LICENSE file in the project root for full license information. + +namespace ReactiveUI.Primitives.Core; + +/// +/// Represents a .NET event notification as a value. +/// +/// The event arguments type. +[System.Diagnostics.DebuggerDisplay("{DebuggerDisplay,nq}")] +public readonly struct EventPattern : IEquatable> + where TEventArgs : EventArgs +{ + /// + /// Initializes a new instance of the struct. + /// + /// The event sender. + /// The event arguments. + public EventPattern(object? sender, TEventArgs eventArgs) + { + Sender = sender; + EventArgs = eventArgs ?? throw new ArgumentNullException(nameof(eventArgs)); + } + + /// + /// Gets the event sender. + /// + public object? Sender { get; } + + /// + /// Gets the event arguments. + /// + public TEventArgs EventArgs { get; } + + /// + /// Gets the debugger display text. + /// + [System.Diagnostics.DebuggerBrowsable(System.Diagnostics.DebuggerBrowsableState.Never)] + private string DebuggerDisplay => ToString(); + + /// + /// Compares two event pattern values for equality. + /// + /// The first value. + /// The second value. + /// when the values are equal; otherwise, . + public static bool operator ==(EventPattern left, EventPattern right) => left.Equals(right); + + /// + /// Compares two event pattern values for inequality. + /// + /// The first value. + /// The second value. + /// when the values are not equal; otherwise, . + public static bool operator !=(EventPattern left, EventPattern right) => !left.Equals(right); + + /// + public bool Equals(EventPattern other) => + ReferenceEquals(Sender, other.Sender) && EqualityComparer.Default.Equals(EventArgs, other.EventArgs); + + /// + public override bool Equals(object? obj) => obj is EventPattern other && Equals(other); + + /// + public override int GetHashCode() + { + var senderHashCode = Sender?.GetHashCode() ?? 0; + return (senderHashCode * 397) ^ EqualityComparer.Default.GetHashCode(EventArgs); + } + + /// + public override string ToString() => $"{Sender}: {EventArgs}"; +} diff --git a/src/ReactiveUI.Primitives/DebuggerDisplay.Partials.cs b/src/ReactiveUI.Primitives/DebuggerDisplay.Partials.cs index 90ec635..b9a4d7e 100644 --- a/src/ReactiveUI.Primitives/DebuggerDisplay.Partials.cs +++ b/src/ReactiveUI.Primitives/DebuggerDisplay.Partials.cs @@ -39,17 +39,6 @@ public sealed partial class CurrentThreadSequencer private string DebuggerDisplay => ToString() ?? string.Empty; } -#if WINDOWS - public partial class DispatcherSequencer - { - /// - /// Gets the debugger display text. - /// - [System.Diagnostics.DebuggerBrowsable(System.Diagnostics.DebuggerBrowsableState.Never)] - private string DebuggerDisplay => ToString() ?? string.Empty; - } -#endif - public sealed partial class ImmediateSequencer { /// diff --git a/src/ReactiveUI.Primitives/ReactiveUI.Primitives.csproj b/src/ReactiveUI.Primitives/ReactiveUI.Primitives.csproj index 0f72b04..6ae0eb4 100644 --- a/src/ReactiveUI.Primitives/ReactiveUI.Primitives.csproj +++ b/src/ReactiveUI.Primitives/ReactiveUI.Primitives.csproj @@ -7,12 +7,6 @@ preview - - true - true - $(DefineConstants);WINDOWS - - 15.0 diff --git a/src/ReactiveUI.Primitives/SignalOperatorParityMixins.cs b/src/ReactiveUI.Primitives/SignalOperatorParityMixins.cs index 2416280..d30a700 100644 --- a/src/ReactiveUI.Primitives/SignalOperatorParityMixins.cs +++ b/src/ReactiveUI.Primitives/SignalOperatorParityMixins.cs @@ -174,6 +174,34 @@ public static IObservable ObserveOn(this IObservable source, ISequencer return source.WitnessOn(scheduler); } + /// + /// Schedules source subscription on the supplied sequencer. + /// + /// The value type. + /// The source sequence. + /// The sequencer used to perform subscription. + /// A sequence that subscribes to on . + /// or is . + public static IObservable SubscribeOn(this IObservable source, ISequencer scheduler) + { + if (source == null) + { + throw new ArgumentNullException(nameof(source)); + } + + if (scheduler == null) + { + throw new ArgumentNullException(nameof(scheduler)); + } + + return Signal.Create(observer => + { + var subscription = new SingleReplaceableDisposable(); + var scheduled = scheduler.Schedule(() => subscription.Create(source.Subscribe(observer))); + return MultipleDisposable.Create(scheduled, subscription); + }); + } + /// /// Alias for using the System.Reactive operator name. /// @@ -1210,6 +1238,40 @@ public static Task ToTask(this IObservable source, CancellationToken ca /// is . public static Task ToTask(this Task task) => task ?? throw new ArgumentNullException(nameof(task)); + /// + /// Awaits source completion and returns the last value produced by the source. + /// + /// The value type. + /// The source sequence. + /// A task that completes with the final source value. + public static Task LastAsync(this IObservable source) => source.ToTask(); + + /// + /// Awaits source completion and returns the last value produced by the source, or when the source is empty. + /// + /// The value type. + /// The source sequence. + /// A task that completes with the final source value, or when the source is empty. + public static Task LastOrDefaultAsync(this IObservable source) => + source.LastOrDefaultAsync(default!); + + /// + /// Awaits source completion and returns the last value produced by the source, or when the source is empty. + /// + /// The value type. + /// The source sequence. + /// The fallback value to use when the source is empty. + /// A task that completes with the final source value, or when the source is empty. + public static Task LastOrDefaultAsync(this IObservable source, T defaultValue) + { + if (source == null) + { + throw new ArgumentNullException(nameof(source)); + } + + return source.DefaultIfEmpty(defaultValue).ToTask(); + } + /// /// Awaits the source count as a task. /// @@ -1323,6 +1385,22 @@ public static Task CollectArrayAsync(this IObservable source) return completion.Task; } + /// + /// Collects all values into an array. + /// + /// The value type. + /// The source sequence. + /// A sequence that emits a single array containing all source values. + public static IObservable ToArray(this IObservable source) => source.CollectArray(); + + /// + /// Collects all values into an array task. + /// + /// The value type. + /// The source sequence. + /// A task that completes with all source values in an array. + public static Task ToArrayAsync(this IObservable source) => source.CollectArrayAsync(); + /// /// Collects all values into a list task. /// @@ -1348,6 +1426,22 @@ public static Task> CollectListAsync(this IObservable source) return completion.Task; } + /// + /// Collects all values into a list. + /// + /// The value type. + /// The source sequence. + /// A sequence that emits a single list containing all source values. + public static IObservable> ToList(this IObservable source) => source.CollectList(); + + /// + /// Collects all values into a list task. + /// + /// The value type. + /// The source sequence. + /// A task that completes with all source values in a list. + public static Task> ToListAsync(this IObservable source) => source.CollectListAsync(); + /// /// Awaits the first source value and applies the configured empty-source behavior. /// diff --git a/src/ReactiveUI.Primitives/Signals/Signal{Factories}.cs b/src/ReactiveUI.Primitives/Signals/Signal{Factories}.cs index a22ac19..e912de5 100644 --- a/src/ReactiveUI.Primitives/Signals/Signal{Factories}.cs +++ b/src/ReactiveUI.Primitives/Signals/Signal{Factories}.cs @@ -3,6 +3,7 @@ // See the LICENSE file in the project root for full license information. using ReactiveUI.Primitives.Concurrency; +using ReactiveUI.Primitives.Core; using ReactiveUI.Primitives.Disposables; using ReactiveUI.Primitives.Signals.Core; @@ -128,6 +129,16 @@ public static IObservable Unfold( true); } + /// + /// Generates a finite signal from state. Alias of . + /// + public static IObservable Generate( + TState initialState, + Func condition, + Func iterate, + Func resultSelector) => + Unfold(initialState, condition, iterate, resultSelector); + /// /// Creates a signal whose subscription lifetime owns a resource. /// @@ -171,6 +182,61 @@ public static IObservable Using(Func resourceFactory where TResource : IDisposable => Use(resourceFactory, signalFactory); + /// + /// Converts an event into a signal of event pattern values. + /// + public static IObservable> FromEventPattern( + Action addHandler, + Action removeHandler) + { + if (addHandler == null) + { + throw new ArgumentNullException(nameof(addHandler)); + } + + if (removeHandler == null) + { + throw new ArgumentNullException(nameof(removeHandler)); + } + + return Create>(observer => + { + void Handler(object? sender, EventArgs eventArgs) => + observer.OnNext(new EventPattern(sender, eventArgs)); + + addHandler(Handler); + return Disposable.Create(() => removeHandler(Handler)); + }); + } + + /// + /// Converts an event into a signal of event pattern values. + /// + public static IObservable> FromEventPattern( + Action> addHandler, + Action> removeHandler) + where TEventArgs : EventArgs + { + if (addHandler == null) + { + throw new ArgumentNullException(nameof(addHandler)); + } + + if (removeHandler == null) + { + throw new ArgumentNullException(nameof(removeHandler)); + } + + return Create>(observer => + { + void Handler(object? sender, TEventArgs eventArgs) => + observer.OnNext(new EventPattern(sender, eventArgs)); + + addHandler(Handler); + return Disposable.Create(() => removeHandler(Handler)); + }); + } + /// /// Creates a signal from an enumerable sequence. /// diff --git a/src/tests/ReactiveUI.Primitives.Tests/CoverageRuntimeTests.cs b/src/tests/ReactiveUI.Primitives.Tests/CoverageRuntimeTests.cs index f0b57bf..949d463 100644 --- a/src/tests/ReactiveUI.Primitives.Tests/CoverageRuntimeTests.cs +++ b/src/tests/ReactiveUI.Primitives.Tests/CoverageRuntimeTests.cs @@ -4,6 +4,7 @@ using System; using System.Collections.Generic; +using System.Threading; using System.Threading.Tasks; using ReactiveUI.Primitives.Concurrency; using ReactiveUI.Primitives.Core; @@ -439,6 +440,40 @@ public async Task SequencersCoverValidationAndExecutionBranches() Assert.Throws(() => ThreadPoolSequencer.Instance.Schedule(One, null!)); Assert.Throws(() => ThreadPoolSequencer.Instance.Schedule(One, TimeSpan.Zero, null!)); + + var synchronizationContext = new ImmediateSynchronizationContext(); + Assert.Throws(CreateSynchronizationContextSequencerWithoutContext); + var previousContext = SynchronizationContext.Current; + try + { + SynchronizationContext.SetSynchronizationContext(synchronizationContext); + Assert.Same(synchronizationContext, SynchronizationContextSequencer.Current.Context); + } + finally + { + SynchronizationContext.SetSynchronizationContext(previousContext); + } + + var synchronizationSequencer = new SynchronizationContextSequencer(synchronizationContext); + Assert.True(synchronizationSequencer.Now > DateTimeOffset.MinValue); + Assert.Throws(() => synchronizationSequencer.Schedule(One, null!)); + Assert.Throws(() => synchronizationSequencer.Schedule(One, TimeSpan.Zero, null!)); + + var synchronizationValues = new List(); + using var synchronizationSubscription = synchronizationSequencer.Schedule(One, (_, state) => + { + synchronizationValues.Add(state); + return Disposable.Empty; + }); + + var delayedSynchronizationCompletion = new TaskCompletionSource(TaskCreationOptions.RunContinuationsAsynchronously); + using var delayedSynchronizationSubscription = synchronizationSequencer.Schedule(Two, TimeSpan.Zero, (_, state) => + { + delayedSynchronizationCompletion.TrySetResult(state); + return Disposable.Empty; + }); + var delayedValue = await delayedSynchronizationCompletion.Task.WaitAsync(TimeSpan.FromSeconds(TimeoutSeconds)); + Assert.Equal(ExpectedOneTwo, (IEnumerable)[.. synchronizationValues, delayedValue]); } /// @@ -498,6 +533,12 @@ private static void CreateScheduledItemWithoutAction() => private static void CreateScheduledItemWithoutComparer() => _ = new ScheduledItem(Sequencer.Immediate, "x", (_, _) => Disposable.Empty, One, null!); + /// + /// Creates a synchronization-context sequencer without a context. + /// + private static void CreateSynchronizationContextSequencerWithoutContext() => + _ = new SynchronizationContextSequencer(null!); + /// /// Compares a scheduled item through the non-generic comparable interface. /// @@ -582,6 +623,15 @@ public ExposedMultipleDisposable(IDisposable disposable) public void DisposeFalse() => Dispose(false); } + /// + /// Synchronization context that runs posted work immediately. + /// + private sealed class ImmediateSynchronizationContext : SynchronizationContext + { + /// + public override void Post(SendOrPostCallback d, object? state) => d(state); + } + /// /// Records observer values and terminal signals. /// diff --git a/src/tests/ReactiveUI.Primitives.Tests/FactoryOperatorContractTests.cs b/src/tests/ReactiveUI.Primitives.Tests/FactoryOperatorContractTests.cs index 0890a11..db207cd 100644 --- a/src/tests/ReactiveUI.Primitives.Tests/FactoryOperatorContractTests.cs +++ b/src/tests/ReactiveUI.Primitives.Tests/FactoryOperatorContractTests.cs @@ -655,6 +655,10 @@ public async Task FactoryAliasesAndGuardsCoverParityBranches() Assert.Throws(() => Signal.Unfold(0, null!, static state => state, static state => state)); Assert.Throws(() => Signal.Unfold(0, static _ => true, null!, static state => state)); Assert.Throws(() => Signal.Unfold(0, static _ => true, static state => state, null!)); + Assert.Throws(() => Signal.FromEventPattern(null!, _ => { })); + Assert.Throws(() => Signal.FromEventPattern(_ => { }, null!)); + Assert.Throws(() => Signal.FromEventPattern(null!, _ => { })); + Assert.Throws(() => Signal.FromEventPattern(_ => { }, null!)); Assert.Throws(() => Signal.Start((Func)null!)); Assert.Throws(() => Signal.Start(static () => FirstValue, null!)); Assert.Throws(() => Signal.Start((Action)null!)); @@ -663,20 +667,34 @@ public async Task FactoryAliasesAndGuardsCoverParityBranches() Assert.Throws(() => Signal.Timer(TimeSpan.Zero, TimeSpan.Zero, null!)); Assert.Throws(() => Signal.FromAsync((Func>)null!)); Assert.Throws(() => Signal.FromAsync((Func>)null!)); + Assert.Throws(() => ((IObservable)null!).SubscribeOn(Sequencer.Immediate)); + Assert.Throws(() => Signal.Empty().SubscribeOn(null!)); Signal.Range(FirstValue, 0).Subscribe(values.Add, errors.Add, () => completed++); Signal.Repeat(FirstValue, 0).Subscribe(values.Add, errors.Add, () => completed++); + Signal.Generate(FirstValue, value => value <= SecondValue, value => value + 1, value => value).Subscribe(values.Add); + Signal.Range(FirstValue, SecondValue).SubscribeOn(Sequencer.Immediate).Subscribe(values.Add); new[] { FirstValue, SecondValue }.ToObservable(cancelled.Token).Subscribe(values.Add, errors.Add, () => completed++); Signal.Start(() => throw new InvalidOperationException("start failed"), Sequencer.Immediate).Subscribe(values.Add, errors.Add, () => completed++); + var eventSource = new EventSource(); + var eventValues = new List>(); + using (Signal.FromEventPattern(handler => eventSource.Raised += handler, handler => eventSource.Raised -= handler).Subscribe(eventValues.Add)) + { + eventSource.Raise(); + } + var fromAsync = await Signal.FromAsync(() => Task.FromResult(RetryResult)).ToTask(); var fromAsyncWithToken = await Signal.FromAsync(static token => Task.FromResult(token.IsCancellationRequested ? -1 : RetrySuccessAttempt)).ToTask(); Assert.Equal(RetryResult, fromAsync); Assert.Equal(RetrySuccessAttempt, fromAsyncWithToken); - Assert.Equal(0, values.Count); + Assert.Equal(new[] { FirstValue, SecondValue, FirstValue, SecondValue }, values); Assert.Equal(SecondValue, completed); Assert.Equal(1, errors.Count); + Assert.Equal(1, eventValues.Count); + Assert.Same(eventSource, eventValues[0].Sender); + Assert.Same(EventArgs.Empty, eventValues[0].EventArgs); } /// @@ -752,14 +770,44 @@ private static async Task VerifyTaskAliasOperators() { var converted = new[] { 4, AfterTicks }.ToObservable(); var last = await converted.ToTask(); + var lastAlias = await converted.LastAsync(); + var lastDefault = await Signal.Empty().LastOrDefaultAsync(RetryResult); + var array = await Signal.Range(FirstValue, FourthValue).ToArrayAsync(); + var list = await Signal.Range(FirstValue, FourthValue).ToListAsync(); +#pragma warning disable S6966 // This verifies the observable ToArray/ToList aliases, not async enumerable materialization. + var observedArray = await Signal.Range(FirstValue, SecondValue).ToArray().ToTask(); + var observedList = await Signal.Range(FirstValue, SecondValue).ToList().ToTask(); +#pragma warning restore S6966 var first = await Signal.FromEnumerable([RepeatValue, ProjectionMultiplier]).FirstAsync().ToTask(); var started = await Signal.Start(() => ProjectedSecondValue, Sequencer.CurrentThread).ToTask(); Assert.Equal(AfterTicks, last); + Assert.Equal(AfterTicks, lastAlias); + Assert.Equal(RetryResult, lastDefault); + Assert.Equal(FourItemExpected, (IEnumerable)array); + Assert.Equal(FourItemExpected, (IEnumerable)list); + Assert.Equal((IEnumerable)[FirstValue, SecondValue], observedArray); + Assert.Equal([FirstValue, SecondValue], (IEnumerable)observedList); Assert.Equal(RepeatValue, first); Assert.Equal(ProjectedSecondValue, started); } + /// + /// Test event source. + /// + private sealed class EventSource + { + /// + /// Raised when is called. + /// + public event EventHandler? Raised; + + /// + /// Raises the event. + /// + public void Raise() => Raised?.Invoke(this, EventArgs.Empty); + } + /// /// Records observer values and terminal signals. /// From 154b7c9cc743c2442f8c9cb4bfe0556e59e5db43 Mon Sep 17 00:00:00 2001 From: Chris Pulman Date: Wed, 27 May 2026 07:22:35 +0100 Subject: [PATCH 6/8] Add Blazor and MAUI integrations and sequencers Introduce focused platform integration projects and scheduling primitives for Blazor and MAUI. - Add new projects: ReactiveUI.Primitives.Blazor and ReactiveUI.Primitives.Maui and include them in the solution. - Add ReactiveComponentBase (Blazor) to manage subscriptions and refresh via ComponentBase.InvokeAsync. - Add BlazorRendererSequencer to marshal sequenced work through a Blazor renderer delegate. - Add MauiDispatcherSequencer and ToSequencer mixin to schedule work via a MAUI IDispatcher. - Update Directory.Build.props to expose BlazorTargetFrameworks and MauiTargetFrameworks. - Update Directory.Packages.props with conditional package versions for Microsoft.AspNetCore.Components and Microsoft.Maui.Core. - Signals: add Timer overloads (absolute DateTimeOffset variants) and an Amb alias for Race. - Fix SubscribeAsyncEnumerable to avoid a race when disposing after completion (use a disposed flag/Interlocked and handle ObjectDisposedException). - Update tests (FactoryOperatorContractTests) to exercise Amb, absolute Timer behavior, async-enumerable disposal, and add guard tests. These changes add cross-platform integration points so ReactiveUI.Primitives can marshal work to Blazor and MAUI UI dispatchers and ensure related factories and tests cover the new behaviors. --- src/Directory.Build.props | 4 + src/Directory.Packages.props | 5 + .../Components/ReactiveComponentBase.cs | 213 ++++++++++++++++++ .../Concurrency/BlazorRendererSequencer.cs | 139 ++++++++++++ .../ReactiveUI.Primitives.Blazor.csproj | 17 ++ .../Concurrency/MauiDispatcherSequencer.cs | 123 ++++++++++ .../MauiDispatcherSequencerMixins.cs | 29 +++ .../ReactiveUI.Primitives.Maui.csproj | 17 ++ src/ReactiveUI.Primitives.slnx | 2 + .../Signals/Signal{Factories}.cs | 53 ++++- .../FactoryOperatorContractTests.cs | 26 +++ 11 files changed, 626 insertions(+), 2 deletions(-) create mode 100644 src/ReactiveUI.Primitives.Blazor/Components/ReactiveComponentBase.cs create mode 100644 src/ReactiveUI.Primitives.Blazor/Concurrency/BlazorRendererSequencer.cs create mode 100644 src/ReactiveUI.Primitives.Blazor/ReactiveUI.Primitives.Blazor.csproj create mode 100644 src/ReactiveUI.Primitives.Maui/Concurrency/MauiDispatcherSequencer.cs create mode 100644 src/ReactiveUI.Primitives.Maui/Concurrency/MauiDispatcherSequencerMixins.cs create mode 100644 src/ReactiveUI.Primitives.Maui/ReactiveUI.Primitives.Maui.csproj diff --git a/src/Directory.Build.props b/src/Directory.Build.props index b3db5f3..7493ad3 100644 --- a/src/Directory.Build.props +++ b/src/Directory.Build.props @@ -59,6 +59,10 @@ net8.0-windows;net9.0-windows;net10.0-windows $(WindowsNetCoreTargetFrameworks);$(NetFrameworkTargetFrameworks) + + $(NetCoreTargetFrameworks) + net9.0;net10.0 + $(NetCoreTargetFrameworks) diff --git a/src/Directory.Packages.props b/src/Directory.Packages.props index 0389a14..195198c 100644 --- a/src/Directory.Packages.props +++ b/src/Directory.Packages.props @@ -20,7 +20,12 @@ + + + + + diff --git a/src/ReactiveUI.Primitives.Blazor/Components/ReactiveComponentBase.cs b/src/ReactiveUI.Primitives.Blazor/Components/ReactiveComponentBase.cs new file mode 100644 index 0000000..ea23b19 --- /dev/null +++ b/src/ReactiveUI.Primitives.Blazor/Components/ReactiveComponentBase.cs @@ -0,0 +1,213 @@ +// Copyright (c) 2019-2026 ReactiveUI Association Incorporated. All rights reserved. +// ReactiveUI Association Incorporated licenses this file to you under the MIT license. +// See the LICENSE file in the project root for full license information. + +using Microsoft.AspNetCore.Components; +using ReactiveUI.Primitives.Blazor.Concurrency; +using ReactiveUI.Primitives.Concurrency; +using ReactiveUI.Primitives.Disposables; + +namespace ReactiveUI.Primitives.Blazor.Components; + +/// +/// Base component that tracks reactive subscriptions and refreshes through Blazor's renderer dispatcher. +/// +[System.Diagnostics.DebuggerDisplay("{DebuggerDisplay,nq}")] +public abstract class ReactiveComponentBase : ComponentBase, IDisposable +{ + /// + /// Tracks subscriptions owned by the component. + /// + private readonly MultipleDisposable _subscriptions = new(); + + /// + /// Value indicating whether the component has been disposed. + /// + private bool _disposed; + + /// + /// Initializes a new instance of the class. + /// + protected ReactiveComponentBase() => + RendererSequencer = new BlazorRendererSequencer(InvokeAsync); + + /// + /// Gets a value indicating whether the component has been disposed. + /// + protected bool IsDisposed => _disposed; + + /// + /// Gets a sequencer that schedules work through the Blazor renderer dispatcher. + /// + protected ISequencer RendererSequencer { get; } + + /// + /// Gets the debugger display text. + /// + [System.Diagnostics.DebuggerBrowsable(System.Diagnostics.DebuggerBrowsableState.Never)] + private string DebuggerDisplay => $"IsDisposed = {IsDisposed}"; + + /// + /// Disposes the component and all tracked subscriptions. + /// + public void Dispose() + { + Dispose(disposing: true); + GC.SuppressFinalize(this); + } + + /// + /// Tracks a subscription so it is disposed when the component is disposed. + /// + /// The subscription to track. + /// The supplied subscription, or when the component has already been disposed. + /// is . + protected IDisposable Track(IDisposable subscription) + { + if (subscription == null) + { + throw new ArgumentNullException(nameof(subscription)); + } + + if (IsDisposed) + { + subscription.Dispose(); + return Disposable.Empty; + } + + _subscriptions.Add(subscription); + return subscription; + } + + /// + /// Subscribes to a source and refreshes the component after each value. + /// + /// The source value type. + /// The source sequence. + /// Action invoked for each value on the Blazor renderer dispatcher. + /// A tracked subscription. + /// or is . + protected IDisposable Observe(IObservable source, Action onNext) => + Observe(source, onNext, null, null); + + /// + /// Subscribes to a source and refreshes the component after each observed signal. + /// + /// The source value type. + /// The source sequence. + /// Action invoked for each value on the Blazor renderer dispatcher. + /// Optional action invoked when the source errors. + /// Optional action invoked when the source completes. + /// A tracked subscription. + /// or is . + protected IDisposable Observe( + IObservable source, + Action onNext, + Action? onError, + Action? onCompleted) => + Observe(source, onNext, onError, onCompleted, true); + + /// + /// Subscribes to a source and refreshes the component after each observed signal. + /// + /// The source value type. + /// The source sequence. + /// Action invoked for each value on the Blazor renderer dispatcher. + /// Optional action invoked when the source errors. + /// Optional action invoked when the source completes. + /// A value indicating whether to call after callbacks. + /// A tracked subscription. + /// or is . + protected IDisposable Observe( + IObservable source, + Action onNext, + Action? onError, + Action? onCompleted, + bool refreshAfterCallbacks) + { + if (source == null) + { + throw new ArgumentNullException(nameof(source)); + } + + if (onNext == null) + { + throw new ArgumentNullException(nameof(onNext)); + } + + return Track(source.Subscribe( + value => _ = InvokeAsync(() => + { + onNext(value); + Refresh(refreshAfterCallbacks); + }), + error => _ = InvokeAsync(() => + { + if (onError == null) + { + OnObservedError(error); + } + else + { + onError(error); + } + + Refresh(refreshAfterCallbacks); + }), + () => _ = InvokeAsync(() => + { + onCompleted?.Invoke(); + Refresh(refreshAfterCallbacks); + }))); + } + + /// + /// Invalidates the component through Blazor's renderer dispatcher. + /// + /// A task that completes when the renderer has accepted the invalidation callback. + protected Task InvalidateAsync() => InvokeAsync(StateHasChanged); + + /// + /// Handles an unhandled subscription error. + /// + /// The observed error. + /// Always thrown to surface the subscription error. + protected virtual void OnObservedError(Exception error) + { + if (error == null) + { + throw new ArgumentNullException(nameof(error)); + } + + throw new InvalidOperationException("The reactive subscription failed.", error); + } + + /// + /// Releases resources used by the component. + /// + /// when managed resources should be released. + protected virtual void Dispose(bool disposing) + { + if (!disposing || _disposed) + { + return; + } + + _disposed = true; + _subscriptions.Dispose(); + } + + /// + /// Refreshes the component when requested and when it is still active. + /// + /// A value indicating whether refresh is requested. + private void Refresh(bool shouldRefresh) + { + if (!shouldRefresh || IsDisposed) + { + return; + } + + StateHasChanged(); + } +} diff --git a/src/ReactiveUI.Primitives.Blazor/Concurrency/BlazorRendererSequencer.cs b/src/ReactiveUI.Primitives.Blazor/Concurrency/BlazorRendererSequencer.cs new file mode 100644 index 0000000..5f2324c --- /dev/null +++ b/src/ReactiveUI.Primitives.Blazor/Concurrency/BlazorRendererSequencer.cs @@ -0,0 +1,139 @@ +// Copyright (c) 2019-2026 ReactiveUI Association Incorporated. All rights reserved. +// ReactiveUI Association Incorporated licenses this file to you under the MIT license. +// See the LICENSE file in the project root for full license information. + +using ReactiveUI.Primitives.Concurrency; +using ReactiveUI.Primitives.Disposables; + +namespace ReactiveUI.Primitives.Blazor.Concurrency; + +/// +/// Sequencer that schedules work through a Blazor renderer dispatcher delegate. +/// +/// +[System.Diagnostics.DebuggerDisplay("{DebuggerDisplay,nq}")] +public sealed class BlazorRendererSequencer : ISequencer +{ + /// + /// Delegate used to marshal work through Blazor's renderer. + /// + private readonly Func _invokeAsync; + + /// + /// Initializes a new instance of the class. + /// + /// A delegate such as ComponentBase.InvokeAsync that runs work through the renderer. + /// is . + public BlazorRendererSequencer(Func invokeAsync) => + _invokeAsync = invokeAsync ?? throw new ArgumentNullException(nameof(invokeAsync)); + + /// + /// Gets the scheduler's notion of current time. + /// + public DateTimeOffset Now => TimeProvider.System.GetUtcNow(); + + /// + /// Gets the debugger display text. + /// + [System.Diagnostics.DebuggerBrowsable(System.Diagnostics.DebuggerBrowsableState.Never)] + private string DebuggerDisplay => ToString() ?? string.Empty; + + /// + /// Schedules an action to be executed. + /// + /// The type of the state passed to the scheduled action. + /// State passed to the action to be executed. + /// Action to be executed. + /// The disposable object used to cancel the scheduled action on a best-effort basis. + /// is . + public IDisposable Schedule(TState state, Func action) + { + if (action == null) + { + throw new ArgumentNullException(nameof(action)); + } + + var cancelable = new BooleanDisposable(); + _ = _invokeAsync(() => + { + if (cancelable.IsDisposed) + { + return; + } + + action(this, state); + }); + return cancelable; + } + + /// + /// Schedules an action to be executed after dueTime. + /// + /// The type of the state passed to the scheduled action. + /// State passed to the action to be executed. + /// Relative time after which to execute the action. + /// Action to be executed. + /// The disposable object used to cancel the scheduled action on a best-effort basis. + /// is . + public IDisposable Schedule(TState state, TimeSpan dueTime, Func action) + { + if (action == null) + { + throw new ArgumentNullException(nameof(action)); + } + + var cancellation = new CancellationDisposable(); + _ = DelayThenDispatchAsync(state, Sequencer.Normalize(dueTime), action, cancellation.Token); + return cancellation; + } + + /// + /// Schedules an action to be executed at dueTime. + /// + /// The type of the state passed to the scheduled action. + /// State passed to the action to be executed. + /// Absolute time at which to execute the action. + /// Action to be executed. + /// The disposable object used to cancel the scheduled action on a best-effort basis. + public IDisposable Schedule(TState state, DateTimeOffset dueTime, Func action) => + Schedule(state, Sequencer.Normalize(dueTime - Now), action); + + /// + /// Delays work and then dispatches it through the renderer. + /// + /// The type of the state passed to the scheduled action. + /// State passed to the action to be executed. + /// The normalized due time. + /// Action to be executed. + /// Token used to cancel delayed work. + /// A task representing the asynchronous delay and dispatch. + private async Task DelayThenDispatchAsync( + TState state, + TimeSpan dueTime, + Func action, + CancellationToken cancellationToken) + { + try + { + await Task.Delay(dueTime, cancellationToken).ConfigureAwait(false); + if (cancellationToken.IsCancellationRequested) + { + return; + } + + await _invokeAsync(() => + { + if (cancellationToken.IsCancellationRequested) + { + return; + } + + action(this, state); + }).ConfigureAwait(false); + } + catch (OperationCanceledException) when (cancellationToken.IsCancellationRequested) + { + // Cancellation is the expected disposal path for delayed renderer work. + } + } +} diff --git a/src/ReactiveUI.Primitives.Blazor/ReactiveUI.Primitives.Blazor.csproj b/src/ReactiveUI.Primitives.Blazor/ReactiveUI.Primitives.Blazor.csproj new file mode 100644 index 0000000..3cd097d --- /dev/null +++ b/src/ReactiveUI.Primitives.Blazor/ReactiveUI.Primitives.Blazor.csproj @@ -0,0 +1,17 @@ + + + + $(BlazorTargetFrameworks) + enable + enable + preview + Blazor integration helpers for ReactiveUI.Primitives. + system.reactive;rx;reactive;primitives;blazor;components;subscriptions + + + + + + + + diff --git a/src/ReactiveUI.Primitives.Maui/Concurrency/MauiDispatcherSequencer.cs b/src/ReactiveUI.Primitives.Maui/Concurrency/MauiDispatcherSequencer.cs new file mode 100644 index 0000000..ffc8ec2 --- /dev/null +++ b/src/ReactiveUI.Primitives.Maui/Concurrency/MauiDispatcherSequencer.cs @@ -0,0 +1,123 @@ +// Copyright (c) 2019-2026 ReactiveUI Association Incorporated. All rights reserved. +// ReactiveUI Association Incorporated licenses this file to you under the MIT license. +// See the LICENSE file in the project root for full license information. + +using Microsoft.Maui.Dispatching; +using ReactiveUI.Primitives.Disposables; + +namespace ReactiveUI.Primitives.Concurrency; + +/// +/// MAUI dispatcher sequencer that schedules work through an . +/// +/// +[System.Diagnostics.DebuggerDisplay("{DebuggerDisplay,nq}")] +public sealed class MauiDispatcherSequencer : ISequencer +{ + /// + /// Initializes a new instance of the class. + /// + /// The dispatcher used to marshal work to the UI thread. + /// is . + public MauiDispatcherSequencer(IDispatcher dispatcher) => + Dispatcher = dispatcher ?? throw new ArgumentNullException(nameof(dispatcher)); + + /// + /// Gets the dispatcher used to marshal work to the UI thread. + /// + public IDispatcher Dispatcher { get; } + + /// + /// Gets the scheduler's notion of current time. + /// + public DateTimeOffset Now => TimeProvider.System.GetUtcNow(); + + /// + /// Gets the debugger display text. + /// + [System.Diagnostics.DebuggerBrowsable(System.Diagnostics.DebuggerBrowsableState.Never)] + private string DebuggerDisplay => ToString() ?? string.Empty; + + /// + /// Schedules an action to be executed. + /// + /// The type of the state passed to the scheduled action. + /// State passed to the action to be executed. + /// Action to be executed. + /// The disposable object used to cancel the scheduled action on a best-effort basis. + /// is . + public IDisposable Schedule(TState state, Func action) + { + if (action == null) + { + throw new ArgumentNullException(nameof(action)); + } + + var cancelable = new BooleanDisposable(); + Dispatcher.Dispatch(() => + { + if (cancelable.IsDisposed) + { + return; + } + + action(this, state); + }); + + return cancelable; + } + + /// + /// Schedules an action to be executed after dueTime. + /// + /// The type of the state passed to the scheduled action. + /// State passed to the action to be executed. + /// Relative time after which to execute the action. + /// Action to be executed. + /// The disposable object used to cancel the scheduled action on a best-effort basis. + /// is . + public IDisposable Schedule(TState state, TimeSpan dueTime, Func action) + { + if (action == null) + { + throw new ArgumentNullException(nameof(action)); + } + + var cancelable = new BooleanDisposable(); + var timer = Dispatcher.CreateTimer(); + timer.Interval = Sequencer.Normalize(dueTime); + timer.IsRepeating = false; + timer.Tick += OnTick; + timer.Start(); + + return Disposable.Create(() => + { + cancelable.Dispose(); + timer.Stop(); + timer.Tick -= OnTick; + }); + + void OnTick(object? sender, EventArgs eventArgs) + { + timer.Stop(); + timer.Tick -= OnTick; + if (cancelable.IsDisposed) + { + return; + } + + action(this, state); + } + } + + /// + /// Schedules an action to be executed at dueTime. + /// + /// The type of the state passed to the scheduled action. + /// State passed to the action to be executed. + /// Absolute time at which to execute the action. + /// Action to be executed. + /// The disposable object used to cancel the scheduled action on a best-effort basis. + public IDisposable Schedule(TState state, DateTimeOffset dueTime, Func action) => + Schedule(state, Sequencer.Normalize(dueTime - Now), action); +} diff --git a/src/ReactiveUI.Primitives.Maui/Concurrency/MauiDispatcherSequencerMixins.cs b/src/ReactiveUI.Primitives.Maui/Concurrency/MauiDispatcherSequencerMixins.cs new file mode 100644 index 0000000..2b84f3c --- /dev/null +++ b/src/ReactiveUI.Primitives.Maui/Concurrency/MauiDispatcherSequencerMixins.cs @@ -0,0 +1,29 @@ +// Copyright (c) 2019-2026 ReactiveUI Association Incorporated. All rights reserved. +// ReactiveUI Association Incorporated licenses this file to you under the MIT license. +// See the LICENSE file in the project root for full license information. + +using Microsoft.Maui.Dispatching; + +namespace ReactiveUI.Primitives.Concurrency; + +/// +/// Convenience helpers for MAUI dispatcher sequencers. +/// +public static class MauiDispatcherSequencerMixins +{ + /// + /// Adapts a MAUI dispatcher to an . + /// + /// The dispatcher to adapt. + /// A sequencer that schedules through . + /// is . + public static MauiDispatcherSequencer ToSequencer(this IDispatcher dispatcher) + { + if (dispatcher == null) + { + throw new ArgumentNullException(nameof(dispatcher)); + } + + return new MauiDispatcherSequencer(dispatcher); + } +} diff --git a/src/ReactiveUI.Primitives.Maui/ReactiveUI.Primitives.Maui.csproj b/src/ReactiveUI.Primitives.Maui/ReactiveUI.Primitives.Maui.csproj new file mode 100644 index 0000000..b9ed076 --- /dev/null +++ b/src/ReactiveUI.Primitives.Maui/ReactiveUI.Primitives.Maui.csproj @@ -0,0 +1,17 @@ + + + + $(MauiTargetFrameworks) + enable + enable + preview + MAUI dispatcher integration sequencers for ReactiveUI.Primitives. + system.reactive;rx;reactive;primitives;maui;dispatcher;scheduler;sequencer + + + + + + + + diff --git a/src/ReactiveUI.Primitives.slnx b/src/ReactiveUI.Primitives.slnx index babbf50..193e48e 100644 --- a/src/ReactiveUI.Primitives.slnx +++ b/src/ReactiveUI.Primitives.slnx @@ -22,6 +22,8 @@ + + diff --git a/src/ReactiveUI.Primitives/Signals/Signal{Factories}.cs b/src/ReactiveUI.Primitives/Signals/Signal{Factories}.cs index e912de5..79ecd5e 100644 --- a/src/ReactiveUI.Primitives/Signals/Signal{Factories}.cs +++ b/src/ReactiveUI.Primitives/Signals/Signal{Factories}.cs @@ -540,6 +540,25 @@ public static IObservable Every(TimeSpan period, ISequencer scheduler) /// public static IObservable Timer(TimeSpan dueTime, ISequencer scheduler) => After(dueTime, scheduler); + /// + /// Emits a single zero tick at the specified absolute due time. + /// + public static IObservable Timer(DateTimeOffset dueTime) => + Timer(dueTime, ThreadPoolSequencer.Instance); + + /// + /// Emits a single zero tick at the specified absolute due time. + /// + public static IObservable Timer(DateTimeOffset dueTime, ISequencer scheduler) + { + if (scheduler == null) + { + throw new ArgumentNullException(nameof(scheduler)); + } + + return After(Sequencer.Normalize(dueTime - scheduler.Now), scheduler); + } + /// /// Creates a timer that emits first after and then at . /// @@ -609,6 +628,11 @@ public static IObservable Race(params IObservable[] sources) return FromEnumerable(validated).Race(); } + /// + /// Mirrors the first supplied signal to produce a value or terminal signal. + /// + public static IObservable Amb(params IObservable[] sources) => Race(sources); + /// /// Zips two signals with a result selector. /// @@ -697,14 +721,39 @@ private static IObservable[] ValidateSources(IObservable[] sources) private static IDisposable SubscribeAsyncEnumerable(IAsyncEnumerable values, IObserver observer, CancellationToken cancellationToken) { var cts = CancellationTokenSource.CreateLinkedTokenSource(cancellationToken); + var disposed = 0; IAsyncEnumerator? enumerator = null; _ = Task.Run( - async () => await PumpAsyncEnumerable(values, observer, cts, enumeratorReference => enumerator = enumeratorReference).ConfigureAwait(false), + async () => + { + try + { + await PumpAsyncEnumerable(values, observer, cts, enumeratorReference => enumerator = enumeratorReference).ConfigureAwait(false); + } + finally + { + Volatile.Write(ref disposed, 1); + } + }, CancellationToken.None); return Disposable.Create(() => { - cts.Cancel(); + if (Interlocked.Exchange(ref disposed, 1) != 0) + { + return; + } + + try + { + cts.Cancel(); + } + catch (ObjectDisposedException) + { + // The async enumerable completed and released its linked token before disposal reached this callback. + return; + } + var current = Volatile.Read(ref enumerator); if (current == null) { diff --git a/src/tests/ReactiveUI.Primitives.Tests/FactoryOperatorContractTests.cs b/src/tests/ReactiveUI.Primitives.Tests/FactoryOperatorContractTests.cs index db207cd..c650ea2 100644 --- a/src/tests/ReactiveUI.Primitives.Tests/FactoryOperatorContractTests.cs +++ b/src/tests/ReactiveUI.Primitives.Tests/FactoryOperatorContractTests.cs @@ -386,6 +386,7 @@ public void CombiningOperatorsPreserveCoreOrderingSemantics() var rangeConcatenated = new List(); var rangeMerged = new List(); var rangeRace = new List(); + var rangeAmb = new List(); var rangeLatest = new List(); var rangeWithLatest = new List(); var rangeForkJoin = new List(); @@ -400,6 +401,7 @@ public void CombiningOperatorsPreserveCoreOrderingSemantics() rangeConcatSignal.Subscribe(rangeObserver); Signal.Merge(Signal.Range(FirstValue, SecondValue), Signal.Range(RetrySuccessAttempt, SecondValue)).Subscribe(rangeMerged.Add); Signal.Race(Signal.Range(FirstValue, SecondValue), Signal.Range(RetrySuccessAttempt, SecondValue)).Subscribe(rangeRace.Add); + Signal.Amb(Signal.Range(FirstValue, SecondValue), Signal.Range(RetrySuccessAttempt, SecondValue)).Subscribe(rangeAmb.Add); Signal.CombineLatest(Signal.Range(FirstValue, SecondValue), Signal.Range(ProjectionMultiplier, SecondValue), static (left, right) => left + right).Subscribe(rangeLatest.Add); Signal.Range(FirstValue, SecondValue).WithLatest(Signal.Range(ProjectionMultiplier, SecondValue), static (left, right) => left + right).Subscribe(rangeWithLatest.Add); Signal.ForkJoin(Signal.Range(FirstValue, SecondValue), Signal.Range(ProjectionMultiplier, SecondValue), static (left, right) => left + right).Subscribe(rangeForkJoin.Add); @@ -416,6 +418,7 @@ public void CombiningOperatorsPreserveCoreOrderingSemantics() Assert.Equal(1, rangeObserver.Completed); Assert.Equal(FourItemExpected, rangeMerged); Assert.Equal(TakeWhileExpected, rangeRace); + Assert.Equal(TakeWhileExpected, rangeAmb); Assert.Equal(new[] { ProjectedSecondBucketPeerValue, RangeZipShorterSecondResult }, rangeLatest); Assert.Equal(new[] { ProjectedSecondBucketPeerValue, RangeZipShorterSecondResult }, rangeWithLatest); Assert.Equal(new[] { RangeZipShorterSecondResult }, rangeForkJoin); @@ -493,6 +496,25 @@ async IAsyncEnumerable Values([EnumeratorCancellation] CancellationToken to Assert.True(disposed); } + /// + /// Verifies completed async enumerable subscriptions can be disposed without racing a disposed token source. + /// + /// A task that completes when asynchronous assertions have run. + [Test] + public async Task AsyncEnumerableFactoryCanDisposeAfterCompletion() + { + static async IAsyncEnumerable Values() + { + yield return FirstValue; + await Task.Yield(); + yield return SecondValue; + } + + var values = await Signal.FromAsyncEnumerable(Values()).CollectArrayAsync(); + + Assert.Equal(TakeWhileExpected, (IEnumerable)values); + } + /// /// Verifies timer factories use an injected virtual sequencer. /// @@ -501,9 +523,11 @@ public void TimeFactoriesUseInjectedScheduler() { var clock = new TestClock(); var after = new List(); + var absoluteTimer = new List(); var every = new List(); Signal.After(TimeSpan.FromTicks(AfterTicks), clock).Subscribe(after.Add); + Signal.Timer(clock.Now.AddTicks(AfterTicks), clock).Subscribe(absoluteTimer.Add); var subscription = Signal.Every(TimeSpan.FromTicks(EveryTicks), clock).Subscribe(every.Add); clock.AdvanceBy(TimeSpan.FromTicks(InitialAdvanceTicks)); @@ -512,6 +536,7 @@ public void TimeFactoriesUseInjectedScheduler() clock.AdvanceBy(TimeSpan.FromTicks(FirstValue)); Assert.Equal(OneShotTimerExpected, after); + Assert.Equal(OneShotTimerExpected, absoluteTimer); clock.AdvanceBy(TimeSpan.FromTicks(InitialAdvanceTicks)); subscription.Dispose(); @@ -663,6 +688,7 @@ public async Task FactoryAliasesAndGuardsCoverParityBranches() Assert.Throws(() => Signal.Start(static () => FirstValue, null!)); Assert.Throws(() => Signal.Start((Action)null!)); Assert.Throws(() => Signal.After(TimeSpan.Zero, null!)); + Assert.Throws(() => Signal.Timer(DateTimeOffset.UnixEpoch, null!)); Assert.Throws(() => Signal.Every(TimeSpan.FromTicks(-1))); Assert.Throws(() => Signal.Timer(TimeSpan.Zero, TimeSpan.Zero, null!)); Assert.Throws(() => Signal.FromAsync((Func>)null!)); From 574f9b5668e8f91fffe60e7ab863f85d3f5b2416 Mon Sep 17 00:00:00 2001 From: Chris Pulman Date: Wed, 27 May 2026 07:31:56 +0100 Subject: [PATCH 7/8] Update CoverageRuntimeTests.cs --- .../CoverageRuntimeTests.cs | 24 ------------------- 1 file changed, 24 deletions(-) diff --git a/src/tests/ReactiveUI.Primitives.Tests/CoverageRuntimeTests.cs b/src/tests/ReactiveUI.Primitives.Tests/CoverageRuntimeTests.cs index f79c382..949d463 100644 --- a/src/tests/ReactiveUI.Primitives.Tests/CoverageRuntimeTests.cs +++ b/src/tests/ReactiveUI.Primitives.Tests/CoverageRuntimeTests.cs @@ -500,30 +500,6 @@ public void VirtualTimeSequencerExtensionsValidateAndRunActions() Assert.Equal(Three, invoked); } - /// - /// Covers virtual-time extension validation and action scheduling. - /// - [Test] - public void VirtualTimeSequencerExtensionsValidateAndRunActions() - { - var clock = new TestClock(DateTimeOffset.UnixEpoch); - var invoked = 0; - - Assert.Throws(() => VirtualTimeSequencerExtensions.ScheduleRelative(null!, TimeSpan.Zero, () => { })); - Assert.Throws(() => clock.ScheduleRelative(TimeSpan.Zero, null!)); - Assert.Throws(() => VirtualTimeSequencerExtensions.ScheduleAbsolute(null!, DateTimeOffset.UnixEpoch, () => { })); - Assert.Throws(() => clock.ScheduleAbsolute(DateTimeOffset.UnixEpoch, null!)); - - clock.ScheduleRelative(TimeSpan.FromTicks(One), () => invoked += One); - clock.ScheduleAbsolute(DateTimeOffset.UnixEpoch.AddTicks(Two), () => invoked += Two); - - clock.AdvanceBy(TimeSpan.FromTicks(One)); - Assert.Equal(One, invoked); - - clock.AdvanceBy(TimeSpan.FromTicks(One)); - Assert.Equal(Three, invoked); - } - /// /// Creates an iterator-backed enumerable for the non-indexable enumerable path. /// From 5fff00278cb17c7f8527a25f333433e78efd650f Mon Sep 17 00:00:00 2001 From: Chris Pulman Date: Wed, 27 May 2026 07:49:29 +0100 Subject: [PATCH 8/8] Update README.md --- README.md | 124 ++++++++++++++++++++++++++++++++++++++---------------- 1 file changed, 87 insertions(+), 37 deletions(-) diff --git a/README.md b/README.md index 5d29be0..d4378d9 100644 --- a/README.md +++ b/README.md @@ -163,8 +163,9 @@ Creation APIs live on `ReactiveUI.Primitives.Signals.Signal`. | `Signal.Throw(Exception)` | Terminate with an error. | | `Signal.Range(int start, int count)` | Emit an integer range and complete. | | `Signal.Repeat(T value)` / `Repeat(T value, int count)` | Repeat indefinitely or a fixed number of times. | -| `Signal.Unfold(...)` | Generate a finite sequence from state. | +| `Signal.Unfold(...)` / `Signal.Generate(...)` | Generate a finite sequence from state. | | `Signal.Use(...)` | Tie a resource lifetime to a subscription. | +| `Signal.FromEventPattern(...)` | Convert .NET events to `EventPattern` values. | | `Signal.FromEnumerable(IEnumerable)` | Convert an enumerable. | | `Signal.FromEnumerable(IEnumerable, CancellationToken)` | Convert an enumerable and stop synchronous enumeration when cancelled. | | `Signal.FromAsyncEnumerable(IAsyncEnumerable, CancellationToken)` | Convert an async enumerable on modern TFMs. | @@ -300,12 +301,13 @@ height.Value = 600; | quiet-period sampling | `Throttle` | | periodic sampling | `Sample` | | timeout | `Timeout` | +| schedule subscription | `SubscribeOn` | | timestamp values | `Timestamp` | | measure intervals | `TimeInterval` | | fixed-size buffers | `Buffer(count)`, `Buffer(count, skip)` | -| collect to list/array signal | `CollectList`, `CollectArray` | -| collect asynchronously | `CollectListAsync`, `CollectArrayAsync` | -| first value task | `FirstAsync`, `FirstOrDefaultAsync` | +| collect to list/array signal | `CollectList`, `CollectArray`, `ToList`, `ToArray` | +| collect asynchronously | `CollectListAsync`, `CollectArrayAsync`, `ToListAsync`, `ToArrayAsync` | +| first/last value task | `FirstAsync`, `FirstOrDefaultAsync`, `LastAsync`, `LastOrDefaultAsync` | Timer example: @@ -396,7 +398,7 @@ using var subscription = failed.Subscribe( ## Sequencers -Sequencers live in `ReactiveUI.Primitives.Concurrency` and implement `ISequencer`. +Sequencers live in `ReactiveUI.Primitives.Concurrency` and implement `ISequencer`. The core `ReactiveUI.Primitives` package does not reference WPF or Windows Forms; UI-thread sequencers are provided by the optional `ReactiveUI.Primitives.Wpf` and `ReactiveUI.Primitives.WinForms` packages. | Sequencer | Purpose | |---|---| @@ -404,7 +406,9 @@ Sequencers live in `ReactiveUI.Primitives.Concurrency` and implement `ISequencer | `Sequencer.CurrentThread` / `CurrentThreadSequencer.Instance` | Queue recursive/current-thread work deterministically. | | `ThreadPoolSequencer.Instance` | Schedule work through the thread pool. | | `TaskPoolSequencer.Instance` | Schedule work through tasks. | -| `DispatcherSequencer` | Schedule onto a WPF dispatcher on Windows TFMs. | +| `SynchronizationContextSequencer` | Schedule through a `SynchronizationContext`. | +| `DispatcherSequencer` | Schedule onto a WPF dispatcher from `ReactiveUI.Primitives.Wpf`. | +| `ControlSequencer` | Schedule onto a Windows Forms control from `ReactiveUI.Primitives.WinForms`. | | `VirtualClock` / `TestClock` | Virtual-time scheduling for deterministic tests. | Scheduling APIs include absolute, relative, recursive, and action-based overloads: @@ -548,8 +552,9 @@ ReactiveUI.Primitives is not a byte-for-byte clone of System.Reactive. It keeps | `DelaySubscription` | `DelayStart` | Delay source subscription. | | `Timeout` | `Timeout` | Error on missing value before due time. | | `Buffer(count)` | `Buffer(count)` | Fixed-size buffers. | -| `ToList` / `ToArray` | `CollectList` / `CollectArray` | Signal results. | -| `FirstAsync` | `FirstAsync` | Task result. | +| `SubscribeOn` | `SubscribeOn` | Schedule source subscription. | +| `ToList` / `ToArray` | `ToList` / `ToArray` or `CollectList` / `CollectArray` | Signal results. | +| `FirstAsync` / `LastAsync` | `FirstAsync` / `LastAsync` | Task result. | | `CountAsync` / `AnyAsync` | `CountAsync` / `AnyAsync` | Task-shaped terminal helpers, including cancellation overloads. | ### Disposable mapping @@ -573,7 +578,9 @@ ReactiveUI.Primitives is not a byte-for-byte clone of System.Reactive. It keeps | `CurrentThreadSequencer.Instance` | `Sequencer.CurrentThread` or `CurrentThreadSequencer.Instance` | | `ThreadPoolSequencer.Instance` | `ThreadPoolSequencer.Instance` | | task-pool scheduling | `TaskPoolSequencer.Instance` | -| dispatcher scheduling | `DispatcherSequencer` | +| synchronization-context scheduling | `SynchronizationContextSequencer` | +| WPF dispatcher scheduling | `DispatcherSequencer` from `ReactiveUI.Primitives.Wpf` | +| Windows Forms control scheduling | `ControlSequencer` from `ReactiveUI.Primitives.WinForms` | | `TestScheduler` / virtual time | `VirtualClock` or `TestClock` | ### Testing migration @@ -603,41 +610,81 @@ Use the generated bridge only at boundaries. Prefer native ReactiveUI.Primitives Benchmarks live in `src/benchmarks/ReactiveUI.Primitives.Benchmarks`. The benchmark project may reference System.Reactive and R3 to compare throughput and allocation behavior; the production package must not. -The latest joined BenchmarkDotNet ShortRun was captured on 2026-05-25 with .NET SDK 10.0.300 on Windows 11, using: +Full BenchmarkDotNet runs were captured on 2026-05-27 with .NET SDK 10.0.300 on Windows 11: ```powershell -dotnet run --project src/benchmarks/ReactiveUI.Primitives.Benchmarks/ReactiveUI.Primitives.Benchmarks.csproj --configuration Release --no-build -- -f '*' -j Short --join +dotnet run --project src/benchmarks/ReactiveUI.Primitives.Benchmarks/ReactiveUI.Primitives.Benchmarks.csproj --configuration Release --no-build -- --filter "*" --join --launchCount 1 --warmupCount 1 --iterationCount 3 ``` -Raw artifacts for the joined run are under `BenchmarkDotNet.Artifacts/results/BenchmarkRun-joined-2026-05-25-21-12-14-report.*`. The focused `FromEnumerable` row was captured in `src/BenchmarkDotNet.Artifacts/results/ReactiveUI.Primitives.Benchmarks.FactoryFromEnumerableBenchmarks-report.*` after the dedicated inline fast path was added. ShortRun is useful for fast regression checks; rerun with a longer BenchmarkDotNet job before making release claims. +Short local jobs are useful for fast regression checks; rerun with a longer BenchmarkDotNet job before making release claims. Current local test coverage after this pass is 82.80% line coverage and 75.50% branch coverage from `coverage-local.cobertura.xml`; the 100% coverage target remains an active work item. + +`N/A` means the current benchmark harness does not contain a matching measured row for that library. | Scenario | ReactiveUI.Primitives | System.Reactive | R3 | |---|---:|---:|---:| -| Completed task bridge | 17.6833 ns / 88 B | 1,348.2890 ns / 793 B | n/a | -| Pocket / composite dispose | 90.8799 ns / 408 B | 138.6110 ns / 512 B | n/a | -| Current-thread schedule | 22.8205 ns / 88 B | 28.3162 ns / 88 B | n/a | -| Safe witness wrapper | 40.2300 ns / 168 B | n/a | n/a | -| Completed spark | 0.3007 ns / 0 B | n/a | n/a | -| Return subscribe | 0.4417 ns / 0 B | 91.5187 ns / 120 B | 49.3844 ns / 72 B | -| Empty subscribe | 7.3897 ns / 40 B | 79.6293 ns / 96 B | 43.8897 ns / 48 B | -| Range subscribe | 55.9990 ns / 96 B | 4,153.4012 ns / 2,472 B | 119.9919 ns / 72 B | -| Repeat subscribe | 10.3262 ns / 0 B | 3,951.5395 ns / 2,408 B | 116.7110 ns / 72 B | -| FromEnumerable subscribe | 48.9910 ns / 40 B | 3,740.3600 ns / 2,504 B | 131.3610 ns / 80 B | -| Throw subscribe | 100.3490 ns / 120 B | 190.9367 ns / 240 B | 158.5640 ns / 192 B | -| Map + Keep | 213.9322 ns / 208 B | 4,463.8969 ns / 2,616 B | 423.8154 ns / 264 B | -| DistinctBy + Count + Any | 427.3704 ns / 992 B | 8,842.7094 ns / 5,896 B | 932.2863 ns / 1,280 B | -| StartWith + Append + DefaultIfEmpty | 79.0351 ns / 184 B | 1,511.0960 ns / 1,257 B | 226.6506 ns / 280 B | -| SelectMany over ranges | 1,174.3683 ns / 712 B | 5,989.3754 ns / 3,872 B | 1,530.4454 ns / 1,032 B | -| Zip over ranges | 1,920.5231 ns / 1,320 B | 5,434.1159 ns / 2,976 B | 1,103.3186 ns / 648 B | -| Replay subscribe | 491.2126 ns / 320 B | 944.9225 ns / 696 B | n/a | -| Behaviour signal, 32 values | 717.1898 ns / 176 B | 735.4731 ns / 200 B | 831.3793 ns / 184 B | -| Behaviour signal, 1024 values | 19,587.6333 ns / 176 B | 18,925.1658 ns / 200 B | 21,464.7502 ns / 184 B | -| Signal subscribe/dispose, 8 subscribers | 415.4351 ns / 1,176 B | 506.4101 ns / 1,288 B | 719.0130 ns / 840 B | -| Signal subscribe/dispose, 64 subscribers | 4,503.8029 ns / 8,864 B | 8,526.7609 ns / 38,472 B | 5,480.4075 ns / 6,216 B | -| Signal emit, 32 values | 108.2371 ns / 160 B | 122.6897 ns / 136 B | 213.9175 ns / 152 B | -| Signal emit, 1024 values | 2,130.8298 ns / 160 B | 1,994.6875 ns / 136 B | 3,677.6208 ns / 152 B | - -Current benchmark coverage is intentionally visible rather than overstated. The next benchmark expansion areas are factory/adapters (`Never`, `Create`, `Defer`, `FromEnumerable`, `FromAsyncEnumerable`, `Start`, `Unfold`, `Use`), time/scheduler operators (`Delay`, `DelayStart`, `Throttle`, `Sample`, `Timestamp`, `TimeInterval`, `Timeout`, `ObserveOn`), higher-order combinators (`Concat`, `Merge`, `Race`, `Switch`, `CombineLatest`, `WithLatest`, `ForkJoin`), terminal/collection APIs, connectable/share APIs, and state/task command surfaces. +| Return subscribe | 0.2089 ns / 0 B | 45.7505 ns / 120 B | 28.1108 ns / 72 B | +| Empty subscribe | 2.6805 ns / 40 B | 40.1536 ns / 96 B | 25.8399 ns / 48 B | +| Range subscribe | 46.0466 ns / 96 B | 2,452.1549 ns / 2,472 B | 63.6342 ns / 72 B | +| Repeat subscribe | 6.7455 ns / 0 B | 2,275.7650 ns / 2,408 B | 64.8692 ns / 72 B | +| Throw subscribe | 54.9685 ns / 120 B | 106.8594 ns / 240 B | 85.8239 ns / 192 B | +| FromEnumerable subscribe | 49.3764 ns / 40 B | 2,171.2470 ns / 2,504 B | 70.3934 ns / 80 B | +| Completed task bridge | 8.7892 ns / 88 B | 769.1087 ns / 793 B | N/A | +| Create subscribe | 43.7376 ns / 248 B | N/A | N/A | +| CreateSafe subscribe | 44.1792 ns / 248 B | N/A | N/A | +| Defer subscribe | 66.0839 ns / 240 B | N/A | N/A | +| Start subscribe | 51.7062 ns / 376 B | N/A | N/A | +| Unfold subscribe | 167.5562 ns / 736 B | N/A | N/A | +| Use subscribe | 67.7116 ns / 432 B | N/A | N/A | +| FromAsyncEnumerable subscribe | 1,921.1208 ns / 2,052 B | N/A | N/A | +| Never subscribe/dispose | 0.2163 ns / 0 B | N/A | N/A | +| Map + Keep over range | 130.0149 ns / 208 B | 2,461.1312 ns / 2,616 B | 256.9470 ns / 264 B | +| Aggregate + Any + Count | 229.9964 ns / 992 B | 5,073.0502 ns / 5,896 B | 529.5892 ns / 1,280 B | +| StartWith + Append + DefaultIfEmpty | 45.2433 ns / 184 B | 869.3671 ns / 1,257 B | 128.0685 ns / 280 B | +| SelectMany over ranges | 939.2041 ns / 712 B | 3,357.9581 ns / 3,872 B | 965.1100 ns / 1,032 B | +| Zip over ranges | 38.8399 ns / 232 B | 2,903.8925 ns / 2,976 B | 658.2362 ns / 648 B | +| Concat ranges | 67.0788 ns / 256 B | N/A | N/A | +| Merge ranges | 66.9464 ns / 256 B | N/A | N/A | +| Race ranges | 37.7646 ns / 192 B | N/A | N/A | +| Switch ranges | 797.3144 ns / 1,376 B | N/A | N/A | +| CombineLatest ranges | 95.0119 ns / 504 B | N/A | N/A | +| WithLatest ranges | 104.8306 ns / 504 B | N/A | N/A | +| ForkJoin ranges | 67.7277 ns / 480 B | N/A | N/A | +| Delay range | 3,132.9781 ns / 38,816 B | N/A | N/A | +| DelayStart range | 874.3075 ns / 25,520 B | N/A | N/A | +| Throttle burst | 2,504.3725 ns / 38,384 B | N/A | N/A | +| Sample latest | 1,045.5780 ns / 26,072 B | N/A | N/A | +| Timestamp range | 394.0507 ns / 312 B | N/A | N/A | +| TimeInterval range | 478.5383 ns / 736 B | N/A | N/A | +| Timeout never | 976.3098 ns / 25,816 B | N/A | N/A | +| ObserveOn immediate | 21.8563 ns / 96 B | N/A | N/A | +| Replay subscribe | 324.2733 ns / 320 B | 665.7033 ns / 696 B | N/A | +| BehaviorSignal 32 values | 554.5077 ns / 176 B | 581.5846 ns / 200 B | 594.3121 ns / 184 B | +| BehaviorSignal 1024 values | 15,698.1415 ns / 176 B | 15,826.6246 ns / 200 B | 15,702.7802 ns / 184 B | +| Signal emit, 32 values | 65.8803 ns / 136 B | 90.0502 ns / 136 B | 116.1177 ns / 152 B | +| Signal emit, 1024 values | 1,650.9938 ns / 136 B | 1,676.8777 ns / 136 B | 1,984.4349 ns / 152 B | +| Signal subscribe/dispose, 8 observers | 240.6380 ns / 592 B | 284.9100 ns / 1,288 B | 450.5067 ns / 840 B | +| Signal subscribe/dispose, 64 observers | 2,599.1562 ns / 3,800 B | 3,600.1331 ns / 38,472 B | 3,401.7292 ns / 6,216 B | +| Publish live connect | 125.5809 ns / 384 B | N/A | N/A | +| Share live subscribe | 225.6140 ns / 848 B | N/A | N/A | +| Replay live late subscribe | 595.9243 ns / 568 B | N/A | N/A | +| RefCount subscribe | 222.4420 ns / 848 B | N/A | N/A | +| AutoConnect subscribe | 167.9847 ns / 728 B | N/A | N/A | +| StateSignal updates | 552.4185 ns / 176 B | N/A | N/A | +| ReadOnlyState projection | 123.5420 ns / 248 B | N/A | N/A | +| TaskSignal subscribe | 2,384.2121 ns / 3,875 B | N/A | N/A | +| Command execute | 114.0456 ns / 600 B | N/A | N/A | +| Command result subscribe | 137.9245 ns / 672 B | N/A | N/A | +| CollectList range | 115.9544 ns / 688 B | N/A | N/A | +| CollectArray range | 83.1385 ns / 656 B | N/A | N/A | +| CollectArrayAsync range | 33.8094 ns / 384 B | N/A | N/A | +| FirstAsync range | 5.9320 ns / 56 B | N/A | N/A | +| ToTask range | 13.9715 ns / 192 B | N/A | N/A | +| Count(predicate) range | 55.0441 ns / 144 B | N/A | N/A | +| All + Contains range | 204.1768 ns / 1,024 B | N/A | N/A | +| Pocket dispose | 60.2873 ns / 408 B | 93.1830 ns / 512 B | N/A | +| CurrentThread schedule | 12.4912 ns / 88 B | 14.7694 ns / 88 B | N/A | +| Safe witness | 21.7079 ns / 168 B | N/A | N/A | +| Completed Spark | 0.0006 ns / 0 B | N/A | N/A | Performance constraints used by the project: @@ -652,6 +699,8 @@ Performance constraints used by the project: | Path | Purpose | |---|---| | `src/ReactiveUI.Primitives` | Production runtime library. | +| `src/ReactiveUI.Primitives.Wpf` | Optional WPF dispatcher integration library. | +| `src/ReactiveUI.Primitives.WinForms` | Optional Windows Forms control integration library. | | `src/ReactiveUI.Primitives.SystemReactiveBridge.Generator` | Source generator for System.Reactive bridge adapters. | | `src/ReactiveUI.Primitives.R3Bridge.Generator` | Source generator for R3 bridge adapters. | | `src/ReactiveUI.Primitives.Tests` | Test project using Microsoft Testing Platform/TUnit-style validation. | @@ -665,6 +714,7 @@ For NuGet package verification, inspect the generated `.nupkg` and confirm: - The nuspec contains `README.md`. - Bridge generator DLLs are present under `analyzers/dotnet/cs`. - Production runtime dependencies do not include System.Reactive or R3. +- The core `ReactiveUI.Primitives` package does not reference WPF or Windows Forms assemblies; those integrations ship from `ReactiveUI.Primitives.Wpf` and `ReactiveUI.Primitives.WinForms`. ## Practical migration checklist