[Proposal] Shell Routing Decomposition — Separate Router from Compositor
- 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
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