Shell | Navigation Interceptors
INavigationInterceptor sits in front of every navigation the app makes and can let it through,
cancel it, or send it somewhere else. Register as many as you like — they run in registration order
and the first one to cancel or redirect wins.
public interface INavigationInterceptor{ Task<NavigationInterceptorResult> InterceptNavigationAsync( string uri, object? viewModel, CancellationToken cancellationToken );
// Lowest runs first; ties keep registration order int Order => 0;}Every navigation method returns Task<bool> — false means a guard cancelled it:
if (!await navigator.NavigateTo<DetailViewModel>()) // an interceptor said noThe auth guard
Section titled “The auth guard”public class AuthNavigationInterceptor(IAuthService auth) : INavigationInterceptor{ // Guards run before anything that only observes public int Order => -100;
public async Task<NavigationInterceptorResult> InterceptNavigationAsync( string uri, object? viewModel, CancellationToken cancellationToken ) { if (await auth.IsAuthorized(cancellationToken) || uri.Contains("Login")) return NavigationInterceptorResult.Continue;
return NavigationInterceptorResult.Redirect<LoginViewModel>(); }}builder.UseShinyShell(x => x .AddGeneratedMaps() .AddNavigationInterceptor<AuthNavigationInterceptor>() .AddNavigationInterceptor<AuditNavigationInterceptor>()
// or inline, when a class would be ceremony .AddNavigationInterceptor((uri, vm, ct) => { Console.WriteLine($"Navigating to {uri}"); return Task.FromResult(NavigationInterceptorResult.Continue); }, order: 100));Interceptors run in Order (lowest first), then registration order — so a guard can be pinned ahead
of an audit log without the two registrations having to know about each other.
Interceptors are registered as singletons, so keep no per-navigation state in fields. Register them
yourself (builder.Services.AddTransient<INavigationInterceptor, MyInterceptor>()) for any other
lifetime — the pipeline resolves them fresh on each navigation.
What gets intercepted
Section titled “What gets intercepted”| Path | Intercepted | viewModel argument |
|---|---|---|
NavigateTo(route) |
✅ | Resolved from the route’s ViewModel mapping |
NavigateTo<TViewModel>(configure) |
✅ | Your instance, after configure ran |
CreateBuilder()…Navigate() |
✅ | The last segment’s ViewModel — the page the user lands on |
GoBack / PopToRoot |
✅ | The existing ViewModel from the navigation stack |
| App links & app shortcuts | ✅ | The ViewModel with the link’s values already applied |
| Tab taps, flyout items, hardware back | ✅ | null |
ShowDialog / SwitchShell |
❌ | — |
Deep links are the reason this exists: an inbound URL can arrive at any moment, at any route, from outside the app, and it goes through exactly the same guards as a button tap.
The ViewModel argument
Section titled “The ViewModel argument”The ViewModel handed over is the destination one, resolved from DI and fully populated before
the interceptor is called — the configure callback has run, an app link’s values are already
bound. So a guard can decide on the destination’s own state rather than parsing its URI:
public Task<NavigationInterceptorResult> InterceptNavigationAsync(string uri, object? viewModel) => Task.FromResult(viewModel is OrderViewModel { RequiresApproval: true } && !user.IsManager ? NavigationInterceptorResult.Cancel() : NavigationInterceptorResult.Continue );That instance is the one bound to the page, so changes an interceptor makes stick. Two exceptions:
navigation arguments are applied by Shell afterwards and win over an interceptor’s edit to the same
property, and a route declared as a ShellContent in AppShell XAML keeps whatever ViewModel its
page is already bound to — the interceptor sees a resolved instance and can still cancel or
redirect on it, but a change it makes does not reach an on-screen page.
It is null when the destination route has no ViewModel mapping, and for user-driven Shell
navigation — a tab tap or the hardware back button — where Shell builds the destination itself and
Shiny will not construct a ViewModel nobody binds.
The page being left
Section titled “The page being left”Inject INavigationContextAccessor for everything that does not fit in the two arguments:
public class UnsavedChangesNavigationInterceptor( INavigationContextAccessor context, IDialogs dialogs) : INavigationInterceptor{ public async Task<NavigationInterceptorResult> InterceptNavigationAsync(string uri, object? viewModel) { if (context.Current?.FromViewModel is not IUnsavedChanges { HasUnsavedChanges: true }) return NavigationInterceptorResult.Continue;
return await dialogs.Confirm("Unsaved Changes", "Discard changes?") ? NavigationInterceptorResult.Continue : NavigationInterceptorResult.Cancel(); }}NavigationContext carries FromUri, FromViewModel, ToUri, NavigationType, Direction,
Parameters and RedirectCount. Current is null outside an interceptor call.
Interceptors run on the main thread, so a dialog can be awaited directly — the navigation waits.
Direction
Section titled “Direction”Direction is the coarse question NavigationType answers precisely, for the guard that only cares
which way the user is going:
NavigationDirection |
From |
|---|---|
Forward |
Push |
Back |
GoBack, PopToRoot |
Root |
SetRoot (an absolute //route), SwitchShell |
if (context.Current?.Direction == NavigationDirection.Back) return NavigationInterceptorResult.Continue; // never guard the way outIt is on NavigationEventArgs and NavigatedEventArgs too, and any NavigationType converts with
navigationType.GetDirection().
Bypassing the guards
Section titled “Bypassing the guards”A guard that navigates would otherwise guard itself. Every navigation method takes
bypassInterceptors:
await navigator.NavigateTo<LoginViewModel>(bypassInterceptors: true);await navigator.GoBack(1, bypassInterceptors: true);await navigator.PopToRoot(bypassInterceptors: true);await navigator.CreateBuilder().AddDetail(42).Navigate(bypassInterceptors: true);
// the builder is fluent, so it reads fluently too - call it anywhere in the chainawait navigator.CreateBuilder().BypassInterceptors().AddDetail(42).Navigate();A RedirectUri never needs it — the chain restarting on a redirect is deliberate, and a redirect to
the destination already being navigated to is ignored rather than looping.
bypassInterceptors is about the interceptor chain only: it does not skip
INavigationConfirmation, which is the ViewModel’s own guard on the page
being left.
Cancellation
Section titled “Cancellation”Interceptors receive the CancellationToken passed to the navigation call — for the network call an
auth guard makes, not for the decision itself, which is what Cancel() is for. Cancelling it
abandons the navigation with an OperationCanceledException.
await navigator.NavigateTo<DetailViewModel>(cancellationToken: cts.Token);Cancel and redirect
Section titled “Cancel and redirect”| Result | Behaviour |
|---|---|
NavigationInterceptorResult.Continue |
Next interceptor, then navigate |
Cancel() |
Nothing navigates; the rest of the chain is skipped |
Redirect("Detail") |
Pushes |
Redirect("//Main/Home") |
Resets the Shell stack |
Redirect("/Login") |
Same as //Login — a single leading slash is promoted |
Redirect<LoginViewModel>() |
Resets the stack to that ViewModel’s route |
Redirect<DetailViewModel>(relativeNavigation: true) |
Pushes that ViewModel’s route |
Prefer the typed Redirect<TViewModel>() — the route comes from the ViewModel map, so renaming a
route does not silently break a guard.
CancelNavigation wins if a result sets both. A cancelled navigation is not a failure: the caller’s
Task completes normally and the user simply stays where they are.
An exception thrown from an interceptor propagates to the caller and the navigation does not happen — a guard that fails is never treated as a guard that passed. On tab taps and hardware back, where there is no caller to throw to, the exception is logged and the navigation is cancelled.
Blocked app links
Section titled “Blocked app links”IAppLinks.Handle returns an AppLinkResult: Navigated, Blocked (a guard cancelled it) or
Unhandled (nothing matched). The platform hooks still report a blocked link as handled —
telling iOS otherwise invites it to open the URL in a browser instead, which is the opposite of what
a guard that just blocked it wants. The distinction is there for the Windows case, where you forward
activation by hand, and for logging.
Choosing between the hooks
Section titled “Choosing between the hooks”| Scope | Can cancel | Can redirect | |
|---|---|---|---|
INavigationInterceptor |
App-wide, about the destination, every navigation | ✅ | ✅ |
INavigationConfirmation |
One ViewModel, about leaving it, user-driven navigation only | ✅ | ❌ |
Navigating / Navigated events |
App-wide, observation only | ❌ | ❌ |
INavigationConfirmation is asked only when the user navigates away (tab tap, flyout item,
hardware back button) — programmatic navigation has never consulted it — and it is asked before the
interceptors. Use it for “this page has unsaved work”; use an interceptor when the rule belongs to
the app rather than to one page, when it must also cover INavigator calls and inbound links, or
when it needs to redirect.


