dotnet / dotnet/maui

[Proposal] Shell Routing Decomposition — Separate Router from Compositor

Open
#35,348 0 comments 4 reactions 0 assignees View on GitHub
area-controls-shell proposal/open s/triaged
Dominant language
C#
Stars
23.3k
Forks
2k
Avg merge
1d 15h
Merged PRs (30d)
290

Description

### Description

Shell today conflates three responsibilities: **visual composition** (tabs, flyout), **route registry** (URI → page mapping), and **navigation stack management** (push/pop/modal). This coupling means developers who want Shell's routing and deep-link support must also adopt Shell's visual structure — even when they need custom tab bars, flyouts, or page layouts that Shell cannot express.

This proposal separates Shell's **router** from its **compositor**, making route-based navigation available to any MAUI app regardless of whether it uses `AppShell.xaml`.

> [!NOTE]
> Route templates were delivered for .NET 11 in #35110. That closes one part of this proposal, but the larger decomposition, navigation-service, lifecycle, diagnostics, and compositor-independence work remains.

### Motivation

Partner feedback from production apps consistently describes this pattern:

> "We don't care for structuring the app in AppShell — we can create the structure within individual pages. What we could use is route-based navigation to ContentPages with deep links, simplified parameter passing, and hierarchical navigation."

Specific partner requirements that Shell cannot satisfy today:
- Custom center button in bottom tabs
- In-page tabs positioned below dynamic-height content
- Text wrapping in tab headers (accessibility)
- Horizontally scrolling dynamic tab headers
- Fully custom flyout section content

These teams have already built custom compositors. They want Shell's **routing engine** without Shell's **visual opinions**.

### Design Spec

Full design spec with API proposals: [`docs/design/ShellRoutingDecomposition.md`](https://github.com/dotnet/maui/blob/pureween/shell-routing-decomposition-spec/docs/design/ShellRoutingDecomposition.md)

### Proposed API Changes

**Phase 1 — Foundation:**
- `INavigationService` — testable navigation abstraction for ViewModels, with no dependency on Shell or MAUI UI types
- `NavigationResult` + `TryGoToAsync()` — non-throwing navigation with typed failure reasons
- Reliable navigation serialization — replaces "Pending Navigations still processing" failures
- Auto UI-thread dispatch on all `GoToAsync` calls
- `IQueryAttributable` moved to `Microsoft.Maui` assembly (type-forwarded)

**Phase 2 — Navigation Lifecycle:**
- Scoped parameter delivery for every matched route segment
- `INavigationAware` / `IConfirmNavigation` — ViewModel lifecycle hooks (including tab switches)

**Phase 3 — Polish:**
- `INavigationGuard` — auth guards
- `[Route]` attribute + source generator
- `RouteFactory` on `ShellContent` for DI-resolved tab root pages

### Relationship to Existing Navigation APIs

This work must build on MAUI's existing navigation plumbing rather than creating a parallel stack:

| Existing layer | Current responsibility | Constraint for this proposal |
|---|---|---|
| `INavigation` | Page-level push, pop, and modal operations | Existing calls and new route-based calls must mutate the same authoritative stack |
| `NavigationProxy` | Delegates `Page.Navigation` through the visual tree and queues operations until a handler attaches | Reuse or deliberately replace this behavior; do not add a second independent queue |
| `IStackNavigation` | Handler protocol used by platform navigation implementations | Remains the adapter boundary for applying stack changes |
| `Page.SendNavigatedTo`, `SendNavigatingFrom`, `SendNavigatedFrom` | Existing page lifecycle notifications | ViewModel navigation lifecycle should compose with these rather than conflict with them |

The key compatibility invariant is that mixing:

```csharp
await page.Navigation.PushAsync(new DetailsPage());
await navigationService.GoToAsync("details/42");
```

must not create two divergent stacks. Back navigation, native gestures, deep links, and restored state must all observe the same navigation state.

`NavigationProxy` already queues operations while a page is unattached and replays them when an inner navigator becomes available. Any new navigation queue or transaction coordinator should first define how it integrates with that behavior.

### Cross-Framework and Lifecycle Constraints

Follow-up design work identified two additional requirements:

- If `INavigationService` is intended for ViewModels shared by MAUI and Blazor, its contracts must live in a UI-independent assembly. Platform-specific presentation options belong in adapters, not the abstraction.
- Navigation lifecycle and visibility lifecycle are different. The contract must explicitly define behavior for route changes, tab reselection, tab switching, modal coverage, window activation, and app resume instead of overloading `OnNavigatedTo`.

### Usage Scenarios

**ViewModel navigation without Shell reference:**
```csharp
public class ProductsViewModel
{
private readonly INavigationService _nav;
public ProductsViewModel(INavigationService nav) => _nav = nav;

public async Task OpenProduct(string sku)
{
var result = await _nav.GoToAsync($"product/{sku}");
if (!result.IsSuccessful)
Debug.WriteLine($"Failed: {result.FailureReason}");
}
}
```

**Route templates (delivered by #35110):**
```csharp
Routing.RegisterRoute("product/{sku}", typeof(ProductDetailPage));

await Shell.Current.GoToAsync("product/seed-tomato");
```

**Navigation confirmation on ViewModel:**
```csharp
public class CheckoutViewModel : IConfirmNavigation
{
public async Task CanNavigateAsync(NavigationContext ctx, CancellationToken ct)
{
if (!_hasUnsavedItems) return true;
return await Shell.Current.DisplayAlert("Leave?", "Abandon cart?", "Leave", "Stay");
}
}
```

### Backward Compatibility

All changes are **additive**. Zero migration required:
- Existing `AppShell.xaml` apps compile and run unchanged
- `Routing.RegisterRoute(string, Type)` works identically
- `GoToAsync(string)` works identically
- `IQueryAttributable` type-forwarded from old namespace

### Related Issues

Delivered foundation: #35110 (route templates)

Fixes or addresses: #35107, #5312, #3917, #3868, #9649, #20902, #27589, #13537, #31266, #11307, #17608, #6193, #12162

Complementary to: #32985 (Shell Handlers for Android), #21816 (Shell service scope hooks), #30180 (Blank Canvas Shell)

Parent epic: #30195

Contributor guide

Open the contributing guide

Research direction

Start by reading docs/design/ShellRoutingDecomposition.md, then inspect the existing INavigation, NavigationProxy, IStackNavigation, and Page lifecycle layers named in the issue. This is an unscoped design proposal, so work is only done after a maintainer selects a phase and the resulting change preserves the stated shared-stack and backward-compatibility requirements.

Written by the indexing model from the issue text.

Assessment

Tech stack
csharp
Domain
api, desktop-dev, mobile-dev
Issue type
Feature
Difficulty
5/5
Estimated time
Over a week
Activity status
Quiet
Clarity
Mostly clear
Newbie friendliness
25/100

Get new issues in your inbox

A short digest of beginner-friendly GitHub issues.