From cfee3b80ab6f7c744a164f0206c646cf62bbe360 Mon Sep 17 00:00:00 2001 From: Easley Date: Fri, 20 Mar 2026 09:24:24 +0100 Subject: [PATCH 01/17] fix: StartTime should use DateTimeOffset.UtcNow instead of DateTime.UtcNow DateTime.UtcNow causes an implicit conversion when assigned to a DateTimeOffset, which loses the explicit UTC offset semantics. DateTimeOffset.UtcNow is the correct and explicit way to capture the current UTC time as a DateTimeOffset. Co-Authored-By: Claude Sonnet 4.6 --- src/AsyncNavigation/NavigationContext.cs | 8 ++++---- 1 file changed, 4 insertions(+), 4 deletions(-) diff --git a/src/AsyncNavigation/NavigationContext.cs b/src/AsyncNavigation/NavigationContext.cs index 9fdba97..92b8f86 100644 --- a/src/AsyncNavigation/NavigationContext.cs +++ b/src/AsyncNavigation/NavigationContext.cs @@ -55,10 +55,10 @@ public INavigationParameters? Parameters public SingleAssignment Target { get; } = new(); public SingleAssignment IndicatorHost { get; } = new(); - public DateTimeOffset StartTime - { - get; - } = DateTime.UtcNow; + public DateTimeOffset StartTime + { + get; + } = DateTimeOffset.UtcNow; public DateTimeOffset EndTime { From 5bcaeac092d8c2b09f8575b5fcccd386d167e3eb Mon Sep 17 00:00:00 2001 From: Easley Date: Fri, 20 Mar 2026 09:26:27 +0100 Subject: [PATCH 02/17] fix: FrontShowAsync window overload discarded close task causing unobserved exceptions The window-based FrontShowAsync overload used fire-and-forget `_ = HandleCloseInternalAsync(...)` while the view-based overload correctly awaited it. This meant any exception thrown during dialog close (OnDialogClosingAsync, OnDialogClosedAsync) would be silently lost as an UnobservedTaskException. Fixed to await the close task consistently with the view overload. Co-Authored-By: Claude Sonnet 4.6 --- src/AsyncNavigation/DialogService.cs | 9 +++++---- 1 file changed, 5 insertions(+), 4 deletions(-) diff --git a/src/AsyncNavigation/DialogService.cs b/src/AsyncNavigation/DialogService.cs index 71282a6..3cd0559 100644 --- a/src/AsyncNavigation/DialogService.cs +++ b/src/AsyncNavigation/DialogService.cs @@ -126,9 +126,9 @@ public IDialogResult ShowDialog(string windowName, return _platformService.WaitOnDispatcher(showTask); } - public async Task FrontShowAsync(string windowName, - Func mainWindowBuilder, - IDialogParameters? parameters, + public async Task FrontShowAsync(string windowName, + Func mainWindowBuilder, + IDialogParameters? parameters, CancellationToken cancellationToken) where TWindow : class { var (dialogWindow, aware) = PrepareDialogWindow(windowName); @@ -136,9 +136,10 @@ public async Task FrontShowAsync(string windowName, var openTask = aware.OnDialogOpenedAsync(parameters, cancellationToken); await openTask; - _ = HandleCloseInternalAsync(dialogWindow, aware, mainWindowBuilder); + var closeTask = HandleCloseInternalAsync(dialogWindow, aware, mainWindowBuilder); _platformService.ShowMainWindow(dialogWindow); _platformService.Show(dialogWindow, false); + await closeTask; } From d64740e6918947548d8408b7afeb6abe3d8446d5 Mon Sep 17 00:00:00 2001 From: Easley Date: Fri, 20 Mar 2026 09:27:08 +0100 Subject: [PATCH 03/17] fix: ObserveCloseTask silently swallowed dialog close exceptions The faulted task branch only read t.Exception to mark it observed but discarded the information entirely. Exceptions from fire-and-forget Show() dialog operations would vanish without any trace, making them impossible to diagnose. Now logs the exception via Debug.WriteLine so they are at least visible during development. Co-Authored-By: Claude Sonnet 4.6 --- src/AsyncNavigation/DialogService.cs | 5 ++--- 1 file changed, 2 insertions(+), 3 deletions(-) diff --git a/src/AsyncNavigation/DialogService.cs b/src/AsyncNavigation/DialogService.cs index 3cd0559..bc3a96e 100644 --- a/src/AsyncNavigation/DialogService.cs +++ b/src/AsyncNavigation/DialogService.cs @@ -151,10 +151,9 @@ private static void ObserveCloseTask(Task closeTask, Action Date: Fri, 20 Mar 2026 09:28:14 +0100 Subject: [PATCH 04/17] fix: rename GoForward/GoBack to GoForwardAsync/GoBackAsync in IRegionManager Methods that return Task must follow the Async naming convention per .NET guidelines. IRegionManager had GoForward/GoBack without the Async suffix, while IRegion correctly used GoForwardAsync/GoBackAsync - causing inconsistency within the same framework. Updated all call sites in samples and tests accordingly. Co-Authored-By: Claude Sonnet 4.6 --- samples/Sample.Common/DViewModel.cs | 4 ++-- samples/Sample.Common/ListBoxRegionViewModel.cs | 4 ++-- samples/Sample.Common/MainWindowViewModel.cs | 4 ++-- src/AsyncNavigation/Abstractions/IRegionManager.cs | 4 ++-- src/AsyncNavigation/RegionManagerBase.cs | 4 ++-- tests/AsyncNavigation.Tests/RegionManagerTests.cs | 4 ++-- 6 files changed, 12 insertions(+), 12 deletions(-) diff --git a/samples/Sample.Common/DViewModel.cs b/samples/Sample.Common/DViewModel.cs index 55dc3fa..b27071a 100644 --- a/samples/Sample.Common/DViewModel.cs +++ b/samples/Sample.Common/DViewModel.cs @@ -28,13 +28,13 @@ private void AsyncNavigateAndForget(string param) [ReactiveCommand] private async Task GoForward() { - await _regionManager.GoForward("TabRegion"); + await _regionManager.GoForwardAsync("TabRegion"); } [ReactiveCommand] private async Task GoBack() { - await _regionManager.GoBack("TabRegion"); + await _regionManager.GoBackAsync("TabRegion"); } [ReactiveCommand] private async Task RequestUnloadView(NavigationContext navigationContext) diff --git a/samples/Sample.Common/ListBoxRegionViewModel.cs b/samples/Sample.Common/ListBoxRegionViewModel.cs index 7f9d141..4d59376 100644 --- a/samples/Sample.Common/ListBoxRegionViewModel.cs +++ b/samples/Sample.Common/ListBoxRegionViewModel.cs @@ -20,12 +20,12 @@ private async Task AsyncNavigate(string param) [ReactiveCommand] private async Task GoForward() { - await _regionManager.GoForward("CustomListBoxRegion"); + await _regionManager.GoForwardAsync("CustomListBoxRegion"); } [ReactiveCommand] private async Task GoBack() { - await _regionManager.GoBack("CustomListBoxRegion"); + await _regionManager.GoBackAsync("CustomListBoxRegion"); } } diff --git a/samples/Sample.Common/MainWindowViewModel.cs b/samples/Sample.Common/MainWindowViewModel.cs index 4a34d0b..32fd4cc 100644 --- a/samples/Sample.Common/MainWindowViewModel.cs +++ b/samples/Sample.Common/MainWindowViewModel.cs @@ -158,13 +158,13 @@ private void ShowWindow(string param) [ReactiveCommand] private async Task GoForward() { - await _regionManager.GoForward("MainRegion"); + await _regionManager.GoForwardAsync("MainRegion"); } [ReactiveCommand] private async Task GoBack() { - await _regionManager.GoBack("MainRegion"); + await _regionManager.GoBackAsync("MainRegion"); } [ReactiveCommand] diff --git a/src/AsyncNavigation/Abstractions/IRegionManager.cs b/src/AsyncNavigation/Abstractions/IRegionManager.cs index 19acf4e..ceb1aeb 100644 --- a/src/AsyncNavigation/Abstractions/IRegionManager.cs +++ b/src/AsyncNavigation/Abstractions/IRegionManager.cs @@ -40,6 +40,6 @@ Task RequestPathNavigateAsync(string path, Task CanGoForwardAsync(string regionName); Task CanGoBackAsync(string regionName); - Task GoForward(string regionName, CancellationToken cancellationToken = default); - Task GoBack(string regionName, CancellationToken cancellationToken = default); + Task GoForwardAsync(string regionName, CancellationToken cancellationToken = default); + Task GoBackAsync(string regionName, CancellationToken cancellationToken = default); } diff --git a/src/AsyncNavigation/RegionManagerBase.cs b/src/AsyncNavigation/RegionManagerBase.cs index dffbba7..ea6b947 100644 --- a/src/AsyncNavigation/RegionManagerBase.cs +++ b/src/AsyncNavigation/RegionManagerBase.cs @@ -157,7 +157,7 @@ public bool TryRemoveRegion(string name, [MaybeNullWhen(false)] out IRegion regi return false; } - public async Task GoForward(string regionName, CancellationToken cancellationToken = default) + public async Task GoForwardAsync(string regionName, CancellationToken cancellationToken = default) { var region = GetRegion(regionName); if (await region.CanGoForwardAsync()) @@ -175,7 +175,7 @@ public async Task GoForward(string regionName, CancellationTok return NavigationResult.Failure(new NavigationException("Cannot go forward.")); } - public async Task GoBack(string regionName, CancellationToken cancellationToken = default) + public async Task GoBackAsync(string regionName, CancellationToken cancellationToken = default) { var region = GetRegion(regionName); if (await region.CanGoBackAsync()) diff --git a/tests/AsyncNavigation.Tests/RegionManagerTests.cs b/tests/AsyncNavigation.Tests/RegionManagerTests.cs index bb6708c..acf0570 100644 --- a/tests/AsyncNavigation.Tests/RegionManagerTests.cs +++ b/tests/AsyncNavigation.Tests/RegionManagerTests.cs @@ -133,7 +133,7 @@ public async Task GoBack_ShouldActivateView() _regionManager.AddRegion("Main", region); _ = await _regionManager.RequestNavigateAsync("Main", "TestView"); _ = await _regionManager.RequestNavigateAsync("Main", "AnotherTestView"); - var result = await _regionManager.GoBack("Main"); + var result = await _regionManager.GoBackAsync("Main"); Assert.True(result.IsSuccessful); } [Fact] @@ -146,7 +146,7 @@ public async Task GoBack_ShouldCancel() cancellationTokenSource.Cancel(); _ = await _regionManager.RequestNavigateAsync("Main", "TestView"); _ = await _regionManager.RequestNavigateAsync("Main", "AnotherTestView"); - var result = await _regionManager.GoBack("Main", cancellationToken: cancellationTokenSource.Token); + var result = await _regionManager.GoBackAsync("Main", cancellationToken: cancellationTokenSource.Token); Assert.True(result.IsCancelled); } } From d1d31189bb7fd64867cf06d19b5d5e67f05291a2 Mon Sep 17 00:00:00 2001 From: Easley Date: Fri, 20 Mar 2026 09:28:54 +0100 Subject: [PATCH 05/17] fix: add [Obsolete] attribute to MaxCachedViews to enforce documented deprecation The XML docs already stated MaxCachedViews was obsolete, but without the [Obsolete] attribute the compiler emits no warning, making the deprecation notice invisible to callers. Added the attribute so IDEs and the compiler surface the warning automatically. Internal usages are suppressed with #pragma warning disable CS0618. Co-Authored-By: Claude Sonnet 4.6 --- src/AsyncNavigation/NavigationOptions.cs | 8 +++++--- src/AsyncNavigation/ViewManager.cs | 2 ++ 2 files changed, 7 insertions(+), 3 deletions(-) diff --git a/src/AsyncNavigation/NavigationOptions.cs b/src/AsyncNavigation/NavigationOptions.cs index a317038..dd4079c 100644 --- a/src/AsyncNavigation/NavigationOptions.cs +++ b/src/AsyncNavigation/NavigationOptions.cs @@ -20,10 +20,10 @@ public class NavigationOptions /// Gets or sets the maximum number of cached views in the navigation system. /// /// - /// This property is obsolete. Use instead. - /// MaxHistoryItems represents the maximum number of navigation history items globally, - /// while MaxCachedViews only controlled per-region view caching in the old design. + /// This property is obsolete. Use for history size + /// or configure caching per-region via the PreferCache attached property. /// + [Obsolete("MaxCachedViews is obsolete. Use MaxHistoryItems for history size or configure caching per-region via the PreferCache attached property.")] public int MaxCachedViews { get; set; } = 10; /// @@ -113,8 +113,10 @@ public void MergeFrom(NavigationOptions other) { if (other == null) return; +#pragma warning disable CS0618 // MaxCachedViews is obsolete but MergeFrom must handle it for backwards compatibility if (other.MaxCachedViews != Default.MaxCachedViews) MaxCachedViews = other.MaxCachedViews; +#pragma warning restore CS0618 if (other.MaxHistoryItems != Default.MaxHistoryItems) MaxHistoryItems = other.MaxHistoryItems; diff --git a/src/AsyncNavigation/ViewManager.cs b/src/AsyncNavigation/ViewManager.cs index 49c92ff..875abf4 100644 --- a/src/AsyncNavigation/ViewManager.cs +++ b/src/AsyncNavigation/ViewManager.cs @@ -18,7 +18,9 @@ internal sealed class ViewManager : IViewManager public ViewManager(NavigationOptions options, IViewFactory viewFactory) { _strategy = options.ViewCacheStrategy; +#pragma warning disable CS0618 // MaxCachedViews is obsolete but still used internally for view cache size _maxCacheSize = options.MaxCachedViews; +#pragma warning restore CS0618 _viewFactory = viewFactory; } From ff7374bb23c51a65be62e9565a8e6d91f5a80c4e Mon Sep 17 00:00:00 2001 From: Easley Date: Fri, 20 Mar 2026 09:35:14 +0100 Subject: [PATCH 06/17] fix: correct spelling of AddSingletonWitAllMembers/AddTransientWitAllMembers (missing 'h') The public API methods had a typo: 'Wit' instead of 'With'. Introduced correctly-spelled AddSingletonWithAllMembers and AddTransientWithAllMembers as the canonical versions, and marked the misspelled originals with [Obsolete] pointing to the new names to preserve backwards compatibility without breaking existing callers. Co-Authored-By: Claude Sonnet 4.6 --- .../DependencyInjectionExtensions.cs | 34 ++++++++++++++++--- 1 file changed, 29 insertions(+), 5 deletions(-) diff --git a/src/AsyncNavigation/DependencyInjectionExtensions.cs b/src/AsyncNavigation/DependencyInjectionExtensions.cs index 08f72cb..7528faa 100644 --- a/src/AsyncNavigation/DependencyInjectionExtensions.cs +++ b/src/AsyncNavigation/DependencyInjectionExtensions.cs @@ -260,7 +260,7 @@ private static IServiceCollection RegisterViewModelAndView /// Registers a service as a singleton with all members dynamically accessible. /// - public static IServiceCollection AddSingletonWitAllMembers<[DynamicallyAccessedMembers(DynamicallyAccessedMemberTypes.All)] T>( + public static IServiceCollection AddSingletonWithAllMembers<[DynamicallyAccessedMembers(DynamicallyAccessedMemberTypes.All)] T>( this IServiceCollection serviceDescriptors) where T : class { return serviceDescriptors.AddSingleton(); @@ -268,7 +268,7 @@ private static IServiceCollection RegisterViewModelAndView /// Registers a service as a singleton with all members dynamically accessible. /// - public static IServiceCollection AddSingletonWitAllMembers<[DynamicallyAccessedMembers(DynamicallyAccessedMemberTypes.All)] T>( + public static IServiceCollection AddSingletonWithAllMembers<[DynamicallyAccessedMembers(DynamicallyAccessedMemberTypes.All)] T>( this IServiceCollection serviceDescriptors, Func builder) where T : class { return serviceDescriptors.AddSingleton(builder); @@ -276,7 +276,7 @@ private static IServiceCollection RegisterViewModelAndView /// Registers a service as a transient with all members dynamically accessible. /// - public static IServiceCollection AddTransientWitAllMembers<[DynamicallyAccessedMembers(DynamicallyAccessedMemberTypes.All)] T>( + public static IServiceCollection AddTransientWithAllMembers<[DynamicallyAccessedMembers(DynamicallyAccessedMemberTypes.All)] T>( this IServiceCollection serviceDescriptors) where T : class { return serviceDescriptors.AddTransient(); @@ -284,11 +284,35 @@ private static IServiceCollection RegisterViewModelAndView /// Registers a service as a transient with all members dynamically accessible. /// - public static IServiceCollection AddTransientWitAllMembers<[DynamicallyAccessedMembers(DynamicallyAccessedMemberTypes.All)] T>( + public static IServiceCollection AddTransientWithAllMembers<[DynamicallyAccessedMembers(DynamicallyAccessedMemberTypes.All)] T>( this IServiceCollection serviceDescriptors, Func builder) where T : class { return serviceDescriptors.AddTransient(builder); } + + /// + [Obsolete("Use AddSingletonWithAllMembers instead. This method has a typo in its name.")] + public static IServiceCollection AddSingletonWitAllMembers<[DynamicallyAccessedMembers(DynamicallyAccessedMemberTypes.All)] T>( + this IServiceCollection serviceDescriptors) where T : class + => AddSingletonWithAllMembers(serviceDescriptors); + + /// + [Obsolete("Use AddSingletonWithAllMembers instead. This method has a typo in its name.")] + public static IServiceCollection AddSingletonWitAllMembers<[DynamicallyAccessedMembers(DynamicallyAccessedMemberTypes.All)] T>( + this IServiceCollection serviceDescriptors, Func builder) where T : class + => AddSingletonWithAllMembers(serviceDescriptors, builder); + + /// + [Obsolete("Use AddTransientWithAllMembers instead. This method has a typo in its name.")] + public static IServiceCollection AddTransientWitAllMembers<[DynamicallyAccessedMembers(DynamicallyAccessedMemberTypes.All)] T>( + this IServiceCollection serviceDescriptors) where T : class + => AddTransientWithAllMembers(serviceDescriptors); + + /// + [Obsolete("Use AddTransientWithAllMembers instead. This method has a typo in its name.")] + public static IServiceCollection AddTransientWitAllMembers<[DynamicallyAccessedMembers(DynamicallyAccessedMemberTypes.All)] T>( + this IServiceCollection serviceDescriptors, Func builder) where T : class + => AddTransientWithAllMembers(serviceDescriptors, builder); /// /// Registers a dialog window and its associated view model using the specified window name as a key. /// @@ -329,7 +353,7 @@ public static IServiceCollection RegisterRouter( { ArgumentNullException.ThrowIfNull(services); - services.AddSingletonWitAllMembers(sp => + services.AddSingletonWithAllMembers(sp => { var router = ActivatorUtilities.CreateInstance(sp); configureRoutes?.Invoke(router, sp); From fd97b4deb4d5ebc8f33438507f94106ae3cf8479 Mon Sep 17 00:00:00 2001 From: Easley Date: Fri, 20 Mar 2026 09:35:58 +0100 Subject: [PATCH 07/17] fix: correct typo OnResovleViewAsync -> OnResolveViewAsync in RegionNavigationService 'Resovle' was a transposition typo. This is an internal private method so there is no public API impact. Co-Authored-By: Claude Sonnet 4.6 --- src/AsyncNavigation/RegionNavigationService.cs | 6 +++--- 1 file changed, 3 insertions(+), 3 deletions(-) diff --git a/src/AsyncNavigation/RegionNavigationService.cs b/src/AsyncNavigation/RegionNavigationService.cs index 6150ec3..190716e 100644 --- a/src/AsyncNavigation/RegionNavigationService.cs +++ b/src/AsyncNavigation/RegionNavigationService.cs @@ -92,13 +92,13 @@ private Task RunNavigationAsync(NavigationContext context, NavigationPipelineMod [ OnRenderIndicatorAsync, OnBeforeNavigationAsync, - OnResovleViewAsync, + OnResolveViewAsync, OnAfterNavigationAsync ], NavigationPipelineMode.ResolveFirst => [ OnBeforeNavigationAsync, - OnResovleViewAsync, + OnResolveViewAsync, OnRenderIndicatorAsync, OnAfterNavigationAsync ], @@ -115,7 +115,7 @@ private Task OnRenderIndicatorAsync(NavigationContext navigationContext) navigationContext.CancellationToken.ThrowIfCancellationRequested(); return Task.CompletedTask; } - private async Task OnResovleViewAsync(NavigationContext navigationContext) + private async Task OnResolveViewAsync(NavigationContext navigationContext) { if (navigationContext.Target.IsSet) { From c0f35b09ace5788725bb211f8b19634d0755870c Mon Sep 17 00:00:00 2001 From: Easley Date: Fri, 20 Mar 2026 09:36:58 +0100 Subject: [PATCH 08/17] fix: WeakAsyncEventHandler use generic WeakReference and fix TOCTOU race Two issues fixed: 1. Used non-generic WeakReference which lacks type safety and requires casting. Replaced with WeakReference to match the generic API. 2. TOCTOU (time-of-check/time-of-use) race: previously checked IsAlive then invoked the handler, but GC could collect the target between the two operations. Fixed by using TryGetTarget() to atomically obtain a strong reference, preventing the target from being collected while the handler runs. Co-Authored-By: Claude Sonnet 4.6 --- src/AsyncNavigation/Core/WeakAsyncEventHandler.cs | 10 ++++++---- 1 file changed, 6 insertions(+), 4 deletions(-) diff --git a/src/AsyncNavigation/Core/WeakAsyncEventHandler.cs b/src/AsyncNavigation/Core/WeakAsyncEventHandler.cs index 40e7267..df9a820 100644 --- a/src/AsyncNavigation/Core/WeakAsyncEventHandler.cs +++ b/src/AsyncNavigation/Core/WeakAsyncEventHandler.cs @@ -1,8 +1,8 @@ -namespace AsyncNavigation.Core; +namespace AsyncNavigation.Core; internal sealed class WeakAsyncEventHandler where TEventArgs : EventArgs { - private readonly WeakReference _targetRef; + private readonly WeakReference _targetRef; private readonly AsyncEventHandler _handler; private readonly Action> _unsubscribe; @@ -11,14 +11,16 @@ public WeakAsyncEventHandler( AsyncEventHandler handler, Action> unsubscribe) { - _targetRef = new WeakReference(target); + _targetRef = new WeakReference(target); _handler = handler; _unsubscribe = unsubscribe; } public async Task InvokeAsync(object sender, TEventArgs args) { - if (_targetRef.IsAlive) + // Obtain a strong reference first to avoid the TOCTOU race where the target + // could be collected between the liveness check and the handler invocation. + if (_targetRef.TryGetTarget(out _)) { await _handler(sender, args); } From 00dc5b125d9a3e209db28c51419323fe4990d56a Mon Sep 17 00:00:00 2001 From: Easley Date: Fri, 20 Mar 2026 09:38:43 +0100 Subject: [PATCH 09/17] fix: AsyncJobProcessor CancelCurrent strategy must await job completion after cancellation CancelAsync() only signals the CancellationToken; it does not wait for the job task to actually finish. Without awaiting WaitAllAsync() afterwards, a new navigation job could start while the previous job is still executing its catch/finally cleanup, causing concurrent navigation state corruption. Fixed by awaiting WaitAllAsync() after CancelAllAsync() in the CancelCurrent branch. Co-Authored-By: Claude Sonnet 4.6 --- src/AsyncNavigation/AsyncJobProcessor.cs | 5 +++++ 1 file changed, 5 insertions(+) diff --git a/src/AsyncNavigation/AsyncJobProcessor.cs b/src/AsyncNavigation/AsyncJobProcessor.cs index 0fe8e2c..a7edc91 100644 --- a/src/AsyncNavigation/AsyncJobProcessor.cs +++ b/src/AsyncNavigation/AsyncJobProcessor.cs @@ -57,7 +57,12 @@ private async ValueTask HandleExistingJobs(NavigationJobStrategy navigationJobSt switch (navigationJobStrategy) { case NavigationJobStrategy.CancelCurrent: + // Cancel all in-flight jobs, then wait for them to actually finish. + // CancelAsync() only signals cancellation; without WaitAllAsync() the + // new job could start while previous jobs are still running their + // finally/cleanup blocks, leading to concurrent navigation state. await CancelAllAsync(); + await WaitAllAsync(); break; case NavigationJobStrategy.Queue: await WaitAllAsync(); From 19d5283df62bfb3b0ba85df3f18bdf037bc59be7 Mon Sep 17 00:00:00 2001 From: Easley Date: Fri, 20 Mar 2026 09:42:45 +0100 Subject: [PATCH 10/17] fix: NavigationResult factory methods should not mutate NavigationContext arguments Static factory methods (Success, Failure, Cancelled) were calling navigationContext.UpdateStatus() as a side effect, which violates the principle that factory methods should not modify their arguments. Callers cannot predict or control when the status changes, making the context lifecycle opaque. Moved all UpdateStatus() calls to the call sites (RegionBase, RegionManagerBase) so the state transition is explicit and visible at each navigation outcome site. Factory methods now only read the context's current state. Co-Authored-By: Claude Sonnet 4.6 --- src/AsyncNavigation/Core/NavigationResult.cs | 4 ---- src/AsyncNavigation/RegionBase.cs | 3 +++ src/AsyncNavigation/RegionManagerBase.cs | 5 +++++ 3 files changed, 8 insertions(+), 4 deletions(-) diff --git a/src/AsyncNavigation/Core/NavigationResult.cs b/src/AsyncNavigation/Core/NavigationResult.cs index 17fae11..608728d 100644 --- a/src/AsyncNavigation/Core/NavigationResult.cs +++ b/src/AsyncNavigation/Core/NavigationResult.cs @@ -17,7 +17,6 @@ private NavigationResult() public static NavigationResult Success(NavigationContext navigationContext) { - navigationContext.UpdateStatus(NavigationStatus.Succeeded); return new NavigationResult { NavigationContext = navigationContext, @@ -39,7 +38,6 @@ public static NavigationResult Success(TimeSpan? duration = null) public static NavigationResult Failure(Exception exception, NavigationContext navigationContext) { - navigationContext.UpdateStatus(NavigationStatus.Failed, exception); return new NavigationResult { NavigationContext = navigationContext, @@ -62,8 +60,6 @@ public static NavigationResult Failure(Exception exception) public static NavigationResult Cancelled(NavigationContext navigationContext) { - navigationContext.UpdateStatus(NavigationStatus.Cancelled); - return new NavigationResult { NavigationContext = navigationContext, diff --git a/src/AsyncNavigation/RegionBase.cs b/src/AsyncNavigation/RegionBase.cs index 08b797f..26e42d1 100644 --- a/src/AsyncNavigation/RegionBase.cs +++ b/src/AsyncNavigation/RegionBase.cs @@ -54,6 +54,7 @@ async Task IRegion.ActivateViewAsync(NavigationContext navigat { await _regionNavigationService.RequestNavigateAsync(navigationContext); _navigationHistory.Add(navigationContext); + navigationContext.UpdateStatus(NavigationStatus.Succeeded); var result = NavigationResult.Success(navigationContext); RaiseNavigated(navigationContext); return result; @@ -70,6 +71,7 @@ public async Task GoBackAsync(CancellationToken cancellationTo navigationContext.IsBackNavigation = true; navigationContext.LinkCancellationToken(cancellationToken); await _regionNavigationService.RequestNavigateAsync(navigationContext); + navigationContext.UpdateStatus(NavigationStatus.Succeeded); var result = NavigationResult.Success(navigationContext); RaiseNavigated(navigationContext); return result; @@ -87,6 +89,7 @@ public async Task GoForwardAsync(CancellationToken cancellatio navigationContext.IsForwardNavigation = true; navigationContext.LinkCancellationToken(cancellationToken); await _regionNavigationService.RequestNavigateAsync(navigationContext); + navigationContext.UpdateStatus(NavigationStatus.Succeeded); var result = NavigationResult.Success(navigationContext); RaiseNavigated(navigationContext); return result; diff --git a/src/AsyncNavigation/RegionManagerBase.cs b/src/AsyncNavigation/RegionManagerBase.cs index ea6b947..fad168e 100644 --- a/src/AsyncNavigation/RegionManagerBase.cs +++ b/src/AsyncNavigation/RegionManagerBase.cs @@ -88,7 +88,10 @@ private async Task PerformNavigationAsync( context.LinkCancellationToken(cancellationToken); if (context.CancellationToken.IsCancellationRequested) + { + context.UpdateStatus(NavigationStatus.Cancelled); return NavigationResult.Cancelled(context); + } try { @@ -260,10 +263,12 @@ private static async Task HandleNavigationException(Exception { Debug.WriteLine($"[Cancel] {context}"); await region.RevertAsync(context); + context.UpdateStatus(NavigationStatus.Cancelled); return NavigationResult.Cancelled(context); } Debug.WriteLine($"[Error] {context} -> {ex}"); + context.UpdateStatus(NavigationStatus.Failed, ex); return NavigationResult.Failure(ex, context); } protected static void OnAddRegionNameCore(string name, object d, IServiceProvider? sp, bool? preferCache) From b1793f5adcc7aee12681517cd2d15f6da4a5933c Mon Sep 17 00:00:00 2001 From: Easley Date: Fri, 20 Mar 2026 09:45:25 +0100 Subject: [PATCH 11/17] fix: NavigationOptions.MergeFrom compared against Default.X causing self-comparison bug MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit MergeFrom is called as NavigationOptions.Default.MergeFrom(userOptions), meaning 'this == Default'. Comparing other.X against Default.X was actually comparing against this.X — a self-comparison that would silently fail if Default had already been mutated, and prevented users from ever restoring a property back to its default value. Extracted factory default values as internal constants (DefaultMaxHistoryItems, etc.) and compare against those instead. This ensures the comparison is stable regardless of the current state of the Default instance. Co-Authored-By: Claude Sonnet 4.6 --- src/AsyncNavigation/NavigationOptions.cs | 28 ++++++++++++++++++------ 1 file changed, 21 insertions(+), 7 deletions(-) diff --git a/src/AsyncNavigation/NavigationOptions.cs b/src/AsyncNavigation/NavigationOptions.cs index dd4079c..c2111c1 100644 --- a/src/AsyncNavigation/NavigationOptions.cs +++ b/src/AsyncNavigation/NavigationOptions.cs @@ -8,6 +8,15 @@ namespace AsyncNavigation; /// public class NavigationOptions { + // Factory defaults used by MergeFrom to detect user-overridden values. + // Kept in sync with the property initializers below. + internal const int DefaultMaxCachedViews = 10; + internal const int DefaultMaxHistoryItems = 10; + internal const int DefaultMaxReplayItems = 10; + internal static readonly TimeSpan DefaultLoadingIndicatorDelay = TimeSpan.FromMilliseconds(100); + internal const NavigationJobStrategy DefaultNavigationJobStrategy = NavigationJobStrategy.CancelCurrent; + internal const NavigationJobScope DefaultNavigationJobScope = NavigationJobScope.Region; + private int _loadingIndicatorRegistered; private int _errorIndicatorRegistered; @@ -106,31 +115,36 @@ internal void EnsureSingleErrorIndicator() /// /// Merges the properties of another instance into this instance, - /// only overwriting properties that differ from the default values. + /// only overwriting properties that differ from the factory default values. /// + /// + /// Compares each property on against the known factory defaults + /// (not against Default) so the comparison is stable even when called on the + /// Default instance itself. + /// /// The options to merge from. Can be null. public void MergeFrom(NavigationOptions other) { if (other == null) return; #pragma warning disable CS0618 // MaxCachedViews is obsolete but MergeFrom must handle it for backwards compatibility - if (other.MaxCachedViews != Default.MaxCachedViews) + if (other.MaxCachedViews != DefaultMaxCachedViews) MaxCachedViews = other.MaxCachedViews; #pragma warning restore CS0618 - if (other.MaxHistoryItems != Default.MaxHistoryItems) + if (other.MaxHistoryItems != DefaultMaxHistoryItems) MaxHistoryItems = other.MaxHistoryItems; - if (other.LoadingIndicatorDelay != Default.LoadingIndicatorDelay) + if (other.LoadingIndicatorDelay != DefaultLoadingIndicatorDelay) LoadingIndicatorDelay = other.LoadingIndicatorDelay; - if (other.NavigationJobStrategy != Default.NavigationJobStrategy) + if (other.NavigationJobStrategy != DefaultNavigationJobStrategy) NavigationJobStrategy = other.NavigationJobStrategy; - if (other.NavigationJobScope != Default.NavigationJobScope) + if (other.NavigationJobScope != DefaultNavigationJobScope) NavigationJobScope = other.NavigationJobScope; - if (other.MaxReplayItems != Default.MaxReplayItems) + if (other.MaxReplayItems != DefaultMaxReplayItems) MaxReplayItems = other.MaxReplayItems; } From ab6173eafe241dcef3f57ac6de52e20eb6a44994 Mon Sep 17 00:00:00 2001 From: Easley Date: Fri, 20 Mar 2026 09:45:58 +0100 Subject: [PATCH 12/17] feat: add NavigationAwareBase abstract base class to reduce INavigationAware boilerplate INavigationAware has 5 methods and 1 event that all must be implemented, even when only a subset of the lifecycle is relevant. This forces users to write empty method bodies throughout their codebase. NavigationAwareBase provides sensible no-op defaults: - InitializeAsync/OnNavigatedToAsync/OnNavigatedFromAsync/OnUnloadAsync: do nothing - IsNavigationTargetAsync: returns true (always reuse cached instance) - AsyncRequestUnloadEvent: declared; RequestUnloadAsync() helper raises it Users can now inherit from NavigationAwareBase and only override the methods they need. Co-Authored-By: Claude Sonnet 4.6 --- src/AsyncNavigation/NavigationAwareBase.cs | 67 ++++++++++++++++++++++ 1 file changed, 67 insertions(+) create mode 100644 src/AsyncNavigation/NavigationAwareBase.cs diff --git a/src/AsyncNavigation/NavigationAwareBase.cs b/src/AsyncNavigation/NavigationAwareBase.cs new file mode 100644 index 0000000..a037825 --- /dev/null +++ b/src/AsyncNavigation/NavigationAwareBase.cs @@ -0,0 +1,67 @@ +using AsyncNavigation.Abstractions; +using AsyncNavigation.Core; + +namespace AsyncNavigation; + +/// +/// Abstract base class that provides no-op default implementations of all +/// members. Derive from this class when you only +/// need to override a subset of the navigation lifecycle methods, avoiding the +/// boilerplate of empty implementations for the rest. +/// +/// +/// +/// public class HomeViewModel : NavigationAwareBase +/// { +/// public override async Task OnNavigatedToAsync(NavigationContext context) +/// { +/// // Only override what you need +/// await LoadDataAsync(); +/// } +/// } +/// +/// +public abstract class NavigationAwareBase : INavigationAware +{ + /// + /// Called only the first time a view is created and shown. Default implementation does nothing. + public virtual Task InitializeAsync(NavigationContext context) => Task.CompletedTask; + + /// + /// Called every time the view becomes the active view. Default implementation does nothing. + public virtual Task OnNavigatedToAsync(NavigationContext context) => Task.CompletedTask; + + /// + /// Called when navigating away from this view. Default implementation does nothing. + public virtual Task OnNavigatedFromAsync(NavigationContext context) => Task.CompletedTask; + + /// + /// + /// Controls whether a cached view instance can be reused for the incoming navigation request. + /// Default returns , meaning the cached instance is always reused. + /// Override and return to force creation of a new instance. + /// + public virtual Task IsNavigationTargetAsync(NavigationContext context) => Task.FromResult(true); + + /// + /// Called when the view is being removed from the region cache. Default implementation does nothing. + public virtual Task OnUnloadAsync(CancellationToken cancellationToken) => Task.CompletedTask; + + /// + /// + /// Raise this event to request that the framework proactively removes this view from the region. + /// Use as a convenient helper to raise it. + /// + public event AsyncEventHandler? AsyncRequestUnloadEvent; + + /// + /// Raises to request that the framework remove this view. + /// + protected Task RequestUnloadAsync(CancellationToken cancellationToken = default) + { + var handler = AsyncRequestUnloadEvent; + if (handler is not null) + return handler(this, new AsyncEventArgs(cancellationToken)); + return Task.CompletedTask; + } +} From cfda239369b92f3516cf5fdb0eeb5e0f74eaf981 Mon Sep 17 00:00:00 2001 From: Easley Date: Fri, 20 Mar 2026 09:46:40 +0100 Subject: [PATCH 13/17] feat: add INavigationGuard to support blocking navigation (e.g. unsaved changes) Previously there was no way for a view model to prevent navigation away from itself. A common use case is an edit form that should confirm before discarding unsaved changes. INavigationGuard.CanNavigateAsync() is called during the OnBeforeNavigation pipeline stage, before OnNavigatedFromAsync. Returning false throws OperationCanceledException, which causes the framework to revert to the current view (existing cancel behavior). The guard is checked only when the current view model implements it; no behavior change for view models that do not implement the interface. Works in combination with NavigationAwareBase and direct INavigationAware implementations. Co-Authored-By: Claude Sonnet 4.6 --- .../Abstractions/INavigationGuard.cs | 30 +++++++++++++++++++ .../RegionNavigationService.cs | 20 +++++++++++-- 2 files changed, 48 insertions(+), 2 deletions(-) create mode 100644 src/AsyncNavigation/Abstractions/INavigationGuard.cs diff --git a/src/AsyncNavigation/Abstractions/INavigationGuard.cs b/src/AsyncNavigation/Abstractions/INavigationGuard.cs new file mode 100644 index 0000000..4b8e14c --- /dev/null +++ b/src/AsyncNavigation/Abstractions/INavigationGuard.cs @@ -0,0 +1,30 @@ +namespace AsyncNavigation.Abstractions; + +/// +/// Allows a view model to block or confirm a navigation request before it proceeds. +/// Implement this interface alongside to intercept +/// navigations away from the current view (e.g., unsaved-changes confirmation). +/// +/// +/// +/// public class EditViewModel : NavigationAwareBase, INavigationGuard +/// { +/// public async Task<bool> CanNavigateAsync(NavigationContext context, CancellationToken ct) +/// { +/// if (!HasUnsavedChanges) return true; +/// return await _dialogService.ConfirmAsync("Discard unsaved changes?", ct); +/// } +/// } +/// +/// +public interface INavigationGuard +{ + /// + /// Called before navigating away from the current view. + /// Return to allow the navigation to proceed, + /// or to cancel it. + /// + /// The navigation context for the incoming navigation request. + /// Token to cancel the guard check itself. + Task CanNavigateAsync(NavigationContext context, CancellationToken cancellationToken); +} diff --git a/src/AsyncNavigation/RegionNavigationService.cs b/src/AsyncNavigation/RegionNavigationService.cs index 190716e..3cf3dba 100644 --- a/src/AsyncNavigation/RegionNavigationService.cs +++ b/src/AsyncNavigation/RegionNavigationService.cs @@ -131,9 +131,25 @@ private async Task OnResolveViewAsync(NavigationContext navigationContext) private async Task OnBeforeNavigationAsync(NavigationContext navigationContext) { - if (Current.HasValue && Current.Value.View.DataContext is INavigationAware currentAware) + if (Current.HasValue) { - await currentAware.OnNavigatedFromAsync(navigationContext); + var currentDataContext = Current.Value.View.DataContext; + + // Check navigation guard before notifying the current view it is leaving. + // If the guard blocks navigation, throw OperationCanceledException so the + // pipeline treats this as a cancellation and reverts to the current view. + if (currentDataContext is INavigationGuard guard) + { + var canNavigate = await guard.CanNavigateAsync(navigationContext, navigationContext.CancellationToken); + navigationContext.CancellationToken.ThrowIfCancellationRequested(); + if (!canNavigate) + throw new OperationCanceledException("Navigation was blocked by INavigationGuard.", navigationContext.CancellationToken); + } + + if (currentDataContext is INavigationAware currentAware) + { + await currentAware.OnNavigatedFromAsync(navigationContext); + } } navigationContext.CancellationToken.ThrowIfCancellationRequested(); } From d608ac09eb8688049c380aa1b6fd31bd504692c2 Mon Sep 17 00:00:00 2001 From: Easley Date: Fri, 20 Mar 2026 09:49:41 +0100 Subject: [PATCH 14/17] feat: add INavigationInterceptor for global navigation hooks (auth, logging, analytics) Previously there was no way to intercept all navigations globally without modifying every view model. Common needs: authentication redirects, analytics tracking, request logging, A/B testing redirects. INavigationInterceptor has two hooks: - OnNavigatingAsync: runs before the navigation pipeline; throw OperationCanceledException to abort (treated as navigation cancellation by the framework) - OnNavigatedAsync: runs after successful navigation; exceptions are logged but do not affect the navigation result Register via RegisterNavigationInterceptor() extension. Multiple interceptors are supported and are called in registration order. The interceptors are resolved as singletons via IEnumerable. Co-Authored-By: Claude Sonnet 4.6 --- .../Abstractions/INavigationInterceptor.cs | 43 +++++++++++++++++++ .../DependencyInjectionExtensions.cs | 13 ++++++ src/AsyncNavigation/RegionManagerBase.cs | 23 +++++++++- 3 files changed, 78 insertions(+), 1 deletion(-) create mode 100644 src/AsyncNavigation/Abstractions/INavigationInterceptor.cs diff --git a/src/AsyncNavigation/Abstractions/INavigationInterceptor.cs b/src/AsyncNavigation/Abstractions/INavigationInterceptor.cs new file mode 100644 index 0000000..d2f4270 --- /dev/null +++ b/src/AsyncNavigation/Abstractions/INavigationInterceptor.cs @@ -0,0 +1,43 @@ +namespace AsyncNavigation.Abstractions; + +/// +/// Globally intercepts navigation requests across all regions. +/// Register an implementation via RegisterNavigationInterceptor<T>(). +/// +/// +/// Interceptors run in registration order and are invoked for every navigation request +/// regardless of region or view. Common uses: authentication checks, analytics/logging, +/// global error handling, or A/B redirect logic. +/// +/// +/// +/// public class AuthInterceptor : INavigationInterceptor +/// { +/// public Task OnNavigatingAsync(NavigationContext context) +/// { +/// if (!_auth.IsLoggedIn) +/// throw new OperationCanceledException("Not authenticated."); +/// return Task.CompletedTask; +/// } +/// +/// public Task OnNavigatedAsync(NavigationContext context) => Task.CompletedTask; +/// } +/// +/// // Registration: +/// services.RegisterNavigationInterceptor<AuthInterceptor>(); +/// +/// +public interface INavigationInterceptor +{ + /// + /// Called before navigation begins (before the pipeline runs). + /// Throw to abort the navigation. + /// + Task OnNavigatingAsync(NavigationContext context); + + /// + /// Called after navigation completes successfully. + /// Exceptions thrown here are logged but do not affect the navigation result. + /// + Task OnNavigatedAsync(NavigationContext context); +} diff --git a/src/AsyncNavigation/DependencyInjectionExtensions.cs b/src/AsyncNavigation/DependencyInjectionExtensions.cs index 7528faa..80db285 100644 --- a/src/AsyncNavigation/DependencyInjectionExtensions.cs +++ b/src/AsyncNavigation/DependencyInjectionExtensions.cs @@ -363,4 +363,17 @@ public static IServiceCollection RegisterRouter( return services; } + /// + /// Registers a navigation interceptor that will be invoked for every navigation request. + /// Multiple interceptors can be registered and are called in registration order. + /// + /// The interceptor type, must implement . + public static IServiceCollection RegisterNavigationInterceptor<[DynamicallyAccessedMembers(DynamicallyAccessedMemberTypes.All)] T>( + this IServiceCollection services) + where T : class, INavigationInterceptor + { + ArgumentNullException.ThrowIfNull(services); + return services.AddSingleton(); + } + } \ No newline at end of file diff --git a/src/AsyncNavigation/RegionManagerBase.cs b/src/AsyncNavigation/RegionManagerBase.cs index fad168e..9672bc4 100644 --- a/src/AsyncNavigation/RegionManagerBase.cs +++ b/src/AsyncNavigation/RegionManagerBase.cs @@ -19,6 +19,7 @@ public abstract class RegionManagerBase : IRegionManager, IDisposable private readonly IServiceProvider _serviceProvider; private readonly IRegionFactory _regionFactory; private readonly int _maxReplayCount; + private readonly IReadOnlyList _interceptors; private IRouter? _router; private IRegion? _currentRegion; @@ -35,6 +36,7 @@ protected RegionManagerBase(IRegionFactory regionFactory, IServiceProvider servi _regionFactory = regionFactory; _serviceProvider = serviceProvider; _maxReplayCount = serviceProvider.GetRequiredService().MaxReplayItems; + _interceptors = serviceProvider.GetServices().ToList(); RecoverTempRegions(); } @@ -95,6 +97,12 @@ private async Task PerformNavigationAsync( try { + // Run OnNavigating interceptors before the navigation pipeline starts. + foreach (var interceptor in _interceptors) + await interceptor.OnNavigatingAsync(context); + + context.CancellationToken.ThrowIfCancellationRequested(); + IRegion? previousRegion; lock (_regionLock) { @@ -105,7 +113,20 @@ private async Task PerformNavigationAsync( if (previousRegion is not null && previousRegion != region) await previousRegion.NavigateFromAsync(context); - return await region.ActivateViewAsync(context); + var result = await region.ActivateViewAsync(context); + + // Run OnNavigated interceptors after successful navigation. + // Exceptions from interceptors are logged but do not affect the result. + if (result.IsSuccessful) + { + foreach (var interceptor in _interceptors) + { + try { await interceptor.OnNavigatedAsync(context); } + catch (Exception ex) { Debug.WriteLine($"[Interceptor] OnNavigatedAsync failed: {ex}"); } + } + } + + return result; } catch (Exception ex) { From cc7e5ace421ee0dd8afba427742725555e4fb000 Mon Sep 17 00:00:00 2001 From: Easley Date: Fri, 20 Mar 2026 09:52:34 +0100 Subject: [PATCH 15/17] feat: extract FrontShowAsync from core IDialogService to platform-specific IAvaloniaDialogService FrontShowAsync was documented as 'particularly useful in AvaloniaUI' yet lived in the core IDialogService interface, forcing WPF users to be aware of an Avalonia startup pattern irrelevant to them. WPF had no equivalent concept in its platform service. Changes: - Added IAvaloniaDialogService (AsyncNavigation.Avalonia) extending IDialogService with FrontShowViewAsync/FrontShowWindowAsync using clean optional-parameter signatures - Added AvaloniaDialogService (internal) implementing both interfaces, delegating FrontShow calls to the existing DialogService.FrontShowAsync implementation - Avalonia DI setup now registers AvaloniaDialogService under both IDialogService and IAvaloniaDialogService so resolving either returns the same instance - Updated IDialogService.FrontShowAsync doc comments to point Avalonia users to IAvaloniaDialogService without breaking existing callers WPF users: no change, IDialogService still has FrontShowAsync for cross-platform use. Avalonia users: resolve IAvaloniaDialogService for FrontShowViewAsync/FrontShowWindowAsync. Co-Authored-By: Claude Sonnet 4.6 --- .../AvaloniaDialogService.cs | 33 +++++++++++ .../DependencyInjectionExtensions.cs | 8 ++- .../IAvaloniaDialogService.cs | 57 +++++++++++++++++++ .../Abstractions/IDialogService.cs | 33 ++++------- 4 files changed, 107 insertions(+), 24 deletions(-) create mode 100644 src/AsyncNavigation.Avalonia/AvaloniaDialogService.cs create mode 100644 src/AsyncNavigation.Avalonia/IAvaloniaDialogService.cs diff --git a/src/AsyncNavigation.Avalonia/AvaloniaDialogService.cs b/src/AsyncNavigation.Avalonia/AvaloniaDialogService.cs new file mode 100644 index 0000000..61c5c9c --- /dev/null +++ b/src/AsyncNavigation.Avalonia/AvaloniaDialogService.cs @@ -0,0 +1,33 @@ +using AsyncNavigation; +using AsyncNavigation.Abstractions; +using AsyncNavigation.Core; + +namespace AsyncNavigation.Avalonia; + +/// +/// Avalonia-specific dialog service. Extends with +/// which exposes the front-show pattern under +/// the clearer FrontShowViewAsync / FrontShowWindowAsync names. +/// +internal sealed class AvaloniaDialogService : DialogService, IAvaloniaDialogService +{ + public AvaloniaDialogService(IServiceProvider serviceProvider, IPlatformService platformService) + : base(serviceProvider, platformService) + { + } + + Task IAvaloniaDialogService.FrontShowViewAsync( + string viewName, + Func mainWindowBuilder, + string? containerName, + IDialogParameters? parameters, + CancellationToken cancellationToken) + => FrontShowAsync(viewName, mainWindowBuilder, containerName, parameters, cancellationToken); + + Task IAvaloniaDialogService.FrontShowWindowAsync( + string windowName, + Func mainWindowBuilder, + IDialogParameters? parameters, + CancellationToken cancellationToken) + => FrontShowAsync(windowName, mainWindowBuilder, parameters, cancellationToken); +} diff --git a/src/AsyncNavigation.Avalonia/DependencyInjectionExtensions.cs b/src/AsyncNavigation.Avalonia/DependencyInjectionExtensions.cs index 3b9cd3d..ae97a80 100644 --- a/src/AsyncNavigation.Avalonia/DependencyInjectionExtensions.cs +++ b/src/AsyncNavigation.Avalonia/DependencyInjectionExtensions.cs @@ -56,7 +56,13 @@ public static IServiceCollection AddNavigationSupport(this IServiceCollection se .AddTransient() .AddSingleton() .RegisterDialogContainer(NavigationConstants.DEFAULT_DIALOG_WINDOW_KEY) - .AddSingleton(); + .AddSingleton() + // Register AvaloniaDialogService as both IDialogService and IAvaloniaDialogService + // so that users who need FrontShowViewAsync/FrontShowWindowAsync can resolve + // IAvaloniaDialogService directly for the clean API. + .AddSingleton() + .AddSingleton(sp => sp.GetRequiredService()) + .AddSingleton(sp => sp.GetRequiredService()); } /// diff --git a/src/AsyncNavigation.Avalonia/IAvaloniaDialogService.cs b/src/AsyncNavigation.Avalonia/IAvaloniaDialogService.cs new file mode 100644 index 0000000..7d5833f --- /dev/null +++ b/src/AsyncNavigation.Avalonia/IAvaloniaDialogService.cs @@ -0,0 +1,57 @@ +using AsyncNavigation.Abstractions; +using AsyncNavigation.Core; + +namespace AsyncNavigation.Avalonia; + +/// +/// Avalonia-specific dialog service that extends with +/// the FrontShowAsync pattern for showing a dialog (e.g. splash screen or +/// login window) before the main application window is displayed. +/// +/// +/// Register and resolve this interface instead of in +/// Avalonia applications when you need the front-show functionality at startup. +/// The underlying implements both +/// interfaces, so resolving will return the +/// same service instance. +/// +public interface IAvaloniaDialogService : IDialogService +{ + /// + /// Displays a dialog view (e.g., a splash screen or login form hosted inside a + /// container window) before showing the main application window. + /// + /// The main window type to create after the dialog closes. + /// The registered name of the dialog view content. + /// + /// A factory that receives the dialog result and returns the main window instance, + /// or to suppress main window creation. + /// + /// Optional container window name; uses the default container if omitted. + /// Optional parameters passed to the dialog view model. + /// Token to cancel the operation. + new Task FrontShowViewAsync( + string viewName, + Func mainWindowBuilder, + string? containerName = null, + IDialogParameters? parameters = null, + CancellationToken cancellationToken = default) where TWindow : class; + + /// + /// Displays a dialog window (e.g., a splash screen or login window) before showing + /// the main application window. + /// + /// The main window type to create after the dialog closes. + /// The registered name of the dialog window. + /// + /// A factory that receives the dialog result and returns the main window instance, + /// or to suppress main window creation. + /// + /// Optional parameters passed to the dialog window view model. + /// Token to cancel the operation. + new Task FrontShowWindowAsync( + string windowName, + Func mainWindowBuilder, + IDialogParameters? parameters = null, + CancellationToken cancellationToken = default) where TWindow : class; +} diff --git a/src/AsyncNavigation/Abstractions/IDialogService.cs b/src/AsyncNavigation/Abstractions/IDialogService.cs index 3f31635..a3115d9 100644 --- a/src/AsyncNavigation/Abstractions/IDialogService.cs +++ b/src/AsyncNavigation/Abstractions/IDialogService.cs @@ -19,19 +19,12 @@ IDialogResult ShowDialog(string name, CancellationToken cancellationToken); /// - /// Displays a dialog (e.g., splash screen or login window) before showing the main window. - /// This method is particularly useful in AvaloniaUI applications, where an initial dialog - /// may need to appear before the main window is created or displayed. + /// Displays a dialog view before showing the main window. /// - /// The main window type to be created after the dialog is closed. - /// The name of the dialog view to show. - /// - /// A function that builds the main window based on the returned by the dialog. - /// - /// Optional: The name of the window container. - /// Optional: The parameters passed to the dialog. - /// Optional: Token to cancel the operation. - /// A task representing the asynchronous operation. + /// + /// For Avalonia applications, prefer resolving IAvaloniaDialogService and calling + /// FrontShowViewAsync which provides a friendlier API with optional parameters. + /// Task FrontShowAsync(string name, Func mainWindowBuilder, string? containerName, @@ -52,18 +45,12 @@ IDialogResult ShowDialog(string windowName, CancellationToken cancellationToken); /// - /// Displays a dialog (e.g., splash screen or login window) before showing the main window. - /// This method is particularly useful in AvaloniaUI applications, where an initial dialog - /// may need to appear before the main window is created or displayed. + /// Displays a dialog window before showing the main window. /// - /// The main window type to be created after the dialog is closed. - /// The name of the dialog window to show. - /// - /// A function that builds the main window based on the returned by the dialog. - /// - /// Optional: The parameters passed to the dialog. - /// Optional: Token to cancel the operation. - /// A task representing the asynchronous operation. + /// + /// For Avalonia applications, prefer resolving IAvaloniaDialogService and calling + /// FrontShowWindowAsync which provides a friendlier API with optional parameters. + /// Task FrontShowAsync(string windowName, Func mainWindowBuilder, IDialogParameters? parameters, From c44529344ea9f8771bcb094d1c63c66c3ae2deb4 Mon Sep 17 00:00:00 2001 From: Easley Date: Fri, 20 Mar 2026 09:59:46 +0100 Subject: [PATCH 16/17] fix: remove spurious 'new' keyword and add missing 'where TWindow : class' constraint Minor follow-up to the IAvaloniaDialogService commit: - Remove CS0109 warning by removing unnecessary 'new' modifier on interface methods (the methods do not hide any members from IDialogService) - Add 'where TWindow : class' to explicit interface implementations in AvaloniaDialogService to satisfy the nullable reference type constraint required by the Func parameter Co-Authored-By: Claude Sonnet 4.6 --- src/AsyncNavigation.Avalonia/AvaloniaDialogService.cs | 4 ++-- src/AsyncNavigation.Avalonia/IAvaloniaDialogService.cs | 4 ++-- 2 files changed, 4 insertions(+), 4 deletions(-) diff --git a/src/AsyncNavigation.Avalonia/AvaloniaDialogService.cs b/src/AsyncNavigation.Avalonia/AvaloniaDialogService.cs index 61c5c9c..f3663e6 100644 --- a/src/AsyncNavigation.Avalonia/AvaloniaDialogService.cs +++ b/src/AsyncNavigation.Avalonia/AvaloniaDialogService.cs @@ -21,13 +21,13 @@ Task IAvaloniaDialogService.FrontShowViewAsync( Func mainWindowBuilder, string? containerName, IDialogParameters? parameters, - CancellationToken cancellationToken) + CancellationToken cancellationToken) where TWindow : class => FrontShowAsync(viewName, mainWindowBuilder, containerName, parameters, cancellationToken); Task IAvaloniaDialogService.FrontShowWindowAsync( string windowName, Func mainWindowBuilder, IDialogParameters? parameters, - CancellationToken cancellationToken) + CancellationToken cancellationToken) where TWindow : class => FrontShowAsync(windowName, mainWindowBuilder, parameters, cancellationToken); } diff --git a/src/AsyncNavigation.Avalonia/IAvaloniaDialogService.cs b/src/AsyncNavigation.Avalonia/IAvaloniaDialogService.cs index 7d5833f..fd0b2d9 100644 --- a/src/AsyncNavigation.Avalonia/IAvaloniaDialogService.cs +++ b/src/AsyncNavigation.Avalonia/IAvaloniaDialogService.cs @@ -30,7 +30,7 @@ public interface IAvaloniaDialogService : IDialogService /// Optional container window name; uses the default container if omitted. /// Optional parameters passed to the dialog view model. /// Token to cancel the operation. - new Task FrontShowViewAsync( + Task FrontShowViewAsync( string viewName, Func mainWindowBuilder, string? containerName = null, @@ -49,7 +49,7 @@ public interface IAvaloniaDialogService : IDialogService /// /// Optional parameters passed to the dialog window view model. /// Token to cancel the operation. - new Task FrontShowWindowAsync( + Task FrontShowWindowAsync( string windowName, Func mainWindowBuilder, IDialogParameters? parameters = null, From 01d7d7e5281f4614399ba8cdf6a04f078de4496f Mon Sep 17 00:00:00 2001 From: Easley Date: Fri, 20 Mar 2026 10:00:31 +0100 Subject: [PATCH 17/17] ignore local settings --- .gitignore | 1 + 1 file changed, 1 insertion(+) diff --git a/.gitignore b/.gitignore index 3b908f7..fa3e326 100644 --- a/.gitignore +++ b/.gitignore @@ -421,3 +421,4 @@ FodyWeavers.xsd /.idea /tests/AsyncNavigation.Tests/coveragereport /src/.idea/.idea.AsyncNavigation/.idea +/.claude