diff --git a/.github/workflows/ci-build.yml b/.github/workflows/ci-build.yml index ff0bd1d..2990568 100644 --- a/.github/workflows/ci-build.yml +++ b/.github/workflows/ci-build.yml @@ -10,7 +10,7 @@ env: jobs: build: - runs-on: windows-2025 + runs-on: windows-latest outputs: nbgv: ${{ steps.nbgv.outputs.SemVer2 }} steps: @@ -25,9 +25,17 @@ jobs: uses: actions/setup-dotnet@v4.3.1 with: dotnet-version: | - 6.0.x - 7.0.x 8.0.x + 9.0.x + 10.0.x + dotnet-quality: 'preview' + cache: true + cache-dependency-path: | + **/Directory.Packages.props + **/*.sln + **/*.csproj + **/global.json + **/nuget.config - name: NBGV id: nbgv diff --git a/.github/workflows/release.yml b/.github/workflows/release.yml index 7539814..6c2f5ea 100644 --- a/.github/workflows/release.yml +++ b/.github/workflows/release.yml @@ -10,7 +10,7 @@ env: jobs: release: - runs-on: windows-2025 + runs-on: windows-latest environment: name: release outputs: @@ -27,9 +27,18 @@ jobs: uses: actions/setup-dotnet@v4.3.1 with: dotnet-version: | - 6.0.x - 7.0.x 8.0.x + 9.0.x + 10.0.x + + - name: Get Latest Visual Studio Version + shell: bash + run: | + dotnet tool update -g dotnet-vs + vs where release + vs update release Enterprise + vs modify release Enterprise +mobile +desktop +uwp +web + vs where release - name: NBGV id: nbgv diff --git a/README.md b/README.md index 8eeea38..8111668 100644 --- a/README.md +++ b/README.md @@ -1,41 +1,283 @@ -# Extensions - -## Overview -Extensions for concerns found in System.Reactive that make consuming the library and using it to build an application easier. - -## The library contains the following extensions - -### `ReactiveMarbles.Extensions` - -#### `ReactiveExtensions` - -- WhereIsNotNull -- AsSignal -- SyncTimer -- BufferUntil -- CatchIgnore -- CombineLatestValuesAreAllFalse -- CombineLatestValuesAreAllTrue -- GetMax -- GetMin -- DetectStale -- Conflate -- Heartbeat -- WithLimitedConcurrency -- OnNext -- ObserveOnSafe -- Start -- ForEach -- ScheduleSafe -- FromArray -- Using -- While -- Schedule -- Filter -- Shuffle -- OnErrorRetry -- TakeUntil -- SyncronizeAsync -- SubscribeAsync -- SyncronizeSynchronous -- SubscribeSynchronous +# ReactiveMarbles.Extensions + +A focused collection of high–value Reactive Extensions (Rx) operators that do **not** ship with `System.Reactive` but are commonly needed when building reactive .NET applications. + +The goal of this library is to: +- Reduce boilerplate for frequent reactive patterns (timers, buffering, throttling, heartbeats, etc.) +- Provide pragmatic, allocation?aware helpers for performance sensitive scenarios +- Avoid additional dependencies – only `System.Reactive` is required + +Supported Target Frameworks: `.NET Standard 2.0`, `.NET 8`, `.NET 9`, `.NET 10`. + +--- +## Table of Contents +1. [Installation](#installation) +2. [Quick Start](#quick-start) +3. [API Catalog](#api-catalog) +4. [Operator Categories & Examples](#operator-categories--examples) + - [Null / Signal Helpers](#null--signal-helpers) + - [Timing, Scheduling & Flow Control](#timing-scheduling--flow-control) + - [Inactivity / Liveness](#inactivity--liveness) + - [Error Handling & Resilience](#error-handling--resilience) + - [Combining, Partitioning & Logical Helpers](#combining-partitioning--logical-helpers) + - [Async / Task Integration](#async--task-integration) + - [Backpressure / Conflation](#backpressure--conflation) + - [Selective & Conditional Emission](#selective--conditional-emission) + - [Buffering & Transformation](#buffering--transformation) + - [Subscription / Side Effects](#subscription--side-effects) + - [Utility & Miscellaneous](#utility--miscellaneous) +5. [Performance Notes](#performance-notes) +6. [Thread Safety](#thread-safety) +7. [License](#license) + +--- +## Installation +```bash +# Package coming soon (example) +dotnet add package ReactiveMarbles.Extensions +``` +Reference the project directly while developing locally. + +--- +## Quick Start +```csharp +using System; +using System.Reactive.Linq; +using ReactiveMarbles.Extensions; + +var source = Observable.Interval(TimeSpan.FromMilliseconds(120)) + .Take(10) + .Select(i => (long?) (i % 3 == 0 ? null : i)); + +// 1. Filter nulls + convert to a Unit signal. +var signal = source.WhereIsNotNull().AsSignal(); + +// 2. Add a heartbeat if the upstream goes quiet for 500ms. +var withHeartbeat = source.WhereIsNotNull() + .Heartbeat(TimeSpan.FromMilliseconds(500), Scheduler.Default); + +// 3. Retry with exponential backoff up to 5 times. +var resilient = Observable.Defer(() => + Observable.Throw(new InvalidOperationException("Boom"))) + .RetryWithBackoff(maxRetries: 5, initialDelay: TimeSpan.FromMilliseconds(100)); + +// 4. Conflate bursty updates. +var conflated = source.Conflate(TimeSpan.FromMilliseconds(300), Scheduler.Default); + +using (conflated.Subscribe(Console.WriteLine)) +{ + Console.ReadLine(); +} +``` + +--- +## API Catalog +Below is the full list of extension methods (grouped logically). +Some overloads omitted for brevity. + +| Category | Operators | +|----------|-----------| +| Null & Signal | `WhereIsNotNull`, `AsSignal` | +| Timing & Scheduling | `SyncTimer`, `Schedule` (overloads), `ScheduleSafe`, `ThrottleFirst`, `DebounceImmediate` | +| Inactivity / Liveness | `Heartbeat`, `DetectStale`, `BufferUntilInactive` | +| Error Handling | `CatchIgnore`, `CatchAndReturn`, `OnErrorRetry` (overloads), `RetryWithBackoff` | +| Combining & Aggregation | `CombineLatestValuesAreAllTrue`, `CombineLatestValuesAreAllFalse`, `GetMax`, `GetMin`, `Partition` | +| Logical / Boolean | `Not`, `WhereTrue`, `WhereFalse` | +| Async / Task | `SelectAsyncSequential`, `SelectLatestAsync`, `SelectAsyncConcurrent`, `SubscribeAsync` (overloads), `SynchronizeSynchronous`, `SynchronizeAsync`, `SubscribeSynchronous` (overloads) | +| Backpressure | `Conflate` | +| Filtering / Conditional | `Filter` (Regex), `TakeUntil` (predicate), `WaitUntil` | +| Buffering | `BufferUntil`, `BufferUntilInactive` | +| Transformation & Utility | `Shuffle`, `ForEach`, `FromArray`, `Using`, `While`, `Start`, `OnNext` (params helper), `DoOnSubscribe`, `DoOnDispose` | + +--- +## Operator Categories & Examples +### Null / Signal Helpers +```csharp +IObservable raw = GetPossiblyNullStream(); +IObservable cleaned = raw.WhereIsNotNull(); +IObservable signal = cleaned.AsSignal(); +``` + +### Timing, Scheduling & Flow Control +```csharp +// Shared timer for a given period (one underlying timer per distinct TimeSpan) +var sharedTimer = ReactiveExtensions.SyncTimer(TimeSpan.FromSeconds(1)); + +// Delay emission of a single value +42.Schedule(TimeSpan.FromMilliseconds(250), Scheduler.Default) + .Subscribe(v => Console.WriteLine($"Delayed: {v}")); + +// Safe scheduling when a scheduler may be null +IScheduler? maybeScheduler = null; +maybeScheduler.ScheduleSafe(() => Console.WriteLine("Ran inline")); + +// ThrottleFirst: allow first item per window, ignore rest +var throttled = Observable.Interval(TimeSpan.FromMilliseconds(50)) + .ThrottleFirst(TimeSpan.FromMilliseconds(200)); + +// DebounceImmediate: emit first immediately then debounce rest +var debounced = Observable.Interval(TimeSpan.FromMilliseconds(40)) + .DebounceImmediate(TimeSpan.FromMilliseconds(250)); +``` + +### Inactivity / Liveness +```csharp +// Heartbeat emits IHeartbeat where IsHeartbeat == true during quiet periods +var heartbeats = Observable.Interval(TimeSpan.FromMilliseconds(400)) + .Take(5) + .Heartbeat(TimeSpan.FromMilliseconds(300), Scheduler.Default); + +// DetectStale emits IStale: one stale marker after inactivity, or fresh update wrappers +var staleAware = Observable.Timer(TimeSpan.Zero, TimeSpan.FromMilliseconds(500)) + .Take(3) + .DetectStale(TimeSpan.FromMilliseconds(300), Scheduler.Default); + +// BufferUntilInactive groups events separated by inactivity +var bursts = Observable.Interval(TimeSpan.FromMilliseconds(60)).Take(20); +var groups = bursts.BufferUntilInactive(TimeSpan.FromMilliseconds(200)); +``` + +### Error Handling & Resilience +```csharp +var flaky = Observable.Create(o => +{ + o.OnNext(1); + o.OnError(new InvalidOperationException("Fail")); + return () => { }; +}); + +// Ignore all errors and complete silently +a flakySafe = flaky.CatchIgnore(); + +// Replace error with a fallback value +var withFallback = flaky.CatchAndReturn(-1); + +// Retry only specific exception type with logging +var retried = flaky.OnErrorRetry(ex => Console.WriteLine(ex.Message), retryCount: 3); + +// Retry with exponential backoff +var backoff = flaky.RetryWithBackoff(maxRetries: 5, initialDelay: TimeSpan.FromMilliseconds(100)); +``` + +### Combining, Partitioning & Logical Helpers +```csharp +var a = Observable.Interval(TimeSpan.FromMilliseconds(150)).Select(i => i % 2 == 0); +var b = Observable.Interval(TimeSpan.FromMilliseconds(170)).Select(i => i % 3 == 0); + +var allTrue = new[] { a, b }.CombineLatestValuesAreAllTrue(); +var allFalse = new[] { a, b }.CombineLatestValuesAreAllFalse(); + +var numbers = Observable.Range(1, 10); +var (even, odd) = numbers.Partition(n => n % 2 == 0); // Partition stream + +var toggles = a.Not(); // Negate booleans +``` + +### Async / Task Integration +```csharp +IObservable inputs = Observable.Range(1, 5); + +// Sequential (preserves order) +var seq = inputs.SelectAsyncSequential(async i => { await Task.Delay(50); return i * 2; }); + +// Latest only (cancels previous) +var latest = inputs.SelectLatestAsync(async i => { await Task.Delay(100); return i; }); + +// Limited parallelism +var concurrent = inputs.SelectAsyncConcurrent(async i => { await Task.Delay(100); return i; }, maxConcurrency: 2); + +// Asynchronous subscription (serializing tasks) +inputs.SubscribeAsync(async i => await Task.Delay(10)); + +// Synchronous gate: ensures per-item async completion before next is emitted +a inputs.SubscribeSynchronous(async i => await Task.Delay(25)); +``` + +### Backpressure / Conflation +```csharp +// Conflate: enforce minimum spacing between emissions while always outputting the most recent value +a var noisy = Observable.Interval(TimeSpan.FromMilliseconds(20)).Take(30); +var conflated = noisy.Conflate(TimeSpan.FromMilliseconds(200), Scheduler.Default); +``` + +### Selective & Conditional Emission +```csharp +// TakeUntil predicate (inclusive) +var untilFive = Observable.Range(1, 100).TakeUntil(x => x == 5); + +// WaitUntil first match then complete +var firstEven = Observable.Range(1, 10).WaitUntil(x => x % 2 == 0); +``` + +### Buffering & Transformation +```csharp +// BufferUntil - collect chars between delimiters +var chars = "".ToCharArray().ToObservable(); +var frames = chars.BufferUntil('<', '>'); // emits "", "", "" + +// Shuffle arrays in-place +var arrays = Observable.Return(new[] { 1, 2, 3, 4, 5 }); +var shuffled = arrays.Shuffle(); +``` + +### Subscription & Side Effects +```csharp +var stream = Observable.Range(1, 3) + .DoOnSubscribe(() => Console.WriteLine("Subscribed")) + .DoOnDispose(() => Console.WriteLine("Disposed")); + +using (stream.Subscribe(Console.WriteLine)) +{ + // auto dispose at using end +} +``` + +### Utility & Miscellaneous +```csharp +// Emit list contents quickly with low allocations +var listSource = Observable.Return>(new List { 1, 2, 3 }); +listSource.ForEach().Subscribe(Console.WriteLine); + +// Using helper for deterministic disposal +var value = new MemoryStream().Using(ms => ms.Length); + +// While loop (reactive) +var counter = 0; +ReactiveExtensions.While(() => counter++ < 3, () => Console.WriteLine(counter)) + .Subscribe(); + +// Batch push with OnNext params +var subj = new Subject(); +subj.OnNext(1, 2, 3, 4); +``` + +--- +## Performance Notes +- `FastForEach` path avoids iterator allocations for `List`, `IList`, and arrays. +- `SyncTimer` ensures only one shared timer per period reducing timer overhead. +- `Conflate` helps tame high–frequency producers without dropping the final value of a burst. +- `Heartbeat` and `DetectStale` use lightweight scheduling primitives. +- Most operators avoid capturing lambdas in hot loops where practical. + +## Thread Safety +- All operators are pure functional transformations unless documented otherwise. +- `SyncTimer` uses a `ConcurrentDictionary` and returns a hot `IConnectableObservable` that connects once per unique `TimeSpan`. +- Methods returning shared observables (`SyncTimer`, `Partition` result sequences) are safe for multi-subscriber usage unless the upstream is inherently side-effecting. + +## License +MIT – see LICENSE file. + +--- +## Contributing +Issues / PRs welcome. Please keep additions dependency–free and focused on broadly useful reactive patterns. + +--- +## Change Log (Excerpt) +(Keep this section updated as the library evolves.) +- Added async task projection helpers (`SelectAsyncSequential`, `SelectLatestAsync`, `SelectAsyncConcurrent`). +- Added liveness operators (`Heartbeat`, `DetectStale`, `BufferUntilInactive`). +- Added resilience (`RetryWithBackoff`, expanded `OnErrorRetry` overloads). +- Added flow control (`Conflate`, `ThrottleFirst`, `DebounceImmediate`). + +--- +Happy reactive coding! ?? diff --git a/src/ReactiveMarbles.Extensions.Tests/ReactiveMarbles.Extensions.Tests.csproj b/src/ReactiveMarbles.Extensions.Tests/ReactiveMarbles.Extensions.Tests.csproj index 7af7972..d8bc139 100644 --- a/src/ReactiveMarbles.Extensions.Tests/ReactiveMarbles.Extensions.Tests.csproj +++ b/src/ReactiveMarbles.Extensions.Tests/ReactiveMarbles.Extensions.Tests.csproj @@ -1,7 +1,7 @@ - net6.0 + net9.0 false diff --git a/src/ReactiveMarbles.Extensions/ReactiveExtensions.cs b/src/ReactiveMarbles.Extensions/ReactiveExtensions.cs index 056859f..587625c 100644 --- a/src/ReactiveMarbles.Extensions/ReactiveExtensions.cs +++ b/src/ReactiveMarbles.Extensions/ReactiveExtensions.cs @@ -3,8 +3,8 @@ // See the LICENSE file in the project root for full license information. using System; +using System.Collections.Concurrent; using System.Collections.Generic; -using System.Diagnostics.CodeAnalysis; using System.Linq; using System.Reactive; using System.Reactive.Concurrency; @@ -24,7 +24,8 @@ namespace ReactiveMarbles.Extensions; /// public static class ReactiveExtensions { - private static readonly Dictionary>> _timerList = []; + // Thread-safe cache of timers keyed by TimeSpan. Ensures single shared timer per period. + private static readonly ConcurrentDictionary>> _timerList = new(); /// /// Returns only values that are not null. @@ -35,7 +36,7 @@ public static class ReactiveExtensions /// A non nullable version of the observable that only emits valid values. public static IObservable WhereIsNotNull(this IObservable observable) => observable - .Where(x => x is not null); + .Where(x => x is not null)!; /// /// Change the source observable type to . @@ -52,45 +53,65 @@ public static IObservable AsSignal(this IObservable observable) => /// Synchronized timer all instances of this with the same TimeSpan use the same timer. /// /// The time span. - /// An Observable DateTime. + /// An observable sequence producing the shared DateTime ticks. public static IObservable SyncTimer(TimeSpan timeSpan) { - if (!_timerList.TryGetValue(timeSpan, out var value)) - { - value = new Lazy>(() => Observable.Timer(TimeSpan.FromMilliseconds(0), timeSpan).Timestamp().Select(x => x.Timestamp.DateTime).Publish()); - _timerList.Add(timeSpan, value); - _timerList[timeSpan].Value.Connect(); - } - - return value.Value; + var lazy = _timerList.GetOrAdd( + timeSpan, + ts => new Lazy>( + () => + { + var published = Observable + .Timer(TimeSpan.Zero, ts) + .Timestamp() + .Select(x => x.Timestamp.DateTime) + .Publish(); + + // Connect immediately so subsequent subscribers share. + published.Connect(); + + return published; + })); + return lazy.Value; } /// /// Buffers until Start char and End char are found. /// - /// The this. - /// The starts with. - /// The ends with. - /// A Value. + /// The source observable of characters. + /// The starting delimiter. + /// The ending delimiter. + /// A sequence of buffered strings including the start and end delimiters. public static IObservable BufferUntil(this IObservable @this, char startsWith, char endsWith) => Observable.Create(o => { StringBuilder sb = new(); var startFound = false; - var sub = @this.Subscribe(s => - { - if (startFound || s == startsWith) + var sub = @this.Subscribe( + s => + { + if (startFound || s == startsWith) + { + startFound = true; + sb.Append(s); + if (s == endsWith) + { + o.OnNext(sb.ToString()); + startFound = false; + sb.Clear(); + } + } + }, + o.OnError, + () => { - startFound = true; - sb.Append(s); - if (s == endsWith) + if (startFound && sb.Length > 0) { o.OnNext(sb.ToString()); - startFound = false; - sb.Clear(); } - } - }); + + o.OnCompleted(); + }); return new CompositeDisposable(sub); }); @@ -99,7 +120,7 @@ public static IObservable BufferUntil(this IObservable @this, char /// /// The type of the source. /// The source. - /// A Value. + /// A sequence that ignores errors and completes. public static IObservable CatchIgnore(this IObservable source) => source.Catch(Observable.Empty()); @@ -110,7 +131,7 @@ public static IObservable BufferUntil(this IObservable @this, char /// The type of the exception. /// The source. /// The error action. - /// A Value. + /// A sequence that invokes on error and completes. public static IObservable CatchIgnore(this IObservable source, Action errorAction) where TException : Exception => source.Catch((TException ex) => @@ -123,7 +144,7 @@ public static IObservable BufferUntil(this IObservable @this, char /// Latest values of each sequence are all false. /// /// The sources. - /// A Value. + /// A sequence that emits true when all latest booleans are false. public static IObservable CombineLatestValuesAreAllFalse(this IEnumerable> sources) => sources.CombineLatest(xs => xs.All(x => !x)); @@ -131,17 +152,17 @@ public static IObservable CombineLatestValuesAreAllFalse(this IEnumerable< /// Latest values of each sequence are all true. /// /// The sources. - /// A Value. + /// A sequence that emits true when all latest booleans are true. public static IObservable CombineLatestValuesAreAllTrue(this IEnumerable> sources) => sources.CombineLatest(xs => xs.All(x => x)); /// /// Gets the maximum from all sources. /// - /// The Type. - /// The this. - /// The sources. - /// A Value. + /// The Value Type. + /// The first observable. + /// Other sources. + /// A sequence emitting the maximum of the latest values. public static IObservable GetMax(this IObservable @this, params IObservable[] sources) where T : struct { @@ -152,10 +173,10 @@ public static IObservable CombineLatestValuesAreAllTrue(this IEnumerable /// Gets the minimum from all sources. /// - /// The Type. - /// The this. - /// The sources. - /// A Value. + /// The Value Type. + /// The first observable. + /// Other sources. + /// A sequence emitting the minimum of the latest values. public static IObservable GetMin(this IObservable @this, params IObservable[] sources) where T : struct { @@ -168,11 +189,9 @@ public static IObservable CombineLatestValuesAreAllTrue(this IEnumerable /// update type. /// source stream. - /// - /// if source steam does not OnNext any update during this period, it is declared staled. - /// + /// If source stream does not OnNext any update during this period, it is declared stale. /// The scheduler. - /// Observable Stale T. + /// Observable stale markers or updates. public static IObservable> DetectStale(this IObservable source, TimeSpan stalenessPeriod, IScheduler scheduler) => Observable.Create>(observer => { @@ -192,7 +211,6 @@ void ScheduleStale() => var sourceSubscription = source.Subscribe( x => { - // cancel any scheduled stale update (timerSubscription?.Disposable)?.Dispose(); lock (observerLock) @@ -210,32 +228,25 @@ void ScheduleStale() => return new CompositeDisposable { sourceSubscription, - timerSubscription + timerSubscription, }; }); /// /// Applies a conflation algorithm to an observable stream. Anytime the stream OnNext twice /// below minimumUpdatePeriod, the second update gets delayed to respect the - /// minimumUpdatePeriod If more than 2 update happen, only the last update is pushed Updates - /// are pushed and rescheduled using the provided scheduler. + /// minimumUpdatePeriod. If more than 2 updates happen, only the last update is pushed. /// /// The type. - /// the stream. - /// minimum delay between 2 updates. - /// to be used to publish updates and schedule delayed updates. - /// Observable T. + /// The stream. + /// Minimum delay between two updates. + /// Scheduler to publish updates. + /// The conflated stream. public static IObservable Conflate(this IObservable source, TimeSpan minimumUpdatePeriod, IScheduler scheduler) => Observable.Create(observer => { - // indicate when the last update was published var lastUpdateTime = DateTimeOffset.MinValue; - - // indicate if an update is currently scheduled MultipleAssignmentDisposable updateScheduled = new(); - - // indicate if completion has been requested (we can't complete immediately if an - // update is in flight) var completionRequested = false; object gate = new(); @@ -259,21 +270,21 @@ public static IObservable Conflate(this IObservable source, TimeSpan mi if (scheduleRequired) { updateScheduled.Disposable = scheduler.Schedule( - lastUpdateTime + minimumUpdatePeriod, - () => - { - observer.OnNext(x); - - lock (gate) - { - lastUpdateTime = scheduler.Now; - updateScheduled.Disposable = null; - if (completionRequested) + lastUpdateTime + minimumUpdatePeriod, + () => { - observer.OnCompleted(); - } - } - }); + observer.OnNext(x); + + lock (gate) + { + lastUpdateTime = scheduler.Now; + updateScheduled.Disposable = null; + if (completionRequested) + { + observer.OnCompleted(); + } + } + }); } else { @@ -287,7 +298,6 @@ public static IObservable Conflate(this IObservable source, TimeSpan mi observer.OnError, () => { - // if we have scheduled an update we need to complete once the update has been published if (updateScheduled.Disposable != null) { lock (gate) @@ -303,17 +313,13 @@ public static IObservable Conflate(this IObservable source, TimeSpan mi }); /// - /// Injects heartbeats in a stream when the source stream becomes quiet: - /// - upon subscription if the source does not OnNext any update a heartbeat will be pushed - /// after heartbeat Period, periodically until source receives an update - /// - when an update is received it is immediately pushed. After this update, if source does - /// not OnNext after heartbeat Period, heartbeats will be pushed. + /// Injects heartbeats in a stream when the source stream becomes quiet. /// - /// update type. - /// source stream. - /// The heartbeat period. - /// The scheduler. - /// Observable Heartbeat T. + /// Update type. + /// Source stream. + /// Period between heartbeats. + /// Scheduler. + /// Observable heartbeat values. public static IObservable> Heartbeat(this IObservable source, TimeSpan heartbeatPeriod, IScheduler scheduler) => Observable.Create>(observer => { @@ -323,7 +329,7 @@ public static IObservable> Heartbeat(this IObservable source void ScheduleHeartbeats() { var disposable = Observable.Timer(heartbeatPeriod, heartbeatPeriod, scheduler) - .Subscribe(_ => observer.OnNext(new Heartbeat())); + .Subscribe(_ => observer.OnNext(new Heartbeat())); lock (gate) { @@ -336,12 +342,10 @@ void ScheduleHeartbeats() { lock (gate) { - // cancel any scheduled heartbeat heartbeatTimerSubscription?.Disposable?.Dispose(); } observer.OnNext(new Heartbeat(x)); - ScheduleHeartbeats(); }, observer.OnError, @@ -350,98 +354,76 @@ void ScheduleHeartbeats() ScheduleHeartbeats(); return new CompositeDisposable - { - sourceSubscription, - heartbeatTimerSubscription - }; + { + sourceSubscription, + heartbeatTimerSubscription, + }; }); /// - /// Executes With limited concurrency. + /// Executes with limited concurrency. /// - /// The Type. - /// The task functions. - /// The maximum concurrency. - /// A Value. + /// The result type. + /// Tasks to execute. + /// Maximum concurrency. + /// A sequence of task results. public static IObservable WithLimitedConcurrency(this IEnumerable> taskFunctions, int maxConcurrency) => new ConcurrencyLimiter(taskFunctions, maxConcurrency).IObservable; /// - /// Called when [next]. + /// Pushes multiple values to an observer. /// - /// The Type. - /// The observer. - /// The events. + /// Type of value. + /// Observer to push to. + /// Values to push. public static void OnNext(this IObserver observer, params T?[] events) => - FastForEach(observer, events); + FastForEach(observer, events!); /// - /// If the scheduler is not Null, wraps the source sequence in order to run its observer callbacks on the specified scheduler. + /// If the scheduler is not null observes on that scheduler. /// - /// The type of the elements in the source sequence. + /// Element type. /// Source sequence. - /// Scheduler to notify observers on. - /// The source sequence whose observations happen on the specified scheduler. - /// or is null. - /// - /// This only invokes observer callbacks on a scheduler. In case the subscription and/or unsubscription actions have side-effects - /// that require to be run on a scheduler, use . - /// + /// Scheduler to notify observers on (optional). + /// The source sequence whose callbacks happen on the specified scheduler. public static IObservable ObserveOnSafe(this IObservable source, IScheduler? scheduler) => scheduler == null ? source : source.ObserveOn(scheduler); /// - /// Invokes the action asynchronously on the specified scheduler, surfacing the result through an observable sequence. + /// Invokes the action asynchronously surfacing the result through a Unit observable. /// - /// Action to run asynchronously. - /// If the scheduler is not Null, Scheduler to run the action on. - /// An observable sequence exposing a Unit value upon completion of the action, or an exception. - /// or is null. - /// - /// - /// The action is called immediately, not during the subscription of the resulting sequence. - /// Multiple subscriptions to the resulting sequence can observe the action's outcome. - /// - /// - public static IObservable Start(Action action, IScheduler? scheduler) - => scheduler == null ? Observable.Start(action) : Observable.Start(action, scheduler); + /// Action to run. + /// Scheduler (optional). + /// A sequence producing Unit upon completion. + public static IObservable Start(Action action, IScheduler? scheduler) => + scheduler == null ? Observable.Start(action) : Observable.Start(action, scheduler); /// - /// Invokes the specified function asynchronously on the specified scheduler, surfacing the result through an observable sequence. + /// Invokes the specified function asynchronously surfacing the result. /// - /// The type of the result returned by the function. - /// Function to run asynchronously. - /// Scheduler to run the function on. - /// An observable sequence exposing the function's result value, or an exception. - /// or is null. - /// - /// - /// The function is called immediately, not during the subscription of the resulting sequence. - /// Multiple subscriptions to the resulting sequence can observe the function's result. - /// - /// - public static IObservable Start(Func function, IScheduler? scheduler) - => scheduler == null ? Observable.Start(function) : Observable.Start(function, scheduler); + /// Result type. + /// Function to run. + /// Scheduler. + /// A sequence producing the function result. + public static IObservable Start(Func function, IScheduler? scheduler) => + scheduler == null ? Observable.Start(function) : Observable.Start(function, scheduler); /// - /// Foreach from an Observable array. + /// Flattens a sequence of enumerables into individual values. /// - /// The Type. - /// The source. - /// The scheduler. - /// - /// A Value. - /// + /// Element type. + /// Source of enumerables. + /// Scheduler (optional). + /// A flattened observable. public static IObservable ForEach(this IObservable> source, IScheduler? scheduler = null) => Observable.Create(observer => source.ObserveOnSafe(scheduler).Subscribe(values => FastForEach(observer, values))); /// - /// If the scheduler is not null, Schedules an action to be executed otherwise executes the action. + /// Schedules an action immediately if scheduler null, else on scheduler. /// - /// Scheduler to execute the action on. - /// Action to execute. - /// The disposable object used to cancel the scheduled action (best effort). - /// or is null. + /// Scheduler. + /// Action. + /// Disposable for the scheduled action. public static IDisposable ScheduleSafe(this IScheduler? scheduler, Action action) { if (scheduler == null) @@ -450,17 +432,16 @@ public static IDisposable ScheduleSafe(this IScheduler? scheduler, Action action return Disposable.Empty; } - return scheduler!.Schedule(action); + return scheduler.Schedule(action); } /// - /// Schedules an action to be executed after the specified relative due time. + /// Schedules an action after a due time. /// - /// Scheduler to execute the action on. - /// Relative time after which to execute the action. - /// Action to execute. - /// The disposable object used to cancel the scheduled action (best effort). - /// or is null. + /// Scheduler. + /// Delay. + /// Action. + /// Disposable for the scheduled action. public static IDisposable ScheduleSafe(this IScheduler? scheduler, TimeSpan dueTime, Action action) { if (scheduler == null) @@ -470,66 +451,62 @@ public static IDisposable ScheduleSafe(this IScheduler? scheduler, TimeSpan dueT return Disposable.Empty; } - return scheduler!.Schedule(dueTime, action); + return scheduler.Schedule(dueTime, action); } /// - /// Froms the array. + /// Emits each element of an IEnumerable. /// - /// The Type. - /// The source. - /// The scheduler. - /// - /// A Value. - /// + /// Element type. + /// Source enumerable. + /// Scheduler (optional). + /// Observable of elements. public static IObservable FromArray(this IEnumerable source, IScheduler? scheduler = null) => Observable.Create(observer => scheduler.ScheduleSafe(() => FastForEach(observer, source))); /// - /// Using the specified object. + /// Using helper with Action. /// - /// The Type. - /// The object. - /// The action. - /// The scheduler. - /// - /// An IObservable of Unit. - /// + /// Disposable type. + /// Object to use. + /// Action to run. + /// Scheduler. + /// Completion signal. public static IObservable Using(this T obj, Action action, IScheduler? scheduler = null) - where T : IDisposable - => Observable.Using(() => obj, id => Start(() => action?.Invoke(id), scheduler)); + where T : IDisposable => + Observable.Using(() => obj, id => Start(() => action?.Invoke(id), scheduler)); /// - /// Usings the specified function. + /// Using helper with Func. /// - /// The type. - /// The type of the result. - /// The object. - /// The function. - /// The scheduler. - /// An IObservable of TResult. + /// Disposable type. + /// Result type. + /// Object to use. + /// Function to invoke. + /// Scheduler. + /// Observable of result. public static IObservable Using(this T obj, Func function, IScheduler? scheduler = null) - where T : IDisposable - => Observable.Using(() => obj, id => Start(() => function.Invoke(id), scheduler)); + where T : IDisposable => + Observable.Using(() => obj, id => Start(() => function.Invoke(id), scheduler)); /// - /// Whiles the specified condition. + /// While construct. /// - /// The condition. - /// The action. - /// The scheduler. - /// An IObservable of Unit. + /// Condition to evaluate. + /// Action to execute. + /// Scheduler. + /// Observable representing the loop. public static IObservable While(Func condition, Action action, IScheduler? scheduler = null) => Observable.While(condition, Start(action, scheduler)); /// - /// Schedules the specified due time. + /// Schedules a single value after a delay. /// - /// The type. - /// The value. - /// The due time. - /// The scheduler. - /// An IObservable of T. + /// Value type. + /// Value. + /// Delay. + /// Scheduler. + /// Observable that emits the value. public static IObservable Schedule(this T value, TimeSpan dueTime, IScheduler scheduler) => Observable.Create(observer => scheduler.ScheduleSafe(dueTime, () => observer.OnNext(value))); @@ -630,70 +607,6 @@ public static IObservable Schedule(this IObservable source, DateTimeOff observer.OnNext(value); }))); - /// - /// Schedules the specified due time. - /// - /// The type. - /// The value. - /// The due time. - /// The scheduler. - /// The action. - /// An IObservable of T. - public static IObservable Schedule(this T value, TimeSpan dueTime, IScheduler scheduler, Action action) => - Observable.Create(observer => scheduler.ScheduleSafe(dueTime, () => - { - action(); - observer.OnNext(value); - })); - - /// - /// Schedules the specified due time. - /// - /// The type. - /// The value. - /// The due time. - /// The scheduler. - /// The action. - /// An IObservable of T. - public static IObservable Schedule(this IObservable source, TimeSpan dueTime, IScheduler scheduler, Action action) => - Observable.Create(observer => source.Subscribe(value => scheduler.ScheduleSafe(dueTime, () => - { - action(); - observer.OnNext(value); - }))); - - /// - /// Schedules the specified due time. - /// - /// The type. - /// The value. - /// The due time. - /// The scheduler. - /// The action. - /// An IObservable of T. - public static IObservable Schedule(this T value, DateTimeOffset dueTime, IScheduler scheduler, Action action) => - Observable.Create(observer => scheduler.Schedule(dueTime, () => - { - action(); - observer.OnNext(value); - })); - - /// - /// Schedules the specified due time. - /// - /// The type. - /// The value. - /// The due time. - /// The scheduler. - /// The action. - /// An IObservable of T. - public static IObservable Schedule(this IObservable source, DateTimeOffset dueTime, IScheduler scheduler, Action action) => - Observable.Create(observer => source.Subscribe(value => scheduler.Schedule(dueTime, () => - { - action(); - observer.OnNext(value); - }))); - /// /// Schedules the specified due time. /// @@ -741,66 +654,41 @@ public static IObservable Schedule(this IObservable source, TimeSpan du Observable.Create(observer => source.Subscribe(value => scheduler.Schedule(dueTime, () => observer.OnNext(function(value))))); /// - /// Schedules the specified due time. + /// Filters strings by regex. /// - /// The type. - /// The value. - /// The due time. - /// The scheduler. - /// The function. - /// An IObservable of T. - public static IObservable Schedule(this T value, DateTimeOffset dueTime, IScheduler scheduler, Func function) => - Observable.Create(observer => scheduler.Schedule(dueTime, () => observer.OnNext(function(value)))); - - /// - /// Schedules the specified due time. - /// - /// The type. - /// The value. - /// The due time. - /// The scheduler. - /// The function. - /// An IObservable of T. - public static IObservable Schedule(this IObservable source, DateTimeOffset dueTime, IScheduler scheduler, Func function) => - Observable.Create(observer => source.Subscribe(value => scheduler.Schedule(dueTime, () => observer.OnNext(function(value))))); - - /// - /// Filters the specified source. - /// - /// The source. - /// The pattern. - /// A Value. + /// Source sequence. + /// Regex pattern. + /// Filtered sequence. public static IObservable Filter(this IObservable source, string regexPattern) => source.Where(f => Regex.IsMatch(f, regexPattern)); /// - /// Shuffles the specified source. + /// Randomly shuffles arrays emitted by the source. /// - /// The type. - /// The source. - /// An array of values shuffled randomly. + /// Array element type. + /// Source array sequence. + /// Sequence of shuffled arrays (in-place). public static IObservable Shuffle(this IObservable source) => Observable.Create(observer => source.Subscribe(array => + { + Random random = new(unchecked(Environment.TickCount * 31)); + var n = array.Length; + while (n > 1) { - Random random = new(unchecked(Environment.TickCount * 31)); - var n = array.Length; - while (n > 1) - { - n--; - var k = random.Next(n + 1); - (array[n], array[k]) = (array[k], array[n]); - } + n--; + var k = random.Next(n + 1); + (array[n], array[k]) = (array[k], array[n]); + } - observer.OnNext(array); - })); + observer.OnNext(array); + })); /// - /// Repeats the source observable sequence until it successfully terminates. - /// This is same as Retry(). + /// Repeats the source until it terminates successfully (alias of Retry). /// - /// The type of the source. - /// The source. - /// A Value. + /// Element type. + /// Source sequence. + /// Retried sequence. public static IObservable OnErrorRetry(this IObservable source) => source.Retry(); /// @@ -810,7 +698,7 @@ public static IObservable Shuffle(this IObservable source) => /// The type of the exception. /// The source. /// The on error. - /// A Value. + /// A sequence that retries on error with optional delay. public static IObservable OnErrorRetry(this IObservable source, Action onError) where TException : Exception => source.OnErrorRetry(onError, TimeSpan.Zero); @@ -822,9 +710,9 @@ public static IObservable Shuffle(this IObservable source) => /// The source. /// The on error. /// The delay. - /// A Value. + /// A sequence that retries on error with optional delay. public static IObservable OnErrorRetry(this IObservable source, Action onError, TimeSpan delay) -where TException : Exception => source.OnErrorRetry(onError, int.MaxValue, delay); + where TException : Exception => source.OnErrorRetry(onError, int.MaxValue, delay); /// /// When caught exception, do onError action and repeat observable sequence during within retryCount. @@ -834,9 +722,9 @@ public static IObservable Shuffle(this IObservable source) => /// The source. /// The on error. /// The retry count. - /// A Value. + /// A sequence that retries on error with optional delay. public static IObservable OnErrorRetry(this IObservable source, Action onError, int retryCount) -where TException : Exception => source.OnErrorRetry(onError, retryCount, TimeSpan.Zero); + where TException : Exception => source.OnErrorRetry(onError, retryCount, TimeSpan.Zero); /// /// When caught exception, do onError action and repeat observable sequence after delay time @@ -848,9 +736,9 @@ public static IObservable Shuffle(this IObservable source) => /// The on error. /// The retry count. /// The delay. - /// A Value. + /// A sequence that retries on error with optional delay. public static IObservable OnErrorRetry(this IObservable source, Action onError, int retryCount, TimeSpan delay) -where TException : Exception => source.OnErrorRetry(onError, retryCount, delay, Scheduler.Default); + where TException : Exception => source.OnErrorRetry(onError, retryCount, delay, Scheduler.Default); /// /// When caught exception, do onError action and repeat observable sequence after delay @@ -863,7 +751,7 @@ public static IObservable Shuffle(this IObservable source) => /// The retry count. /// The delay. /// The delay scheduler. - /// A Value. + /// A sequence that retries on error with optional delay. public static IObservable OnErrorRetry(this IObservable source, Action onError, int retryCount, TimeSpan delay, IScheduler delayScheduler) where TException : Exception => Observable.Defer(() => { @@ -885,34 +773,32 @@ public static IObservable Shuffle(this IObservable source) => }); /// - /// Takes the until the predicate is true. + /// Takes elements until predicate returns true for an element (inclusive) then completes. /// - /// The type of the source. - /// The source. - /// The predicate for completion. - /// Observable TSource. + /// Element type. + /// Source sequence. + /// Predicate for completion. + /// Sequence that completes when predicate satisfied. public static IObservable TakeUntil(this IObservable source, Func predicate) => Observable.Create(observer => source.Subscribe( - item => - { - observer.OnNext(item); - if (predicate?.Invoke(item) ?? default) + item => { - observer.OnCompleted(); - } - }, - observer.OnError, - observer.OnCompleted)); + observer.OnNext(item); + if (predicate?.Invoke(item) ?? default) + { + observer.OnCompleted(); + } + }, + observer.OnError, + observer.OnCompleted)); /// - /// Synchronizes the asynchronous operations in downstream operations. - /// Use SubscribeSynchronus instead for a simpler version. - /// Call Sync.Dispose() to release the lock in the downstream methods. + /// Wraps values with a synchronization disposable that completes when disposed. /// - /// The type of the elements in the source sequence. - /// The source. - /// An Observable of T and a release mechanism. + /// Element type. + /// Source sequence. + /// Sequence of (value, sync handle). public static IObservable<(T Value, IDisposable Sync)> SynchronizeSynchronous(this IObservable source) => Observable.Create<(T Value, IDisposable Sync)>(observer => source.Subscribe(item => new Continuation().Lock(item, observer).Wait())); @@ -1046,6 +932,344 @@ public static IDisposable SubscribeAsync(this IObservable source, Func(this IObservable source, Func onNext, Action onError, Action onCompleted) => source.Select(o => Observable.FromAsync(() => onNext(o))).Concat().Subscribe(_ => { }, onError, onCompleted); + /// + /// Emits the boolean negation of the source sequence. + /// + /// Boolean source. + /// Negated boolean sequence. + public static IObservable Not(this IObservable source) => source.Select(b => !b); + + /// + /// Filters to true values only. + /// + /// Boolean source. + /// Sequence of true values. + public static IObservable WhereTrue(this IObservable source) => source.Where(b => b); + + /// + /// Filters to false values only. + /// + /// Boolean source. + /// Sequence of false values. + public static IObservable WhereFalse(this IObservable source) => source.Where(b => !b); + + /// + /// Catches any error and returns a fallback value then completes. + /// + /// Element type. + /// Source sequence. + /// Fallback value. + /// Sequence producing either original values or fallback on error then completing. + public static IObservable CatchAndReturn(this IObservable source, T fallback) => + source.Catch(Observable.Return(fallback)); + + /// + /// Catches a specific exception type mapping it to a fallback value. + /// + /// Element type. + /// Exception type. + /// Source sequence. + /// Factory producing fallback from the exception. + /// Recovered sequence. + public static IObservable CatchAndReturn(this IObservable source, Func fallbackFactory) + where TException : Exception => + source.Catch(ex => Observable.Return(fallbackFactory(ex))); + + /// + /// Retries with exponential backoff. + /// + /// Element type. + /// Source sequence. + /// Maximum number of retries. + /// Initial backoff delay. + /// Multiplier for each retry (default 2). + /// Optional maximum delay. + /// Scheduler (optional). + /// Retried sequence with backoff. + public static IObservable RetryWithBackoff(this IObservable source, int maxRetries, TimeSpan initialDelay, double backoffFactor = 2.0, TimeSpan? maxDelay = null, IScheduler? scheduler = null) => + Observable.Defer(() => + { + scheduler ??= Scheduler.Default; + var attempt = 0; + return source.Catch(ex => + { + if (attempt++ >= maxRetries) + { + return Observable.Throw(ex); + } + + var nextDelay = TimeSpan.FromMilliseconds(initialDelay.TotalMilliseconds * Math.Pow(backoffFactor, attempt - 1)); + if (maxDelay.HasValue && nextDelay > maxDelay.Value) + { + nextDelay = maxDelay.Value; + } + + return Observable.Timer(nextDelay, scheduler).Select(_ => default(T)!).IgnoreElements().Concat(source).RetryWithBackoff(maxRetries - attempt, initialDelay, backoffFactor, maxDelay, scheduler); + }); + }); + + /// + /// Emits only the first value in each time window. + /// + /// Element type. + /// Source sequence. + /// Time window. + /// Scheduler (optional). + /// Throttle-first sequence. + public static IObservable ThrottleFirst(this IObservable source, TimeSpan window, IScheduler? scheduler = null) + { + scheduler ??= Scheduler.Default; + return Observable.Create(obs => + { + object gate = new(); + DateTimeOffset last = DateTimeOffset.MinValue; + return source.Subscribe( + x => + { + var now = scheduler.Now; + bool emit; + lock (gate) + { + emit = now - last >= window; + if (emit) + { + last = now; + } + } + + if (emit) + { + obs.OnNext(x); + } + }, + obs.OnError, + obs.OnCompleted); + }); + } + + /// + /// Debounces with an immediate first emission then standard debounce behavior. + /// + /// Element type. + /// Source sequence. + /// Debounce time. + /// Scheduler (optional). + /// Debounced sequence. + public static IObservable DebounceImmediate(this IObservable source, TimeSpan dueTime, IScheduler? scheduler = null) + { + scheduler ??= Scheduler.Default; + return Observable.Create(obs => + { + SerialDisposable timer = new(); + object gate = new(); + var isFirst = true; + T? lastValue = default; + bool hasValue = false; + + void Emit() + { + if (hasValue) + { + obs.OnNext(lastValue!); + hasValue = false; + } + } + + var subscription = source.Subscribe( + v => + { + lock (gate) + { + if (isFirst) + { + isFirst = false; + obs.OnNext(v); + return; + } + + lastValue = v; + hasValue = true; + timer.Disposable = scheduler.Schedule(dueTime, Emit); + } + }, + ex => + { + lock (gate) + { + Emit(); + } + + obs.OnError(ex); + }, + () => + { + lock (gate) + { + Emit(); + } + + obs.OnCompleted(); + }); + return new CompositeDisposable(subscription, timer); + }); + } + + /// + /// Projects each element to a task executed sequentially. + /// + /// Source element type. + /// Result type. + /// Source sequence. + /// Task selector. + /// Sequence of results preserving order. + public static IObservable SelectAsyncSequential(this IObservable source, Func> selector) => + source.Select(x => Observable.FromAsync(() => selector(x))).Concat(); + + /// + /// Projects each element to a task but only latest result is emitted. + /// + /// Source type. + /// Result type. + /// Source sequence. + /// Task selector. + /// Sequence of latest task results. + public static IObservable SelectLatestAsync(this IObservable source, Func> selector) => + source.Select(x => Observable.FromAsync(() => selector(x))).Switch(); + + /// + /// Projects each element to a task with limited concurrency. + /// + /// Source type. + /// Result type. + /// Source sequence. + /// Task selector. + /// Max concurrency. + /// Merged sequence of task results. + public static IObservable SelectAsyncConcurrent(this IObservable source, Func> selector, int maxConcurrency) => + source.Select(x => Observable.FromAsync(() => selector(x))).Merge(maxConcurrency); + + /// + /// Partitions a sequence into two based on predicate. + /// + /// Element type. + /// Source sequence. + /// Predicate. + /// Tuple of (trueSequence, falseSequence). + public static (IObservable True, IObservable False) Partition(this IObservable source, Func predicate) + { + var published = source.Publish().RefCount(); + return (published.Where(predicate), published.Where(x => !predicate(x))); + } + + /// + /// Buffers items until inactivity period elapses then emits and resets buffer. + /// + /// Element type. + /// Source sequence. + /// Inactivity period. + /// Scheduler. + /// Sequence of buffered lists. + public static IObservable> BufferUntilInactive(this IObservable source, TimeSpan inactivityPeriod, IScheduler? scheduler = null) + { + scheduler ??= Scheduler.Default; + return Observable.Create>(observer => + { + object gate = new(); + List buffer = new(); + SerialDisposable timer = new(); + + void Flush() + { + List? toEmit = null; + lock (gate) + { + if (buffer.Count > 0) + { + toEmit = buffer; + buffer = new(); + } + } + + if (toEmit != null) + { + observer.OnNext(toEmit); + } + } + + void ScheduleFlush() => timer.Disposable = scheduler.Schedule(inactivityPeriod, Flush); + + var subscription = source.Subscribe( + x => + { + lock (gate) + { + buffer.Add(x); + ScheduleFlush(); + } + }, + ex => + { + Flush(); + observer.OnError(ex); + }, + () => + { + Flush(); + observer.OnCompleted(); + }); + + return new CompositeDisposable(subscription, timer); + }); + } + + /// + /// Emits the first element matching predicate then completes. + /// + /// Element type. + /// Source sequence. + /// Predicate. + /// Sequence with first matching element. + public static IObservable WaitUntil(this IObservable source, Func predicate) => + source.Where(predicate).Take(1); + + /// + /// Executes an action at subscription time. + /// + /// Element type. + /// Source sequence. + /// Action to run on subscribe. + /// Original sequence with subscribe side-effect. + public static IObservable DoOnSubscribe(this IObservable source, Action action) => + Observable.Create(o => + { + action(); + return source.Subscribe(o); + }); + + /// + /// Executes an action when subscription is disposed. + /// + /// Element type. + /// Source sequence. + /// Action to run on dispose. + /// Original sequence with dispose side-effect. + public static IObservable DoOnDispose(this IObservable source, Action disposeAction) => + Observable.Create(o => + { + var disp = source.Subscribe(o); + return Disposable.Create(() => + { + try + { + disp.Dispose(); + } + finally + { + disposeAction(); + } + }); + }); + private static void FastForEach(IObserver observer, IEnumerable source) { if (source is List fullList) @@ -1057,7 +1281,6 @@ private static void FastForEach(IObserver observer, IEnumerable source) } else if (source is IList list) { - // zero allocation enumerator foreach (var item in EnumerableIList.Create(list)) { observer.OnNext(item); diff --git a/src/ReactiveMarbles.Extensions/ReactiveMarbles.Extensions.csproj b/src/ReactiveMarbles.Extensions/ReactiveMarbles.Extensions.csproj index 0350d51..edd695f 100644 --- a/src/ReactiveMarbles.Extensions/ReactiveMarbles.Extensions.csproj +++ b/src/ReactiveMarbles.Extensions/ReactiveMarbles.Extensions.csproj @@ -1,7 +1,7 @@  - netstandard2.0;net6.0;net7.0;net8.0 + netstandard2.0;net8.0;net9.0;net10.0 README.md diff --git a/src/directory.packages.props b/src/directory.packages.props index 2579ec4..b757bf5 100644 --- a/src/directory.packages.props +++ b/src/directory.packages.props @@ -23,7 +23,7 @@ - +