Skip to content

Instantly share code, notes, and snippets.

@mattleibow
Created May 7, 2026 17:13
Show Gist options
  • Select an option

  • Save mattleibow/dd45411c6dfa63fcbe6ba90f48c3e5b2 to your computer and use it in GitHub Desktop.

Select an option

Save mattleibow/dd45411c6dfa63fcbe6ba90f48c3e5b2 to your computer and use it in GitHub Desktop.
Shell v2: Incremental Navigation Redesign — spec for discussion

Shell v2: Incremental Navigation Redesign

Problem Statement

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:

  1. Split registration model — Tab roots must be declared in AppShell.xaml; pushed pages must use Routing.RegisterRoute. Restructuring your app (promoting a pushed page to a tab) requires refactoring both XAML and startup code.

  2. No scoped parameters for repeated routes — Shell's flat ShellRouteParameters dictionary 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.

  3. Silent navigation failuresGoToAsync swallows exceptions internally, returns void Task, and provides no feedback mechanism. Discovering why navigation failed requires breakpoints and folklore workarounds (OnNavigatedTo instead of OnAppearing, dispatch to main thread, delay one frame).

  4. No programmatic tab/modal creation — Tabs must exist in AppShell.xaml at startup. You cannot GoToAsync("//newtab/...") to create a tab at runtime, and modals require Navigation.PushModalAsync bypassing Shell entirely. Prism solved this years ago with navigation builders.

  5. No ViewModel navigation lifecycle — Navigation events (OnNavigatedTo, OnNavigatingFrom, guard checks) only exist on Page. ViewModels — the primary interaction point in MVVM apps — must implement IQueryAttributable and receive a raw IDictionary<string, object> with no lifecycle hooks.

Design Principles

  • Incremental, non-breaking — Every phase ships independently alongside existing APIs. AppShell.xaml, Routing.RegisterRoute, and GoToAsync(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.

Phase 0: Unified Route Registration + Route Templates

Status: Route templates implemented in PR #35110 (81 tests, all passing). Registration API is next.

Route Templates (Done)

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.

Unified RouteBuilder API (Proposed)

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.

Estimated effort: 4–6 weeks

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


Phase 1: Scoped Navigation Parameters

Replace the flat ShellRouteParameters dictionary with segment-scoped parameter delivery.

Problem

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.

Proposed Design

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

Estimated effort: 4–6 weeks

New code: ScopedNavigationParameters (~300 lines) Changed code: ShellNavigationManager.ApplyQueryAttributes, ShellSection.GoToAsync (~200 lines) Risk: Medium — internal refactor of parameter flow, but external API unchanged


Phase 2: Navigation Results + Error Handling

Make navigation failures explicit instead of silent.

Problem

await Shell.Current.GoToAsync("nonexistent/route");
// No exception (caught internally), no return value, no event.
// The app just... stays where it was.

Proposed Design

// 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 catch block in ShellNavigationManager.GoToAsync populates the result instead of swallowing
  • NavigationFailed event fires for ALL failures, including ones from OnAppearing race conditions
  • Existing GoToAsync returning Task continues to work (throws on failure as today)

Estimated effort: 2–3 weeks

New code: ShellNavigationResult (~50 lines), NavigationFailed event Changed code: ShellNavigationManager.GoToAsync error handling (~100 lines) Risk: Low — additive API, existing behavior preserved


Phase 3: Programmatic Tabs + Modals via GoToAsync

Make GoToAsync the single entry point for ALL navigation operations.

Problem

// 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

Proposed Design

// 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:

  • ShellNavigationManager creates ShellItem/ShellSection/ShellContent dynamically when CreateTab = true
  • Modal presentation routes through Shell's navigation pipeline (parameters, lifecycle, back navigation all work)
  • Platform renderers (ShellItemRenderer on Android/iOS/Windows) must handle dynamic tab insertion

Estimated effort: 6–8 weeks

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.


Phase 4: ViewModel Navigation Lifecycle

Give ViewModels first-class navigation participation.

Problem

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

Proposed Design

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:

  • ShellNavigationManager checks Page.BindingContext for INavigationAware / INavigationGuard
  • IQueryAttributable still works (backward compat)
  • INavigationParameters wraps the scoped dictionary from Phase 1 with typed accessors

Estimated effort: 3–4 weeks

New code: INavigationAware, INavigationGuard, INavigationParameters (~150 lines) Changed code: ShellNavigationManager lifecycle hooks (~200 lines) Risk: Low — additive interfaces, existing IQueryAttributable unchanged


Summary

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.

Prior Art

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
@aritchie
Copy link
Copy Markdown

aritchie commented May 7, 2026

I love it!

A few questions.

  1. Why not just throw the exception when it fails?
Shell.Current.NavigationFailed += (sender, args) =>
{
    logger.LogWarning("Navigation failed: {Reason}", args.Result.FailureReason);
};
  1. I love the viewmodel proposal. I am curious how you wire that up though? Doing this by naming convention will likely be very problematic. Could it just be added to the route data below? PS - love this layout.
// 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
});
  1. Is there a way to allow any page to be used with "//", so say "//thisotherpage"
  2. Any route page to be used further up a stack? An easy (but stupid sample) "myhomepage" -> "thisotherpage/myhomepage".
  3. Sometimes you want a "middle man" on the tabs. Maybe updating badges or deciding what tabs should/shouldn't be shown based on some API or config file. Any thoughts what could be done there?
  4. Mixin in some AI tool logic here for source gen? :)

Sign up for free to join this conversation on GitHub. Already have an account? Sign in to comment