diff --git a/src/Directory.Packages.props b/src/Directory.Packages.props
index cfaebaf077..b188f889dd 100644
--- a/src/Directory.Packages.props
+++ b/src/Directory.Packages.props
@@ -20,8 +20,8 @@
3.1.32
-
-
+
+
@@ -30,11 +30,11 @@
-
+
-
+
@@ -74,14 +74,14 @@
-
-
+
+
-
+
diff --git a/src/ReactiveUI.Wpf/Binding/ValidationBindingWpf.cs b/src/ReactiveUI.Wpf/Binding/ValidationBindingWpf.cs
index 9b893b4291..d9674fdaa0 100644
--- a/src/ReactiveUI.Wpf/Binding/ValidationBindingWpf.cs
+++ b/src/ReactiveUI.Wpf/Binding/ValidationBindingWpf.cs
@@ -3,6 +3,7 @@
// The .NET Foundation licenses this file to you under the MIT license.
// See the LICENSE file in the project root for full license information.
+using System.ComponentModel;
using System.Linq.Expressions;
using System.Windows;
using System.Windows.Data;
@@ -223,6 +224,17 @@ internal static IEnumerable EnumerateAttachedProperties(obje
return null;
}
+ if (element is DependencyObject dependencyObject)
+ {
+ var targetType = dependencyObject.DependencyObjectType.SystemType;
+ var descriptor = DependencyPropertyDescriptor.FromName(name, targetType, targetType);
+
+ if (descriptor?.DependencyProperty is not null)
+ {
+ return descriptor.DependencyProperty;
+ }
+ }
+
return EnumerateDependencyProperties(element)
.Concat(EnumerateAttachedProperties(element))
.FirstOrDefault(x => x.Name == name);
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>
{
ArgumentExceptionHelper.ThrowIfNull(handler);
- return RegisterHandler(interaction =>
+ IObservable ContentHandler(IInteractionContext interaction)
{
handler(interaction);
return Observables.Unit;
- });
+ }
+
+ return RegisterHandlerCore(ContentHandler);
}
///
@@ -99,7 +101,15 @@ public IDisposable RegisterHandler(Func, Ta
{
ArgumentExceptionHelper.ThrowIfNull(handler);
- return RegisterHandler(interaction => handler(interaction).ToObservable());
+ IObservable ContentHandler(IInteractionContext interaction) =>
+ Observable.FromAsync(
+ async () =>
+ {
+ await YieldToCurrentContext();
+ await handler(interaction).ConfigureAwait(false);
+ });
+
+ return RegisterHandlerCore(ContentHandler);
}
///
@@ -107,10 +117,16 @@ public IDisposable RegisterHandler(Func ContentHandler(IInteractionContext context) => handler(context).Select(_ => Unit.Default);
+ IObservable ContentHandler(IInteractionContext 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);
}
///
@@ -153,6 +169,27 @@ protected Func, IObservable>[] GetHan
/// The interaction context.
protected virtual IOutputContext GenerateContext(TInput input) => new InteractionContext(input);
+ ///
+ /// Yields once so asynchronous handlers are not invoked inside the current scheduler trampoline.
+ ///
+ /// A task that completes after the current context has yielded.
+ private static async Task YieldToCurrentContext()
+ {
+ await Task.Yield();
+ }
+
+ ///
+ /// Registers a normalized interaction handler.
+ ///
+ /// The normalized handler.
+ /// A disposable which unregisters the handler.
+ private IDisposable RegisterHandlerCore(Func, IObservable> contentHandler)
+ {
+ ArgumentExceptionHelper.ThrowIfNull(contentHandler);
+ AddHandler(contentHandler);
+ return Disposable.Create(() => RemoveHandler(contentHandler));
+ }
+
///
/// Adds a handler delegate to be invoked for interaction contexts.
///
diff --git a/src/ReactiveUI/Suspension/SuspensionHostExtensions.cs b/src/ReactiveUI/Suspension/SuspensionHostExtensions.cs
index bf2ea63229..e58c7cab64 100644
--- a/src/ReactiveUI/Suspension/SuspensionHostExtensions.cs
+++ b/src/ReactiveUI/Suspension/SuspensionHostExtensions.cs
@@ -284,16 +284,20 @@ private static IObservable EnsureLoadAppState(this ISuspensionHost item, I
return Observable.Return(Unit.Default);
}
+ object? loadedState;
+
try
{
- item.AppState = _suspensionDriver.LoadState().Wait();
+ loadedState = _suspensionDriver.LoadState().Wait();
}
catch (Exception ex)
{
item.Log().Warn(ex, "Failed to restore app state from storage, creating from scratch");
- item.AppState = item.CreateNewAppState?.Invoke();
+ loadedState = null;
}
+ item.AppState = loadedState ?? item.CreateNewAppState?.Invoke();
+
return Observable.Return(Unit.Default);
}
@@ -321,16 +325,20 @@ private static IObservable EnsureLoadAppState(this ISuspensionH
return Observable.Return(Unit.Default);
}
+ TAppState? loadedState;
+
try
{
- item.AppStateValue = _suspensionDriver.LoadState(typeInfo).Wait();
+ loadedState = _suspensionDriver.LoadState(typeInfo).Wait();
}
catch (Exception ex)
{
item.Log().Warn(ex, "Failed to restore app state from storage, creating from scratch");
- item.AppStateValue = item.CreateNewAppStateTyped?.Invoke();
+ loadedState = null;
}
+ item.AppStateValue = loadedState ?? item.CreateNewAppStateTyped?.Invoke();
+
return Observable.Return(Unit.Default);
}
}
diff --git a/src/tests/ReactiveUI.Test.Utilities/SuspensionHost/SuspensionHostTestExecutor.cs b/src/tests/ReactiveUI.Test.Utilities/SuspensionHost/SuspensionHostTestExecutor.cs
index 15a7b61d64..34abf74919 100644
--- a/src/tests/ReactiveUI.Test.Utilities/SuspensionHost/SuspensionHostTestExecutor.cs
+++ b/src/tests/ReactiveUI.Test.Utilities/SuspensionHost/SuspensionHostTestExecutor.cs
@@ -5,6 +5,8 @@
using System.Reactive;
+using ReactiveUI.Tests.Utilities.AppBuilder;
+
namespace ReactiveUI.Tests.Utilities.SuspensionHost;
///
@@ -17,13 +19,13 @@ namespace ReactiveUI.Tests.Utilities.SuspensionHost;
/// - SuspensionHostExtensions.SuspensionDriver
/// Tests using this executor should be marked with [NotInParallel] due to static state modifications.
///
-public class SuspensionHostTestExecutor : ITestExecutor
+public class SuspensionHostTestExecutor : AppBuilderTestExecutor
{
private Func>? _previousEnsureLoadAppStateFunc;
private ISuspensionDriver? _previousSuspensionDriver;
///
- public virtual async ValueTask ExecuteTest(TestContext context, Func testAction)
+ public override async ValueTask ExecuteTest(TestContext context, Func testAction)
{
ArgumentNullException.ThrowIfNull(testAction);
@@ -31,7 +33,7 @@ public virtual async ValueTask ExecuteTest(TestContext context, Func
try
{
- await testAction();
+ await base.ExecuteTest(context, testAction);
}
finally
{
diff --git a/src/tests/ReactiveUI.Tests/InteractionsTest.cs b/src/tests/ReactiveUI.Tests/InteractionsTest.cs
index 726f8117b5..082773fe66 100644
--- a/src/tests/ReactiveUI.Tests/InteractionsTest.cs
+++ b/src/tests/ReactiveUI.Tests/InteractionsTest.cs
@@ -10,6 +10,7 @@ namespace ReactiveUI.Tests;
///
/// Tests interactions.
///
+[NotInParallel]
public class InteractionsTest
{
///
@@ -23,8 +24,8 @@ public async Task AttemptingToGetInteractionOutputBeforeItHasBeenSetShouldCauseE
interaction.RegisterHandler(context => { _ = ((InteractionContext)context).GetOutput(); });
- var ex = Assert.Throws(() => interaction.Handle(Unit.Default).Subscribe());
- await Assert.That(ex.Message).IsEqualTo("Output has not been set.");
+ var ex = await Assert.ThrowsAsync(() => interaction.Handle(Unit.Default).ToTask());
+ await Assert.That(ex!.Message).IsEqualTo("Output has not been set.");
}
///
@@ -42,20 +43,21 @@ public async Task AttemptingToSetInteractionOutputMoreThanOnceShouldCauseExcepti
context.SetOutput(Unit.Default);
});
- var ex = Assert.Throws(() => interaction.Handle(Unit.Default).Subscribe());
- await Assert.That(ex.Message).IsEqualTo("Output has already been set.");
+ var ex = await Assert.ThrowsAsync(() => interaction.Handle(Unit.Default).ToTask());
+ await Assert.That(ex!.Message).IsEqualTo("Output has already been set.");
}
///
/// Tests that Handled interactions should not cause exception.
///
+ /// A representing the asynchronous operation.
[Test]
- public void HandledInteractionsShouldNotCauseException()
+ public async Task HandledInteractionsShouldNotCauseException()
{
var interaction = new Interaction();
interaction.RegisterHandler(static c => c.SetOutput(true));
- interaction.Handle(Unit.Default).FirstAsync().Wait();
+ await interaction.Handle(Unit.Default);
}
///
@@ -63,21 +65,32 @@ public void HandledInteractionsShouldNotCauseException()
///
/// A representing the asynchronous operation.
[Test]
- [TestExecutor]
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(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);
+ }
}
}
@@ -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(
+ 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 +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();
@@ -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");
}
+ ///
+ /// Tests that task handlers release the current scheduler before invoking user code.
+ ///
+ /// A representing the asynchronous operation.
+ [Test]
+ public async Task TaskHandlersShouldNotBlockNestedInteractionsBeforeReturningTask()
+ {
+ var parent = new Interaction();
+ var nested = new Interaction();
+ 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();
+ }
+
+ ///
+ /// Tests that observable handlers release the current scheduler before invoking user code.
+ ///
+ /// A representing the asynchronous operation.
+ [Test]
+ public async Task ObservableHandlersShouldNotBlockNestedInteractionsBeforeReturningObservable()
+ {
+ var parent = new Interaction();
+ var nested = new Interaction();
+ 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();
+ }
+
///
/// Tests that handlers can opt not to handle the interaction.
///
@@ -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");
}
}
}
@@ -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");
}
@@ -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");
}
}
@@ -261,21 +339,21 @@ public void RegisterNullHandlerShouldCauseException()
public async Task UnhandledInteractionsShouldCauseException()
{
var interaction = new Interaction();
- var ex = Assert.Throws>(() =>
- interaction.Handle("foo").FirstAsync().Wait());
+ var ex = await Assert.ThrowsAsync>(() =>
+ 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>(() =>
- interaction.Handle("bar").FirstAsync().Wait());
+ ex = await Assert.ThrowsAsync>(() =>
+ 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");
}
}
diff --git a/src/tests/ReactiveUI.Tests/Suspension/SuspensionHostExtensionsAotTests.cs b/src/tests/ReactiveUI.Tests/Suspension/SuspensionHostExtensionsAotTests.cs
index 8728f0ce43..28810305ff 100644
--- a/src/tests/ReactiveUI.Tests/Suspension/SuspensionHostExtensionsAotTests.cs
+++ b/src/tests/ReactiveUI.Tests/Suspension/SuspensionHostExtensionsAotTests.cs
@@ -69,6 +69,36 @@ public async Task GetAppState_Typed_TriggersEnsureLoadAppState()
await Assert.That(driver.LoadStateCallCount).IsEqualTo(1);
}
+ [Test]
+ public async Task GetAppState_Typed_WhenNoPersistedState_CreatesAndStoresNewAppState()
+ {
+ var createdState = new TestAppState { Value = 99 };
+ var createNewAppStateCallCount = 0;
+ using var host = new SuspensionHost
+ {
+ CreateNewAppStateTyped = () =>
+ {
+ createNewAppStateCallCount++;
+ return createdState;
+ },
+ IsLaunchingNew = Observable.Never(),
+ IsResuming = Observable.Never(),
+ ShouldPersistState = Observable.Never(),
+ ShouldInvalidateState = Observable.Never()
+ };
+
+ var driver = new TestSuspensionDriver();
+
+ using var disposable = host.SetupDefaultSuspendResume(TestAppStateContext.Default.TestAppState, driver);
+
+ var state = host.GetAppState();
+
+ await Assert.That(state).IsSameReferenceAs(createdState);
+ await Assert.That(host.AppStateValue).IsSameReferenceAs(createdState);
+ await Assert.That(createNewAppStateCallCount).IsEqualTo(1);
+ await Assert.That(driver.LoadStateCallCount).IsEqualTo(1);
+ }
+
[Test]
public async Task ObserveAppState_Typed_EmitsCurrentValueImmediately()
{
diff --git a/src/tests/ReactiveUI.Tests/SuspensionHostExtensionsTests.cs b/src/tests/ReactiveUI.Tests/SuspensionHostExtensionsTests.cs
index 0d47e0bd1f..818d3d37d5 100644
--- a/src/tests/ReactiveUI.Tests/SuspensionHostExtensionsTests.cs
+++ b/src/tests/ReactiveUI.Tests/SuspensionHostExtensionsTests.cs
@@ -124,6 +124,36 @@ public async Task EnsureLoadAppState_LoadStateThrows_CreatesNewAppState()
await Assert.That(driver.LoadStateCallCount).IsEqualTo(1);
}
+ [Test]
+ public async Task GetAppState_WhenNoPersistedState_CreatesAndStoresNewAppState()
+ {
+ var createdState = new DummyAppState();
+ var createNewAppStateCallCount = 0;
+ using var host = new SuspensionHost
+ {
+ CreateNewAppState = () =>
+ {
+ createNewAppStateCallCount++;
+ return createdState;
+ },
+ IsLaunchingNew = Observable.Never(),
+ IsResuming = Observable.Never(),
+ ShouldPersistState = Observable.Never(),
+ ShouldInvalidateState = Observable.Never()
+ };
+
+ var driver = new TestSuspensionDriver { ReturnNullOnLoad = true };
+
+ using var disposable = host.SetupDefaultSuspendResume(driver);
+
+ var state = host.GetAppState();
+
+ await Assert.That(state).IsSameReferenceAs(createdState);
+ await Assert.That(host.AppState).IsSameReferenceAs(createdState);
+ await Assert.That(createNewAppStateCallCount).IsEqualTo(1);
+ await Assert.That(driver.LoadStateCallCount).IsEqualTo(1);
+ }
+
[Test]
public async Task EnsureLoadAppState_WithExistingAppState_DoesNotLoad()
{
@@ -475,6 +505,8 @@ private class TestSuspensionDriver : ISuspensionDriver
public bool ShouldThrowOnLoad { get; set; }
+ public bool ReturnNullOnLoad { get; set; }
+
public object? StateToLoad { get; set; }
public IObservable InvalidateState()
@@ -497,6 +529,11 @@ public IObservable InvalidateState()
ImmediateScheduler.Instance);
}
+ if (ReturnNullOnLoad)
+ {
+ return Observable.Return