Shaunebu.MAUI.Navigation.Debugger 1.0.0-preview.1

This is a prerelease version of Shaunebu.MAUI.Navigation.Debugger.
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
                    
This command is intended to be used within the Package Manager Console in Visual Studio, as it uses the NuGet module's version of Install-Package.
<PackageReference Include="Shaunebu.MAUI.Navigation.Debugger" Version="1.0.0-preview.1" />
                    
For projects that support PackageReference, copy this XML node into the project file to reference the package.
<PackageVersion Include="Shaunebu.MAUI.Navigation.Debugger" Version="1.0.0-preview.1" />
                    
Directory.Packages.props
<PackageReference Include="Shaunebu.MAUI.Navigation.Debugger" />
                    
Project file
For projects that support Central Package Management (CPM), copy this XML node into the solution Directory.Packages.props file to version the package.
paket add Shaunebu.MAUI.Navigation.Debugger --version 1.0.0-preview.1
                    
#r "nuget: Shaunebu.MAUI.Navigation.Debugger, 1.0.0-preview.1"
                    
#r directive can be used in F# Interactive and Polyglot Notebooks. Copy this into the interactive tool or source code of the script to reference the package.
#:package Shaunebu.MAUI.Navigation.Debugger@1.0.0-preview.1
                    
#:package directive can be used in C# file-based apps starting in .NET 10 preview 4. Copy this into a .cs file before any lines of code to reference the package.
#addin nuget:?package=Shaunebu.MAUI.Navigation.Debugger&version=1.0.0-preview.1&prerelease
                    
Install as a Cake Addin
#tool nuget:?package=Shaunebu.MAUI.Navigation.Debugger&version=1.0.0-preview.1&prerelease
                    
Install as a Cake Tool

Shaunebu.MAUI.Navigation

NuGet License: MIT

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 TPage type, 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?

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)
}

// 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 LoadingPage or NoInternetPage onto the navigation stack. This corrupts the back stack and leaks transient states.


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.


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

  1. Never call Shell.Current.GoToAsync directly in ViewModels or services. Always use INavigationHandler.

  2. Never use Application.Current.MainPage.Navigation in ViewModels. Use INavigationHandler or IBackNavigationService.

  3. Register all pages through the route registry. Unregistered pages will cause NavigationFailureReason.PageNotRegistered at runtime.

  4. Use flows for major application state transitions (Auth → Main, Onboarding → Main, Maintenance). Do not manually wipe back stacks.

  5. Use overlays for transient states (loading, no-internet, maintenance banners). Do not push them onto the navigation stack.

  6. Implement INavigationAware in ViewModels that need to refresh data on navigation. Avoid using OnAppearing overrides in code-behind.

  7. Implement IBackAware in edit screens with unsaved changes. Let the library call your guard before popping.

  8. Enable diagnostics in Debug or Staging environments. Disable in Release if performance is critical.

  9. Set ThrowOnNavigationFailure = false in production. Handle NavigationResult.Succeeded == false gracefully.

  10. Use CancellationToken in long-running ViewModel operations that trigger navigation. Pass the token through to GoToAsync, GoBackAsync, etc.

  11. Use ResetToFlowAsync for flow transitions, not SetRootAsync. ResetToFlowAsync also fires OnExitAsync and OnEnterAsync lifecycle hooks.

  12. Register guards in priority order. Maintenance guards should be registered before auth guards so maintenance mode takes precedence.

  13. Use sealed record for parameter objects. Immutable records produce clean equality semantics and work well with the typed parameter pipeline.

  14. Always hide overlays in a finally block. An overlay left visible after an exception is a confusing UX defect.

  15. 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.


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 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. 
Compatible target framework(s)
Included target framework(s) (in package)
Learn more about Target Frameworks and .NET Standard.

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.