From 5c152d844cd519b06ef6f9d3973c5b28d7ca8b66 Mon Sep 17 00:00:00 2001
From: Chris Pulman <chris.pulman@yahoo.com>
Date: Mon, 4 May 2026 14:06:18 +0100
Subject: [PATCH 1/2] Fix interaction async handler scheduling

---
 src/ReactiveUI/Interactions/Interaction.cs    |  49 ++++++-
 .../ReactiveUI.Tests/InteractionsTest.cs      | 131 +++++++++++++-----
 2 files changed, 142 insertions(+), 38 deletions(-)

diff --git a/src/ReactiveUI/Interactions/Interaction.cs b/src/ReactiveUI/Interactions/Interaction.cs
index 97a633f63b..f050ae24bf 100644
--- a/src/ReactiveUI/Interactions/Interaction.cs
+++ b/src/ReactiveUI/Interactions/Interaction.cs
@@ -87,11 +87,13 @@ public IDisposable RegisterHandler(Action<IInteractionContext<TInput, TOutput>>
     {
         ArgumentExceptionHelper.ThrowIfNull(handler);
 
-        return RegisterHandler(interaction =>
+        IObservable<Unit> ContentHandler(IInteractionContext<TInput, TOutput> interaction)
         {
             handler(interaction);
             return Observables.Unit;
-        });
+        }
+
+        return RegisterHandlerCore(ContentHandler);
     }
 
     /// <inheritdoc />
@@ -99,7 +101,15 @@ public IDisposable RegisterHandler(Func<IInteractionContext<TInput, TOutput>, Ta
     {
         ArgumentExceptionHelper.ThrowIfNull(handler);
 
-        return RegisterHandler(interaction => handler(interaction).ToObservable());
+        IObservable<Unit> ContentHandler(IInteractionContext<TInput, TOutput> interaction) =>
+            Observable.FromAsync(
+                async () =>
+                {
+                    await YieldToCurrentContext();
+                    await handler(interaction).ConfigureAwait(false);
+                });
+
+        return RegisterHandlerCore(ContentHandler);
     }
 
     /// <inheritdoc />
@@ -107,10 +117,16 @@ public IDisposable RegisterHandler<TDontCare>(Func<IInteractionContext<TInput, T
     {
         ArgumentExceptionHelper.ThrowIfNull(handler);
 
-        IObservable<Unit> ContentHandler(IInteractionContext<TInput, TOutput> context) => handler(context).Select(_ => Unit.Default);
+        IObservable<Unit> ContentHandler(IInteractionContext<TInput, TOutput> context) =>
+            Observable.FromAsync(
+                    async () =>
+                    {
+                        await YieldToCurrentContext();
+                        return handler(context);
+                    })
+                .SelectMany(result => result.Select(_ => Unit.Default));
 
-        AddHandler(ContentHandler);
-        return Disposable.Create(() => RemoveHandler(ContentHandler));
+        return RegisterHandlerCore(ContentHandler);
     }
 
     /// <inheritdoc />
@@ -153,6 +169,27 @@ protected Func<IInteractionContext<TInput, TOutput>, IObservable<Unit>>[] GetHan
     /// <returns>The interaction context.</returns>
     protected virtual IOutputContext<TInput, TOutput> GenerateContext(TInput input) => new InteractionContext<TInput, TOutput>(input);
 
+    /// <summary>
+    /// Yields once so asynchronous handlers are not invoked inside the current scheduler trampoline.
+    /// </summary>
+    /// <returns>A task that completes after the current context has yielded.</returns>
+    private static async Task YieldToCurrentContext()
+    {
+        await Task.Yield();
+    }
+
+    /// <summary>
+    /// Registers a normalized interaction handler.
+    /// </summary>
+    /// <param name="contentHandler">The normalized handler.</param>
+    /// <returns>A disposable which unregisters the handler.</returns>
+    private IDisposable RegisterHandlerCore(Func<IInteractionContext<TInput, TOutput>, IObservable<Unit>> contentHandler)
+    {
+        ArgumentExceptionHelper.ThrowIfNull(contentHandler);
+        AddHandler(contentHandler);
+        return Disposable.Create(() => RemoveHandler(contentHandler));
+    }
+
     /// <summary>
     /// Adds a handler delegate to be invoked for interaction contexts.
     /// </summary>
diff --git a/src/tests/ReactiveUI.Tests/InteractionsTest.cs b/src/tests/ReactiveUI.Tests/InteractionsTest.cs
index 726f8117b5..8446411e39 100644
--- a/src/tests/ReactiveUI.Tests/InteractionsTest.cs
+++ b/src/tests/ReactiveUI.Tests/InteractionsTest.cs
@@ -10,6 +10,7 @@ namespace ReactiveUI.Tests;
 /// <summary>
 ///     Tests interactions.
 /// </summary>
+[NotInParallel]
 public class InteractionsTest
 {
     /// <summary>
@@ -23,8 +24,8 @@ public async Task AttemptingToGetInteractionOutputBeforeItHasBeenSetShouldCauseE
 
         interaction.RegisterHandler(context => { _ = ((InteractionContext<Unit, Unit>)context).GetOutput(); });
 
-        var ex = Assert.Throws<InvalidOperationException>(() => interaction.Handle(Unit.Default).Subscribe());
-        await Assert.That(ex.Message).IsEqualTo("Output has not been set.");
+        var ex = await Assert.ThrowsAsync<InvalidOperationException>(() => interaction.Handle(Unit.Default).ToTask());
+        await Assert.That(ex!.Message).IsEqualTo("Output has not been set.");
     }
 
     /// <summary>
@@ -42,20 +43,21 @@ public async Task AttemptingToSetInteractionOutputMoreThanOnceShouldCauseExcepti
             context.SetOutput(Unit.Default);
         });
 
-        var ex = Assert.Throws<InvalidOperationException>(() => interaction.Handle(Unit.Default).Subscribe());
-        await Assert.That(ex.Message).IsEqualTo("Output has already been set.");
+        var ex = await Assert.ThrowsAsync<InvalidOperationException>(() => interaction.Handle(Unit.Default).ToTask());
+        await Assert.That(ex!.Message).IsEqualTo("Output has already been set.");
     }
 
     /// <summary>
     ///     Tests that Handled interactions should not cause exception.
     /// </summary>
+    /// <returns>A <see cref="Task" /> representing the asynchronous operation.</returns>
     [Test]
-    public void HandledInteractionsShouldNotCauseException()
+    public async Task HandledInteractionsShouldNotCauseException()
     {
         var interaction = new Interaction<Unit, bool>();
         interaction.RegisterHandler(static c => c.SetOutput(true));
 
-        interaction.Handle(Unit.Default).FirstAsync().Wait();
+        await interaction.Handle(Unit.Default);
     }
 
     /// <summary>
@@ -95,16 +97,27 @@ public async Task HandlersCanContainAsynchronousCode()
 
         // even though handler B is "slow" (i.e. mimicks waiting for the user), it takes precedence over A, so we expect A to never even be called
         var handler1AWasCalled = false;
+        var handler1BWasSubscribed = new TaskCompletionSource(TaskCreationOptions.RunContinuationsAsynchronously);
         var handler1A = interaction.RegisterHandler(x =>
         {
             x.SetOutput("A");
             handler1AWasCalled = true;
         });
         var handler1B = interaction.RegisterHandler(x =>
-            Observables
-                .Unit
-                .Delay(TimeSpan.FromSeconds(1), scheduler)
-                .Do(_ => x.SetOutput("B")));
+        {
+            return Observable.Create<Unit>(
+                observer =>
+                {
+                    var subscription = Observables
+                        .Unit
+                        .Delay(TimeSpan.FromSeconds(1), scheduler)
+                        .Do(_ => x.SetOutput("B"))
+                        .Subscribe(observer);
+
+                    handler1BWasSubscribed.TrySetResult();
+                    return subscription;
+                });
+        });
 
         using (handler1A)
         using (handler1B)
@@ -113,6 +126,7 @@ public async Task HandlersCanContainAsynchronousCode()
                 .Handle(Unit.Default)
                 .ToObservableChangeSet(ImmediateScheduler.Instance).Bind(out var result).Subscribe();
 
+            await handler1BWasSubscribed.Task.WaitAsync(TimeSpan.FromSeconds(5));
             await Assert.That(result).IsEmpty();
             scheduler.AdvanceBy(TimeSpan.FromSeconds(0.5));
             await Assert.That(result).IsEmpty();
@@ -139,14 +153,67 @@ public async Task HandlersCanContainAsynchronousCodeViaTasks()
             return Task.FromResult(true);
         });
 
-        string? result = null;
-        interaction
-            .Handle(Unit.Default)
-            .Subscribe(r => result = r);
+        var result = await interaction.Handle(Unit.Default);
 
         await Assert.That(result).IsEqualTo("result");
     }
 
+    /// <summary>
+    ///     Tests that task handlers release the current scheduler before invoking user code.
+    /// </summary>
+    /// <returns>A <see cref="Task" /> representing the asynchronous operation.</returns>
+    [Test]
+    public async Task TaskHandlersShouldNotBlockNestedInteractionsBeforeReturningTask()
+    {
+        var parent = new Interaction<Unit, Unit>();
+        var nested = new Interaction<Unit, string>();
+        var nestedHandledBeforeParentReturned = false;
+        string? nestedOutput = null;
+
+        nested.RegisterHandler(context => context.SetOutput("nested"));
+
+        parent.RegisterHandler(context =>
+        {
+            using var nestedSubscription = nested.Handle(Unit.Default).Subscribe(output => nestedOutput = output);
+            nestedHandledBeforeParentReturned = nestedOutput == "nested";
+
+            context.SetOutput(Unit.Default);
+            return Task.CompletedTask;
+        });
+
+        await parent.Handle(Unit.Default);
+
+        await Assert.That(nestedHandledBeforeParentReturned).IsTrue();
+    }
+
+    /// <summary>
+    ///     Tests that observable handlers release the current scheduler before invoking user code.
+    /// </summary>
+    /// <returns>A <see cref="Task" /> representing the asynchronous operation.</returns>
+    [Test]
+    public async Task ObservableHandlersShouldNotBlockNestedInteractionsBeforeReturningObservable()
+    {
+        var parent = new Interaction<Unit, Unit>();
+        var nested = new Interaction<Unit, string>();
+        var nestedHandledBeforeParentReturned = false;
+        string? nestedOutput = null;
+
+        nested.RegisterHandler(context => context.SetOutput("nested"));
+
+        parent.RegisterHandler(context =>
+        {
+            using var nestedSubscription = nested.Handle(Unit.Default).Subscribe(output => nestedOutput = output);
+            nestedHandledBeforeParentReturned = nestedOutput == "nested";
+
+            context.SetOutput(Unit.Default);
+            return Observables.Unit;
+        });
+
+        await parent.Handle(Unit.Default);
+
+        await Assert.That(nestedHandledBeforeParentReturned).IsTrue();
+    }
+
     /// <summary>
     ///     Tests that handlers can opt not to handle the interaction.
     /// </summary>
@@ -174,21 +241,21 @@ public async Task HandlersCanOptNotToHandleTheInteraction()
                 using (handler1C)
                 using (Assert.Multiple())
                 {
-                    await Assert.That(interaction.Handle(false).FirstAsync().Wait()).IsEqualTo("C");
-                    await Assert.That(interaction.Handle(true).FirstAsync().Wait()).IsEqualTo("C");
+                    await Assert.That(await interaction.Handle(false)).IsEqualTo("C");
+                    await Assert.That(await interaction.Handle(true)).IsEqualTo("C");
                 }
 
                 using (Assert.Multiple())
                 {
-                    await Assert.That(interaction.Handle(false).FirstAsync().Wait()).IsEqualTo("A");
-                    await Assert.That(interaction.Handle(true).FirstAsync().Wait()).IsEqualTo("B");
+                    await Assert.That(await interaction.Handle(false)).IsEqualTo("A");
+                    await Assert.That(await interaction.Handle(true)).IsEqualTo("B");
                 }
             }
 
             using (Assert.Multiple())
             {
-                await Assert.That(interaction.Handle(false).FirstAsync().Wait()).IsEqualTo("A");
-                await Assert.That(interaction.Handle(true).FirstAsync().Wait()).IsEqualTo("A");
+                await Assert.That(await interaction.Handle(false)).IsEqualTo("A");
+                await Assert.That(await interaction.Handle(true)).IsEqualTo("A");
             }
         }
     }
@@ -207,7 +274,7 @@ public async Task HandlersReturningObservablesCanReturnAnyKindOfObservable()
                 .Return(42)
                 .Do(_ => x.SetOutput("result")));
 
-        var result = interaction.Handle(Unit.Default).FirstAsync().Wait();
+        var result = await interaction.Handle(Unit.Default);
         await Assert.That(result).IsEqualTo("result");
     }
 
@@ -222,19 +289,19 @@ public async Task NestedHandlersAreExecutedInReverseOrderOfSubscription()
 
         using (interaction.RegisterHandler(static x => x.SetOutput("A")))
         {
-            await Assert.That(interaction.Handle(Unit.Default).FirstAsync().Wait()).IsEqualTo("A");
+            await Assert.That(await interaction.Handle(Unit.Default)).IsEqualTo("A");
             using (interaction.RegisterHandler(static x => x.SetOutput("B")))
             {
-                await Assert.That(interaction.Handle(Unit.Default).FirstAsync().Wait()).IsEqualTo("B");
+                await Assert.That(await interaction.Handle(Unit.Default)).IsEqualTo("B");
                 using (interaction.RegisterHandler(static x => x.SetOutput("C")))
                 {
-                    await Assert.That(interaction.Handle(Unit.Default).FirstAsync().Wait()).IsEqualTo("C");
+                    await Assert.That(await interaction.Handle(Unit.Default)).IsEqualTo("C");
                 }
 
-                await Assert.That(interaction.Handle(Unit.Default).FirstAsync().Wait()).IsEqualTo("B");
+                await Assert.That(await interaction.Handle(Unit.Default)).IsEqualTo("B");
             }
 
-            await Assert.That(interaction.Handle(Unit.Default).FirstAsync().Wait()).IsEqualTo("A");
+            await Assert.That(await interaction.Handle(Unit.Default)).IsEqualTo("A");
         }
     }
 
@@ -261,21 +328,21 @@ public void RegisterNullHandlerShouldCauseException()
     public async Task UnhandledInteractionsShouldCauseException()
     {
         var interaction = new Interaction<string, Unit>();
-        var ex = Assert.Throws<UnhandledInteractionException<string, Unit>>(() =>
-            interaction.Handle("foo").FirstAsync().Wait());
+        var ex = await Assert.ThrowsAsync<UnhandledInteractionException<string, Unit>>(() =>
+            interaction.Handle("foo").ToTask());
         using (Assert.Multiple())
         {
-            await Assert.That(ex.Interaction).IsSameReferenceAs(interaction);
+            await Assert.That(ex!.Interaction).IsSameReferenceAs(interaction);
             await Assert.That(ex.Input).IsEqualTo("foo");
         }
 
         interaction.RegisterHandler(_ => { });
         interaction.RegisterHandler(_ => { });
-        ex = Assert.Throws<UnhandledInteractionException<string, Unit>>(() =>
-            interaction.Handle("bar").FirstAsync().Wait());
+        ex = await Assert.ThrowsAsync<UnhandledInteractionException<string, Unit>>(() =>
+            interaction.Handle("bar").ToTask());
         using (Assert.Multiple())
         {
-            await Assert.That(ex.Interaction).IsSameReferenceAs(interaction);
+            await Assert.That(ex!.Interaction).IsSameReferenceAs(interaction);
             await Assert.That(ex.Input).IsEqualTo("bar");
         }
     }

From 926fec1cf590552011924686dd119e16cb94af26 Mon Sep 17 00:00:00 2001
From: Chris Pulman <chris.pulman@yahoo.com>
Date: Mon, 4 May 2026 15:14:27 +0100
Subject: [PATCH 2/2] Fix interaction scheduler test on Release runners

---
 .../ReactiveUI.Tests/InteractionsTest.cs      | 29 +++++++++++++------
 1 file changed, 20 insertions(+), 9 deletions(-)

diff --git a/src/tests/ReactiveUI.Tests/InteractionsTest.cs b/src/tests/ReactiveUI.Tests/InteractionsTest.cs
index 8446411e39..082773fe66 100644
--- a/src/tests/ReactiveUI.Tests/InteractionsTest.cs
+++ b/src/tests/ReactiveUI.Tests/InteractionsTest.cs
@@ -65,21 +65,32 @@ public async Task HandledInteractionsShouldNotCauseException()
     /// </summary>
     /// <returns>A <see cref="Task" /> representing the asynchronous operation.</returns>
     [Test]
-    [TestExecutor<WithSchedulerExecutor>]
     public async Task HandlersAreExecutedOnHandlerScheduler()
     {
-        var scheduler = TestContext.Current!.GetScheduler();
+        var schedulerThreadId = -1;
+        using var scheduler = new EventLoopScheduler(
+            threadStart =>
+            {
+                var thread = new Thread(threadStart) { IsBackground = true };
+                schedulerThreadId = thread.ManagedThreadId;
+                return thread;
+            });
         var interaction = new Interaction<Unit, string>(scheduler);
+        var handlerThreadId = -1;
 
-        using (interaction.RegisterHandler(x => x.SetOutput("done")))
+        using (interaction.RegisterHandler(x =>
         {
-            var handled = false;
-            interaction
-                .Handle(Unit.Default)
-                .Subscribe(_ => handled = true);
+            handlerThreadId = Environment.CurrentManagedThreadId;
+            x.SetOutput("done");
+        }))
+        {
+            var result = await interaction.Handle(Unit.Default).ToTask().WaitAsync(TimeSpan.FromSeconds(5));
 
-            // With ImmediateScheduler, handlers execute immediately
-            await Assert.That(handled).IsTrue();
+            using (Assert.Multiple())
+            {
+                await Assert.That(result).IsEqualTo("done");
+                await Assert.That(handlerThreadId).IsEqualTo(schedulerThreadId);
+            }
         }
     }
 
