Shaunebu.MAUI.Navigation.Debugger
1.0.0-preview.1
dotnet add package Shaunebu.MAUI.Navigation.Debugger --version 1.0.0-preview.1
NuGet\Install-Package Shaunebu.MAUI.Navigation.Debugger -Version 1.0.0-preview.1
<PackageReference Include="Shaunebu.MAUI.Navigation.Debugger" Version="1.0.0-preview.1" />
<PackageVersion Include="Shaunebu.MAUI.Navigation.Debugger" Version="1.0.0-preview.1" />
<PackageReference Include="Shaunebu.MAUI.Navigation.Debugger" />
paket add Shaunebu.MAUI.Navigation.Debugger --version 1.0.0-preview.1
#r "nuget: Shaunebu.MAUI.Navigation.Debugger, 1.0.0-preview.1"
#:package Shaunebu.MAUI.Navigation.Debugger@1.0.0-preview.1
#addin nuget:?package=Shaunebu.MAUI.Navigation.Debugger&version=1.0.0-preview.1&prerelease
#tool nuget:?package=Shaunebu.MAUI.Navigation.Debugger&version=1.0.0-preview.1&prerelease
Shaunebu.MAUI.Navigation
Enterprise-grade typed navigation orchestration for .NET MAUI.
Every production MAUI app eventually runs into the same problems: route strings that fail silently at runtime, back stacks that get corrupted across auth/main flows, loading pages accidentally pushed onto the navigation stack, duplicate navigation from rapid button taps, and ViewModels that are impossible to unit-test because they call Shell.Current directly. Shaunebu.MAUI.Navigation solves all of these by providing a single navigation authority — a strongly typed, DI-friendly, testable API that sits in front of Shell and NavigationPage without replacing them.
Design goals:
- Type-safe — navigate by
TPagetype, never by string. - Single authority — one pipeline handles forward, back, modal, root, flow switching, and overlays.
- Testable — every public API is an interface; mock freely in unit tests.
- Non-throwing by default — all operations return
NavigationResult; exceptions are opt-in. - Flow-aware — auth/main/onboarding flows are first-class concepts with lifecycle hooks.
- Guard pipeline — centralized navigation policy with allow / reject / redirect semantics.
- Overlay system — loading and no-internet states are visual layers, not navigation pages.
Table of Contents
- What Problem Does This Solve?
- Installation
- Basic Setup
- Typed Navigation
- Modal Navigation
- Root Navigation
- Flow Navigation
- Back Navigation
- Overlays
- Navigation Guards
- Parameter Passing
- Diagnostics and Logging
- Shell Compatibility
- NavigationPage Compatibility
- Testing Your ViewModels
- Architecture Overview
- Enterprise Best Practices
- Anti-Patterns
- Troubleshooting
- Common Pitfalls
- Further Documentation
- Versioning
- Contributing
- License
What Problem Does This Solve?
Raw .NET MAUI navigation has several pain points in medium-to-large applications:
| Problem | Without this library | With this library |
|---|---|---|
| Broken Shell routes | await Shell.Current.GoToAsync("privcy") silently fails |
await navigation.GoToAsync<PrivacyPage>() is compiler-checked |
| Duplicate routes | Registered twice; second silently wins | DuplicateRouteException at startup |
| Back stack corruption | Navigation.PopAsync() on wrong stack |
GoBackAsync() always resolves the correct stack |
| Modal/push confusion | Mixed PushModalAsync / PushAsync calls |
PresentationMode.Modal vs Push declared at call site |
| Loading page in back stack | User presses back; loading page appears | Overlays are visual layers, not pages |
| Double-tap navigation | Second tap pushes duplicate page | Built-in duplicate navigation prevention |
| No testability | Shell.Current is static and hard to mock |
INavigationHandler injected via DI; easy to substitute in tests |
| Flow switching | Auth→Main requires manual stack wipes | ResetToFlowAsync<MainFlow>() handles it cleanly |
Installation
dotnet add package Shaunebu.MAUI.Navigation
Or from the NuGet Package Manager:
Install-Package Shaunebu.MAUI.Navigation
Supported frameworks: net9.0, net10.0
Basic Setup
1. Register the library in MauiProgram.cs
public static MauiApp CreateMauiApp()
{
var builder = MauiApp.CreateBuilder();
builder
.UseMauiApp<App>()
.UseShaunebuNavigation(options =>
{
options.DefaultNavigationMode = NavigationPresentationMode.Push;
options.PreventDoubleNavigation = true;
options.DoubleNavigationThreshold = TimeSpan.FromMilliseconds(750);
options.EnableDiagnostics = true;
options.EnableNavigationGuards = true;
options.EnableBackButtonHandling = true;
options.EnableOverlaySystem = true;
options.ThrowOnNavigationFailure = false;
});
// Register pages and routes
builder.Services.AddShaunebuNavigation()
.AddPage<LoginPage>("auth/login")
.AddPage<RegisterPage>("auth/register")
.AddPage<HomePage>("main/home")
.AddPage<SettingsPage>("main/settings")
.AddPage<PrivacyPage>("auth/privacy", flow: "Auth")
.AddPage<PrivacyPage>("main/privacy", flow: "Main");
// Register pages and viewmodels with DI
builder.Services.AddTransient<LoginPage>();
builder.Services.AddTransient<LoginViewModel>();
builder.Services.AddTransient<HomePage>();
builder.Services.AddTransient<HomeViewModel>();
return builder.Build();
}
2. Inject INavigationHandler into your ViewModel
public sealed partial class LoginViewModel
{
private readonly INavigationHandler _navigation;
public LoginViewModel(INavigationHandler navigation)
{
_navigation = navigation;
}
}
Typed Navigation
Navigate by page type — no route strings at the call site:
// Simple push
await _navigation.GoToAsync<SettingsPage>();
// Push with options
await _navigation.GoToAsync<SettingsPage>(options =>
{
options.Animated = true;
options.Reason = "User tapped settings";
});
Check the result:
var result = await _navigation.GoToAsync<SettingsPage>();
if (!result.Succeeded)
{
// result.FailureReason contains NavigationFailureReason
// result.Message contains a human-readable description
// result.Exception contains the underlying exception (if any)
}
Modal Navigation
// Show modal
await _navigation.ShowModalAsync<TermsPage>();
// Close modal
await _navigation.CloseModalAsync();
// Show modal with options
await _navigation.ShowModalAsync<TermsPage>(options =>
{
options.Animated = true;
});
Root Navigation
Replace the entire root page, clearing the back stack:
await _navigation.SetRootAsync<HomePage>(options =>
{
options.ClearBackStack = true;
options.Animated = false;
options.Reason = "User authenticated";
});
Flow Navigation
Flows represent distinct application states such as Auth, Main, Onboarding, or Checkout.
Define a flow
public sealed class AuthFlow : INavigationFlow
{
public string Name => "Auth";
public Type RootPageType => typeof(LoginPage);
public bool RequiresAuthentication => false;
public Task OnEnterAsync(NavigationFlowContext context) => Task.CompletedTask;
public Task OnExitAsync(NavigationFlowContext context) => Task.CompletedTask;
}
public sealed class MainFlow : INavigationFlow
{
public string Name => "Main";
public Type RootPageType => typeof(HomePage);
public bool RequiresAuthentication => true;
public Task OnEnterAsync(NavigationFlowContext context) => Task.CompletedTask;
public Task OnExitAsync(NavigationFlowContext context) => Task.CompletedTask;
}
Switch flows
// Start auth flow (login screen)
await _flowManager.ResetToFlowAsync<AuthFlow>();
// After login — switch to main flow, back stack cleared automatically
await _flowManager.ResetToFlowAsync<MainFlow>();
// Logout — return to auth
await _flowManager.ResetToFlowAsync<AuthFlow>();
Shared pages across flows
PrivacyPage can be reached from both AuthFlow and MainFlow with different back stacks:
// Registration in MauiProgram.cs
builder.Services.AddShaunebuNavigation()
.AddPage<PrivacyPage>("auth/privacy", flow: "Auth")
.AddPage<PrivacyPage>("main/privacy", flow: "Main");
// Navigation in ViewModel — route resolves based on current flow
await _navigation.GoToAsync<PrivacyPage>();
Back Navigation
// Go back
await _navigation.GoBackAsync();
// Go back with options
await _navigation.GoBackAsync(options =>
{
options.Animated = true;
options.CloseModalFirst = true;
options.RespectGuards = true;
});
// Check if back is available
var canGoBack = await _backService.CanGoBackAsync();
// Handle Android hardware back button
var handled = await _backService.HandleSystemBackButtonAsync();
Back-aware ViewModels
Implement IBackAware to intercept back navigation:
public sealed partial class EditProfileViewModel : IBackAware
{
public async Task<bool> CanGoBackAsync()
{
if (_hasUnsavedChanges)
{
// Show confirmation dialog
return await ShowUnsavedChangesDialogAsync();
}
return true;
}
public Task OnBackAsync() => Task.CompletedTask;
}
Overlays
Transient UI states such as loading and no-internet should be overlays, not navigation pages.
// Loading overlay
await _overlay.ShowLoadingAsync();
try
{
await _authService.LoginAsync();
}
finally
{
await _overlay.HideLoadingAsync();
}
// No-internet overlay
await _overlay.ShowNoInternetAsync();
// ... restore connectivity ...
await _overlay.HideNoInternetAsync();
⚠️ Do not push
LoadingPageorNoInternetPageonto the navigation stack. This corrupts the back stack and leaks transient states.
Navigation Guards
Guards can allow, reject, or redirect navigation before it executes.
Define a guard
public sealed class AuthGuard : INavigationGuard
{
private readonly IAuthService _auth;
public AuthGuard(IAuthService auth) => _auth = auth;
public Task<NavigationGuardResult> CanNavigateAsync(
NavigationGuardContext context,
CancellationToken cancellationToken = default)
{
if (!_auth.IsAuthenticated)
return Task.FromResult(NavigationGuardResult.RedirectTo<LoginPage>());
return Task.FromResult(NavigationGuardResult.Allow());
}
}
Register a guard
builder.Services.AddTransient<INavigationGuard, AuthGuard>();
Guards are automatically invoked by the navigation pipeline before every navigation operation.
Parameter Passing
Simple parameters via dictionary
await _navigation.GoToAsync<ProductDetailsPage>(options =>
{
options.Parameters["ProductId"] = productId;
});
Strongly typed parameters
Define a parameter record:
public sealed record ProductDetailsParameters(Guid ProductId) : INavigationParameters;
Navigate:
await _navigation.GoToAsync<ProductDetailsPage, ProductDetailsParameters>(
new ProductDetailsParameters(productId));
Receive in ViewModel:
public sealed partial class ProductDetailsViewModel
: INavigationParameterReceiver<ProductDetailsParameters>
{
public Task ReceiveParametersAsync(ProductDetailsParameters parameters)
{
ProductId = parameters.ProductId;
return Task.CompletedTask;
}
}
Diagnostics and Logging
Enable diagnostics in options:
options.EnableDiagnostics = true;
The library logs to ILogger via Microsoft.Extensions.Logging. Each navigation operation emits:
- Operation ID
- Source and target page/route
- Presentation mode and stack behavior
- Duration
- Guard results
- Failure reason (if applicable)
Stack inspection
public sealed partial class DebugViewModel
{
private readonly INavigationStackInspector _inspector;
public void InspectStack()
{
var snapshot = _inspector.GetSnapshot();
Console.WriteLine($"Current route: {snapshot.CurrentRoute}");
Console.WriteLine($"Current flow: {snapshot.CurrentFlow}");
Console.WriteLine($"Stack depth: {snapshot.NavigationStack.Count}");
}
}
Shell Compatibility
The library works with Shell-hosted apps:
// AppShell.xaml.cs — bootstrap the initial flow
protected override async void OnAppearing()
{
base.OnAppearing();
await _flowManager.ResetToFlowAsync<AuthFlow>();
}
The Shell adapter (IShellNavigationAdapter) is used internally when Shell is available. You never call Shell.Current.GoToAsync directly.
NavigationPage Compatibility
The library also works with NavigationPage-hosted apps via the INavigationPageAdapter. The same public INavigationHandler API is used regardless — only the internal adapter changes.
Testing Your ViewModels
Because navigation is injected via INavigationHandler, ViewModels are easy to test:
public class LoginViewModelTests
{
[Fact]
public async Task LoginAsync_ShouldSetRootToHomePage()
{
// Arrange
var navigation = Substitute.For<INavigationHandler>();
navigation
.SetRootAsync<HomePage>(Arg.Any<Action<NavigationOptions>>(), Arg.Any<CancellationToken>())
.Returns(NavigationResult.Success(new NavigationOperation
{
PresentationMode = NavigationPresentationMode.Root
}));
var vm = new LoginViewModel(navigation);
// Act
await vm.LoginAsync();
// Assert
await navigation.Received(1).SetRootAsync<HomePage>(
Arg.Any<Action<NavigationOptions>>(),
Arg.Any<CancellationToken>());
}
}
Architecture Overview
┌──────────────────────────────────────────────────────┐
│ Public API Layer │
│ INavigationHandler INavigationFlowManager │
│ IBackNavigationService IOverlayNavigationService │
│ INavigationGuard INavigationDiagnostics │
└────────────────────────┬─────────────────────────────┘
│
┌────────────────────────▼─────────────────────────────┐
│ Navigation Pipeline │
│ 1. Create NavigationOperation │
│ 2. Validate target page / route │
│ 3. Check duplicate navigation threshold │
│ 4. Acquire navigation lock (SemaphoreSlim) │
│ 5. Run guard pipeline │
│ 6. Resolve route/page via IPageResolver │
│ 7. Execute adapter (Shell or NavigationPage) │
│ 8. Invoke INavigationAware lifecycle callbacks │
│ 9. Emit INavigationDiagnostics events │
│ 10. Return NavigationResult │
└────────────────────────┬─────────────────────────────┘
│
┌───────────────┴───────────────┐
│ │
┌────────▼──────────┐ ┌──────────▼────────┐
│ IShellNavigation │ │ INavigationPage │
│ Adapter │ │ Adapter │
└───────────────────┘ └───────────────────┘
Key abstractions
| Interface | Purpose |
|---|---|
INavigationHandler |
Primary navigation API for ViewModels |
INavigationFlowManager |
Start, reset, and complete application flows |
IBackNavigationService |
Centralized back navigation and Android back button handling |
IOverlayNavigationService |
Show/hide loading and no-internet overlays |
INavigationGuard |
Intercept and conditionally allow, reject, or redirect navigation |
INavigationRouteRegistry |
Typed route registration and lookup |
IPageResolver |
Create pages via DI |
INavigationDiagnostics |
Structured navigation event logging |
INavigationStackInspector |
Read-only snapshot of navigation stack state |
INavigationAware |
ViewModel lifecycle: navigated to, navigating from, navigated from |
IBackAware |
ViewModel back button interception with guard logic |
Enterprise Best Practices
Never call
Shell.Current.GoToAsyncdirectly in ViewModels or services. Always useINavigationHandler.Never use
Application.Current.MainPage.Navigationin ViewModels. UseINavigationHandlerorIBackNavigationService.Register all pages through the route registry. Unregistered pages will cause
NavigationFailureReason.PageNotRegisteredat runtime.Use flows for major application state transitions (Auth → Main, Onboarding → Main, Maintenance). Do not manually wipe back stacks.
Use overlays for transient states (loading, no-internet, maintenance banners). Do not push them onto the navigation stack.
Implement
INavigationAwarein ViewModels that need to refresh data on navigation. Avoid usingOnAppearingoverrides in code-behind.Implement
IBackAwarein edit screens with unsaved changes. Let the library call your guard before popping.Enable diagnostics in Debug or Staging environments. Disable in Release if performance is critical.
Set
ThrowOnNavigationFailure = falsein production. HandleNavigationResult.Succeeded == falsegracefully.Use
CancellationTokenin long-running ViewModel operations that trigger navigation. Pass the token through toGoToAsync,GoBackAsync, etc.Use
ResetToFlowAsyncfor flow transitions, notSetRootAsync.ResetToFlowAsyncalso firesOnExitAsyncandOnEnterAsynclifecycle hooks.Register guards in priority order. Maintenance guards should be registered before auth guards so maintenance mode takes precedence.
Use
sealed recordfor parameter objects. Immutable records produce clean equality semantics and work well with the typed parameter pipeline.Always hide overlays in a
finallyblock. An overlay left visible after an exception is a confusing UX defect.Let DI resolve your pages. Never call
new MyPage()— the ViewModel and all its dependencies will be missing.
Anti-Patterns
| Anti-Pattern | Why it's wrong | Correct approach |
|---|---|---|
await Shell.Current.GoToAsync("home") |
String route — typos fail silently; not testable | await _navigation.GoToAsync<HomePage>() |
await Navigation.PushAsync(new LoadingPage()) |
Pushes loading into back stack; user can navigate back to it | await _overlay.ShowLoadingAsync() |
new HomePage() in a PushAsync call |
Bypasses DI; ViewModel won't be injected | Register via DI; let IPageResolver create the page |
Calling GoToAsync without awaiting |
Concurrent navigations corrupt the stack | Always await navigation calls |
Storing Shell.Current in a field |
Null at startup and during tests | Inject INavigationHandler instead |
Wiping back stack manually with PopToRootAsync |
Bypasses flow lifecycle hooks | Use ResetToFlowAsync<TFlow>() |
Catching and swallowing NavigationException |
Hides navigation failures | Use ThrowOnNavigationFailure = false and check NavigationResult.Succeeded |
| Registering the same route string twice | Second registration silently wins or throws | Use distinct route strings; use flow: parameter for shared pages |
| Navigating from a background thread | MAUI UI operations must run on the main thread | Marshal navigation calls to the main thread via MainThread.BeginInvokeOnMainThread |
Implementing OnBackButtonPressed in code-behind |
Platform-only, not testable | Implement IBackAware in the ViewModel |
Troubleshooting
Shell Navigation
Problem: Navigation returns NavigationFailureReason.ShellUnavailable.
Cause: The app is not using a Shell-based root, or Shell.Current is null at startup.
Fix: Ensure your app's root is a Shell subclass, or switch to NavigationPage mode.
Problem: Route is not found despite being registered.
Cause: The route string passed to Shell.RegisterRoute does not match what the library generates.
Fix: Enable RegisterShellRoutesAutomatically = true in ShaunebuNavigationOptions. The library will register routes at startup.
Modal Navigation
Problem: CloseModalAsync returns NavigationFailureReason.ModalStackEmpty.
Cause: No modal page is currently displayed.
Fix: Always check result.Succeeded before proceeding. Consider calling CanGoBackAsync() first.
Problem: Modal page appears in the navigation back stack.
Cause: PushAsync was used instead of PushModalAsync internally.
Fix: Pass PresentationMode = NavigationPresentationMode.Modal explicitly in NavigationOptions.
Back Navigation
Problem: GoBackAsync returns NavigationFailureReason.BackNavigationNotAvailable.
Cause: The navigation stack is empty or you are at the flow root.
Fix: Check CanGoBackAsync() first. At a flow root, use ResetToFlowAsync instead.
Problem: Android hardware back button does not respect IBackAware.
Cause: EnableBackButtonHandling is not enabled.
Fix: Set options.EnableBackButtonHandling = true in setup and ensure your app shell or page calls _backService.HandleSystemBackButtonAsync() from the Android back event.
Overlays
Problem: Overlay does not appear.
Cause: EnableOverlaySystem is false, or no overlay view is registered.
Fix: Set options.EnableOverlaySystem = true. Register a custom overlay view if using ShowOverlayAsync<TOverlay>.
Problem: Overlay persists after navigation.
Cause: HideLoadingAsync was not called in a finally block.
Fix: Always wrap overlay usage in try/finally.
Flow Switching
Problem: After ResetToFlowAsync<MainFlow>(), the login page is still accessible via back.
Cause: ClearBackStack is not applied during flow reset.
Fix: ResetToFlowAsync clears the back stack by default. Verify no manual navigation calls are pushing extra pages after the reset.
Problem: PrivacyPage resolves to the wrong route after flow switch.
Cause: Route registration is missing the flow: parameter for one of the contexts.
Fix: Register the page under each flow explicitly: .AddPage<PrivacyPage>("auth/privacy", flow: "Auth") and .AddPage<PrivacyPage>("main/privacy", flow: "Main").
Common Pitfalls
| Pitfall | Correct approach |
|---|---|
await Shell.Current.GoToAsync("privacy") |
await navigation.GoToAsync<PrivacyPage>() |
await Navigation.PushAsync(new LoadingPage()) |
await overlay.ShowLoadingAsync() |
await Navigation.PushAsync(new NoInternetPage()) |
await overlay.ShowNoInternetAsync() |
| Calling navigation without awaiting | Always await navigation calls |
| Multiple rapid taps causing double push | Handled automatically when PreventDoubleNavigation = true |
Injecting Shell.Current into a ViewModel |
Inject INavigationHandler instead |
Further Documentation
| Document | Description |
|---|---|
| docs/architecture.md | Pipeline internals, adapter pattern, DI graph, flow diagrams |
| docs/migration.md | Moving from raw Shell, NavigationPage, or Prism-style string routing |
| docs/troubleshooting.md | Diagnosing common Shell, flow, guard, overlay, and back-navigation issues |
| docs/extensibility.md | Custom guards, diagnostics, overlays, flows, adapters, and route registry extensions |
| CHANGELOG.md | Release history and roadmap |
| CONTRIBUTING.md | Contribution guidelines, coding standards, and PR rules |
Versioning
This package follows Semantic Versioning 2.0.0.
1.0.0-preview.x— Preview releases. Public API may change between previews.1.0.0— Stable release. Public API is locked per SemVer guarantees.1.x.x— Backward-compatible additions.2.0.0— Breaking public API changes.
Current recommended version: 1.0.0-preview.1
Contributing
See CONTRIBUTING.md for contribution guidelines.
License
This project is licensed under the MIT License.
| Product | Versions Compatible and additional computed target framework versions. |
|---|---|
| .NET | net9.0 is compatible. net9.0-android was computed. net9.0-browser was computed. net9.0-ios was computed. net9.0-maccatalyst was computed. net9.0-macos was computed. net9.0-tvos was computed. net9.0-windows was computed. net10.0 is compatible. net10.0-android was computed. net10.0-browser was computed. net10.0-ios was computed. net10.0-maccatalyst was computed. net10.0-macos was computed. net10.0-tvos was computed. net10.0-windows was computed. |
| .NET Framework | net48 is compatible. net481 was computed. |
-
.NETFramework 4.8
- Microsoft.Extensions.DependencyInjection.Abstractions (>= 8.0.0)
- Microsoft.Extensions.Logging.Abstractions (>= 8.0.0)
- System.Text.Json (>= 8.0.5)
-
net10.0
- Microsoft.Extensions.DependencyInjection.Abstractions (>= 10.0.0)
- Microsoft.Extensions.Logging.Abstractions (>= 10.0.0)
- Shaunebu.MAUI.Navigation (>= 1.0.0-preview.1)
-
net9.0
- Microsoft.Extensions.DependencyInjection.Abstractions (>= 10.0.0)
- Microsoft.Extensions.Logging.Abstractions (>= 10.0.0)
- Shaunebu.MAUI.Navigation (>= 1.0.0-preview.1)
NuGet packages
This package is not used by any NuGet packages.
GitHub repositories
This package is not used by any popular GitHub repositories.
| Version | Downloads | Last Updated |
|---|---|---|
| 1.0.0-preview.1 | 76 | 7/3/2026 |
See CHANGELOG.md for full release history.