Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
Show all changes
17 commits
Select commit Hold shift + click to select a range
cfee3b8
fix: StartTime should use DateTimeOffset.UtcNow instead of DateTime.U…
NeverMorewd Mar 20, 2026
5bcaeac
fix: FrontShowAsync window overload discarded close task causing unob…
NeverMorewd Mar 20, 2026
d64740e
fix: ObserveCloseTask silently swallowed dialog close exceptions
NeverMorewd Mar 20, 2026
21e5537
fix: rename GoForward/GoBack to GoForwardAsync/GoBackAsync in IRegion…
NeverMorewd Mar 20, 2026
d1d3118
fix: add [Obsolete] attribute to MaxCachedViews to enforce documented…
NeverMorewd Mar 20, 2026
ff7374b
fix: correct spelling of AddSingletonWitAllMembers/AddTransientWitAll…
NeverMorewd Mar 20, 2026
fd97b4d
fix: correct typo OnResovleViewAsync -> OnResolveViewAsync in RegionN…
NeverMorewd Mar 20, 2026
c0f35b0
fix: WeakAsyncEventHandler use generic WeakReference<T> and fix TOCTO…
NeverMorewd Mar 20, 2026
00dc5b1
fix: AsyncJobProcessor CancelCurrent strategy must await job completi…
NeverMorewd Mar 20, 2026
19d5283
fix: NavigationResult factory methods should not mutate NavigationCon…
NeverMorewd Mar 20, 2026
b1793f5
fix: NavigationOptions.MergeFrom compared against Default.X causing s…
NeverMorewd Mar 20, 2026
ab6173e
feat: add NavigationAwareBase abstract base class to reduce INavigati…
NeverMorewd Mar 20, 2026
cfda239
feat: add INavigationGuard to support blocking navigation (e.g. unsav…
NeverMorewd Mar 20, 2026
d608ac0
feat: add INavigationInterceptor for global navigation hooks (auth, l…
NeverMorewd Mar 20, 2026
cc7e5ac
feat: extract FrontShowAsync from core IDialogService to platform-spe…
NeverMorewd Mar 20, 2026
c445293
fix: remove spurious 'new' keyword and add missing 'where TWindow : c…
NeverMorewd Mar 20, 2026
01d7d7e
ignore local settings
NeverMorewd Mar 20, 2026
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
1 change: 1 addition & 0 deletions .gitignore
Original file line number Diff line number Diff line change
Expand Up @@ -421,3 +421,4 @@ FodyWeavers.xsd
/.idea
/tests/AsyncNavigation.Tests/coveragereport
/src/.idea/.idea.AsyncNavigation/.idea
/.claude
4 changes: 2 additions & 2 deletions samples/Sample.Common/DViewModel.cs
Original file line number Diff line number Diff line change
Expand Up @@ -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)
Expand Down
4 changes: 2 additions & 2 deletions samples/Sample.Common/ListBoxRegionViewModel.cs
Original file line number Diff line number Diff line change
Expand Up @@ -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");
}
}
4 changes: 2 additions & 2 deletions samples/Sample.Common/MainWindowViewModel.cs
Original file line number Diff line number Diff line change
Expand Up @@ -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]
Expand Down
33 changes: 33 additions & 0 deletions src/AsyncNavigation.Avalonia/AvaloniaDialogService.cs
Original file line number Diff line number Diff line change
@@ -0,0 +1,33 @@
using AsyncNavigation;
using AsyncNavigation.Abstractions;
using AsyncNavigation.Core;

namespace AsyncNavigation.Avalonia;

/// <summary>
/// Avalonia-specific dialog service. Extends <see cref="DialogService"/> with
/// <see cref="IAvaloniaDialogService"/> which exposes the front-show pattern under
/// the clearer <c>FrontShowViewAsync</c> / <c>FrontShowWindowAsync</c> names.
/// </summary>
internal sealed class AvaloniaDialogService : DialogService, IAvaloniaDialogService
{
public AvaloniaDialogService(IServiceProvider serviceProvider, IPlatformService platformService)
: base(serviceProvider, platformService)
{
}

Task IAvaloniaDialogService.FrontShowViewAsync<TWindow>(
string viewName,
Func<IDialogResult, TWindow?> mainWindowBuilder,
string? containerName,
IDialogParameters? parameters,
CancellationToken cancellationToken) where TWindow : class
=> FrontShowAsync(viewName, mainWindowBuilder, containerName, parameters, cancellationToken);

Task IAvaloniaDialogService.FrontShowWindowAsync<TWindow>(
string windowName,
Func<IDialogResult, TWindow?> mainWindowBuilder,
IDialogParameters? parameters,
CancellationToken cancellationToken) where TWindow : class
=> FrontShowAsync(windowName, mainWindowBuilder, parameters, cancellationToken);
}
Original file line number Diff line number Diff line change
Expand Up @@ -56,7 +56,13 @@ public static IServiceCollection AddNavigationSupport(this IServiceCollection se
.AddTransient<IInnerRegionIndicatorHost, InnerIndicatorHost>()
.AddSingleton<IRegionManager, RegionManager>()
.RegisterDialogContainer<DefaultDialogContainer>(NavigationConstants.DEFAULT_DIALOG_WINDOW_KEY)
.AddSingleton<IPlatformService, PlatformService>();
.AddSingleton<IPlatformService, PlatformService>()
// Register AvaloniaDialogService as both IDialogService and IAvaloniaDialogService
// so that users who need FrontShowViewAsync/FrontShowWindowAsync can resolve
// IAvaloniaDialogService directly for the clean API.
.AddSingleton<AvaloniaDialogService>()
.AddSingleton<IDialogService>(sp => sp.GetRequiredService<AvaloniaDialogService>())
.AddSingleton<IAvaloniaDialogService>(sp => sp.GetRequiredService<AvaloniaDialogService>());
}

/// <summary>
Expand Down
57 changes: 57 additions & 0 deletions src/AsyncNavigation.Avalonia/IAvaloniaDialogService.cs
Original file line number Diff line number Diff line change
@@ -0,0 +1,57 @@
using AsyncNavigation.Abstractions;
using AsyncNavigation.Core;

namespace AsyncNavigation.Avalonia;

/// <summary>
/// Avalonia-specific dialog service that extends <see cref="IDialogService"/> with
/// the <c>FrontShowAsync</c> pattern for showing a dialog (e.g. splash screen or
/// login window) before the main application window is displayed.
/// </summary>
/// <remarks>
/// Register and resolve this interface instead of <see cref="IDialogService"/> in
/// Avalonia applications when you need the front-show functionality at startup.
/// The underlying <see cref="AsyncNavigation.DialogService"/> implements both
/// interfaces, so resolving <see cref="IAvaloniaDialogService"/> will return the
/// same service instance.
/// </remarks>
public interface IAvaloniaDialogService : IDialogService
{
/// <summary>
/// Displays a dialog view (e.g., a splash screen or login form hosted inside a
/// container window) before showing the main application window.
/// </summary>
/// <typeparam name="TWindow">The main window type to create after the dialog closes.</typeparam>
/// <param name="viewName">The registered name of the dialog view content.</param>
/// <param name="mainWindowBuilder">
/// A factory that receives the dialog result and returns the main window instance,
/// or <see langword="null"/> to suppress main window creation.
/// </param>
/// <param name="containerName">Optional container window name; uses the default container if omitted.</param>
/// <param name="parameters">Optional parameters passed to the dialog view model.</param>
/// <param name="cancellationToken">Token to cancel the operation.</param>
Task FrontShowViewAsync<TWindow>(
string viewName,
Func<IDialogResult, TWindow?> mainWindowBuilder,
string? containerName = null,
IDialogParameters? parameters = null,
CancellationToken cancellationToken = default) where TWindow : class;

/// <summary>
/// Displays a dialog window (e.g., a splash screen or login window) before showing
/// the main application window.
/// </summary>
/// <typeparam name="TWindow">The main window type to create after the dialog closes.</typeparam>
/// <param name="windowName">The registered name of the dialog window.</param>
/// <param name="mainWindowBuilder">
/// A factory that receives the dialog result and returns the main window instance,
/// or <see langword="null"/> to suppress main window creation.
/// </param>
/// <param name="parameters">Optional parameters passed to the dialog window view model.</param>
/// <param name="cancellationToken">Token to cancel the operation.</param>
Task FrontShowWindowAsync<TWindow>(
string windowName,
Func<IDialogResult, TWindow?> mainWindowBuilder,
IDialogParameters? parameters = null,
CancellationToken cancellationToken = default) where TWindow : class;
}
33 changes: 10 additions & 23 deletions src/AsyncNavigation/Abstractions/IDialogService.cs
Original file line number Diff line number Diff line change
Expand Up @@ -19,19 +19,12 @@ IDialogResult ShowDialog(string name,
CancellationToken cancellationToken);

/// <summary>
/// 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.
/// </summary>
/// <typeparam name="TWindow">The main window type to be created after the dialog is closed.</typeparam>
/// <param name="name">The name of the dialog view to show.</param>
/// <param name="mainWindowBuilder">
/// A function that builds the main window based on the <see cref="IDialogResult"/> returned by the dialog.
/// </param>
/// <param name="containerName">Optional: The name of the window container.</param>
/// <param name="parameters">Optional: The parameters passed to the dialog.</param>
/// <param name="cancellationToken">Optional: Token to cancel the operation.</param>
/// <returns>A task representing the asynchronous operation.</returns>
/// <remarks>
/// For Avalonia applications, prefer resolving <c>IAvaloniaDialogService</c> and calling
/// <c>FrontShowViewAsync</c> which provides a friendlier API with optional parameters.
/// </remarks>
Task FrontShowAsync<TWindow>(string name,
Func<IDialogResult, TWindow?> mainWindowBuilder,
string? containerName,
Expand All @@ -52,18 +45,12 @@ IDialogResult ShowDialog(string windowName,
CancellationToken cancellationToken);

/// <summary>
/// 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.
/// </summary>
/// <typeparam name="TWindow">The main window type to be created after the dialog is closed.</typeparam>
/// <param name="windowName">The name of the dialog window to show.</param>
/// <param name="mainWindowBuilder">
/// A function that builds the main window based on the <see cref="IDialogResult"/> returned by the dialog.
/// </param>
/// <param name="parameters">Optional: The parameters passed to the dialog.</param>
/// <param name="cancellationToken">Optional: Token to cancel the operation.</param>
/// <returns>A task representing the asynchronous operation.</returns>
/// <remarks>
/// For Avalonia applications, prefer resolving <c>IAvaloniaDialogService</c> and calling
/// <c>FrontShowWindowAsync</c> which provides a friendlier API with optional parameters.
/// </remarks>
Task FrontShowAsync<TWindow>(string windowName,
Func<IDialogResult, TWindow?> mainWindowBuilder,
IDialogParameters? parameters,
Expand Down
30 changes: 30 additions & 0 deletions src/AsyncNavigation/Abstractions/INavigationGuard.cs
Original file line number Diff line number Diff line change
@@ -0,0 +1,30 @@
namespace AsyncNavigation.Abstractions;

/// <summary>
/// Allows a view model to block or confirm a navigation request before it proceeds.
/// Implement this interface alongside <see cref="INavigationAware"/> to intercept
/// navigations away from the current view (e.g., unsaved-changes confirmation).
/// </summary>
/// <example>
/// <code>
/// public class EditViewModel : NavigationAwareBase, INavigationGuard
/// {
/// public async Task&lt;bool&gt; CanNavigateAsync(NavigationContext context, CancellationToken ct)
/// {
/// if (!HasUnsavedChanges) return true;
/// return await _dialogService.ConfirmAsync("Discard unsaved changes?", ct);
/// }
/// }
/// </code>
/// </example>
public interface INavigationGuard
{
/// <summary>
/// Called before navigating away from the current view.
/// Return <see langword="true"/> to allow the navigation to proceed,
/// or <see langword="false"/> to cancel it.
/// </summary>
/// <param name="context">The navigation context for the incoming navigation request.</param>
/// <param name="cancellationToken">Token to cancel the guard check itself.</param>
Task<bool> CanNavigateAsync(NavigationContext context, CancellationToken cancellationToken);
}
43 changes: 43 additions & 0 deletions src/AsyncNavigation/Abstractions/INavigationInterceptor.cs
Original file line number Diff line number Diff line change
@@ -0,0 +1,43 @@
namespace AsyncNavigation.Abstractions;

/// <summary>
/// Globally intercepts navigation requests across all regions.
/// Register an implementation via <c>RegisterNavigationInterceptor&lt;T&gt;()</c>.
/// </summary>
/// <remarks>
/// 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.
/// </remarks>
/// <example>
/// <code>
/// 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&lt;AuthInterceptor&gt;();
/// </code>
/// </example>
public interface INavigationInterceptor
{
/// <summary>
/// Called before navigation begins (before the pipeline runs).
/// Throw <see cref="OperationCanceledException"/> to abort the navigation.
/// </summary>
Task OnNavigatingAsync(NavigationContext context);

/// <summary>
/// Called after navigation completes successfully.
/// Exceptions thrown here are logged but do not affect the navigation result.
/// </summary>
Task OnNavigatedAsync(NavigationContext context);
}
4 changes: 2 additions & 2 deletions src/AsyncNavigation/Abstractions/IRegionManager.cs
Original file line number Diff line number Diff line change
Expand Up @@ -40,6 +40,6 @@ Task<NavigationResult> RequestPathNavigateAsync(string path,

Task<bool> CanGoForwardAsync(string regionName);
Task<bool> CanGoBackAsync(string regionName);
Task<NavigationResult> GoForward(string regionName, CancellationToken cancellationToken = default);
Task<NavigationResult> GoBack(string regionName, CancellationToken cancellationToken = default);
Task<NavigationResult> GoForwardAsync(string regionName, CancellationToken cancellationToken = default);
Task<NavigationResult> GoBackAsync(string regionName, CancellationToken cancellationToken = default);
}
5 changes: 5 additions & 0 deletions src/AsyncNavigation/AsyncJobProcessor.cs
Original file line number Diff line number Diff line change
Expand Up @@ -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();
Comment on lines 64 to +65
Copy link
Copy Markdown

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

P1 Badge Ignore canceled jobs while draining CancelCurrent queue

In HandleExistingJobs, the CancelCurrent branch now does await WaitAllAsync() immediately after cancellation, but WaitAllAsync() propagates TaskCanceledException/OperationCanceledException from the jobs that were just canceled. When a running job correctly throws on cancellation (e.g., Task.Delay(..., ct)), this exception escapes RunJobAsync before the new job is added, so the replacement navigation never starts. This breaks the core CancelCurrent behavior under normal cancellable workloads.

Useful? React with 👍 / 👎.

break;
case NavigationJobStrategy.Queue:
await WaitAllAsync();
Expand Down
4 changes: 0 additions & 4 deletions src/AsyncNavigation/Core/NavigationResult.cs
Original file line number Diff line number Diff line change
Expand Up @@ -17,7 +17,6 @@ private NavigationResult()

public static NavigationResult Success(NavigationContext navigationContext)
{
navigationContext.UpdateStatus(NavigationStatus.Succeeded);
return new NavigationResult
{
NavigationContext = navigationContext,
Expand All @@ -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,
Expand All @@ -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,
Expand Down
10 changes: 6 additions & 4 deletions src/AsyncNavigation/Core/WeakAsyncEventHandler.cs
Original file line number Diff line number Diff line change
@@ -1,8 +1,8 @@
namespace AsyncNavigation.Core;
namespace AsyncNavigation.Core;

internal sealed class WeakAsyncEventHandler<TEventArgs> where TEventArgs : EventArgs
{
private readonly WeakReference _targetRef;
private readonly WeakReference<object> _targetRef;
private readonly AsyncEventHandler<TEventArgs> _handler;
private readonly Action<AsyncEventHandler<TEventArgs>> _unsubscribe;

Expand All @@ -11,14 +11,16 @@ public WeakAsyncEventHandler(
AsyncEventHandler<TEventArgs> handler,
Action<AsyncEventHandler<TEventArgs>> unsubscribe)
{
_targetRef = new WeakReference(target);
_targetRef = new WeakReference<object>(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);
}
Expand Down
Loading
Loading