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

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
49 changes: 43 additions & 6 deletions src/ReactiveUI/Interactions/Interaction.cs
Original file line number Diff line number Diff line change
Expand Up @@ -87,30 +87,46 @@ 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 />
public IDisposable RegisterHandler(Func<IInteractionContext<TInput, TOutput>, Task> handler)
{
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 />
public IDisposable RegisterHandler<TDontCare>(Func<IInteractionContext<TInput, TOutput>, IObservable<TDontCare>> handler)
{
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 />
Expand Down Expand Up @@ -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>
Expand Down
160 changes: 119 additions & 41 deletions src/tests/ReactiveUI.Tests/InteractionsTest.cs
Original file line number Diff line number Diff line change
Expand Up @@ -10,6 +10,7 @@ namespace ReactiveUI.Tests;
/// <summary>
/// Tests interactions.
/// </summary>
[NotInParallel]
public class InteractionsTest
{
/// <summary>
Expand All @@ -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>
Expand All @@ -42,42 +43,54 @@ 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>
/// Tests that Handlers are executed on handler scheduler.
/// </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);
}
}
}

Expand All @@ -95,16 +108,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)
Expand All @@ -113,6 +137,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();
Expand All @@ -139,14 +164,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>
Expand Down Expand Up @@ -174,21 +252,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");
}
}
}
Expand All @@ -207,7 +285,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");
}

Expand All @@ -222,19 +300,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");
}
}

Expand All @@ -261,21 +339,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");
}
}
Expand Down
Loading