Shell's current architecture conflates navigation structure (tabs, flyout, modals) with navigation operations (pushing pages with parameters). This creates five interrelated pain points that push developers toward workarounds or away from Shell entirely:
-
Split registration model — Tab roots must be declared in
AppShell.xaml; pushed pages must useRouting.RegisterRoute. Restructuring your app (promoting a pushed page to a tab) requires refactoring both XAML and startup code. -
No scoped parameters for repeated routes — Shell's flat
ShellRouteParametersdictionary means you can't push the same page type twice with different state (e.g.,/product/apple/compare/product/banana). Path parameters compound this by making the pattern more tempting without delivering scoped delivery. -
Silent navigation failures —
GoToAsyncswallows exceptions internally, returns voidTask, and provides no feedback mechanism. Discovering why navigation failed requires breakpoints and folklore workarounds (OnNavigatedToinstead ofOnAppearing, dispatch to main thread, delay one frame). -
No programmatic tab/modal creation — Tabs must exist in
AppShell.xamlat startup. You cannotGoToAsync("//newtab/...")to create a tab at runtime, and modals requireNavigation.PushModalAsyncbypassing Shell entirely. Prism solved this years ago with navigation builders. -
No ViewModel navigation lifecycle — Navigation events (
OnNavigatedTo,OnNavigatingFrom, guard checks) only exist onPage. ViewModels — the primary interaction point in MVVM apps — must implementIQueryAttributableand receive a rawIDictionary<string, object>with no lifecycle hooks.
- Incremental, non-breaking — Every phase ships independently alongside existing APIs.
AppShell.xaml,Routing.RegisterRoute, andGoToAsync(string)continue working unchanged. - Unified registration — One API for all route types (tabs, pushable pages, modals).
- Scoped parameters — Each route segment owns its parameters. No flat dictionary sharing.
- Explicit over silent — Navigation always reports success/failure.
- ViewModel-first — Navigation lifecycle lives on ViewModels, not just Pages.
Status: Route templates implemented in PR #35110 (81 tests, all passing). Registration API is next.
Full {param} path parameter support integrated into Shell:
// Required, optional, defaults, constraints, catch-all, mixed segments
Routing.RegisterRoute("product/{sku}", typeof(ProductDetailPage));
Routing.RegisterRoute("review/{stars:int=5}", typeof(ReviewPage));
Routing.RegisterRoute("order/{id:int}", typeof(OrderDetailPage));
Routing.RegisterRoute("files/{*path}", typeof(FileBrowserPage));
Routing.RegisterRoute("item-{sku}", typeof(ProductPage)); // mixed
// Navigation extracts parameters automatically
await Shell.Current.GoToAsync("//products/product/seed-tomato/review");
// ProductDetailPage.Sku == "seed-tomato"
// ReviewPage.Stars == "5" (default), ReviewPage.Sku == "seed-tomato" (inherited)Supported syntax: {name}, {name?}, {name=default}, {*name}, {name:int}, prefix-{name}-suffix
Constraints: int, long, double, bool, guid, alpha
Behavior: Literal routes win over templates (ASP.NET Core precedence). Path params win over query strings. Shell.CurrentState.Location shows resolved values, not template tokens.
Replace the XAML/RegisterRoute split with a single fluent builder:
// In MauiProgram.cs or Shell constructor
Shell.Routes.Configure(routes =>
{
routes.Tab("products", "Products", "groceries.png", tab =>
{
tab.Content<ProductCatalogPage>("catalog"); // tab root
tab.Route<ProductDetailPage>("product/{sku}"); // pushable child
tab.Route<ReviewPage>("product/{sku}/review/{stars:int=5}");
});
routes.Tab("orders", "Orders", "orders.png", tab =>
{
tab.Content<OrderListPage>("list");
tab.Route<OrderDetailPage>("order/{id:int}");
});
routes.Modal<PermissionsPage>("permissions");
routes.Route<SettingsPage>("settings"); // global pushable
});Key insight: The builder decides what's structural (tabs) vs operational (pushable routes). Developers don't need to know the difference — the API handles it.
AppShell.xaml backward compatibility: Existing XAML-declared shells continue working. The builder is an alternative, not a replacement. Source generators can emit builder calls from XAML for a unified internal model.
New code: ShellRouteBuilder (~500 lines), registration wiring
Changed code: Shell.cs (new Routes property), Routing.cs (internal consolidation)
Risk: Low — additive API, no existing behavior changes
Replace the flat ShellRouteParameters dictionary with segment-scoped parameter delivery.
Today, ShellRouteParameters is Dictionary<string, object>. When navigating to product/apple/compare/product/banana, both ProductPage instances receive the same flat dictionary. The framework uses prefix filtering ("product/{sku}.sku") as a workaround, but it's fragile and undiscoverable.
// Internal: replaces ShellRouteParameters
internal class ScopedNavigationParameters
{
// Query string + programmatic params (GoToAsync overload)
public Dictionary<string, object> Global { get; }
// Path params scoped to their route segment
// Key: route key (e.g., "product/{sku}")
// Value: extracted params (e.g., { "sku": "seed-tomato" })
public Dictionary<string, Dictionary<string, object>> RouteScoped { get; }
// Resolves params for a specific page given its route key and position
public IDictionary<string, object> ResolveForRoute(string routeKey, bool isLast) { }
}Delivery: ApplyQueryAttributes calls ResolveForRoute(routeKey, isLast) instead of prefix-filtering a flat dictionary. Each page receives only its own route-scoped params plus global params.
Backward compatibility: IQueryAttributable.ApplyQueryAttributes(IDictionary<string, object>) still receives a merged dictionary. The scoping is internal — existing code sees the same interface.
New code: ScopedNavigationParameters (~300 lines)
Changed code: ShellNavigationManager.ApplyQueryAttributes, ShellSection.GoToAsync (~200 lines)
Risk: Medium — internal refactor of parameter flow, but external API unchanged
Make navigation failures explicit instead of silent.
await Shell.Current.GoToAsync("nonexistent/route");
// No exception (caught internally), no return value, no event.
// The app just... stays where it was.// New return type
public class ShellNavigationResult
{
public bool Succeeded { get; }
public Exception Exception { get; } // null on success
public string FailureReason { get; } // human-readable
public ShellNavigationSource Source { get; }
}
// New overload (existing overload unchanged)
public Task<ShellNavigationResult> GoToAsync(
ShellNavigationState state,
ShellNavigationParameters parameters = null);
// Event for global handling
Shell.Current.NavigationFailed += (sender, args) =>
{
logger.LogWarning("Navigation failed: {Reason}", args.Result.FailureReason);
};Key behaviors:
- Every
catchblock inShellNavigationManager.GoToAsyncpopulates the result instead of swallowing NavigationFailedevent fires for ALL failures, including ones fromOnAppearingrace conditions- Existing
GoToAsyncreturningTaskcontinues to work (throws on failure as today)
New code: ShellNavigationResult (~50 lines), NavigationFailed event
Changed code: ShellNavigationManager.GoToAsync error handling (~100 lines)
Risk: Low — additive API, existing behavior preserved
Make GoToAsync the single entry point for ALL navigation operations.
// Today: tabs must exist in AppShell.xaml. You cannot do this:
await Shell.Current.GoToAsync("//newtab/product/seed-tomato");
// Today: modals require bypassing Shell:
await Navigation.PushModalAsync(new PermissionsPage());
// ...which doesn't participate in Shell routing, parameters, or lifecycle// Create a tab at runtime
await Shell.Current.GoToAsync("//products/product/seed-tomato", new NavigationOptions
{
CreateTab = true,
TabTitle = "Seed Tomato",
TabIcon = "tomato.png"
});
// Present as modal (stays in Shell routing)
await Shell.Current.GoToAsync("permissions", new NavigationOptions
{
Presentation = PresentationMode.Modal
});
// Present as modal with animation control
await Shell.Current.GoToAsync("permissions", new NavigationOptions
{
Presentation = PresentationMode.ModalAnimated
});Internal changes:
ShellNavigationManagercreatesShellItem/ShellSection/ShellContentdynamically whenCreateTab = true- Modal presentation routes through Shell's navigation pipeline (parameters, lifecycle, back navigation all work)
- Platform renderers (
ShellItemRendereron Android/iOS/Windows) must handle dynamic tab insertion
New code: NavigationOptions (~100 lines), dynamic ShellItem creation (~300 lines)
Changed code: ShellNavigationManager, platform renderers (~500 lines across 3 platforms)
Risk: Highest phase — platform renderers must respond to runtime tab additions. Android's BottomNavigationView, iOS's UITabBarController, and Windows NavigationView all handle this differently.
Give ViewModels first-class navigation participation.
// Today: navigation lifecycle is Page-only
public partial class ProductPage : ContentPage
{
protected override void OnNavigatedTo(NavigatedToEventArgs args) { }
}
// ViewModels get IQueryAttributable — a raw dictionary, no lifecycle:
public class ProductViewModel : IQueryAttributable
{
public void ApplyQueryAttributes(IDictionary<string, object> query) { }
// No OnNavigatingFrom, no guard checks, no async initialization
}public interface INavigationAware
{
/// Called after navigation completes. Parameters are scoped to this route.
void OnNavigatedTo(INavigationParameters parameters);
/// Called before navigating away. Set context.Cancel = true to block.
void OnNavigatingFrom(NavigatingFromContext context);
}
public interface INavigationGuard
{
/// Called before navigation. Return false to prevent navigation.
Task<bool> CanNavigateAsync(INavigationParameters parameters);
}
// Usage
public class ProductViewModel : ObservableObject, INavigationAware, INavigationGuard
{
public async Task<bool> CanNavigateAsync(INavigationParameters parameters)
{
// Check permissions, unsaved changes, etc.
if (HasUnsavedChanges)
return await ShowConfirmDialog("Discard changes?");
return true;
}
public void OnNavigatedTo(INavigationParameters parameters)
{
Sku = parameters.Get<string>("sku");
await LoadProductAsync(Sku);
}
public void OnNavigatingFrom(NavigatingFromContext context)
{
// Cleanup, cancel pending operations
}
}Internal changes:
ShellNavigationManagerchecksPage.BindingContextforINavigationAware/INavigationGuardIQueryAttributablestill works (backward compat)INavigationParameterswraps the scoped dictionary from Phase 1 with typed accessors
New code: INavigationAware, INavigationGuard, INavigationParameters (~150 lines)
Changed code: ShellNavigationManager lifecycle hooks (~200 lines)
Risk: Low — additive interfaces, existing IQueryAttributable unchanged
| Phase | What | Weeks | Breaking? | Ships alone? | Risk |
|---|---|---|---|---|---|
| 0 | Route templates + RouteBuilder | 4–6 | No | ✅ | Low |
| 1 | Scoped parameters | 4–6 | No | ✅ | Medium |
| 2 | Navigation results | 2–3 | No | ✅ | Low |
| 3 | Programmatic tabs + modals | 6–8 | No | ✅ | High |
| 4 | ViewModel lifecycle | 3–4 | No | ✅ | Low |
| Total | 19–27 weeks | None | All |
Key property: Every phase is non-breaking and ships independently. No big-bang rewrite. The existing Shell API surface is preserved — new APIs are strictly better alternatives.
Phase 0 is already in progress — route templates are implemented in PR #35110 with 81 tests, full constraint/default/catch-all/mixed-segment support, and 5 rounds of adversarial review.
| Framework | Unified Registration | Scoped Params | Nav Results | Programmatic Tabs | VM Lifecycle |
|---|---|---|---|---|---|
| Prism | ✅ ContainerRegistry | ✅ INavigationParameters | ✅ INavigationResult | ✅ NavigationBuilder | ✅ INavigationAware |
| Blazor | ✅ @page directives | ✅ [Parameter] per component | ✅ NavigationManager events | N/A (web) | ✅ Component lifecycle |
| React Navigation | ✅ createNavigator | ✅ route.params | ✅ navigation.navigate returns | ✅ dynamic screens | ✅ useFocusEffect |
| Shell today | ❌ Split XAML/code | ❌ Flat dictionary | ❌ Silent failures | ❌ XAML only | ❌ Page only |
| Shell v2 | ✅ RouteBuilder | ✅ ScopedParams | ✅ NavigationResult | ✅ NavigationOptions | ✅ INavigationAware |
The navigation result concept feels a bit over-engineered... I think the main problem today is that you have no idea if navigation fails necessarily, but if there was an exception thrown at least instead of buried, that would be fine. GoToAsync could just throw.