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
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.Avalonia/AvaloniaDialogService.cs b/src/AsyncNavigation.Avalonia/AvaloniaDialogService.cs
new file mode 100644
index 0000000..f3663e6
--- /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) where TWindow : class
+ => FrontShowAsync(viewName, mainWindowBuilder, containerName, parameters, cancellationToken);
+
+ Task IAvaloniaDialogService.FrontShowWindowAsync(
+ string windowName,
+ Func mainWindowBuilder,
+ IDialogParameters? parameters,
+ CancellationToken cancellationToken) where TWindow : class
+ => 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..fd0b2d9
--- /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.
+ 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.
+ 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,
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/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/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/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();
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/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
- 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);
@@ -339,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/DialogService.cs b/src/AsyncNavigation/DialogService.cs
index 71282a6..bc3a96e 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;
}
@@ -150,10 +151,9 @@ private static void ObserveCloseTask(Task closeTask, Action
+/// 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;
+ }
+}
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
{
diff --git a/src/AsyncNavigation/NavigationOptions.cs b/src/AsyncNavigation/NavigationOptions.cs
index a317038..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;
@@ -20,10 +29,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;
///
@@ -106,29 +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;
- if (other.MaxCachedViews != Default.MaxCachedViews)
+#pragma warning disable CS0618 // MaxCachedViews is obsolete but MergeFrom must handle it for backwards compatibility
+ 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;
}
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 dffbba7..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();
}
@@ -88,10 +90,19 @@ private async Task PerformNavigationAsync(
context.LinkCancellationToken(cancellationToken);
if (context.CancellationToken.IsCancellationRequested)
+ {
+ context.UpdateStatus(NavigationStatus.Cancelled);
return NavigationResult.Cancelled(context);
+ }
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)
{
@@ -102,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)
{
@@ -157,7 +181,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 +199,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())
@@ -260,10 +284,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)
diff --git a/src/AsyncNavigation/RegionNavigationService.cs b/src/AsyncNavigation/RegionNavigationService.cs
index 6150ec3..3cf3dba 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)
{
@@ -131,9 +131,25 @@ private async Task OnResovleViewAsync(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();
}
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;
}
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);
}
}