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 _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); } diff --git a/src/AsyncNavigation/DependencyInjectionExtensions.cs b/src/AsyncNavigation/DependencyInjectionExtensions.cs index 08f72cb..80db285 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); @@ -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); } }