diff --git a/src/ReactiveUI.Primitives.Maui.Reactive/Concurrency/MauiDispatcherSequencer.cs b/src/ReactiveUI.Primitives.Maui.Reactive/Concurrency/MauiDispatcherSequencer.cs
index 69404b29..6de04a0d 100644
--- a/src/ReactiveUI.Primitives.Maui.Reactive/Concurrency/MauiDispatcherSequencer.cs
+++ b/src/ReactiveUI.Primitives.Maui.Reactive/Concurrency/MauiDispatcherSequencer.cs
@@ -5,6 +5,7 @@
using System.Reactive.Concurrency;
using System.Reactive.Disposables;
using System.Runtime.CompilerServices;
+using Microsoft.Maui;
using Microsoft.Maui.Dispatching;
namespace ReactiveUI.Primitives.Reactive.Concurrency;
@@ -15,6 +16,13 @@ namespace ReactiveUI.Primitives.Reactive.Concurrency;
[System.Diagnostics.DebuggerDisplay("MauiDispatcherSequencer: Dispatcher = {Dispatcher}")]
public sealed class MauiDispatcherSequencer : LocalScheduler
{
+ /// The shared main-thread scheduler, set once the application's dispatcher is available.
+ private static MauiDispatcherSequencer? _main;
+
+ /// The scheduler for the calling thread's dispatcher, cached per thread.
+ [ThreadStatic]
+ private static MauiDispatcherSequencer? _current;
+
/// Queues work and coalesces dispatcher drains.
private CoalescingDispatchState _dispatch;
@@ -27,6 +35,23 @@ public MauiDispatcherSequencer(IDispatcher dispatcher)
_dispatch = new(RunDrain, DefaultScheduler.Instance);
}
+ /// Gets the shared scheduler for the MAUI main (UI) thread.
+ /// No application exists yet and the calling thread has no dispatcher.
+ ///
+ /// Bound once to the running application's dispatcher, from any thread. Until an application exists nothing is
+ /// cached, and the calling thread's existing dispatcher is used instead. Never falls back to the thread pool.
+ ///
+ public static MauiDispatcherSequencer Main =>
+ Volatile.Read(ref _main) ?? BindMain(ref _main, ResolveApplicationDispatcher(IPlatformApplication.Current)) ?? Current;
+
+ /// Gets the scheduler for the calling thread's dispatcher.
+ /// The calling thread has no dispatcher.
+ ///
+ /// Cached per thread, for applications that run UI on more than one thread. Never creates a dispatcher: a thread
+ /// that has none cannot run the scheduled work.
+ ///
+ public static MauiDispatcherSequencer Current => _current ??= new(ResolveCurrentDispatcher());
+
/// Gets the dispatcher used to marshal work to the UI thread.
public IDispatcher Dispatcher { get; }
@@ -42,6 +67,35 @@ public override IDisposable Schedule(TState state, Func(TState state, TimeSpan dueTime, Func action) =>
_dispatch.Schedule(new DispatchHost(this), this, state, dueTime, action);
+ /// Caches the shared main-thread scheduler for the application's dispatcher, keeping the first one bound.
+ /// The field that holds the shared scheduler.
+ /// The application's dispatcher, or when no application exists.
+ /// The shared scheduler, or when is .
+ internal static MauiDispatcherSequencer? BindMain(ref MauiDispatcherSequencer? slot, IDispatcher? applicationDispatcher)
+ {
+ if (applicationDispatcher is null)
+ {
+ return null;
+ }
+
+ MauiDispatcherSequencer created = new(applicationDispatcher);
+ return Interlocked.CompareExchange(ref slot, created, null) ?? created;
+ }
+
+ /// Returns the application's dispatcher, which MAUI resolves on the UI thread while it starts the application.
+ /// The running platform application, or before it exists.
+ /// The application's dispatcher, or when no application or dispatcher exists yet.
+ internal static IDispatcher? ResolveApplicationDispatcher(IPlatformApplication? application) =>
+ application?.Services?.GetService(typeof(IDispatcher)) as IDispatcher;
+
+ /// Returns the calling thread's existing dispatcher without creating one.
+ /// The calling thread's dispatcher.
+ /// The calling thread has no dispatcher.
+ internal static IDispatcher ResolveCurrentDispatcher() =>
+ Microsoft.Maui.Dispatching.Dispatcher.GetForCurrentThread()
+ ?? throw new InvalidOperationException(
+ "The calling thread has no MAUI dispatcher. Use MauiDispatcherSequencer.Main or Current from a UI thread, or after the application is created.");
+
/// Schedules delayed work through the dispatcher.
/// The callback to run.
/// The requested delay.
diff --git a/src/ReactiveUI.Primitives.Maui.Reactive/PublicAPI/net10.0/PublicAPI.txt b/src/ReactiveUI.Primitives.Maui.Reactive/PublicAPI/net10.0/PublicAPI.txt
index a730f18c..6b0eb812 100644
--- a/src/ReactiveUI.Primitives.Maui.Reactive/PublicAPI/net10.0/PublicAPI.txt
+++ b/src/ReactiveUI.Primitives.Maui.Reactive/PublicAPI/net10.0/PublicAPI.txt
@@ -6,6 +6,8 @@ public sealed class MauiDispatcherSequencer : System.Reactive.Concurrency.LocalS
{
public MauiDispatcherSequencer(Microsoft.Maui.Dispatching.IDispatcher dispatcher) { }
public Microsoft.Maui.Dispatching.IDispatcher Dispatcher { get; }
+ public static ReactiveUI.Primitives.Reactive.Concurrency.MauiDispatcherSequencer Current { get; }
+ public static ReactiveUI.Primitives.Reactive.Concurrency.MauiDispatcherSequencer Main { get; }
[System.Runtime.CompilerServices.MethodImpl(System.Runtime.CompilerServices.MethodImplOptions.AggressiveInlining)]
public override System.IDisposable Schedule(TState state, System.Func action) { }
[System.Runtime.CompilerServices.MethodImpl(System.Runtime.CompilerServices.MethodImplOptions.AggressiveInlining)]
diff --git a/src/ReactiveUI.Primitives.Maui.Reactive/PublicAPI/net11.0/PublicAPI.txt b/src/ReactiveUI.Primitives.Maui.Reactive/PublicAPI/net11.0/PublicAPI.txt
index a730f18c..6b0eb812 100644
--- a/src/ReactiveUI.Primitives.Maui.Reactive/PublicAPI/net11.0/PublicAPI.txt
+++ b/src/ReactiveUI.Primitives.Maui.Reactive/PublicAPI/net11.0/PublicAPI.txt
@@ -6,6 +6,8 @@ public sealed class MauiDispatcherSequencer : System.Reactive.Concurrency.LocalS
{
public MauiDispatcherSequencer(Microsoft.Maui.Dispatching.IDispatcher dispatcher) { }
public Microsoft.Maui.Dispatching.IDispatcher Dispatcher { get; }
+ public static ReactiveUI.Primitives.Reactive.Concurrency.MauiDispatcherSequencer Current { get; }
+ public static ReactiveUI.Primitives.Reactive.Concurrency.MauiDispatcherSequencer Main { get; }
[System.Runtime.CompilerServices.MethodImpl(System.Runtime.CompilerServices.MethodImplOptions.AggressiveInlining)]
public override System.IDisposable Schedule(TState state, System.Func action) { }
[System.Runtime.CompilerServices.MethodImpl(System.Runtime.CompilerServices.MethodImplOptions.AggressiveInlining)]
diff --git a/src/ReactiveUI.Primitives.Maui/Concurrency/MauiDispatcherSequencer.cs b/src/ReactiveUI.Primitives.Maui/Concurrency/MauiDispatcherSequencer.cs
index ad7aa548..0bb47552 100644
--- a/src/ReactiveUI.Primitives.Maui/Concurrency/MauiDispatcherSequencer.cs
+++ b/src/ReactiveUI.Primitives.Maui/Concurrency/MauiDispatcherSequencer.cs
@@ -3,6 +3,7 @@
// See the LICENSE file in the project root for full license information.
using System.Runtime.CompilerServices;
+using Microsoft.Maui;
using Microsoft.Maui.Dispatching;
using ReactiveUI.Primitives.Advanced;
@@ -17,6 +18,13 @@ namespace ReactiveUI.Primitives.Concurrency;
[System.Diagnostics.DebuggerDisplay("{DebuggerDisplay,nq}")]
public sealed class MauiDispatcherSequencer : ISequencer
{
+ /// The shared main-thread sequencer, set once the application's dispatcher is available.
+ private static MauiDispatcherSequencer? _main;
+
+ /// The sequencer for the calling thread's dispatcher, cached per thread.
+ [ThreadStatic]
+ private static MauiDispatcherSequencer? _current;
+
/// Coalescing dispatch engine.
private DispatchSequencerState _state;
@@ -29,6 +37,23 @@ public MauiDispatcherSequencer(IDispatcher dispatcher)
_state = new(this, Post, RunDrain, ScheduleDelayed);
}
+ /// Gets the shared sequencer for the MAUI main (UI) thread.
+ /// No application exists yet and the calling thread has no dispatcher.
+ ///
+ /// Bound once to the running application's dispatcher, from any thread. Until an application exists nothing is
+ /// cached, and the calling thread's existing dispatcher is used instead. Never falls back to the thread pool.
+ ///
+ public static MauiDispatcherSequencer Main =>
+ Volatile.Read(ref _main) ?? BindMain(ref _main, ResolveApplicationDispatcher(IPlatformApplication.Current)) ?? Current;
+
+ /// Gets the sequencer for the calling thread's dispatcher.
+ /// The calling thread has no dispatcher.
+ ///
+ /// Cached per thread, for applications that run UI on more than one thread. Never creates a dispatcher: a thread
+ /// that has none cannot run the scheduled work.
+ ///
+ public static MauiDispatcherSequencer Current => _current ??= new(ResolveCurrentDispatcher());
+
/// Gets the dispatcher used to marshal work to the UI thread.
public IDispatcher Dispatcher { get; }
@@ -51,6 +76,35 @@ public MauiDispatcherSequencer(IDispatcher dispatcher)
[MethodImpl(MethodImplOptions.AggressiveInlining)]
public void Schedule(IWorkItem item, long dueTimestamp) => _state.Schedule(item, dueTimestamp);
+ /// Caches the shared main-thread sequencer for the application's dispatcher, keeping the first one bound.
+ /// The field that holds the shared sequencer.
+ /// The application's dispatcher, or when no application exists.
+ /// The shared sequencer, or when is .
+ internal static MauiDispatcherSequencer? BindMain(ref MauiDispatcherSequencer? slot, IDispatcher? applicationDispatcher)
+ {
+ if (applicationDispatcher is null)
+ {
+ return null;
+ }
+
+ MauiDispatcherSequencer created = new(applicationDispatcher);
+ return Interlocked.CompareExchange(ref slot, created, null) ?? created;
+ }
+
+ /// Returns the application's dispatcher, which MAUI resolves on the UI thread while it starts the application.
+ /// The running platform application, or before it exists.
+ /// The application's dispatcher, or when no application or dispatcher exists yet.
+ internal static IDispatcher? ResolveApplicationDispatcher(IPlatformApplication? application) =>
+ application?.Services?.GetService(typeof(IDispatcher)) as IDispatcher;
+
+ /// Returns the calling thread's existing dispatcher without creating one.
+ /// The calling thread's dispatcher.
+ /// The calling thread has no dispatcher.
+ internal static IDispatcher ResolveCurrentDispatcher() =>
+ Microsoft.Maui.Dispatching.Dispatcher.GetForCurrentThread()
+ ?? throw new InvalidOperationException(
+ "The calling thread has no MAUI dispatcher. Use MauiDispatcherSequencer.Main or Current from a UI thread, or after the application is created.");
+
/// Marshals the cached drain callback through the dispatcher.
/// The drain callback.
/// when the dispatcher accepted the work.
diff --git a/src/ReactiveUI.Primitives.Maui/PublicAPI/net10.0/PublicAPI.txt b/src/ReactiveUI.Primitives.Maui/PublicAPI/net10.0/PublicAPI.txt
index 9c8f1300..4194572c 100644
--- a/src/ReactiveUI.Primitives.Maui/PublicAPI/net10.0/PublicAPI.txt
+++ b/src/ReactiveUI.Primitives.Maui/PublicAPI/net10.0/PublicAPI.txt
@@ -1,3 +1,4 @@
+[assembly: System.Runtime.CompilerServices.InternalsVisibleTo("ReactiveUI.Primitives.Maui.Tests")]
namespace ReactiveUI.Primitives.Concurrency;
[System.Diagnostics.DebuggerDisplay("{DebuggerDisplay,nq}")]
@@ -5,6 +6,8 @@ public sealed class MauiDispatcherSequencer : ReactiveUI.Primitives.Concurrency.
{
public MauiDispatcherSequencer(Microsoft.Maui.Dispatching.IDispatcher dispatcher) { }
public Microsoft.Maui.Dispatching.IDispatcher Dispatcher { get; }
+ public static ReactiveUI.Primitives.Concurrency.MauiDispatcherSequencer Current { get; }
+ public static ReactiveUI.Primitives.Concurrency.MauiDispatcherSequencer Main { get; }
public System.DateTimeOffset Now { get; }
public long Timestamp { get; }
[System.Runtime.CompilerServices.MethodImpl(System.Runtime.CompilerServices.MethodImplOptions.AggressiveInlining)]
diff --git a/src/ReactiveUI.Primitives.Maui/PublicAPI/net11.0/PublicAPI.txt b/src/ReactiveUI.Primitives.Maui/PublicAPI/net11.0/PublicAPI.txt
index 9c8f1300..4194572c 100644
--- a/src/ReactiveUI.Primitives.Maui/PublicAPI/net11.0/PublicAPI.txt
+++ b/src/ReactiveUI.Primitives.Maui/PublicAPI/net11.0/PublicAPI.txt
@@ -1,3 +1,4 @@
+[assembly: System.Runtime.CompilerServices.InternalsVisibleTo("ReactiveUI.Primitives.Maui.Tests")]
namespace ReactiveUI.Primitives.Concurrency;
[System.Diagnostics.DebuggerDisplay("{DebuggerDisplay,nq}")]
@@ -5,6 +6,8 @@ public sealed class MauiDispatcherSequencer : ReactiveUI.Primitives.Concurrency.
{
public MauiDispatcherSequencer(Microsoft.Maui.Dispatching.IDispatcher dispatcher) { }
public Microsoft.Maui.Dispatching.IDispatcher Dispatcher { get; }
+ public static ReactiveUI.Primitives.Concurrency.MauiDispatcherSequencer Current { get; }
+ public static ReactiveUI.Primitives.Concurrency.MauiDispatcherSequencer Main { get; }
public System.DateTimeOffset Now { get; }
public long Timestamp { get; }
[System.Runtime.CompilerServices.MethodImpl(System.Runtime.CompilerServices.MethodImplOptions.AggressiveInlining)]
diff --git a/src/ReactiveUI.Primitives.Maui/ReactiveUI.Primitives.Maui.csproj b/src/ReactiveUI.Primitives.Maui/ReactiveUI.Primitives.Maui.csproj
index a374b0d0..e32f362f 100644
--- a/src/ReactiveUI.Primitives.Maui/ReactiveUI.Primitives.Maui.csproj
+++ b/src/ReactiveUI.Primitives.Maui/ReactiveUI.Primitives.Maui.csproj
@@ -17,4 +17,8 @@
+
+
+
+
diff --git a/src/ReactiveUI.Primitives.WinForms.Reactive/Concurrency/ControlSequencer.cs b/src/ReactiveUI.Primitives.WinForms.Reactive/Concurrency/ControlSequencer.cs
index f4ce366d..54266e48 100644
--- a/src/ReactiveUI.Primitives.WinForms.Reactive/Concurrency/ControlSequencer.cs
+++ b/src/ReactiveUI.Primitives.WinForms.Reactive/Concurrency/ControlSequencer.cs
@@ -13,6 +13,9 @@ namespace ReactiveUI.Primitives.Reactive.Concurrency;
[System.Diagnostics.DebuggerDisplay("ControlSequencer: Control = {Control}")]
public sealed class ControlSequencer : LocalScheduler
{
+ /// The shared main-thread scheduler, set once a UI thread first reads it.
+ private static ControlSequencer? _main;
+
/// Optional callback for posting ready work.
private readonly Func? _post;
@@ -40,6 +43,17 @@ internal ControlSequencer(Control control, Func? post, IScheduler?
Control.HandleCreated += OnHandleCreated;
}
+ /// Gets the shared scheduler for the Windows Forms main (UI) thread.
+ /// Main is not bound yet and the calling thread is not an STA thread.
+ ///
+ /// The first read binds Main to a hidden control whose handle belongs to the calling thread. Windows Forms UI
+ /// threads are always STA and thread-pool threads never are, so a read from any other thread throws and nothing is
+ /// cached. Read it first on the thread that runs the message loop, such as in Main before
+ /// .
+ ///
+ public static ControlSequencer Main =>
+ Volatile.Read(ref _main) ?? BindMain(ref _main, Thread.CurrentThread.GetApartmentState());
+
/// Gets the control used to marshal work to the UI thread.
public Control Control { get; }
@@ -57,6 +71,32 @@ public override IDisposable Schedule(TState state, Func(TState state, TimeSpan dueTime, Func action) =>
_dispatch.Schedule(new DispatchHost(this), this, state, dueTime, action);
+ /// Caches the shared main-thread scheduler for a hidden control on the calling thread, keeping the first one bound.
+ /// The field that holds the shared scheduler.
+ /// The calling thread's apartment state.
+ /// The shared scheduler.
+ /// is not .
+ internal static ControlSequencer BindMain(ref ControlSequencer? slot, ApartmentState apartment)
+ {
+ if (apartment != ApartmentState.STA)
+ {
+ throw new InvalidOperationException(
+ "The calling thread is not a Windows Forms UI thread. Read ControlSequencer.Main first on the STA thread that runs the message loop.");
+ }
+
+ Control control = new();
+ _ = control.Handle;
+ ControlSequencer created = new(control);
+ var bound = Interlocked.CompareExchange(ref slot, created, null);
+ if (bound is null)
+ {
+ return created;
+ }
+
+ control.Dispose();
+ return bound;
+ }
+
/// Posts work when the control is live and its handle is available.
/// The control whose disposal state is checked.
/// Whether the control handle is available.
diff --git a/src/ReactiveUI.Primitives.WinForms.Reactive/PublicAPI/net10.0-windows/PublicAPI.txt b/src/ReactiveUI.Primitives.WinForms.Reactive/PublicAPI/net10.0-windows/PublicAPI.txt
index 12a4575b..81367b4b 100644
--- a/src/ReactiveUI.Primitives.WinForms.Reactive/PublicAPI/net10.0-windows/PublicAPI.txt
+++ b/src/ReactiveUI.Primitives.WinForms.Reactive/PublicAPI/net10.0-windows/PublicAPI.txt
@@ -7,6 +7,7 @@ public sealed class ControlSequencer : System.Reactive.Concurrency.LocalSchedule
{
public ControlSequencer(System.Windows.Forms.Control control) { }
public System.Windows.Forms.Control Control { get; }
+ public static ReactiveUI.Primitives.Reactive.Concurrency.ControlSequencer Main { get; }
[System.Runtime.CompilerServices.MethodImpl(System.Runtime.CompilerServices.MethodImplOptions.AggressiveInlining)]
public override System.IDisposable Schedule(TState state, System.Func action) { }
[System.Runtime.CompilerServices.MethodImpl(System.Runtime.CompilerServices.MethodImplOptions.AggressiveInlining)]
diff --git a/src/ReactiveUI.Primitives.WinForms.Reactive/PublicAPI/net11.0-windows/PublicAPI.txt b/src/ReactiveUI.Primitives.WinForms.Reactive/PublicAPI/net11.0-windows/PublicAPI.txt
index 12a4575b..81367b4b 100644
--- a/src/ReactiveUI.Primitives.WinForms.Reactive/PublicAPI/net11.0-windows/PublicAPI.txt
+++ b/src/ReactiveUI.Primitives.WinForms.Reactive/PublicAPI/net11.0-windows/PublicAPI.txt
@@ -7,6 +7,7 @@ public sealed class ControlSequencer : System.Reactive.Concurrency.LocalSchedule
{
public ControlSequencer(System.Windows.Forms.Control control) { }
public System.Windows.Forms.Control Control { get; }
+ public static ReactiveUI.Primitives.Reactive.Concurrency.ControlSequencer Main { get; }
[System.Runtime.CompilerServices.MethodImpl(System.Runtime.CompilerServices.MethodImplOptions.AggressiveInlining)]
public override System.IDisposable Schedule(TState state, System.Func action) { }
[System.Runtime.CompilerServices.MethodImpl(System.Runtime.CompilerServices.MethodImplOptions.AggressiveInlining)]
diff --git a/src/ReactiveUI.Primitives.WinForms.Reactive/PublicAPI/net462/PublicAPI.txt b/src/ReactiveUI.Primitives.WinForms.Reactive/PublicAPI/net462/PublicAPI.txt
index 13610393..b1a1cc09 100644
--- a/src/ReactiveUI.Primitives.WinForms.Reactive/PublicAPI/net462/PublicAPI.txt
+++ b/src/ReactiveUI.Primitives.WinForms.Reactive/PublicAPI/net462/PublicAPI.txt
@@ -6,6 +6,7 @@ public sealed class ControlSequencer : System.Reactive.Concurrency.LocalSchedule
{
public ControlSequencer(System.Windows.Forms.Control control) { }
public System.Windows.Forms.Control Control { get; }
+ public static ReactiveUI.Primitives.Reactive.Concurrency.ControlSequencer Main { get; }
[System.Runtime.CompilerServices.MethodImpl(System.Runtime.CompilerServices.MethodImplOptions.AggressiveInlining)]
public override System.IDisposable Schedule(TState state, System.Func action) { }
[System.Runtime.CompilerServices.MethodImpl(System.Runtime.CompilerServices.MethodImplOptions.AggressiveInlining)]
diff --git a/src/ReactiveUI.Primitives.WinForms.Reactive/PublicAPI/net472/PublicAPI.txt b/src/ReactiveUI.Primitives.WinForms.Reactive/PublicAPI/net472/PublicAPI.txt
index 13610393..b1a1cc09 100644
--- a/src/ReactiveUI.Primitives.WinForms.Reactive/PublicAPI/net472/PublicAPI.txt
+++ b/src/ReactiveUI.Primitives.WinForms.Reactive/PublicAPI/net472/PublicAPI.txt
@@ -6,6 +6,7 @@ public sealed class ControlSequencer : System.Reactive.Concurrency.LocalSchedule
{
public ControlSequencer(System.Windows.Forms.Control control) { }
public System.Windows.Forms.Control Control { get; }
+ public static ReactiveUI.Primitives.Reactive.Concurrency.ControlSequencer Main { get; }
[System.Runtime.CompilerServices.MethodImpl(System.Runtime.CompilerServices.MethodImplOptions.AggressiveInlining)]
public override System.IDisposable Schedule(TState state, System.Func action) { }
[System.Runtime.CompilerServices.MethodImpl(System.Runtime.CompilerServices.MethodImplOptions.AggressiveInlining)]
diff --git a/src/ReactiveUI.Primitives.WinForms.Reactive/PublicAPI/net48/PublicAPI.txt b/src/ReactiveUI.Primitives.WinForms.Reactive/PublicAPI/net48/PublicAPI.txt
index 13610393..b1a1cc09 100644
--- a/src/ReactiveUI.Primitives.WinForms.Reactive/PublicAPI/net48/PublicAPI.txt
+++ b/src/ReactiveUI.Primitives.WinForms.Reactive/PublicAPI/net48/PublicAPI.txt
@@ -6,6 +6,7 @@ public sealed class ControlSequencer : System.Reactive.Concurrency.LocalSchedule
{
public ControlSequencer(System.Windows.Forms.Control control) { }
public System.Windows.Forms.Control Control { get; }
+ public static ReactiveUI.Primitives.Reactive.Concurrency.ControlSequencer Main { get; }
[System.Runtime.CompilerServices.MethodImpl(System.Runtime.CompilerServices.MethodImplOptions.AggressiveInlining)]
public override System.IDisposable Schedule(TState state, System.Func action) { }
[System.Runtime.CompilerServices.MethodImpl(System.Runtime.CompilerServices.MethodImplOptions.AggressiveInlining)]
diff --git a/src/ReactiveUI.Primitives.WinForms.Reactive/PublicAPI/net481/PublicAPI.txt b/src/ReactiveUI.Primitives.WinForms.Reactive/PublicAPI/net481/PublicAPI.txt
index 13610393..b1a1cc09 100644
--- a/src/ReactiveUI.Primitives.WinForms.Reactive/PublicAPI/net481/PublicAPI.txt
+++ b/src/ReactiveUI.Primitives.WinForms.Reactive/PublicAPI/net481/PublicAPI.txt
@@ -6,6 +6,7 @@ public sealed class ControlSequencer : System.Reactive.Concurrency.LocalSchedule
{
public ControlSequencer(System.Windows.Forms.Control control) { }
public System.Windows.Forms.Control Control { get; }
+ public static ReactiveUI.Primitives.Reactive.Concurrency.ControlSequencer Main { get; }
[System.Runtime.CompilerServices.MethodImpl(System.Runtime.CompilerServices.MethodImplOptions.AggressiveInlining)]
public override System.IDisposable Schedule(TState state, System.Func action) { }
[System.Runtime.CompilerServices.MethodImpl(System.Runtime.CompilerServices.MethodImplOptions.AggressiveInlining)]
diff --git a/src/ReactiveUI.Primitives.WinForms.Reactive/PublicAPI/net8.0-windows/PublicAPI.txt b/src/ReactiveUI.Primitives.WinForms.Reactive/PublicAPI/net8.0-windows/PublicAPI.txt
index 12a4575b..81367b4b 100644
--- a/src/ReactiveUI.Primitives.WinForms.Reactive/PublicAPI/net8.0-windows/PublicAPI.txt
+++ b/src/ReactiveUI.Primitives.WinForms.Reactive/PublicAPI/net8.0-windows/PublicAPI.txt
@@ -7,6 +7,7 @@ public sealed class ControlSequencer : System.Reactive.Concurrency.LocalSchedule
{
public ControlSequencer(System.Windows.Forms.Control control) { }
public System.Windows.Forms.Control Control { get; }
+ public static ReactiveUI.Primitives.Reactive.Concurrency.ControlSequencer Main { get; }
[System.Runtime.CompilerServices.MethodImpl(System.Runtime.CompilerServices.MethodImplOptions.AggressiveInlining)]
public override System.IDisposable Schedule(TState state, System.Func action) { }
[System.Runtime.CompilerServices.MethodImpl(System.Runtime.CompilerServices.MethodImplOptions.AggressiveInlining)]
diff --git a/src/ReactiveUI.Primitives.WinForms.Reactive/PublicAPI/net9.0-windows/PublicAPI.txt b/src/ReactiveUI.Primitives.WinForms.Reactive/PublicAPI/net9.0-windows/PublicAPI.txt
index 12a4575b..81367b4b 100644
--- a/src/ReactiveUI.Primitives.WinForms.Reactive/PublicAPI/net9.0-windows/PublicAPI.txt
+++ b/src/ReactiveUI.Primitives.WinForms.Reactive/PublicAPI/net9.0-windows/PublicAPI.txt
@@ -7,6 +7,7 @@ public sealed class ControlSequencer : System.Reactive.Concurrency.LocalSchedule
{
public ControlSequencer(System.Windows.Forms.Control control) { }
public System.Windows.Forms.Control Control { get; }
+ public static ReactiveUI.Primitives.Reactive.Concurrency.ControlSequencer Main { get; }
[System.Runtime.CompilerServices.MethodImpl(System.Runtime.CompilerServices.MethodImplOptions.AggressiveInlining)]
public override System.IDisposable Schedule(TState state, System.Func action) { }
[System.Runtime.CompilerServices.MethodImpl(System.Runtime.CompilerServices.MethodImplOptions.AggressiveInlining)]
diff --git a/src/ReactiveUI.Primitives.WinForms/Concurrency/ControlSequencer.cs b/src/ReactiveUI.Primitives.WinForms/Concurrency/ControlSequencer.cs
index 0187d28f..3701f98a 100644
--- a/src/ReactiveUI.Primitives.WinForms/Concurrency/ControlSequencer.cs
+++ b/src/ReactiveUI.Primitives.WinForms/Concurrency/ControlSequencer.cs
@@ -15,6 +15,9 @@ namespace ReactiveUI.Primitives.Concurrency;
[System.Diagnostics.DebuggerDisplay("{DebuggerDisplay,nq}")]
public sealed class ControlSequencer : ISequencer
{
+ /// The shared main-thread sequencer, set once a UI thread first reads it.
+ private static ControlSequencer? _main;
+
/// Optional callback for posting ready work.
private readonly Func? _post;
@@ -42,6 +45,17 @@ internal ControlSequencer(Control control, Func? post, ActionGets the shared sequencer for the Windows Forms main (UI) thread.
+ /// Main is not bound yet and the calling thread is not an STA thread.
+ ///
+ /// The first read binds Main to a hidden control whose handle belongs to the calling thread. Windows Forms UI
+ /// threads are always STA and thread-pool threads never are, so a read from any other thread throws and nothing is
+ /// cached. Read it first on the thread that runs the message loop, such as in Main before
+ /// .
+ ///
+ public static ControlSequencer Main =>
+ Volatile.Read(ref _main) ?? BindMain(ref _main, Thread.CurrentThread.GetApartmentState());
+
/// Gets the control used to marshal work to the UI thread.
public Control Control { get; }
@@ -63,6 +77,32 @@ internal ControlSequencer(Control control, Func? post, Action _state.Schedule(item, dueTimestamp);
+ /// Caches the shared main-thread sequencer for a hidden control on the calling thread, keeping the first one bound.
+ /// The field that holds the shared sequencer.
+ /// The calling thread's apartment state.
+ /// The shared sequencer.
+ /// is not .
+ internal static ControlSequencer BindMain(ref ControlSequencer? slot, ApartmentState apartment)
+ {
+ if (apartment != ApartmentState.STA)
+ {
+ throw new InvalidOperationException(
+ "The calling thread is not a Windows Forms UI thread. Read ControlSequencer.Main first on the STA thread that runs the message loop.");
+ }
+
+ Control control = new();
+ _ = control.Handle;
+ ControlSequencer created = new(control);
+ var bound = Interlocked.CompareExchange(ref slot, created, null);
+ if (bound is null)
+ {
+ return created;
+ }
+
+ control.Dispose();
+ return bound;
+ }
+
/// Posts work when the control is live and its handle is available.
/// The control whose disposal state is checked.
/// Whether the control handle is available.
diff --git a/src/ReactiveUI.Primitives.WinForms/PublicAPI/net10.0-windows/PublicAPI.txt b/src/ReactiveUI.Primitives.WinForms/PublicAPI/net10.0-windows/PublicAPI.txt
index 377808e5..6aefebe8 100644
--- a/src/ReactiveUI.Primitives.WinForms/PublicAPI/net10.0-windows/PublicAPI.txt
+++ b/src/ReactiveUI.Primitives.WinForms/PublicAPI/net10.0-windows/PublicAPI.txt
@@ -7,6 +7,7 @@ public sealed class ControlSequencer : ReactiveUI.Primitives.Concurrency.ISequen
{
public ControlSequencer(System.Windows.Forms.Control control) { }
public System.Windows.Forms.Control Control { get; }
+ public static ReactiveUI.Primitives.Concurrency.ControlSequencer Main { get; }
public System.DateTimeOffset Now { get; }
public long Timestamp { get; }
[System.Runtime.CompilerServices.MethodImpl(System.Runtime.CompilerServices.MethodImplOptions.AggressiveInlining)]
diff --git a/src/ReactiveUI.Primitives.WinForms/PublicAPI/net11.0-windows/PublicAPI.txt b/src/ReactiveUI.Primitives.WinForms/PublicAPI/net11.0-windows/PublicAPI.txt
index 377808e5..6aefebe8 100644
--- a/src/ReactiveUI.Primitives.WinForms/PublicAPI/net11.0-windows/PublicAPI.txt
+++ b/src/ReactiveUI.Primitives.WinForms/PublicAPI/net11.0-windows/PublicAPI.txt
@@ -7,6 +7,7 @@ public sealed class ControlSequencer : ReactiveUI.Primitives.Concurrency.ISequen
{
public ControlSequencer(System.Windows.Forms.Control control) { }
public System.Windows.Forms.Control Control { get; }
+ public static ReactiveUI.Primitives.Concurrency.ControlSequencer Main { get; }
public System.DateTimeOffset Now { get; }
public long Timestamp { get; }
[System.Runtime.CompilerServices.MethodImpl(System.Runtime.CompilerServices.MethodImplOptions.AggressiveInlining)]
diff --git a/src/ReactiveUI.Primitives.WinForms/PublicAPI/net462/PublicAPI.txt b/src/ReactiveUI.Primitives.WinForms/PublicAPI/net462/PublicAPI.txt
index aba67e76..808f3f1b 100644
--- a/src/ReactiveUI.Primitives.WinForms/PublicAPI/net462/PublicAPI.txt
+++ b/src/ReactiveUI.Primitives.WinForms/PublicAPI/net462/PublicAPI.txt
@@ -6,6 +6,7 @@ public sealed class ControlSequencer : ReactiveUI.Primitives.Concurrency.ISequen
{
public ControlSequencer(System.Windows.Forms.Control control) { }
public System.Windows.Forms.Control Control { get; }
+ public static ReactiveUI.Primitives.Concurrency.ControlSequencer Main { get; }
public System.DateTimeOffset Now { get; }
public long Timestamp { get; }
[System.Runtime.CompilerServices.MethodImpl(System.Runtime.CompilerServices.MethodImplOptions.AggressiveInlining)]
diff --git a/src/ReactiveUI.Primitives.WinForms/PublicAPI/net472/PublicAPI.txt b/src/ReactiveUI.Primitives.WinForms/PublicAPI/net472/PublicAPI.txt
index aba67e76..808f3f1b 100644
--- a/src/ReactiveUI.Primitives.WinForms/PublicAPI/net472/PublicAPI.txt
+++ b/src/ReactiveUI.Primitives.WinForms/PublicAPI/net472/PublicAPI.txt
@@ -6,6 +6,7 @@ public sealed class ControlSequencer : ReactiveUI.Primitives.Concurrency.ISequen
{
public ControlSequencer(System.Windows.Forms.Control control) { }
public System.Windows.Forms.Control Control { get; }
+ public static ReactiveUI.Primitives.Concurrency.ControlSequencer Main { get; }
public System.DateTimeOffset Now { get; }
public long Timestamp { get; }
[System.Runtime.CompilerServices.MethodImpl(System.Runtime.CompilerServices.MethodImplOptions.AggressiveInlining)]
diff --git a/src/ReactiveUI.Primitives.WinForms/PublicAPI/net48/PublicAPI.txt b/src/ReactiveUI.Primitives.WinForms/PublicAPI/net48/PublicAPI.txt
index aba67e76..808f3f1b 100644
--- a/src/ReactiveUI.Primitives.WinForms/PublicAPI/net48/PublicAPI.txt
+++ b/src/ReactiveUI.Primitives.WinForms/PublicAPI/net48/PublicAPI.txt
@@ -6,6 +6,7 @@ public sealed class ControlSequencer : ReactiveUI.Primitives.Concurrency.ISequen
{
public ControlSequencer(System.Windows.Forms.Control control) { }
public System.Windows.Forms.Control Control { get; }
+ public static ReactiveUI.Primitives.Concurrency.ControlSequencer Main { get; }
public System.DateTimeOffset Now { get; }
public long Timestamp { get; }
[System.Runtime.CompilerServices.MethodImpl(System.Runtime.CompilerServices.MethodImplOptions.AggressiveInlining)]
diff --git a/src/ReactiveUI.Primitives.WinForms/PublicAPI/net481/PublicAPI.txt b/src/ReactiveUI.Primitives.WinForms/PublicAPI/net481/PublicAPI.txt
index aba67e76..808f3f1b 100644
--- a/src/ReactiveUI.Primitives.WinForms/PublicAPI/net481/PublicAPI.txt
+++ b/src/ReactiveUI.Primitives.WinForms/PublicAPI/net481/PublicAPI.txt
@@ -6,6 +6,7 @@ public sealed class ControlSequencer : ReactiveUI.Primitives.Concurrency.ISequen
{
public ControlSequencer(System.Windows.Forms.Control control) { }
public System.Windows.Forms.Control Control { get; }
+ public static ReactiveUI.Primitives.Concurrency.ControlSequencer Main { get; }
public System.DateTimeOffset Now { get; }
public long Timestamp { get; }
[System.Runtime.CompilerServices.MethodImpl(System.Runtime.CompilerServices.MethodImplOptions.AggressiveInlining)]
diff --git a/src/ReactiveUI.Primitives.WinForms/PublicAPI/net8.0-windows/PublicAPI.txt b/src/ReactiveUI.Primitives.WinForms/PublicAPI/net8.0-windows/PublicAPI.txt
index 377808e5..6aefebe8 100644
--- a/src/ReactiveUI.Primitives.WinForms/PublicAPI/net8.0-windows/PublicAPI.txt
+++ b/src/ReactiveUI.Primitives.WinForms/PublicAPI/net8.0-windows/PublicAPI.txt
@@ -7,6 +7,7 @@ public sealed class ControlSequencer : ReactiveUI.Primitives.Concurrency.ISequen
{
public ControlSequencer(System.Windows.Forms.Control control) { }
public System.Windows.Forms.Control Control { get; }
+ public static ReactiveUI.Primitives.Concurrency.ControlSequencer Main { get; }
public System.DateTimeOffset Now { get; }
public long Timestamp { get; }
[System.Runtime.CompilerServices.MethodImpl(System.Runtime.CompilerServices.MethodImplOptions.AggressiveInlining)]
diff --git a/src/ReactiveUI.Primitives.WinForms/PublicAPI/net9.0-windows/PublicAPI.txt b/src/ReactiveUI.Primitives.WinForms/PublicAPI/net9.0-windows/PublicAPI.txt
index 377808e5..6aefebe8 100644
--- a/src/ReactiveUI.Primitives.WinForms/PublicAPI/net9.0-windows/PublicAPI.txt
+++ b/src/ReactiveUI.Primitives.WinForms/PublicAPI/net9.0-windows/PublicAPI.txt
@@ -7,6 +7,7 @@ public sealed class ControlSequencer : ReactiveUI.Primitives.Concurrency.ISequen
{
public ControlSequencer(System.Windows.Forms.Control control) { }
public System.Windows.Forms.Control Control { get; }
+ public static ReactiveUI.Primitives.Concurrency.ControlSequencer Main { get; }
public System.DateTimeOffset Now { get; }
public long Timestamp { get; }
[System.Runtime.CompilerServices.MethodImpl(System.Runtime.CompilerServices.MethodImplOptions.AggressiveInlining)]
diff --git a/src/ReactiveUI.Primitives.WinUI.Reactive/Concurrency/DispatcherQueueSequencer.cs b/src/ReactiveUI.Primitives.WinUI.Reactive/Concurrency/DispatcherQueueSequencer.cs
index bba4c55c..820fce25 100644
--- a/src/ReactiveUI.Primitives.WinUI.Reactive/Concurrency/DispatcherQueueSequencer.cs
+++ b/src/ReactiveUI.Primitives.WinUI.Reactive/Concurrency/DispatcherQueueSequencer.cs
@@ -15,6 +15,13 @@ namespace ReactiveUI.Primitives.Reactive.Concurrency;
[System.Diagnostics.DebuggerDisplay("DispatcherQueueSequencer: DispatcherQueue = {DispatcherQueue}, Priority = {Priority}")]
public sealed class DispatcherQueueSequencer : LocalScheduler
{
+ /// The shared main-thread scheduler, set once a UI thread first reads it.
+ private static DispatcherQueueSequencer? _main;
+
+ /// The scheduler for the calling thread's dispatcher queue, cached per thread.
+ [ThreadStatic]
+ private static DispatcherQueueSequencer? _current;
+
/// Optional callback for enqueueing native drain delegates.
private readonly Func? _tryEnqueue;
@@ -66,6 +73,25 @@ internal DispatcherQueueSequencer(
_dispatch = new(RunDrain, DefaultScheduler.Instance);
}
+ /// Gets the shared scheduler for the WinUI main (UI) thread.
+ /// Main is not bound yet and the calling thread has no dispatcher queue.
+ ///
+ /// WinUI has no dispatcher queue that any thread can reach, so the first read from a UI thread binds Main to that
+ /// thread's dispatcher queue. After that, every thread gets the same scheduler. A read from a thread without a
+ /// dispatcher queue before then throws, and nothing is cached, so a later read from the UI thread still binds.
+ ///
+ public static DispatcherQueueSequencer Main =>
+ Volatile.Read(ref _main) ?? BindMain(ref _main, DispatcherQueue.GetForCurrentThread()) ?? throw NoDispatcherQueue();
+
+ /// Gets the scheduler for the calling thread's dispatcher queue.
+ /// The calling thread has no dispatcher queue.
+ ///
+ /// Cached per thread, for applications that run UI on more than one thread. Never creates a dispatcher queue: a
+ /// thread that has none cannot run the scheduled work.
+ ///
+ public static DispatcherQueueSequencer Current =>
+ _current ??= new(DispatcherQueue.GetForCurrentThread() ?? throw NoDispatcherQueue());
+
/// Gets the dispatcher queue used to marshal work to the UI thread.
public DispatcherQueue DispatcherQueue { get; }
@@ -86,6 +112,26 @@ public override IDisposable Schedule(TState state, Func(TState state, TimeSpan dueTime, Func action) =>
_dispatch.Schedule(new DispatchHost(this), this, state, dueTime, action);
+ /// Caches the shared main-thread scheduler for a dispatcher queue, keeping the first one bound.
+ /// The field that holds the shared scheduler.
+ /// The calling thread's dispatcher queue, or when it has none.
+ /// The shared scheduler, or when is .
+ internal static DispatcherQueueSequencer? BindMain(ref DispatcherQueueSequencer? slot, DispatcherQueue? dispatcherQueue)
+ {
+ if (dispatcherQueue is null)
+ {
+ return null;
+ }
+
+ DispatcherQueueSequencer created = new(dispatcherQueue);
+ return Interlocked.CompareExchange(ref slot, created, null) ?? created;
+ }
+
+ /// Creates the error for a thread that has no dispatcher queue.
+ /// The exception to throw.
+ internal static InvalidOperationException NoDispatcherQueue() =>
+ new("The calling thread has no WinUI dispatcher queue. Use DispatcherQueueSequencer.Main or Current from a UI thread.");
+
/// Enqueues the drain callback on the dispatcher queue.
/// The drain callback.
/// Always .
diff --git a/src/ReactiveUI.Primitives.WinUI.Reactive/PublicAPI/net10.0-windows10.0.19041.0/PublicAPI.txt b/src/ReactiveUI.Primitives.WinUI.Reactive/PublicAPI/net10.0-windows10.0.19041.0/PublicAPI.txt
index 30be1c6e..6bcc1ebd 100644
--- a/src/ReactiveUI.Primitives.WinUI.Reactive/PublicAPI/net10.0-windows10.0.19041.0/PublicAPI.txt
+++ b/src/ReactiveUI.Primitives.WinUI.Reactive/PublicAPI/net10.0-windows10.0.19041.0/PublicAPI.txt
@@ -8,6 +8,8 @@ public sealed class DispatcherQueueSequencer : System.Reactive.Concurrency.Local
public DispatcherQueueSequencer(Microsoft.UI.Dispatching.DispatcherQueue dispatcherQueue) { }
public DispatcherQueueSequencer(Microsoft.UI.Dispatching.DispatcherQueue dispatcherQueue, Microsoft.UI.Dispatching.DispatcherQueuePriority priority) { }
public Microsoft.UI.Dispatching.DispatcherQueue DispatcherQueue { get; }
+ public static ReactiveUI.Primitives.Reactive.Concurrency.DispatcherQueueSequencer Current { get; }
+ public static ReactiveUI.Primitives.Reactive.Concurrency.DispatcherQueueSequencer Main { get; }
public Microsoft.UI.Dispatching.DispatcherQueuePriority Priority { get; }
[System.Runtime.CompilerServices.MethodImpl(System.Runtime.CompilerServices.MethodImplOptions.AggressiveInlining)]
public override System.IDisposable Schedule(TState state, System.Func action) { }
diff --git a/src/ReactiveUI.Primitives.WinUI.Reactive/PublicAPI/net11.0-windows10.0.19041.0/PublicAPI.txt b/src/ReactiveUI.Primitives.WinUI.Reactive/PublicAPI/net11.0-windows10.0.19041.0/PublicAPI.txt
index 30be1c6e..6bcc1ebd 100644
--- a/src/ReactiveUI.Primitives.WinUI.Reactive/PublicAPI/net11.0-windows10.0.19041.0/PublicAPI.txt
+++ b/src/ReactiveUI.Primitives.WinUI.Reactive/PublicAPI/net11.0-windows10.0.19041.0/PublicAPI.txt
@@ -8,6 +8,8 @@ public sealed class DispatcherQueueSequencer : System.Reactive.Concurrency.Local
public DispatcherQueueSequencer(Microsoft.UI.Dispatching.DispatcherQueue dispatcherQueue) { }
public DispatcherQueueSequencer(Microsoft.UI.Dispatching.DispatcherQueue dispatcherQueue, Microsoft.UI.Dispatching.DispatcherQueuePriority priority) { }
public Microsoft.UI.Dispatching.DispatcherQueue DispatcherQueue { get; }
+ public static ReactiveUI.Primitives.Reactive.Concurrency.DispatcherQueueSequencer Current { get; }
+ public static ReactiveUI.Primitives.Reactive.Concurrency.DispatcherQueueSequencer Main { get; }
public Microsoft.UI.Dispatching.DispatcherQueuePriority Priority { get; }
[System.Runtime.CompilerServices.MethodImpl(System.Runtime.CompilerServices.MethodImplOptions.AggressiveInlining)]
public override System.IDisposable Schedule(TState state, System.Func action) { }
diff --git a/src/ReactiveUI.Primitives.WinUI.Reactive/PublicAPI/net8.0-windows10.0.19041.0/PublicAPI.txt b/src/ReactiveUI.Primitives.WinUI.Reactive/PublicAPI/net8.0-windows10.0.19041.0/PublicAPI.txt
index 30be1c6e..6bcc1ebd 100644
--- a/src/ReactiveUI.Primitives.WinUI.Reactive/PublicAPI/net8.0-windows10.0.19041.0/PublicAPI.txt
+++ b/src/ReactiveUI.Primitives.WinUI.Reactive/PublicAPI/net8.0-windows10.0.19041.0/PublicAPI.txt
@@ -8,6 +8,8 @@ public sealed class DispatcherQueueSequencer : System.Reactive.Concurrency.Local
public DispatcherQueueSequencer(Microsoft.UI.Dispatching.DispatcherQueue dispatcherQueue) { }
public DispatcherQueueSequencer(Microsoft.UI.Dispatching.DispatcherQueue dispatcherQueue, Microsoft.UI.Dispatching.DispatcherQueuePriority priority) { }
public Microsoft.UI.Dispatching.DispatcherQueue DispatcherQueue { get; }
+ public static ReactiveUI.Primitives.Reactive.Concurrency.DispatcherQueueSequencer Current { get; }
+ public static ReactiveUI.Primitives.Reactive.Concurrency.DispatcherQueueSequencer Main { get; }
public Microsoft.UI.Dispatching.DispatcherQueuePriority Priority { get; }
[System.Runtime.CompilerServices.MethodImpl(System.Runtime.CompilerServices.MethodImplOptions.AggressiveInlining)]
public override System.IDisposable Schedule(TState state, System.Func action) { }
diff --git a/src/ReactiveUI.Primitives.WinUI.Reactive/PublicAPI/net9.0-windows10.0.19041.0/PublicAPI.txt b/src/ReactiveUI.Primitives.WinUI.Reactive/PublicAPI/net9.0-windows10.0.19041.0/PublicAPI.txt
index 30be1c6e..6bcc1ebd 100644
--- a/src/ReactiveUI.Primitives.WinUI.Reactive/PublicAPI/net9.0-windows10.0.19041.0/PublicAPI.txt
+++ b/src/ReactiveUI.Primitives.WinUI.Reactive/PublicAPI/net9.0-windows10.0.19041.0/PublicAPI.txt
@@ -8,6 +8,8 @@ public sealed class DispatcherQueueSequencer : System.Reactive.Concurrency.Local
public DispatcherQueueSequencer(Microsoft.UI.Dispatching.DispatcherQueue dispatcherQueue) { }
public DispatcherQueueSequencer(Microsoft.UI.Dispatching.DispatcherQueue dispatcherQueue, Microsoft.UI.Dispatching.DispatcherQueuePriority priority) { }
public Microsoft.UI.Dispatching.DispatcherQueue DispatcherQueue { get; }
+ public static ReactiveUI.Primitives.Reactive.Concurrency.DispatcherQueueSequencer Current { get; }
+ public static ReactiveUI.Primitives.Reactive.Concurrency.DispatcherQueueSequencer Main { get; }
public Microsoft.UI.Dispatching.DispatcherQueuePriority Priority { get; }
[System.Runtime.CompilerServices.MethodImpl(System.Runtime.CompilerServices.MethodImplOptions.AggressiveInlining)]
public override System.IDisposable Schedule(TState state, System.Func action) { }
diff --git a/src/ReactiveUI.Primitives.WinUI/Concurrency/DispatcherQueueSequencer.cs b/src/ReactiveUI.Primitives.WinUI/Concurrency/DispatcherQueueSequencer.cs
index bfa1ac2d..dc69bd82 100644
--- a/src/ReactiveUI.Primitives.WinUI/Concurrency/DispatcherQueueSequencer.cs
+++ b/src/ReactiveUI.Primitives.WinUI/Concurrency/DispatcherQueueSequencer.cs
@@ -14,6 +14,13 @@ namespace ReactiveUI.Primitives.Concurrency;
[System.Diagnostics.DebuggerDisplay("{DebuggerDisplay,nq}")]
public sealed class DispatcherQueueSequencer : ISequencer
{
+ /// The shared main-thread sequencer, set once a UI thread first reads it.
+ private static DispatcherQueueSequencer? _main;
+
+ /// The sequencer for the calling thread's dispatcher queue, cached per thread.
+ [ThreadStatic]
+ private static DispatcherQueueSequencer? _current;
+
/// Optional callback for enqueueing native drain delegates.
private readonly Func? _tryEnqueue;
@@ -61,6 +68,25 @@ internal DispatcherQueueSequencer(
_state = new(this, Post, RunDrain, scheduleDelayed);
}
+ /// Gets the shared sequencer for the WinUI main (UI) thread.
+ /// Main is not bound yet and the calling thread has no dispatcher queue.
+ ///
+ /// WinUI has no dispatcher queue that any thread can reach, so the first read from a UI thread binds Main to that
+ /// thread's dispatcher queue. After that, every thread gets the same sequencer. A read from a thread without a
+ /// dispatcher queue before then throws, and nothing is cached, so a later read from the UI thread still binds.
+ ///
+ public static DispatcherQueueSequencer Main =>
+ Volatile.Read(ref _main) ?? BindMain(ref _main, DispatcherQueue.GetForCurrentThread()) ?? throw NoDispatcherQueue();
+
+ /// Gets the sequencer for the calling thread's dispatcher queue.
+ /// The calling thread has no dispatcher queue.
+ ///
+ /// Cached per thread, for applications that run UI on more than one thread. Never creates a dispatcher queue: a
+ /// thread that has none cannot run the scheduled work.
+ ///
+ public static DispatcherQueueSequencer Current =>
+ _current ??= new(DispatcherQueue.GetForCurrentThread() ?? throw NoDispatcherQueue());
+
/// Gets the dispatcher queue used to marshal work to the UI thread.
public DispatcherQueue DispatcherQueue { get; }
@@ -85,6 +111,26 @@ internal DispatcherQueueSequencer(
[MethodImpl(MethodImplOptions.AggressiveInlining)]
public void Schedule(IWorkItem item, long dueTimestamp) => _state.Schedule(item, dueTimestamp);
+ /// Caches the shared main-thread sequencer for a dispatcher queue, keeping the first one bound.
+ /// The field that holds the shared sequencer.
+ /// The calling thread's dispatcher queue, or when it has none.
+ /// The shared sequencer, or when is .
+ internal static DispatcherQueueSequencer? BindMain(ref DispatcherQueueSequencer? slot, DispatcherQueue? dispatcherQueue)
+ {
+ if (dispatcherQueue is null)
+ {
+ return null;
+ }
+
+ DispatcherQueueSequencer created = new(dispatcherQueue);
+ return Interlocked.CompareExchange(ref slot, created, null) ?? created;
+ }
+
+ /// Creates the error for a thread that has no dispatcher queue.
+ /// The exception to throw.
+ internal static InvalidOperationException NoDispatcherQueue() =>
+ new("The calling thread has no WinUI dispatcher queue. Use DispatcherQueueSequencer.Main or Current from a UI thread.");
+
/// Marshals the cached drain callback through the dispatcher queue.
/// The drain callback.
/// when the drain was enqueued.
diff --git a/src/ReactiveUI.Primitives.WinUI/PublicAPI/net10.0-windows10.0.19041.0/PublicAPI.txt b/src/ReactiveUI.Primitives.WinUI/PublicAPI/net10.0-windows10.0.19041.0/PublicAPI.txt
index f6b8bb0b..17383cf1 100644
--- a/src/ReactiveUI.Primitives.WinUI/PublicAPI/net10.0-windows10.0.19041.0/PublicAPI.txt
+++ b/src/ReactiveUI.Primitives.WinUI/PublicAPI/net10.0-windows10.0.19041.0/PublicAPI.txt
@@ -8,6 +8,8 @@ public sealed class DispatcherQueueSequencer : ReactiveUI.Primitives.Concurrency
public DispatcherQueueSequencer(Microsoft.UI.Dispatching.DispatcherQueue dispatcherQueue) { }
public DispatcherQueueSequencer(Microsoft.UI.Dispatching.DispatcherQueue dispatcherQueue, Microsoft.UI.Dispatching.DispatcherQueuePriority priority) { }
public Microsoft.UI.Dispatching.DispatcherQueue DispatcherQueue { get; }
+ public static ReactiveUI.Primitives.Concurrency.DispatcherQueueSequencer Current { get; }
+ public static ReactiveUI.Primitives.Concurrency.DispatcherQueueSequencer Main { get; }
public System.DateTimeOffset Now { get; }
public Microsoft.UI.Dispatching.DispatcherQueuePriority Priority { get; }
public long Timestamp { get; }
diff --git a/src/ReactiveUI.Primitives.WinUI/PublicAPI/net11.0-windows10.0.19041.0/PublicAPI.txt b/src/ReactiveUI.Primitives.WinUI/PublicAPI/net11.0-windows10.0.19041.0/PublicAPI.txt
index f6b8bb0b..17383cf1 100644
--- a/src/ReactiveUI.Primitives.WinUI/PublicAPI/net11.0-windows10.0.19041.0/PublicAPI.txt
+++ b/src/ReactiveUI.Primitives.WinUI/PublicAPI/net11.0-windows10.0.19041.0/PublicAPI.txt
@@ -8,6 +8,8 @@ public sealed class DispatcherQueueSequencer : ReactiveUI.Primitives.Concurrency
public DispatcherQueueSequencer(Microsoft.UI.Dispatching.DispatcherQueue dispatcherQueue) { }
public DispatcherQueueSequencer(Microsoft.UI.Dispatching.DispatcherQueue dispatcherQueue, Microsoft.UI.Dispatching.DispatcherQueuePriority priority) { }
public Microsoft.UI.Dispatching.DispatcherQueue DispatcherQueue { get; }
+ public static ReactiveUI.Primitives.Concurrency.DispatcherQueueSequencer Current { get; }
+ public static ReactiveUI.Primitives.Concurrency.DispatcherQueueSequencer Main { get; }
public System.DateTimeOffset Now { get; }
public Microsoft.UI.Dispatching.DispatcherQueuePriority Priority { get; }
public long Timestamp { get; }
diff --git a/src/ReactiveUI.Primitives.WinUI/PublicAPI/net8.0-windows10.0.19041.0/PublicAPI.txt b/src/ReactiveUI.Primitives.WinUI/PublicAPI/net8.0-windows10.0.19041.0/PublicAPI.txt
index f6b8bb0b..17383cf1 100644
--- a/src/ReactiveUI.Primitives.WinUI/PublicAPI/net8.0-windows10.0.19041.0/PublicAPI.txt
+++ b/src/ReactiveUI.Primitives.WinUI/PublicAPI/net8.0-windows10.0.19041.0/PublicAPI.txt
@@ -8,6 +8,8 @@ public sealed class DispatcherQueueSequencer : ReactiveUI.Primitives.Concurrency
public DispatcherQueueSequencer(Microsoft.UI.Dispatching.DispatcherQueue dispatcherQueue) { }
public DispatcherQueueSequencer(Microsoft.UI.Dispatching.DispatcherQueue dispatcherQueue, Microsoft.UI.Dispatching.DispatcherQueuePriority priority) { }
public Microsoft.UI.Dispatching.DispatcherQueue DispatcherQueue { get; }
+ public static ReactiveUI.Primitives.Concurrency.DispatcherQueueSequencer Current { get; }
+ public static ReactiveUI.Primitives.Concurrency.DispatcherQueueSequencer Main { get; }
public System.DateTimeOffset Now { get; }
public Microsoft.UI.Dispatching.DispatcherQueuePriority Priority { get; }
public long Timestamp { get; }
diff --git a/src/ReactiveUI.Primitives.WinUI/PublicAPI/net9.0-windows10.0.19041.0/PublicAPI.txt b/src/ReactiveUI.Primitives.WinUI/PublicAPI/net9.0-windows10.0.19041.0/PublicAPI.txt
index f6b8bb0b..17383cf1 100644
--- a/src/ReactiveUI.Primitives.WinUI/PublicAPI/net9.0-windows10.0.19041.0/PublicAPI.txt
+++ b/src/ReactiveUI.Primitives.WinUI/PublicAPI/net9.0-windows10.0.19041.0/PublicAPI.txt
@@ -8,6 +8,8 @@ public sealed class DispatcherQueueSequencer : ReactiveUI.Primitives.Concurrency
public DispatcherQueueSequencer(Microsoft.UI.Dispatching.DispatcherQueue dispatcherQueue) { }
public DispatcherQueueSequencer(Microsoft.UI.Dispatching.DispatcherQueue dispatcherQueue, Microsoft.UI.Dispatching.DispatcherQueuePriority priority) { }
public Microsoft.UI.Dispatching.DispatcherQueue DispatcherQueue { get; }
+ public static ReactiveUI.Primitives.Concurrency.DispatcherQueueSequencer Current { get; }
+ public static ReactiveUI.Primitives.Concurrency.DispatcherQueueSequencer Main { get; }
public System.DateTimeOffset Now { get; }
public Microsoft.UI.Dispatching.DispatcherQueuePriority Priority { get; }
public long Timestamp { get; }
diff --git a/src/tests/ReactiveUI.Primitives.Maui.Reactive.Tests/MauiDispatcherSequencerTests.cs b/src/tests/ReactiveUI.Primitives.Maui.Reactive.Tests/MauiDispatcherSequencerTests.cs
index 68c8cc03..2cf0c966 100644
--- a/src/tests/ReactiveUI.Primitives.Maui.Reactive.Tests/MauiDispatcherSequencerTests.cs
+++ b/src/tests/ReactiveUI.Primitives.Maui.Reactive.Tests/MauiDispatcherSequencerTests.cs
@@ -4,6 +4,7 @@
using System.Reactive.Concurrency;
using System.Runtime.CompilerServices;
+using Microsoft.Maui;
using Microsoft.Maui.Dispatching;
using ReactiveUI.Primitives.Reactive.Concurrency;
@@ -15,6 +16,10 @@ public sealed class MauiDispatcherSequencerTests
/// The values an immediate burst produces, in the FIFO order asserted.
private static readonly int[] ExpectedBurst = [1, 2, 3];
+ /// Installs a dispatcher provider that returns the dispatcher each test thread registers.
+ [Before(Class)]
+ public static void InstallThreadDispatcherProvider() => _ = DispatcherProvider.SetCurrent(new ThreadDispatcherProvider());
+
/// Verifies the constructor rejects a null dispatcher.
/// A representing the asynchronous operation.
[Test]
@@ -101,6 +106,161 @@ public async Task ImmediateBurstExecutesInOrder()
await Assert.That(values).IsEquivalentTo(ExpectedBurst, EqualityComparer.Default, TUnit.Assertions.Enums.CollectionOrdering.Matching);
}
+ /// A thread without a dispatcher gets an error rather than a sequencer that could never run work.
+ /// A representing the asynchronous operation.
+ [Test]
+ public async Task CurrentThrowsWhenTheThreadHasNoDispatcher() =>
+ await Assert.That(static () => RunOnNewThread(null, static () => MauiDispatcherSequencer.Current))
+ .ThrowsExactly();
+
+ /// Each thread gets its own cached sequencer bound to that thread's dispatcher.
+ /// A representing the asynchronous operation.
+ [Test]
+ public async Task CurrentIsCachedPerThreadAndBoundToThatThreadsDispatcher()
+ {
+ FakeDispatcher firstDispatcher = new();
+ FakeDispatcher secondDispatcher = new();
+
+ var first = await RunOnNewThread(firstDispatcher, CaptureCurrent);
+ var second = await RunOnNewThread(secondDispatcher, CaptureCurrent);
+
+ await Assert.That(first.Repeat).IsSameReferenceAs(first.Sequencer);
+ await Assert.That(first.Sequencer.Dispatcher).IsSameReferenceAs(firstDispatcher);
+ await Assert.That(second.Sequencer).IsNotSameReferenceAs(first.Sequencer);
+ await Assert.That(second.Sequencer.Dispatcher).IsSameReferenceAs(secondDispatcher);
+ }
+
+ /// Before an application exists, Main uses the calling thread's dispatcher and never the thread pool.
+ /// A representing the asynchronous operation.
+ [Test]
+ public async Task MainFallsBackToCurrentBeforeAnApplicationExists()
+ {
+ if (IPlatformApplication.Current is not null)
+ {
+ return;
+ }
+
+ FakeDispatcher dispatcher = new();
+ var captured = await RunOnNewThread(
+ dispatcher,
+ static () => (MauiDispatcherSequencer.Main, MauiDispatcherSequencer.Current));
+
+ await Assert.That(captured.Main).IsSameReferenceAs(captured.Current);
+ await Assert.That(captured.Main.Dispatcher).IsSameReferenceAs(dispatcher);
+ await Assert.That(static () => RunOnNewThread(null, static () => MauiDispatcherSequencer.Main))
+ .ThrowsExactly();
+ }
+
+ /// The application's dispatcher comes from the running application's services.
+ /// A representing the asynchronous operation.
+ [Test]
+ public async Task ResolveApplicationDispatcherReadsTheApplicationServices()
+ {
+ FakeDispatcher dispatcher = new();
+
+ await Assert.That(MauiDispatcherSequencer.ResolveApplicationDispatcher(null)).IsNull();
+ await Assert.That(MauiDispatcherSequencer.ResolveApplicationDispatcher(new FakePlatformApplication(null))).IsNull();
+ await Assert.That(MauiDispatcherSequencer.ResolveApplicationDispatcher(new FakePlatformApplication(new DispatcherServices(null))))
+ .IsNull();
+ await Assert.That(MauiDispatcherSequencer.ResolveApplicationDispatcher(new FakePlatformApplication(new DispatcherServices(dispatcher))))
+ .IsSameReferenceAs(dispatcher);
+ }
+
+ /// Without an application dispatcher there is nothing to bind, and nothing is cached.
+ /// A representing the asynchronous operation.
+ [Test]
+ public async Task BindMainReturnsNullWithoutAnApplicationDispatcher()
+ {
+ MauiDispatcherSequencer? slot = null;
+ await Assert.That(MauiDispatcherSequencer.BindMain(ref slot, null)).IsNull();
+ await Assert.That(slot).IsNull();
+ }
+
+ /// The first application dispatcher bound stays bound.
+ /// A representing the asynchronous operation.
+ [Test]
+ public async Task BindMainKeepsTheFirstBinding()
+ {
+ FakeDispatcher first = new();
+ FakeDispatcher second = new();
+ MauiDispatcherSequencer? slot = null;
+
+ var bound = MauiDispatcherSequencer.BindMain(ref slot, first);
+ var rebound = MauiDispatcherSequencer.BindMain(ref slot, second);
+
+ await Assert.That(bound).IsNotNull();
+ await Assert.That(bound!.Dispatcher).IsSameReferenceAs(first);
+ await Assert.That(slot).IsSameReferenceAs(bound);
+ await Assert.That(rebound).IsSameReferenceAs(bound);
+ }
+
+ /// Reads twice on the calling thread.
+ /// Both reads.
+ private static (MauiDispatcherSequencer Sequencer, MauiDispatcherSequencer Repeat) CaptureCurrent() =>
+ (MauiDispatcherSequencer.Current, MauiDispatcherSequencer.Current);
+
+ /// Runs a function on a fresh thread that reports the given dispatcher as its own.
+ /// The result type.
+ /// The dispatcher the thread reports, or for none.
+ /// The function to run.
+ /// The function's result.
+ private static Task RunOnNewThread(IDispatcher? dispatcher, Func func)
+ {
+ TaskCompletionSource completion = new(TaskCreationOptions.RunContinuationsAsynchronously);
+ Thread thread = new(() =>
+ {
+ ThreadDispatcherProvider.ForThread = dispatcher;
+ try
+ {
+ completion.SetResult(func());
+ }
+ catch (Exception ex)
+ {
+ completion.SetException(ex);
+ }
+ });
+ thread.Start();
+ return completion.Task;
+ }
+
+ /// Dispatcher provider that reports the dispatcher registered by the calling thread.
+ private sealed class ThreadDispatcherProvider : IDispatcherProvider
+ {
+ /// The dispatcher registered by the calling thread.
+ [ThreadStatic]
+ private static IDispatcher? _forThread;
+
+ /// Gets or sets the dispatcher registered by the calling thread.
+ public static IDispatcher? ForThread
+ {
+ get => _forThread;
+ set => _forThread = value;
+ }
+
+ ///
+ [MethodImpl(MethodImplOptions.AggressiveInlining)]
+ public IDispatcher? GetForCurrentThread() => _forThread;
+ }
+
+ /// Platform application that exposes the given services.
+ /// The application services, or before MAUI sets them.
+ private sealed class FakePlatformApplication(IServiceProvider? services) : IPlatformApplication
+ {
+ ///
+ public IServiceProvider Services => services!;
+
+ ///
+ public IApplication Application => null!;
+ }
+
+ /// Service provider that resolves only .
+ /// The dispatcher to return, or for none.
+ private sealed class DispatcherServices(IDispatcher? dispatcher) : IServiceProvider
+ {
+ ///
+ public object? GetService(Type serviceType) => serviceType == typeof(IDispatcher) ? dispatcher : null;
+ }
+
/// Fake MAUI dispatcher that runs marshalled work synchronously and records how it was dispatched.
private sealed class FakeDispatcher : IDispatcher
{
diff --git a/src/tests/ReactiveUI.Primitives.Maui.Tests/MauiDispatcherSequencerTests.cs b/src/tests/ReactiveUI.Primitives.Maui.Tests/MauiDispatcherSequencerTests.cs
index bea3957c..22f43b1e 100644
--- a/src/tests/ReactiveUI.Primitives.Maui.Tests/MauiDispatcherSequencerTests.cs
+++ b/src/tests/ReactiveUI.Primitives.Maui.Tests/MauiDispatcherSequencerTests.cs
@@ -3,6 +3,7 @@
// See the LICENSE file in the project root for full license information.
using System.Runtime.CompilerServices;
+using Microsoft.Maui;
using Microsoft.Maui.Dispatching;
using ReactiveUI.Primitives.Concurrency;
@@ -14,6 +15,10 @@ public sealed class MauiDispatcherSequencerTests
/// The values an immediate burst produces, in the FIFO order asserted.
private static readonly int[] ExpectedBurst = [1, 2, 3];
+ /// Installs a dispatcher provider that returns the dispatcher each test thread registers.
+ [Before(Class)]
+ public static void InstallThreadDispatcherProvider() => _ = DispatcherProvider.SetCurrent(new ThreadDispatcherProvider());
+
/// Verifies the constructor rejects a null dispatcher.
/// A representing the asynchronous operation.
[Test]
@@ -115,6 +120,161 @@ public async Task ClockPropertiesReportTheSharedDispatchClock()
await Assert.That(sequencer.Timestamp).IsGreaterThanOrEqualTo(before);
}
+ /// A thread without a dispatcher gets an error rather than a sequencer that could never run work.
+ /// A representing the asynchronous operation.
+ [Test]
+ public async Task CurrentThrowsWhenTheThreadHasNoDispatcher() =>
+ await Assert.That(static () => RunOnNewThread(null, static () => MauiDispatcherSequencer.Current))
+ .ThrowsExactly();
+
+ /// Each thread gets its own cached sequencer bound to that thread's dispatcher.
+ /// A representing the asynchronous operation.
+ [Test]
+ public async Task CurrentIsCachedPerThreadAndBoundToThatThreadsDispatcher()
+ {
+ FakeDispatcher firstDispatcher = new();
+ FakeDispatcher secondDispatcher = new();
+
+ var first = await RunOnNewThread(firstDispatcher, CaptureCurrent);
+ var second = await RunOnNewThread(secondDispatcher, CaptureCurrent);
+
+ await Assert.That(first.Repeat).IsSameReferenceAs(first.Sequencer);
+ await Assert.That(first.Sequencer.Dispatcher).IsSameReferenceAs(firstDispatcher);
+ await Assert.That(second.Sequencer).IsNotSameReferenceAs(first.Sequencer);
+ await Assert.That(second.Sequencer.Dispatcher).IsSameReferenceAs(secondDispatcher);
+ }
+
+ /// Before an application exists, Main uses the calling thread's dispatcher and never the thread pool.
+ /// A representing the asynchronous operation.
+ [Test]
+ public async Task MainFallsBackToCurrentBeforeAnApplicationExists()
+ {
+ if (IPlatformApplication.Current is not null)
+ {
+ return;
+ }
+
+ FakeDispatcher dispatcher = new();
+ var captured = await RunOnNewThread(
+ dispatcher,
+ static () => (MauiDispatcherSequencer.Main, MauiDispatcherSequencer.Current));
+
+ await Assert.That(captured.Main).IsSameReferenceAs(captured.Current);
+ await Assert.That(captured.Main.Dispatcher).IsSameReferenceAs(dispatcher);
+ await Assert.That(static () => RunOnNewThread(null, static () => MauiDispatcherSequencer.Main))
+ .ThrowsExactly();
+ }
+
+ /// The application's dispatcher comes from the running application's services.
+ /// A representing the asynchronous operation.
+ [Test]
+ public async Task ResolveApplicationDispatcherReadsTheApplicationServices()
+ {
+ FakeDispatcher dispatcher = new();
+
+ await Assert.That(MauiDispatcherSequencer.ResolveApplicationDispatcher(null)).IsNull();
+ await Assert.That(MauiDispatcherSequencer.ResolveApplicationDispatcher(new FakePlatformApplication(null))).IsNull();
+ await Assert.That(MauiDispatcherSequencer.ResolveApplicationDispatcher(new FakePlatformApplication(new DispatcherServices(null))))
+ .IsNull();
+ await Assert.That(MauiDispatcherSequencer.ResolveApplicationDispatcher(new FakePlatformApplication(new DispatcherServices(dispatcher))))
+ .IsSameReferenceAs(dispatcher);
+ }
+
+ /// Without an application dispatcher there is nothing to bind, and nothing is cached.
+ /// A representing the asynchronous operation.
+ [Test]
+ public async Task BindMainReturnsNullWithoutAnApplicationDispatcher()
+ {
+ MauiDispatcherSequencer? slot = null;
+ await Assert.That(MauiDispatcherSequencer.BindMain(ref slot, null)).IsNull();
+ await Assert.That(slot).IsNull();
+ }
+
+ /// The first application dispatcher bound stays bound.
+ /// A representing the asynchronous operation.
+ [Test]
+ public async Task BindMainKeepsTheFirstBinding()
+ {
+ FakeDispatcher first = new();
+ FakeDispatcher second = new();
+ MauiDispatcherSequencer? slot = null;
+
+ var bound = MauiDispatcherSequencer.BindMain(ref slot, first);
+ var rebound = MauiDispatcherSequencer.BindMain(ref slot, second);
+
+ await Assert.That(bound).IsNotNull();
+ await Assert.That(bound!.Dispatcher).IsSameReferenceAs(first);
+ await Assert.That(slot).IsSameReferenceAs(bound);
+ await Assert.That(rebound).IsSameReferenceAs(bound);
+ }
+
+ /// Reads twice on the calling thread.
+ /// Both reads.
+ private static (MauiDispatcherSequencer Sequencer, MauiDispatcherSequencer Repeat) CaptureCurrent() =>
+ (MauiDispatcherSequencer.Current, MauiDispatcherSequencer.Current);
+
+ /// Runs a function on a fresh thread that reports the given dispatcher as its own.
+ /// The result type.
+ /// The dispatcher the thread reports, or for none.
+ /// The function to run.
+ /// The function's result.
+ private static Task RunOnNewThread(IDispatcher? dispatcher, Func func)
+ {
+ TaskCompletionSource completion = new(TaskCreationOptions.RunContinuationsAsynchronously);
+ Thread thread = new(() =>
+ {
+ ThreadDispatcherProvider.ForThread = dispatcher;
+ try
+ {
+ completion.SetResult(func());
+ }
+ catch (Exception ex)
+ {
+ completion.SetException(ex);
+ }
+ });
+ thread.Start();
+ return completion.Task;
+ }
+
+ /// Dispatcher provider that reports the dispatcher registered by the calling thread.
+ private sealed class ThreadDispatcherProvider : IDispatcherProvider
+ {
+ /// The dispatcher registered by the calling thread.
+ [ThreadStatic]
+ private static IDispatcher? _forThread;
+
+ /// Gets or sets the dispatcher registered by the calling thread.
+ public static IDispatcher? ForThread
+ {
+ get => _forThread;
+ set => _forThread = value;
+ }
+
+ ///
+ [MethodImpl(MethodImplOptions.AggressiveInlining)]
+ public IDispatcher? GetForCurrentThread() => _forThread;
+ }
+
+ /// Platform application that exposes the given services.
+ /// The application services, or before MAUI sets them.
+ private sealed class FakePlatformApplication(IServiceProvider? services) : IPlatformApplication
+ {
+ ///
+ public IServiceProvider Services => services!;
+
+ ///
+ public IApplication Application => null!;
+ }
+
+ /// Service provider that resolves only .
+ /// The dispatcher to return, or for none.
+ private sealed class DispatcherServices(IDispatcher? dispatcher) : IServiceProvider
+ {
+ ///
+ public object? GetService(Type serviceType) => serviceType == typeof(IDispatcher) ? dispatcher : null;
+ }
+
/// Work item that invokes a delegate when executed.
private sealed class DelegateWorkItem : IWorkItem
{
diff --git a/src/tests/ReactiveUI.Primitives.WinForms.Reactive.Tests/ControlSequencerTests.cs b/src/tests/ReactiveUI.Primitives.WinForms.Reactive.Tests/ControlSequencerTests.cs
index 3f0c0ca9..d9a40198 100644
--- a/src/tests/ReactiveUI.Primitives.WinForms.Reactive.Tests/ControlSequencerTests.cs
+++ b/src/tests/ReactiveUI.Primitives.WinForms.Reactive.Tests/ControlSequencerTests.cs
@@ -190,6 +190,82 @@ public async Task DelayedScheduleHonorsCancellationBeforeDelivery(bool cancel)
handle.Dispose();
}
+ /// A thread that is not STA gets an error, and nothing is cached.
+ /// The calling thread's apartment state.
+ /// The test operation.
+ [Test]
+ [Arguments(ApartmentState.MTA)]
+ [Arguments(ApartmentState.Unknown)]
+ public async Task BindMainRejectsAThreadThatIsNotStaAndCachesNothing(ApartmentState apartment)
+ {
+ ControlSequencer? slot = null;
+ InvalidOperationException? error = null;
+ try
+ {
+ _ = ControlSequencer.BindMain(ref slot, apartment);
+ }
+ catch (InvalidOperationException ex)
+ {
+ error = ex;
+ }
+
+ await Assert.That(error).IsNotNull();
+ await Assert.That(slot).IsNull();
+ }
+
+ /// On an STA thread, Main owns a hidden control whose handle belongs to that thread, and the first binding stays bound.
+ /// The test operation.
+ [Test]
+ public async Task BindMainOnAnStaThreadCreatesTheHandleOnThatThread()
+ {
+ var captured = await RunOnStaThread(static () =>
+ {
+ ControlSequencer? slot = null;
+ var bound = ControlSequencer.BindMain(ref slot, ApartmentState.STA);
+ var rebound = ControlSequencer.BindMain(ref slot, ApartmentState.STA);
+ return (Bound: bound, Rebound: rebound, Slot: slot, bound.Control.IsHandleCreated, bound.Control.InvokeRequired);
+ });
+
+ await Assert.That(captured.IsHandleCreated).IsTrue();
+ await Assert.That(captured.InvokeRequired).IsFalse();
+ await Assert.That(captured.Slot).IsSameReferenceAs(captured.Bound);
+ await Assert.That(captured.Rebound).IsSameReferenceAs(captured.Bound);
+ }
+
+ /// Once an STA thread binds Main, every thread gets the same sequencer.
+ /// The test operation.
+ [Test]
+ public async Task MainIsSharedWithEveryThreadOnceAnStaThreadBindsIt()
+ {
+ var onSta = await RunOnStaThread(static () => ControlSequencer.Main);
+ var onPool = await Task.Run(static () => ControlSequencer.Main);
+
+ await Assert.That(onPool).IsSameReferenceAs(onSta);
+ }
+
+ /// Runs a function on a fresh STA thread and returns its result or exception.
+ /// The result type.
+ /// The function to run.
+ /// The function's result.
+ private static Task RunOnStaThread(Func func)
+ {
+ TaskCompletionSource completion = new(TaskCreationOptions.RunContinuationsAsynchronously);
+ Thread thread = new(() =>
+ {
+ try
+ {
+ completion.SetResult(func());
+ }
+ catch (Exception ex)
+ {
+ completion.SetException(ex);
+ }
+ });
+ thread.SetApartmentState(ApartmentState.STA);
+ thread.Start();
+ return completion.Task;
+ }
+
/// Creates a control without changing the caller's synchronization context.
/// The control.
private static Control CreateControl()
diff --git a/src/tests/ReactiveUI.Primitives.WinForms.Tests/ControlSequencerTests.cs b/src/tests/ReactiveUI.Primitives.WinForms.Tests/ControlSequencerTests.cs
index d5a8b5da..65b7932c 100644
--- a/src/tests/ReactiveUI.Primitives.WinForms.Tests/ControlSequencerTests.cs
+++ b/src/tests/ReactiveUI.Primitives.WinForms.Tests/ControlSequencerTests.cs
@@ -182,6 +182,82 @@ public async Task DelayedScheduleWaitsForItsCallback(bool cancel)
await Assert.That(calls).IsEqualTo(cancel ? 0 : 1);
}
+ /// A thread that is not STA gets an error, and nothing is cached.
+ /// The calling thread's apartment state.
+ /// The test operation.
+ [Test]
+ [Arguments(ApartmentState.MTA)]
+ [Arguments(ApartmentState.Unknown)]
+ public async Task BindMainRejectsAThreadThatIsNotStaAndCachesNothing(ApartmentState apartment)
+ {
+ ControlSequencer? slot = null;
+ InvalidOperationException? error = null;
+ try
+ {
+ _ = ControlSequencer.BindMain(ref slot, apartment);
+ }
+ catch (InvalidOperationException ex)
+ {
+ error = ex;
+ }
+
+ await Assert.That(error).IsNotNull();
+ await Assert.That(slot).IsNull();
+ }
+
+ /// On an STA thread, Main owns a hidden control whose handle belongs to that thread, and the first binding stays bound.
+ /// The test operation.
+ [Test]
+ public async Task BindMainOnAnStaThreadCreatesTheHandleOnThatThread()
+ {
+ var captured = await RunOnStaThread(static () =>
+ {
+ ControlSequencer? slot = null;
+ var bound = ControlSequencer.BindMain(ref slot, ApartmentState.STA);
+ var rebound = ControlSequencer.BindMain(ref slot, ApartmentState.STA);
+ return (Bound: bound, Rebound: rebound, Slot: slot, bound.Control.IsHandleCreated, bound.Control.InvokeRequired);
+ });
+
+ await Assert.That(captured.IsHandleCreated).IsTrue();
+ await Assert.That(captured.InvokeRequired).IsFalse();
+ await Assert.That(captured.Slot).IsSameReferenceAs(captured.Bound);
+ await Assert.That(captured.Rebound).IsSameReferenceAs(captured.Bound);
+ }
+
+ /// Once an STA thread binds Main, every thread gets the same sequencer.
+ /// The test operation.
+ [Test]
+ public async Task MainIsSharedWithEveryThreadOnceAnStaThreadBindsIt()
+ {
+ var onSta = await RunOnStaThread(static () => ControlSequencer.Main);
+ var onPool = await Task.Run(static () => ControlSequencer.Main);
+
+ await Assert.That(onPool).IsSameReferenceAs(onSta);
+ }
+
+ /// Runs a function on a fresh STA thread and returns its result or exception.
+ /// The result type.
+ /// The function to run.
+ /// The function's result.
+ private static Task RunOnStaThread(Func func)
+ {
+ TaskCompletionSource completion = new(TaskCreationOptions.RunContinuationsAsynchronously);
+ Thread thread = new(() =>
+ {
+ try
+ {
+ completion.SetResult(func());
+ }
+ catch (Exception ex)
+ {
+ completion.SetException(ex);
+ }
+ });
+ thread.SetApartmentState(ApartmentState.STA);
+ thread.Start();
+ return completion.Task;
+ }
+
/// Invokes the getter used by the debugger without reflection.
/// The sequencer to display.
/// The debugger display text.
diff --git a/src/tests/ReactiveUI.Primitives.WinUI.Reactive.Tests/DispatcherQueueSequencerTests.cs b/src/tests/ReactiveUI.Primitives.WinUI.Reactive.Tests/DispatcherQueueSequencerTests.cs
index ca82101c..99e10991 100644
--- a/src/tests/ReactiveUI.Primitives.WinUI.Reactive.Tests/DispatcherQueueSequencerTests.cs
+++ b/src/tests/ReactiveUI.Primitives.WinUI.Reactive.Tests/DispatcherQueueSequencerTests.cs
@@ -146,6 +146,142 @@ public async Task DelayedScheduleHonorsCancellationBeforeDelivery(bool cancel)
handle.Dispose();
}
+ /// A thread without a dispatcher queue gets an error rather than a sequencer that could never run work.
+ /// The test operation.
+ [Test]
+ public async Task CurrentThrowsWhenTheThreadHasNoDispatcherQueue() =>
+ await Assert.That(static () => RunOnNewThread(static () => DispatcherQueueSequencer.Current))
+ .ThrowsExactly();
+
+ /// Each dispatcher queue thread gets its own cached sequencer bound to that thread's queue.
+ /// The test operation.
+ [Test]
+ public async Task CurrentIsCachedPerThreadAndBoundToThatThreadsQueue()
+ {
+ var firstController = DispatcherQueueController.CreateOnDedicatedThread();
+ var secondController = DispatcherQueueController.CreateOnDedicatedThread();
+ try
+ {
+ var first = await RunOnQueue(firstController.DispatcherQueue, CaptureCurrent);
+ var second = await RunOnQueue(secondController.DispatcherQueue, CaptureCurrent);
+
+ await Assert.That(first.Repeat).IsSameReferenceAs(first.Sequencer);
+ await Assert.That(first.Sequencer.DispatcherQueue).IsSameReferenceAs(firstController.DispatcherQueue);
+ await Assert.That(first.Sequencer.Priority).IsEqualTo(DispatcherQueuePriority.Normal);
+ await Assert.That(second.Sequencer).IsNotSameReferenceAs(first.Sequencer);
+ await Assert.That(second.Sequencer.DispatcherQueue).IsSameReferenceAs(secondController.DispatcherQueue);
+ }
+ finally
+ {
+ await firstController.ShutdownQueueAsync();
+ await secondController.ShutdownQueueAsync();
+ }
+ }
+
+ /// Once a UI thread binds Main, a thread without a dispatcher queue gets the same sequencer.
+ /// The test operation.
+ [Test]
+ public async Task MainIsSharedWithEveryThreadOnceAUiThreadBindsIt()
+ {
+ var controller = DispatcherQueueController.CreateOnDedicatedThread();
+ try
+ {
+ var onQueue = await RunOnQueue(controller.DispatcherQueue, static () => DispatcherQueueSequencer.Main);
+ var offQueue = await RunOnNewThread(static () => DispatcherQueueSequencer.Main);
+
+ await Assert.That(offQueue).IsSameReferenceAs(onQueue);
+ }
+ finally
+ {
+ await controller.ShutdownQueueAsync();
+ }
+ }
+
+ /// Without a dispatcher queue there is nothing to bind, and nothing is cached.
+ /// The test operation.
+ [Test]
+ public async Task BindMainReturnsNullWithoutADispatcherQueue()
+ {
+ DispatcherQueueSequencer? slot = null;
+ await Assert.That(DispatcherQueueSequencer.BindMain(ref slot, null)).IsNull();
+ await Assert.That(slot).IsNull();
+ }
+
+ /// The first dispatcher queue bound stays bound.
+ /// The test operation.
+ [Test]
+ public async Task BindMainKeepsTheFirstBinding()
+ {
+ var firstController = DispatcherQueueController.CreateOnDedicatedThread();
+ var secondController = DispatcherQueueController.CreateOnDedicatedThread();
+ try
+ {
+ DispatcherQueueSequencer? slot = null;
+
+ var bound = DispatcherQueueSequencer.BindMain(ref slot, firstController.DispatcherQueue);
+ var rebound = DispatcherQueueSequencer.BindMain(ref slot, secondController.DispatcherQueue);
+
+ await Assert.That(bound).IsNotNull();
+ await Assert.That(bound!.DispatcherQueue).IsSameReferenceAs(firstController.DispatcherQueue);
+ await Assert.That(slot).IsSameReferenceAs(bound);
+ await Assert.That(rebound).IsSameReferenceAs(bound);
+ }
+ finally
+ {
+ await firstController.ShutdownQueueAsync();
+ await secondController.ShutdownQueueAsync();
+ }
+ }
+
+ /// Reads twice on the calling thread.
+ /// Both reads.
+ private static (DispatcherQueueSequencer Sequencer, DispatcherQueueSequencer Repeat) CaptureCurrent() =>
+ (DispatcherQueueSequencer.Current, DispatcherQueueSequencer.Current);
+
+ /// Runs a function on a dispatcher queue's thread and returns its result or exception.
+ /// The result type.
+ /// The dispatcher queue whose thread runs the function.
+ /// The function to run.
+ /// The function's result.
+ private static Task RunOnQueue(DispatcherQueue queue, Func func)
+ {
+ TaskCompletionSource completion = new(TaskCreationOptions.RunContinuationsAsynchronously);
+ if (!queue.TryEnqueue(() => Complete(completion, func)))
+ {
+ completion.SetException(new InvalidOperationException("The dispatcher queue rejected the work."));
+ }
+
+ return completion.Task;
+ }
+
+ /// Runs a function on a fresh thread that has no dispatcher queue and returns its result or exception.
+ /// The result type.
+ /// The function to run.
+ /// The function's result.
+ private static Task RunOnNewThread(Func func)
+ {
+ TaskCompletionSource completion = new(TaskCreationOptions.RunContinuationsAsynchronously);
+ Thread thread = new(() => Complete(completion, func));
+ thread.Start();
+ return completion.Task;
+ }
+
+ /// Completes a task with a function's result or exception.
+ /// The result type.
+ /// The task to complete.
+ /// The function to run.
+ private static void Complete(TaskCompletionSource completion, Func func)
+ {
+ try
+ {
+ completion.SetResult(func());
+ }
+ catch (Exception ex)
+ {
+ completion.SetException(ex);
+ }
+ }
+
/// Retains callbacks until they are explicitly delivered.
private sealed class ManualDispatcher
{
diff --git a/src/tests/ReactiveUI.Primitives.WinUI.Tests/DispatcherQueueSequencerTests.cs b/src/tests/ReactiveUI.Primitives.WinUI.Tests/DispatcherQueueSequencerTests.cs
index 8f903a21..35e04c65 100644
--- a/src/tests/ReactiveUI.Primitives.WinUI.Tests/DispatcherQueueSequencerTests.cs
+++ b/src/tests/ReactiveUI.Primitives.WinUI.Tests/DispatcherQueueSequencerTests.cs
@@ -148,6 +148,142 @@ public async Task DelayedScheduleWaitsForItsCallback(bool cancel)
await Assert.That(calls).IsEqualTo(cancel ? 0 : 1);
}
+ /// A thread without a dispatcher queue gets an error rather than a sequencer that could never run work.
+ /// The test operation.
+ [Test]
+ public async Task CurrentThrowsWhenTheThreadHasNoDispatcherQueue() =>
+ await Assert.That(static () => RunOnNewThread(static () => DispatcherQueueSequencer.Current))
+ .ThrowsExactly();
+
+ /// Each dispatcher queue thread gets its own cached sequencer bound to that thread's queue.
+ /// The test operation.
+ [Test]
+ public async Task CurrentIsCachedPerThreadAndBoundToThatThreadsQueue()
+ {
+ var firstController = DispatcherQueueController.CreateOnDedicatedThread();
+ var secondController = DispatcherQueueController.CreateOnDedicatedThread();
+ try
+ {
+ var first = await RunOnQueue(firstController.DispatcherQueue, CaptureCurrent);
+ var second = await RunOnQueue(secondController.DispatcherQueue, CaptureCurrent);
+
+ await Assert.That(first.Repeat).IsSameReferenceAs(first.Sequencer);
+ await Assert.That(first.Sequencer.DispatcherQueue).IsSameReferenceAs(firstController.DispatcherQueue);
+ await Assert.That(first.Sequencer.Priority).IsEqualTo(DispatcherQueuePriority.Normal);
+ await Assert.That(second.Sequencer).IsNotSameReferenceAs(first.Sequencer);
+ await Assert.That(second.Sequencer.DispatcherQueue).IsSameReferenceAs(secondController.DispatcherQueue);
+ }
+ finally
+ {
+ await firstController.ShutdownQueueAsync();
+ await secondController.ShutdownQueueAsync();
+ }
+ }
+
+ /// Once a UI thread binds Main, a thread without a dispatcher queue gets the same sequencer.
+ /// The test operation.
+ [Test]
+ public async Task MainIsSharedWithEveryThreadOnceAUiThreadBindsIt()
+ {
+ var controller = DispatcherQueueController.CreateOnDedicatedThread();
+ try
+ {
+ var onQueue = await RunOnQueue(controller.DispatcherQueue, static () => DispatcherQueueSequencer.Main);
+ var offQueue = await RunOnNewThread(static () => DispatcherQueueSequencer.Main);
+
+ await Assert.That(offQueue).IsSameReferenceAs(onQueue);
+ }
+ finally
+ {
+ await controller.ShutdownQueueAsync();
+ }
+ }
+
+ /// Without a dispatcher queue there is nothing to bind, and nothing is cached.
+ /// The test operation.
+ [Test]
+ public async Task BindMainReturnsNullWithoutADispatcherQueue()
+ {
+ DispatcherQueueSequencer? slot = null;
+ await Assert.That(DispatcherQueueSequencer.BindMain(ref slot, null)).IsNull();
+ await Assert.That(slot).IsNull();
+ }
+
+ /// The first dispatcher queue bound stays bound.
+ /// The test operation.
+ [Test]
+ public async Task BindMainKeepsTheFirstBinding()
+ {
+ var firstController = DispatcherQueueController.CreateOnDedicatedThread();
+ var secondController = DispatcherQueueController.CreateOnDedicatedThread();
+ try
+ {
+ DispatcherQueueSequencer? slot = null;
+
+ var bound = DispatcherQueueSequencer.BindMain(ref slot, firstController.DispatcherQueue);
+ var rebound = DispatcherQueueSequencer.BindMain(ref slot, secondController.DispatcherQueue);
+
+ await Assert.That(bound).IsNotNull();
+ await Assert.That(bound!.DispatcherQueue).IsSameReferenceAs(firstController.DispatcherQueue);
+ await Assert.That(slot).IsSameReferenceAs(bound);
+ await Assert.That(rebound).IsSameReferenceAs(bound);
+ }
+ finally
+ {
+ await firstController.ShutdownQueueAsync();
+ await secondController.ShutdownQueueAsync();
+ }
+ }
+
+ /// Reads twice on the calling thread.
+ /// Both reads.
+ private static (DispatcherQueueSequencer Sequencer, DispatcherQueueSequencer Repeat) CaptureCurrent() =>
+ (DispatcherQueueSequencer.Current, DispatcherQueueSequencer.Current);
+
+ /// Runs a function on a dispatcher queue's thread and returns its result or exception.
+ /// The result type.
+ /// The dispatcher queue whose thread runs the function.
+ /// The function to run.
+ /// The function's result.
+ private static Task RunOnQueue(DispatcherQueue queue, Func func)
+ {
+ TaskCompletionSource completion = new(TaskCreationOptions.RunContinuationsAsynchronously);
+ if (!queue.TryEnqueue(() => Complete(completion, func)))
+ {
+ completion.SetException(new InvalidOperationException("The dispatcher queue rejected the work."));
+ }
+
+ return completion.Task;
+ }
+
+ /// Runs a function on a fresh thread that has no dispatcher queue and returns its result or exception.
+ /// The result type.
+ /// The function to run.
+ /// The function's result.
+ private static Task RunOnNewThread(Func func)
+ {
+ TaskCompletionSource completion = new(TaskCreationOptions.RunContinuationsAsynchronously);
+ Thread thread = new(() => Complete(completion, func));
+ thread.Start();
+ return completion.Task;
+ }
+
+ /// Completes a task with a function's result or exception.
+ /// The result type.
+ /// The task to complete.
+ /// The function to run.
+ private static void Complete(TaskCompletionSource completion, Func func)
+ {
+ try
+ {
+ completion.SetResult(func());
+ }
+ catch (Exception ex)
+ {
+ completion.SetException(ex);
+ }
+ }
+
/// Retains callbacks until they are explicitly delivered.
private sealed class ManualDispatcher
{