From 03ccb2ecdd9f43cb372aeb7c47629ae82bd88848 Mon Sep 17 00:00:00 2001 From: Chris Pulman Date: Sun, 3 May 2026 21:39:46 -0300 Subject: [PATCH 1/8] Fixes 4331 Handle null load state; add tests (#4349) ## What kind of change does this PR introduce? Fix ## What is the current behavior? Closes #4331 ## What is the new behavior? Do not assign a potentially-null loaded state directly to AppState/AppStateValue. Capture the result of ISuspensionDriver.LoadState into a local (object? / TAppState?) and assign item.AppState/item.AppStateValue to the loaded value or fallback to CreateNewAppState/CreateNewAppStateTyped. Add tests in SuspensionHostExtensionsTests and SuspensionHostExtensionsAotTests that verify GetAppState creates and stores a new app state when no persisted state exists. Add ReturnNullOnLoad to TestSuspensionDriver to simulate drivers returning null. Change SuspensionHostTestExecutor to inherit from AppBuilderTestExecutor and delegate ExecuteTest to the base implementation (and add the required using). These changes ensure null-returning drivers are handled safely and covered by tests. ## What might this PR break? ## Checklist - [x] I have read the [Contribute guide](https://www.reactiveui.net/contribute/index.html) - [x] Tests have been added or updated (for bug fixes / features) - [ ] Docs have been added or updated (for bug fixes / features) - [x] Changes target the `main` branch - [x] PR title follows [Conventional Commits](https://www.conventionalcommits.org/) ## Additional information --- .../Suspension/SuspensionHostExtensions.cs | 16 ++++++-- .../SuspensionHostTestExecutor.cs | 8 ++-- .../SuspensionHostExtensionsAotTests.cs | 30 +++++++++++++++ .../SuspensionHostExtensionsTests.cs | 37 +++++++++++++++++++ 4 files changed, 84 insertions(+), 7 deletions(-) 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/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(null, ImmediateScheduler.Instance); + } + return Observable.Return(StateToLoad ?? new DummyAppState(), ImmediateScheduler.Instance); } From 79661cfae513ff6b5dcead3dfc15028b490001a5 Mon Sep 17 00:00:00 2001 From: Chris Pulman Date: Mon, 4 May 2026 07:54:41 -0300 Subject: [PATCH 2/8] Fix 3921 Support inherited DependencyProperty lookup (#4350) ## What kind of change does this PR introduce? Fix ## What is the current behavior? Closes #3921 ## What is the new behavior? Use DependencyPropertyDescriptor.FromName to resolve dependency properties declared on base control types (via DependencyObjectType.SystemType) and return the descriptor's DependencyProperty when found; fall back to existing enumeration otherwise. Add System.ComponentModel using and unit tests covering inherited property lookup (Selector.SelectedValue, TextBoxBase.IsReadOnly) and a BindWithValidation test for ComboBox.SelectedValue, plus a small TestViewWithComboBox and SelectedValue on the test view model. ## What might this PR break? ## Checklist - [x] I have read the [Contribute guide](https://www.reactiveui.net/contribute/index.html) - [x] Tests have been added or updated (for bug fixes / features) - [ ] Docs have been added or updated (for bug fixes / features) - [x] Changes target the `main` branch - [x] PR title follows [Conventional Commits](https://www.conventionalcommits.org/) ## Additional information --- .../Binding/ValidationBindingWpf.cs | 12 +++ .../Wpf/ValidationBindingWpfTest.cs | 77 +++++++++++++++++++ 2 files changed, 89 insertions(+) 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/tests/ReactiveUI.Wpf.Tests/Wpf/ValidationBindingWpfTest.cs b/src/tests/ReactiveUI.Wpf.Tests/Wpf/ValidationBindingWpfTest.cs index a9ce0bb7ac..349ebe02a2 100644 --- a/src/tests/ReactiveUI.Wpf.Tests/Wpf/ValidationBindingWpfTest.cs +++ b/src/tests/ReactiveUI.Wpf.Tests/Wpf/ValidationBindingWpfTest.cs @@ -5,6 +5,8 @@ using System.Windows; using System.Windows.Controls; +using System.Windows.Controls.Primitives; +using System.Windows.Data; using ReactiveUI.Wpf.Binding; @@ -189,6 +191,36 @@ public async Task GetDependencyProperty_FindsPropertyByName() await Assert.That(result!.Name).IsEqualTo("Text"); } + /// + /// Tests that GetDependencyProperty finds dependency properties inherited from base controls. + /// + /// A representing the asynchronous operation. + [Test] + public async Task GetDependencyProperty_FindsInheritedPropertyByName() + { + var comboBox = new ComboBox(); + + var result = ValidationBindingWpf.GetDependencyProperty(comboBox, nameof(Selector.SelectedValue)); + + await Assert.That(result).IsNotNull(); + await Assert.That(result).IsSameReferenceAs(Selector.SelectedValueProperty); + } + + /// + /// Tests that GetDependencyProperty finds dependency properties inherited by TextBox. + /// + /// A representing the asynchronous operation. + [Test] + public async Task GetDependencyProperty_FindsInheritedTextBoxBasePropertyByName() + { + var textBox = new TextBox(); + + var result = ValidationBindingWpf.GetDependencyProperty(textBox, nameof(TextBoxBase.IsReadOnly)); + + await Assert.That(result).IsNotNull(); + await Assert.That(result).IsSameReferenceAs(TextBoxBase.IsReadOnlyProperty); + } + /// /// Tests that GetDependencyProperty returns null for non-existent property. /// @@ -375,6 +407,25 @@ public async Task Constructor_ThrowsWhenDependencyPropertyNotFound() } } + /// + /// Tests that BindWithValidation supports ComboBox.SelectedValue. + /// + /// A representing the asynchronous operation. + [Test] + public async Task BindWithValidation_BindsComboBoxSelectedValue() + { + var view = new TestViewWithComboBox(); + var viewModel = new TestViewModel { SelectedValue = "initial" }; + view.ViewModel = viewModel; + + using var binding = view.BindWithValidation(viewModel, vm => vm.SelectedValue, v => v.MyComboBox.SelectedValue); + + var bindingExpression = BindingOperations.GetBindingExpression(view.MyComboBox, Selector.SelectedValueProperty); + + await Assert.That(bindingExpression).IsNotNull(); + await Assert.That(bindingExpression!.ParentBinding.Path.Path).IsEqualTo("SelectedValue"); + } + /// /// Tests that Dispose clears the binding. /// @@ -645,10 +696,30 @@ public TestViewWithControl() public TextBox MyTextBox { get; } } + private class TestViewWithComboBox : StackPanel, IViewFor + { + public TestViewWithComboBox() + { + MyComboBox = new ComboBox { Name = "MyComboBox" }; + Children.Add(MyComboBox); + } + + public TestViewModel? ViewModel { get; set; } + + object? IViewFor.ViewModel + { + get => ViewModel; + set => ViewModel = value as TestViewModel; + } + + public ComboBox MyComboBox { get; } + } + private class TestViewModel : ReactiveObject { private string? _testProperty; private NestedTestObject? _nestedObject; + private string? _selectedValue; public string? TestProperty { @@ -661,6 +732,12 @@ public NestedTestObject? NestedObject get => _nestedObject; set => this.RaiseAndSetIfChanged(ref _nestedObject, value); } + + public string? SelectedValue + { + get => _selectedValue; + set => this.RaiseAndSetIfChanged(ref _selectedValue, value); + } } private class NestedTestObject From adb2598e140ba408a3ac4ec3a18b6e527c4d226b Mon Sep 17 00:00:00 2001 From: "renovate[bot]" <29139614+renovate[bot]@users.noreply.github.com> Date: Mon, 4 May 2026 11:46:25 +0000 Subject: [PATCH 3/8] chore(deps): update dependency microsoft.windowsappsdk to v2 (#4347) MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit This PR contains the following updates: | Package | Change | [Age](https://docs.renovatebot.com/merge-confidence/) | [Confidence](https://docs.renovatebot.com/merge-confidence/) | |---|---|---|---| | [Microsoft.WindowsAppSDK](https://redirect.github.com/microsoft/windowsappsdk) | `1.8.260416003` → `2.0.1` | ![age](https://developer.mend.io/api/mc/badges/age/nuget/Microsoft.WindowsAppSDK/2.0.1?slim=true) | ![confidence](https://developer.mend.io/api/mc/badges/confidence/nuget/Microsoft.WindowsAppSDK/1.8.260416003/2.0.1?slim=true) | --- ### Release Notes
microsoft/windowsappsdk (Microsoft.WindowsAppSDK) ### [`v2.0.1`](https://redirect.github.com/microsoft/WindowsAppSDK/releases/tag/v2.0.1): Windows App SDK 2.0 (2.0.1) 🎉 WinAppSDK 2.0 is the next major release of the Windows App SDK, the first major version update since 1.0 (November 2021) and the first release on the new [Semantic Versioning](https://semver.org/) scheme. It ships new XAML capabilities, a modernized Storage Pickers surface, expanded popup and anchoring APIs in `Microsoft.UI.Content`, a new package deployment and validation framework, a refactored Windows ML stack, and additions across the Windows AI surface. ##### What's new in WinAppSDK 2.0: - **Semantic Versioning.** Windows App SDK 2.0 standardizes on [SemVer 2.0.0](https://semver.org/) and aligns the SDK version with the NuGet package version, so there's no separate date-based build number to track. The package family name now aligns with the major version, so the next side-by-side release will be 3.0.0. - **WebView2 (WinUI 3) drag support.** Dragging text, HTML, images, and URLs out of WebView2 content hosted in WinUI 3 is now supported, along with drag cancellation, custom drag visuals, and customizable drag data. (Requires WebView2 Runtime 144.0.3719.11 or higher.) - **Package deployment and validation.** The `Microsoft.Windows.Management.Deployment` namespace adds a new `IPackageValidator` framework with three built-in validators (`PackageCertificateEkuValidator`, `PackageFamilyNameValidator`, `PackageMinimumVersionValidator`), plus a new `PackageVolume` API for managing the storage volumes that packages are staged onto. - **Windows ML refactor.** Core Windows ML features have been refactored into a new base package, `Microsoft.Windows.AI.MachineLearning`, with a minimal set of dependencies that supports apps down to Windows 10 v1903. The existing `Microsoft.WindowsAppSDK.ML` package continues to support Windows 10 v1809. The included **ONNX Runtime** version has been updated to 1.24.5. - **Windows AI additions.** New `AIFeatureReadyState` values (`CapabilityMissing`, `NotCompatibleWithSystemHardware`, `OSUpdateNeeded`) help apps explain transient and durable failures during AI model acquisition so users get actionable guidance instead of a generic "not ready" condition. Phi Silica APIs are now enforced as part of a Limited Access Feature (LAF); see [Phi Silica](https://learn.microsoft.com/windows/ai/apis/phi-silica) for details. - **Storage Pickers updates.** The `Microsoft.Windows.Storage.Pickers` API (introduced in 1.8) is extended with file type choice grouping, persistent settings identifiers, suggested start folders, custom titles, multi-folder picking, and more, across `FileOpenPicker`, `FileSavePicker`, and `FolderPicker`. - **`SystemBackdropElement`** is a new lightweight `FrameworkElement` that lets apps place a system backdrop such as Mica or Acrylic anywhere within the XAML layout, with a `CornerRadius` property for rounded backdrop areas. It closes a long-standing WinUI 3 gap where in-app acrylic effects (previously straightforward in WinUI 2 via `AcrylicBrush.BackgroundSource`) had no direct equivalent. - **Custom XAML Conditionals (`IXamlCondition`)** enable developers to define custom conditions that integrate with XAML's conditional namespace syntax and are evaluated at XAML parse time. This replaces the experimental `IXamlPredicate` interface and unlocks conditional XAML based on feature flags, device capabilities, business logic, configuration settings, and other runtime conditions. - **Relative popup positioning** in `Microsoft.UI.Content`. The new `PopupAnchor` API allows `DesktopPopupSiteBridge` to anchor to its owning window or island instead of being limited to absolute screen coordinates, with new `AnchoringBehavior` and `AnchoringPixelAlignment` properties to control the behavior. ##### Notable bug fixes: - Fixed an issue where the WindowsAppSDK installer showed no progress during installation, making it appear stalled. The installer now provides clearer progress feedback. - Fixed `MSB8027` and `LNK4042` build warnings caused by duplicate `ClCompile` items in Windows App SDK NuGet `.targets` files. - Fixed a ListView crash that could occur during keyboard navigation (Tab/Shift+Tab) after the items list was updated. - Fixed an issue where WinUI 3 could crash if focus was moved to the `CoreWebView2Controller` while the controller was not visible. - Fixed a Windows ML bug where calling `RegisterCertifiedAsync` again in the same process incorrectly returned 0 execution providers (EP). **To see everything that's new and changed, including upgrade guidance and known issues, see the full [Windows App SDK 2.0 release notes](https://learn.microsoft.com/en-us/windows/apps/windows-app-sdk/release-notes/windows-app-sdk-2-0?pivots=stable).** ##### Try it out - Download the [2.0.1 NuGet](https://www.nuget.org/packages/Microsoft.WindowsAppSDK/2.0.1) package to use WinAppSDK 2.0 in your app. - Download and update the [WinUI Gallery](https://www.microsoft.com/store/productId/9P3JFPWWDZRC) to see the WinUI 3 updates firsthand. ##### Getting started To get started using Windows App SDK to develop Windows apps, check out the following documentation: - [Install developer tools](https://docs.microsoft.com/windows/apps/windows-app-sdk/set-up-your-development-environment). - [Create a WinUI 3 app](https://docs.microsoft.com/windows/apps/winui/winui3/create-your-first-winui3-app). - [Continue your development journey](https://docs.microsoft.com/windows/apps/develop).
--- ### Configuration 📅 **Schedule**: (UTC) - Branch creation - At any time (no schedule defined) - Automerge - At any time (no schedule defined) 🚦 **Automerge**: Disabled by config. Please merge this manually once you are satisfied. ♻ **Rebasing**: Whenever PR becomes conflicted, or you tick the rebase/retry checkbox. 🔕 **Ignore**: Close this PR and you won't be reminded about this update again. --- - [ ] If you want to rebase/retry this PR, check this box --- This PR was generated by [Mend Renovate](https://mend.io/renovate/). View the [repository job log](https://developer.mend.io/github/reactiveui/ReactiveUI). Co-authored-by: renovate[bot] <29139614+renovate[bot]@users.noreply.github.com> --- src/Directory.Packages.props | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/src/Directory.Packages.props b/src/Directory.Packages.props index cfaebaf077..40ed68d8fb 100644 --- a/src/Directory.Packages.props +++ b/src/Directory.Packages.props @@ -81,7 +81,7 @@ - + From 6eb0c7f0f3722b7030cb4773f773b250818573df Mon Sep 17 00:00:00 2001 From: "renovate[bot]" <29139614+renovate[bot]@users.noreply.github.com> Date: Mon, 4 May 2026 11:57:46 +0000 Subject: [PATCH 4/8] chore(deps): update dependency microsoft.testing.platform.msbuild to 2.2.2 (#4348) MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit This PR contains the following updates: | Package | Change | [Age](https://docs.renovatebot.com/merge-confidence/) | [Confidence](https://docs.renovatebot.com/merge-confidence/) | |---|---|---|---| | [Microsoft.Testing.Platform.MSBuild](https://redirect.github.com/microsoft/testfx) | `2.2.1` → `2.2.2` | ![age](https://developer.mend.io/api/mc/badges/age/nuget/Microsoft.Testing.Platform.MSBuild/2.2.2?slim=true) | ![confidence](https://developer.mend.io/api/mc/badges/confidence/nuget/Microsoft.Testing.Platform.MSBuild/2.2.1/2.2.2?slim=true) | --- ### Release Notes
microsoft/testfx (Microsoft.Testing.Platform.MSBuild) ### [`v2.2.2`](https://redirect.github.com/microsoft/testfx/releases/tag/v2.2.2) See release notes [here](https://redirect.github.com/microsoft/testfx/blob/main/docs/Changelog.md#222---2021-03-15).
--- ### Configuration 📅 **Schedule**: (UTC) - Branch creation - At any time (no schedule defined) - Automerge - At any time (no schedule defined) 🚦 **Automerge**: Disabled by config. Please merge this manually once you are satisfied. ♻ **Rebasing**: Whenever PR becomes conflicted, or you tick the rebase/retry checkbox. 🔕 **Ignore**: Close this PR and you won't be reminded about this update again. --- - [ ] If you want to rebase/retry this PR, check this box --- This PR was generated by [Mend Renovate](https://mend.io/renovate/). View the [repository job log](https://developer.mend.io/github/reactiveui/ReactiveUI). Co-authored-by: renovate[bot] <29139614+renovate[bot]@users.noreply.github.com> --- src/Directory.Packages.props | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/src/Directory.Packages.props b/src/Directory.Packages.props index 40ed68d8fb..dfb7b82e8f 100644 --- a/src/Directory.Packages.props +++ b/src/Directory.Packages.props @@ -30,7 +30,7 @@ - + From 5cd13f62b893c5405d45785665d91f425d3813db Mon Sep 17 00:00:00 2001 From: "renovate[bot]" <29139614+renovate[bot]@users.noreply.github.com> Date: Mon, 4 May 2026 22:10:58 +1000 Subject: [PATCH 5/8] chore(deps): update xamarin & androidx to 1.6.0 (#4340) MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit This PR contains the following updates: | Package | Change | [Age](https://docs.renovatebot.com/merge-confidence/) | [Confidence](https://docs.renovatebot.com/merge-confidence/) | |---|---|---|---| | [Xamarin.AndroidX.Collection.Jvm](https://aka.ms/android-libraries) ([source](https://redirect.github.com/xamarin/AndroidX)) | `1.5.0.5` → `1.6.0` | ![age](https://developer.mend.io/api/mc/badges/age/nuget/Xamarin.AndroidX.Collection.Jvm/1.6.0?slim=true) | ![confidence](https://developer.mend.io/api/mc/badges/confidence/nuget/Xamarin.AndroidX.Collection.Jvm/1.5.0.5/1.6.0?slim=true) | | [Xamarin.AndroidX.Collection.Ktx](https://aka.ms/android-libraries) ([source](https://redirect.github.com/xamarin/AndroidX)) | `1.5.0.5` → `1.6.0` | ![age](https://developer.mend.io/api/mc/badges/age/nuget/Xamarin.AndroidX.Collection.Ktx/1.6.0?slim=true) | ![confidence](https://developer.mend.io/api/mc/badges/confidence/nuget/Xamarin.AndroidX.Collection.Ktx/1.5.0.5/1.6.0?slim=true) | --- ### Configuration 📅 **Schedule**: (UTC) - Branch creation - At any time (no schedule defined) - Automerge - At any time (no schedule defined) 🚦 **Automerge**: Disabled by config. Please merge this manually once you are satisfied. ♻ **Rebasing**: Whenever PR becomes conflicted, or you tick the rebase/retry checkbox. 🔕 **Ignore**: Close this PR and you won't be reminded about these updates again. --- - [ ] If you want to rebase/retry this PR, check this box --- This PR was generated by [Mend Renovate](https://mend.io/renovate/). View the [repository job log](https://developer.mend.io/github/reactiveui/ReactiveUI). Co-authored-by: renovate[bot] <29139614+renovate[bot]@users.noreply.github.com> --- src/Directory.Packages.props | 4 ++-- 1 file changed, 2 insertions(+), 2 deletions(-) diff --git a/src/Directory.Packages.props b/src/Directory.Packages.props index dfb7b82e8f..ac146c847c 100644 --- a/src/Directory.Packages.props +++ b/src/Directory.Packages.props @@ -74,8 +74,8 @@ - - + + From 0943b5db060f43a1c61cc0a3053d44ba52357fc4 Mon Sep 17 00:00:00 2001 From: "renovate[bot]" <29139614+renovate[bot]@users.noreply.github.com> Date: Mon, 4 May 2026 22:11:21 +1000 Subject: [PATCH 6/8] chore(deps): update dependency microsoft.windows.sdk.buildtools to 10.0.28000.1839 (#4346) MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit This PR contains the following updates: | Package | Change | [Age](https://docs.renovatebot.com/merge-confidence/) | [Confidence](https://docs.renovatebot.com/merge-confidence/) | |---|---|---|---| | [Microsoft.Windows.SDK.BuildTools](https://aka.ms/WinSDKProjectURL) | `10.0.28000.1721` → `10.0.28000.1839` | ![age](https://developer.mend.io/api/mc/badges/age/nuget/Microsoft.Windows.SDK.BuildTools/10.0.28000.1839?slim=true) | ![confidence](https://developer.mend.io/api/mc/badges/confidence/nuget/Microsoft.Windows.SDK.BuildTools/10.0.28000.1721/10.0.28000.1839?slim=true) | --- ### Configuration 📅 **Schedule**: (UTC) - Branch creation - At any time (no schedule defined) - Automerge - At any time (no schedule defined) 🚦 **Automerge**: Disabled by config. Please merge this manually once you are satisfied. ♻ **Rebasing**: Whenever PR becomes conflicted, or you tick the rebase/retry checkbox. 🔕 **Ignore**: Close this PR and you won't be reminded about this update again. --- - [ ] If you want to rebase/retry this PR, check this box --- This PR was generated by [Mend Renovate](https://mend.io/renovate/). View the [repository job log](https://developer.mend.io/github/reactiveui/ReactiveUI). Co-authored-by: renovate[bot] <29139614+renovate[bot]@users.noreply.github.com> --- src/Directory.Packages.props | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/src/Directory.Packages.props b/src/Directory.Packages.props index ac146c847c..25f7e3f141 100644 --- a/src/Directory.Packages.props +++ b/src/Directory.Packages.props @@ -34,7 +34,7 @@ - + From b54d29b6952ce380b038a76364159070be9fd131 Mon Sep 17 00:00:00 2001 From: "renovate[bot]" <29139614+renovate[bot]@users.noreply.github.com> Date: Mon, 4 May 2026 22:11:47 +1000 Subject: [PATCH 7/8] chore(deps): update akavache to v12 (major) (#4345) MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit This PR contains the following updates: | Package | Change | [Age](https://docs.renovatebot.com/merge-confidence/) | [Confidence](https://docs.renovatebot.com/merge-confidence/) | |---|---|---|---| | [Akavache.Sqlite3](https://redirect.github.com/reactiveui/akavache) | `11.6.1` → `12.0.12` | ![age](https://developer.mend.io/api/mc/badges/age/nuget/Akavache.Sqlite3/12.0.12?slim=true) | ![confidence](https://developer.mend.io/api/mc/badges/confidence/nuget/Akavache.Sqlite3/11.6.1/12.0.12?slim=true) | | [Akavache.SystemTextJson](https://redirect.github.com/reactiveui/akavache) | `11.6.1` → `12.0.12` | ![age](https://developer.mend.io/api/mc/badges/age/nuget/Akavache.SystemTextJson/12.0.12?slim=true) | ![confidence](https://developer.mend.io/api/mc/badges/confidence/nuget/Akavache.SystemTextJson/11.6.1/12.0.12?slim=true) | --- ### Release Notes
reactiveui/akavache (Akavache.Sqlite3) ### [`v12.0.12`](https://redirect.github.com/reactiveui/Akavache/releases/tag/12.0.12) [Compare Source](https://redirect.github.com/reactiveui/akavache/compare/12.0.8...12.0.12) ##### 🗞️ What's Changed ##### 🐛 Fixes - [reactiveui/Akavache@`6d921bd`](https://redirect.github.com/reactiveui/Akavache/commit/6d921bdf3c20ed08dc1696604e1aec9b7125e367) Fixed initialization example after v12 API changes ([#​1187](https://redirect.github.com/reactiveui/akavache/issues/1187)) [@​markuspalme](https://redirect.github.com/markuspalme) - [reactiveui/Akavache@`5b1a4cd`](https://redirect.github.com/reactiveui/Akavache/commit/5b1a4cd867fa1f6d94965888fd6091a8e0939dc0) fix: read Akavache 11.x encrypted databases with v12 ([#​1192](https://redirect.github.com/reactiveui/akavache/issues/1192)) [@​markuspalme](https://redirect.github.com/markuspalme) ##### 📦 Dependencies - [reactiveui/Akavache@`bc5628f`](https://redirect.github.com/reactiveui/Akavache/commit/bc5628faf5f4a424af556ef13e956c5911329054) chore(deps): update dependency microsoft.sourcelink.github to 10.0.203 ([#​1181](https://redirect.github.com/reactiveui/akavache/issues/1181)) [@​renovate](https://redirect.github.com/renovate)\[bot] ##### 📌 Other - [reactiveui/Akavache@`be23347`](https://redirect.github.com/reactiveui/Akavache/commit/be233471e3258d01d1d25723b557a86d2d79672a) Revise serializer compatibility details in README [@​glennawatson](https://redirect.github.com/glennawatson) 🔗 **Full Changelog**: ##### 🙌 Contributions 🌱 New contributors since the last release: [@​markuspalme](https://redirect.github.com/markuspalme) 💖 Thanks to all the contributors: [@​glennawatson](https://redirect.github.com/glennawatson), [@​markuspalme](https://redirect.github.com/markuspalme) 🤖 Automated services that contributed: [@​renovate](https://redirect.github.com/renovate)\[bot] ### [`v12.0.8`](https://redirect.github.com/reactiveui/Akavache/releases/tag/12.0.8) [Compare Source](https://redirect.github.com/reactiveui/akavache/compare/11.6.1...12.0.8) ##### DANGER. Note this is a major upgrade release. We have moved away from the high level SQLite library, and moved towards a low level library. ##### What's Changed - feat: V12 API modernization, AOT-safe assembly config, V10→V11 migration, and full coverage by [@​glennawatson](https://redirect.github.com/glennawatson) in [reactiveui/Akavache#1178](https://redirect.github.com/reactiveui/Akavache/pull/1178) - perf: allocation reduction sweep across Rx, SQLite and serializer hot paths by [@​glennawatson](https://redirect.github.com/glennawatson) in [reactiveui/Akavache#1179](https://redirect.github.com/reactiveui/Akavache/pull/1179) - breaking: replace sqlite-net-pcl with SQLitePCLRaw, extract Akavache.Http by [@​glennawatson](https://redirect.github.com/glennawatson) in [reactiveui/Akavache#1182](https://redirect.github.com/reactiveui/Akavache/pull/1182) - chore(deps): update dependency verify.tunit to 31.16.0 by [@​renovate](https://redirect.github.com/renovate)\[bot] in [reactiveui/Akavache#1183](https://redirect.github.com/reactiveui/Akavache/pull/1183) - chore(deps): update dependency microsoft.windowsappsdk to 1.8.260416003 by [@​renovate](https://redirect.github.com/renovate)\[bot] in [reactiveui/Akavache#1185](https://redirect.github.com/reactiveui/Akavache/pull/1185) - chore(deps): update dependency verify.tunit to 31.16.2 by [@​renovate](https://redirect.github.com/renovate)\[bot] in [reactiveui/Akavache#1184](https://redirect.github.com/reactiveui/Akavache/pull/1184) - chore: Rename Akavache.Http → Akavache.HttpDownloader to avoid NuGet name collision by [@​Copilot](https://redirect.github.com/Copilot) in [reactiveui/Akavache#1186](https://redirect.github.com/reactiveui/Akavache/pull/1186) **Full Changelog**:
--- ### Configuration 📅 **Schedule**: (UTC) - Branch creation - At any time (no schedule defined) - Automerge - At any time (no schedule defined) 🚦 **Automerge**: Disabled by config. Please merge this manually once you are satisfied. ♻ **Rebasing**: Whenever PR becomes conflicted, or you tick the rebase/retry checkbox. 🔕 **Ignore**: Close this PR and you won't be reminded about these updates again. --- - [ ] If you want to rebase/retry this PR, check this box --- This PR was generated by [Mend Renovate](https://mend.io/renovate/). View the [repository job log](https://developer.mend.io/github/reactiveui/ReactiveUI). Co-authored-by: renovate[bot] <29139614+renovate[bot]@users.noreply.github.com> --- src/Directory.Packages.props | 4 ++-- 1 file changed, 2 insertions(+), 2 deletions(-) diff --git a/src/Directory.Packages.props b/src/Directory.Packages.props index 25f7e3f141..b188f889dd 100644 --- a/src/Directory.Packages.props +++ b/src/Directory.Packages.props @@ -20,8 +20,8 @@ 3.1.32 - - + + From 303d1846649be7ecc5a38f392f6c0e955c64493d Mon Sep 17 00:00:00 2001 From: Chris Pulman Date: Mon, 4 May 2026 12:00:11 -0300 Subject: [PATCH 8/8] Fix for 4280 interaction async handler scheduling (#4351) ## What kind of change does this PR introduce? Fix ## What is the current behavior? Closes #4280 ## What is the new behavior? This pull request refactors the `Interaction` handler registration and execution logic in `ReactiveUI` to improve consistency, reliability, and testability, especially around asynchronous handler scenarios. It introduces a unified handler registration core, ensures asynchronous handlers yield to the current context, and updates tests to use modern async/await patterns. Several new tests are also added to verify scheduler release behavior for both task-based and observable handlers. **Core handler registration and async context improvements:** * Introduced a private `RegisterHandlerCore` method to centralize handler registration and disposal logic, replacing duplicated code in `RegisterHandler` overloads. [[1]](diffhunk://#diff-4c32538b51e31225de509d21d08ea6cfd573a7df738530957077df781d5d3ba9L90-R129) [[2]](diffhunk://#diff-4c32538b51e31225de509d21d08ea6cfd573a7df738530957077df781d5d3ba9R172-R192) * Added a private `YieldToCurrentContext` method to ensure asynchronous handlers (both `Task` and `IObservable`-based) yield execution before invoking user code, preventing scheduler trampoline issues and improving nested interaction support. [[1]](diffhunk://#diff-4c32538b51e31225de509d21d08ea6cfd573a7df738530957077df781d5d3ba9L90-R129) [[2]](diffhunk://#diff-4c32538b51e31225de509d21d08ea6cfd573a7df738530957077df781d5d3ba9R172-R192) **Test modernization and reliability:** * Updated all relevant tests in `InteractionsTest.cs` to use `async`/`await` and `ToTask()` instead of synchronous blocking (`Wait()`/`FirstAsync().Wait()`), improving test reliability and better reflecting real-world async usage. [[1]](diffhunk://#diff-f2ed870457c71b058a31b5f78fde682cb52406c51bd54a6626d8f71de439d2ceL26-R28) [[2]](diffhunk://#diff-f2ed870457c71b058a31b5f78fde682cb52406c51bd54a6626d8f71de439d2ceL45-R60) [[3]](diffhunk://#diff-f2ed870457c71b058a31b5f78fde682cb52406c51bd54a6626d8f71de439d2ceL177-R258) [[4]](diffhunk://#diff-f2ed870457c71b058a31b5f78fde682cb52406c51bd54a6626d8f71de439d2ceL210-R277) [[5]](diffhunk://#diff-f2ed870457c71b058a31b5f78fde682cb52406c51bd54a6626d8f71de439d2ceL225-R304) [[6]](diffhunk://#diff-f2ed870457c71b058a31b5f78fde682cb52406c51bd54a6626d8f71de439d2ceL264-R345) * Marked the `InteractionsTest` class with `[NotInParallel]` to ensure tests are not run in parallel, avoiding flakiness due to shared state. **Expanded test coverage for async handler behavior:** * Added tests to verify that both `Task`-based and `IObservable`-based handlers release the current scheduler before executing user code, ensuring nested interactions are not blocked and behave as expected. **Test improvements for handler precedence and subscription timing:** * Enhanced tests to more reliably detect when handlers are subscribed and to ensure correct handler precedence, especially in asynchronous scenarios, using `TaskCompletionSource` for precise subscription tracking. [[1]](diffhunk://#diff-f2ed870457c71b058a31b5f78fde682cb52406c51bd54a6626d8f71de439d2ceR100-R120) [[2]](diffhunk://#diff-f2ed870457c71b058a31b5f78fde682cb52406c51bd54a6626d8f71de439d2ceR129) These changes collectively make handler registration more robust, improve async interaction patterns, and ensure that both the implementation and test suite are future-proof and reliable. ## What might this PR break? ## Checklist - [x] I have read the [Contribute guide](https://www.reactiveui.net/contribute/index.html) - [x] Tests have been added or updated (for bug fixes / features) - [ ] Docs have been added or updated (for bug fixes / features) - [x] Changes target the `main` branch - [x] PR title follows [Conventional Commits](https://www.conventionalcommits.org/) ## Additional information --- src/ReactiveUI/Interactions/Interaction.cs | 49 +++++- .../ReactiveUI.Tests/InteractionsTest.cs | 160 +++++++++++++----- 2 files changed, 162 insertions(+), 47 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> { 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/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"); } }