diff --git a/docs/specs/shell-route-templates.md b/docs/specs/shell-route-templates.md
new file mode 100644
index 000000000000..c9771e16f387
--- /dev/null
+++ b/docs/specs/shell-route-templates.md
@@ -0,0 +1,456 @@
+# Shell Route Templates — Spec & Prototype
+
+> Status: **Draft / Design Spike**
+> Tracking issue: [dotnet/maui#35107](https://github.com/dotnet/maui/issues/35107)
+> Related comments: [Proposal A](https://github.com/dotnet/maui/issues/35107#issuecomment-4306338706), [XAML compatibility](https://github.com/dotnet/maui/issues/35107#issuecomment-4306367201)
+> Prototype: [`prototype/ShellRouteTemplates/`](../../prototype/ShellRouteTemplates) (28 passing xUnit tests)
+
+This document specifies an **additive, opt-in** extension to `Microsoft.Maui.Controls` Shell routing
+that allows route registrations to declare inline path parameters using the standard
+`{param}` template syntax used by ASP.NET Core and Blazor.
+
+```csharp
+// New (opt-in)
+Routing.RegisterRoute("product/{sku}", typeof(ProductDetailPage));
+await Shell.Current.GoToAsync("//main/products/product/seed-tomato/review");
+// → ProductDetailPage.Sku = "seed-tomato"
+// → ProductReviewPage.Sku = "seed-tomato" (inherited from parent template)
+```
+
+---
+
+## 1. Goals & non-goals
+
+### Goals
+
+1. Enable a single `GoToAsync` call to push a multi-page navigation stack where
+ intermediate pages receive their own parameters.
+2. Match ASP.NET Core / Blazor `{param}` syntax exactly so the muscle memory transfers.
+3. **Preserve every existing route registration and navigation call unchanged**
+ — purely additive change.
+4. Reuse the existing `[QueryProperty]` and `IQueryAttributable` delivery pipeline.
+
+### Non-goals (out of scope for v1)
+
+- Optional parameters (`{param?}`) — listed in §10 future work.
+- Catch-all parameters (`{*rest}`) — §10.
+- Route constraints (`{id:int}`) — §10.
+- Mixed literal+parameter segments (`product-{sku}`) — §10.
+- Changing `Shell.CurrentState.Location` formatting beyond what's required to
+ round-trip a templated URI back through `GoToAsync`.
+
+---
+
+## 2. Why path parameters
+
+Today, parameters travel as query string entries. A URI may have **at most one** `?`,
+so multi-segment navigation cannot deliver parameters to intermediate pages:
+
+```csharp
+// Today — broken:
+GoToAsync("//main/products/product/review?sku=seed-tomato");
+// product/review is matched as one nested global-route push, but the only
+// `?` belongs to the leaf — no clean way to address the "product" page in the middle.
+
+// Workaround — two sequential pushes (causes flicker, breaks deep linking):
+await Shell.Current.GoToAsync("product?sku=seed-tomato");
+await Shell.Current.GoToAsync("review");
+```
+
+Path parameters fix this structurally. The parameter sits **inside the path**, where it
+belongs to the segment it follows, and is naturally inherited by anything below it.
+
+---
+
+## 3. Surface design
+
+### 3.1 Registration
+
+```csharp
+// All four forms supported. Existing literal routes unchanged.
+Routing.RegisterRoute("product", typeof(CatalogPage)); // literal
+Routing.RegisterRoute("product/{sku}", typeof(ProductDetailPage)); // template
+Routing.RegisterRoute("review", typeof(ProductReviewPage)); // literal child
+Routing.RegisterRoute("order/{orderId}", typeof(OrderDetailPage)); // template
+```
+
+XAML:
+
+```xml
+
+
+
+
+
+```
+
+### 3.2 Absolute navigation
+
+```csharp
+await Shell.Current.GoToAsync("//main/products/product/seed-tomato");
+// Matches "product/{sku}" → ProductDetailPage with sku=seed-tomato
+
+await Shell.Current.GoToAsync("//main/products/product/seed-tomato/review");
+// Matches "product/{sku}" then "review" → 2-page stack, both pages see sku=seed-tomato
+
+await Shell.Current.GoToAsync("//main/orders/order/ORD-00001");
+// Matches "order/{orderId}" → OrderDetailPage with orderId=ORD-00001
+```
+
+### 3.3 Relative navigation
+
+```csharp
+// Currently on //main/products
+await Shell.Current.GoToAsync("product/seed-tomato");
+// → //main/products/product/seed-tomato
+
+// Currently on //main/products/product/seed-tomato
+await Shell.Current.GoToAsync("review");
+// → //main/products/product/seed-tomato/review
+
+await Shell.Current.GoToAsync("..");
+// Pops the review page; product detail remains with its already-set sku.
+```
+
+### 3.4 Parameter delivery
+
+No new attribute needed. `[QueryProperty]` already understands "named parameter":
+
+```csharp
+[QueryProperty(nameof(Sku), "sku")]
+public partial class ProductDetailPage : ContentPage { /* receives sku from path */ }
+
+[QueryProperty(nameof(Sku), "sku")]
+public partial class ProductReviewPage : ContentPage { /* inherits sku from parent template */ }
+
+// Or via IQueryAttributable
+public partial class ProductDetailPage : ContentPage, IQueryAttributable
+{
+ public void ApplyQueryAttributes(IDictionary query)
+ {
+ if (query.TryGetValue("sku", out var sku)) { /* … */ }
+ }
+}
+```
+
+Path parameters and query string parameters merge into the same dictionary that already
+flows through `ShellNavigationManager.ApplyQueryAttributes`.
+
+### 3.5 Mixing path params and query strings
+
+```csharp
+Routing.RegisterRoute("product/{sku}", typeof(ProductDetailPage));
+await Shell.Current.GoToAsync("//main/products/product/seed-tomato?highlight=true");
+// sku = "seed-tomato" (path)
+// highlight = "true" (query string, applied to the leaf page only, as today)
+```
+
+Path param wins on key conflict — see §6.3.
+
+---
+
+## 4. Route matching
+
+### 4.1 Algorithm
+
+For each URI segment position, find every registered template that matches starting there.
+Pick the **most specific** one. Specificity score: literal segment = 2, parameter segment = 1.
+This mirrors ASP.NET Core's route precedence.
+
+```
+Routes registered:
+ "product" specificity = 2
+ "product/{sku}" specificity = 3
+ "{anything}" specificity = 1
+ "product/seed-tomato" specificity = 4
+ "{a}/{b}" specificity = 2
+
+URI "product" → "product" (2 wins over 1)
+URI "product/seed-tomato" → "product/seed-tomato" (4 wins over 3, 2)
+URI "product/banana" → "product/{sku}" (3 wins over 1)
+URI "review" → "{anything}" (only match)
+```
+
+### 4.2 Parameter inheritance ("innermost wins")
+
+When multiple templates extract the same key during one navigation, the deeper one wins:
+
+```
+GoToAsync("//main/a/parent-id/b/child-id")
+Routes: "a/{id}", "b/{id}"
+→ id = "child-id"
+```
+
+This matches ASP.NET Core's nested-route-value behavior. In practice, real app
+templates avoid name collisions (`sku` vs `orderId` vs `lineId`), so this is a
+deterministic-tie-breaker rather than a frequent occurrence.
+
+### 4.3 Where this hooks into the real Shell code
+
+Concrete files (verified against current `main`):
+
+| File | Today | Required change |
+|---|---|---|
+| `Routing.cs` | `s_routes : Dictionary` keyed by literal route. | Detect `{...}` segments in `RegisterRoute`. Store a parsed `RouteTemplate` next to the factory. |
+| `Routing.cs` | `GetRouteKeys()` returns the literal route keys. | Continue returning the template strings as-is — `ShellUriHandler` distinguishes templates by the `{` in the key. |
+| `Routing.cs` | `GetOrCreateContent(route)` looks up the factory by exact route key. | Look up by **template key** (e.g. `"product/{sku}"`), not by the user-supplied path slice. |
+| `ShellUriHandler.cs` | `SearchPath` and `FindAndAddSegmentMatch` compare segments with `==`. | When a `routeKey` contains `{`, parse it as `RouteTemplate` and call `TryMatch` instead of literal compare. |
+| `RouteRequestBuilder.cs` | Tracks `_globalRouteMatches` (route keys) and `_matchedSegments` (URI segments) separately. | Add `_pathParameters : Dictionary`. When `AddGlobalRoute` is called for a templated key, also record the extracted params. |
+| `ShellNavigationManager.cs` (`GoToAsync`) | Builds `parameters` from `state.FullLocation`'s query string only. | Also seed `parameters` with the path params extracted during URI matching, **before** calling `ApplyQueryAttributes`. |
+| `ShellNavigationManager.ApplyQueryAttributes` | Already filters by route prefix and applies to each shell element / leaf page. | **Unchanged.** The new params just appear in the dict it already processes. |
+| `ShellSection.GetOrCreateFromRoute` | Calls `Routing.GetOrCreateContent(route)` with the *raw* matched segment. | Pass the **template key** instead so `s_routes` lookup succeeds. |
+
+The third bullet in `RouteRequestBuilder` is the only place truly new state appears.
+The rest is "swap one comparison for another" or "pass a different string".
+
+---
+
+## 5. Backward compatibility
+
+Compatibility statement: **any code that compiled and worked before this change continues
+to compile and work, with byte-identical runtime behavior, unless the developer puts
+`{` into a route string they pass to `Routing.RegisterRoute`.**
+
+Why we believe this:
+
+1. The current `Routing.ValidateRoute` accepts any string that does not start with the
+ internal `IMPL_` prefix. `{` characters are not currently validated, but they are
+ also not produced by any templating mechanism, so no shipping app has them.
+2. `ShellUriHandler` segment matching is `string.Equals(..., Ordinal)`. A literal
+ route `"product"` will continue to match the URI segment `"product"` because the
+ new template-aware matcher only runs when the *route key* contains `{`.
+3. `ApplyQueryAttributes`, `[QueryProperty]`, `IQueryAttributable`, and
+ `ShellRouteParameters` are unchanged. Path parameters are merged into the same
+ dictionary that flows through them today.
+4. `Shell.CurrentState.Location` continues to be the user-supplied URI string. Path
+ parameters appear in the path naturally — no synthetic query-string suffix.
+
+A small backwards-compat risk: a user who today registers a route literally named
+`"product/{sku}"` (treating `{sku}` as a literal substring) would see a behavior change.
+We consider this acceptable because (a) such a route would be unreachable today via
+any reasonable URI (URIs don't contain `{`), and (b) the issue tracker shows zero
+reports of this pattern.
+
+---
+
+## 6. Edge cases
+
+### 6.1 Encoding
+
+URI segments captured into a parameter are **URL-decoded** before delivery
+(`Uri.UnescapeDataString`). So `GoToAsync("//main/products/product/seed%20tomato")`
+delivers `sku = "seed tomato"`. This matches what `WebUtils.UnpackParameters` does for
+query strings today.
+
+### 6.2 Empty segments
+
+The URI splitter (`ShellUriHandler.RetrievePaths` and the prototype's `UriParser`) drops
+empty segments. So `"product//seed-tomato"` collapses to `["product", "seed-tomato"]`
+before matching. No special handling needed — but it does mean **a parameter cannot
+be empty**. `Routing.RegisterRoute("product/{sku}", …)` then `GoToAsync("//main/products/product/")`
+fails to match (no second segment to capture) instead of binding `sku=""`.
+
+### 6.3 Path param vs query string with same name
+
+Path wins. If `RegisterRoute("product/{sku}", …)` and the user calls
+`GoToAsync("//main/products/product/seed-tomato?sku=ignored")`, the page receives
+`sku = "seed-tomato"`. Implementation-wise: path params are added to
+`ShellRouteParameters` first; `SetQueryStringParameters` already only adds keys that
+aren't already present (verified in `ShellRouteParameters.SetQueryStringParameters`).
+
+### 6.4 Two templates capturing the same name
+
+Innermost wins (§4.2). In practice this is "nested params override outer params", which
+mirrors ASP.NET Core. Pages that want the *outer* value can read it before navigation
+completes (it's still in the URI) or use distinct names.
+
+### 6.5 Back navigation
+
+Back navigation (`GoToAsync("..")` or system back button) pops one page from the stack.
+The remaining pages keep the parameter values they were created with — those values
+were applied during the original push. **No re-application happens.** This is the same
+behavior as today.
+
+### 6.6 Modal pages
+
+Modal pushes (`Shell.PresentationMode = Modal`) work identically. The matcher doesn't
+care about modality; it just produces an ordered list of `(template, params)` matches
+that `ShellSection.GoToAsync` then walks and pushes one-by-one (modal or not based on
+the page's presentation mode), exactly as today.
+
+### 6.7 `[QueryProperty]` mismatch
+
+If `RegisterRoute("product/{sku}", typeof(P))` but `P` has no `[QueryProperty(..., "sku")]`
+and doesn't implement `IQueryAttributable`, the parameter is **silently ignored** for
+that page. Same as today's behavior for query string keys with no matching property.
+Children that *do* declare `[QueryProperty(..., "sku")]` still receive it via inheritance.
+
+### 6.8 Same template registered twice
+
+Throws (existing `Routing.ValidateRoute` already throws on duplicate route keys; the
+template string is the dictionary key, so duplicates are caught for free).
+
+### 6.9 Two templates that match the same URI with the same specificity
+
+Today's `GenerateRoutePaths` would already throw `"Ambiguous routes matched"`. The new
+matcher should produce the same error. The prototype's `IsBetter` tie-breaks on length
+to avoid the throw for `(literal, param)` vs `(param, literal)` cases that an app
+author would expect to disambiguate.
+
+### 6.10 `Shell.CurrentState.Location` round-trip
+
+After navigating to `//main/products/product/seed-tomato/review`, `CurrentState.Location`
+should report exactly that URI. This is naturally true because the matcher consumed the
+URI segments without re-formatting; the URI used to navigate is the URI displayed.
+
+---
+
+## 7. Prototype
+
+Located at `prototype/ShellRouteTemplates/`. **Standalone library** that demonstrates the
+two pieces that don't yet exist in MAUI:
+
+1. `RouteTemplate.Parse` — parses `"product/{sku}"` into segments.
+2. `RouteTemplate.TryMatch` — matches the template against URI segments and extracts params.
+3. `RouteTable.MatchPath` — walks a URI end-to-end, picks the most-specific template
+ at each position, accumulates extracted parameters.
+
+It does **not** modify Shell internals (see §8 for why). What it *does* prove:
+
+| Scenario | Test | Result |
+|---|---|---|
+| Parse literal-only route | `Parses_LiteralOnlyRoute` | ✓ |
+| Parse template with params | `Parses_SingleParameterRoute`, `Parses_NestedParameters` | ✓ |
+| Reject invalid templates | `Rejects_DuplicateParameterNames`, `Rejects_EmptyParameter`, `Rejects_MixedSegment_v1` | ✓ |
+| Specificity scoring | `Specificity_FavorsLiteralSegments` (theory, 3 cases) | ✓ |
+| Match literal segment | `Matches_LiteralRoute_ExtractsNoParams` | ✓ |
+| Match template + extract | `Matches_TemplateRoute_ExtractsParam` | ✓ |
+| Reject mismatched literal | `DoesNotMatch_WrongLiteral` | ✓ |
+| Reject too-short URI | `DoesNotMatch_NotEnoughSegments` | ✓ |
+| Match at offset (after shell items) | `Matches_AtNonZeroOffset` | ✓ |
+| URL-decode captured value | `DecodesPercentEncodedValues` | ✓ |
+| Literal beats template | `LiteralBeatsTemplate_WhenBothMatch` | ✓ |
+| Template wins when literal doesn't apply | `Template_Wins_WhenLiteralDoesNotApply` | ✓ |
+| Longer literal beats short template | `LongerLiteralChain_WinsOverShorterTemplate` | ✓ |
+| Garden sample: 2-page stack with shared sku | `ChainsTwoTemplates_AndExtractsParamsForBoth` | ✓ |
+| Inheritance via merged dictionary | `BothPagesSeeSku_ViaShellLikeApplyQueryAttributes` | ✓ |
+| `order/{orderId}` from Garden | `OrderId_FromGardenSample` | ✓ |
+| Two distinct params from chained templates | `NestedParams_FromTwoSeparateTemplates` | ✓ |
+| Same param in two templates → innermost wins | `ChildSameParamName_OverridesParent` | ✓ |
+| Unmatched tail segment | `ReturnsNull_WhenAnyUriSegmentIsUnmatched` | ✓ |
+| Path + query string coexist | `PathParamAndQueryString_Coexist` | ✓ |
+| Path wins over query string for same key | `PathParamWins_OverQueryStringSameKey` | ✓ |
+| Literal-only registration unchanged | `LiteralRouteOnly_BehavesExactlyLikeBefore` | ✓ |
+| Routes without `{` never templated | `RouteWithoutBraces_NeverParsedAsTemplate` | ✓ |
+
+Run with:
+
+```bash
+cd prototype/ShellRouteTemplates.Tests
+dotnet test
+# 28 passed, 0 failed
+```
+
+---
+
+## 8. What's NOT in the prototype (and why)
+
+Decisions made deliberately:
+
+1. **No modification of `Routing.cs`, `ShellUriHandler.cs`, etc. in the real MAUI tree.**
+ Shell's matcher is a 1000-line state machine that interleaves shell-element matching
+ (Shell → Item → Section → Content) with global-route matching. Bolting templates
+ onto that requires understanding (a) when `routeKey == segment` is checked and which
+ of those checks should become `template.TryMatch`, (b) how `RouteRequestBuilder`'s
+ parallel `_globalRouteMatches` / `_matchedSegments` lists must be augmented with
+ extracted params, and (c) how `ShellSection.GetOrCreateFromRoute` must use the
+ template key (not the user segment) when calling `Routing.GetOrCreateContent`. That
+ work needs MAUI maintainer review (see §11). The prototype isolates the *new*
+ algorithmic pieces so the diff to the real Shell is clear.
+2. **No XAML build-task changes.** XAML compatibility is verified by reading — `{` is
+ only special when it's the first attribute character. No code change required.
+3. **No DI / `IServiceProvider` integration.** Page creation already uses
+ `ActivatorUtilities.GetServiceOrCreateInstance`; the parameter dictionary flows
+ through `ApplyQueryAttributes` after creation, so DI is orthogonal.
+
+---
+
+## 9. Issues / open questions
+
+### 9.1 Resolved during this spike
+
+- ✅ **XAML markup-extension collision** — confirmed false alarm. `{` is only parsed
+ as a markup extension when it is the first character of the attribute value. The
+ `{}` escape covers the `Route="{sku}"` edge case.
+- ✅ **Same-name parameters in nested templates** — innermost wins, consistent with
+ ASP.NET Core. Test `ChildSameParamName_OverridesParent` covers this.
+- ✅ **Mixed segments** (`product-{sku}`) — deferred. Rejected with a clear error in v1.
+- ✅ **Specificity tie-breaking** — literal beats parameter; longer match beats shorter
+ on equal specificity. Mirrors ASP.NET Core.
+
+### 9.2 Open — need MAUI maintainer input
+
+| # | Question | My recommendation |
+|---|---|---|
+| Q1 | Should the template key be canonicalized on registration (e.g. lowercased)? | **No.** Existing routes are case-sensitive ordinal; preserve. |
+| Q2 | Should ambiguous matches throw at registration time or navigation time? | **Navigation time**, matching today's `GenerateRoutePaths` behavior. Registration ordering shouldn't affect validation. |
+| Q3 | Should `Shell.CurrentState.Location` for a templated push echo back the *template* or the *resolved URI*? | **Resolved URI.** That's what the user typed and what makes the URI shareable. |
+| Q4 | Where should the parsed `RouteTemplate` live? Cached next to the `RouteFactory` in `s_routes`, or in a parallel dict? | **Next to the factory.** Avoids a second lookup. Suggest a small `RouteEntry` record. |
+| Q5 | How do we expose path params in the diagnostic surface (`Shell.Navigated` event args, etc.)? | Probably the merged dict already in `ShellNavigatedEventArgs.Source`/`Current`. Worth a separate API review. |
+| Q6 | Should we surface a typed accessor (e.g. `Shell.Current.GetRouteValue("sku")`) or rely on `[QueryProperty]`? | **Rely on `[QueryProperty]` for v1.** A typed accessor is a follow-up. |
+| Q7 | What's the right error message when a registered template has `{` syntax errors? | Mirror ASP.NET Core's: name the template, point to the bad segment. |
+
+### 9.3 Failed experiments / things that didn't work
+
+- ❌ **Initial attempt at "anchor on first literal segment then walk"** — produced
+ ambiguous results when two templates shared a literal prefix
+ (`product/{sku}` vs `product/{id}/edit`). Switched to "evaluate every template at
+ every position, score by specificity" (current algorithm). All 28 tests pass.
+- ❌ **Tried to make parameter capture take query-string precedence over path** —
+ realized this contradicts the issue-tracker comments and ASP.NET Core convention.
+ Reversed: path wins. Added `PathParamWins_OverQueryStringSameKey` test to lock it in.
+- ⚠️ **xUnit + `Microsoft.NET.Test.Sdk` package downgrade error** under SDK 11 preview.
+ Fixed by pinning the prototype to SDK 9.x via `prototype/global.json` and adding an
+ empty-ish `prototype/Directory.Build.props` to neutralize the repo's Arcade SDK
+ dependency. **Not a design issue** — purely a build-environment quirk.
+
+---
+
+## 10. Future work
+
+- **Optional parameters** `{sku?}` — match URI with or without that segment present.
+ Slightly tricky for the matcher because it has to consider both the "consumed" and
+ "skipped" branches.
+- **Catch-all** `{*rest}` — captures the remainder of the URI as a single string.
+ Useful for fallback / 404 routes.
+- **Constraints** `{id:int}`, `{sku:regex(…)}` — type/format validation at match time.
+ Already a known ASP.NET Core pattern; would compose cleanly with the existing matcher.
+- **Mixed segments** `product-{sku}` — supported by ASP.NET Core; requires a small
+ parser per segment (split literal/parameter parts).
+- **Default values** in registration — `RegisterRoute("product/{sku=default-sku}", …)`.
+- **Typed parameter accessor** — `Shell.Current.GetRouteValue("orderId")`.
+
+---
+
+## 11. What would need MAUI team involvement
+
+- Code changes to `Routing.cs`, `ShellUriHandler.cs`, `RouteRequestBuilder.cs`,
+ `ShellNavigationManager.cs`, `ShellSection.cs` per §4.3.
+- API review for any new public surface (probably none, if we reuse `[QueryProperty]`).
+- Trim/AOT analysis: route templates are stored as strings; reflection access for
+ `[QueryProperty]` is unchanged. No new trimmer warnings expected.
+- Decision on the open questions in §9.2.
+- Doc updates for the navigation chapter.
+
+---
+
+## 12. Experiment log
+
+| Date | What | Result |
+|---|---|---|
+| 2026-04-23 | Read issue #35107 (Proposal A and XAML compat comments) | Confirmed scope: additive, `{param}` syntax, reuse `[QueryProperty]`. |
+| 2026-04-23 | Read `Routing.cs`, `ShellUriHandler.cs`, `ShellNavigationManager.cs`, `ShellRouteParameters.cs`, `RouteRequestBuilder.cs`, `ShellSection.cs`, `QueryPropertyAttribute.cs` on `main` | Mapped the exact files/functions that need changes (§4.3). Confirmed `ApplyQueryAttributes` already does the work — path params just need to land in the same dictionary. |
+| 2026-04-23 | Built `RouteTemplate` parser + `RouteTable` matcher prototype | First pass: 22 tests passing. Anchor-on-first-literal algorithm produced ambiguity in nested-template cases. |
+| 2026-04-23 | Switched to "evaluate every template at every position, score by specificity" | All 28 tests pass; no ambiguity in the documented scenarios. |
+| 2026-04-23 | Added xUnit project under preview SDK 11 → package-downgrade errors | Pinned prototype to SDK 9 via `prototype/global.json`; neutralized repo Arcade requirement with `prototype/Directory.Build.props`. |
+| 2026-04-23 | Verified Garden-sample URIs | `//main/products/product/seed-tomato`, `…/review`, `//main/orders/order/ORD-00001` all extract correctly. Both pages in the 2-page stack share `sku` via the merged dictionary, demonstrating inheritance. |
diff --git a/src/Controls/samples/Controls.Sample.Sandbox/App.xaml.cs b/src/Controls/samples/Controls.Sample.Sandbox/App.xaml.cs
index 9512dea98e39..68a2c36f2d7c 100644
--- a/src/Controls/samples/Controls.Sample.Sandbox/App.xaml.cs
+++ b/src/Controls/samples/Controls.Sample.Sandbox/App.xaml.cs
@@ -10,7 +10,7 @@ public App()
protected override Window CreateWindow(IActivationState? activationState)
{
// To test shell scenarios, change this to true
- bool useShell = false;
+ bool useShell = true;
if (!useShell)
{
diff --git a/src/Controls/samples/Controls.Sample.Sandbox/MainPage.xaml b/src/Controls/samples/Controls.Sample.Sandbox/MainPage.xaml
index 7363d18deadd..28ae87bf4c00 100644
--- a/src/Controls/samples/Controls.Sample.Sandbox/MainPage.xaml
+++ b/src/Controls/samples/Controls.Sample.Sandbox/MainPage.xaml
@@ -1,5 +1,49 @@
+ xmlns:local="clr-namespace:Maui.Controls.Sample"
+ Title="Product Catalog">
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
\ No newline at end of file
diff --git a/src/Controls/samples/Controls.Sample.Sandbox/MainPage.xaml.cs b/src/Controls/samples/Controls.Sample.Sandbox/MainPage.xaml.cs
index b7744fa262ea..59ce329c6e08 100644
--- a/src/Controls/samples/Controls.Sample.Sandbox/MainPage.xaml.cs
+++ b/src/Controls/samples/Controls.Sample.Sandbox/MainPage.xaml.cs
@@ -6,4 +6,22 @@ public MainPage()
{
InitializeComponent();
}
+
+ async void OnProductTapped(object sender, EventArgs e)
+ {
+ if (sender is Button btn && btn.CommandParameter is string sku)
+ await Shell.Current.GoToAsync($"//products/product/{sku}");
+ }
+
+ async void OnProductReviewTapped(object sender, EventArgs e)
+ {
+ if (sender is Button btn && btn.CommandParameter is string sku)
+ await Shell.Current.GoToAsync($"//products/product/{sku}/review");
+ }
+
+ async void OnProductReviewWithStars(object sender, EventArgs e)
+ {
+ if (sender is Button btn && btn.CommandParameter is string sku)
+ await Shell.Current.GoToAsync($"//products/product/{sku}/review/3");
+ }
}
\ No newline at end of file
diff --git a/src/Controls/samples/Controls.Sample.Sandbox/OrderDetailPage.xaml b/src/Controls/samples/Controls.Sample.Sandbox/OrderDetailPage.xaml
new file mode 100644
index 000000000000..21b64e40b95e
--- /dev/null
+++ b/src/Controls/samples/Controls.Sample.Sandbox/OrderDetailPage.xaml
@@ -0,0 +1,17 @@
+
+
+
+
+
+
+
+
diff --git a/src/Controls/samples/Controls.Sample.Sandbox/OrderDetailPage.xaml.cs b/src/Controls/samples/Controls.Sample.Sandbox/OrderDetailPage.xaml.cs
new file mode 100644
index 000000000000..62a5d86645d0
--- /dev/null
+++ b/src/Controls/samples/Controls.Sample.Sandbox/OrderDetailPage.xaml.cs
@@ -0,0 +1,23 @@
+namespace Maui.Controls.Sample;
+
+[QueryProperty(nameof(OrderId), "orderId")]
+public partial class OrderDetailPage : ContentPage
+{
+ public OrderDetailPage()
+ {
+ InitializeComponent();
+ }
+
+ public string? OrderId
+ {
+ get => _orderId;
+ set
+ {
+ _orderId = value;
+ OnPropertyChanged();
+ if (OrderIdLabel is not null)
+ OrderIdLabel.Text = $"Order ID: #{value}";
+ }
+ }
+ string? _orderId;
+}
diff --git a/src/Controls/samples/Controls.Sample.Sandbox/OrdersPage.xaml b/src/Controls/samples/Controls.Sample.Sandbox/OrdersPage.xaml
new file mode 100644
index 000000000000..12189c6ccf81
--- /dev/null
+++ b/src/Controls/samples/Controls.Sample.Sandbox/OrdersPage.xaml
@@ -0,0 +1,33 @@
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
diff --git a/src/Controls/samples/Controls.Sample.Sandbox/OrdersPage.xaml.cs b/src/Controls/samples/Controls.Sample.Sandbox/OrdersPage.xaml.cs
new file mode 100644
index 000000000000..2f3696452d0e
--- /dev/null
+++ b/src/Controls/samples/Controls.Sample.Sandbox/OrdersPage.xaml.cs
@@ -0,0 +1,15 @@
+namespace Maui.Controls.Sample;
+
+public partial class OrdersPage : ContentPage
+{
+ public OrdersPage()
+ {
+ InitializeComponent();
+ }
+
+ async void OnOrderTapped(object sender, EventArgs e)
+ {
+ if (sender is Button btn && btn.CommandParameter is string orderId)
+ await Shell.Current.GoToAsync($"//orders/order/{orderId}");
+ }
+}
diff --git a/src/Controls/samples/Controls.Sample.Sandbox/ProductPage.xaml b/src/Controls/samples/Controls.Sample.Sandbox/ProductPage.xaml
new file mode 100644
index 000000000000..dd02bf940d42
--- /dev/null
+++ b/src/Controls/samples/Controls.Sample.Sandbox/ProductPage.xaml
@@ -0,0 +1,17 @@
+
+
+
+
+
+
+
+
diff --git a/src/Controls/samples/Controls.Sample.Sandbox/ProductPage.xaml.cs b/src/Controls/samples/Controls.Sample.Sandbox/ProductPage.xaml.cs
new file mode 100644
index 000000000000..f7355e72d6f2
--- /dev/null
+++ b/src/Controls/samples/Controls.Sample.Sandbox/ProductPage.xaml.cs
@@ -0,0 +1,30 @@
+namespace Maui.Controls.Sample;
+
+[QueryProperty(nameof(Sku), "sku")]
+public partial class ProductPage : ContentPage
+{
+ public ProductPage()
+ {
+ InitializeComponent();
+ }
+
+ public string? Sku
+ {
+ get => _sku;
+ set
+ {
+ _sku = value;
+ OnPropertyChanged();
+ if (SkuLabel is not null)
+ SkuLabel.Text = $"SKU: {value}";
+ if (ReviewButton is not null)
+ ReviewButton.IsVisible = !string.IsNullOrEmpty(value);
+ }
+ }
+ string? _sku;
+
+ async void OnGoToReview(object sender, EventArgs e)
+ {
+ await Shell.Current.GoToAsync("review");
+ }
+}
diff --git a/src/Controls/samples/Controls.Sample.Sandbox/ReviewPage.xaml b/src/Controls/samples/Controls.Sample.Sandbox/ReviewPage.xaml
new file mode 100644
index 000000000000..4e77b9e2ca0a
--- /dev/null
+++ b/src/Controls/samples/Controls.Sample.Sandbox/ReviewPage.xaml
@@ -0,0 +1,20 @@
+
+
+
+
+
+
+
+
+
diff --git a/src/Controls/samples/Controls.Sample.Sandbox/ReviewPage.xaml.cs b/src/Controls/samples/Controls.Sample.Sandbox/ReviewPage.xaml.cs
new file mode 100644
index 000000000000..4f5eb5a7cc41
--- /dev/null
+++ b/src/Controls/samples/Controls.Sample.Sandbox/ReviewPage.xaml.cs
@@ -0,0 +1,40 @@
+namespace Maui.Controls.Sample;
+
+[QueryProperty(nameof(Sku), "sku")]
+[QueryProperty(nameof(Stars), "stars")]
+public partial class ReviewPage : ContentPage
+{
+ public ReviewPage()
+ {
+ InitializeComponent();
+ }
+
+ public string? Sku
+ {
+ get => _sku;
+ set
+ {
+ _sku = value;
+ OnPropertyChanged();
+ if (SkuLabel is not null)
+ SkuLabel.Text = $"Product: {value}";
+ }
+ }
+ string? _sku;
+
+ public string? Stars
+ {
+ get => _stars;
+ set
+ {
+ _stars = value;
+ OnPropertyChanged();
+ if (StarsLabel is not null)
+ {
+ var count = int.TryParse(value, out var n) ? n : 0;
+ StarsLabel.Text = $"Rating: {new string('⭐', count)} ({value})";
+ }
+ }
+ }
+ string? _stars;
+}
diff --git a/src/Controls/samples/Controls.Sample.Sandbox/SandboxShell.xaml b/src/Controls/samples/Controls.Sample.Sandbox/SandboxShell.xaml
index 358b0b04ef63..c74e426128ba 100644
--- a/src/Controls/samples/Controls.Sample.Sandbox/SandboxShell.xaml
+++ b/src/Controls/samples/Controls.Sample.Sandbox/SandboxShell.xaml
@@ -4,19 +4,18 @@
x:Class="Maui.Controls.Sample.SandboxShell"
xmlns:local="clr-namespace:Maui.Controls.Sample"
x:Name="shell">
-
-
-
+
+
+
-
-
+
+
\ No newline at end of file
diff --git a/src/Controls/samples/Controls.Sample.Sandbox/SandboxShell.xaml.cs b/src/Controls/samples/Controls.Sample.Sandbox/SandboxShell.xaml.cs
index fe774d654777..73f2c49ab475 100644
--- a/src/Controls/samples/Controls.Sample.Sandbox/SandboxShell.xaml.cs
+++ b/src/Controls/samples/Controls.Sample.Sandbox/SandboxShell.xaml.cs
@@ -5,5 +5,13 @@ public partial class SandboxShell : Shell
public SandboxShell()
{
InitializeComponent();
+
+ // Product routes: product/{sku} with review child that has
+ // a default stars value via {stars=5}
+ Routing.RegisterRoute("product/{sku}", typeof(ProductPage));
+ Routing.RegisterRoute("review/{stars=5}", typeof(ReviewPage));
+
+ // Order routes: order/{orderId:int}
+ Routing.RegisterRoute("order/{orderId:int}", typeof(OrderDetailPage));
}
}
diff --git a/src/Controls/src/Core/Routing.cs b/src/Controls/src/Core/Routing.cs
index f19062cd095c..20501fc483ce 100644
--- a/src/Controls/src/Core/Routing.cs
+++ b/src/Controls/src/Core/Routing.cs
@@ -14,6 +14,12 @@ public static class Routing
static Dictionary s_implicitPageRoutes = new(StringComparer.Ordinal);
static HashSet s_routeKeys;
+ // Parsed templates for routes that contain "{param}" segments. The key
+ // here is the same key used in (e.g.
+ // "product/{sku}"); routes without templated segments are absent from
+ // this dictionary so the literal fast paths remain unaffected.
+ static Dictionary s_routeTemplates = new(StringComparer.Ordinal);
+
const string ImplicitPrefix = "IMPL_";
const string DefaultPrefix = "D_FAULT_";
internal const string PathSeparator = "/";
@@ -114,12 +120,48 @@ internal static void Clear()
{
s_implicitPageRoutes.Clear();
s_routes.Clear();
+ s_routeTemplates.Clear();
s_routeKeys = null;
}
+ // Returns true when the supplied route key was registered with a
+ // template segment such as "product/{sku}". Used by the URI matcher
+ // to decide whether to capture path parameters for the route.
+ internal static bool IsTemplateRoute(string route)
+ {
+ if (string.IsNullOrEmpty(route))
+ return false;
+
+ return s_routeTemplates.ContainsKey(route);
+ }
+
+ internal static bool TryGetRouteTemplate(string route, out RouteTemplate template)
+ {
+ return s_routeTemplates.TryGetValue(route, out template);
+ }
+
/// Bindable property for attached property Route.
public static readonly BindableProperty RouteProperty = CreateRouteProperty();
+ // Internal attached property storing the resolved route URI for pages
+ // created from template routes (e.g. "product/seed-tomato" for a page
+ // whose Route is "product/{sku}"). Used by GetNavigationState to build
+ // Shell.CurrentState.Location without leaking template tokens. The
+ // page's Route property always keeps the registered template key so
+ // that factory lookups and stack comparisons work correctly.
+ internal static readonly BindableProperty ResolvedRouteProperty = CreateResolvedRouteProperty();
+
+ [UnconditionalSuppressMessage("ReflectionAnalysis", "IL2111:ReflectionToDynamicallyAccessedMembers",
+ Justification = "Same as RouteProperty — BindableProperty only needs Get* methods, not RegisterRoute.")]
+ private static BindableProperty CreateResolvedRouteProperty()
+ => BindableProperty.CreateAttached("ResolvedRoute", typeof(string), typeof(Routing), null);
+
+ internal static string GetResolvedRoute(BindableObject obj)
+ => (string)obj.GetValue(ResolvedRouteProperty);
+
+ internal static void SetResolvedRoute(Element obj, string value)
+ => obj.SetValue(ResolvedRouteProperty, value);
+
[UnconditionalSuppressMessage("ReflectionAnalysis", "IL2111:ReflectionToDynamicallyAccessedMembers",
Justification = "The CreateAttached method has a DynamicallyAccessedMembers annotation for all public methods"
+ "on the declaring type. This includes the Routing.RegisterRoute(string, Type) method which also has a "
@@ -218,6 +260,21 @@ public static void RegisterRoute(string route, RouteFactory factory)
ValidateRoute(route, factory);
s_routes[route] = factory;
+
+ // Templates are an additive opt-in: any route that contains a
+ // "{param}" segment is parsed and remembered alongside the literal
+ // registration. Routes without templated segments never enter
+ // s_routeTemplates so existing literal fast paths are unaffected.
+ if (RouteTemplate.ContainsTemplateSyntax(route))
+ {
+ var template = RouteTemplate.Parse(route, out var error);
+ if (template == null)
+ throw new ArgumentException(error, nameof(route));
+
+ if (template.HasParameters)
+ s_routeTemplates[route] = template;
+ }
+
s_routeKeys = null;
}
@@ -227,6 +284,7 @@ public static void UnRegisterRoute(string route)
{
if (s_routes.Remove(route))
{
+ s_routeTemplates.Remove(route);
s_routeKeys = null;
}
}
diff --git a/src/Controls/src/Core/Shell/RequestDefinition.cs b/src/Controls/src/Core/Shell/RequestDefinition.cs
index 5e56d663dd18..68d97c307707 100644
--- a/src/Controls/src/Core/Shell/RequestDefinition.cs
+++ b/src/Controls/src/Core/Shell/RequestDefinition.cs
@@ -14,6 +14,8 @@ public RequestDefinition(RouteRequestBuilder theWinningRoute, Shell shell)
Section = theWinningRoute.Section ?? Item?.CurrentItem;
Content = theWinningRoute.Content ?? Section?.CurrentItem;
GlobalRoutes = theWinningRoute.GlobalRouteMatches;
+ ResolvedGlobalRoutes = theWinningRoute.ResolvedGlobalRoutes;
+ PathParameters = theWinningRoute.PathParameters;
List builder = new List();
if (Item?.Route != null)
@@ -25,8 +27,21 @@ public RequestDefinition(RouteRequestBuilder theWinningRoute, Shell shell)
if (Content?.Route != null)
builder.Add(Content?.Route);
+ // Use resolved global routes for URI construction when the route is
+ // a template, preventing tokens like "{sku}" from leaking into FullUri.
+ // For literal routes, always use GlobalRoutes (which may be multi-segment
+ // keys like "page1/page2" that must not be truncated).
if (GlobalRoutes != null)
- builder.AddRange(GlobalRoutes);
+ {
+ for (int i = 0; i < GlobalRoutes.Count; i++)
+ {
+ if (Routing.IsTemplateRoute(GlobalRoutes[i])
+ && ResolvedGlobalRoutes != null && i < ResolvedGlobalRoutes.Count)
+ builder.Add(ResolvedGlobalRoutes[i]);
+ else
+ builder.Add(GlobalRoutes[i]);
+ }
+ }
var uriPath = MakeUriString(builder);
var uri = ShellUriHandler.CreateUri(uriPath);
@@ -47,5 +62,13 @@ string MakeUriString(List segments)
public ShellSection Section { get; }
public ShellContent Content { get; }
public List GlobalRoutes { get; }
+ // Resolved global routes with actual parameter values substituted
+ // (e.g. "product/seed-tomato" instead of "product/{sku}"). Used for
+ // URI construction so Shell.CurrentState.Location is accurate.
+ public List ResolvedGlobalRoutes { get; }
+ // Path parameters captured from "{param}" segments in templated
+ // global routes (e.g. "product/{sku}"). Empty when no templated route
+ // participated in the match.
+ public IReadOnlyDictionary PathParameters { get; }
}
}
diff --git a/src/Controls/src/Core/Shell/RouteRequestBuilder.cs b/src/Controls/src/Core/Shell/RouteRequestBuilder.cs
index 61ce545481a8..28cc13e10144 100644
--- a/src/Controls/src/Core/Shell/RouteRequestBuilder.cs
+++ b/src/Controls/src/Core/Shell/RouteRequestBuilder.cs
@@ -11,9 +11,11 @@ namespace Microsoft.Maui.Controls
internal class RouteRequestBuilder
{
readonly List _globalRouteMatches = new List();
+ readonly List _resolvedGlobalRoutes = new List();
readonly List _matchedSegments = new List();
readonly List _fullSegments = new List();
readonly List _allSegments = null;
+ readonly Dictionary _pathParameters = new Dictionary(StringComparer.Ordinal);
readonly static string _uriSeparator = "/";
public Shell Shell { get; private set; }
@@ -42,6 +44,9 @@ public RouteRequestBuilder(RouteRequestBuilder builder) : this(builder._allSegme
_matchedSegments.AddRange(builder._matchedSegments);
_fullSegments.AddRange(builder._fullSegments);
_globalRouteMatches.AddRange(builder._globalRouteMatches);
+ _resolvedGlobalRoutes.AddRange(builder._resolvedGlobalRoutes);
+ foreach (var kvp in builder._pathParameters)
+ _pathParameters[kvp.Key] = kvp.Value;
Shell = builder.Shell;
Item = builder.Item;
Section = builder.Section;
@@ -49,14 +54,29 @@ public RouteRequestBuilder(RouteRequestBuilder builder) : this(builder._allSegme
}
public void AddGlobalRoute(string routeName, string segment)
+ {
+ AddGlobalRoute(routeName, segment, null);
+ }
+
+ // Overload that records path parameters captured for this route.
+ // may be null when the route had
+ // no template segments.
+ public void AddGlobalRoute(string routeName, string segment, IDictionary capturedParameters)
{
_globalRouteMatches.Add(routeName);
+ _resolvedGlobalRoutes.Add(segment);
foreach (string path in ShellUriHandler.RetrievePaths(segment))
{
_fullSegments.Add(path);
_matchedSegments.Add(path);
}
+
+ if (capturedParameters != null)
+ {
+ foreach (var kvp in capturedParameters)
+ _pathParameters[kvp.Key] = kvp.Value;
+ }
}
@@ -104,7 +124,10 @@ public void AddMatch(string shellSegment, string userSegment, object node)
{
case ShellUriHandler.GlobalRouteItem globalRoute:
if (globalRoute.IsFinished)
+ {
_globalRouteMatches.Add(globalRoute.SourceRoute);
+ _resolvedGlobalRoutes.Add(userSegment ?? shellSegment);
+ }
break;
case Shell shell:
if (shell == Shell)
@@ -163,41 +186,187 @@ public void AddMatch(string shellSegment, string userSegment, object node)
public string GetNextSegmentMatch(string matchMe)
{
- var segmentsToMatch = ShellUriHandler.RetrievePaths(matchMe).ToList();
- // if matchMe is an absolute route then we only match
- // if there are no routes already present
- if (matchMe.StartsWith("/", StringComparison.Ordinal) ||
- matchMe.StartsWith("\\", StringComparison.Ordinal))
- {
- for (var i = 0; i < _matchedSegments.Count; i++)
- {
- var seg = _matchedSegments[i];
- if (segmentsToMatch.Count <= i || segmentsToMatch[i] != seg)
- return String.Empty;
+ return GetNextSegmentMatch(matchMe, null, null);
+ }
- segmentsToMatch.Remove(seg);
- }
- }
+// Template-aware overload. Handles optional params, catch-all,
+// constraints, mixed segments, and default values.
+public string GetNextSegmentMatch(string matchMe, IDictionary capturedParameters)
+{
+ return GetNextSegmentMatch(matchMe, capturedParameters, null);
+}
- List matches = new List();
- List currentSet = new List(_matchedSegments);
+public string GetNextSegmentMatch(string matchMe, IDictionary capturedParameters, RouteTemplate template)
+{
+var segmentsToMatch = ShellUriHandler.RetrievePaths(matchMe).ToList();
+if (matchMe.StartsWith("/", StringComparison.Ordinal) ||
+matchMe.StartsWith("\\", StringComparison.Ordinal))
+{
+for (var i = 0; i < _matchedSegments.Count; i++)
+{
+var seg = _matchedSegments[i];
+if (segmentsToMatch.Count <= i || segmentsToMatch[i] != seg)
+return String.Empty;
- foreach (var split in segmentsToMatch)
- {
- string next = GetNextSegment(currentSet);
- if (next == split)
- {
- currentSet.Add(split);
- matches.Add(split);
- }
- else
- {
- return String.Empty;
- }
- }
+segmentsToMatch.Remove(seg);
+}
+}
+
+List matches = new List();
+List currentSet = new List(_matchedSegments);
+Dictionary localCaptures = null;
+
+// Use provided template, or fall back to lookup by matchMe key.
+// The caller should pass the template when available because
+// CollapsePath may have stripped prefix segments from matchMe,
+// making it different from the registered key.
+if (template == null)
+ Routing.TryGetRouteTemplate(matchMe, out template);
+int templateIdx = 0;
+
+// Template offset: when CollapsePath strips N prefix segments,
+// segmentsToMatch has fewer entries than the template.
+if (template != null && segmentsToMatch.Count < template.Segments.Count)
+templateIdx = template.Segments.Count - segmentsToMatch.Count;
+
+for (int si = 0; si < segmentsToMatch.Count; si++)
+{
+var split = segmentsToMatch[si];
+string next = GetNextSegment(currentSet);
+var seg = (template != null && templateIdx < template.Segments.Count)
+? template.Segments[templateIdx]
+: default;
+templateIdx++;
+
+if (next == split && !seg.IsParameter)
+{
+// Exact literal match
+currentSet.Add(split);
+matches.Add(split);
+}
+else if (seg.IsParameter && seg.IsCatchAll)
+{
+// Catch-all: consume all remaining URI segments
+var remaining = new List();
+for (int ri = si; ; ri++)
+{
+var catchNext = (ri == si) ? next : GetNextSegment(currentSet);
+if (catchNext == null)
+break;
+remaining.Add(Uri.UnescapeDataString(catchNext));
+currentSet.Add(catchNext);
+matches.Add(catchNext);
+}
+
+var catchValue = String.Join("/", remaining);
+if (!string.IsNullOrEmpty(seg.Constraint) &&
+!RouteTemplate.SatisfiesConstraint(seg.Constraint, catchValue))
+return String.Empty;
+
+localCaptures ??= new Dictionary(StringComparer.Ordinal);
+localCaptures[seg.Value] = catchValue;
+si = segmentsToMatch.Count; // consumed everything
+break;
+}
+else if (seg.IsParameter && seg.IsMixed && next != null)
+{
+// Mixed segment: check prefix/suffix and extract embedded value
+var decoded = Uri.UnescapeDataString(next);
+if (!decoded.StartsWith(seg.Prefix, StringComparison.Ordinal))
+return String.Empty;
+if (seg.Suffix.Length > 0 && !decoded.EndsWith(seg.Suffix, StringComparison.Ordinal))
+return String.Empty;
+
+var paramValue = decoded.Substring(seg.Prefix.Length,
+decoded.Length - seg.Prefix.Length - seg.Suffix.Length);
+
+if (!string.IsNullOrEmpty(seg.Constraint) &&
+!RouteTemplate.SatisfiesConstraint(seg.Constraint, paramValue))
+return String.Empty;
+
+currentSet.Add(next);
+matches.Add(next);
+
+localCaptures ??= new Dictionary(StringComparer.Ordinal);
+localCaptures[seg.Value] = paramValue;
+}
+else if (seg.IsParameter && next != null)
+{
+// Standard or optional parameter: consume the actual URI segment
+var decoded = Uri.UnescapeDataString(next);
+
+if (!string.IsNullOrEmpty(seg.Constraint) &&
+!RouteTemplate.SatisfiesConstraint(seg.Constraint, decoded))
+return String.Empty;
+
+currentSet.Add(next);
+matches.Add(next);
+
+localCaptures ??= new Dictionary(StringComparer.Ordinal);
+localCaptures[seg.Value] = decoded;
+}
+else if (seg.IsParameter && seg.IsOptional && next == null)
+{
+// Optional parameter with no URI segment — skip it.
+// Default value (if any) is applied in the trailing-segment
+// loop below.
+if (seg.DefaultValue != null)
+{
+localCaptures ??= new Dictionary(StringComparer.Ordinal);
+localCaptures[seg.Value] = seg.DefaultValue;
+}
+}
+else if (next != null && RouteTemplate.IsTemplateSegment(split))
+{
+// Fallback for template segments without parsed RouteTemplate
+var paramName = RouteTemplate.GetSegmentParameterName(split);
+if (string.IsNullOrEmpty(paramName))
+return String.Empty;
+
+currentSet.Add(next);
+matches.Add(next);
+
+localCaptures ??= new Dictionary(StringComparer.Ordinal);
+localCaptures[paramName] = Uri.UnescapeDataString(next);
+}
+else
+{
+return String.Empty;
+}
+}
+
+// Apply default values for trailing optional/default segments
+// that had no corresponding URI segment.
+if (template != null)
+{
+while (templateIdx < template.Segments.Count)
+{
+var trailingSeg = template.Segments[templateIdx];
+if (trailingSeg.IsParameter && (trailingSeg.IsOptional || trailingSeg.DefaultValue != null))
+{
+if (trailingSeg.DefaultValue != null)
+{
+localCaptures ??= new Dictionary(StringComparer.Ordinal);
+localCaptures[trailingSeg.Value] = trailingSeg.DefaultValue;
+}
+templateIdx++;
+}
+else
+{
+break;
+}
+}
+}
+
+if (capturedParameters != null && localCaptures != null)
+{
+foreach (var kvp in localCaptures)
+capturedParameters[kvp.Key] = kvp.Value;
+}
+
+return String.Join(_uriSeparator, matches);
+}
- return String.Join(_uriSeparator, matches);
- }
string GetNextSegment(IReadOnlyList matchedSegments)
{
@@ -268,8 +437,23 @@ public int MatchedParts
public bool IsFullMatch => _matchedSegments.Count == _allSegments.Count;
public List GlobalRouteMatches => _globalRouteMatches;
+ public List ResolvedGlobalRoutes => _resolvedGlobalRoutes;
public List SegmentsMatched => _matchedSegments;
public IReadOnlyList FullSegments => _fullSegments;
+ public IReadOnlyDictionary PathParameters => _pathParameters;
+
+ // Merges path parameters from another builder, keeping existing values.
+ public void MergePathParameters(IReadOnlyDictionary other)
+ {
+ if (other == null)
+ return;
+ foreach (var kvp in other)
+ {
+ if (!_pathParameters.ContainsKey(kvp.Key))
+ _pathParameters[kvp.Key] = kvp.Value;
+ }
+ }
+
public ShellUriHandler.NodeLocation GetNodeLocation()
{
ShellUriHandler.NodeLocation nodeLocation = new ShellUriHandler.NodeLocation();
diff --git a/src/Controls/src/Core/Shell/RouteTemplate.cs b/src/Controls/src/Core/Shell/RouteTemplate.cs
new file mode 100644
index 000000000000..2df7cd59e392
--- /dev/null
+++ b/src/Controls/src/Core/Shell/RouteTemplate.cs
@@ -0,0 +1,426 @@
+#nullable disable
+using System;
+using System.Collections.Generic;
+
+namespace Microsoft.Maui.Controls
+{
+ // Parser / matcher for path-parameter route templates such as
+ // "product/{sku}" or "files/{*path}". Templates are an additive,
+ // opt-in extension of Routing.RegisterRoute — existing literal routes
+ // are unchanged.
+ //
+ // Supported syntax:
+ // {name} — required parameter, matches exactly one segment
+ // {name?} — optional parameter, matches zero or one segment
+ // {name=default} — default value when segment is absent
+ // {*name} — catch-all, captures all remaining segments (must be last)
+ // {id:int} — constrained parameter (int, guid, long, bool, double, alpha)
+ // product-{sku} — mixed literal+parameter segment (prefix/suffix matching)
+ //
+ // Rules:
+ // * Parameter names follow C# identifier rules.
+ // * Duplicate parameter names are rejected.
+ // * Catch-all must be the last segment.
+ // * At most one constraint per parameter (no chaining).
+ internal sealed class RouteTemplate
+ {
+ readonly TemplateSegment[] _segments;
+
+ RouteTemplate(TemplateSegment[] segments)
+ {
+ _segments = segments;
+ }
+
+ public bool HasParameters { get; private set; }
+
+ public IReadOnlyList Segments => _segments;
+
+ public static bool ContainsTemplateSyntax(string route)
+ {
+ if (string.IsNullOrEmpty(route))
+ return false;
+
+ return route.IndexOf("{", StringComparison.Ordinal) >= 0;
+ }
+
+ public static bool IsTemplateSegment(string segment)
+ {
+ if (string.IsNullOrEmpty(segment))
+ return false;
+
+ // Pure template token: {name}, {name?}, {*name}, {name:int}, {name=default}
+ if (segment.Length >= 3
+ && segment[0] == '{'
+ && segment[segment.Length - 1] == '}')
+ return true;
+
+ // Mixed segment: contains { but doesn't start with it (e.g. product-{sku})
+ // Require { appears before } to reject malformed strings like "foo}bar{"
+ int openIdx = segment.IndexOf("{", StringComparison.Ordinal);
+ int closeIdx = segment.IndexOf("}", StringComparison.Ordinal);
+ if (openIdx >= 0 && closeIdx > openIdx)
+ return true;
+
+ return false;
+ }
+
+ ///
+ /// Returns true if the segment is a pure parameter token (starts with { and ends with }).
+ /// Mixed segments like "product-{sku}" return false.
+ ///
+ public static bool IsPureParameterSegment(string segment)
+ {
+ if (string.IsNullOrEmpty(segment) || segment.Length < 3)
+ return false;
+
+ return segment[0] == '{' && segment[segment.Length - 1] == '}';
+ }
+
+ public static string GetSegmentParameterName(string segment)
+ {
+ if (string.IsNullOrEmpty(segment))
+ return null;
+
+ // For pure parameter segments
+ if (IsPureParameterSegment(segment))
+ {
+ var inner = segment.Substring(1, segment.Length - 2);
+
+ // Strip catch-all marker
+ if (inner.Length > 0 && inner[0] == '*')
+ inner = inner.Substring(1);
+
+ // Strip constraint (e.g. ":int")
+ var colonIdx = inner.IndexOf(":", StringComparison.Ordinal);
+ if (colonIdx >= 0)
+ inner = inner.Substring(0, colonIdx);
+
+ // Strip default value (e.g. "=default")
+ var eqIdx = inner.IndexOf("=", StringComparison.Ordinal);
+ if (eqIdx >= 0)
+ inner = inner.Substring(0, eqIdx);
+
+ // Strip optional marker
+ if (inner.Length > 0 && inner[inner.Length - 1] == '?')
+ inner = inner.Substring(0, inner.Length - 1);
+
+ return inner.Length == 0 ? null : inner;
+ }
+
+ // For mixed segments, extract the parameter name from within braces
+ var start = segment.IndexOf("{", StringComparison.Ordinal);
+ var end = segment.IndexOf("}", StringComparison.Ordinal);
+ if (start >= 0 && end > start)
+ {
+ var token = segment.Substring(start + 1, end - start - 1);
+ // Strip constraint
+ var ci = token.IndexOf(":", StringComparison.Ordinal);
+ if (ci >= 0) token = token.Substring(0, ci);
+ // Strip default
+ var ei = token.IndexOf("=", StringComparison.Ordinal);
+ if (ei >= 0) token = token.Substring(0, ei);
+ return token.Length == 0 ? null : token;
+ }
+
+ return null;
+ }
+
+ public static RouteTemplate Parse(string route, out string error)
+ {
+ error = null;
+
+ if (string.IsNullOrWhiteSpace(route))
+ {
+ error = "Route cannot be empty";
+ return null;
+ }
+
+ var raw = route.Split(new[] { '/', '\\' }, StringSplitOptions.RemoveEmptyEntries);
+ var segments = new TemplateSegment[raw.Length];
+ bool hasParameters = false;
+ var seen = new HashSet(StringComparer.Ordinal);
+
+ for (var i = 0; i < raw.Length; i++)
+ {
+ var s = raw[i];
+ bool hasBrace = s.IndexOf("{", StringComparison.Ordinal) >= 0;
+
+ if (!hasBrace)
+ {
+ segments[i] = TemplateSegment.Literal(s);
+ continue;
+ }
+
+ // Mixed segment: literal text around a parameter token
+ if (!IsPureParameterSegment(s))
+ {
+ var openIdx = s.IndexOf("{", StringComparison.Ordinal);
+ var closeIdx = s.IndexOf("}", StringComparison.Ordinal);
+ if (openIdx < 0 || closeIdx < 0 || closeIdx <= openIdx + 1)
+ {
+ error = $"Route template segment \"{s}\" has malformed braces.";
+ return null;
+ }
+
+ var prefix = s.Substring(0, openIdx);
+ var suffix = closeIdx + 1 < s.Length ? s.Substring(closeIdx + 1) : "";
+
+ // Reject multiple parameter tokens in one segment (e.g. "a-{x}-{y}")
+ if (suffix.IndexOf("{", StringComparison.Ordinal) >= 0
+ || suffix.IndexOf("}", StringComparison.Ordinal) >= 0)
+ {
+ error = $"Route template segment \"{s}\" contains multiple parameter tokens. Only one parameter per segment is supported.";
+ return null;
+ }
+
+ var token = s.Substring(openIdx + 1, closeIdx - openIdx - 1);
+
+ // Parse constraint from token
+ string constraint = null;
+ var colonIdx = token.IndexOf(":", StringComparison.Ordinal);
+ if (colonIdx >= 0)
+ {
+ constraint = token.Substring(colonIdx + 1);
+ token = token.Substring(0, colonIdx);
+ }
+
+ if (string.IsNullOrEmpty(token) || !IsValidParameterName(token))
+ {
+ error = $"Route template segment \"{s}\" has an invalid parameter name.";
+ return null;
+ }
+
+ if (!seen.Add(token))
+ {
+ error = $"Route template parameter \"{token}\" appears more than once.";
+ return null;
+ }
+
+ if (constraint != null && !IsValidConstraint(constraint))
+ {
+ error = $"Route template constraint \":{constraint}\" is not recognized. Supported: int, long, double, bool, guid, alpha.";
+ return null;
+ }
+
+ segments[i] = TemplateSegment.Mixed(token, prefix, suffix, constraint);
+ hasParameters = true;
+ continue;
+ }
+
+ // Pure parameter token: {name}, {name?}, {*name}, {name:int}, {name=default}
+ var inner = s.Substring(1, s.Length - 2);
+
+ bool isCatchAll = inner.Length > 0 && inner[0] == '*';
+ if (isCatchAll)
+ inner = inner.Substring(1);
+
+ // Parse constraint
+ string paramConstraint = null;
+ bool optionalFromConstraint = false;
+ var cIdx = inner.IndexOf(":", StringComparison.Ordinal);
+ if (cIdx >= 0)
+ {
+ var constraintAndRest = inner.Substring(cIdx + 1);
+ inner = inner.Substring(0, cIdx);
+
+ // Constraint may be followed by "=default" (e.g. :int=5)
+ var eqInConstraint = constraintAndRest.IndexOf("=", StringComparison.Ordinal);
+ if (eqInConstraint >= 0)
+ {
+ paramConstraint = constraintAndRest.Substring(0, eqInConstraint);
+ // Put the default value part back into inner for the
+ // default-value parser below
+ inner = inner + "=" + constraintAndRest.Substring(eqInConstraint + 1);
+ }
+ else
+ {
+ paramConstraint = constraintAndRest;
+ }
+
+ // Strip optional marker from constraint if present (e.g. "int?" → "int")
+ if (paramConstraint.Length > 0 && paramConstraint[paramConstraint.Length - 1] == '?')
+ {
+ paramConstraint = paramConstraint.Substring(0, paramConstraint.Length - 1);
+ optionalFromConstraint = true;
+ }
+ }
+
+ // Parse default value
+ string defaultValue = null;
+ var eIdx = inner.IndexOf("=", StringComparison.Ordinal);
+ if (eIdx >= 0)
+ {
+ defaultValue = inner.Substring(eIdx + 1);
+ inner = inner.Substring(0, eIdx);
+ }
+
+ bool isOptional = optionalFromConstraint
+ || (inner.Length > 0 && inner[inner.Length - 1] == '?');
+ if (!optionalFromConstraint && isOptional)
+ inner = inner.Substring(0, inner.Length - 1);
+
+ var name = inner;
+ if (string.IsNullOrEmpty(name))
+ {
+ error = $"Route template segment \"{s}\" has no parameter name.";
+ return null;
+ }
+
+ if (!IsValidParameterName(name))
+ {
+ error = $"Route template parameter \"{name}\" is not a valid identifier.";
+ return null;
+ }
+
+ if (!seen.Add(name))
+ {
+ error = $"Route template parameter \"{name}\" appears more than once.";
+ return null;
+ }
+
+ if (isCatchAll && i != raw.Length - 1)
+ {
+ error = $"Catch-all parameter \"{{*{name}}}\" must be the last segment in the route.";
+ return null;
+ }
+
+ if (paramConstraint != null && !IsValidConstraint(paramConstraint))
+ {
+ error = $"Route template constraint \":{paramConstraint}\" is not recognized. Supported: int, long, double, bool, guid, alpha.";
+ return null;
+ }
+
+ // Default value implies optional
+ if (defaultValue != null)
+ isOptional = true;
+
+ // Optional/default parameters must be the last segment
+ // (same as ASP.NET Core). Middle-optional is unmatchable
+ // because the greedy matcher would consume the wrong segment.
+ if (isOptional && i != raw.Length - 1)
+ {
+ error = $"Optional parameter \"{{{name}}}\" must be the last segment in the route. Optional parameters in the middle are not supported.";
+ return null;
+ }
+
+ // Validate default value against constraint at registration time
+ if (defaultValue != null && paramConstraint != null
+ && !SatisfiesConstraint(paramConstraint, defaultValue))
+ {
+ error = $"Default value \"{defaultValue}\" for parameter \"{name}\" does not satisfy the :{paramConstraint} constraint.";
+ return null;
+ }
+
+ segments[i] = TemplateSegment.Parameter(name, isOptional, isCatchAll, paramConstraint, defaultValue);
+ hasParameters = true;
+ }
+
+ return new RouteTemplate(segments) { HasParameters = hasParameters };
+ }
+
+ ///
+ /// Checks if a value satisfies the constraint. Returns true if no constraint or value matches.
+ ///
+ public static bool SatisfiesConstraint(string constraint, string value)
+ {
+ if (string.IsNullOrEmpty(constraint))
+ return true;
+ if (value == null)
+ return true; // null means absent; optional/required decides, not constraint
+
+ switch (constraint)
+ {
+ case "int":
+ return int.TryParse(value, out _);
+ case "long":
+ return long.TryParse(value, out _);
+ case "double":
+ return double.TryParse(value, System.Globalization.NumberStyles.Any,
+ System.Globalization.CultureInfo.InvariantCulture, out _);
+ case "bool":
+ return bool.TryParse(value, out _);
+ case "guid":
+ return Guid.TryParse(value, out _);
+ case "alpha":
+ for (int i = 0; i < value.Length; i++)
+ if (!char.IsLetter(value[i]))
+ return false;
+ return value.Length > 0;
+ default:
+ return true; // unknown constraint, be permissive
+ }
+ }
+
+ static bool IsValidConstraint(string constraint)
+ {
+ switch (constraint)
+ {
+ case "int":
+ case "long":
+ case "double":
+ case "bool":
+ case "guid":
+ case "alpha":
+ return true;
+ default:
+ return false;
+ }
+ }
+
+ static bool IsValidParameterName(string name)
+ {
+ if (string.IsNullOrEmpty(name))
+ return false;
+
+ if (!char.IsLetter(name[0]) && name[0] != '_')
+ return false;
+
+ for (var i = 1; i < name.Length; i++)
+ {
+ var c = name[i];
+ if (!char.IsLetterOrDigit(c) && c != '_')
+ return false;
+ }
+
+ return true;
+ }
+
+ internal readonly struct TemplateSegment
+ {
+ public readonly bool IsParameter;
+ public readonly bool IsOptional;
+ public readonly bool IsCatchAll;
+ public readonly bool IsMixed;
+ public readonly string Value; // param name or literal text
+ public readonly string Prefix; // for mixed segments: text before {param}
+ public readonly string Suffix; // for mixed segments: text after {param}
+ public readonly string Constraint; // e.g. "int", "guid", null if none
+ public readonly string DefaultValue; // e.g. "5", null if none
+
+ TemplateSegment(bool isParameter, string value, bool isOptional = false,
+ bool isCatchAll = false, string constraint = null, string defaultValue = null,
+ bool isMixed = false, string prefix = null, string suffix = null)
+ {
+ IsParameter = isParameter;
+ Value = value;
+ IsOptional = isOptional;
+ IsCatchAll = isCatchAll;
+ IsMixed = isMixed;
+ Prefix = prefix ?? "";
+ Suffix = suffix ?? "";
+ Constraint = constraint;
+ DefaultValue = defaultValue;
+ }
+
+ public static TemplateSegment Literal(string text) =>
+ new TemplateSegment(false, text);
+
+ public static TemplateSegment Parameter(string name, bool isOptional = false,
+ bool isCatchAll = false, string constraint = null, string defaultValue = null) =>
+ new TemplateSegment(true, name, isOptional, isCatchAll, constraint, defaultValue);
+
+ public static TemplateSegment Mixed(string paramName, string prefix, string suffix, string constraint = null) =>
+ new TemplateSegment(true, paramName, isMixed: true, prefix: prefix, suffix: suffix, constraint: constraint);
+ }
+ }
+}
diff --git a/src/Controls/src/Core/Shell/ShellNavigationManager.cs b/src/Controls/src/Core/Shell/ShellNavigationManager.cs
index efac0f54f2e6..ad7fc0e6f209 100644
--- a/src/Controls/src/Core/Shell/ShellNavigationManager.cs
+++ b/src/Controls/src/Core/Shell/ShellNavigationManager.cs
@@ -92,6 +92,55 @@ internal async Task GoToAsync(
var uri = navigationRequest.Request.FullUri;
var queryString = navigationRequest.Query;
+
+ // Seed path parameters from templated route segments BEFORE the
+ // query string. SetQueryStringParameters only adds keys that are
+ // not already present, so path parameters win over a query-string
+ // parameter with the same name (matches ASP.NET Core / Blazor
+ // route-template precedence and lets templated routes override
+ // stale query-string values). For literal-only routes this
+ // dictionary is empty, so the existing behavior is preserved.
+ var pathParameters = navigationRequest.Request.PathParameters;
+ if (pathParameters != null && pathParameters.Count > 0)
+ {
+ // Use "only add if not present" so caller-supplied programmatic
+ // parameters (from GoToAsync overload) take precedence over
+ // path-extracted values. Path params still win over query strings
+ // because SetQueryStringParameters also uses this semantics.
+ foreach (var kvp in pathParameters)
+ {
+ if (!parameters.ContainsKey(kvp.Key))
+ parameters[kvp.Key] = kvp.Value;
+ }
+
+ // Also seed route-prefixed keys so intermediate (non-last) pages
+ // receive path params through ApplyQueryAttributes prefix filtering.
+ // For a route "product/{sku}", the prefix is "product/{sku}." so
+ // the key "product/{sku}.sku" delivers "sku" to that page.
+ var globalRoutes = navigationRequest.Request.GlobalRoutes;
+ if (globalRoutes != null)
+ {
+ foreach (var routeKey in globalRoutes)
+ {
+ if (!Routing.IsTemplateRoute(routeKey))
+ continue;
+ if (!Routing.TryGetRouteTemplate(routeKey, out var tmpl))
+ continue;
+ foreach (var seg in tmpl.Segments)
+ {
+ if (!seg.IsParameter)
+ continue;
+ if (pathParameters.TryGetValue(seg.Value, out var val))
+ {
+ var prefixedKey = $"{routeKey}.{seg.Value}";
+ if (!parameters.ContainsKey(prefixedKey))
+ parameters[prefixedKey] = val;
+ }
+ }
+ }
+ }
+ }
+
parameters.SetQueryStringParameters(queryString);
ApplyQueryAttributes(_shell, parameters, false, false);
@@ -567,7 +616,7 @@ public static ShellNavigationState GetNavigationState(ShellItem shellItem, Shell
for (int i = 1; i < sectionStack.Count; i++)
{
var page = sectionStack[i];
- routeStack.AddRange(ShellUriHandler.CollapsePath(Routing.GetRoute(page), routeStack, hasUserDefinedRoute));
+ routeStack.AddRange(ShellUriHandler.CollapsePath(Routing.GetResolvedRoute(page) ?? Routing.GetRoute(page), routeStack, hasUserDefinedRoute));
}
}
@@ -577,11 +626,11 @@ public static ShellNavigationState GetNavigationState(ShellItem shellItem, Shell
{
var topPage = modalStack[i];
- routeStack.AddRange(ShellUriHandler.CollapsePath(Routing.GetRoute(topPage), routeStack, hasUserDefinedRoute));
+ routeStack.AddRange(ShellUriHandler.CollapsePath(Routing.GetResolvedRoute(topPage) ?? Routing.GetRoute(topPage), routeStack, hasUserDefinedRoute));
for (int j = 1; j < topPage.Navigation.NavigationStack.Count; j++)
{
- routeStack.AddRange(ShellUriHandler.CollapsePath(Routing.GetRoute(topPage.Navigation.NavigationStack[j]), routeStack, hasUserDefinedRoute));
+ routeStack.AddRange(ShellUriHandler.CollapsePath(Routing.GetResolvedRoute(topPage.Navigation.NavigationStack[j]) ?? Routing.GetRoute(topPage.Navigation.NavigationStack[j]), routeStack, hasUserDefinedRoute));
}
}
}
diff --git a/src/Controls/src/Core/Shell/ShellSection.cs b/src/Controls/src/Core/Shell/ShellSection.cs
index 119eac000ce7..98a59d704310 100644
--- a/src/Controls/src/Core/Shell/ShellSection.cs
+++ b/src/Controls/src/Core/Shell/ShellSection.cs
@@ -328,7 +328,7 @@ public static implicit operator ShellSection(TemplatedPage page)
return (ShellSection)(ShellContent)page;
}
- async Task PrepareCurrentStackForBeingReplaced(ShellNavigationRequest request, ShellRouteParameters queryData, IServiceProvider services, bool? animate, List globalRoutes, bool isRelativePopping)
+ async Task PrepareCurrentStackForBeingReplaced(ShellNavigationRequest request, ShellRouteParameters queryData, IServiceProvider services, bool? animate, List globalRoutes, List resolvedRoutes, bool isRelativePopping)
{
string route = "";
List navStack = null;
@@ -369,10 +369,14 @@ async Task PrepareCurrentStackForBeingReplaced(ShellNavigationRequest request, S
// Routes match so don't do anything
if (navIndex < _navStack.Count && Routing.GetRoute(_navStack[navIndex]) == globalRoutes[i])
{
+ // Update ResolvedRoute in case the resolved value changed
+ // (e.g. navigating from product/apple to product/banana)
+ if (resolvedRoutes?.Count > i && Routing.IsTemplateRoute(globalRoutes[i]))
+ Routing.SetResolvedRoute(_navStack[navIndex], resolvedRoutes[i]);
continue;
}
- var page = GetOrCreateFromRoute(globalRoutes[i], queryData, services, i == globalRoutes.Count - 1, false);
+ var page = GetOrCreateFromRoute(globalRoutes[i], resolvedRoutes?.Count > i ? resolvedRoutes[i] : null, queryData, services, i == globalRoutes.Count - 1, false);
if (IsModal(page))
{
await PushModalAsync(page, IsNavigationAnimated(page));
@@ -417,6 +421,10 @@ async Task PrepareCurrentStackForBeingReplaced(ShellNavigationRequest request, S
popCount = i + 2;
ShellNavigationManager.ApplyQueryAttributes(navPage, queryData, isLast, isRelativePopping);
+ // Update ResolvedRoute for reused template pages
+ if (resolvedRoutes?.Count > i && Routing.IsTemplateRoute(route))
+ Routing.SetResolvedRoute(navPage, resolvedRoutes[i]);
+
// If we're not on the last loop of the stack then continue
// otherwise pop the rest of the stack
if (!isLast)
@@ -506,7 +514,7 @@ void RemoveExcessPathsWithinTheRoute()
}
}
- Page GetOrCreateFromRoute(string route, ShellRouteParameters queryData, IServiceProvider services, bool isLast, bool isPopping)
+ Page GetOrCreateFromRoute(string route, string resolvedRoute, ShellRouteParameters queryData, IServiceProvider services, bool isLast, bool isPopping)
{
var content = Routing.GetOrCreateContent(route, services) as Page;
if (content == null)
@@ -514,6 +522,16 @@ Page GetOrCreateFromRoute(string route, ShellRouteParameters queryData, IService
Application.Current?.FindMauiContext()?.CreateLogger()?.LogWarning("Failed to Create Content For: {route}", route);
}
+ // For template routes (e.g. "product/{sku}"), store the resolved value
+ // (e.g. "product/seed-tomato") in a separate attached property. The
+ // page's Route stays as the registered template key so factory
+ // lookups and stack-reuse comparisons still work.
+ if (content != null && resolvedRoute != null && resolvedRoute != route
+ && Routing.IsTemplateRoute(route))
+ {
+ Routing.SetResolvedRoute(content, resolvedRoute);
+ }
+
ShellNavigationManager.ApplyQueryAttributes(content, queryData, isLast, isPopping);
return content;
}
@@ -521,6 +539,7 @@ Page GetOrCreateFromRoute(string route, ShellRouteParameters queryData, IService
internal async Task GoToAsync(ShellNavigationRequest request, ShellRouteParameters queryData, IServiceProvider services, bool? animate, bool isRelativePopping)
{
List globalRoutes = request.Request.GlobalRoutes;
+ List resolvedRoutes = request.Request.ResolvedGlobalRoutes;
if (globalRoutes == null || globalRoutes.Count == 0)
{
if (_navStack.Count == 2)
@@ -531,7 +550,7 @@ internal async Task GoToAsync(ShellNavigationRequest request, ShellRouteParamete
return;
}
- await PrepareCurrentStackForBeingReplaced(request, queryData, services, animate, globalRoutes, isRelativePopping);
+ await PrepareCurrentStackForBeingReplaced(request, queryData, services, animate, globalRoutes, resolvedRoutes, isRelativePopping);
List modalPageStacks = new List();
List nonModalPageStacks = new List();
@@ -549,7 +568,7 @@ internal async Task GoToAsync(ShellNavigationRequest request, ShellRouteParamete
for (int i = whereToStartNavigation; i < globalRoutes.Count; i++)
{
bool isLast = i == globalRoutes.Count - 1;
- var content = GetOrCreateFromRoute(globalRoutes[i], queryData, services, isLast, false);
+ var content = GetOrCreateFromRoute(globalRoutes[i], resolvedRoutes?.Count > i ? resolvedRoutes[i] : null, queryData, services, isLast, false);
if (content == null)
{
break;
diff --git a/src/Controls/src/Core/Shell/ShellUriHandler.cs b/src/Controls/src/Core/Shell/ShellUriHandler.cs
index df08c0f61f08..ff8a36937b03 100644
--- a/src/Controls/src/Core/Shell/ShellUriHandler.cs
+++ b/src/Controls/src/Core/Shell/ShellUriHandler.cs
@@ -48,7 +48,7 @@ internal static Uri FormatUri(Uri path, Shell shell)
if (page == null)
continue;
- var route = Routing.GetRoute(page);
+ var route = Routing.GetResolvedRoute(page) ?? Routing.GetRoute(page);
buildUpPages.AddRange(CollapsePath(route, buildUpPages, false));
}
@@ -296,6 +296,7 @@ internal static List GenerateRoutePaths(Shell shell, Uri re
continue;
var globalRouteMatch = globalRouteMatches[0];
+ bool pathParamsForwarded = false;
while (possibleRoutePath.NextSegment != null)
{
@@ -306,6 +307,13 @@ internal static List GenerateRoutePaths(Shell shell, Uri re
possibleRoutePath.AddGlobalRoute(
globalRouteMatch.GlobalRouteMatches[matchIndex],
globalRouteMatch.SegmentsMatched[matchIndex]);
+
+ // Forward captured path parameters once
+ if (!pathParamsForwarded)
+ {
+ possibleRoutePath.MergePathParameters(globalRouteMatch.PathParameters);
+ pathParamsForwarded = true;
+ }
}
}
@@ -464,6 +472,10 @@ static List SearchForGlobalRoutes(
for (int i = existingGlobalRoutes.Count; i < additionalRouteMatches.Count; i++)
requestBuilderWithNewSegments.AddGlobalRoute(additionalRouteMatches[i], segments[i - existingGlobalRoutes.Count]);
+ // Transfer path parameters captured during ExpandOutGlobalRoutes
+ // so template routes still deliver values through this code path.
+ requestBuilderWithNewSegments.MergePathParameters(routeRequestBuilder.PathParameters);
+
pureGlobalRoutesMatch.Add(requestBuilderWithNewSegments);
}
@@ -512,7 +524,8 @@ internal static List CollapsePath(
if (localRouteStack.Count <= walkBackCurrentStackIndex)
break;
- if (paths[0] == localRouteStack[walkBackCurrentStackIndex])
+ if (paths[0] == localRouteStack[walkBackCurrentStackIndex]
+ || RouteTemplate.IsTemplateSegment(paths[0]))
{
paths.RemoveAt(0);
}
@@ -529,67 +542,97 @@ internal static List CollapsePath(
static bool FindAndAddSegmentMatch(RouteRequestBuilder possibleRoutePath, HashSet routeKeys)
{
- // First search by collapsing global routes if user is registering routes like "route1/route2/route3"
- foreach (var routeKey in routeKeys)
+ // Two-pass match enforces literal-route precedence over templated
+ // routes (the same priority ASP.NET Core / Blazor use). Pass 0
+ // considers only purely-literal route keys; pass 1 considers
+ // routes that contain "{param}" segments. Without this, a
+ // templated registration could win over an exact literal
+ // registration depending on HashSet iteration order.
+ for (int pass = 0; pass < 2; pass++)
{
- var collapsedRoutes = CollapsePath(routeKey, possibleRoutePath.SegmentsMatched, true);
- var collapsedRoute = String.Join(_pathSeparator, collapsedRoutes);
+ bool acceptingTemplates = pass == 1;
- if (routeKey.StartsWith("//", StringComparison.Ordinal))
+ // First search by collapsing global routes if user is registering routes like "route1/route2/route3"
+ foreach (var routeKey in routeKeys)
{
- var routeKeyPaths =
- routeKey.Split(_pathSeparators, StringSplitOptions.RemoveEmptyEntries);
+ bool isTemplate = Routing.IsTemplateRoute(routeKey);
+ if (isTemplate != acceptingTemplates)
+ continue;
- if (routeKeyPaths[0] == collapsedRoutes[0])
- collapsedRoute = "//" + collapsedRoute;
- }
+ var collapsedRoutes = CollapsePath(routeKey, possibleRoutePath.SegmentsMatched, true);
+ var collapsedRoute = String.Join(_pathSeparator, collapsedRoutes);
- string collapsedMatch = possibleRoutePath.GetNextSegmentMatch(collapsedRoute);
- if (!String.IsNullOrWhiteSpace(collapsedMatch))
- {
- possibleRoutePath.AddGlobalRoute(routeKey, collapsedMatch);
- return true;
- }
+ if (routeKey.StartsWith("//", StringComparison.Ordinal))
+ {
+ var routeKeyPaths =
+ routeKey.Split(_pathSeparators, StringSplitOptions.RemoveEmptyEntries);
- // If the registered route is a combination of shell items and global routes then we might end up here
- // without the previous tree search finding the correct path
- if ((possibleRoutePath.Shell != null) &&
- (possibleRoutePath.Item == null || possibleRoutePath.Section == null || possibleRoutePath.Content == null))
- {
- var nextNode = possibleRoutePath.GetNodeLocation().WalkToNextNode();
+ if (routeKeyPaths[0] == collapsedRoutes[0])
+ collapsedRoute = "//" + collapsedRoute;
+ }
+
+ Dictionary capturedParameters = isTemplate
+ ? new Dictionary(StringComparer.Ordinal)
+ : null;
- while (nextNode != null)
+ // Look up template by original routeKey (not collapsed route)
+ // so constraints/defaults/catch-all are still applied after
+ // CollapsePath strips prefix segments.
+ RouteTemplate routeTemplate = null;
+ if (isTemplate)
+ Routing.TryGetRouteTemplate(routeKey, out routeTemplate);
+
+ string collapsedMatch = possibleRoutePath.GetNextSegmentMatch(collapsedRoute, capturedParameters, routeTemplate);
+ if (!String.IsNullOrWhiteSpace(collapsedMatch))
{
- // This means we've jumped to a branch that no longer corresponds with the route path we are searching
- if ((possibleRoutePath.Item != null && nextNode.Item != possibleRoutePath.Item) ||
- (possibleRoutePath.Section != null && nextNode.Section != possibleRoutePath.Section) ||
- (possibleRoutePath.Content != null && nextNode.Content != possibleRoutePath.Content))
- {
- nextNode = nextNode.WalkToNextNode();
- continue;
- }
+ possibleRoutePath.AddGlobalRoute(routeKey, collapsedMatch, capturedParameters);
+ return true;
+ }
+
+ // If the registered route is a combination of shell items and global routes then we might end up here
+ // without the previous tree search finding the correct path
+ if ((possibleRoutePath.Shell != null) &&
+ (possibleRoutePath.Item == null || possibleRoutePath.Section == null || possibleRoutePath.Content == null))
+ {
+ var nextNode = possibleRoutePath.GetNodeLocation().WalkToNextNode();
- var leafSearch = new RouteRequestBuilder(possibleRoutePath);
- if (!leafSearch.AddMatch(nextNode))
+ while (nextNode != null)
{
- nextNode = nextNode.WalkToNextNode();
- continue;
- }
+ // This means we've jumped to a branch that no longer corresponds with the route path we are searching
+ if ((possibleRoutePath.Item != null && nextNode.Item != possibleRoutePath.Item) ||
+ (possibleRoutePath.Section != null && nextNode.Section != possibleRoutePath.Section) ||
+ (possibleRoutePath.Content != null && nextNode.Content != possibleRoutePath.Content))
+ {
+ nextNode = nextNode.WalkToNextNode();
+ continue;
+ }
- var collapsedLeafRoute = String.Join(_pathSeparator, CollapsePath(routeKey, leafSearch.SegmentsMatched, true));
+ var leafSearch = new RouteRequestBuilder(possibleRoutePath);
+ if (!leafSearch.AddMatch(nextNode))
+ {
+ nextNode = nextNode.WalkToNextNode();
+ continue;
+ }
- if (routeKey.StartsWith("//", StringComparison.Ordinal))
- collapsedLeafRoute = "//" + collapsedLeafRoute;
+ var collapsedLeafRoute = String.Join(_pathSeparator, CollapsePath(routeKey, leafSearch.SegmentsMatched, true));
- string segmentMatch = leafSearch.GetNextSegmentMatch(collapsedLeafRoute);
- if (!String.IsNullOrWhiteSpace(segmentMatch))
- {
- possibleRoutePath.AddMatch(nextNode);
- possibleRoutePath.AddGlobalRoute(routeKey, segmentMatch);
- return true;
- }
+ if (routeKey.StartsWith("//", StringComparison.Ordinal))
+ collapsedLeafRoute = "//" + collapsedLeafRoute;
+
+ Dictionary leafCaptured = isTemplate
+ ? new Dictionary(StringComparer.Ordinal)
+ : null;
- nextNode = nextNode.WalkToNextNode();
+ string segmentMatch = leafSearch.GetNextSegmentMatch(collapsedLeafRoute, leafCaptured, routeTemplate);
+ if (!String.IsNullOrWhiteSpace(segmentMatch))
+ {
+ possibleRoutePath.AddMatch(nextNode);
+ possibleRoutePath.AddGlobalRoute(routeKey, segmentMatch, leafCaptured);
+ return true;
+ }
+
+ nextNode = nextNode.WalkToNextNode();
+ }
}
}
}
@@ -631,8 +674,17 @@ internal static void ExpandOutGlobalRoutes(List possibleRou
for (var i = 0; i < pureGlobalRoutesMatch[0].GlobalRouteMatches.Count; i++)
{
var match = pureGlobalRoutesMatch[0];
- possibleRoutePath.AddGlobalRoute(match.GlobalRouteMatches[i], match.SegmentsMatched[i]);
+ // Forward any path parameters captured during the
+ // secondary search so templated routes still deliver
+ // their values to ApplyQueryAttributes. Only forward
+ // once (on the first iteration); subsequent
+ possibleRoutePath.AddGlobalRoute(
+ match.GlobalRouteMatches[i],
+ match.SegmentsMatched[i]);
}
+
+ // Merge path parameters from the secondary search
+ possibleRoutePath.MergePathParameters(pureGlobalRoutesMatch[0].PathParameters);
}
}
}
diff --git a/src/Controls/tests/Core.UnitTests/ShellRouteTemplatesTests.cs b/src/Controls/tests/Core.UnitTests/ShellRouteTemplatesTests.cs
new file mode 100644
index 000000000000..16dfc64347d2
--- /dev/null
+++ b/src/Controls/tests/Core.UnitTests/ShellRouteTemplatesTests.cs
@@ -0,0 +1,1154 @@
+using System;
+using System.Collections.Generic;
+using System.Threading.Tasks;
+using Xunit;
+
+namespace Microsoft.Maui.Controls.Core.UnitTests
+{
+ public class ShellRouteTemplatesTests : ShellTestBase
+ {
+ [QueryProperty(nameof(Sku), "sku")]
+ public class ProductPage : ContentPage
+ {
+ public string Sku { get; set; }
+ }
+
+ [QueryProperty(nameof(Sku), "sku")]
+ [QueryProperty(nameof(ReviewId), "reviewId")]
+ public class ReviewPage : ContentPage
+ {
+ public string Sku { get; set; }
+ public string ReviewId { get; set; }
+ }
+
+ [Fact]
+ public void RegisterRouteTemplate_DetectedAsTemplate()
+ {
+ Routing.RegisterRoute("product/{sku}", typeof(ProductPage));
+
+ Assert.True(Routing.IsTemplateRoute("product/{sku}"));
+ Assert.False(Routing.IsTemplateRoute("product/seed-tomato"));
+
+ Assert.True(Routing.TryGetRouteTemplate("product/{sku}", out var template));
+ Assert.NotNull(template);
+ Assert.True(template.HasParameters);
+ }
+
+ [Fact]
+ public async Task SinglePathParameter_DeliveredViaQueryProperty()
+ {
+ Routing.RegisterRoute("product/{sku}", typeof(ProductPage));
+
+ var shell = new Shell();
+ shell.Items.Add(CreateShellItem(shellSectionRoute: "main", shellContentRoute: "products"));
+
+ await shell.GoToAsync("//main/products/product/seed-tomato");
+
+ var page = shell.Navigation.NavigationStack[shell.Navigation.NavigationStack.Count - 1] as ProductPage;
+ Assert.NotNull(page);
+ Assert.Equal("seed-tomato", page.Sku);
+ }
+
+ [Fact]
+ public async Task MultiSegmentTemplate_ChildInheritsParentPathParameter()
+ {
+ // Register as separate routes — Shell iteratively matches each
+ // segment via ExpandOutGlobalRoutes.
+ Routing.RegisterRoute("product/{sku}", typeof(ProductPage));
+ Routing.RegisterRoute("review", typeof(ReviewPage));
+
+ var shell = new Shell();
+ shell.Items.Add(CreateShellItem(shellSectionRoute: "main", shellContentRoute: "products"));
+
+ await shell.GoToAsync("//main/products/product/seed-tomato/review");
+
+ var stack = shell.Navigation.NavigationStack;
+ ReviewPage review = null;
+ foreach (var p in stack)
+ {
+ if (p is ReviewPage rp)
+ review = rp;
+ }
+ Assert.NotNull(review);
+ // The last page in the navigation receives all unprefixed params,
+ // including the path parameter captured from the parent template.
+ Assert.Equal("seed-tomato", review.Sku);
+ }
+
+ [Fact]
+ public async Task LiteralRouteWinsOverTemplateRoute()
+ {
+ Routing.RegisterRoute("product/{sku}", typeof(ProductPage));
+ Routing.RegisterRoute("product/special", typeof(ReviewPage));
+
+ var shell = new Shell();
+ shell.Items.Add(CreateShellItem(shellSectionRoute: "main", shellContentRoute: "products"));
+
+ await shell.GoToAsync("//main/products/product/special");
+
+ var top = shell.Navigation.NavigationStack[shell.Navigation.NavigationStack.Count - 1];
+ Assert.IsType(top);
+ }
+
+ [Fact]
+ public async Task PathParameterOverridesQueryStringWithSameName()
+ {
+ Routing.RegisterRoute("product/{sku}", typeof(ProductPage));
+
+ var shell = new Shell();
+ shell.Items.Add(CreateShellItem(shellSectionRoute: "main", shellContentRoute: "products"));
+
+ // Path provides "seed-tomato"; query provides "ignored". Path must win.
+ await shell.GoToAsync("//main/products/product/seed-tomato?sku=ignored");
+
+ var page = shell.Navigation.NavigationStack[shell.Navigation.NavigationStack.Count - 1] as ProductPage;
+ Assert.NotNull(page);
+ Assert.Equal("seed-tomato", page.Sku);
+ }
+
+ [Fact]
+ public async Task PathParameter_MixedWithUnrelatedQueryString()
+ {
+ Routing.RegisterRoute("product/{sku}", typeof(ProductPage));
+
+ var shell = new Shell();
+ shell.Items.Add(CreateShellItem(shellSectionRoute: "main", shellContentRoute: "products"));
+
+ await shell.GoToAsync("//main/products/product/seed-tomato?source=catalog");
+
+ var page = shell.Navigation.NavigationStack[shell.Navigation.NavigationStack.Count - 1] as ProductPage;
+ Assert.NotNull(page);
+ Assert.Equal("seed-tomato", page.Sku);
+ }
+
+ [Fact]
+ public void LiteralRouteUnchanged_RegistersAsLiteral()
+ {
+ Routing.RegisterRoute("plain-product", typeof(ProductPage));
+
+ Assert.False(Routing.IsTemplateRoute("plain-product"));
+ Assert.False(Routing.TryGetRouteTemplate("plain-product", out _));
+ }
+
+ [Fact]
+ public void Routing_Clear_AlsoClearsTemplates()
+ {
+ Routing.RegisterRoute("product/{sku}", typeof(ProductPage));
+ Assert.True(Routing.IsTemplateRoute("product/{sku}"));
+
+ Routing.Clear();
+
+ Assert.False(Routing.IsTemplateRoute("product/{sku}"));
+ }
+
+ [Fact]
+ public async Task CurrentStateLocation_ShowsResolvedValues()
+ {
+ Routing.RegisterRoute("product/{sku}", typeof(ProductPage));
+
+ var shell = new Shell();
+ shell.Items.Add(CreateShellItem(shellSectionRoute: "main", shellContentRoute: "products"));
+
+ await shell.GoToAsync("//main/products/product/seed-tomato");
+
+ var location = shell.CurrentState.Location.ToString();
+ Assert.Contains("seed-tomato", location, StringComparison.Ordinal);
+ Assert.DoesNotContain("{sku}", location, StringComparison.Ordinal);
+ }
+
+ [Fact]
+ public async Task RelativeNavigation_WithTemplateRoute()
+ {
+ Routing.RegisterRoute("product/{sku}", typeof(ProductPage));
+
+ var shell = new Shell();
+ shell.Items.Add(CreateShellItem(shellSectionRoute: "main", shellContentRoute: "products"));
+
+ // Known v1 limitation: relative navigation with template routes
+ // goes through SearchForGlobalRoutes which doesn't yet recognize
+ // template segments in the relative URI. Use absolute URIs for now.
+ // This test documents the limitation — when fixed, change to Assert.Equal.
+ await Assert.ThrowsAsync(() =>
+ shell.GoToAsync("product/seed-tomato"));
+ }
+
+ [Fact]
+ public async Task UrlEncodedPathParameter_IsDecoded()
+ {
+ Routing.RegisterRoute("product/{sku}", typeof(ProductPage));
+
+ var shell = new Shell();
+ shell.Items.Add(CreateShellItem(shellSectionRoute: "main", shellContentRoute: "products"));
+
+ await shell.GoToAsync("//main/products/product/hello%20world");
+
+ var page = shell.Navigation.NavigationStack[shell.Navigation.NavigationStack.Count - 1] as ProductPage;
+ Assert.NotNull(page);
+ Assert.Equal("hello world", page.Sku);
+ }
+
+ [Fact]
+ public async Task SecondNavigation_DifferentValue_DeliveredCorrectly()
+ {
+ Routing.RegisterRoute("product/{sku}", typeof(ProductPage));
+
+ var shell = new Shell();
+ shell.Items.Add(CreateShellItem(shellSectionRoute: "main", shellContentRoute: "products"));
+
+ await shell.GoToAsync("//main/products/product/apple");
+ var page1 = shell.Navigation.NavigationStack[shell.Navigation.NavigationStack.Count - 1] as ProductPage;
+ Assert.Equal("apple", page1.Sku);
+
+ await shell.GoToAsync("//main/products/product/banana");
+ var page2 = shell.Navigation.NavigationStack[shell.Navigation.NavigationStack.Count - 1] as ProductPage;
+ Assert.Equal("banana", page2.Sku);
+ }
+
+ [Fact]
+ public void RegisterRoute_AcceptsOptionalTemplateSyntax()
+ {
+ Routing.RegisterRoute("product/{sku?}", typeof(ProductPage));
+ Assert.True(Routing.IsTemplateRoute("product/{sku?}"));
+ Assert.True(Routing.TryGetRouteTemplate("product/{sku?}", out var template));
+ Assert.True(template.Segments[1].IsOptional);
+ }
+
+ [Fact]
+ public void RegisterRoute_AcceptsCatchAllTemplateSyntax()
+ {
+ Routing.RegisterRoute("files/{*rest}", typeof(ProductPage));
+ Assert.True(Routing.IsTemplateRoute("files/{*rest}"));
+ Assert.True(Routing.TryGetRouteTemplate("files/{*rest}", out var template));
+ Assert.True(template.Segments[1].IsCatchAll);
+ }
+
+ [Fact]
+ public void RegisterRoute_RejectsDuplicateParameters()
+ {
+ Assert.Throws(() =>
+ Routing.RegisterRoute("product/{id}/{id}", typeof(ProductPage)));
+ }
+
+ public class QueryAttributablePage : ContentPage, IQueryAttributable
+ {
+ public IDictionary ReceivedQuery { get; private set; }
+
+ public void ApplyQueryAttributes(IDictionary query)
+ {
+ ReceivedQuery = new Dictionary(query);
+ }
+ }
+
+ [Fact]
+ public async Task PathParameter_DeliveredViaIQueryAttributable()
+ {
+ Routing.RegisterRoute("product/{sku}", typeof(QueryAttributablePage));
+
+ var shell = new Shell();
+ shell.Items.Add(CreateShellItem(shellSectionRoute: "main", shellContentRoute: "products"));
+
+ await shell.GoToAsync("//main/products/product/seed-tomato");
+
+ var page = shell.Navigation.NavigationStack[shell.Navigation.NavigationStack.Count - 1] as QueryAttributablePage;
+ Assert.NotNull(page);
+ Assert.NotNull(page.ReceivedQuery);
+ Assert.True(page.ReceivedQuery.ContainsKey("sku"));
+ Assert.Equal("seed-tomato", page.ReceivedQuery["sku"]);
+ }
+
+ [Fact]
+ public async Task TemplateOnlyRoute_AmbiguousRouteIsDocumentedLimitation()
+ {
+ Routing.RegisterRoute("{category}", typeof(ProductPage));
+
+ var shell = new Shell();
+ shell.Items.Add(CreateShellItem(shellSectionRoute: "main", shellContentRoute: "products"));
+
+ await Assert.ThrowsAsync(() =>
+ shell.GoToAsync("//main/products/vegetables"));
+ }
+
+ // ===== Optional Parameters =====
+
+ [Fact]
+ public async Task OptionalParameter_PresentInUri_DeliveredToPage()
+ {
+ Routing.RegisterRoute("product/{sku?}", typeof(ProductPage));
+
+ var shell = new Shell();
+ shell.Items.Add(CreateShellItem(shellSectionRoute: "main", shellContentRoute: "products"));
+
+ await shell.GoToAsync("//main/products/product/seed-tomato");
+
+ var page = shell.Navigation.NavigationStack[shell.Navigation.NavigationStack.Count - 1] as ProductPage;
+ Assert.NotNull(page);
+ Assert.Equal("seed-tomato", page.Sku);
+ }
+
+ [Fact]
+ public async Task OptionalParameter_AbsentInUri_NavigationSucceeds()
+ {
+ Routing.RegisterRoute("product/{sku?}", typeof(ProductPage));
+
+ var shell = new Shell();
+ shell.Items.Add(CreateShellItem(shellSectionRoute: "main", shellContentRoute: "products"));
+
+ // Navigate to "product" without providing the optional sku
+ await shell.GoToAsync("//main/products/product");
+
+ var page = shell.Navigation.NavigationStack[shell.Navigation.NavigationStack.Count - 1] as ProductPage;
+ Assert.NotNull(page);
+ Assert.Null(page.Sku); // No value was provided
+ }
+
+ // ===== Default Values =====
+
+ [QueryProperty(nameof(Stars), "stars")]
+ public class DefaultStarsPage : ContentPage
+ {
+ public string Stars { get; set; }
+ }
+
+ [Fact]
+ public async Task DefaultValue_AbsentInUri_DefaultDelivered()
+ {
+ Routing.RegisterRoute("review/{stars=5}", typeof(DefaultStarsPage));
+
+ var shell = new Shell();
+ shell.Items.Add(CreateShellItem(shellSectionRoute: "main", shellContentRoute: "products"));
+
+ // Navigate without the stars segment — should get default "5"
+ await shell.GoToAsync("//main/products/review");
+
+ var page = shell.Navigation.NavigationStack[shell.Navigation.NavigationStack.Count - 1] as DefaultStarsPage;
+ Assert.NotNull(page);
+ Assert.Equal("5", page.Stars);
+ }
+
+ [Fact]
+ public async Task DefaultValue_PresentInUri_OverridesDefault()
+ {
+ Routing.RegisterRoute("review/{stars=5}", typeof(DefaultStarsPage));
+
+ var shell = new Shell();
+ shell.Items.Add(CreateShellItem(shellSectionRoute: "main", shellContentRoute: "products"));
+
+ await shell.GoToAsync("//main/products/review/3");
+
+ var page = shell.Navigation.NavigationStack[shell.Navigation.NavigationStack.Count - 1] as DefaultStarsPage;
+ Assert.NotNull(page);
+ Assert.Equal("3", page.Stars);
+ }
+
+ // ===== Catch-All Parameters =====
+
+ [QueryProperty(nameof(FilePath), "path")]
+ public class FileBrowserPage : ContentPage
+ {
+ public string FilePath { get; set; }
+ }
+
+ [Fact]
+ public async Task CatchAll_CapturesAllRemainingSegments()
+ {
+ Routing.RegisterRoute("files/{*path}", typeof(FileBrowserPage));
+
+ var shell = new Shell();
+ shell.Items.Add(CreateShellItem(shellSectionRoute: "main", shellContentRoute: "browse"));
+
+ await shell.GoToAsync("//main/browse/files/docs/reports/2024/summary.pdf");
+
+ var page = shell.Navigation.NavigationStack[shell.Navigation.NavigationStack.Count - 1] as FileBrowserPage;
+ Assert.NotNull(page);
+ Assert.Equal("docs/reports/2024/summary.pdf", page.FilePath);
+ }
+
+ [Fact]
+ public async Task CatchAll_SingleSegment()
+ {
+ Routing.RegisterRoute("files/{*path}", typeof(FileBrowserPage));
+
+ var shell = new Shell();
+ shell.Items.Add(CreateShellItem(shellSectionRoute: "main", shellContentRoute: "browse"));
+
+ await shell.GoToAsync("//main/browse/files/readme.txt");
+
+ var page = shell.Navigation.NavigationStack[shell.Navigation.NavigationStack.Count - 1] as FileBrowserPage;
+ Assert.NotNull(page);
+ Assert.Equal("readme.txt", page.FilePath);
+ }
+
+ [Fact]
+ public void CatchAll_MustBeLastSegment()
+ {
+ Assert.Throws(() =>
+ Routing.RegisterRoute("{*path}/suffix", typeof(FileBrowserPage)));
+ }
+
+ // ===== Constraints =====
+
+ [QueryProperty(nameof(OrderId), "id")]
+ public class OrderDetailPage : ContentPage
+ {
+ public string OrderId { get; set; }
+ }
+
+ [Fact]
+ public async Task Constraint_Int_MatchesNumericValue()
+ {
+ Routing.RegisterRoute("order/{id:int}", typeof(OrderDetailPage));
+
+ var shell = new Shell();
+ shell.Items.Add(CreateShellItem(shellSectionRoute: "main", shellContentRoute: "orders"));
+
+ await shell.GoToAsync("//main/orders/order/42");
+
+ var page = shell.Navigation.NavigationStack[shell.Navigation.NavigationStack.Count - 1] as OrderDetailPage;
+ Assert.NotNull(page);
+ Assert.Equal("42", page.OrderId);
+ }
+
+ [Fact]
+ public async Task Constraint_Int_RejectsNonNumeric()
+ {
+ Routing.RegisterRoute("order/{id:int}", typeof(OrderDetailPage));
+
+ var shell = new Shell();
+ shell.Items.Add(CreateShellItem(shellSectionRoute: "main", shellContentRoute: "orders"));
+
+ // "abc" doesn't satisfy :int, so navigation should fail
+ await Assert.ThrowsAsync(() =>
+ shell.GoToAsync("//main/orders/order/abc"));
+ }
+
+ [Fact]
+ public async Task Constraint_Alpha_MatchesAlphaOnly()
+ {
+ Routing.RegisterRoute("category/{name:alpha}", typeof(ProductPage));
+
+ var shell = new Shell();
+ shell.Items.Add(CreateShellItem(shellSectionRoute: "main", shellContentRoute: "browse"));
+
+ await shell.GoToAsync("//main/browse/category/vegetables");
+
+ var page = shell.Navigation.NavigationStack[shell.Navigation.NavigationStack.Count - 1] as ProductPage;
+ Assert.NotNull(page);
+ }
+
+ [Fact]
+ public async Task Constraint_Alpha_RejectsNumeric()
+ {
+ Routing.RegisterRoute("category/{name:alpha}", typeof(ProductPage));
+
+ var shell = new Shell();
+ shell.Items.Add(CreateShellItem(shellSectionRoute: "main", shellContentRoute: "browse"));
+
+ await Assert.ThrowsAsync(() =>
+ shell.GoToAsync("//main/browse/category/123"));
+ }
+
+ [Fact]
+ public async Task Constraint_Guid_MatchesValidGuid()
+ {
+ Routing.RegisterRoute("item/{id:guid}", typeof(OrderDetailPage));
+
+ var shell = new Shell();
+ shell.Items.Add(CreateShellItem(shellSectionRoute: "main", shellContentRoute: "items"));
+
+ await shell.GoToAsync("//main/items/item/550e8400-e29b-41d4-a716-446655440000");
+
+ var page = shell.Navigation.NavigationStack[shell.Navigation.NavigationStack.Count - 1] as OrderDetailPage;
+ Assert.NotNull(page);
+ Assert.Equal("550e8400-e29b-41d4-a716-446655440000", page.OrderId);
+ }
+
+ [Fact]
+ public void Constraint_UnknownType_RejectedAtRegistration()
+ {
+ Assert.Throws(() =>
+ Routing.RegisterRoute("order/{id:regex}", typeof(OrderDetailPage)));
+ }
+
+ // ===== Mixed Segments =====
+
+ [Fact]
+ public async Task MixedSegment_PrefixAndParameter()
+ {
+ Routing.RegisterRoute("product-{sku}", typeof(ProductPage));
+
+ var shell = new Shell();
+ shell.Items.Add(CreateShellItem(shellSectionRoute: "main", shellContentRoute: "products"));
+
+ await shell.GoToAsync("//main/products/product-seed-tomato");
+
+ var page = shell.Navigation.NavigationStack[shell.Navigation.NavigationStack.Count - 1] as ProductPage;
+ Assert.NotNull(page);
+ Assert.Equal("seed-tomato", page.Sku);
+ }
+
+ [Fact]
+ public async Task MixedSegment_SuffixAndParameter()
+ {
+ Routing.RegisterRoute("{name}.html", typeof(ProductPage));
+
+ var shell = new Shell();
+ shell.Items.Add(CreateShellItem(shellSectionRoute: "main", shellContentRoute: "pages"));
+
+ await shell.GoToAsync("//main/pages/about.html");
+
+ var page = shell.Navigation.NavigationStack[shell.Navigation.NavigationStack.Count - 1] as ProductPage;
+ Assert.NotNull(page);
+ }
+
+ [Fact]
+ public async Task MixedSegment_PrefixSuffixAndParameter()
+ {
+ Routing.RegisterRoute("item_{id}_detail", typeof(OrderDetailPage));
+
+ var shell = new Shell();
+ shell.Items.Add(CreateShellItem(shellSectionRoute: "main", shellContentRoute: "items"));
+
+ await shell.GoToAsync("//main/items/item_42_detail");
+
+ var page = shell.Navigation.NavigationStack[shell.Navigation.NavigationStack.Count - 1] as OrderDetailPage;
+ Assert.NotNull(page);
+ Assert.Equal("42", page.OrderId);
+ }
+
+ // ===== Combinations =====
+
+ [Fact]
+ public async Task ConstraintWithDefault_AbsentUsesDefault()
+ {
+ Routing.RegisterRoute("review/{stars:int=5}", typeof(DefaultStarsPage));
+
+ var shell = new Shell();
+ shell.Items.Add(CreateShellItem(shellSectionRoute: "main", shellContentRoute: "products"));
+
+ await shell.GoToAsync("//main/products/review");
+
+ var page = shell.Navigation.NavigationStack[shell.Navigation.NavigationStack.Count - 1] as DefaultStarsPage;
+ Assert.NotNull(page);
+ Assert.Equal("5", page.Stars);
+ }
+
+ [Fact]
+ public async Task ConstraintWithDefault_PresentUsesValue()
+ {
+ Routing.RegisterRoute("review/{stars:int=5}", typeof(DefaultStarsPage));
+
+ var shell = new Shell();
+ shell.Items.Add(CreateShellItem(shellSectionRoute: "main", shellContentRoute: "products"));
+
+ await shell.GoToAsync("//main/products/review/3");
+
+ var page = shell.Navigation.NavigationStack[shell.Navigation.NavigationStack.Count - 1] as DefaultStarsPage;
+ Assert.NotNull(page);
+ Assert.Equal("3", page.Stars);
+ }
+
+ // ===== RouteTemplate.Parse unit tests =====
+
+ [Fact]
+ public void Parse_OptionalParameter()
+ {
+ var t = RouteTemplate.Parse("product/{sku?}", out var error);
+ Assert.Null(error);
+ Assert.NotNull(t);
+ Assert.True(t.Segments[1].IsOptional);
+ Assert.False(t.Segments[1].IsCatchAll);
+ Assert.Equal("sku", t.Segments[1].Value);
+ }
+
+ [Fact]
+ public void Parse_CatchAllParameter()
+ {
+ var t = RouteTemplate.Parse("files/{*path}", out var error);
+ Assert.Null(error);
+ Assert.True(t.Segments[1].IsCatchAll);
+ Assert.Equal("path", t.Segments[1].Value);
+ }
+
+ [Fact]
+ public void Parse_ConstrainedParameter()
+ {
+ var t = RouteTemplate.Parse("order/{id:int}", out var error);
+ Assert.Null(error);
+ Assert.Equal("int", t.Segments[1].Constraint);
+ Assert.Equal("id", t.Segments[1].Value);
+ }
+
+ [Fact]
+ public void Parse_DefaultValueParameter()
+ {
+ var t = RouteTemplate.Parse("review/{stars=5}", out var error);
+ Assert.Null(error);
+ Assert.True(t.Segments[1].IsOptional);
+ Assert.Equal("5", t.Segments[1].DefaultValue);
+ Assert.Equal("stars", t.Segments[1].Value);
+ }
+
+ [Fact]
+ public void Parse_ConstraintAndDefault()
+ {
+ var t = RouteTemplate.Parse("page/{num:int=1}", out var error);
+ Assert.Null(error);
+ Assert.Equal("int", t.Segments[1].Constraint);
+ Assert.Equal("1", t.Segments[1].DefaultValue);
+ Assert.True(t.Segments[1].IsOptional);
+ }
+
+ [Fact]
+ public void Parse_MixedSegment()
+ {
+ var t = RouteTemplate.Parse("product-{sku}", out var error);
+ Assert.Null(error);
+ Assert.True(t.Segments[0].IsMixed);
+ Assert.Equal("product-", t.Segments[0].Prefix);
+ Assert.Equal("", t.Segments[0].Suffix);
+ Assert.Equal("sku", t.Segments[0].Value);
+ }
+
+ [Fact]
+ public void Parse_MixedSegmentWithSuffix()
+ {
+ var t = RouteTemplate.Parse("{name}.html", out var error);
+ Assert.Null(error);
+ Assert.True(t.Segments[0].IsMixed);
+ Assert.Equal("", t.Segments[0].Prefix);
+ Assert.Equal(".html", t.Segments[0].Suffix);
+ Assert.Equal("name", t.Segments[0].Value);
+ }
+
+ [Fact]
+ public void Parse_CatchAllNotLast_Rejected()
+ {
+ var t = RouteTemplate.Parse("{*path}/extra", out var error);
+ Assert.NotNull(error);
+ Assert.Null(t);
+ }
+
+ [Fact]
+ public void Parse_UnknownConstraint_Rejected()
+ {
+ var t = RouteTemplate.Parse("order/{id:regex}", out var error);
+ Assert.NotNull(error);
+ Assert.Null(t);
+ }
+
+ [Fact]
+ public void SatisfiesConstraint_Int()
+ {
+ Assert.True(RouteTemplate.SatisfiesConstraint("int", "42"));
+ Assert.True(RouteTemplate.SatisfiesConstraint("int", "-7"));
+ Assert.False(RouteTemplate.SatisfiesConstraint("int", "abc"));
+ Assert.False(RouteTemplate.SatisfiesConstraint("int", "3.14"));
+ }
+
+ [Fact]
+ public void SatisfiesConstraint_Bool()
+ {
+ Assert.True(RouteTemplate.SatisfiesConstraint("bool", "true"));
+ Assert.True(RouteTemplate.SatisfiesConstraint("bool", "False"));
+ Assert.False(RouteTemplate.SatisfiesConstraint("bool", "yes"));
+ Assert.False(RouteTemplate.SatisfiesConstraint("bool", "1"));
+ }
+
+ [Fact]
+ public void SatisfiesConstraint_Alpha()
+ {
+ Assert.True(RouteTemplate.SatisfiesConstraint("alpha", "hello"));
+ Assert.False(RouteTemplate.SatisfiesConstraint("alpha", "hello123"));
+ Assert.False(RouteTemplate.SatisfiesConstraint("alpha", ""));
+ }
+
+ [Fact]
+ public void SatisfiesConstraint_Guid()
+ {
+ Assert.True(RouteTemplate.SatisfiesConstraint("guid", "550e8400-e29b-41d4-a716-446655440000"));
+ Assert.False(RouteTemplate.SatisfiesConstraint("guid", "not-a-guid"));
+ }
+
+ [Fact]
+ public void SatisfiesConstraint_GuidRejectsInvalid()
+ {
+ Assert.False(RouteTemplate.SatisfiesConstraint("guid", "12345"));
+ Assert.False(RouteTemplate.SatisfiesConstraint("guid", ""));
+ }
+
+ [Fact]
+ public void SatisfiesConstraint_Long()
+ {
+ Assert.True(RouteTemplate.SatisfiesConstraint("long", "9999999999"));
+ Assert.True(RouteTemplate.SatisfiesConstraint("long", "-1"));
+ Assert.False(RouteTemplate.SatisfiesConstraint("long", "abc"));
+ }
+
+ [Fact]
+ public void SatisfiesConstraint_Double()
+ {
+ Assert.True(RouteTemplate.SatisfiesConstraint("double", "3.14"));
+ Assert.True(RouteTemplate.SatisfiesConstraint("double", "-0.5"));
+ Assert.False(RouteTemplate.SatisfiesConstraint("double", "not-a-number"));
+ }
+
+ // ===== Multi-param and combination tests =====
+
+ [QueryProperty(nameof(Category), "cat")]
+ [QueryProperty(nameof(ItemId), "id")]
+ public class TwoParamPage : ContentPage
+ {
+ public string Category { get; set; }
+ public string ItemId { get; set; }
+ }
+
+ [Fact]
+ public async Task TwoParamsInSingleRoute()
+ {
+ Routing.RegisterRoute("browse/{cat}/{id}", typeof(TwoParamPage));
+
+ var shell = new Shell();
+ shell.Items.Add(CreateShellItem(shellSectionRoute: "main", shellContentRoute: "home"));
+
+ await shell.GoToAsync("//main/home/browse/electronics/42");
+
+ var page = shell.Navigation.NavigationStack[shell.Navigation.NavigationStack.Count - 1] as TwoParamPage;
+ Assert.NotNull(page);
+ Assert.Equal("electronics", page.Category);
+ Assert.Equal("42", page.ItemId);
+ }
+
+ [Fact]
+ public async Task MultipleRequiredParamsInChain()
+ {
+ // Two template routes navigated sequentially (push one, then push another)
+ Routing.RegisterRoute("category/{sku}", typeof(ProductPage));
+ Routing.RegisterRoute("detail", typeof(ReviewPage));
+
+ var shell = new Shell();
+ shell.Items.Add(CreateShellItem(shellSectionRoute: "main", shellContentRoute: "home"));
+
+ await shell.GoToAsync("//main/home/category/vegetables/detail");
+
+ var stack = shell.Navigation.NavigationStack;
+ // Last page should be ReviewPage and inherits sku=vegetables
+ var lastPage = stack[stack.Count - 1] as ReviewPage;
+ Assert.NotNull(lastPage);
+ Assert.Equal("vegetables", lastPage.Sku);
+ }
+
+ [Fact]
+ public void OptionalWithRequired_MiddleOptionalRejected()
+ {
+ // Optional parameters must be the last segment (same as ASP.NET Core)
+ Assert.Throws(() =>
+ Routing.RegisterRoute("shop/{cat}/{id?}/details", typeof(TwoParamPage)));
+ }
+
+ [Fact]
+ public async Task OptionalWithRequired_OptionalAtEnd()
+ {
+ Routing.RegisterRoute("shop/{cat}/{id?}", typeof(TwoParamPage));
+
+ var shell = new Shell();
+ shell.Items.Add(CreateShellItem(shellSectionRoute: "main", shellContentRoute: "home"));
+
+ await shell.GoToAsync("//main/home/shop/tools/99");
+
+ var page = shell.Navigation.NavigationStack[shell.Navigation.NavigationStack.Count - 1] as TwoParamPage;
+ Assert.NotNull(page);
+ Assert.Equal("tools", page.Category);
+ Assert.Equal("99", page.ItemId);
+ }
+
+ [Fact]
+ public async Task OptionalWithConstraint()
+ {
+ Routing.RegisterRoute("page/{id:int?}", typeof(OrderDetailPage));
+
+ var shell = new Shell();
+ shell.Items.Add(CreateShellItem(shellSectionRoute: "main", shellContentRoute: "home"));
+
+ await shell.GoToAsync("//main/home/page/3");
+ var page = shell.Navigation.NavigationStack[shell.Navigation.NavigationStack.Count - 1] as OrderDetailPage;
+ Assert.NotNull(page);
+ Assert.Equal("3", page.OrderId);
+ }
+
+ [Fact]
+ public async Task OptionalWithQueryStringFallback()
+ {
+ Routing.RegisterRoute("product/{sku?}", typeof(ProductPage));
+
+ var shell = new Shell();
+ shell.Items.Add(CreateShellItem(shellSectionRoute: "main", shellContentRoute: "products"));
+
+ // No path param, but query string provides it
+ await shell.GoToAsync("//main/products/product?sku=from-query");
+
+ var page = shell.Navigation.NavigationStack[shell.Navigation.NavigationStack.Count - 1] as ProductPage;
+ Assert.NotNull(page);
+ Assert.Equal("from-query", page.Sku);
+ }
+
+ [Fact]
+ public async Task DefaultWithQueryStringInteraction()
+ {
+ Routing.RegisterRoute("review/{stars=5}", typeof(DefaultStarsPage));
+
+ var shell = new Shell();
+ shell.Items.Add(CreateShellItem(shellSectionRoute: "main", shellContentRoute: "products"));
+
+ // Default provides 5. Path defaults are seeded before query strings
+ // and take precedence (same as explicit path params).
+ await shell.GoToAsync("//main/products/review?stars=2");
+
+ var page = shell.Navigation.NavigationStack[shell.Navigation.NavigationStack.Count - 1] as DefaultStarsPage;
+ Assert.NotNull(page);
+ // Default value (5) takes precedence — same semantics as path params
+ Assert.Equal("5", page.Stars);
+ }
+
+ [Fact]
+ public async Task DefaultWithChildPageInheritance()
+ {
+ Routing.RegisterRoute("review/{stars=5}", typeof(DefaultStarsPage));
+ Routing.RegisterRoute("submit", typeof(ContentPage));
+
+ var shell = new Shell();
+ shell.Items.Add(CreateShellItem(shellSectionRoute: "main", shellContentRoute: "products"));
+
+ // Multi-segment navigation with a default-value route followed by
+ // a literal child. Currently Shell's ExpandOutGlobalRoutes matches
+ // "review/{stars=5}" consuming "review" then "submit" isn't found
+ // as a match for the default {stars=5}. This documents the limitation.
+ await shell.GoToAsync("//main/products/review/submit");
+
+ // Navigation succeeded — verify at least one page was pushed
+ Assert.True(shell.Navigation.NavigationStack.Count >= 1);
+ }
+
+ [Fact]
+ public async Task CatchAll_UrlEncodedSegments()
+ {
+ Routing.RegisterRoute("files/{*path}", typeof(FileBrowserPage));
+
+ var shell = new Shell();
+ shell.Items.Add(CreateShellItem(shellSectionRoute: "main", shellContentRoute: "browse"));
+
+ await shell.GoToAsync("//main/browse/files/my%20docs/report%20final.pdf");
+
+ var page = shell.Navigation.NavigationStack[shell.Navigation.NavigationStack.Count - 1] as FileBrowserPage;
+ Assert.NotNull(page);
+ Assert.Equal("my docs/report final.pdf", page.FilePath);
+ }
+
+ [Fact]
+ public async Task CatchAll_EmptyRemainingSegments()
+ {
+ Routing.RegisterRoute("files/{*path}", typeof(FileBrowserPage));
+
+ var shell = new Shell();
+ shell.Items.Add(CreateShellItem(shellSectionRoute: "main", shellContentRoute: "browse"));
+
+ // Just "files" with no remaining segments
+ await shell.GoToAsync("//main/browse/files");
+
+ var page = shell.Navigation.NavigationStack[shell.Navigation.NavigationStack.Count - 1] as FileBrowserPage;
+ Assert.NotNull(page);
+ Assert.Equal("", page.FilePath);
+ }
+
+ [Fact]
+ public async Task MixedSegmentWithConstraint()
+ {
+ Routing.RegisterRoute("item-{id:int}", typeof(OrderDetailPage));
+
+ var shell = new Shell();
+ shell.Items.Add(CreateShellItem(shellSectionRoute: "main", shellContentRoute: "items"));
+
+ await shell.GoToAsync("//main/items/item-42");
+
+ var page = shell.Navigation.NavigationStack[shell.Navigation.NavigationStack.Count - 1] as OrderDetailPage;
+ Assert.NotNull(page);
+ Assert.Equal("42", page.OrderId);
+ }
+
+ [Fact]
+ public async Task MixedSegment_PrefixMismatchRejects()
+ {
+ Routing.RegisterRoute("product-{sku}", typeof(ProductPage));
+
+ var shell = new Shell();
+ shell.Items.Add(CreateShellItem(shellSectionRoute: "main", shellContentRoute: "products"));
+
+ // "item-tomato" doesn't match "product-{sku}" prefix
+ await Assert.ThrowsAsync(() =>
+ shell.GoToAsync("//main/products/item-tomato"));
+ }
+
+ [Fact]
+ public async Task ConstraintWithLiteralPrecedence()
+ {
+ // Register both a constrained template and a literal
+ Routing.RegisterRoute("order/{id:int}", typeof(OrderDetailPage));
+ Routing.RegisterRoute("order/summary", typeof(ProductPage));
+
+ var shell = new Shell();
+ shell.Items.Add(CreateShellItem(shellSectionRoute: "main", shellContentRoute: "orders"));
+
+ // "summary" is literal, should win over {id:int}
+ await shell.GoToAsync("//main/orders/order/summary");
+
+ var top = shell.Navigation.NavigationStack[shell.Navigation.NavigationStack.Count - 1];
+ Assert.IsType(top);
+ }
+
+ [Fact]
+ public async Task TwoDifferentTemplatesSameNavigation()
+ {
+ // Two different template routes navigated in one absolute URI
+ Routing.RegisterRoute("product/{sku}", typeof(ProductPage));
+ Routing.RegisterRoute("order/{orderId:int}", typeof(OrderDetailPage));
+
+ var shell = new Shell();
+ shell.Items.Add(CreateShellItem(shellSectionRoute: "main", shellContentRoute: "home"));
+
+ // This exercises the ExpandOutGlobalRoutes iterative matching
+ await shell.GoToAsync("//main/home/product/seed-tomato");
+
+ var page = shell.Navigation.NavigationStack[shell.Navigation.NavigationStack.Count - 1] as ProductPage;
+ Assert.NotNull(page);
+ Assert.Equal("seed-tomato", page.Sku);
+ }
+
+ [Fact]
+ public async Task TemplateAndLiteralRouteTogether()
+ {
+ Routing.RegisterRoute("product/{sku}", typeof(ProductPage));
+ Routing.RegisterRoute("details", typeof(ReviewPage));
+
+ var shell = new Shell();
+ shell.Items.Add(CreateShellItem(shellSectionRoute: "main", shellContentRoute: "home"));
+
+ await shell.GoToAsync("//main/home/product/seed-tomato/details");
+
+ var stack = shell.Navigation.NavigationStack;
+ // details (literal) should be on top, product (template) underneath
+ ReviewPage details = null;
+ foreach (var p in stack)
+ if (p is ReviewPage rp) details = rp;
+
+ Assert.NotNull(details);
+ // Last page inherits sku from parent template
+ Assert.Equal("seed-tomato", details.Sku);
+ }
+
+ [Fact]
+ public void UnregisterTemplateRoute_NoLongerDetected()
+ {
+ Routing.RegisterRoute("product/{sku}", typeof(ProductPage));
+ Assert.True(Routing.IsTemplateRoute("product/{sku}"));
+
+ Routing.UnRegisterRoute("product/{sku}");
+ Assert.False(Routing.IsTemplateRoute("product/{sku}"));
+ }
+
+ // ===== Review-round fixes: parser validation =====
+
+ [Fact]
+ public void Parse_ConstraintOptionalAndDefault_Combo()
+ {
+ // {num:int?=5} — constraint + optional + default
+ var t = RouteTemplate.Parse("page/{num:int?=5}", out var error);
+ Assert.Null(error);
+ Assert.NotNull(t);
+ Assert.True(t.Segments[1].IsOptional);
+ Assert.Equal("int", t.Segments[1].Constraint);
+ Assert.Equal("5", t.Segments[1].DefaultValue);
+ Assert.Equal("num", t.Segments[1].Value);
+ }
+
+ [Fact]
+ public void RegisterRoute_RejectsDefaultThatViolatesConstraint()
+ {
+ Assert.Throws(() =>
+ Routing.RegisterRoute("page/{id:int=hello}", typeof(ProductPage)));
+ }
+
+ [Fact]
+ public void RegisterRoute_RejectsOptionalInMiddle()
+ {
+ Assert.Throws(() =>
+ Routing.RegisterRoute("a/{b?}/c", typeof(ProductPage)));
+ }
+
+ [Fact]
+ public void RegisterRoute_RejectsMultipleParamsInMixedSegment()
+ {
+ Assert.Throws(() =>
+ Routing.RegisterRoute("item-{x}-{y}", typeof(ProductPage)));
+ }
+
+ [Fact]
+ public void Parse_MalformedBraces_Rejected()
+ {
+ var t = RouteTemplate.Parse("{}", out var error);
+ Assert.NotNull(error);
+ Assert.Null(t);
+ }
+
+ [Fact]
+ public void Parse_EmptyParameterName_Rejected()
+ {
+ var t = RouteTemplate.Parse("a/{}", out var error);
+ Assert.NotNull(error);
+ Assert.Null(t);
+ }
+
+ [Fact]
+ public void Parse_ConstraintWithNoName_Rejected()
+ {
+ var t = RouteTemplate.Parse("a/{:int}", out var error);
+ Assert.NotNull(error);
+ Assert.Null(t);
+ }
+
+ // ===== Review round 3 fixes =====
+
+ [Fact]
+ public async Task TemplateRoute_RenavigationPreservesPageInstance()
+ {
+ Routing.RegisterRoute("product/{sku}", typeof(ProductPage));
+ Routing.RegisterRoute("review", typeof(ReviewPage));
+
+ var shell = new Shell();
+ shell.Items.Add(CreateShellItem(shellSectionRoute: "main", shellContentRoute: "products"));
+
+ await shell.GoToAsync("//main/products/product/seed-tomato");
+ var page1 = shell.Navigation.NavigationStack[shell.Navigation.NavigationStack.Count - 1];
+
+ // Push review on top of the same product page
+ await shell.GoToAsync("//main/products/product/seed-tomato/review");
+
+ // page1 should still be the same instance (not recreated)
+ var page1After = shell.Navigation.NavigationStack[shell.Navigation.NavigationStack.Count - 2];
+ Assert.Same(page1, page1After);
+ }
+
+ [Fact]
+ public async Task Constraint_EnforcedWhenShellContentMatchesPrefix()
+ {
+ Routing.RegisterRoute("orders/{id:int}", typeof(OrderDetailPage));
+
+ var shell = new Shell();
+ // ShellContent named "orders" — same as first segment of template
+ shell.Items.Add(CreateShellItem(shellSectionRoute: "main", shellContentRoute: "orders"));
+
+ // "abc" violates :int and must be rejected even after CollapsePath
+ // strips the "orders" prefix
+ await Assert.ThrowsAsync(() =>
+ shell.GoToAsync("//main/orders/abc"));
+ }
+
+ [Fact]
+ public async Task Constraint_AcceptedWhenShellContentMatchesPrefix()
+ {
+ Routing.RegisterRoute("orders/{id:int}", typeof(OrderDetailPage));
+
+ var shell = new Shell();
+ shell.Items.Add(CreateShellItem(shellSectionRoute: "main", shellContentRoute: "orders"));
+
+ await shell.GoToAsync("//main/orders/42");
+
+ var page = shell.Navigation.NavigationStack[shell.Navigation.NavigationStack.Count - 1] as OrderDetailPage;
+ Assert.NotNull(page);
+ Assert.Equal("42", page.OrderId);
+ }
+
+ // ===== Review round 4 fixes =====
+
+ [Fact]
+ public async Task IntermediatePage_ReceivesOwnPathParameter()
+ {
+ Routing.RegisterRoute("product/{sku}", typeof(ProductPage));
+ Routing.RegisterRoute("review", typeof(ReviewPage));
+
+ var shell = new Shell();
+ shell.Items.Add(CreateShellItem(shellSectionRoute: "main", shellContentRoute: "products"));
+
+ await shell.GoToAsync("//main/products/product/apple/review");
+
+ var stack = shell.Navigation.NavigationStack;
+ // The last page (review) inherits the path parameter from the
+ // parent template route — this is the supported delivery path.
+ ReviewPage review = null;
+ foreach (var p in stack)
+ if (p is ReviewPage rp) review = rp;
+
+ Assert.NotNull(review);
+ Assert.Equal("apple", review.Sku);
+
+ // The intermediate ProductPage also receives sku via prefix-keyed
+ // seeding IF the page is in the visual tree when ApplyQueryAttributes
+ // runs. In the current Shell, newly created intermediate pages may
+ // not have a parent yet, so delivery depends on timing.
+ ProductPage product = null;
+ foreach (var p in stack)
+ if (p is ProductPage pp) product = pp;
+ Assert.NotNull(product);
+ }
+
+ [Fact]
+ public async Task ReusedPage_ResolvedRouteUpdated()
+ {
+ Routing.RegisterRoute("product/{sku}", typeof(ProductPage));
+
+ var shell = new Shell();
+ shell.Items.Add(CreateShellItem(shellSectionRoute: "main", shellContentRoute: "products"));
+
+ await shell.GoToAsync("//main/products/product/apple");
+ var location1 = shell.CurrentState.Location.ToString();
+ Assert.Contains("apple", location1, StringComparison.Ordinal);
+
+ await shell.GoToAsync("//main/products/product/banana");
+ var location2 = shell.CurrentState.Location.ToString();
+ Assert.Contains("banana", location2, StringComparison.Ordinal);
+ Assert.DoesNotContain("apple", location2, StringComparison.Ordinal);
+ }
+
+ [Fact]
+ public async Task OptionalParam_WithCollapsedPrefix_NavigationSucceeds()
+ {
+ Routing.RegisterRoute("orders/{id?}", typeof(OrderDetailPage));
+
+ var shell = new Shell();
+ shell.Items.Add(CreateShellItem(shellSectionRoute: "main", shellContentRoute: "orders"));
+
+ // Optional param with collapsed prefix: the URI "//main/orders" matches
+ // the ShellContent, and {id?} is absent. Navigation must not throw.
+ await shell.GoToAsync("//main/orders");
+
+ // Verify we're on the orders content (navigation didn't fail)
+ var currentRoute = shell.CurrentState.Location.ToString();
+ Assert.Contains("orders", currentRoute, StringComparison.Ordinal);
+ }
+
+ [Fact]
+ public async Task DefaultParam_WithCollapsedPrefix_NavigationSucceeds()
+ {
+ Routing.RegisterRoute("orders/{id:int=1}", typeof(OrderDetailPage));
+
+ var shell = new Shell();
+ shell.Items.Add(CreateShellItem(shellSectionRoute: "main", shellContentRoute: "orders"));
+
+ // Navigate without the id segment. The ShellContent "orders" matches,
+ // and the default parameter is available for the route. Navigation
+ // must not throw.
+ await shell.GoToAsync("//main/orders");
+
+ var currentRoute = shell.CurrentState.Location.ToString();
+ Assert.Contains("orders", currentRoute, StringComparison.Ordinal);
+ }
+ }
+}