From c5fe9daf3e8c55e636faaf6773ed5c440020e29a Mon Sep 17 00:00:00 2001 From: Glenn Watson <5834289+glennawatson@users.noreply.github.com> Date: Thu, 24 Sep 2026 13:24:09 +1000 Subject: [PATCH 1/4] feat(wpf): add DispatcherSequencer.Main for the WPF UI thread Give WPF a shared main-thread sequencer, matching HandlerSequencer.Main and NSRunloopSequencer.Main on the other platforms, so consumers such as ReactiveUI no longer resolve the UI dispatcher themselves. Main binds lazily on first access to the running Application's dispatcher, falling back to the calling thread's dispatcher when no application exists yet. Added to both the ISequencer and the System.Reactive IScheduler flavours on every WPF target, including net462. Co-authored-by: Pranay Kumar Vonamala --- .../Concurrency/DispatcherSequencer.cs | 15 ++++++++++++++ .../PublicAPI/net10.0-windows/PublicAPI.txt | 1 + .../PublicAPI/net11.0-windows/PublicAPI.txt | 1 + .../PublicAPI/net462/PublicAPI.txt | 1 + .../PublicAPI/net472/PublicAPI.txt | 1 + .../PublicAPI/net48/PublicAPI.txt | 1 + .../PublicAPI/net481/PublicAPI.txt | 1 + .../PublicAPI/net8.0-windows/PublicAPI.txt | 1 + .../PublicAPI/net9.0-windows/PublicAPI.txt | 1 + .../Concurrency/DispatcherSequencer.cs | 15 ++++++++++++++ .../PublicAPI/net10.0-windows/PublicAPI.txt | 1 + .../PublicAPI/net11.0-windows/PublicAPI.txt | 1 + .../PublicAPI/net462/PublicAPI.txt | 1 + .../PublicAPI/net472/PublicAPI.txt | 1 + .../PublicAPI/net48/PublicAPI.txt | 1 + .../PublicAPI/net481/PublicAPI.txt | 1 + .../PublicAPI/net8.0-windows/PublicAPI.txt | 1 + .../PublicAPI/net9.0-windows/PublicAPI.txt | 1 + .../DispatcherSequencerTests.cs | 20 +++++++++++++++++++ .../DispatcherSequencerTests.cs | 20 +++++++++++++++++++ 20 files changed, 86 insertions(+) diff --git a/src/ReactiveUI.Primitives.Wpf.Reactive/Concurrency/DispatcherSequencer.cs b/src/ReactiveUI.Primitives.Wpf.Reactive/Concurrency/DispatcherSequencer.cs index f2b39374..0b799429 100644 --- a/src/ReactiveUI.Primitives.Wpf.Reactive/Concurrency/DispatcherSequencer.cs +++ b/src/ReactiveUI.Primitives.Wpf.Reactive/Concurrency/DispatcherSequencer.cs @@ -15,6 +15,9 @@ namespace ReactiveUI.Primitives.Reactive.Concurrency; [System.Diagnostics.DebuggerDisplay("DispatcherSequencer: Dispatcher = {Dispatcher}, Priority = {Priority}")] public sealed class DispatcherSequencer : LocalScheduler { + /// Lazily binds the shared main-thread scheduler to the application's dispatcher on first use. + private static readonly Lazy LazyMain = new(static () => new(ResolveMainDispatcher())); + /// Optional callback for posting ready work. private readonly Func? _post; @@ -60,6 +63,13 @@ internal DispatcherSequencer( _dispatch = new(RunDrain, DefaultScheduler.Instance); } + /// Gets the shared scheduler for the WPF main (UI) thread. + /// + /// Bound on first access to the running 's dispatcher, or to the calling + /// thread's dispatcher when no application exists yet. + /// + public static DispatcherSequencer Main => LazyMain.Value; + /// Gets the dispatcher whose thread runs the scheduled work. public Dispatcher Dispatcher { get; } @@ -78,6 +88,11 @@ public override IDisposable Schedule(TState state, Func(TState state, TimeSpan dueTime, Func action) => _dispatch.Schedule(new DispatchHost(this), this, state, dueTime, action); + /// Resolves the dispatcher that owns the WPF main (UI) thread. + /// The application's dispatcher, or the calling thread's dispatcher when no application exists yet. + internal static Dispatcher ResolveMainDispatcher() => + System.Windows.Application.Current?.Dispatcher ?? Dispatcher.CurrentDispatcher; + /// Posts the drain callback, through the test hook when one was supplied. /// The drain callback. /// Whether the dispatcher accepted the callback. diff --git a/src/ReactiveUI.Primitives.Wpf.Reactive/PublicAPI/net10.0-windows/PublicAPI.txt b/src/ReactiveUI.Primitives.Wpf.Reactive/PublicAPI/net10.0-windows/PublicAPI.txt index 980b0ebb..f0467613 100644 --- a/src/ReactiveUI.Primitives.Wpf.Reactive/PublicAPI/net10.0-windows/PublicAPI.txt +++ b/src/ReactiveUI.Primitives.Wpf.Reactive/PublicAPI/net10.0-windows/PublicAPI.txt @@ -8,6 +8,7 @@ public sealed class DispatcherSequencer : System.Reactive.Concurrency.LocalSched public DispatcherSequencer(System.Windows.Threading.Dispatcher dispatcher) { } public DispatcherSequencer(System.Windows.Threading.Dispatcher dispatcher, System.Windows.Threading.DispatcherPriority priority) { } public System.Windows.Threading.Dispatcher Dispatcher { get; } + public static ReactiveUI.Primitives.Reactive.Concurrency.DispatcherSequencer Main { get; } public System.Windows.Threading.DispatcherPriority 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.Wpf.Reactive/PublicAPI/net11.0-windows/PublicAPI.txt b/src/ReactiveUI.Primitives.Wpf.Reactive/PublicAPI/net11.0-windows/PublicAPI.txt index 980b0ebb..f0467613 100644 --- a/src/ReactiveUI.Primitives.Wpf.Reactive/PublicAPI/net11.0-windows/PublicAPI.txt +++ b/src/ReactiveUI.Primitives.Wpf.Reactive/PublicAPI/net11.0-windows/PublicAPI.txt @@ -8,6 +8,7 @@ public sealed class DispatcherSequencer : System.Reactive.Concurrency.LocalSched public DispatcherSequencer(System.Windows.Threading.Dispatcher dispatcher) { } public DispatcherSequencer(System.Windows.Threading.Dispatcher dispatcher, System.Windows.Threading.DispatcherPriority priority) { } public System.Windows.Threading.Dispatcher Dispatcher { get; } + public static ReactiveUI.Primitives.Reactive.Concurrency.DispatcherSequencer Main { get; } public System.Windows.Threading.DispatcherPriority 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.Wpf.Reactive/PublicAPI/net462/PublicAPI.txt b/src/ReactiveUI.Primitives.Wpf.Reactive/PublicAPI/net462/PublicAPI.txt index 1cf2b989..e4461302 100644 --- a/src/ReactiveUI.Primitives.Wpf.Reactive/PublicAPI/net462/PublicAPI.txt +++ b/src/ReactiveUI.Primitives.Wpf.Reactive/PublicAPI/net462/PublicAPI.txt @@ -7,6 +7,7 @@ public sealed class DispatcherSequencer : System.Reactive.Concurrency.LocalSched public DispatcherSequencer(System.Windows.Threading.Dispatcher dispatcher) { } public DispatcherSequencer(System.Windows.Threading.Dispatcher dispatcher, System.Windows.Threading.DispatcherPriority priority) { } public System.Windows.Threading.Dispatcher Dispatcher { get; } + public static ReactiveUI.Primitives.Reactive.Concurrency.DispatcherSequencer Main { get; } public System.Windows.Threading.DispatcherPriority 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.Wpf.Reactive/PublicAPI/net472/PublicAPI.txt b/src/ReactiveUI.Primitives.Wpf.Reactive/PublicAPI/net472/PublicAPI.txt index 1cf2b989..e4461302 100644 --- a/src/ReactiveUI.Primitives.Wpf.Reactive/PublicAPI/net472/PublicAPI.txt +++ b/src/ReactiveUI.Primitives.Wpf.Reactive/PublicAPI/net472/PublicAPI.txt @@ -7,6 +7,7 @@ public sealed class DispatcherSequencer : System.Reactive.Concurrency.LocalSched public DispatcherSequencer(System.Windows.Threading.Dispatcher dispatcher) { } public DispatcherSequencer(System.Windows.Threading.Dispatcher dispatcher, System.Windows.Threading.DispatcherPriority priority) { } public System.Windows.Threading.Dispatcher Dispatcher { get; } + public static ReactiveUI.Primitives.Reactive.Concurrency.DispatcherSequencer Main { get; } public System.Windows.Threading.DispatcherPriority 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.Wpf.Reactive/PublicAPI/net48/PublicAPI.txt b/src/ReactiveUI.Primitives.Wpf.Reactive/PublicAPI/net48/PublicAPI.txt index 1cf2b989..e4461302 100644 --- a/src/ReactiveUI.Primitives.Wpf.Reactive/PublicAPI/net48/PublicAPI.txt +++ b/src/ReactiveUI.Primitives.Wpf.Reactive/PublicAPI/net48/PublicAPI.txt @@ -7,6 +7,7 @@ public sealed class DispatcherSequencer : System.Reactive.Concurrency.LocalSched public DispatcherSequencer(System.Windows.Threading.Dispatcher dispatcher) { } public DispatcherSequencer(System.Windows.Threading.Dispatcher dispatcher, System.Windows.Threading.DispatcherPriority priority) { } public System.Windows.Threading.Dispatcher Dispatcher { get; } + public static ReactiveUI.Primitives.Reactive.Concurrency.DispatcherSequencer Main { get; } public System.Windows.Threading.DispatcherPriority 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.Wpf.Reactive/PublicAPI/net481/PublicAPI.txt b/src/ReactiveUI.Primitives.Wpf.Reactive/PublicAPI/net481/PublicAPI.txt index 1cf2b989..e4461302 100644 --- a/src/ReactiveUI.Primitives.Wpf.Reactive/PublicAPI/net481/PublicAPI.txt +++ b/src/ReactiveUI.Primitives.Wpf.Reactive/PublicAPI/net481/PublicAPI.txt @@ -7,6 +7,7 @@ public sealed class DispatcherSequencer : System.Reactive.Concurrency.LocalSched public DispatcherSequencer(System.Windows.Threading.Dispatcher dispatcher) { } public DispatcherSequencer(System.Windows.Threading.Dispatcher dispatcher, System.Windows.Threading.DispatcherPriority priority) { } public System.Windows.Threading.Dispatcher Dispatcher { get; } + public static ReactiveUI.Primitives.Reactive.Concurrency.DispatcherSequencer Main { get; } public System.Windows.Threading.DispatcherPriority 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.Wpf.Reactive/PublicAPI/net8.0-windows/PublicAPI.txt b/src/ReactiveUI.Primitives.Wpf.Reactive/PublicAPI/net8.0-windows/PublicAPI.txt index 980b0ebb..f0467613 100644 --- a/src/ReactiveUI.Primitives.Wpf.Reactive/PublicAPI/net8.0-windows/PublicAPI.txt +++ b/src/ReactiveUI.Primitives.Wpf.Reactive/PublicAPI/net8.0-windows/PublicAPI.txt @@ -8,6 +8,7 @@ public sealed class DispatcherSequencer : System.Reactive.Concurrency.LocalSched public DispatcherSequencer(System.Windows.Threading.Dispatcher dispatcher) { } public DispatcherSequencer(System.Windows.Threading.Dispatcher dispatcher, System.Windows.Threading.DispatcherPriority priority) { } public System.Windows.Threading.Dispatcher Dispatcher { get; } + public static ReactiveUI.Primitives.Reactive.Concurrency.DispatcherSequencer Main { get; } public System.Windows.Threading.DispatcherPriority 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.Wpf.Reactive/PublicAPI/net9.0-windows/PublicAPI.txt b/src/ReactiveUI.Primitives.Wpf.Reactive/PublicAPI/net9.0-windows/PublicAPI.txt index 980b0ebb..f0467613 100644 --- a/src/ReactiveUI.Primitives.Wpf.Reactive/PublicAPI/net9.0-windows/PublicAPI.txt +++ b/src/ReactiveUI.Primitives.Wpf.Reactive/PublicAPI/net9.0-windows/PublicAPI.txt @@ -8,6 +8,7 @@ public sealed class DispatcherSequencer : System.Reactive.Concurrency.LocalSched public DispatcherSequencer(System.Windows.Threading.Dispatcher dispatcher) { } public DispatcherSequencer(System.Windows.Threading.Dispatcher dispatcher, System.Windows.Threading.DispatcherPriority priority) { } public System.Windows.Threading.Dispatcher Dispatcher { get; } + public static ReactiveUI.Primitives.Reactive.Concurrency.DispatcherSequencer Main { get; } public System.Windows.Threading.DispatcherPriority 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.Wpf/Concurrency/DispatcherSequencer.cs b/src/ReactiveUI.Primitives.Wpf/Concurrency/DispatcherSequencer.cs index 47f49db5..ced306d0 100644 --- a/src/ReactiveUI.Primitives.Wpf/Concurrency/DispatcherSequencer.cs +++ b/src/ReactiveUI.Primitives.Wpf/Concurrency/DispatcherSequencer.cs @@ -14,6 +14,9 @@ namespace ReactiveUI.Primitives.Concurrency; [System.Diagnostics.DebuggerDisplay("{DebuggerDisplay,nq}")] public sealed class DispatcherSequencer : ISequencer { + /// Lazily binds the shared main-thread sequencer to the application's dispatcher on first use. + private static readonly Lazy LazyMain = new(static () => new(ResolveMainDispatcher())); + /// Coalescing dispatch engine. private DispatchSequencerState _state; @@ -51,6 +54,13 @@ internal DispatcherSequencer( _state = new(this, post ?? Post, RunDrain, scheduleDelayed ?? ScheduleDelayed); } + /// Gets the shared sequencer for the WPF main (UI) thread. + /// + /// Bound on first access to the running 's dispatcher, or to the calling + /// thread's dispatcher when no application exists yet. + /// + public static DispatcherSequencer Main => LazyMain.Value; + /// Gets the dispatcher whose thread runs the scheduled work. public Dispatcher Dispatcher { get; } @@ -75,6 +85,11 @@ internal DispatcherSequencer( [MethodImpl(MethodImplOptions.AggressiveInlining)] public void Schedule(IWorkItem item, long dueTimestamp) => _state.Schedule(item, dueTimestamp); + /// Resolves the dispatcher that owns the WPF main (UI) thread. + /// The application's dispatcher, or the calling thread's dispatcher when no application exists yet. + internal static Dispatcher ResolveMainDispatcher() => + System.Windows.Application.Current?.Dispatcher ?? Dispatcher.CurrentDispatcher; + /// Marshals the cached drain callback onto the dispatcher. /// The drain callback. /// , since the dispatcher always accepts the work. diff --git a/src/ReactiveUI.Primitives.Wpf/PublicAPI/net10.0-windows/PublicAPI.txt b/src/ReactiveUI.Primitives.Wpf/PublicAPI/net10.0-windows/PublicAPI.txt index 964e7afa..9925a04b 100644 --- a/src/ReactiveUI.Primitives.Wpf/PublicAPI/net10.0-windows/PublicAPI.txt +++ b/src/ReactiveUI.Primitives.Wpf/PublicAPI/net10.0-windows/PublicAPI.txt @@ -8,6 +8,7 @@ public sealed class DispatcherSequencer : ReactiveUI.Primitives.Concurrency.ISeq public DispatcherSequencer(System.Windows.Threading.Dispatcher dispatcher) { } public DispatcherSequencer(System.Windows.Threading.Dispatcher dispatcher, System.Windows.Threading.DispatcherPriority priority) { } public System.Windows.Threading.Dispatcher Dispatcher { get; } + public static ReactiveUI.Primitives.Concurrency.DispatcherSequencer Main { get; } public System.DateTimeOffset Now { get; } public System.Windows.Threading.DispatcherPriority Priority { get; } public long Timestamp { get; } diff --git a/src/ReactiveUI.Primitives.Wpf/PublicAPI/net11.0-windows/PublicAPI.txt b/src/ReactiveUI.Primitives.Wpf/PublicAPI/net11.0-windows/PublicAPI.txt index 964e7afa..9925a04b 100644 --- a/src/ReactiveUI.Primitives.Wpf/PublicAPI/net11.0-windows/PublicAPI.txt +++ b/src/ReactiveUI.Primitives.Wpf/PublicAPI/net11.0-windows/PublicAPI.txt @@ -8,6 +8,7 @@ public sealed class DispatcherSequencer : ReactiveUI.Primitives.Concurrency.ISeq public DispatcherSequencer(System.Windows.Threading.Dispatcher dispatcher) { } public DispatcherSequencer(System.Windows.Threading.Dispatcher dispatcher, System.Windows.Threading.DispatcherPriority priority) { } public System.Windows.Threading.Dispatcher Dispatcher { get; } + public static ReactiveUI.Primitives.Concurrency.DispatcherSequencer Main { get; } public System.DateTimeOffset Now { get; } public System.Windows.Threading.DispatcherPriority Priority { get; } public long Timestamp { get; } diff --git a/src/ReactiveUI.Primitives.Wpf/PublicAPI/net462/PublicAPI.txt b/src/ReactiveUI.Primitives.Wpf/PublicAPI/net462/PublicAPI.txt index 178ccd13..01c320ae 100644 --- a/src/ReactiveUI.Primitives.Wpf/PublicAPI/net462/PublicAPI.txt +++ b/src/ReactiveUI.Primitives.Wpf/PublicAPI/net462/PublicAPI.txt @@ -7,6 +7,7 @@ public sealed class DispatcherSequencer : ReactiveUI.Primitives.Concurrency.ISeq public DispatcherSequencer(System.Windows.Threading.Dispatcher dispatcher) { } public DispatcherSequencer(System.Windows.Threading.Dispatcher dispatcher, System.Windows.Threading.DispatcherPriority priority) { } public System.Windows.Threading.Dispatcher Dispatcher { get; } + public static ReactiveUI.Primitives.Concurrency.DispatcherSequencer Main { get; } public System.DateTimeOffset Now { get; } public System.Windows.Threading.DispatcherPriority Priority { get; } public long Timestamp { get; } diff --git a/src/ReactiveUI.Primitives.Wpf/PublicAPI/net472/PublicAPI.txt b/src/ReactiveUI.Primitives.Wpf/PublicAPI/net472/PublicAPI.txt index 178ccd13..01c320ae 100644 --- a/src/ReactiveUI.Primitives.Wpf/PublicAPI/net472/PublicAPI.txt +++ b/src/ReactiveUI.Primitives.Wpf/PublicAPI/net472/PublicAPI.txt @@ -7,6 +7,7 @@ public sealed class DispatcherSequencer : ReactiveUI.Primitives.Concurrency.ISeq public DispatcherSequencer(System.Windows.Threading.Dispatcher dispatcher) { } public DispatcherSequencer(System.Windows.Threading.Dispatcher dispatcher, System.Windows.Threading.DispatcherPriority priority) { } public System.Windows.Threading.Dispatcher Dispatcher { get; } + public static ReactiveUI.Primitives.Concurrency.DispatcherSequencer Main { get; } public System.DateTimeOffset Now { get; } public System.Windows.Threading.DispatcherPriority Priority { get; } public long Timestamp { get; } diff --git a/src/ReactiveUI.Primitives.Wpf/PublicAPI/net48/PublicAPI.txt b/src/ReactiveUI.Primitives.Wpf/PublicAPI/net48/PublicAPI.txt index 178ccd13..01c320ae 100644 --- a/src/ReactiveUI.Primitives.Wpf/PublicAPI/net48/PublicAPI.txt +++ b/src/ReactiveUI.Primitives.Wpf/PublicAPI/net48/PublicAPI.txt @@ -7,6 +7,7 @@ public sealed class DispatcherSequencer : ReactiveUI.Primitives.Concurrency.ISeq public DispatcherSequencer(System.Windows.Threading.Dispatcher dispatcher) { } public DispatcherSequencer(System.Windows.Threading.Dispatcher dispatcher, System.Windows.Threading.DispatcherPriority priority) { } public System.Windows.Threading.Dispatcher Dispatcher { get; } + public static ReactiveUI.Primitives.Concurrency.DispatcherSequencer Main { get; } public System.DateTimeOffset Now { get; } public System.Windows.Threading.DispatcherPriority Priority { get; } public long Timestamp { get; } diff --git a/src/ReactiveUI.Primitives.Wpf/PublicAPI/net481/PublicAPI.txt b/src/ReactiveUI.Primitives.Wpf/PublicAPI/net481/PublicAPI.txt index 178ccd13..01c320ae 100644 --- a/src/ReactiveUI.Primitives.Wpf/PublicAPI/net481/PublicAPI.txt +++ b/src/ReactiveUI.Primitives.Wpf/PublicAPI/net481/PublicAPI.txt @@ -7,6 +7,7 @@ public sealed class DispatcherSequencer : ReactiveUI.Primitives.Concurrency.ISeq public DispatcherSequencer(System.Windows.Threading.Dispatcher dispatcher) { } public DispatcherSequencer(System.Windows.Threading.Dispatcher dispatcher, System.Windows.Threading.DispatcherPriority priority) { } public System.Windows.Threading.Dispatcher Dispatcher { get; } + public static ReactiveUI.Primitives.Concurrency.DispatcherSequencer Main { get; } public System.DateTimeOffset Now { get; } public System.Windows.Threading.DispatcherPriority Priority { get; } public long Timestamp { get; } diff --git a/src/ReactiveUI.Primitives.Wpf/PublicAPI/net8.0-windows/PublicAPI.txt b/src/ReactiveUI.Primitives.Wpf/PublicAPI/net8.0-windows/PublicAPI.txt index 964e7afa..9925a04b 100644 --- a/src/ReactiveUI.Primitives.Wpf/PublicAPI/net8.0-windows/PublicAPI.txt +++ b/src/ReactiveUI.Primitives.Wpf/PublicAPI/net8.0-windows/PublicAPI.txt @@ -8,6 +8,7 @@ public sealed class DispatcherSequencer : ReactiveUI.Primitives.Concurrency.ISeq public DispatcherSequencer(System.Windows.Threading.Dispatcher dispatcher) { } public DispatcherSequencer(System.Windows.Threading.Dispatcher dispatcher, System.Windows.Threading.DispatcherPriority priority) { } public System.Windows.Threading.Dispatcher Dispatcher { get; } + public static ReactiveUI.Primitives.Concurrency.DispatcherSequencer Main { get; } public System.DateTimeOffset Now { get; } public System.Windows.Threading.DispatcherPriority Priority { get; } public long Timestamp { get; } diff --git a/src/ReactiveUI.Primitives.Wpf/PublicAPI/net9.0-windows/PublicAPI.txt b/src/ReactiveUI.Primitives.Wpf/PublicAPI/net9.0-windows/PublicAPI.txt index 964e7afa..9925a04b 100644 --- a/src/ReactiveUI.Primitives.Wpf/PublicAPI/net9.0-windows/PublicAPI.txt +++ b/src/ReactiveUI.Primitives.Wpf/PublicAPI/net9.0-windows/PublicAPI.txt @@ -8,6 +8,7 @@ public sealed class DispatcherSequencer : ReactiveUI.Primitives.Concurrency.ISeq public DispatcherSequencer(System.Windows.Threading.Dispatcher dispatcher) { } public DispatcherSequencer(System.Windows.Threading.Dispatcher dispatcher, System.Windows.Threading.DispatcherPriority priority) { } public System.Windows.Threading.Dispatcher Dispatcher { get; } + public static ReactiveUI.Primitives.Concurrency.DispatcherSequencer Main { get; } public System.DateTimeOffset Now { get; } public System.Windows.Threading.DispatcherPriority Priority { get; } public long Timestamp { get; } diff --git a/src/tests/ReactiveUI.Primitives.Wpf.Reactive.Tests/DispatcherSequencerTests.cs b/src/tests/ReactiveUI.Primitives.Wpf.Reactive.Tests/DispatcherSequencerTests.cs index 052c1a6f..2c83701f 100644 --- a/src/tests/ReactiveUI.Primitives.Wpf.Reactive.Tests/DispatcherSequencerTests.cs +++ b/src/tests/ReactiveUI.Primitives.Wpf.Reactive.Tests/DispatcherSequencerTests.cs @@ -38,6 +38,26 @@ public async Task ConstructorRetainsDispatcherAndPriority() await Assert.That(new DispatcherSequencer(dispatcher).Priority).IsEqualTo(DispatcherPriority.Normal); } + /// The shared main-thread scheduler is a single instance bound to a dispatcher at normal priority. + /// The test operation. + [Test] + public async Task MainIsSharedAndBoundToADispatcher() + { + var main = DispatcherSequencer.Main; + await Assert.That(DispatcherSequencer.Main).IsSameReferenceAs(main); + await Assert.That(main.Dispatcher).IsNotNull(); + await Assert.That(main.Priority).IsEqualTo(DispatcherPriority.Normal); + } + + /// Without a running application the main dispatcher falls back to the calling thread's dispatcher. + /// The test operation. + [Test] + public async Task ResolveMainDispatcherFallsBackToCurrentDispatcher() + { + var expected = System.Windows.Application.Current?.Dispatcher ?? Dispatcher.CurrentDispatcher; + await Assert.That(DispatcherSequencer.ResolveMainDispatcher()).IsSameReferenceAs(expected); + } + /// A posted batch preserves order and skips cancelled work. /// The test operation. [Test] diff --git a/src/tests/ReactiveUI.Primitives.Wpf.Tests/DispatcherSequencerTests.cs b/src/tests/ReactiveUI.Primitives.Wpf.Tests/DispatcherSequencerTests.cs index ebd787c7..8b060fb0 100644 --- a/src/tests/ReactiveUI.Primitives.Wpf.Tests/DispatcherSequencerTests.cs +++ b/src/tests/ReactiveUI.Primitives.Wpf.Tests/DispatcherSequencerTests.cs @@ -41,6 +41,26 @@ public async Task ConstructorRetainsDispatcherAndPriority() await Assert.That(sequencer.DebuggerDisplay).IsEqualTo(typeof(DispatcherSequencer).FullName); } + /// The shared main-thread sequencer is a single instance bound to a dispatcher at normal priority. + /// The test operation. + [Test] + public async Task MainIsSharedAndBoundToADispatcher() + { + var main = DispatcherSequencer.Main; + await Assert.That(DispatcherSequencer.Main).IsSameReferenceAs(main); + await Assert.That(main.Dispatcher).IsNotNull(); + await Assert.That(main.Priority).IsEqualTo(DispatcherPriority.Normal); + } + + /// Without a running application the main dispatcher falls back to the calling thread's dispatcher. + /// The test operation. + [Test] + public async Task ResolveMainDispatcherFallsBackToCurrentDispatcher() + { + var expected = System.Windows.Application.Current?.Dispatcher ?? Dispatcher.CurrentDispatcher; + await Assert.That(DispatcherSequencer.ResolveMainDispatcher()).IsSameReferenceAs(expected); + } + /// The dispatcher sequencer shares the monotonic timestamp scale used by scheduled work. /// A task representing the asynchronous test. [Test] From cf594142be6cc83369e714fc26fe49252ddd2c1d Mon Sep 17 00:00:00 2001 From: Glenn Watson <5834289+glennawatson@users.noreply.github.com> Date: Thu, 24 Sep 2026 13:54:03 +1000 Subject: [PATCH 2/4] feat(wpf): add DispatcherSequencer.Current and never bind Main to a dead dispatcher Main now binds only to the Application's dispatcher, from any thread. Until an application exists it caches nothing and uses the calling thread's existing dispatcher, so an early access from a background thread can no longer pin Main to a dispatcher that is never pumped. Current returns a sequencer for the calling thread's dispatcher, cached per thread, for applications that run UI on more than one thread. Neither property creates a dispatcher: a thread without one throws. --- .../Concurrency/DispatcherSequencer.cs | 49 +++++++++--- .../PublicAPI/net10.0-windows/PublicAPI.txt | 1 + .../PublicAPI/net11.0-windows/PublicAPI.txt | 1 + .../PublicAPI/net462/PublicAPI.txt | 1 + .../PublicAPI/net472/PublicAPI.txt | 1 + .../PublicAPI/net48/PublicAPI.txt | 1 + .../PublicAPI/net481/PublicAPI.txt | 1 + .../PublicAPI/net8.0-windows/PublicAPI.txt | 1 + .../PublicAPI/net9.0-windows/PublicAPI.txt | 1 + .../Concurrency/DispatcherSequencer.cs | 49 +++++++++--- .../PublicAPI/net10.0-windows/PublicAPI.txt | 1 + .../PublicAPI/net11.0-windows/PublicAPI.txt | 1 + .../PublicAPI/net462/PublicAPI.txt | 1 + .../PublicAPI/net472/PublicAPI.txt | 1 + .../PublicAPI/net48/PublicAPI.txt | 1 + .../PublicAPI/net481/PublicAPI.txt | 1 + .../PublicAPI/net8.0-windows/PublicAPI.txt | 1 + .../PublicAPI/net9.0-windows/PublicAPI.txt | 1 + .../DispatcherSequencerTests.cs | 77 ++++++++++++++++--- .../DispatcherSequencerTests.cs | 77 ++++++++++++++++--- 20 files changed, 230 insertions(+), 38 deletions(-) diff --git a/src/ReactiveUI.Primitives.Wpf.Reactive/Concurrency/DispatcherSequencer.cs b/src/ReactiveUI.Primitives.Wpf.Reactive/Concurrency/DispatcherSequencer.cs index 0b799429..2c8f3ce7 100644 --- a/src/ReactiveUI.Primitives.Wpf.Reactive/Concurrency/DispatcherSequencer.cs +++ b/src/ReactiveUI.Primitives.Wpf.Reactive/Concurrency/DispatcherSequencer.cs @@ -15,8 +15,12 @@ namespace ReactiveUI.Primitives.Reactive.Concurrency; [System.Diagnostics.DebuggerDisplay("DispatcherSequencer: Dispatcher = {Dispatcher}, Priority = {Priority}")] public sealed class DispatcherSequencer : LocalScheduler { - /// Lazily binds the shared main-thread scheduler to the application's dispatcher on first use. - private static readonly Lazy LazyMain = new(static () => new(ResolveMainDispatcher())); + /// The shared main-thread scheduler, set once the application's dispatcher is available. + private static DispatcherSequencer? _main; + + /// The scheduler for the calling thread's dispatcher, cached per thread. + [ThreadStatic] + private static DispatcherSequencer? _current; /// Optional callback for posting ready work. private readonly Func? _post; @@ -64,11 +68,21 @@ internal DispatcherSequencer( } /// Gets the shared scheduler for the WPF main (UI) thread. + /// No application exists yet and the calling thread has no dispatcher. /// - /// Bound on first access to the running 's dispatcher, or to the calling - /// thread's dispatcher when no application exists yet. + /// Bound once to the running 's dispatcher, from any thread. Until an + /// application exists nothing is cached, and the calling thread's existing dispatcher is used instead. /// - public static DispatcherSequencer Main => LazyMain.Value; + public static DispatcherSequencer Main => + Volatile.Read(ref _main) ?? BindMain(System.Windows.Application.Current?.Dispatcher) ?? 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 DispatcherSequencer Current => _current ??= new(ResolveCurrentDispatcher()); /// Gets the dispatcher whose thread runs the scheduled work. public Dispatcher Dispatcher { get; } @@ -88,10 +102,27 @@ public override IDisposable Schedule(TState state, Func(TState state, TimeSpan dueTime, Func action) => _dispatch.Schedule(new DispatchHost(this), this, state, dueTime, action); - /// Resolves the dispatcher that owns the WPF main (UI) thread. - /// The application's dispatcher, or the calling thread's dispatcher when no application exists yet. - internal static Dispatcher ResolveMainDispatcher() => - System.Windows.Application.Current?.Dispatcher ?? Dispatcher.CurrentDispatcher; + /// Caches the shared main-thread scheduler for the application's dispatcher, keeping the first one bound. + /// The application's dispatcher, or when no application exists. + /// The shared scheduler, or when is . + internal static DispatcherSequencer? BindMain(Dispatcher? applicationDispatcher) + { + if (applicationDispatcher is null) + { + return null; + } + + DispatcherSequencer created = new(applicationDispatcher); + return Interlocked.CompareExchange(ref _main, created, null) ?? created; + } + + /// Returns the calling thread's existing dispatcher without creating one. + /// The calling thread's dispatcher. + /// The calling thread has no dispatcher. + internal static Dispatcher ResolveCurrentDispatcher() => + Dispatcher.FromThread(Thread.CurrentThread) + ?? throw new InvalidOperationException( + "The calling thread has no WPF dispatcher. Use DispatcherSequencer.Main or Current from a UI thread, or after the Application is created."); /// Posts the drain callback, through the test hook when one was supplied. /// The drain callback. diff --git a/src/ReactiveUI.Primitives.Wpf.Reactive/PublicAPI/net10.0-windows/PublicAPI.txt b/src/ReactiveUI.Primitives.Wpf.Reactive/PublicAPI/net10.0-windows/PublicAPI.txt index f0467613..5b33c9ac 100644 --- a/src/ReactiveUI.Primitives.Wpf.Reactive/PublicAPI/net10.0-windows/PublicAPI.txt +++ b/src/ReactiveUI.Primitives.Wpf.Reactive/PublicAPI/net10.0-windows/PublicAPI.txt @@ -8,6 +8,7 @@ public sealed class DispatcherSequencer : System.Reactive.Concurrency.LocalSched public DispatcherSequencer(System.Windows.Threading.Dispatcher dispatcher) { } public DispatcherSequencer(System.Windows.Threading.Dispatcher dispatcher, System.Windows.Threading.DispatcherPriority priority) { } public System.Windows.Threading.Dispatcher Dispatcher { get; } + public static ReactiveUI.Primitives.Reactive.Concurrency.DispatcherSequencer Current { get; } public static ReactiveUI.Primitives.Reactive.Concurrency.DispatcherSequencer Main { get; } public System.Windows.Threading.DispatcherPriority Priority { get; } [System.Runtime.CompilerServices.MethodImpl(System.Runtime.CompilerServices.MethodImplOptions.AggressiveInlining)] diff --git a/src/ReactiveUI.Primitives.Wpf.Reactive/PublicAPI/net11.0-windows/PublicAPI.txt b/src/ReactiveUI.Primitives.Wpf.Reactive/PublicAPI/net11.0-windows/PublicAPI.txt index f0467613..5b33c9ac 100644 --- a/src/ReactiveUI.Primitives.Wpf.Reactive/PublicAPI/net11.0-windows/PublicAPI.txt +++ b/src/ReactiveUI.Primitives.Wpf.Reactive/PublicAPI/net11.0-windows/PublicAPI.txt @@ -8,6 +8,7 @@ public sealed class DispatcherSequencer : System.Reactive.Concurrency.LocalSched public DispatcherSequencer(System.Windows.Threading.Dispatcher dispatcher) { } public DispatcherSequencer(System.Windows.Threading.Dispatcher dispatcher, System.Windows.Threading.DispatcherPriority priority) { } public System.Windows.Threading.Dispatcher Dispatcher { get; } + public static ReactiveUI.Primitives.Reactive.Concurrency.DispatcherSequencer Current { get; } public static ReactiveUI.Primitives.Reactive.Concurrency.DispatcherSequencer Main { get; } public System.Windows.Threading.DispatcherPriority Priority { get; } [System.Runtime.CompilerServices.MethodImpl(System.Runtime.CompilerServices.MethodImplOptions.AggressiveInlining)] diff --git a/src/ReactiveUI.Primitives.Wpf.Reactive/PublicAPI/net462/PublicAPI.txt b/src/ReactiveUI.Primitives.Wpf.Reactive/PublicAPI/net462/PublicAPI.txt index e4461302..01a52dd9 100644 --- a/src/ReactiveUI.Primitives.Wpf.Reactive/PublicAPI/net462/PublicAPI.txt +++ b/src/ReactiveUI.Primitives.Wpf.Reactive/PublicAPI/net462/PublicAPI.txt @@ -7,6 +7,7 @@ public sealed class DispatcherSequencer : System.Reactive.Concurrency.LocalSched public DispatcherSequencer(System.Windows.Threading.Dispatcher dispatcher) { } public DispatcherSequencer(System.Windows.Threading.Dispatcher dispatcher, System.Windows.Threading.DispatcherPriority priority) { } public System.Windows.Threading.Dispatcher Dispatcher { get; } + public static ReactiveUI.Primitives.Reactive.Concurrency.DispatcherSequencer Current { get; } public static ReactiveUI.Primitives.Reactive.Concurrency.DispatcherSequencer Main { get; } public System.Windows.Threading.DispatcherPriority Priority { get; } [System.Runtime.CompilerServices.MethodImpl(System.Runtime.CompilerServices.MethodImplOptions.AggressiveInlining)] diff --git a/src/ReactiveUI.Primitives.Wpf.Reactive/PublicAPI/net472/PublicAPI.txt b/src/ReactiveUI.Primitives.Wpf.Reactive/PublicAPI/net472/PublicAPI.txt index e4461302..01a52dd9 100644 --- a/src/ReactiveUI.Primitives.Wpf.Reactive/PublicAPI/net472/PublicAPI.txt +++ b/src/ReactiveUI.Primitives.Wpf.Reactive/PublicAPI/net472/PublicAPI.txt @@ -7,6 +7,7 @@ public sealed class DispatcherSequencer : System.Reactive.Concurrency.LocalSched public DispatcherSequencer(System.Windows.Threading.Dispatcher dispatcher) { } public DispatcherSequencer(System.Windows.Threading.Dispatcher dispatcher, System.Windows.Threading.DispatcherPriority priority) { } public System.Windows.Threading.Dispatcher Dispatcher { get; } + public static ReactiveUI.Primitives.Reactive.Concurrency.DispatcherSequencer Current { get; } public static ReactiveUI.Primitives.Reactive.Concurrency.DispatcherSequencer Main { get; } public System.Windows.Threading.DispatcherPriority Priority { get; } [System.Runtime.CompilerServices.MethodImpl(System.Runtime.CompilerServices.MethodImplOptions.AggressiveInlining)] diff --git a/src/ReactiveUI.Primitives.Wpf.Reactive/PublicAPI/net48/PublicAPI.txt b/src/ReactiveUI.Primitives.Wpf.Reactive/PublicAPI/net48/PublicAPI.txt index e4461302..01a52dd9 100644 --- a/src/ReactiveUI.Primitives.Wpf.Reactive/PublicAPI/net48/PublicAPI.txt +++ b/src/ReactiveUI.Primitives.Wpf.Reactive/PublicAPI/net48/PublicAPI.txt @@ -7,6 +7,7 @@ public sealed class DispatcherSequencer : System.Reactive.Concurrency.LocalSched public DispatcherSequencer(System.Windows.Threading.Dispatcher dispatcher) { } public DispatcherSequencer(System.Windows.Threading.Dispatcher dispatcher, System.Windows.Threading.DispatcherPriority priority) { } public System.Windows.Threading.Dispatcher Dispatcher { get; } + public static ReactiveUI.Primitives.Reactive.Concurrency.DispatcherSequencer Current { get; } public static ReactiveUI.Primitives.Reactive.Concurrency.DispatcherSequencer Main { get; } public System.Windows.Threading.DispatcherPriority Priority { get; } [System.Runtime.CompilerServices.MethodImpl(System.Runtime.CompilerServices.MethodImplOptions.AggressiveInlining)] diff --git a/src/ReactiveUI.Primitives.Wpf.Reactive/PublicAPI/net481/PublicAPI.txt b/src/ReactiveUI.Primitives.Wpf.Reactive/PublicAPI/net481/PublicAPI.txt index e4461302..01a52dd9 100644 --- a/src/ReactiveUI.Primitives.Wpf.Reactive/PublicAPI/net481/PublicAPI.txt +++ b/src/ReactiveUI.Primitives.Wpf.Reactive/PublicAPI/net481/PublicAPI.txt @@ -7,6 +7,7 @@ public sealed class DispatcherSequencer : System.Reactive.Concurrency.LocalSched public DispatcherSequencer(System.Windows.Threading.Dispatcher dispatcher) { } public DispatcherSequencer(System.Windows.Threading.Dispatcher dispatcher, System.Windows.Threading.DispatcherPriority priority) { } public System.Windows.Threading.Dispatcher Dispatcher { get; } + public static ReactiveUI.Primitives.Reactive.Concurrency.DispatcherSequencer Current { get; } public static ReactiveUI.Primitives.Reactive.Concurrency.DispatcherSequencer Main { get; } public System.Windows.Threading.DispatcherPriority Priority { get; } [System.Runtime.CompilerServices.MethodImpl(System.Runtime.CompilerServices.MethodImplOptions.AggressiveInlining)] diff --git a/src/ReactiveUI.Primitives.Wpf.Reactive/PublicAPI/net8.0-windows/PublicAPI.txt b/src/ReactiveUI.Primitives.Wpf.Reactive/PublicAPI/net8.0-windows/PublicAPI.txt index f0467613..5b33c9ac 100644 --- a/src/ReactiveUI.Primitives.Wpf.Reactive/PublicAPI/net8.0-windows/PublicAPI.txt +++ b/src/ReactiveUI.Primitives.Wpf.Reactive/PublicAPI/net8.0-windows/PublicAPI.txt @@ -8,6 +8,7 @@ public sealed class DispatcherSequencer : System.Reactive.Concurrency.LocalSched public DispatcherSequencer(System.Windows.Threading.Dispatcher dispatcher) { } public DispatcherSequencer(System.Windows.Threading.Dispatcher dispatcher, System.Windows.Threading.DispatcherPriority priority) { } public System.Windows.Threading.Dispatcher Dispatcher { get; } + public static ReactiveUI.Primitives.Reactive.Concurrency.DispatcherSequencer Current { get; } public static ReactiveUI.Primitives.Reactive.Concurrency.DispatcherSequencer Main { get; } public System.Windows.Threading.DispatcherPriority Priority { get; } [System.Runtime.CompilerServices.MethodImpl(System.Runtime.CompilerServices.MethodImplOptions.AggressiveInlining)] diff --git a/src/ReactiveUI.Primitives.Wpf.Reactive/PublicAPI/net9.0-windows/PublicAPI.txt b/src/ReactiveUI.Primitives.Wpf.Reactive/PublicAPI/net9.0-windows/PublicAPI.txt index f0467613..5b33c9ac 100644 --- a/src/ReactiveUI.Primitives.Wpf.Reactive/PublicAPI/net9.0-windows/PublicAPI.txt +++ b/src/ReactiveUI.Primitives.Wpf.Reactive/PublicAPI/net9.0-windows/PublicAPI.txt @@ -8,6 +8,7 @@ public sealed class DispatcherSequencer : System.Reactive.Concurrency.LocalSched public DispatcherSequencer(System.Windows.Threading.Dispatcher dispatcher) { } public DispatcherSequencer(System.Windows.Threading.Dispatcher dispatcher, System.Windows.Threading.DispatcherPriority priority) { } public System.Windows.Threading.Dispatcher Dispatcher { get; } + public static ReactiveUI.Primitives.Reactive.Concurrency.DispatcherSequencer Current { get; } public static ReactiveUI.Primitives.Reactive.Concurrency.DispatcherSequencer Main { get; } public System.Windows.Threading.DispatcherPriority Priority { get; } [System.Runtime.CompilerServices.MethodImpl(System.Runtime.CompilerServices.MethodImplOptions.AggressiveInlining)] diff --git a/src/ReactiveUI.Primitives.Wpf/Concurrency/DispatcherSequencer.cs b/src/ReactiveUI.Primitives.Wpf/Concurrency/DispatcherSequencer.cs index ced306d0..351c91f2 100644 --- a/src/ReactiveUI.Primitives.Wpf/Concurrency/DispatcherSequencer.cs +++ b/src/ReactiveUI.Primitives.Wpf/Concurrency/DispatcherSequencer.cs @@ -14,8 +14,12 @@ namespace ReactiveUI.Primitives.Concurrency; [System.Diagnostics.DebuggerDisplay("{DebuggerDisplay,nq}")] public sealed class DispatcherSequencer : ISequencer { - /// Lazily binds the shared main-thread sequencer to the application's dispatcher on first use. - private static readonly Lazy LazyMain = new(static () => new(ResolveMainDispatcher())); + /// The shared main-thread sequencer, set once the application's dispatcher is available. + private static DispatcherSequencer? _main; + + /// The sequencer for the calling thread's dispatcher, cached per thread. + [ThreadStatic] + private static DispatcherSequencer? _current; /// Coalescing dispatch engine. private DispatchSequencerState _state; @@ -55,11 +59,21 @@ internal DispatcherSequencer( } /// Gets the shared sequencer for the WPF main (UI) thread. + /// No application exists yet and the calling thread has no dispatcher. /// - /// Bound on first access to the running 's dispatcher, or to the calling - /// thread's dispatcher when no application exists yet. + /// Bound once to the running 's dispatcher, from any thread. Until an + /// application exists nothing is cached, and the calling thread's existing dispatcher is used instead. /// - public static DispatcherSequencer Main => LazyMain.Value; + public static DispatcherSequencer Main => + Volatile.Read(ref _main) ?? BindMain(System.Windows.Application.Current?.Dispatcher) ?? 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 DispatcherSequencer Current => _current ??= new(ResolveCurrentDispatcher()); /// Gets the dispatcher whose thread runs the scheduled work. public Dispatcher Dispatcher { get; } @@ -85,10 +99,27 @@ internal DispatcherSequencer( [MethodImpl(MethodImplOptions.AggressiveInlining)] public void Schedule(IWorkItem item, long dueTimestamp) => _state.Schedule(item, dueTimestamp); - /// Resolves the dispatcher that owns the WPF main (UI) thread. - /// The application's dispatcher, or the calling thread's dispatcher when no application exists yet. - internal static Dispatcher ResolveMainDispatcher() => - System.Windows.Application.Current?.Dispatcher ?? Dispatcher.CurrentDispatcher; + /// Caches the shared main-thread sequencer for the application's dispatcher, keeping the first one bound. + /// The application's dispatcher, or when no application exists. + /// The shared sequencer, or when is . + internal static DispatcherSequencer? BindMain(Dispatcher? applicationDispatcher) + { + if (applicationDispatcher is null) + { + return null; + } + + DispatcherSequencer created = new(applicationDispatcher); + return Interlocked.CompareExchange(ref _main, created, null) ?? created; + } + + /// Returns the calling thread's existing dispatcher without creating one. + /// The calling thread's dispatcher. + /// The calling thread has no dispatcher. + internal static Dispatcher ResolveCurrentDispatcher() => + Dispatcher.FromThread(Thread.CurrentThread) + ?? throw new InvalidOperationException( + "The calling thread has no WPF dispatcher. Use DispatcherSequencer.Main or Current from a UI thread, or after the Application is created."); /// Marshals the cached drain callback onto the dispatcher. /// The drain callback. diff --git a/src/ReactiveUI.Primitives.Wpf/PublicAPI/net10.0-windows/PublicAPI.txt b/src/ReactiveUI.Primitives.Wpf/PublicAPI/net10.0-windows/PublicAPI.txt index 9925a04b..b3498857 100644 --- a/src/ReactiveUI.Primitives.Wpf/PublicAPI/net10.0-windows/PublicAPI.txt +++ b/src/ReactiveUI.Primitives.Wpf/PublicAPI/net10.0-windows/PublicAPI.txt @@ -8,6 +8,7 @@ public sealed class DispatcherSequencer : ReactiveUI.Primitives.Concurrency.ISeq public DispatcherSequencer(System.Windows.Threading.Dispatcher dispatcher) { } public DispatcherSequencer(System.Windows.Threading.Dispatcher dispatcher, System.Windows.Threading.DispatcherPriority priority) { } public System.Windows.Threading.Dispatcher Dispatcher { get; } + public static ReactiveUI.Primitives.Concurrency.DispatcherSequencer Current { get; } public static ReactiveUI.Primitives.Concurrency.DispatcherSequencer Main { get; } public System.DateTimeOffset Now { get; } public System.Windows.Threading.DispatcherPriority Priority { get; } diff --git a/src/ReactiveUI.Primitives.Wpf/PublicAPI/net11.0-windows/PublicAPI.txt b/src/ReactiveUI.Primitives.Wpf/PublicAPI/net11.0-windows/PublicAPI.txt index 9925a04b..b3498857 100644 --- a/src/ReactiveUI.Primitives.Wpf/PublicAPI/net11.0-windows/PublicAPI.txt +++ b/src/ReactiveUI.Primitives.Wpf/PublicAPI/net11.0-windows/PublicAPI.txt @@ -8,6 +8,7 @@ public sealed class DispatcherSequencer : ReactiveUI.Primitives.Concurrency.ISeq public DispatcherSequencer(System.Windows.Threading.Dispatcher dispatcher) { } public DispatcherSequencer(System.Windows.Threading.Dispatcher dispatcher, System.Windows.Threading.DispatcherPriority priority) { } public System.Windows.Threading.Dispatcher Dispatcher { get; } + public static ReactiveUI.Primitives.Concurrency.DispatcherSequencer Current { get; } public static ReactiveUI.Primitives.Concurrency.DispatcherSequencer Main { get; } public System.DateTimeOffset Now { get; } public System.Windows.Threading.DispatcherPriority Priority { get; } diff --git a/src/ReactiveUI.Primitives.Wpf/PublicAPI/net462/PublicAPI.txt b/src/ReactiveUI.Primitives.Wpf/PublicAPI/net462/PublicAPI.txt index 01c320ae..38c3d4b4 100644 --- a/src/ReactiveUI.Primitives.Wpf/PublicAPI/net462/PublicAPI.txt +++ b/src/ReactiveUI.Primitives.Wpf/PublicAPI/net462/PublicAPI.txt @@ -7,6 +7,7 @@ public sealed class DispatcherSequencer : ReactiveUI.Primitives.Concurrency.ISeq public DispatcherSequencer(System.Windows.Threading.Dispatcher dispatcher) { } public DispatcherSequencer(System.Windows.Threading.Dispatcher dispatcher, System.Windows.Threading.DispatcherPriority priority) { } public System.Windows.Threading.Dispatcher Dispatcher { get; } + public static ReactiveUI.Primitives.Concurrency.DispatcherSequencer Current { get; } public static ReactiveUI.Primitives.Concurrency.DispatcherSequencer Main { get; } public System.DateTimeOffset Now { get; } public System.Windows.Threading.DispatcherPriority Priority { get; } diff --git a/src/ReactiveUI.Primitives.Wpf/PublicAPI/net472/PublicAPI.txt b/src/ReactiveUI.Primitives.Wpf/PublicAPI/net472/PublicAPI.txt index 01c320ae..38c3d4b4 100644 --- a/src/ReactiveUI.Primitives.Wpf/PublicAPI/net472/PublicAPI.txt +++ b/src/ReactiveUI.Primitives.Wpf/PublicAPI/net472/PublicAPI.txt @@ -7,6 +7,7 @@ public sealed class DispatcherSequencer : ReactiveUI.Primitives.Concurrency.ISeq public DispatcherSequencer(System.Windows.Threading.Dispatcher dispatcher) { } public DispatcherSequencer(System.Windows.Threading.Dispatcher dispatcher, System.Windows.Threading.DispatcherPriority priority) { } public System.Windows.Threading.Dispatcher Dispatcher { get; } + public static ReactiveUI.Primitives.Concurrency.DispatcherSequencer Current { get; } public static ReactiveUI.Primitives.Concurrency.DispatcherSequencer Main { get; } public System.DateTimeOffset Now { get; } public System.Windows.Threading.DispatcherPriority Priority { get; } diff --git a/src/ReactiveUI.Primitives.Wpf/PublicAPI/net48/PublicAPI.txt b/src/ReactiveUI.Primitives.Wpf/PublicAPI/net48/PublicAPI.txt index 01c320ae..38c3d4b4 100644 --- a/src/ReactiveUI.Primitives.Wpf/PublicAPI/net48/PublicAPI.txt +++ b/src/ReactiveUI.Primitives.Wpf/PublicAPI/net48/PublicAPI.txt @@ -7,6 +7,7 @@ public sealed class DispatcherSequencer : ReactiveUI.Primitives.Concurrency.ISeq public DispatcherSequencer(System.Windows.Threading.Dispatcher dispatcher) { } public DispatcherSequencer(System.Windows.Threading.Dispatcher dispatcher, System.Windows.Threading.DispatcherPriority priority) { } public System.Windows.Threading.Dispatcher Dispatcher { get; } + public static ReactiveUI.Primitives.Concurrency.DispatcherSequencer Current { get; } public static ReactiveUI.Primitives.Concurrency.DispatcherSequencer Main { get; } public System.DateTimeOffset Now { get; } public System.Windows.Threading.DispatcherPriority Priority { get; } diff --git a/src/ReactiveUI.Primitives.Wpf/PublicAPI/net481/PublicAPI.txt b/src/ReactiveUI.Primitives.Wpf/PublicAPI/net481/PublicAPI.txt index 01c320ae..38c3d4b4 100644 --- a/src/ReactiveUI.Primitives.Wpf/PublicAPI/net481/PublicAPI.txt +++ b/src/ReactiveUI.Primitives.Wpf/PublicAPI/net481/PublicAPI.txt @@ -7,6 +7,7 @@ public sealed class DispatcherSequencer : ReactiveUI.Primitives.Concurrency.ISeq public DispatcherSequencer(System.Windows.Threading.Dispatcher dispatcher) { } public DispatcherSequencer(System.Windows.Threading.Dispatcher dispatcher, System.Windows.Threading.DispatcherPriority priority) { } public System.Windows.Threading.Dispatcher Dispatcher { get; } + public static ReactiveUI.Primitives.Concurrency.DispatcherSequencer Current { get; } public static ReactiveUI.Primitives.Concurrency.DispatcherSequencer Main { get; } public System.DateTimeOffset Now { get; } public System.Windows.Threading.DispatcherPriority Priority { get; } diff --git a/src/ReactiveUI.Primitives.Wpf/PublicAPI/net8.0-windows/PublicAPI.txt b/src/ReactiveUI.Primitives.Wpf/PublicAPI/net8.0-windows/PublicAPI.txt index 9925a04b..b3498857 100644 --- a/src/ReactiveUI.Primitives.Wpf/PublicAPI/net8.0-windows/PublicAPI.txt +++ b/src/ReactiveUI.Primitives.Wpf/PublicAPI/net8.0-windows/PublicAPI.txt @@ -8,6 +8,7 @@ public sealed class DispatcherSequencer : ReactiveUI.Primitives.Concurrency.ISeq public DispatcherSequencer(System.Windows.Threading.Dispatcher dispatcher) { } public DispatcherSequencer(System.Windows.Threading.Dispatcher dispatcher, System.Windows.Threading.DispatcherPriority priority) { } public System.Windows.Threading.Dispatcher Dispatcher { get; } + public static ReactiveUI.Primitives.Concurrency.DispatcherSequencer Current { get; } public static ReactiveUI.Primitives.Concurrency.DispatcherSequencer Main { get; } public System.DateTimeOffset Now { get; } public System.Windows.Threading.DispatcherPriority Priority { get; } diff --git a/src/ReactiveUI.Primitives.Wpf/PublicAPI/net9.0-windows/PublicAPI.txt b/src/ReactiveUI.Primitives.Wpf/PublicAPI/net9.0-windows/PublicAPI.txt index 9925a04b..b3498857 100644 --- a/src/ReactiveUI.Primitives.Wpf/PublicAPI/net9.0-windows/PublicAPI.txt +++ b/src/ReactiveUI.Primitives.Wpf/PublicAPI/net9.0-windows/PublicAPI.txt @@ -8,6 +8,7 @@ public sealed class DispatcherSequencer : ReactiveUI.Primitives.Concurrency.ISeq public DispatcherSequencer(System.Windows.Threading.Dispatcher dispatcher) { } public DispatcherSequencer(System.Windows.Threading.Dispatcher dispatcher, System.Windows.Threading.DispatcherPriority priority) { } public System.Windows.Threading.Dispatcher Dispatcher { get; } + public static ReactiveUI.Primitives.Concurrency.DispatcherSequencer Current { get; } public static ReactiveUI.Primitives.Concurrency.DispatcherSequencer Main { get; } public System.DateTimeOffset Now { get; } public System.Windows.Threading.DispatcherPriority Priority { get; } diff --git a/src/tests/ReactiveUI.Primitives.Wpf.Reactive.Tests/DispatcherSequencerTests.cs b/src/tests/ReactiveUI.Primitives.Wpf.Reactive.Tests/DispatcherSequencerTests.cs index 2c83701f..c479102c 100644 --- a/src/tests/ReactiveUI.Primitives.Wpf.Reactive.Tests/DispatcherSequencerTests.cs +++ b/src/tests/ReactiveUI.Primitives.Wpf.Reactive.Tests/DispatcherSequencerTests.cs @@ -38,26 +38,55 @@ public async Task ConstructorRetainsDispatcherAndPriority() await Assert.That(new DispatcherSequencer(dispatcher).Priority).IsEqualTo(DispatcherPriority.Normal); } - /// The shared main-thread scheduler is a single instance bound to a dispatcher at normal priority. + /// A thread without a dispatcher gets an error rather than a scheduler that could never run work. /// The test operation. [Test] - public async Task MainIsSharedAndBoundToADispatcher() + public async Task CurrentThrowsWhenTheThreadHasNoDispatcher() => + await Assert.That(static () => RunOnNewThread(static () => DispatcherSequencer.Current)) + .ThrowsExactly(); + + /// Each thread gets its own cached scheduler bound to that thread's dispatcher. + /// The test operation. + [Test] + public async Task CurrentIsCachedPerThreadAndBoundToThatThreadsDispatcher() { - var main = DispatcherSequencer.Main; - await Assert.That(DispatcherSequencer.Main).IsSameReferenceAs(main); - await Assert.That(main.Dispatcher).IsNotNull(); - await Assert.That(main.Priority).IsEqualTo(DispatcherPriority.Normal); + var first = await RunOnNewThread(CaptureCurrent); + var second = await RunOnNewThread(CaptureCurrent); + + await Assert.That(first.Repeat).IsSameReferenceAs(first.Sequencer); + await Assert.That(first.Sequencer.Dispatcher).IsSameReferenceAs(first.Dispatcher); + await Assert.That(first.Sequencer.Priority).IsEqualTo(DispatcherPriority.Normal); + await Assert.That(second.Sequencer).IsNotSameReferenceAs(first.Sequencer); + await Assert.That(second.Sequencer.Dispatcher).IsSameReferenceAs(second.Dispatcher); } - /// Without a running application the main dispatcher falls back to the calling thread's dispatcher. + /// Before an application exists, Main uses the calling thread's dispatcher without caching it. /// The test operation. [Test] - public async Task ResolveMainDispatcherFallsBackToCurrentDispatcher() + public async Task MainFallsBackToCurrentBeforeAnApplicationExists() { - var expected = System.Windows.Application.Current?.Dispatcher ?? Dispatcher.CurrentDispatcher; - await Assert.That(DispatcherSequencer.ResolveMainDispatcher()).IsSameReferenceAs(expected); + if (System.Windows.Application.Current is not null) + { + return; + } + + var captured = await RunOnNewThread(static () => + { + _ = Dispatcher.CurrentDispatcher; + return (DispatcherSequencer.Main, DispatcherSequencer.Current); + }); + + await Assert.That(captured.Main).IsSameReferenceAs(captured.Current); + await Assert.That(static () => RunOnNewThread(static () => DispatcherSequencer.Main)) + .ThrowsExactly(); } + /// Without an application dispatcher there is nothing to bind. + /// The test operation. + [Test] + public async Task BindMainReturnsNullWithoutAnApplicationDispatcher() => + await Assert.That(DispatcherSequencer.BindMain(null)).IsNull(); + /// A posted batch preserves order and skips cancelled work. /// The test operation. [Test] @@ -151,4 +180,32 @@ public async Task DelayedScheduleHonorsCancellationBeforeDelivery(bool cancel) handle.Dispose(); } + + /// Gives the calling thread a dispatcher, then reads twice. + /// The thread's dispatcher and both reads. + private static (Dispatcher Dispatcher, DispatcherSequencer Sequencer, DispatcherSequencer Repeat) CaptureCurrent() => + (Dispatcher.CurrentDispatcher, DispatcherSequencer.Current, DispatcherSequencer.Current); + + /// 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 RunOnNewThread(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; + } } diff --git a/src/tests/ReactiveUI.Primitives.Wpf.Tests/DispatcherSequencerTests.cs b/src/tests/ReactiveUI.Primitives.Wpf.Tests/DispatcherSequencerTests.cs index 8b060fb0..ec7489e9 100644 --- a/src/tests/ReactiveUI.Primitives.Wpf.Tests/DispatcherSequencerTests.cs +++ b/src/tests/ReactiveUI.Primitives.Wpf.Tests/DispatcherSequencerTests.cs @@ -41,26 +41,55 @@ public async Task ConstructorRetainsDispatcherAndPriority() await Assert.That(sequencer.DebuggerDisplay).IsEqualTo(typeof(DispatcherSequencer).FullName); } - /// The shared main-thread sequencer is a single instance bound to a dispatcher at normal priority. + /// A thread without a dispatcher gets an error rather than a sequencer that could never run work. /// The test operation. [Test] - public async Task MainIsSharedAndBoundToADispatcher() + public async Task CurrentThrowsWhenTheThreadHasNoDispatcher() => + await Assert.That(static () => RunOnNewThread(static () => DispatcherSequencer.Current)) + .ThrowsExactly(); + + /// Each thread gets its own cached sequencer bound to that thread's dispatcher. + /// The test operation. + [Test] + public async Task CurrentIsCachedPerThreadAndBoundToThatThreadsDispatcher() { - var main = DispatcherSequencer.Main; - await Assert.That(DispatcherSequencer.Main).IsSameReferenceAs(main); - await Assert.That(main.Dispatcher).IsNotNull(); - await Assert.That(main.Priority).IsEqualTo(DispatcherPriority.Normal); + var first = await RunOnNewThread(CaptureCurrent); + var second = await RunOnNewThread(CaptureCurrent); + + await Assert.That(first.Repeat).IsSameReferenceAs(first.Sequencer); + await Assert.That(first.Sequencer.Dispatcher).IsSameReferenceAs(first.Dispatcher); + await Assert.That(first.Sequencer.Priority).IsEqualTo(DispatcherPriority.Normal); + await Assert.That(second.Sequencer).IsNotSameReferenceAs(first.Sequencer); + await Assert.That(second.Sequencer.Dispatcher).IsSameReferenceAs(second.Dispatcher); } - /// Without a running application the main dispatcher falls back to the calling thread's dispatcher. + /// Before an application exists, Main uses the calling thread's dispatcher without caching it. /// The test operation. [Test] - public async Task ResolveMainDispatcherFallsBackToCurrentDispatcher() + public async Task MainFallsBackToCurrentBeforeAnApplicationExists() { - var expected = System.Windows.Application.Current?.Dispatcher ?? Dispatcher.CurrentDispatcher; - await Assert.That(DispatcherSequencer.ResolveMainDispatcher()).IsSameReferenceAs(expected); + if (System.Windows.Application.Current is not null) + { + return; + } + + var captured = await RunOnNewThread(static () => + { + _ = Dispatcher.CurrentDispatcher; + return (DispatcherSequencer.Main, DispatcherSequencer.Current); + }); + + await Assert.That(captured.Main).IsSameReferenceAs(captured.Current); + await Assert.That(static () => RunOnNewThread(static () => DispatcherSequencer.Main)) + .ThrowsExactly(); } + /// Without an application dispatcher there is nothing to bind. + /// The test operation. + [Test] + public async Task BindMainReturnsNullWithoutAnApplicationDispatcher() => + await Assert.That(DispatcherSequencer.BindMain(null)).IsNull(); + /// The dispatcher sequencer shares the monotonic timestamp scale used by scheduled work. /// A task representing the asynchronous test. [Test] @@ -158,6 +187,34 @@ public async Task DelayedScheduleWaitsForItsCallback(bool cancel) await Assert.That(calls).IsEqualTo(cancel ? 0 : 1); } + /// Gives the calling thread a dispatcher, then reads twice. + /// The thread's dispatcher and both reads. + private static (Dispatcher Dispatcher, DispatcherSequencer Sequencer, DispatcherSequencer Repeat) CaptureCurrent() => + (Dispatcher.CurrentDispatcher, DispatcherSequencer.Current, DispatcherSequencer.Current); + + /// 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 RunOnNewThread(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; + } + /// Records execution and supports cancellation. /// The callback to run. private sealed class RecordingWorkItem(Action action) : IWorkItem, IsDisposed From 4ef6bca2c62708b98c1b0c30193bf430ce35814d Mon Sep 17 00:00:00 2001 From: Glenn Watson <5834289+glennawatson@users.noreply.github.com> Date: Thu, 24 Sep 2026 14:23:04 +1000 Subject: [PATCH 3/4] test(wpf): cover binding the main dispatcher sequencer --- .../Concurrency/DispatcherSequencer.cs | 7 +++-- .../Concurrency/DispatcherSequencer.cs | 7 +++-- .../DispatcherSequencerTests.cs | 28 +++++++++++++++++-- .../DispatcherSequencerTests.cs | 28 +++++++++++++++++-- 4 files changed, 58 insertions(+), 12 deletions(-) diff --git a/src/ReactiveUI.Primitives.Wpf.Reactive/Concurrency/DispatcherSequencer.cs b/src/ReactiveUI.Primitives.Wpf.Reactive/Concurrency/DispatcherSequencer.cs index 2c8f3ce7..3b6b4917 100644 --- a/src/ReactiveUI.Primitives.Wpf.Reactive/Concurrency/DispatcherSequencer.cs +++ b/src/ReactiveUI.Primitives.Wpf.Reactive/Concurrency/DispatcherSequencer.cs @@ -74,7 +74,7 @@ internal DispatcherSequencer( /// application exists nothing is cached, and the calling thread's existing dispatcher is used instead. /// public static DispatcherSequencer Main => - Volatile.Read(ref _main) ?? BindMain(System.Windows.Application.Current?.Dispatcher) ?? Current; + Volatile.Read(ref _main) ?? BindMain(ref _main, System.Windows.Application.Current?.Dispatcher) ?? Current; /// Gets the scheduler for the calling thread's dispatcher. /// The calling thread has no dispatcher. @@ -103,9 +103,10 @@ public override IDisposable Schedule(TState state, TimeSpan dueTime, Fun _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 DispatcherSequencer? BindMain(Dispatcher? applicationDispatcher) + internal static DispatcherSequencer? BindMain(ref DispatcherSequencer? slot, Dispatcher? applicationDispatcher) { if (applicationDispatcher is null) { @@ -113,7 +114,7 @@ public override IDisposable Schedule(TState state, TimeSpan dueTime, Fun } DispatcherSequencer created = new(applicationDispatcher); - return Interlocked.CompareExchange(ref _main, created, null) ?? created; + return Interlocked.CompareExchange(ref slot, created, null) ?? created; } /// Returns the calling thread's existing dispatcher without creating one. diff --git a/src/ReactiveUI.Primitives.Wpf/Concurrency/DispatcherSequencer.cs b/src/ReactiveUI.Primitives.Wpf/Concurrency/DispatcherSequencer.cs index 351c91f2..f5a670b3 100644 --- a/src/ReactiveUI.Primitives.Wpf/Concurrency/DispatcherSequencer.cs +++ b/src/ReactiveUI.Primitives.Wpf/Concurrency/DispatcherSequencer.cs @@ -65,7 +65,7 @@ internal DispatcherSequencer( /// application exists nothing is cached, and the calling thread's existing dispatcher is used instead. /// public static DispatcherSequencer Main => - Volatile.Read(ref _main) ?? BindMain(System.Windows.Application.Current?.Dispatcher) ?? Current; + Volatile.Read(ref _main) ?? BindMain(ref _main, System.Windows.Application.Current?.Dispatcher) ?? Current; /// Gets the sequencer for the calling thread's dispatcher. /// The calling thread has no dispatcher. @@ -100,9 +100,10 @@ internal DispatcherSequencer( 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 DispatcherSequencer? BindMain(Dispatcher? applicationDispatcher) + internal static DispatcherSequencer? BindMain(ref DispatcherSequencer? slot, Dispatcher? applicationDispatcher) { if (applicationDispatcher is null) { @@ -110,7 +111,7 @@ internal DispatcherSequencer( } DispatcherSequencer created = new(applicationDispatcher); - return Interlocked.CompareExchange(ref _main, created, null) ?? created; + return Interlocked.CompareExchange(ref slot, created, null) ?? created; } /// Returns the calling thread's existing dispatcher without creating one. diff --git a/src/tests/ReactiveUI.Primitives.Wpf.Reactive.Tests/DispatcherSequencerTests.cs b/src/tests/ReactiveUI.Primitives.Wpf.Reactive.Tests/DispatcherSequencerTests.cs index c479102c..53d858c8 100644 --- a/src/tests/ReactiveUI.Primitives.Wpf.Reactive.Tests/DispatcherSequencerTests.cs +++ b/src/tests/ReactiveUI.Primitives.Wpf.Reactive.Tests/DispatcherSequencerTests.cs @@ -81,11 +81,33 @@ await Assert.That(static () => RunOnNewThread(static () => .ThrowsExactly(); } - /// Without an application dispatcher there is nothing to bind. + /// Without an application dispatcher there is nothing to bind, and nothing is cached. /// The test operation. [Test] - public async Task BindMainReturnsNullWithoutAnApplicationDispatcher() => - await Assert.That(DispatcherSequencer.BindMain(null)).IsNull(); + public async Task BindMainReturnsNullWithoutAnApplicationDispatcher() + { + DispatcherSequencer? slot = null; + await Assert.That(DispatcherSequencer.BindMain(ref slot, null)).IsNull(); + await Assert.That(slot).IsNull(); + } + + /// The first application dispatcher bound stays bound. + /// The test operation. + [Test] + public async Task BindMainKeepsTheFirstBinding() + { + var first = await RunOnNewThread(static () => Dispatcher.CurrentDispatcher); + var second = await RunOnNewThread(static () => Dispatcher.CurrentDispatcher); + DispatcherSequencer? slot = null; + + var bound = DispatcherSequencer.BindMain(ref slot, first); + var rebound = DispatcherSequencer.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); + } /// A posted batch preserves order and skips cancelled work. /// The test operation. diff --git a/src/tests/ReactiveUI.Primitives.Wpf.Tests/DispatcherSequencerTests.cs b/src/tests/ReactiveUI.Primitives.Wpf.Tests/DispatcherSequencerTests.cs index ec7489e9..75f95a28 100644 --- a/src/tests/ReactiveUI.Primitives.Wpf.Tests/DispatcherSequencerTests.cs +++ b/src/tests/ReactiveUI.Primitives.Wpf.Tests/DispatcherSequencerTests.cs @@ -84,11 +84,33 @@ await Assert.That(static () => RunOnNewThread(static () => .ThrowsExactly(); } - /// Without an application dispatcher there is nothing to bind. + /// Without an application dispatcher there is nothing to bind, and nothing is cached. /// The test operation. [Test] - public async Task BindMainReturnsNullWithoutAnApplicationDispatcher() => - await Assert.That(DispatcherSequencer.BindMain(null)).IsNull(); + public async Task BindMainReturnsNullWithoutAnApplicationDispatcher() + { + DispatcherSequencer? slot = null; + await Assert.That(DispatcherSequencer.BindMain(ref slot, null)).IsNull(); + await Assert.That(slot).IsNull(); + } + + /// The first application dispatcher bound stays bound. + /// The test operation. + [Test] + public async Task BindMainKeepsTheFirstBinding() + { + var first = await RunOnNewThread(static () => Dispatcher.CurrentDispatcher); + var second = await RunOnNewThread(static () => Dispatcher.CurrentDispatcher); + DispatcherSequencer? slot = null; + + var bound = DispatcherSequencer.BindMain(ref slot, first); + var rebound = DispatcherSequencer.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); + } /// The dispatcher sequencer shares the monotonic timestamp scale used by scheduled work. /// A task representing the asynchronous test. From 1b3cd8e34b91ebac9182bf84d4f3b73620f40e1d Mon Sep 17 00:00:00 2001 From: Glenn Watson <5834289+glennawatson@users.noreply.github.com> Date: Thu, 24 Sep 2026 14:31:12 +1000 Subject: [PATCH 4/4] chore(deps): update NuGet packages to their latest releases --- src/Directory.Packages.props | 10 +++++----- 1 file changed, 5 insertions(+), 5 deletions(-) diff --git a/src/Directory.Packages.props b/src/Directory.Packages.props index db6a750d..ec88dcba 100644 --- a/src/Directory.Packages.props +++ b/src/Directory.Packages.props @@ -5,8 +5,8 @@ true - 5.1.1 - 12.1.2 + 5.1.2 + 12.1.3 @@ -21,8 +21,8 @@ - - + + @@ -68,7 +68,7 @@ - +