Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
92 changes: 91 additions & 1 deletion README.md
Original file line number Diff line number Diff line change
Expand Up @@ -440,7 +440,7 @@ Important behavior:
ReactiveUI.Primitives keeps the standard `IObserver<T>` shape and provides helper observer implementations internally
under the `Core` namespace.

Common user-facing subscription overloads live in `SubscribeMixins`:
Common user-facing subscription overloads live in `SubscribeExtensions`:

```csharp
using ReactiveUI.Primitives;
Expand All @@ -458,6 +458,76 @@ using var full = signal.Subscribe(
The library uses the term witness for lightweight observer wrappers. You normally use delegates or `IObserver<T>`
directly rather than constructing witness types by hand.

### Using Primitives alongside System.Reactive

Packages such as DynamicData can bring in System.Reactive transitively. Importing both `System` and
`ReactiveUI.Primitives` then exposes two sets of `Subscribe` extension methods for `IObservable<T>`.
Use `SubscribePrimitives` to select the Primitives implementation without changing the observable:

```csharp
using var subscription = saveCommand.ThrownExceptions.SubscribePrimitives(
error => activity.AddItem(error.ToString()));
```

It has the same five callback overloads and behavior as `Subscribe`, including disposal and unhandled-error
propagation. Existing `Subscribe` APIs remain available. An explicit static call also selects Primitives:

```csharp
using var subscription = SubscribeExtensions.Subscribe(
saveCommand.ThrownExceptions,
error => activity.AddItem(error.ToString()));
```

Putting `using ReactiveUI.Primitives;` inside the consuming namespace also gives its extension methods
precedence over a global `using System;`. This applies per namespace; a global import alone does not
resolve the conflict. Import only one set of LINQ operators when their signatures overlap.

`SubscribeSafe` is not a drop-in rename: its single `Action<Exception>` overload handles terminal errors,
not values emitted by an `IObservable<Exception>`. To handle exception values with `SubscribeSafe`, supply
both `onNext` and `onError` explicitly.

System.Reactive declares its own observer-taking `SubscribeSafe` in the `System` namespace, so that one
overload is ambiguous under the same conditions as `Subscribe`. Use `SubscribeSafePrimitives(observer)` to
select the Primitives implementation. The callback shapes of `SubscribeSafe` have no System.Reactive
counterpart and stay callable under their own name.

### Scheduling event handlers and drawing

`ObserveOn` schedules downstream notifications. Moving work from an event handler into a subscriber
after `ObserveOn` therefore changes when that work runs, even when the scheduler targets the UI thread.
For paint events such as SkiaSharp's `PaintSurface`, draw synchronously while the event's surface is valid.
Do not defer use of its canvas through `ObserveOn` or an `await`. Schedule a redraw request instead, and
perform the drawing in the resulting paint callback.

The `Signal.FromEventPattern<TEventHandler, TEventArgs>(conversion, addHandler, removeHandler)` overload
lets a custom event handler perform synchronous work before invoking the notification callback. Each
subscription owns its converted handler and detaches that same handler on disposal. Supplying the conversion
also avoids deriving the handler reflectively, which is what makes this shape trim- and AOT-safe.

Three siblings build on the same conversion. `FromEventPattern<TEventHandler, TSender, TEventArgs>` keeps the
sender's static type instead of erasing it to `object`. `FromEvent<TEventHandler, TEventArgs>` emits the event
argument on its own, for events that carry no sender, and `FromEvent<TEventArgs>(addHandler, removeHandler)`
covers the plain `Action<TEventArgs>` case. Every one of them, and every `FromEventPattern` overload, accepts a
trailing sequencer that attaches and detaches the handler as scheduled work rather than on the subscribing
thread — the shape to use when an event may only be subscribed from the UI thread:

```csharp
using var painted = Signal.FromEventPattern<SKPaintSurfaceEventArgs>(
handler => view.SkiaElement.PaintSurface += handler,
handler => view.SkiaElement.PaintSurface -= handler,
RxSchedulers.MainThreadScheduler)
.SubscribePrimitives(pattern => Draw(pattern.EventArgs));
```

Disposing cancels a pending attach, so a subscription torn down before the sequencer ran it never leaves the
handler on the event.

`Throttle` (also called `Calm` or `Stabilize`) waits for a quiet period after the most recent value.
By default its timer uses the thread pool; it does not marshal the result to the UI thread. Use
`Throttle(duration, uiSequencer)` or put `ObserveOn(uiSequencer)` after `Throttle` when the subscriber
requires the UI thread. Source completion flushes a pending value immediately, matching Rx debounce
semantics; it does not wait for the remaining quiet period.

### Disposables, handles, and slots

Subscriptions and scheduled work return `IDisposable`. ReactiveUI.Primitives includes lightweight disposable primitives
Expand Down Expand Up @@ -706,6 +776,26 @@ sources. The `.Reactive` package variants expose the same overloads with `System
conventions, which keeps migrated Rx code using familiar `CombineLatest` names while running on the Primitives
implementation.

`CombineLatest` also provides tuple results for 2–16 sources without a selector. Tuple members are named
`First`, `Second`, `Third`, and so on, and values start flowing after every source has produced a value:

```csharp
using var dimensions = width.CombineLatest(height)
.SubscribePrimitives(size => Console.WriteLine($"{size.First} x {size.Second}"));
```

When the sources share an element type and are too many to name, or they only exist as a collection,
`CombineLatest` also combines them into an `IList<T>`, with an optional selector over that list. The
collection is enumerated once, when the operator is called, and every notification carries its own list:

```csharp
using var totals = gauges.CombineLatest(readings => Total(readings))
.SubscribePrimitives(total => Console.WriteLine($"total={total}"));
```

Listing two to sixteen same-typed sources inline still selects the tuple overload that names each of them;
the list overload takes over past that arity, and whenever the sources arrive as an array or a sequence.

Multi-source latest example:

```csharp
Expand Down
4 changes: 2 additions & 2 deletions src/Primitives.Shared/Advanced/AfterSubscription.cs
Original file line number Diff line number Diff line change
Expand Up @@ -53,7 +53,7 @@ public AfterSubscription(IObserver<long> observer, ISequencer scheduler, TimeSpa
/// <returns>The subscription handle.</returns>
public AfterSubscription Run()
{
Slot.Create(Scheduler.Schedule(Sequencer.Normalize(DueTime), Tick));
TimerSlot.Arm(Slot, Scheduler, Sequencer.Normalize(DueTime), Tick);
return this;
}

Expand All @@ -74,6 +74,6 @@ private void Tick()
return;
}

Slot.Create(Scheduler.Schedule(period, Tick));
TimerSlot.Arm(Slot, Scheduler, period, Tick);
}
}
80 changes: 80 additions & 0 deletions src/Primitives.Shared/Advanced/EventHandlerScope.cs
Original file line number Diff line number Diff line change
@@ -0,0 +1,80 @@
// 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.

#if REACTIVE_SHIM
namespace ReactiveUI.Primitives.Reactive.Advanced;
#else
namespace ReactiveUI.Primitives.Advanced;
#endif

/// <summary>Owns one subscription's event-handler attachment for the event bridge signals.</summary>
public static class EventHandlerScope
{
/// <summary>Attaches a handler and returns the disposable that detaches that same handler.</summary>
/// <typeparam name="TEventHandler">The delegate type used by the event.</typeparam>
/// <param name="handler">The handler this subscription owns.</param>
/// <param name="addHandler">The action that attaches the handler.</param>
/// <param name="removeHandler">The action that detaches the handler.</param>
/// <param name="scheduler">The sequencer that attaches and detaches the handler, or <see langword="null"/> to use the calling thread.</param>
/// <returns>The disposable that detaches the handler.</returns>
/// <exception cref="ArgumentNullException"><paramref name="addHandler"/> or <paramref name="removeHandler"/> is <see langword="null"/>.</exception>
public static IDisposable Attach<TEventHandler>(
TEventHandler handler,
Action<TEventHandler> addHandler,
Action<TEventHandler> removeHandler,
ISequencer? scheduler)
{
ArgumentExceptionHelper.ThrowIfNull(addHandler);

ArgumentExceptionHelper.ThrowIfNull(removeHandler);

if (scheduler is not { } sequencer)
{
addHandler(handler);
return Scope.Create((handler, removeHandler), static state => state.removeHandler(state.handler));
}

return AttachScheduled(handler, addHandler, removeHandler, sequencer);
}

/// <summary>Attaches and later detaches the handler on the sequencer instead of the calling thread.</summary>
/// <typeparam name="TEventHandler">The delegate type used by the event.</typeparam>
/// <param name="handler">The handler this subscription owns.</param>
/// <param name="addHandler">The action that attaches the handler.</param>
/// <param name="removeHandler">The action that detaches the handler.</param>
/// <param name="sequencer">The sequencer that attaches and detaches the handler.</param>
/// <returns>The disposable that detaches the handler.</returns>
/// <remarks>
/// Disposing cancels a still-pending attach before requesting the detach, so a subscription torn down
/// before the sequencer ran the attach cannot leave the handler on the event.
/// </remarks>
private static IDisposable AttachScheduled<TEventHandler>(
TEventHandler handler,
Action<TEventHandler> addHandler,
Action<TEventHandler> removeHandler,
ISequencer sequencer)
{
var attach = sequencer.Schedule(
(handler, addHandler),
static (_, state) =>
{
state.addHandler(state.handler);
return EmptyDisposable.Instance;
});

return Scope.Create(
(handler, removeHandler, attach, sequencer),
static state =>
{
state.attach.Dispose();
_ = state.sequencer.Schedule(
(state.handler, state.removeHandler),
static (_, detach) =>
{
detach.removeHandler(detach.handler);
return EmptyDisposable.Instance;
});
});
}
}
2 changes: 1 addition & 1 deletion src/Primitives.Shared/Advanced/EverySignal.cs
Original file line number Diff line number Diff line change
Expand Up @@ -96,7 +96,7 @@ internal EveryCoordinator Run()

/// <summary>Schedules the next tick into the cancellation slot.</summary>
[MethodImpl(MethodImplOptions.AggressiveInlining)]
private void ScheduleNext() => _slot.Create(_scheduler.Schedule(_period, _tickAction));
private void ScheduleNext() => TimerSlot.Arm(_slot, _scheduler, _period, _tickAction);

/// <summary>Emits the current tick and reschedules unless cancelled.</summary>
/// <remarks>
Expand Down
Original file line number Diff line number Diff line change
@@ -0,0 +1,56 @@
// 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.

#if REACTIVE_SHIM
namespace ReactiveUI.Primitives.Reactive.Advanced;
#else
namespace ReactiveUI.Primitives.Advanced;
#endif

/// <summary>
/// Bridges an event whose handler is built by a caller-supplied conversion, so the handler can do
/// synchronous work inside the event before the notification is published downstream. Supplying the
/// conversion removes the need to derive a delegate reflectively, keeping the bridge trim- and AOT-safe.
/// </summary>
/// <typeparam name="TEventHandler">The delegate type used by the event.</typeparam>
/// <typeparam name="TCallback">The notification callback type handed to the conversion.</typeparam>
/// <typeparam name="TResult">The element type published downstream.</typeparam>
/// <param name="conversion">Converts the notification callback into the event's handler type.</param>
/// <param name="addHandler">The action that attaches the converted handler.</param>
/// <param name="removeHandler">The action that detaches the converted handler.</param>
/// <param name="callback">Builds the notification callback for a subscribed observer.</param>
/// <param name="scheduler">The sequencer that attaches and detaches the handler, or <see langword="null"/> to use the calling thread.</param>
[System.Diagnostics.DebuggerDisplay("FromEventConversionSignal: Conversion = {_conversion}, Scheduler = {_scheduler}")]
public sealed class FromEventConversionSignal<TEventHandler, TCallback, TResult>(
Func<TCallback, TEventHandler> conversion,
Action<TEventHandler> addHandler,
Action<TEventHandler> removeHandler,
Func<IObserver<TResult>, TCallback> callback,
ISequencer? scheduler) : IObservable<TResult>
where TEventHandler : Delegate
{
/// <summary>Converts the notification callback into the event's handler type.</summary>
private readonly Func<TCallback, TEventHandler> _conversion = conversion;

/// <summary>The action that attaches the converted handler.</summary>
private readonly Action<TEventHandler> _addHandler = addHandler;

/// <summary>The action that detaches the converted handler.</summary>
private readonly Action<TEventHandler> _removeHandler = removeHandler;

/// <summary>Builds the notification callback for a subscribed observer.</summary>
private readonly Func<IObserver<TResult>, TCallback> _callback = callback;

/// <summary>The sequencer that attaches and detaches the handler, or <see langword="null"/> for the calling thread.</summary>
private readonly ISequencer? _scheduler = scheduler;

/// <inheritdoc/>
public IDisposable Subscribe(IObserver<TResult> observer)
{
ArgumentExceptionHelper.ThrowIfNull(observer);

var handler = _conversion(_callback(observer));
return EventHandlerScope.Attach(handler, _addHandler, _removeHandler, _scheduler);
}
}
Original file line number Diff line number Diff line change
Expand Up @@ -28,9 +28,22 @@ public sealed class FromEventPatternSignal<TEventHandler, TEventArgs> : IObserva
/// <param name="addHandler">The action that attaches the generated handler.</param>
/// <param name="removeHandler">The action that detaches the generated handler.</param>
public FromEventPatternSignal(Action<TEventHandler> addHandler, Action<TEventHandler> removeHandler)
: this(addHandler, removeHandler, null)
{
}

/// <summary>Initializes a new instance of the <see cref="FromEventPatternSignal{TEventHandler, TEventArgs}"/> class.</summary>
/// <param name="addHandler">The action that attaches the generated handler.</param>
/// <param name="removeHandler">The action that detaches the generated handler.</param>
/// <param name="scheduler">The sequencer that attaches and detaches the handler, or <see langword="null"/> to use the subscribing thread.</param>
public FromEventPatternSignal(
Action<TEventHandler> addHandler,
Action<TEventHandler> removeHandler,
ISequencer? scheduler)
{
AddHandler = addHandler;
RemoveHandler = removeHandler;
Scheduler = scheduler;
}

/// <summary>Gets the action that attaches the generated handler.</summary>
Expand All @@ -39,14 +52,16 @@ public FromEventPatternSignal(Action<TEventHandler> addHandler, Action<TEventHan
/// <summary>Gets the action that detaches the generated handler.</summary>
private Action<TEventHandler> RemoveHandler { get; }

/// <summary>Gets the sequencer that attaches and detaches the handler.</summary>
private ISequencer? Scheduler { get; }

/// <inheritdoc/>
public IDisposable Subscribe(IObserver<EventPattern<TEventArgs>> observer)
{
ArgumentExceptionHelper.ThrowIfNull(observer);

var handler = CreateHandler(observer);
AddHandler(handler);
return Scope.Create((Self: this, handler), static s => s.Self.RemoveHandler(s.handler));
return EventHandlerScope.Attach(handler, AddHandler, RemoveHandler, Scheduler);
}

/// <summary>Creates a supported event delegate for the observer.</summary>
Expand Down
39 changes: 39 additions & 0 deletions src/Primitives.Shared/Advanced/TimerSlot.cs
Original file line number Diff line number Diff line change
@@ -0,0 +1,39 @@
// 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.

#if REACTIVE_SHIM
namespace ReactiveUI.Primitives.Reactive.Advanced;
#else
namespace ReactiveUI.Primitives.Advanced;
#endif

/// <summary>Arms the single active timer held by an operator's cancellation slot.</summary>
public static class TimerSlot
{
/// <summary>Publishes slot ownership before scheduling, then fills it with the scheduled handle.</summary>
/// <param name="slot">The slot holding the operator's currently armed timer.</param>
/// <param name="sequencer">The sequencer that runs the callback.</param>
/// <param name="delay">The delay before the callback runs.</param>
/// <param name="tick">The timer callback.</param>
/// <exception cref="ArgumentNullException">An argument is <see langword="null"/>.</exception>
/// <remarks>
/// A sequencer may run <paramref name="tick"/> before its own <c>Schedule</c> returns, and that callback
/// may arm its successor. Assigning the returned handle straight into <paramref name="slot"/> would then
/// replace - and so cancel - the successor rather than the timer that has already fired. Reserving the
/// slot first means the late assignment lands in a reservation the successor has already superseded, and
/// the already-fired handle is disposed instead.
/// </remarks>
public static void Arm(SingleReplaceableDisposable slot, ISequencer sequencer, TimeSpan delay, Action tick)
{
ArgumentExceptionHelper.ThrowIfNull(slot);

ArgumentExceptionHelper.ThrowIfNull(sequencer);

ArgumentExceptionHelper.ThrowIfNull(tick);

SingleDisposable pending = new();
slot.Create(pending);
pending.Create(sequencer.Schedule(delay, tick));
}
}
Loading
Loading