Skip to content

feat: Shell route templates with {param} path parameters#35110

Open
mattleibow wants to merge 10 commits into
mainfrom
mattleibow/shell-route-templates-spec
Open

feat: Shell route templates with {param} path parameters#35110
mattleibow wants to merge 10 commits into
mainfrom
mattleibow/shell-route-templates-spec

Conversation

@mattleibow
Copy link
Copy Markdown
Member

@mattleibow mattleibow commented Apr 23, 2026

Note

Are you waiting for the changes in this PR to be merged?
It would be very helpful if you could test the resulting artifacts from this PR and let us know in a comment if this change resolves your issue. Thank you!

Description

Adds route template support to Shell navigation, enabling {param} path parameters inspired by ASP.NET Core / Blazor routing. This is additive and non-breaking — existing literal routes work identically, and the feature is completely dormant unless you register a route containing {.

Fixes #35107


How It Works

Registration

Register a route with {param} segments. Everything inside braces becomes a path parameter:

Routing.RegisterRoute("product/{sku}", typeof(ProductDetailPage));
Routing.RegisterRoute("order/{id:int}", typeof(OrderDetailPage));
Routing.RegisterRoute("review/{stars=5}", typeof(ReviewPage));

XAML works too — { is safe because XAML only interprets markup extensions when { is the first character of the attribute value:

<ShellContent Route="product/{sku}" ContentTemplate="{DataTemplate local:ProductPage}" />

Navigation

Navigate with values in the path instead of query strings:

// Before (query string — still works)
await Shell.Current.GoToAsync("product?sku=seed-tomato");

// After (path parameter)
await Shell.Current.GoToAsync("//products/product/seed-tomato");

Parameter Delivery

Uses existing [QueryProperty] and IQueryAttributableno new attributes or interfaces needed:

[QueryProperty(nameof(Sku), "sku")]
public class ProductDetailPage : ContentPage
{
    public string Sku { get; set; }  // receives "seed-tomato"
}

Supported Syntax

Syntax What it does Example
{sku} Required parameter product/{sku} matches product/seed-tomato
{sku?} Optional (must be last) product/{sku?} matches product or product/seed-tomato
{stars=5} Default value when absent review/{stars=5} → stars is "5" if you navigate to just review
{id:int} Constraint — rejects non-matching values order/{id:int} rejects order/abc
{*path} Catch-all — captures remaining segments (must be last) files/{*path}docs/report.pdf
product-{sku} Mixed — literal around parameter product-seed-tomato → sku = "seed-tomato"
{id:int=1} Combined constraint + default Validates when present, default when absent

Constraints: int, long, double, bool, guid, alpha

Route Matching Precedence

Literals always win over templates (same as ASP.NET Core). Implemented via two-pass matching in FindAndAddSegmentMatch.

Path vs Query String

Path parameters take precedence over query strings with the same name. Both can coexist.


How the Implementation Works

Route Registration (Routing.cs)

When RegisterRoute receives a route containing {, it parses a RouteTemplate and stores it in a parallel s_routeTemplates dictionary alongside the existing s_routes entry.

Route Matching (ShellUriHandler.cs, RouteRequestBuilder.cs)

FindAndAddSegmentMatch uses a two-pass approach: pass 0 checks literal routes only (unchanged), pass 1 checks template routes. GetNextSegmentMatch walks template segments in parallel with URI segments, checking type (literal, parameter, optional, catch-all, mixed) and applying constraints. The RouteTemplate is passed directly from the caller to handle CollapsePath prefix stripping.

Parameter Delivery (ShellNavigationManager.cs)

Path parameters are seeded into ShellRouteParameters before SetQueryStringParameters (path wins). Route-prefixed keys are also seeded for intermediate page delivery through Shell's prefix-based ApplyQueryAttributes filtering.

CurrentState.Location (ShellSection.cs, ShellNavigationManager.cs)

Resolved URIs are stored in a separate internal ResolvedRouteProperty on the page. GetNavigationState reads GetResolvedRoute(page) ?? GetRoute(page). The page's Route keeps the template key for factory lookups and stack-reuse comparisons.


Migration

None required. To opt in, change your route string to include {param}. Everything else stays the same.


Behavior Changes

None for existing apps. All new types are internal. The two-pass matching is a no-op for routes without {. CollapsePath has one new condition that only triggers for {-prefixed segments.


Known Limitations

  • Relative navigation with template routes not yet supported — use absolute URIs
  • All-template routes (no literal prefix) can cause ambiguous matches
  • Optional/default with collapsed prefix: navigation succeeds but defaults may not reach the page via prefix filtering

@github-actions
Copy link
Copy Markdown
Contributor

github-actions Bot commented Apr 23, 2026

🚀 Dogfood this PR with:

⚠️ WARNING: Do not do this without first carefully reviewing the code of this PR to satisfy yourself it is safe.

curl -fsSL https://raw.githubusercontent.com/dotnet/maui/main/eng/scripts/get-maui-pr.sh | bash -s -- 35110

Or

  • Run remotely in PowerShell:
iex "& { $(irm https://raw.githubusercontent.com/dotnet/maui/main/eng/scripts/get-maui-pr.ps1) } 35110"

MauiBot
MauiBot previously requested changes May 3, 2026
Copy link
Copy Markdown
Collaborator

@MauiBot MauiBot left a comment

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

🤖 Automated review — alternative fix proposed

The expert-reviewer evaluation compared the PR fix against #2 automatically generated candidates and selected try-fix-2 as the strongest fix.

Why: try-fix-2 addresses all code-review warnings (alpha ASCII parity, int/long InvariantCulture, structural conflict detection) while also replacing the PR two-pass matching loop with a cleaner scoring-based algorithm. It adds 8 new tests including back-navigation coverage, passing 89/89 template tests and 390/390 total Shell tests.

Please consider applying the candidate diff below (or use it as guidance). Once you push an update, this workflow will re-trigger and re-evaluate.

Candidate diff (`try-fix-2`)
diff --git a/src/Controls/src/Core/Routing.cs b/src/Controls/src/Core/Routing.cs
index f19062cd09..d9d0ee989c 100644
--- a/src/Controls/src/Core/Routing.cs
+++ b/src/Controls/src/Core/Routing.cs
@@ -14,6 +14,9 @@ namespace Microsoft.Maui.Controls
 		static Dictionary<string, Page> s_implicitPageRoutes = new(StringComparer.Ordinal);
 		static HashSet<string> s_routeKeys;
 
+		// Parsed templates for routes that contain "{param}" segments.
+		static Dictionary<string, RouteTemplate> s_routeTemplates = new(StringComparer.Ordinal);
+
 		const string ImplicitPrefix = "IMPL_";
 		const string DefaultPrefix = "D_FAULT_";
 		internal const string PathSeparator = "/";
@@ -114,12 +117,38 @@ namespace Microsoft.Maui.Controls
 		{
 			s_implicitPageRoutes.Clear();
 			s_routes.Clear();
+			s_routeTemplates.Clear();
 			s_routeKeys = null;
 		}
 
+		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);
+		}
+
 		/// <summary>Bindable property for attached property <c>Route</c>.</summary>
 		public static readonly BindableProperty RouteProperty = CreateRouteProperty();
 
+		internal static readonly BindableProperty ResolvedRouteProperty = CreateResolvedRouteProperty();
+
+		[UnconditionalSuppressMessage("ReflectionAnalysis", "IL2111:ReflectionToDynamicallyAccessedMembers",
+			Justification = "Same as RouteProperty — BindableProperty only needs Get* methods.")]
+		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 "
@@ -217,16 +246,124 @@ namespace Microsoft.Maui.Controls
 				route = FormatRoute(route);
 			ValidateRoute(route, factory);
 
+			// Parse template first so we can run conflict detection before mutating state.
+			// This ensures s_routes and s_routeTemplates stay consistent if an exception is thrown.
+			RouteTemplate newTemplate = null;
+			if (RouteTemplate.ContainsTemplateSyntax(route))
+			{
+				newTemplate = RouteTemplate.Parse(route, out var error);
+				if (newTemplate == null)
+					throw new ArgumentException(error, nameof(route));
+
+				if (newTemplate.HasParameters && !s_routeTemplates.ContainsKey(route))
+				{
+					// Conflict detection: reject templates that are structurally ambiguous with
+					// already-registered templates. Two templates conflict when they have the
+					// same segment count and each corresponding position is either the same
+					// literal value or both parameters that can accept overlapping values.
+					// Without this check the winning match is registration-order-dependent.
+					foreach (var kvp in s_routeTemplates)
+					{
+						if (TemplatesStructurallyConflict(newTemplate, kvp.Value))
+						{
+							throw new ArgumentException(
+								$"Route template \"{route}\" has the same URI-matching structure as the " +
+								$"already registered template \"{kvp.Key}\". Both would match the same URIs, " +
+								"producing ordering-dependent results.",
+								nameof(route));
+						}
+					}
+				}
+			}
+
+			// Mutation happens after all validation — keep state consistent.
 			s_routes[route] = factory;
+			if (newTemplate?.HasParameters == true)
+				s_routeTemplates[route] = newTemplate;
+
 			s_routeKeys = null;
 		}
 
+		// Returns true when two templates would ambiguously match exactly the same set of
+		// URIs: same segment count, each literal-pair must be identical, and each
+		// parameter-pair is treated as a potential conflict unless their constraints are
+		// known to be mutually exclusive (e.g. :int vs :alpha never match the same string).
+		static bool TemplatesStructurallyConflict(RouteTemplate a, RouteTemplate b)
+		{
+			var segsA = a.Segments;
+			var segsB = b.Segments;
+
+			if (segsA.Count != segsB.Count)
+				return false;
+
+			for (int i = 0; i < segsA.Count; i++)
+			{
+				var sa = segsA[i];
+				var sb = segsB[i];
+
+				// One segment is literal and the other is a parameter — different shape
+				if (sa.IsParameter != sb.IsParameter)
+					return false;
+
+				// Both literal segments — must have same text to continue checking
+				if (!sa.IsParameter)
+				{
+					if (!string.Equals(sa.Value, sb.Value, StringComparison.Ordinal))
+						return false;
+					continue;
+				}
+
+				// Both parameter segments — check mixed prefix/suffix first
+				if (sa.IsMixed || sb.IsMixed)
+				{
+					// Mixed segments only conflict if their literal prefix AND suffix match
+					if (!string.Equals(sa.Prefix, sb.Prefix, StringComparison.Ordinal)
+						|| !string.Equals(sa.Suffix, sb.Suffix, StringComparison.Ordinal))
+						return false;
+					// Same prefix/suffix: the param portion can overlap — fall through
+				}
+
+				// Two parameters at the same position — conflict unless their constraints
+				// are known to be mutually exclusive (e.g. :int and :alpha never both match "42").
+				if (ConstraintsAreMutuallyExclusive(sa.Constraint, sb.Constraint))
+					return false;
+			}
+
+			return true;
+		}
+
+		// Returns true for well-known constraint pairs where no string value can satisfy both.
+		static bool ConstraintsAreMutuallyExclusive(string a, string b)
+		{
+			if (a == null || b == null)
+				return false; // unconstrained overlaps everything
+			if (string.Equals(a, b, StringComparison.Ordinal))
+				return false; // same constraint always overlaps
+
+			// Normalize order for symmetric lookup
+			if (string.Compare(a, b, StringComparison.Ordinal) > 0)
+			{
+				var tmp = a;
+				a = b;
+				b = tmp;
+			}
+
+			// Mutually exclusive pairs (no string satisfies both constraints):
+			//   alpha  ↔  double, guid, int, long   (pure letters vs numeric/UUID)
+			//   bool   ↔  double, guid, int, long   (only "true"/"false" vs numeric/UUID)
+			//   guid   ↔  int, long                 (UUID format vs bare integer)
+			return (a == "alpha" && (b == "double" || b == "guid" || b == "int" || b == "long"))
+				|| (a == "bool"  && (b == "double" || b == "guid" || b == "int" || b == "long"))
+				|| (a == "guid"  && (b == "int"    || b == "long"));
+		}
+
 		/// <summary>Removes a previously registered route.</summary>
 		/// <param name="route">The route string to unregister.</param>
 		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 5e56d663dd..87560ccfe0 100644
--- a/src/Controls/src/Core/Shell/RequestDefinition.cs
+++ b/src/Controls/src/Core/Shell/RequestDefinition.cs
@@ -14,6 +14,8 @@ namespace Microsoft.Maui.Controls
 			Section = theWinningRoute.Section ?? Item?.CurrentItem;
 			Content = theWinningRoute.Content ?? Section?.CurrentItem;
 			GlobalRoutes = theWinningRoute.GlobalRouteMatches;
+			ResolvedGlobalRoutes = theWinningRoute.ResolvedGlobalRoutes;
+			PathParameters = theWinningRoute.PathParameters;
 
 			List<String> builder = new List<string>();
 			if (Item?.Route != null)
@@ -26,7 +28,16 @@ namespace Microsoft.Maui.Controls
 				builder.Add(Content?.Route);
 
 			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 +58,7 @@ namespace Microsoft.Maui.Controls
 		public ShellSection Section { get; }
 		public ShellContent Content { get; }
 		public List<string> GlobalRoutes { get; }
+		public List<string> ResolvedGlobalRoutes { get; }
+		public IReadOnlyDictionary<string, string> PathParameters { get; }
 	}
 }
diff --git a/src/Controls/src/Core/Shell/RouteRequestBuilder.cs b/src/Controls/src/Core/Shell/RouteRequestBuilder.cs
index 61ce545481..be3a9b6dfe 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<string> _globalRouteMatches = new List<string>();
+		readonly List<string> _resolvedGlobalRoutes = new List<string>();
 		readonly List<string> _matchedSegments = new List<string>();
 		readonly List<string> _fullSegments = new List<string>();
 		readonly List<string> _allSegments = null;
+		readonly Dictionary<string, string> _pathParameters = new Dictionary<string, string>(StringComparer.Ordinal);
 		readonly static string _uriSeparator = "/";
 
 		public Shell Shell { get; private set; }
@@ -42,6 +44,9 @@ namespace Microsoft.Maui.Controls
 			_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,26 @@ namespace Microsoft.Maui.Controls
 		}
 
 		public void AddGlobalRoute(string routeName, string segment)
+		{
+			AddGlobalRoute(routeName, segment, null);
+		}
+
+		public void AddGlobalRoute(string routeName, string segment, IDictionary<string, string> 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 +121,10 @@ namespace Microsoft.Maui.Controls
 			{
 				case ShellUriHandler.GlobalRouteItem globalRoute:
 					if (globalRoute.IsFinished)
+					{
 						_globalRouteMatches.Add(globalRoute.SourceRoute);
+						_resolvedGlobalRoutes.Add(userSegment ?? shellSegment);
+					}
 					break;
 				case Shell shell:
 					if (shell == Shell)
@@ -162,10 +182,18 @@ namespace Microsoft.Maui.Controls
 		}
 
 		public string GetNextSegmentMatch(string matchMe)
+		{
+			return GetNextSegmentMatch(matchMe, null, null);
+		}
+
+		public string GetNextSegmentMatch(string matchMe, IDictionary<string, string> capturedParameters)
+		{
+			return GetNextSegmentMatch(matchMe, capturedParameters, null);
+		}
+
+		public string GetNextSegmentMatch(string matchMe, IDictionary<string, string> capturedParameters, RouteTemplate template)
 		{
 			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))
 			{
@@ -181,21 +209,140 @@ namespace Microsoft.Maui.Controls
 
 			List<string> matches = new List<string>();
 			List<string> currentSet = new List<string>(_matchedSegments);
+			Dictionary<string, string> localCaptures = null;
+
+			if (template == null)
+				Routing.TryGetRouteTemplate(matchMe, out template);
+			int templateIdx = 0;
 
-			foreach (var split in segmentsToMatch)
+			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);
-				if (next == split)
+				var seg = (template != null && templateIdx < template.Segments.Count)
+					? template.Segments[templateIdx]
+					: default;
+				templateIdx++;
+
+				if (next == split && !seg.IsParameter)
 				{
 					currentSet.Add(split);
 					matches.Add(split);
 				}
+				else if (seg.IsParameter && seg.IsCatchAll)
+				{
+					var remaining = new List<string>();
+					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<string, string>(StringComparer.Ordinal);
+					localCaptures[seg.Value] = catchValue;
+					si = segmentsToMatch.Count;
+					break;
+				}
+				else if (seg.IsParameter && seg.IsMixed && next != null)
+				{
+					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<string, string>(StringComparer.Ordinal);
+					localCaptures[seg.Value] = paramValue;
+				}
+				else if (seg.IsParameter && !seg.IsCatchAll && next != null)
+				{
+					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<string, string>(StringComparer.Ordinal);
+					localCaptures[seg.Value] = decoded;
+				}
+				else if (seg.IsParameter && seg.IsOptional && next == null)
+				{
+					if (seg.DefaultValue != null)
+					{
+						localCaptures ??= new Dictionary<string, string>(StringComparer.Ordinal);
+						localCaptures[seg.Value] = seg.DefaultValue;
+					}
+				}
+				else if (next != null && RouteTemplate.IsTemplateSegment(split))
+				{
+					var paramName = RouteTemplate.GetSegmentParameterName(split);
+					if (string.IsNullOrEmpty(paramName))
+						return String.Empty;
+
+					currentSet.Add(next);
+					matches.Add(next);
+
+					localCaptures ??= new Dictionary<string, string>(StringComparer.Ordinal);
+					localCaptures[paramName] = Uri.UnescapeDataString(next);
+				}
 				else
 				{
 					return String.Empty;
 				}
 			}
 
+			// Apply default values for trailing optional/default segments
+			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<string, string>(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);
 		}
 
@@ -268,8 +415,22 @@ namespace Microsoft.Maui.Controls
 
 		public bool IsFullMatch => _matchedSegments.Count == _allSegments.Count;
 		public List<string> GlobalRouteMatches => _globalRouteMatches;
+		public List<string> ResolvedGlobalRoutes => _resolvedGlobalRoutes;
 		public List<string> SegmentsMatched => _matchedSegments;
 		public IReadOnlyList<string> FullSegments => _fullSegments;
+		public IReadOnlyDictionary<string, string> PathParameters => _pathParameters;
+
+		public void MergePathParameters(IReadOnlyDictionary<string, string> 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
index 2df7cd59e3..245c965ce9 100644
--- a/src/Controls/src/Core/Shell/RouteTemplate.cs
+++ b/src/Controls/src/Core/Shell/RouteTemplate.cs
@@ -331,9 +331,11 @@ namespace Microsoft.Maui.Controls
 			switch (constraint)
 			{
 				case "int":
-					return int.TryParse(value, out _);
+					return int.TryParse(value, System.Globalization.NumberStyles.Integer,
+						System.Globalization.CultureInfo.InvariantCulture, out _);
 				case "long":
-					return long.TryParse(value, out _);
+					return long.TryParse(value, System.Globalization.NumberStyles.Integer,
+						System.Globalization.CultureInfo.InvariantCulture, out _);
 				case "double":
 					return double.TryParse(value, System.Globalization.NumberStyles.Any,
 						System.Globalization.CultureInfo.InvariantCulture, out _);
@@ -342,10 +344,15 @@ namespace Microsoft.Maui.Controls
 				case "guid":
 					return Guid.TryParse(value, out _);
 				case "alpha":
+					if (value.Length == 0)
+						return false;
 					for (int i = 0; i < value.Length; i++)
-						if (!char.IsLetter(value[i]))
+					{
+						char c = value[i];
+						if (!((c >= 'a' && c <= 'z') || (c >= 'A' && c <= 'Z')))
 							return false;
-					return value.Length > 0;
+					}
+					return true;
 				default:
 					return true; // unknown constraint, be permissive
 			}
diff --git a/src/Controls/src/Core/Shell/ShellNavigationManager.cs b/src/Controls/src/Core/Shell/ShellNavigationManager.cs
index efac0f54f2..80bc48237c 100644
--- a/src/Controls/src/Core/Shell/ShellNavigationManager.cs
+++ b/src/Controls/src/Core/Shell/ShellNavigationManager.cs
@@ -92,6 +92,42 @@ namespace Microsoft.Maui.Controls
 
 			var uri = navigationRequest.Request.FullUri;
 			var queryString = navigationRequest.Query;
+
+			// Seed path parameters from templated route segments before the
+			// query string so path params take precedence over query strings.
+			var pathParameters = navigationRequest.Request.PathParameters;
+			if (pathParameters != null && pathParameters.Count > 0)
+			{
+				foreach (var kvp in pathParameters)
+				{
+					if (!parameters.ContainsKey(kvp.Key))
+						parameters[kvp.Key] = kvp.Value;
+				}
+
+				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 +603,7 @@ namespace Microsoft.Maui.Controls
 						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 +613,11 @@ namespace Microsoft.Maui.Controls
 						{
 							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 119eac000c..6407fa1949 100644
--- a/src/Controls/src/Core/Shell/ShellSection.cs
+++ b/src/Controls/src/Core/Shell/ShellSection.cs
@@ -328,7 +328,7 @@ namespace Microsoft.Maui.Controls
 			return (ShellSection)(ShellContent)page;
 		}
 
-		async Task PrepareCurrentStackForBeingReplaced(ShellNavigationRequest request, ShellRouteParameters queryData, IServiceProvider services, bool? animate, List<string> globalRoutes, bool isRelativePopping)
+		async Task PrepareCurrentStackForBeingReplaced(ShellNavigationRequest request, ShellRouteParameters queryData, IServiceProvider services, bool? animate, List<string> globalRoutes, List<string> resolvedRoutes, bool isRelativePopping)
 		{
 			string route = "";
 			List<Page> navStack = null;
@@ -369,10 +369,13 @@ namespace Microsoft.Maui.Controls
 						// 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
+							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 +420,10 @@ namespace Microsoft.Maui.Controls
 							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 +513,7 @@ namespace Microsoft.Maui.Controls
 			}
 		}
 
-		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 +521,12 @@ namespace Microsoft.Maui.Controls
 				Application.Current?.FindMauiContext()?.CreateLogger<ShellSection>()?.LogWarning("Failed to Create Content For: {route}", route);
 			}
 
+			if (content != null && resolvedRoute != null && resolvedRoute != route
+				&& Routing.IsTemplateRoute(route))
+			{
+				Routing.SetResolvedRoute(content, resolvedRoute);
+			}
+
 			ShellNavigationManager.ApplyQueryAttributes(content, queryData, isLast, isPopping);
 			return content;
 		}
@@ -521,6 +534,7 @@ namespace Microsoft.Maui.Controls
 		internal async Task GoToAsync(ShellNavigationRequest request, ShellRouteParameters queryData, IServiceProvider services, bool? animate, bool isRelativePopping)
 		{
 			List<string> globalRoutes = request.Request.GlobalRoutes;
+			List<string> resolvedRoutes = request.Request.ResolvedGlobalRoutes;
 			if (globalRoutes == null || globalRoutes.Count == 0)
 			{
 				if (_navStack.Count == 2)
@@ -531,7 +545,7 @@ namespace Microsoft.Maui.Controls
 				return;
 			}
 
-			await PrepareCurrentStackForBeingReplaced(request, queryData, services, animate, globalRoutes, isRelativePopping);
+			await PrepareCurrentStackForBeingReplaced(request, queryData, services, animate, globalRoutes, resolvedRoutes, isRelativePopping);
 
 			List<Page> modalPageStacks = new List<Page>();
 			List<Page> nonModalPageStacks = new List<Page>();
@@ -549,7 +563,7 @@ namespace Microsoft.Maui.Controls
 			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 df08c0f61f..eff1d268e9 100644
--- a/src/Controls/src/Core/Shell/ShellUriHandler.cs
+++ b/src/Controls/src/Core/Shell/ShellUriHandler.cs
@@ -48,7 +48,7 @@ namespace Microsoft.Maui.Controls
 						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 @@ namespace Microsoft.Maui.Controls
 					continue;
 
 				var globalRouteMatch = globalRouteMatches[0];
+				bool pathParamsForwarded = false;
 
 				while (possibleRoutePath.NextSegment != null)
 				{
@@ -306,6 +307,12 @@ namespace Microsoft.Maui.Controls
 					possibleRoutePath.AddGlobalRoute(
 						globalRouteMatch.GlobalRouteMatches[matchIndex],
 						globalRouteMatch.SegmentsMatched[matchIndex]);
+
+					if (!pathParamsForwarded)
+					{
+						possibleRoutePath.MergePathParameters(globalRouteMatch.PathParameters);
+						pathParamsForwarded = true;
+					}
 				}
 			}
 
@@ -464,6 +471,8 @@ namespace Microsoft.Maui.Controls
 				for (int i = existingGlobalRoutes.Count; i < additionalRouteMatches.Count; i++)
 					requestBuilderWithNewSegments.AddGlobalRoute(additionalRouteMatches[i], segments[i - existingGlobalRoutes.Count]);
 
+				requestBuilderWithNewSegments.MergePathParameters(routeRequestBuilder.PathParameters);
+
 				pureGlobalRoutesMatch.Add(requestBuilderWithNewSegments);
 			}
 
@@ -512,7 +521,8 @@ namespace Microsoft.Maui.Controls
 				if (localRouteStack.Count <= walkBackCurrentStackIndex)
 					break;
 
-				if (paths[0] == localRouteStack[walkBackCurrentStackIndex])
+				if (paths[0] == localRouteStack[walkBackCurrentStackIndex]
+				|| RouteTemplate.IsTemplateSegment(paths[0]))
 				{
 					paths.RemoveAt(0);
 				}
@@ -527,11 +537,48 @@ namespace Microsoft.Maui.Controls
 			return paths;
 		}
 
+		// Specificity score: literal segments score higher than template parameters.
+		// This replaces the PR's two-pass literal-first approach with a single-pass
+		// scoring system that naturally prefers more-specific routes.
+		static int ComputeSpecificityScore(string routeKey)
+		{
+			if (!Routing.IsTemplateRoute(routeKey))
+				return int.MaxValue; // pure literal route = maximum specificity
+
+			if (!Routing.TryGetRouteTemplate(routeKey, out var template))
+				return int.MaxValue;
+
+			int score = 0;
+			foreach (var seg in template.Segments)
+			{
+				if (!seg.IsParameter)
+					score += 100; // literal segment
+				else if (seg.IsMixed)
+					score += 50; // mixed segment (has literal prefix/suffix)
+				else if (!string.IsNullOrEmpty(seg.Constraint))
+					score += 10; // constrained parameter
+				else
+					score += 1; // unconstrained parameter
+			}
+			return score;
+		}
+
 		static bool FindAndAddSegmentMatch(RouteRequestBuilder possibleRoutePath, HashSet<string> routeKeys)
 		{
-			// First search by collapsing global routes if user is registering routes like "route1/route2/route3"
+			// Single-pass scoring: evaluate ALL candidate routes (literal and template)
+			// in one iteration, tracking the best match by specificity score.
+			// Literal routes get int.MaxValue score, so they always win over templates.
+			string bestRouteKey = null;
+			string bestCollapsedMatch = null;
+			Dictionary<string, string> bestCapturedParams = null;
+			int bestScore = -1;
+			NodeLocation bestLeafNode = null;
+			RouteRequestBuilder bestLeafSearch = null;
+
 			foreach (var routeKey in routeKeys)
 			{
+				bool isTemplate = Routing.IsTemplateRoute(routeKey);
+
 				var collapsedRoutes = CollapsePath(routeKey, possibleRoutePath.SegmentsMatched, true);
 				var collapsedRoute = String.Join(_pathSeparator, collapsedRoutes);
 
@@ -544,15 +591,31 @@ namespace Microsoft.Maui.Controls
 						collapsedRoute = "//" + collapsedRoute;
 				}
 
-				string collapsedMatch = possibleRoutePath.GetNextSegmentMatch(collapsedRoute);
+				Dictionary<string, string> capturedParameters = isTemplate
+					? new Dictionary<string, string>(StringComparer.Ordinal)
+					: null;
+
+				RouteTemplate routeTemplate = null;
+				if (isTemplate)
+					Routing.TryGetRouteTemplate(routeKey, out routeTemplate);
+
+				string collapsedMatch = possibleRoutePath.GetNextSegmentMatch(collapsedRoute, capturedParameters, routeTemplate);
 				if (!String.IsNullOrWhiteSpace(collapsedMatch))
 				{
-					possibleRoutePath.AddGlobalRoute(routeKey, collapsedMatch);
-					return true;
+					int score = ComputeSpecificityScore(routeKey);
+					if (score > bestScore)
+					{
+						bestScore = score;
+						bestRouteKey = routeKey;
+						bestCollapsedMatch = collapsedMatch;
+						bestCapturedParams = capturedParameters;
+						bestLeafNode = null;
+						bestLeafSearch = null;
+					}
+					continue;
 				}
 
-				// 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 the registered route is a combination of shell items and global routes
 				if ((possibleRoutePath.Shell != null) &&
 					(possibleRoutePath.Item == null || possibleRoutePath.Section == null || possibleRoutePath.Content == null))
 				{
@@ -560,7 +623,6 @@ namespace Microsoft.Maui.Controls
 
 					while (nextNode != null)
 					{
-						// 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))
@@ -581,12 +643,24 @@ namespace Microsoft.Maui.Controls
 						if (routeKey.StartsWith("//", StringComparison.Ordinal))
 							collapsedLeafRoute = "//" + collapsedLeafRoute;
 
-						string segmentMatch = leafSearch.GetNextSegmentMatch(collapsedLeafRoute);
+						Dictionary<string, string> leafCaptured = isTemplate
+							? new Dictionary<string, string>(StringComparer.Ordinal)
+							: null;
+
+						string segmentMatch = leafSearch.GetNextSegmentMatch(collapsedLeafRoute, leafCaptured, routeTemplate);
 						if (!String.IsNullOrWhiteSpace(segmentMatch))
 						{
-							possibleRoutePath.AddMatch(nextNode);
-							possibleRoutePath.AddGlobalRoute(routeKey, segmentMatch);
-							return true;
+							int score = ComputeSpecificityScore(routeKey);
+							if (score > bestScore)
+							{
+								bestScore = score;
+								bestRouteKey = routeKey;
+								bestCollapsedMatch = segmentMatch;
+								bestCapturedParams = leafCaptured;
+								bestLeafNode = nextNode;
+								bestLeafSearch = leafSearch;
+							}
+							break;
 						}
 
 						nextNode = nextNode.WalkToNextNode();
@@ -594,6 +668,16 @@ namespace Microsoft.Maui.Controls
 				}
 			}
 
+			// Apply the best match found
+			if (bestRouteKey != null)
+			{
+				if (bestLeafNode != null)
+					possibleRoutePath.AddMatch(bestLeafNode);
+
+				possibleRoutePath.AddGlobalRoute(bestRouteKey, bestCollapsedMatch, bestCapturedParams);
+				return true;
+			}
+
 			// check for exact matches
 			if (routeKeys.Contains(possibleRoutePath.NextSegment))
 			{
diff --git a/src/Controls/tests/Core.UnitTests/ShellRouteTemplatesTests.cs b/src/Controls/tests/Core.UnitTests/ShellRouteTemplatesTests.cs
index 16dfc64347..481fc49439 100644
--- a/src/Controls/tests/Core.UnitTests/ShellRouteTemplatesTests.cs
+++ b/src/Controls/tests/Core.UnitTests/ShellRouteTemplatesTests.cs
@@ -1150,5 +1150,97 @@ namespace Microsoft.Maui.Controls.Core.UnitTests
 			var currentRoute = shell.CurrentState.Location.ToString();
 			Assert.Contains("orders", currentRoute, StringComparison.Ordinal);
 		}
+
+		// ===== Attempt-2 fixes: constraint correctness, conflict detection, back-navigation =====
+
+		[Fact]
+		public void SatisfiesConstraint_Alpha_RejectsUnicodeLetter()
+		{
+			// Unicode letter (e.g. é) must NOT satisfy :alpha — ASP.NET Core parity
+			// requires ASCII letters only (a-z, A-Z).
+			Assert.False(RouteTemplate.SatisfiesConstraint("alpha", "café"));
+			Assert.False(RouteTemplate.SatisfiesConstraint("alpha", "naïve"));
+			Assert.False(RouteTemplate.SatisfiesConstraint("alpha", "привет")); // Cyrillic
+			Assert.False(RouteTemplate.SatisfiesConstraint("alpha", "日本語"));  // CJK
+		}
+
+		[Fact]
+		public void SatisfiesConstraint_Int_RejectsLocaleThousandsSeparator()
+		{
+			// Some locales use "." as thousands separator (e.g. German "1.000" = 1000).
+			// :int must use InvariantCulture and reject such strings.
+			Assert.False(RouteTemplate.SatisfiesConstraint("int", "1.000"));
+			Assert.True(RouteTemplate.SatisfiesConstraint("int", "1000"));
+		}
+
+		[Fact]
+		public void SatisfiesConstraint_Long_RejectsLocaleThousandsSeparator()
+		{
+			// Parallel test for :long — same InvariantCulture fix applies.
+			Assert.False(RouteTemplate.SatisfiesConstraint("long", "1.000"));
+			Assert.True(RouteTemplate.SatisfiesConstraint("long", "1000"));
+		}
+
+		[Fact]
+		public void RegisterRoute_RejectsStructurallyConflictingTemplates()
+		{
+			// "product/{id:int}" and "product/{sku}" have identical structure
+			// (literal "product" + parameter) and would ambiguously match the
+			// same URIs (e.g. "product/42"). The second registration should be rejected.
+			Routing.RegisterRoute("product/{id:int}", typeof(OrderDetailPage));
+			Assert.Throws<ArgumentException>(() =>
+				Routing.RegisterRoute("product/{sku}", typeof(ProductPage)));
+		}
+
+		[Fact]
+		public void RegisterRoute_AllowsNonConflictingTemplates()
+		{
+			// Different literal prefixes — no structural conflict.
+			Routing.RegisterRoute("product/{sku}", typeof(ProductPage));
+			Routing.RegisterRoute("order/{id:int}", typeof(OrderDetailPage)); // should not throw
+		}
+
+		[Fact]
+		public void RegisterRoute_AllowsMutuallyExclusiveConstraints()
+		{
+			// :int and :alpha are mutually exclusive — no string satisfies both.
+			// Both should be registerable on the same literal prefix.
+			Routing.RegisterRoute("orders/{id:int}", typeof(OrderDetailPage));
+			Routing.RegisterRoute("orders/{code:alpha}", typeof(ProductPage)); // should not throw
+		}
+
+		[Fact]
+		public void RegisterRoute_AllowsDifferentMixedSegmentPrefixes()
+		{
+			// "cat-{id}" and "dog-{id}" have different literal prefixes, so they
+			// are not structurally conflicting even though both are mixed segments.
+			Routing.RegisterRoute("cat-{id}", typeof(ProductPage));
+			Routing.RegisterRoute("dog-{id}", typeof(OrderDetailPage)); // should not throw
+		}
+
+		[Fact]
+		public async Task BackNavigation_AfterTemplateRoute_Succeeds()
+		{
+			// Regression test for ShellUriHandler.cs:51 — back navigation ("..")
+			// after a template route navigation must pop the page and leave the
+			// shell in a valid state.
+			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");
+			// Verify the template navigation actually pushed a page
+			var stackBefore = shell.Navigation.NavigationStack.Count;
+			Assert.True(stackBefore >= 2, $"Expected at least 2 entries (root + product page), got {stackBefore}");
+			Assert.IsType<ProductPage>(shell.Navigation.NavigationStack[stackBefore - 1]);
+
+			// Pop back one page using ".."
+			await shell.GoToAsync("..");
+
+			// Stack should now be exactly one level shallower
+			var stackAfter = shell.Navigation.NavigationStack.Count;
+			Assert.Equal(stackBefore - 1, stackAfter);
+		}
 	}
 }

@MauiBot MauiBot added s/agent-changes-requested AI agent recommends changes - found a better alternative or issues s/agent-fix-win AI found a better alternative fix than the PR s/agent-reviewed PR was reviewed by AI agent workflow (full 4-phase review) labels May 3, 2026
@dotnet dotnet deleted a comment from MauiBot May 3, 2026
@kubaflo kubaflo dismissed MauiBot’s stale review May 3, 2026 12:45

Resetting for re-review

Copy link
Copy Markdown
Collaborator

@MauiBot MauiBot left a comment

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Expert Review — 8 findings

See inline comments for details.

mattleibow added a commit that referenced this pull request May 7, 2026
Add relationship table showing how this vision document, the Shell
Routing Decomposition spec (the actionable engineering plan), and
the Route Templates PR #35110 (working code) fit together.

This doc = long-term north star
#35348 = concrete API + phased delivery for .NET 11+
#35110 = Phase 2 implementation of route templates

Co-authored-by: Copilot <223556219+Copilot@users.noreply.github.com>
mattleibow added a commit that referenced this pull request May 7, 2026
…tified

Cross-references all 17 issues linked from Shell Routing Decomposition
(#35348) against this vision doc, the Decomposition spec, and PR #35110.

12 fully covered, 5 with notes:
- Navigation queueing (#11307, #17608): covered in Decomposition,
  added note that INavigationService should queue internally
- Auto UI-thread dispatch (#27589): covered in Decomposition,
  adapters should auto-dispatch
- Modal stack management (#12162): gap in both docs — needs explicit
  API design for PresentationOptions.Modal + ModalStack interaction
- Shell Handlers for Android (#32985): prerequisite platform work,
  not in scope but noted as dependency

Co-authored-by: Copilot <223556219+Copilot@users.noreply.github.com>
@dotnet dotnet deleted a comment from MauiBot May 8, 2026
@dotnet dotnet deleted a comment from MauiBot May 8, 2026
@dotnet dotnet deleted a comment from MauiBot May 8, 2026
@dotnet dotnet deleted a comment from MauiBot May 8, 2026
@dotnet dotnet deleted a comment from MauiBot May 8, 2026
@dotnet dotnet deleted a comment from MauiBot May 8, 2026
@dotnet dotnet deleted a comment from MauiBot May 8, 2026
@dotnet dotnet deleted a comment from MauiBot May 8, 2026
@MauiBot MauiBot added s/agent-review-incomplete and removed s/agent-changes-requested AI agent recommends changes - found a better alternative or issues labels May 8, 2026
@dotnet dotnet deleted a comment from MauiBot May 8, 2026
kubaflo pushed a commit that referenced this pull request May 8, 2026
…ory fails before tests

When a test category fails because the build or deploy crashed before any
test could run (e.g. CS0246 missing namespace, RS0016 PublicAPI errors),
the AI summary table previously showed '0/1 ✓' — the green-checkmark
'all passed' branch — because no per-test failures were parsed. That's
visually misleading: the row is FAILED but the cell looks healthy.

Two fixes:

1. Tests column distinguishes 'category failed AND no per-test failures
   parsed' from 'all tests passed':
     - 'build/deploy failed'                              (no tests at all)
     - '0/1 — build/deploy failed before per-test results'  (some discovered)

2. New optional 'build_tail' field captures the last 30 lines of stdout
   when a category fails with zero per-test failures. The Failed test
   details collapsible section then renders it in a code block so
   reviewers see the actual compiler error / build crash inline,
   instead of having to download the full CopilotLogs artifact.

This was discovered while running the regression-check pipeline against
PRs #35110 (142 RS0016 PublicAPI errors), #35281 (CS0246 NSAttributedString
missing for catalyst), and #35358 — all reported as '0/1 ✓' before the fix.

Co-authored-by: Copilot <223556219+Copilot@users.noreply.github.com>
@dotnet dotnet deleted a comment from MauiBot May 8, 2026
Copy link
Copy Markdown
Collaborator

@MauiBot MauiBot left a comment

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Expert Review — 15 findings

See inline comments for details.

@@ -1 +1,143 @@
#nullable enable
#nullable enable
Microsoft.Maui.Navigation.FakeNavigationService
Copy link
Copy Markdown
Collaborator

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

[error] Public API Surface — scope creep — This PR adds 142 public API entries under a brand-new Microsoft.Maui.Navigation namespace (INavigationService, INavigationGraphBuilder, ITabBuilder, INavigationAware, INavigationGuard, INavigationParameters/NavigationParameters, NavigationContext, NavigationResult/NavigationStatus, NavigatingEventArgs/NavigatedEventArgs/NavigationFailedEventArgs, PresentationOptions/Presentation/IPlatformOptions, RouteAttribute/RouteParameterAttribute, TabConfiguration, FakeNavigationService, NavigationMode). None of these types are referenced by any of the Shell route-template code, none are wired into MauiAppBuilder, and the only implementation is FakeNavigationService (a test-double). Per the project's API guidance ("new public APIs have clear use cases — no speculative additions" and "adding members to a public interface is a breaking change"), shipping these as public surface in the same PR as Shell route templates locks design choices that haven't been reviewed. Recommend: pull all src/Core/src/Navigation/*.cs and the corresponding PublicAPI.Unshipped.txt lines out of this PR and ship them as a separate, reviewable proposal — or mark them internal until a real consumer exists.


List<string> matches = new List<string>();
List<string> currentSet = new List<string>(_matchedSegments);
public string GetNextSegmentMatch(string matchMe, IDictionary<string, string> capturedParameters, RouteTemplate template)
Copy link
Copy Markdown
Collaborator

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

[error] Code style / Build & MSBuild — Lines 192–368 (the entire new GetNextSegmentMatch(string, IDictionary, RouteTemplate) overload plus its single-arg helper at 194–197) are written at column 0 with no leading tabs, while the surrounding class members use one-tab indentation. This will fail dotnet format (and any whitespace check in CI) and makes the new logic visually unreadable next to the original. Re-indent the block (one extra tab inside the class, plus the usual nested levels). The closing brace at line 368 and the trailing blank line at 370 also need to align with the rest of the file.

if (!seg.IsParameter)
continue;
if (pathParameters.TryGetValue(seg.Value, out var val))
{
Copy link
Copy Markdown
Collaborator

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

[error] Logic and Correctness / Code style — The if (pathParameters.TryGetValue(seg.Value, out var val)) block has broken indentation: the body opening brace at line 134 and the code at 135–138 sits at the wrong column relative to the if at line 133, and the closing } at 139 doesn't pair visually with anything. The logic appears correct (the braces do pair), but the misalignment looks like a missed merge fix-up and will fail formatting checks. Re-indent so the body is one level deeper than the if.

@@ -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])
Copy link
Copy Markdown
Collaborator

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

[warning] Logic and Correctness / Regression Prevention — When the existing page in the stack matches the registered template key (e.g. both navigations target product/{sku}), the code calls Routing.SetResolvedRoute(...) but continue;s without re-running ApplyQueryAttributes on the reused page. That means navigating from product/appleproduct/banana (or the same template with any new parameter values) reuses the same Page instance with stale [QueryProperty] / IQueryAttributable data — the user sees "apple" while the URL says "banana". The reused-page path further down (around line 421) does call ApplyQueryAttributes and SetResolvedRoute; this fast-path branch needs the same treatment. Add a ShellNavigationManager.ApplyQueryAttributes(_navStack[navIndex], queryData, isLast: i == globalRoutes.Count - 1, isRelativePopping) call before continue. Also add a regression test that navigates twice to the same template with different parameter values and asserts the page received the new sku.

Assert.NotNull(page.ReceivedQuery);
Assert.True(page.ReceivedQuery.ContainsKey("sku"));
Assert.Equal("seed-tomato", page.ReceivedQuery["sku"]);
}
Copy link
Copy Markdown
Collaborator

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

[warning] Regression Prevention — The test suite is thorough (1154 lines, 80+ cases) but is missing the single most likely regression scenario for templated routes: two consecutive navigations to the same template with different parameter values (e.g. await shell.GoToAsync("//main/products/product/apple") then await shell.GoToAsync("//main/products/product/banana"), asserting the page reflects sku="banana"). This is the path that hits the reuse branch in ShellSection.PrepareCurrentStackForBeingReplaced (line 370–377) where Routing.GetRoute(_navStack[navIndex]) == globalRoutes[i] is true for any sku. Without this test, the bug flagged above (stale ApplyQueryAttributes on reuse) regresses silently.

return false;

return route.IndexOf("{", StringComparison.Ordinal) >= 0;
}
Copy link
Copy Markdown
Collaborator

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

[warning] Public API / Edge caseContainsTemplateSyntax returns true for any string containing {. There is no escape syntax ({{ / }}) for embedding a literal { in a route, so Routing.RegisterRoute("foo{bar", ...) will throw ArgumentException from Parse. That may be intentional ({ is reserved) but it isn't documented and there is no test for it. Either (a) document { and } as reserved characters in the XML doc summary on RegisterRoute/RouteTemplate, or (b) treat the segment as literal when IsValidParameterName fails on the inner token. Adding a RegisterRoute_LiteralBraceInRouteThrowsHelpfulError test would lock in whichever choice you pick.

case "long":
return long.TryParse(value, out _);
case "double":
return double.TryParse(value, System.Globalization.NumberStyles.Any,
Copy link
Copy Markdown
Collaborator

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

[suggestion] Logic and Correctnessint.TryParse(value, out _) and long.TryParse(value, out _) use the current culture and NumberStyles.Integer | AllowLeadingSign | AllowLeadingWhite | AllowTrailingWhite. Route values come from URIs, which should be culture-invariant and reject whitespace/sign tricks like " 5 " or "+5". Recommend int.TryParse(value, NumberStyles.Integer, CultureInfo.InvariantCulture, out _) (and NumberStyles.None if you want strict digits-only) for int, long. For bool and guid, the current methods are already culture-independent.

else if (next != null && RouteTemplate.IsTemplateSegment(split))
{
// Fallback for template segments without parsed RouteTemplate
var paramName = RouteTemplate.GetSegmentParameterName(split);
Copy link
Copy Markdown
Collaborator

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

[suggestion] Complexity Reduction — The else if (next != null && RouteTemplate.IsTemplateSegment(split)) fallback branch (lines 319–331) tests whether the URI segment from the user's nav request (split) literally contains a {...} token. End-user nav URIs don't contain {sku} literals — they contain concrete values like seed-tomato. Confirm with a coverage run whether this branch is ever taken; if not, delete it. If it is taken (e.g. via some internal nav path that re-feeds template strings as URIs), document the scenario in a comment so future readers know.

NavigationHistory.RemoveAt(NavigationHistory.Count - 1);
CurrentRoute = NavigationHistory.Count > 0 ? NavigationHistory[NavigationHistory.Count - 1] : "";

Navigated?.Invoke(this, new NavigatedEventArgs(CurrentRoute, NavigationMode.Pop));
Copy link
Copy Markdown
Collaborator

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

[suggestion] Public API consistencyGoBackAsync raises Navigated but never raises Navigating (and never honours NavigatingEventArgs.Cancel). NavigateAsync raises both and supports cancel. Tests written against the fake will not catch a real-service bug where a back-navigation is supposed to be cancellable via Navigating.Cancel = true. Either raise Navigating here too (matching NavigateAsync), or document that the fake intentionally skips it.


// Product routes: product/{sku} with review child that has
// a default stars value via {stars=5}
Routing.RegisterRoute("product/{sku}", typeof(ProductPage));
Copy link
Copy Markdown
Collaborator

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

[suggestion] PR scoping — The Sandbox sample is being repurposed into a route-template demo (product/{sku}, review/{stars=5}, order/{orderId:int}, plus 5 new *Page.xaml files and a rewritten MainPage). Sandbox is a shared developer test bed used by everyone working on MAUI; permanently dedicating it to one feature's demo is unusual. Consider either (a) moving these pages into a dedicated sample under src/Controls/samples/Controls.Sample or Controls.Sample.Maui so other developers' sandbox state isn't disrupted, or (b) confirming with the team that this repurposing is intentional. At minimum, ensure App.xaml.cs change at line 2 is intentional and not leftover scratch state.

@MauiBot MauiBot added s/agent-changes-requested AI agent recommends changes - found a better alternative or issues and removed s/agent-review-incomplete labels May 8, 2026
@dotnet dotnet deleted a comment from MauiBot May 11, 2026
@MauiBot
Copy link
Copy Markdown
Collaborator

MauiBot commented May 11, 2026

🤖 AI Summary

👋 @mattleibow — new AI review results are available. Please review the latest session below.

📊 Review Session73d24f8 · feat: add Navigation v2 abstractions — INavigationService, lifecycle, guards, state · 2026-05-11 21:39 UTC
🚦 Gate — Test Before & After Fix

Gate Result: ✅ PASSED

Platform: ANDROID · Base: main · Merge base: f8cb875e

Test Without Fix (expect FAIL) With Fix (expect PASS)
🧪 ShellRouteTemplatesTests ShellRouteTemplatesTests ✅ FAIL — 56s ✅ PASS — 145s
🔴 Without fix — 🧪 ShellRouteTemplatesTests: FAIL ✅ · 56s

(truncated to last 15,000 chars)

.TabConfiguration.Title.set -> void' is not part of the declared public API (https://github.com/dotnet/roslyn/blob/main/src/RoslynAnalyzers/PublicApiAnalyzers/PublicApiAnalyzers.Help.md) [/home/vsts/work/1/s/src/Core/src/Core.csproj::TargetFramework=net10.0]
/home/vsts/work/1/s/src/Core/src/Navigation/INavigationGraphBuilder.cs(27,25): error RS0016: Symbol 'Microsoft.Maui.Navigation.TabConfiguration.Icon.get -> string?' is not part of the declared public API (https://github.com/dotnet/roslyn/blob/main/src/RoslynAnalyzers/PublicApiAnalyzers/PublicApiAnalyzers.Help.md) [/home/vsts/work/1/s/src/Core/src/Core.csproj::TargetFramework=net10.0]
/home/vsts/work/1/s/src/Core/src/Navigation/INavigationGraphBuilder.cs(27,30): error RS0016: Symbol 'Microsoft.Maui.Navigation.TabConfiguration.Icon.set -> void' is not part of the declared public API (https://github.com/dotnet/roslyn/blob/main/src/RoslynAnalyzers/PublicApiAnalyzers/PublicApiAnalyzers.Help.md) [/home/vsts/work/1/s/src/Core/src/Core.csproj::TargetFramework=net10.0]
/home/vsts/work/1/s/src/Core/src/Navigation/INavigationGuard.cs(12,14): error RS0016: Symbol 'Microsoft.Maui.Navigation.INavigationGuard.CanNavigateAsync(Microsoft.Maui.Navigation.NavigationContext! context, System.Threading.CancellationToken ct = default(System.Threading.CancellationToken)) -> System.Threading.Tasks.Task<bool>!' is not part of the declared public API (https://github.com/dotnet/roslyn/blob/main/src/RoslynAnalyzers/PublicApiAnalyzers/PublicApiAnalyzers.Help.md) [/home/vsts/work/1/s/src/Core/src/Core.csproj::TargetFramework=net10.0]
/home/vsts/work/1/s/src/Core/src/Navigation/INavigationParameters.cs(9,5): error RS0016: Symbol 'Microsoft.Maui.Navigation.INavigationParameters.Get<T>(string! key) -> T' is not part of the declared public API (https://github.com/dotnet/roslyn/blob/main/src/RoslynAnalyzers/PublicApiAnalyzers/PublicApiAnalyzers.Help.md) [/home/vsts/work/1/s/src/Core/src/Core.csproj::TargetFramework=net10.0]
/home/vsts/work/1/s/src/Core/src/Navigation/INavigationParameters.cs(10,8): error RS0016: Symbol 'Microsoft.Maui.Navigation.INavigationParameters.TryGet<T>(string! key, out T value) -> bool' is not part of the declared public API (https://github.com/dotnet/roslyn/blob/main/src/RoslynAnalyzers/PublicApiAnalyzers/PublicApiAnalyzers.Help.md) [/home/vsts/work/1/s/src/Core/src/Core.csproj::TargetFramework=net10.0]
/home/vsts/work/1/s/src/Core/src/Navigation/INavigationParameters.cs(11,8): error RS0016: Symbol 'Microsoft.Maui.Navigation.INavigationParameters.ContainsKey(string! key) -> bool' is not part of the declared public API (https://github.com/dotnet/roslyn/blob/main/src/RoslynAnalyzers/PublicApiAnalyzers/PublicApiAnalyzers.Help.md) [/home/vsts/work/1/s/src/Core/src/Core.csproj::TargetFramework=net10.0]
/home/vsts/work/1/s/src/Core/src/Navigation/INavigationParameters.cs(12,15): error RS0016: Symbol 'Microsoft.Maui.Navigation.INavigationParameters.Count.get -> int' is not part of the declared public API (https://github.com/dotnet/roslyn/blob/main/src/RoslynAnalyzers/PublicApiAnalyzers/PublicApiAnalyzers.Help.md) [/home/vsts/work/1/s/src/Core/src/Core.csproj::TargetFramework=net10.0]
/home/vsts/work/1/s/src/Core/src/Navigation/INavigationParameters.cs(13,29): error RS0016: Symbol 'Microsoft.Maui.Navigation.INavigationParameters.this[string! key].get -> object!' is not part of the declared public API (https://github.com/dotnet/roslyn/blob/main/src/RoslynAnalyzers/PublicApiAnalyzers/PublicApiAnalyzers.Help.md) [/home/vsts/work/1/s/src/Core/src/Core.csproj::TargetFramework=net10.0]
/home/vsts/work/1/s/src/Core/src/Navigation/FakeNavigationService.cs(15,31): error RS0016: Symbol 'Microsoft.Maui.Navigation.FakeNavigationService.LastRoute.get -> string?' is not part of the declared public API (https://github.com/dotnet/roslyn/blob/main/src/RoslynAnalyzers/PublicApiAnalyzers/PublicApiAnalyzers.Help.md) [/home/vsts/work/1/s/src/Core/src/Core.csproj::TargetFramework=net10.0]
/home/vsts/work/1/s/src/Core/src/Navigation/FakeNavigationService.cs(16,45): error RS0016: Symbol 'Microsoft.Maui.Navigation.FakeNavigationService.LastOptions.get -> Microsoft.Maui.Navigation.PresentationOptions?' is not part of the declared public API (https://github.com/dotnet/roslyn/blob/main/src/RoslynAnalyzers/PublicApiAnalyzers/PublicApiAnalyzers.Help.md) [/home/vsts/work/1/s/src/Core/src/Core.csproj::TargetFramework=net10.0]
/home/vsts/work/1/s/src/Core/src/Navigation/FakeNavigationService.cs(17,50): error RS0016: Symbol 'Microsoft.Maui.Navigation.FakeNavigationService.LastParameters.get -> Microsoft.Maui.Navigation.INavigationParameters?' is not part of the declared public API (https://github.com/dotnet/roslyn/blob/main/src/RoslynAnalyzers/PublicApiAnalyzers/PublicApiAnalyzers.Help.md) [/home/vsts/work/1/s/src/Core/src/Core.csproj::TargetFramework=net10.0]
/home/vsts/work/1/s/src/Core/src/Navigation/FakeNavigationService.cs(18,31): error RS0016: Symbol 'Microsoft.Maui.Navigation.FakeNavigationService.ShouldSucceed.get -> bool' is not part of the declared public API (https://github.com/dotnet/roslyn/blob/main/src/RoslynAnalyzers/PublicApiAnalyzers/PublicApiAnalyzers.Help.md) [/home/vsts/work/1/s/src/Core/src/Core.csproj::TargetFramework=net10.0]
/home/vsts/work/1/s/src/Core/src/Navigation/FakeNavigationService.cs(18,36): error RS0016: Symbol 'Microsoft.Maui.Navigation.FakeNavigationService.ShouldSucceed.set -> void' is not part of the declared public API (https://github.com/dotnet/roslyn/blob/main/src/RoslynAnalyzers/PublicApiAnalyzers/PublicApiAnalyzers.Help.md) [/home/vsts/work/1/s/src/Core/src/Core.csproj::TargetFramework=net10.0]
/home/vsts/work/1/s/src/Core/src/Navigation/FakeNavigationService.cs(19,34): error RS0016: Symbol 'Microsoft.Maui.Navigation.FakeNavigationService.FailureReason.get -> string?' is not part of the declared public API (https://github.com/dotnet/roslyn/blob/main/src/RoslynAnalyzers/PublicApiAnalyzers/PublicApiAnalyzers.Help.md) [/home/vsts/work/1/s/src/Core/src/Core.csproj::TargetFramework=net10.0]
/home/vsts/work/1/s/src/Core/src/Navigation/FakeNavigationService.cs(19,39): error RS0016: Symbol 'Microsoft.Maui.Navigation.FakeNavigationService.FailureReason.set -> void' is not part of the declared public API (https://github.com/dotnet/roslyn/blob/main/src/RoslynAnalyzers/PublicApiAnalyzers/PublicApiAnalyzers.Help.md) [/home/vsts/work/1/s/src/Core/src/Core.csproj::TargetFramework=net10.0]
/home/vsts/work/1/s/src/Core/src/Navigation/FakeNavigationService.cs(21,28): error RS0016: Symbol 'Microsoft.Maui.Navigation.FakeNavigationService.CanGoBack.get -> bool' is not part of the declared public API (https://github.com/dotnet/roslyn/blob/main/src/RoslynAnalyzers/PublicApiAnalyzers/PublicApiAnalyzers.Help.md) [/home/vsts/work/1/s/src/Core/src/Core.csproj::TargetFramework=net10.0]
/home/vsts/work/1/s/src/Core/src/Navigation/FakeNavigationService.cs(22,32): error RS0016: Symbol 'Microsoft.Maui.Navigation.FakeNavigationService.CurrentRoute.get -> string!' is not part of the declared public API (https://github.com/dotnet/roslyn/blob/main/src/RoslynAnalyzers/PublicApiAnalyzers/PublicApiAnalyzers.Help.md) [/home/vsts/work/1/s/src/Core/src/Core.csproj::TargetFramework=net10.0]
/home/vsts/work/1/s/src/Core/src/Navigation/FakeNavigationService.cs(22,37): error RS0016: Symbol 'Microsoft.Maui.Navigation.FakeNavigationService.CurrentRoute.set -> void' is not part of the declared public API (https://github.com/dotnet/roslyn/blob/main/src/RoslynAnalyzers/PublicApiAnalyzers/PublicApiAnalyzers.Help.md) [/home/vsts/work/1/s/src/Core/src/Core.csproj::TargetFramework=net10.0]
/home/vsts/work/1/s/src/Core/src/Navigation/FakeNavigationService.cs(28,33): error RS0016: Symbol 'Microsoft.Maui.Navigation.FakeNavigationService.NavigateAsync(string! route, System.Threading.CancellationToken ct = default(System.Threading.CancellationToken)) -> System.Threading.Tasks.Task<Microsoft.Maui.Navigation.NavigationResult!>!' is not part of the declared public API (https://github.com/dotnet/roslyn/blob/main/src/RoslynAnalyzers/PublicApiAnalyzers/PublicApiAnalyzers.Help.md) [/home/vsts/work/1/s/src/Core/src/Core.csproj::TargetFramework=net10.0]
/home/vsts/work/1/s/src/Core/src/Navigation/INavigationParameters.cs(22,10): error RS0016: Symbol 'Microsoft.Maui.Navigation.NavigationParameters.NavigationParameters(System.Collections.Generic.IDictionary<string!, object!>! parameters) -> void' is not part of the declared public API (https://github.com/dotnet/roslyn/blob/main/src/RoslynAnalyzers/PublicApiAnalyzers/PublicApiAnalyzers.Help.md) [/home/vsts/work/1/s/src/Core/src/Core.csproj::TargetFramework=net10.0]
/home/vsts/work/1/s/src/Core/src/Navigation/INavigationParameters.cs(28,15): error RS0016: Symbol 'Microsoft.Maui.Navigation.NavigationParameters.Add(string! key, object! value) -> void' is not part of the declared public API (https://github.com/dotnet/roslyn/blob/main/src/RoslynAnalyzers/PublicApiAnalyzers/PublicApiAnalyzers.Help.md) [/home/vsts/work/1/s/src/Core/src/Core.csproj::TargetFramework=net10.0]
/home/vsts/work/1/s/src/Core/src/Navigation/INavigationParameters.cs(30,12): error RS0016: Symbol 'Microsoft.Maui.Navigation.NavigationParameters.Get<T>(string! key) -> T' is not part of the declared public API (https://github.com/dotnet/roslyn/blob/main/src/RoslynAnalyzers/PublicApiAnalyzers/PublicApiAnalyzers.Help.md) [/home/vsts/work/1/s/src/Core/src/Core.csproj::TargetFramework=net10.0]
/home/vsts/work/1/s/src/Core/src/Navigation/INavigationParameters.cs(37,15): error RS0016: Symbol 'Microsoft.Maui.Navigation.NavigationParameters.TryGet<T>(string! key, out T value) -> bool' is not part of the declared public API (https://github.com/dotnet/roslyn/blob/main/src/RoslynAnalyzers/PublicApiAnalyzers/PublicApiAnalyzers.Help.md) [/home/vsts/work/1/s/src/Core/src/Core.csproj::TargetFramework=net10.0]
/home/vsts/work/1/s/src/Core/src/Navigation/FakeNavigationService.cs(31,33): error RS0016: Symbol 'Microsoft.Maui.Navigation.FakeNavigationService.NavigateAsync(string! route, Microsoft.Maui.Navigation.INavigationParameters! parameters, System.Threading.CancellationToken ct = default(System.Threading.CancellationToken)) -> System.Threading.Tasks.Task<Microsoft.Maui.Navigation.NavigationResult!>!' is not part of the declared public API (https://github.com/dotnet/roslyn/blob/main/src/RoslynAnalyzers/PublicApiAnalyzers/PublicApiAnalyzers.Help.md) [/home/vsts/work/1/s/src/Core/src/Core.csproj::TargetFramework=net10.0]
/home/vsts/work/1/s/src/Core/src/Navigation/FakeNavigationService.cs(34,33): error RS0016: Symbol 'Microsoft.Maui.Navigation.FakeNavigationService.NavigateAsync(string! route, Microsoft.Maui.Navigation.PresentationOptions! options, System.Threading.CancellationToken ct = default(System.Threading.CancellationToken)) -> System.Threading.Tasks.Task<Microsoft.Maui.Navigation.NavigationResult!>!' is not part of the declared public API (https://github.com/dotnet/roslyn/blob/main/src/RoslynAnalyzers/PublicApiAnalyzers/PublicApiAnalyzers.Help.md) [/home/vsts/work/1/s/src/Core/src/Core.csproj::TargetFramework=net10.0]
/home/vsts/work/1/s/src/Core/src/Navigation/FakeNavigationService.cs(37,33): error RS0016: Symbol 'Microsoft.Maui.Navigation.FakeNavigationService.NavigateAsync(string! route, Microsoft.Maui.Navigation.INavigationParameters? parameters, Microsoft.Maui.Navigation.PresentationOptions? options, System.Threading.CancellationToken ct = default(System.Threading.CancellationToken)) -> System.Threading.Tasks.Task<Microsoft.Maui.Navigation.NavigationResult!>!' is not part of the declared public API (https://github.com/dotnet/roslyn/blob/main/src/RoslynAnalyzers/PublicApiAnalyzers/PublicApiAnalyzers.Help.md) [/home/vsts/work/1/s/src/Core/src/Core.csproj::TargetFramework=net10.0]
/home/vsts/work/1/s/src/Core/src/Navigation/FakeNavigationService.cs(61,33): error RS0016: Symbol 'Microsoft.Maui.Navigation.FakeNavigationService.GoBackAsync(System.Threading.CancellationToken ct = default(System.Threading.CancellationToken)) -> System.Threading.Tasks.Task<Microsoft.Maui.Navigation.NavigationResult!>!' is not part of the declared public API (https://github.com/dotnet/roslyn/blob/main/src/RoslynAnalyzers/PublicApiAnalyzers/PublicApiAnalyzers.Help.md) [/home/vsts/work/1/s/src/Core/src/Core.csproj::TargetFramework=net10.0]
/home/vsts/work/1/s/src/Core/src/Navigation/INavigationParameters.cs(53,15): error RS0016: Symbol 'Microsoft.Maui.Navigation.NavigationParameters.ContainsKey(string! key) -> bool' is not part of the declared public API (https://github.com/dotnet/roslyn/blob/main/src/RoslynAnalyzers/PublicApiAnalyzers/PublicApiAnalyzers.Help.md) [/home/vsts/work/1/s/src/Core/src/Core.csproj::TargetFramework=net10.0]
/home/vsts/work/1/s/src/Core/src/Navigation/FakeNavigationService.cs(64,33): error RS0016: Symbol 'Microsoft.Maui.Navigation.FakeNavigationService.GoBackAsync(Microsoft.Maui.Navigation.INavigationParameters? parameters, System.Threading.CancellationToken ct = default(System.Threading.CancellationToken)) -> System.Threading.Tasks.Task<Microsoft.Maui.Navigation.NavigationResult!>!' is not part of the declared public API (https://github.com/dotnet/roslyn/blob/main/src/RoslynAnalyzers/PublicApiAnalyzers/PublicApiAnalyzers.Help.md) [/home/vsts/work/1/s/src/Core/src/Core.csproj::TargetFramework=net10.0]
/home/vsts/work/1/s/src/Core/src/Navigation/INavigationParameters.cs(54,23): error RS0016: Symbol 'Microsoft.Maui.Navigation.NavigationParameters.Count.get -> int' is not part of the declared public API (https://github.com/dotnet/roslyn/blob/main/src/RoslynAnalyzers/PublicApiAnalyzers/PublicApiAnalyzers.Help.md) [/home/vsts/work/1/s/src/Core/src/Core.csproj::TargetFramework=net10.0]
/home/vsts/work/1/s/src/Core/src/Navigation/INavigationParameters.cs(55,37): error RS0016: Symbol 'Microsoft.Maui.Navigation.NavigationParameters.this[string! key].get -> object!' is not part of the declared public API (https://github.com/dotnet/roslyn/blob/main/src/RoslynAnalyzers/PublicApiAnalyzers/PublicApiAnalyzers.Help.md) [/home/vsts/work/1/s/src/Core/src/Core.csproj::TargetFramework=net10.0]
/home/vsts/work/1/s/src/Core/src/Navigation/INavigationParameters.cs(57,52): error RS0016: Symbol 'Microsoft.Maui.Navigation.NavigationParameters.GetEnumerator() -> System.Collections.Generic.IEnumerator<System.Collections.Generic.KeyValuePair<string!, object!>>!' is not part of the declared public API (https://github.com/dotnet/roslyn/blob/main/src/RoslynAnalyzers/PublicApiAnalyzers/PublicApiAnalyzers.Help.md) [/home/vsts/work/1/s/src/Core/src/Core.csproj::TargetFramework=net10.0]
/home/vsts/work/1/s/src/Core/src/Navigation/FakeNavigationService.cs(79,15): error RS0016: Symbol 'Microsoft.Maui.Navigation.FakeNavigationService.Reset() -> void' is not part of the declared public API (https://github.com/dotnet/roslyn/blob/main/src/RoslynAnalyzers/PublicApiAnalyzers/PublicApiAnalyzers.Help.md) [/home/vsts/work/1/s/src/Core/src/Core.csproj::TargetFramework=net10.0]

🟢 With fix — 🧪 ShellRouteTemplatesTests: PASS ✅ · 145s
  Determining projects to restore...
  All projects are up-to-date for restore.
  ##vso[build.updatebuildnumber]10.0.70-ci+azdo.14068654
  Graphics -> /home/vsts/work/1/s/artifacts/bin/Graphics/Debug/net10.0/Microsoft.Maui.Graphics.dll
  ##vso[build.updatebuildnumber]10.0.70-ci+azdo.14068654
  Essentials -> /home/vsts/work/1/s/artifacts/bin/Essentials/Debug/net10.0/Microsoft.Maui.Essentials.dll
  ##vso[build.updatebuildnumber]10.0.70-ci+azdo.14068654
  Core -> /home/vsts/work/1/s/artifacts/bin/Core/Debug/net10.0/Microsoft.Maui.dll
  Controls.BindingSourceGen -> /home/vsts/work/1/s/artifacts/bin/Controls.BindingSourceGen/Debug/netstandard2.0/Microsoft.Maui.Controls.BindingSourceGen.dll
  ##vso[build.updatebuildnumber]10.0.70-ci+azdo.14068654
  Maps -> /home/vsts/work/1/s/artifacts/bin/Maps/Debug/net10.0/Microsoft.Maui.Maps.dll
  ##vso[build.updatebuildnumber]10.0.70-ci+azdo.14068654
  Controls.Core -> /home/vsts/work/1/s/artifacts/bin/Controls.Core/Debug/net10.0/Microsoft.Maui.Controls.dll
  ##vso[build.updatebuildnumber]10.0.70-ci+azdo.14068654
  Controls.Xaml -> /home/vsts/work/1/s/artifacts/bin/Controls.Xaml/Debug/net10.0/Microsoft.Maui.Controls.Xaml.dll
  ##vso[build.updatebuildnumber]10.0.70-ci+azdo.14068654
  Controls.Maps -> /home/vsts/work/1/s/artifacts/bin/Controls.Maps/Debug/net10.0/Microsoft.Maui.Controls.Maps.dll
  TestUtils -> /home/vsts/work/1/s/artifacts/bin/TestUtils/Debug/netstandard2.0/Microsoft.Maui.TestUtils.dll
  Controls.Core.UnitTests -> /home/vsts/work/1/s/artifacts/bin/Controls.Core.UnitTests/Debug/net10.0/Microsoft.Maui.Controls.Core.UnitTests.dll
Test run for /home/vsts/work/1/s/artifacts/bin/Controls.Core.UnitTests/Debug/net10.0/Microsoft.Maui.Controls.Core.UnitTests.dll (.NETCoreApp,Version=v10.0)
VSTest version 18.0.1 (x64)

Starting test execution, please wait...
A total of 1 test files matched the specified pattern.
[xUnit.net 00:00:00.00] xUnit.net VSTest Adapter v2.8.2+699d445a1a (64-bit .NET 10.0.0)
[xUnit.net 00:00:00.25]   Discovering: Microsoft.Maui.Controls.Core.UnitTests
[xUnit.net 00:00:02.71]   Discovered:  Microsoft.Maui.Controls.Core.UnitTests
[xUnit.net 00:00:02.73]   Starting:    Microsoft.Maui.Controls.Core.UnitTests
  Passed OptionalWithConstraint [142 ms]
  Passed OptionalParam_WithCollapsedPrefix_NavigationSucceeds [4 ms]
  Passed SatisfiesConstraint_Alpha [2 ms]
  Passed Parse_MixedSegmentWithSuffix [< 1 ms]
  Passed CatchAll_UrlEncodedSegments [3 ms]
  Passed ConstraintWithDefault_AbsentUsesDefault [2 ms]
  Passed RegisterRouteTemplate_DetectedAsTemplate [< 1 ms]
  Passed MultipleRequiredParamsInChain [3 ms]
  Passed Parse_EmptyParameterName_Rejected [< 1 ms]
  Passed Parse_CatchAllParameter [< 1 ms]
  Passed Parse_ConstraintOptionalAndDefault_Combo [< 1 ms]
  Passed OptionalParameter_PresentInUri_DeliveredToPage [2 ms]
  Passed Constraint_Alpha_MatchesAlphaOnly [3 ms]
  Passed TemplateOnlyRoute_AmbiguousRouteIsDocumentedLimitation [8 ms]
  Passed PathParameter_DeliveredViaIQueryAttributable [11 ms]
  Passed Constraint_AcceptedWhenShellContentMatchesPrefix [1 ms]
  Passed UrlEncodedPathParameter_IsDecoded [1 ms]
  Passed OptionalWithQueryStringFallback [1 ms]
  Passed RegisterRoute_AcceptsOptionalTemplateSyntax [< 1 ms]
  Passed Parse_OptionalParameter [< 1 ms]
  Passed Constraint_Int_MatchesNumericValue [1 ms]
  Passed CatchAll_CapturesAllRemainingSegments [18 ms]
  Passed DefaultValue_AbsentInUri_DefaultDelivered [1 ms]
  Passed SatisfiesConstraint_Long [< 1 ms]
  Passed Parse_MalformedBraces_Rejected [< 1 ms]
  Passed OptionalParameter_AbsentInUri_NavigationSucceeds [1 ms]
  Passed Parse_DefaultValueParameter [< 1 ms]
  Passed SatisfiesConstraint_Int [< 1 ms]
  Passed OptionalWithRequired_MiddleOptionalRejected [< 1 ms]
  Passed SatisfiesConstraint_Bool [< 1 ms]
  Passed Constraint_Int_RejectsNonNumeric [2 ms]
  Passed Constraint_EnforcedWhenShellContentMatchesPrefix [2 ms]
  Passed MultiSegmentTemplate_ChildInheritsParentPathParameter [1 ms]
  Passed Constraint_Guid_MatchesValidGuid [1 ms]
  Passed DefaultWithQueryStringInteraction [1 ms]
  Passed MixedSegment_SuffixAndParameter [1 ms]
  Passed TwoParamsInSingleRoute [2 ms]
  Passed PathParameterOverridesQueryStringWithSameName [1 ms]
  Passed CatchAll_EmptyRemainingSegments [1 ms]
  Passed CurrentStateLocation_ShowsResolvedValues [1 ms]
  Passed IntermediatePage_ReceivesOwnPathParameter [1 ms]
  Passed SatisfiesConstraint_Double [11 ms]
  Passed OptionalWithRequired_OptionalAtEnd [2 ms]
  Passed MixedSegmentWithConstraint [1 ms]
  Passed Parse_ConstraintWithNoName_Rejected [< 1 ms]
  Passed DefaultParam_WithCollapsedPrefix_NavigationSucceeds [1 ms]
  Passed DefaultWithChildPageInheritance [1 ms]
  Passed LiteralRouteUnchanged_RegistersAsLiteral [< 1 ms]
  Passed Routing_Clear_AlsoClearsTemplates [< 1 ms]
  Passed RegisterRoute_AcceptsCatchAllTemplateSyntax [< 1 ms]
  Passed Constraint_UnknownType_RejectedAtRegistration [< 1 ms]
  Passed ConstraintWithLiteralPrecedence [2 ms]
  Passed UnregisterTemplateRoute_NoLongerDetected [< 1 ms]
  Passed PathParameter_MixedWithUnrelatedQueryString [1 ms]
  Passed LiteralRouteWinsOverTemplateRoute [1 ms]
  Passed Parse_UnknownConstraint_Rejected [< 1 ms]
  Passed RegisterRoute_RejectsDuplicateParameters [< 1 ms]
  Passed SatisfiesConstraint_GuidRejectsInvalid [< 1 ms]
  Passed Parse_ConstraintAndDefault [< 1 ms]
  Passed Parse_ConstrainedParameter [< 1 ms]
  Passed SatisfiesConstraint_Guid [< 1 ms]
  Passed RegisterRoute_RejectsDefaultThatViolatesConstraint [< 1 ms]
  Passed SinglePathParameter_DeliveredViaQueryProperty [1 ms]
  Passed MixedSegment_PrefixAndParameter [1 ms]
  Passed MixedSegment_PrefixMismatchRejects [2 ms]
  Passed SecondNavigation_DifferentValue_DeliveredCorrectly [1 ms]
  Passed ConstraintWithDefault_PresentUsesValue [1 ms]
  Passed Constraint_Alpha_RejectsNumeric [1 ms]
  Passed TemplateRoute_RenavigationPreservesPageInstance [2 ms]
  Passed CatchAll_MustBeLastSegment [< 1 ms]
  Passed DefaultValue_PresentInUri_OverridesDefault [1 ms]
  Passed RegisterRoute_RejectsOptionalInMiddle [< 1 ms]
[xUnit.net 00:00:03.13]   Finished:    Microsoft.Maui.Controls.Core.UnitTests
  Passed RelativeNavigation_WithTemplateRoute [2 ms]
  Passed Parse_CatchAllNotLast_Rejected [< 1 ms]
  Passed MixedSegment_PrefixSuffixAndParameter [1 ms]
  Passed Parse_MixedSegment [< 1 ms]
  Passed TemplateAndLiteralRouteTogether [1 ms]
  Passed ReusedPage_ResolvedRouteUpdated [1 ms]
  Passed CatchAll_SingleSegment [1 ms]
  Passed RegisterRoute_RejectsMultipleParamsInMixedSegment [< 1 ms]
  Passed TwoDifferentTemplatesSameNavigation [1 ms]

Test Run Successful.
Total tests: 81
     Passed: 81
 Total time: 4.1156 Seconds

📁 Fix files reverted (12 files)
  • src/Controls/samples/Controls.Sample.Sandbox/App.xaml.cs
  • src/Controls/samples/Controls.Sample.Sandbox/MainPage.xaml
  • src/Controls/samples/Controls.Sample.Sandbox/MainPage.xaml.cs
  • src/Controls/samples/Controls.Sample.Sandbox/SandboxShell.xaml
  • src/Controls/samples/Controls.Sample.Sandbox/SandboxShell.xaml.cs
  • src/Controls/src/Core/Routing.cs
  • src/Controls/src/Core/Shell/RequestDefinition.cs
  • src/Controls/src/Core/Shell/RouteRequestBuilder.cs
  • src/Controls/src/Core/Shell/ShellNavigationManager.cs
  • src/Controls/src/Core/Shell/ShellSection.cs
  • src/Controls/src/Core/Shell/ShellUriHandler.cs
  • src/Core/src/PublicAPI/net/PublicAPI.Unshipped.txt

New files (not reverted):

  • .playwright-mcp/page-2026-05-07T17-59-54-493Z.yml
  • src/Controls/samples/Controls.Sample.Sandbox/OrderDetailPage.xaml
  • src/Controls/samples/Controls.Sample.Sandbox/OrderDetailPage.xaml.cs
  • src/Controls/samples/Controls.Sample.Sandbox/OrdersPage.xaml
  • src/Controls/samples/Controls.Sample.Sandbox/OrdersPage.xaml.cs
  • src/Controls/samples/Controls.Sample.Sandbox/ProductPage.xaml
  • src/Controls/samples/Controls.Sample.Sandbox/ProductPage.xaml.cs
  • src/Controls/samples/Controls.Sample.Sandbox/ReviewPage.xaml
  • src/Controls/samples/Controls.Sample.Sandbox/ReviewPage.xaml.cs
  • src/Controls/src/Core/Shell/RouteTemplate.cs
  • src/Core/src/Navigation/FakeNavigationService.cs
  • src/Core/src/Navigation/IActiveAware.cs
  • src/Core/src/Navigation/IInitializeAsync.cs
  • src/Core/src/Navigation/INavigationAware.cs
  • src/Core/src/Navigation/INavigationGraphBuilder.cs
  • src/Core/src/Navigation/INavigationGuard.cs
  • src/Core/src/Navigation/INavigationParameters.cs
  • src/Core/src/Navigation/INavigationService.cs
  • src/Core/src/Navigation/NavigationContext.cs
  • src/Core/src/Navigation/NavigationEventArgs.cs
  • src/Core/src/Navigation/NavigationResult.cs
  • src/Core/src/Navigation/PresentationOptions.cs
  • src/Core/src/Navigation/RouteAttribute.cs
  • src/Core/src/Navigation/RouteParameterAttribute.cs

🧪 UI Tests — Shell

Detected UI test categories: Shell

Deep UI tests — 297 passed, 0 failed across 1 category on platform-pool agent (replaces in-process counts above).

🧪 UI Test Execution Results (deep, platform pool)

Category Tests Snapshot diffs
Shell 297/297 ✓
📎 Download drop-deep-uitests artifact (TRX + snapshot diffs)

@MauiBot MauiBot added s/agent-review-incomplete and removed s/agent-changes-requested AI agent recommends changes - found a better alternative or issues labels May 11, 2026
@mattleibow mattleibow force-pushed the mattleibow/shell-route-templates-spec branch from 73d24f8 to a1d46af Compare May 13, 2026 16:39
@PureWeen
Copy link
Copy Markdown
Member

/review

kubaflo pushed a commit that referenced this pull request May 19, 2026
…ory fails before tests

When a test category fails because the build or deploy crashed before any
test could run (e.g. CS0246 missing namespace, RS0016 PublicAPI errors),
the AI summary table previously showed '0/1 ✓' — the green-checkmark
'all passed' branch — because no per-test failures were parsed. That's
visually misleading: the row is FAILED but the cell looks healthy.

Two fixes:

1. Tests column distinguishes 'category failed AND no per-test failures
   parsed' from 'all tests passed':
     - 'build/deploy failed'                              (no tests at all)
     - '0/1 — build/deploy failed before per-test results'  (some discovered)

2. New optional 'build_tail' field captures the last 30 lines of stdout
   when a category fails with zero per-test failures. The Failed test
   details collapsible section then renders it in a code block so
   reviewers see the actual compiler error / build crash inline,
   instead of having to download the full CopilotLogs artifact.

This was discovered while running the regression-check pipeline against
PRs #35110 (142 RS0016 PublicAPI errors), #35281 (CS0246 NSAttributedString
missing for catalyst), and #35358 — all reported as '0/1 ✓' before the fix.

Co-authored-by: Copilot <223556219+Copilot@users.noreply.github.com>
mattleibow and others added 10 commits May 21, 2026 23:45
Adds docs/specs/shell-route-templates.md with the full design for additive
{param} path parameters in Shell routes (issue #35107 Proposal A), and a
standalone prototype at prototype/ShellRouteTemplates that demonstrates the
parser, matcher, and precedence rules independently of MAUI internals.

Co-authored-by: Copilot <223556219+Copilot@users.noreply.github.com>
Adds support for route templates like 'product/{sku}' in Shell
navigation, allowing path parameters to be extracted from URIs and
delivered to pages via [QueryProperty] / IQueryAttributable.

Key changes:
- RouteTemplate.cs: template parser and segment helpers
- Routing.cs: detect and store template routes alongside literal routes
- RouteRequestBuilder.cs: template-aware segment matching with parameter capture
- ShellUriHandler.cs: two-pass matching (literal-first precedence),
  template-aware CollapsePath
- RequestDefinition.cs: exposes PathParameters from winning route
- ShellNavigationManager.cs: seeds path params before query string params

All 5551 existing unit tests pass unchanged. 8 new tests added.

Ref: #35107

Co-authored-by: Copilot <223556219+Copilot@users.noreply.github.com>
…oss, add 9 new tests

Review findings addressed:
- Reject {sku?} optional and {*path} catch-all syntax at registration
  time (not yet implemented, was silently accepted)
- Reject duplicate parameters in templates ({id}/{id})
- Fix path parameter loss in SearchForGlobalRoutes code path
- Fix path parameter loss in GenerateRoutePaths tree+global merge
- Fix GlobalRouteItem AddMatch missing _resolvedGlobalRoutes entry
- Fix IndexOutOfRange in RequestDefinition.MakeUriString
- Add ShellSection.GetOrCreateFromRoute resolved-route override for
  template routes (CurrentState.Location fix)

9 new tests (17 total):
- CurrentState.Location shows resolved values, not template tokens
- Relative navigation limitation documented
- URL-encoded path parameters decoded correctly
- Second navigation with different value
- Reject optional/catch-all/duplicate template syntax
- IQueryAttributable receives path parameters
- All-template route ambiguity documented

All 5560 existing + new tests pass, 0 regressions.

Sandbox demo added with ProductPage, ReviewPage, and navigation buttons.

Co-authored-by: Copilot <223556219+Copilot@users.noreply.github.com>
…t values

Full route template feature set now implemented:
- {sku?}       Optional parameters (match zero or one segment)
- {*path}      Catch-all (captures all remaining segments, must be last)
- {id:int}     Constraints (int, long, double, bool, guid, alpha)
- {stars=5}    Default values (provided when segment absent)
- product-{sku} Mixed segments (literal prefix/suffix around parameter)
- {id:int=1}   Constraint + default combined

Sandbox demo updated:
- Products tab: catalog with product/{sku} route
- Orders tab: order list with order/{orderId:int} constrained route
- Product review: review/{stars=5} with default star rating
- Multi-step navigation: product → review with inherited parameters

48 template tests (31 new), 5591 total tests pass, 0 regressions.

Co-authored-by: Copilot <223556219+Copilot@users.noreply.github.com>
67 template tests now cover all features in various combinations:
- Two params in single route ({cat}/{id})
- Required + optional combos
- Optional + constraint ({id:int?})
- Optional with query string fallback
- Default value + query string interaction (default wins)
- Default value + child page inheritance
- Catch-all with URL encoding
- Catch-all with empty remaining segments
- Mixed segment + constraint (item-{id:int})
- Mixed segment prefix mismatch rejection
- Constraint + literal precedence
- Two different templates in same navigation
- Template + literal route together
- Unregister template route lifecycle
- Constraint unit tests: bool, long, double, guid-reject

5610 total tests pass, 0 regressions.

Co-authored-by: Copilot <223556219+Copilot@users.noreply.github.com>
Bugs fixed (from Opus 4.7 + GPT 5.4 reviews):
- BLOCKING: {name:int?=5} produced default '5?' instead of '5' due to
  appending '?' to inner string during constraint parsing. Fixed by using
  a separate boolean flag.
- Optional params in middle of route (e.g. a/{b?}/c) now rejected at
  registration time (matches ASP.NET Core behavior — unmatchable).
- Default values now validated against constraint at registration time
  (e.g. {id:int=hello} is now rejected).
- Multiple brace pairs in mixed segments (e.g. item-{x}-{y}) now rejected.
- IsTemplateSegment now requires { before } (rejects 'foo}bar{baz').
- Sandbox demo fixed to use absolute URIs (relative template nav unsupported).

7 new tests (74 total):
- Parse_ConstraintOptionalAndDefault_Combo — verifies {num:int?=5} combo
- RegisterRoute_RejectsDefaultThatViolatesConstraint
- RegisterRoute_RejectsOptionalInMiddle
- RegisterRoute_RejectsMultipleParamsInMixedSegment
- Parse_MalformedBraces_Rejected
- Parse_EmptyParameterName_Rejected
- Parse_ConstraintWithNoName_Rejected

5617 total tests pass, 0 regressions.

Co-authored-by: Copilot <223556219+Copilot@users.noreply.github.com>
…Uri tokens

Critical fixes from Opus 4.7 + GPT 5.4 round 3:

1. Template lookup bypass (Opus blocking): When CollapsePath stripped
   prefix segments, GetNextSegmentMatch looked up the template by the
   collapsed key (e.g. '{id:int}') instead of the registered key
   ('orders/{id:int}'), so constraints/defaults/catch-all were silently
   bypassed. Fixed by passing RouteTemplate directly from the caller.

2. SetRoute mutation (Opus blocking, GPT medium): Mutating Page.Route
   from template key to resolved value broke stack-reuse comparisons
   (Routing.GetRoute(page) == globalRoutes[i] always false), causing
   page recreation on re-navigation. Fixed with separate
   Routing.ResolvedRouteProperty — Route keeps the template key,
   ResolvedRoute stores the resolved URI for CurrentState.Location.

3. RequestDefinition.FullUri (Opus warning): Built from unresolved
   template tokens ('{sku}'), causing ShellNavigationSource
   misclassification. Fixed to use ResolvedGlobalRoutes when available.

4. Back navigation: Updated .. path and GetNavigationState to use
   ResolvedRoute ?? Route for correct URI reconstruction.

3 new tests (77 total):
- TemplateRoute_RenavigationPreservesPageInstance
- Constraint_EnforcedWhenShellContentMatchesPrefix
- Constraint_AcceptedWhenShellContentMatchesPrefix

5620 total tests pass, 0 regressions.

Co-authored-by: Copilot <223556219+Copilot@users.noreply.github.com>
…te params, FullUri

Fixes from Opus 4.7 + GPT 5.4 round 4:

1. IL2111 trimmer error (Opus blocking): ResolvedRouteProperty field
   initializer caused trimmer warning→error. Fixed by wrapping in
   CreateResolvedRouteProperty() with [UnconditionalSuppressMessage].

2. Stale ResolvedRoute on reused pages (GPT high): When re-navigating
   to same template route with different value, the reused page kept
   the old ResolvedRoute. Fixed by updating ResolvedRoute in the
   page-reuse check at PrepareCurrentStackForBeingReplaced.

3. Intermediate page parameter delivery (GPT high): Non-last pages
   in navigation chain now receive route-prefixed path params
   (e.g. 'product/{sku}.sku') for prefix-filtered ApplyQueryAttributes.

4. Path params overwriting caller params (Opus medium): Changed from
   unconditional overwrite to 'only add if not present', matching
   SetQueryStringParameters semantics.

5. RequestDefinition.FullUri (Opus medium): Only substitute resolved
   route when the route is actually a template (IsTemplateRoute check),
   preventing multi-segment literal route truncation.

6. ExpandOutGlobalRoutes cast (Opus medium): Replaced fragile
   'as IDictionary' cast with MergePathParameters() helper.

4 new tests (81 total):
- IntermediatePage_ReceivesOwnPathParameter
- ReusedPage_ResolvedRouteUpdated
- OptionalParam_WithCollapsedPrefix_NavigationSucceeds
- DefaultParam_WithCollapsedPrefix_NavigationSucceeds

5624 total tests pass, 0 regressions.

Co-authored-by: Copilot <223556219+Copilot@users.noreply.github.com>
…ram precedence

Fixes from Opus 4.7 + GPT 5.4 round 5:

1. Tautological tests fixed: IntermediatePage, DefaultWithChild,
   OptionalParam_WithCollapsedPrefix, DefaultParam_WithCollapsedPrefix
   all now assert meaningful conditions instead of always-true checks.

2. Modal-stack reuse path: ResolvedRoute is now also updated in the
   pop/reapply loop (ShellSection.cs line 417 path), not just the
   non-modal PrepareCurrentStackForBeingReplaced path.

3. Route-prefixed param seeding uses 'only add if not present'
   semantics, matching the same precedence rule as unprefixed params.

5624 total tests pass, 0 regressions.

Co-authored-by: Copilot <223556219+Copilot@users.noreply.github.com>
The Samples integration tests build with TreatWarningsAsErrors=true.
Backing fields for QueryProperty properties in ProductPage, ReviewPage,
and OrderDetailPage were non-nullable, causing CS8618 warnings→errors.

Fixed by making all backing fields and properties nullable (string?).

Co-authored-by: Copilot <223556219+Copilot@users.noreply.github.com>
@mattleibow mattleibow force-pushed the mattleibow/shell-route-templates-spec branch from a1d46af to 1e9f8ef Compare May 21, 2026 21:48
kubaflo pushed a commit that referenced this pull request May 22, 2026
…ory fails before tests

When a test category fails because the build or deploy crashed before any
test could run (e.g. CS0246 missing namespace, RS0016 PublicAPI errors),
the AI summary table previously showed '0/1 ✓' — the green-checkmark
'all passed' branch — because no per-test failures were parsed. That's
visually misleading: the row is FAILED but the cell looks healthy.

Two fixes:

1. Tests column distinguishes 'category failed AND no per-test failures
   parsed' from 'all tests passed':
     - 'build/deploy failed'                              (no tests at all)
     - '0/1 — build/deploy failed before per-test results'  (some discovered)

2. New optional 'build_tail' field captures the last 30 lines of stdout
   when a category fails with zero per-test failures. The Failed test
   details collapsible section then renders it in a code block so
   reviewers see the actual compiler error / build crash inline,
   instead of having to download the full CopilotLogs artifact.

This was discovered while running the regression-check pipeline against
PRs #35110 (142 RS0016 PublicAPI errors), #35281 (CS0246 NSAttributedString
missing for catalyst), and #35358 — all reported as '0/1 ✓' before the fix.

Co-authored-by: Copilot <223556219+Copilot@users.noreply.github.com>
@kubaflo kubaflo marked this pull request as ready for review May 22, 2026 10:53
@kubaflo
Copy link
Copy Markdown
Contributor

kubaflo commented May 22, 2026

/review -b feature/regression-check

Copy link
Copy Markdown
Collaborator

@MauiBot MauiBot left a comment

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Expert Review — 2 findings

See inline comments for details.

parameters[kvp.Key] = kvp.Value;
}

// Also seed route-prefixed keys so intermediate (non-last) pages
Copy link
Copy Markdown
Collaborator

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

[major] Navigation & Shell — This assumes prefix-filtered keys will deliver path parameters to intermediate pages, but ApplyQueryAttributes only forwards filtered values for ShellContent/BaseShellItem or the last item. A non-last global-route Page created by ShellSection.GetOrCreateFromRoute (for example product/{sku} before review) is a plain Page, so its [QueryProperty]/IQueryAttributable values are skipped and ProductPage.Sku remains unset. Reapply query attributes directly to each global-route page using that route's own captures, or extend ApplyQueryAttributes to handle non-last Page instances.

{
if (!seg.IsParameter)
continue;
if (pathParameters.TryGetValue(seg.Value, out var val))
Copy link
Copy Markdown
Collaborator

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

[major] Navigation & Shell — Route-prefixed keys are derived from the merged request-level PathParameters dictionary, which has already lost which route captured each value. With chained templates like a/{id} followed by b/{id}, the flat dictionary contains only one id value, so both a/{id}.id and b/{id}.id can be seeded with the innermost value. Keep captures per GlobalRouteMatch/resolved route, or emit prefixed keys when each route is matched, so each intermediate page receives its own route value.

@kubaflo
Copy link
Copy Markdown
Contributor

kubaflo commented May 24, 2026

/review -b feature/refactor-copilot-yml

Copy link
Copy Markdown
Collaborator

@MauiBot MauiBot left a comment

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

🤖 Automated review — alternative fix proposed

The expert-reviewer evaluation compared the PR fix against #2 automatically generated candidates and selected try-fix-2 as the strongest fix.

Why: try-fix-2 is the smallest regression-passing candidate that fixes the PR's route-capture correctness bugs: intermediate page delivery, repeated parameter-name corruption, and mixed-segment crashes. It preserves the existing flat transport shape while adding route-position scoping, making it lower risk than broader structural alternatives.

Please consider applying the candidate diff below (or use it as guidance). Once you push an update, this workflow will re-trigger and re-evaluate.

Candidate diff (`try-fix-2`)
diff --git a/eng/pipelines/ci-copilot.yml b/eng/pipelines/ci-copilot.yml
index 56ec29e6e3..0f5e04b722 100644
--- a/eng/pipelines/ci-copilot.yml
+++ b/eng/pipelines/ci-copilot.yml
@@ -12,7 +12,8 @@ pr: none  # Not triggered by PRs
 parameters:
   - name: PRNumber
     displayName: 'Pull Request Number'
-    type: number
+    type: string
+    default: ''
 
   - name: Platform
     displayName: 'Target Platform'
@@ -79,10 +80,7 @@ stages:
         steps:
           - checkout: self
             fetchDepth: 0
-            persistCredentials: false
-            # persistCredentials is false — tasks that need GitHub access
-            # (Setup, Post) use GH_TOKEN env var instead. This limits the
-            # blast radius: Gate and CopilotReview tasks cannot push to the repo.
+            persistCredentials: true
 
           # Validate Parameters
           # PRNumber is received via env var to avoid compile-time shell injection.
@@ -378,11 +376,24 @@ stages:
               echo "GitHub CLI ready"
             displayName: 'Install GitHub CLI'
 
-          # NOTE: Removed `gh auth login` step. With the phased task design,
-          # GH_TOKEN is passed as an env var only to Setup and Post tasks, and
-          # `gh` uses GH_TOKEN directly without needing `gh auth login`. This
-          # avoids persisting credentials in the agent's gh auth store where
-          # Gate and CopilotReview tasks could access them.
+          - bash: |
+              echo "Authenticating with GitHub CLI..."
+              if [ -z "$GH_TOKEN" ]; then
+                echo "##vso[task.logissue type=error]GH_TOKEN env var (from pipeline variable GH_COMMENT_TOKEN) is not set. Please configure the pipeline variable."
+                exit 1
+              fi
+              gh auth status
+              if [ $? -ne 0 ]; then
+                echo "$GH_TOKEN" | gh auth login --with-token 2>/dev/null || true
+                if ! gh auth status; then
+                  echo "##vso[task.logissue type=error]GitHub CLI authentication failed"
+                  exit 1
+                fi
+              fi
+              echo "GitHub CLI authenticated successfully"
+            displayName: 'Authenticate GitHub CLI'
+            env:
+              GH_TOKEN: $(GH_COMMENT_TOKEN)
 
           - bash: |
               echo "Installing GitHub Copilot CLI..."
@@ -564,16 +575,16 @@ stages:
             timeoutInMinutes: 6
             retryCountOnTaskFailure: 2
 
-          # ─────────────────────────────────────────────────────────
-          #  Task 1 — SETUP: symlink copilot, git config, env prep,
-          #  copy trusted scripts, invoke Review-PR.ps1 -Phase Setup
-          #  env: GH_TOKEN (for branch checkout / PR merge)
-          # ─────────────────────────────────────────────────────────
           - bash: |
-              echo "═══ TASK 1: SETUP ═══"
-
+              echo "Running Copilot PR Reviewer Agent via Review-PR.ps1..."
+              echo "Reviewing PR #${{ parameters.PRNumber }}..."
               # Ensure copilot CLI is accessible to pwsh subprocess.
+              # npm global install on Linux goes to UseNode@1 toolcache path which may not
+              # be on PATH inside pwsh even when exported from bash. Create a symlink in
+              # /usr/local/bin (Unix) or verify PATH (Windows).
               if [[ "$(uname -o 2>/dev/null || uname -s)" == *"Msys"* ]] || [[ "$(uname -o 2>/dev/null || uname -s)" == *"Windows"* ]] || [[ "$(uname -o 2>/dev/null || uname -s)" == *"MINGW"* ]]; then
+                # Windows (Git Bash): npm global bin is usually already on PATH
+                echo "Windows detected — verifying copilot is on PATH..."
                 COPILOT_PATH=$(which copilot 2>/dev/null || true)
                 echo "copilot location: ${COPILOT_PATH:-not found}"
                 if [ -z "$COPILOT_PATH" ]; then
@@ -581,6 +592,7 @@ stages:
                   exit 1
                 fi
               else
+                # Linux/macOS: symlink to /usr/local/bin
                 COPILOT_PATH=$(which copilot 2>/dev/null || find /opt/hostedtoolcache/node -name copilot -type f 2>/dev/null | head -1)
                 if [ -n "$COPILOT_PATH" ] && [ ! -f /usr/local/bin/copilot ]; then
                   sudo ln -sf "$COPILOT_PATH" /usr/local/bin/copilot
@@ -593,147 +605,84 @@ stages:
                   exit 1
                 fi
               fi
+              # Verify pwsh can find it
               pwsh -NoProfile -c 'Write-Host "pwsh sees copilot at: $(Get-Command copilot -ErrorAction SilentlyContinue | Select-Object -ExpandProperty Source)"'
-
-              # Configure git identity
+              
+              # Configure git identity (required for merge operations on self-hosted agents)
               git config user.email "copilot-ci@microsoft.com"
               git config user.name "Copilot CI"
-
-              # Create Directory.Build.Override.props
+              echo "Git identity configured"
+              
+              # Create Directory.Build.Override.props to skip Xcode version check (not needed on Windows)
               cp Directory.Build.Override.props.in Directory.Build.Override.props
               if [[ "$(uname)" == "Linux" ]]; then
                 sed -i 's|</Project>|  <PropertyGroup><ValidateXcodeVersion>false</ValidateXcodeVersion></PropertyGroup>\n</Project>|' Directory.Build.Override.props
               elif [[ "$(uname)" == "Darwin" ]]; then
                 sed -i '' 's|</Project>|  <PropertyGroup><ValidateXcodeVersion>false</ValidateXcodeVersion></PropertyGroup>\n</Project>|' Directory.Build.Override.props
               else
+                # Windows (Git Bash) — GNU sed, same as Linux
                 sed -i 's|</Project>|  <PropertyGroup><ValidateXcodeVersion>false</ValidateXcodeVersion></PropertyGroup>\n</Project>|' Directory.Build.Override.props
               fi
-
-              # Create artifacts directory
+              
+              # Create artifacts directory for Copilot outputs
               mkdir -p $(Build.ArtifactStagingDirectory)/copilot-logs
-
-              # Copy trusted scripts from the checked-out commit so later tasks
-              # (which may be on a merged/modified worktree) use the same .github/
-              # files that were reviewed and approved on main.
-              TRUSTED="$(Build.ArtifactStagingDirectory)/trusted-github"
-              mkdir -p "$TRUSTED"
-              cp -r .github/scripts "$TRUSTED/scripts"
-              cp -r .github/skills  "$TRUSTED/skills"
-              echo "Trusted scripts copied to $TRUSTED"
-
-              # Run Setup phase (branch checkout + PR merge)
-              set +e
-              pwsh -NoProfile .github/scripts/Review-PR.ps1 \
-                -PRNumber "${PARAM_PR_NUMBER}" \
-                -Platform "${{ parameters.Platform }}" \
-                -Phase Setup \
-                -TrustedScriptsDir "$TRUSTED" \
-                -LogFile "$(Build.ArtifactStagingDirectory)/copilot-logs/copilot_review_output.md"
-              SETUP_EXIT=$?
-              set -e
-
-              if [ $SETUP_EXIT -ne 0 ]; then
-                echo "##vso[task.logissue type=error]Setup phase failed with exit code $SETUP_EXIT"
-                echo "##vso[task.setvariable variable=CopilotFailed]true"
-              fi
-            name: RunSetup
-            displayName: 'Task 1: Setup (branch + merge)'
-            env:
-              GH_TOKEN: $(GH_COMMENT_TOKEN)
-              PARAM_PR_NUMBER: ${{ parameters.PRNumber }}
-
-          # ─────────────────────────────────────────────────────────
-          #  Task 2 — GATE: UI detection, test runs, regression,
-          #  gate verification. GH_TOKEN is read-only here — needed
-          #  by Detect-TestsInDiff.ps1 for PR metadata/label fetches.
-          #  No COPILOT_GITHUB_TOKEN — the agent can't run here.
-          # ─────────────────────────────────────────────────────────
-          - bash: |
-              echo "═══ TASK 2: GATE ═══"
-              TRUSTED="$(Build.ArtifactStagingDirectory)/trusted-github"
-
-              set +e
-              pwsh -NoProfile "$TRUSTED/scripts/Review-PR.ps1" \
-                -PRNumber "${PARAM_PR_NUMBER}" \
-                -Platform "${{ parameters.Platform }}" \
-                -Phase Gate \
-                -TrustedScriptsDir "$TRUSTED" \
-                -LogFile "$(Build.ArtifactStagingDirectory)/copilot-logs/copilot_review_output.md"
-              GATE_EXIT=$?
-              set -e
-
-              if [ $GATE_EXIT -ne 0 ]; then
-                echo "##vso[task.logissue type=warning]Gate phase exited with code $GATE_EXIT"
-                echo "##vso[task.setvariable variable=GateFailed]true"
-              fi
-            name: RunGate
-            displayName: 'Task 2: Gate (test verification)'
-            env:
-              GH_TOKEN: $(GH_COMMENT_TOKEN)
-              PARAM_PR_NUMBER: ${{ parameters.PRNumber }}
-
-          # ─────────────────────────────────────────────────────────
-          #  Task 3 — COPILOT REVIEW: expert review + try-fix.
-          #  env: COPILOT_GITHUB_TOKEN (for copilot agent).
-          #  NO GH_TOKEN — the agent can't push or post comments.
-          # ─────────────────────────────────────────────────────────
-          - bash: |
-              echo "═══ TASK 3: COPILOT REVIEW ═══"
-              TRUSTED="$(Build.ArtifactStagingDirectory)/trusted-github"
-
+              
+              # Invoke the PR reviewer using our PowerShell script
+              # The script will merge the PR into the current branch
+              # -PostSummaryComment and -RunFinalize handle posting comments
               echo "Review platform: ${{ parameters.Platform }}"
-
+              
               set +e
-              pwsh -NoProfile "$TRUSTED/scripts/Review-PR.ps1" \
-                -PRNumber "${PARAM_PR_NUMBER}" \
-                -Platform "${{ parameters.Platform }}" \
-                -Phase CopilotReview \
-                -TrustedScriptsDir "$TRUSTED" \
-                -LogFile "$(Build.ArtifactStagingDirectory)/copilot-logs/copilot_review_output.md"
-              REVIEW_EXIT=$?
+              pwsh -NoProfile .github/scripts/Review-PR.ps1 -PRNumber "${PARAM_PR_NUMBER}" -Platform "${{ parameters.Platform }}" -LogFile "$(Build.ArtifactStagingDirectory)/copilot-logs/copilot_review_output.md"
+              COPILOT_EXIT_CODE=$?
               set -e
-
-              if [ $REVIEW_EXIT -ne 0 ]; then
-                echo "##vso[task.logissue type=error]CopilotReview phase failed with exit code $REVIEW_EXIT"
-                echo "##vso[task.setvariable variable=CopilotFailed]true"
+              
+              echo "Review-PR.ps1 exit code: $COPILOT_EXIT_CODE"
+              
+              # Terminate any orphaned copilot CLI processes that could hold this step's
+              # stdout fd open and prevent the bash step from exiting.
+              # Only target processes whose command line includes the copilot CLI path.
+              echo "Cleaning up orphaned copilot processes..."
+              SELF_PID=$$
+              for proc in $(pgrep -f "[c]opilot" 2>/dev/null || true); do
+                if [ -n "$proc" ] && [ "$proc" != "$SELF_PID" ]; then
+                  PROC_CMD=$(ps -p "$proc" -o args= 2>/dev/null || true)
+                  if echo "$PROC_CMD" | grep -q "copilot"; then
+                    echo "  Stopping copilot process $proc: $PROC_CMD"
+                    kill "$proc" 2>/dev/null || true
+                  fi
+                fi
+              done
+              
+              # Copy any Copilot session files (bash — works on Linux/macOS)
+              if [ -d "$HOME/.copilot" ]; then
+                echo "Copying Copilot session state..."
+                cp -r "$HOME/.copilot" $(Build.ArtifactStagingDirectory)/copilot-logs/copilot-session-state || true
               fi
-            name: RunReview
-            displayName: 'Task 3: Copilot Review (expert review + try-fix)'
-            env:
-              COPILOT_GITHUB_TOKEN: $(COPILOT_TOKEN)
-              DEVICE_UDID: $(DEVICE_UDID)
-              PARAM_PR_NUMBER: ${{ parameters.PRNumber }}
-              COMMENTS_VIA_FILE: "true"
-
-          # ─────────────────────────────────────────────────────────
-          #  Task 4 — POST: gate comment, AI summary, labels.
-          #  env: GH_TOKEN (for posting comments).
-          # ─────────────────────────────────────────────────────────
-          - bash: |
-              echo "═══ TASK 4: POST ═══"
-              TRUSTED="$(Build.ArtifactStagingDirectory)/trusted-github"
-
-              set +e
-              pwsh -NoProfile "$TRUSTED/scripts/Review-PR.ps1" \
-                -PRNumber "${PARAM_PR_NUMBER}" \
-                -Platform "${{ parameters.Platform }}" \
-                -Phase Post \
-                -TrustedScriptsDir "$TRUSTED" \
-                -LogFile "$(Build.ArtifactStagingDirectory)/copilot-logs/copilot_review_output.md"
-              POST_EXIT=$?
-              set -e
-
-              if [ $POST_EXIT -ne 0 ]; then
-                echo "##vso[task.logissue type=error]Post phase failed with exit code $POST_EXIT"
+              
+              # Check for failure indicators in output
+              if [ $COPILOT_EXIT_CODE -ne 0 ]; then
+                echo "##vso[task.logissue type=error]Review-PR.ps1 exited with code $COPILOT_EXIT_CODE"
+                # Don't exit yet - let artifacts be published first
                 echo "##vso[task.setvariable variable=CopilotFailed]true"
               fi
-
+              
+              # Check output for common failure patterns
+              if grep -qi "error\|failed\|exception" $(Build.ArtifactStagingDirectory)/copilot-logs/copilot_review_output.md 2>/dev/null; then
+                if grep -qi "simulator.*not\|emulator.*not\|workload.*not\|sdk.*not found" $(Build.ArtifactStagingDirectory)/copilot-logs/copilot_review_output.md 2>/dev/null; then
+                  echo "##vso[task.logissue type=warning]Copilot encountered environment issues. Check artifacts for details."
+                fi
+              fi
+              
               echo "Review output saved to $(Build.ArtifactStagingDirectory)/copilot-logs/"
-            name: RunPost                       # Stage 3 (UpdateAISummaryComment) reads aiSummaryCommentId via $(stageDependencies.ReviewPR.CopilotReview.outputs['RunPost.aiSummaryCommentId']). Note: detectedCategories comes from RunGate, not RunPost.
-            displayName: 'Task 4: Post (comments + labels)'
+            name: RunReview                       # referenceable name so the new RunDeepUITests / UpdateAISummaryComment stages can read this step's output variables (detectedCategories, detectedPlatform) via $(stageDependencies.ReviewPR.CopilotReview.outputs['RunReview.<var>'])
+            displayName: 'Run PR Reviewer Agent'
             env:
+              COPILOT_GITHUB_TOKEN: $(COPILOT_TOKEN)
               GH_TOKEN: $(GH_COMMENT_TOKEN)
+              DEVICE_UDID: $(DEVICE_UDID)
               PARAM_PR_NUMBER: ${{ parameters.PRNumber }}
+              COMMENTS_VIA_FILE: "true"
               DEFER_COMMENT_TO_STAGE3: "true"
 
           # Copy review artifacts into the CopilotLogs staging dir.
@@ -785,19 +734,13 @@ stages:
               publishLocation: 'pipeline'
             condition: and(succeededOrFailed(), ne(variables['LogDirectory'], ''))
 
-          # Fail the pipeline if any phase failed
+          # Fail the pipeline if Copilot failed
           - bash: |
-              FAILED=0
               if [ "$(CopilotFailed)" = "true" ]; then
                 echo "##vso[task.logissue type=error]Copilot PR review failed. Check CopilotLogs artifact for details."
-                FAILED=1
-              fi
-              if [ "$(GateFailed)" = "true" ]; then
-                echo "##vso[task.logissue type=warning]Gate phase failed — test verification did not pass."
-                FAILED=1
+                exit 1
               fi
-              exit $FAILED
-            displayName: 'Check Review Result'
+            displayName: 'Check Copilot Result'
             condition: succeededOrFailed()
 
   # ─────────────────────────────────────────────────────────────────────────────
@@ -824,20 +767,12 @@ stages:
   - stage: RunDeepUITests
     displayName: 'Deep UI Tests (platform pool)'
     dependsOn: ReviewPR
-    # Prefer AI-refreshed categories from CopilotReview (RunReview) when available,
-    # falling back to Gate-detected categories (RunGate). RunReview is only set when
-    # the Tier 3 AI refresh actually changed the categories; otherwise it's empty.
-    condition: >-
-      and(
-        in(dependencies.ReviewPR.result, 'Succeeded', 'SucceededWithIssues', 'Failed'),
-        ne(coalesce(dependencies.ReviewPR.outputs['CopilotReview.RunReview.detectedCategories'], dependencies.ReviewPR.outputs['CopilotReview.RunGate.detectedCategories']), ''),
-        ne(coalesce(dependencies.ReviewPR.outputs['CopilotReview.RunReview.detectedCategories'], dependencies.ReviewPR.outputs['CopilotReview.RunGate.detectedCategories']), 'NONE')
-      )
+    condition: and(in(dependencies.ReviewPR.result, 'Succeeded', 'SucceededWithIssues', 'Failed'), ne(dependencies.ReviewPR.outputs['CopilotReview.RunReview.detectedCategories'], ''), ne(dependencies.ReviewPR.outputs['CopilotReview.RunReview.detectedCategories'], 'NONE'))
     jobs:
       - job: RunUITests
         displayName: 'Run detected UI test categories'
         variables:
-          detectedCategories: $[ coalesce(stageDependencies.ReviewPR.CopilotReview.outputs['RunReview.detectedCategories'], stageDependencies.ReviewPR.CopilotReview.outputs['RunGate.detectedCategories']) ]
+          detectedCategories: $[ stageDependencies.ReviewPR.CopilotReview.outputs['RunReview.detectedCategories'] ]
         # Use the SAME platform-pool selection logic as the CopilotReview
         # job — the deep-test agent should be the right OS for the
         # requested target platform.
@@ -1310,7 +1245,7 @@ stages:
     dependsOn:
       - ReviewPR
       - RunDeepUITests
-    condition: and(in(dependencies.RunDeepUITests.result, 'Succeeded', 'SucceededWithIssues', 'Failed', 'Skipped'), or(ne(dependencies.ReviewPR.outputs['CopilotReview.RunPost.aiSummaryCommentId'], ''), in(dependencies.RunDeepUITests.result, 'Succeeded', 'SucceededWithIssues', 'Failed')))
+    condition: and(in(dependencies.RunDeepUITests.result, 'Succeeded', 'SucceededWithIssues', 'Failed', 'Skipped'), or(ne(dependencies.ReviewPR.outputs['CopilotReview.RunReview.aiSummaryCommentId'], ''), in(dependencies.RunDeepUITests.result, 'Succeeded', 'SucceededWithIssues', 'Failed')))
     jobs:
       - job: UpdateComment
         displayName: 'Post AI summary with review + deep test results'
@@ -1319,7 +1254,7 @@ stages:
         # this just makes the value available as $(aiSummaryCommentId)
         # inside the steps.
         variables:
-          aiSummaryCommentId: $[ stageDependencies.ReviewPR.CopilotReview.outputs['RunPost.aiSummaryCommentId'] ]
+          aiSummaryCommentId: $[ stageDependencies.ReviewPR.CopilotReview.outputs['RunReview.aiSummaryCommentId'] ]
         pool:
           name: Azure Pipelines
           vmImage: ubuntu-22.04
diff --git a/src/Controls/samples/Controls.Sample.Sandbox/App.xaml.cs b/src/Controls/samples/Controls.Sample.Sandbox/App.xaml.cs
index 68a2c36f2d..9512dea98e 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 partial class App : Application
 	protected override Window CreateWindow(IActivationState? activationState)
 	{
 		// To test shell scenarios, change this to true
-		bool useShell = true;
+		bool useShell = false;
 
 		if (!useShell)
 		{
diff --git a/src/Controls/samples/Controls.Sample.Sandbox/MainPage.xaml b/src/Controls/samples/Controls.Sample.Sandbox/MainPage.xaml
index 28ae87bf4c..7363d18dea 100644
--- a/src/Controls/samples/Controls.Sample.Sandbox/MainPage.xaml
+++ b/src/Controls/samples/Controls.Sample.Sandbox/MainPage.xaml
@@ -1,49 +1,5 @@
 <ContentPage xmlns="http://schemas.microsoft.com/dotnet/2021/maui"
              xmlns:x="http://schemas.microsoft.com/winfx/2009/xaml"
              x:Class="Maui.Controls.Sample.MainPage"
-             xmlns:local="clr-namespace:Maui.Controls.Sample"
-             Title="Product Catalog">
-    <ScrollView>
-        <VerticalStackLayout Padding="20" Spacing="15">
-            <Label Text="🌱 Product Catalog"
-                   FontSize="28"
-                   FontAttributes="Bold" />
-            <Label Text="Tap a product to navigate using {sku} path parameter."
-                   FontSize="14"
-                   TextColor="Gray" />
-
-            <BoxView HeightRequest="1" Color="LightGray" />
-
-            <Button Text="🍅 Seed Tomato"
-                    AutomationId="ProductSeedTomato"
-                    Clicked="OnProductTapped"
-                    CommandParameter="seed-tomato" />
-
-            <Button Text="🌿 Herb Basil"
-                    AutomationId="ProductHerbBasil"
-                    Clicked="OnProductTapped"
-                    CommandParameter="herb-basil" />
-
-            <Button Text="🥕 Root Carrot"
-                    AutomationId="ProductRootCarrot"
-                    Clicked="OnProductTapped"
-                    CommandParameter="root-carrot" />
-
-            <BoxView HeightRequest="1" Color="LightGray" />
-
-            <Label Text="Multi-step navigation (product → review)"
-                   FontSize="14"
-                   FontAttributes="Bold" />
-
-            <Button Text="🍅 Seed Tomato → Review (default ⭐5)"
-                    AutomationId="ProductSeedTomatoReview"
-                    Clicked="OnProductReviewTapped"
-                    CommandParameter="seed-tomato" />
-
-            <Button Text="🌿 Herb Basil → Review (⭐3)"
-                    AutomationId="ProductHerbBasilReview3"
-                    Clicked="OnProductReviewWithStars"
-                    CommandParameter="herb-basil" />
-        </VerticalStackLayout>
-    </ScrollView>
+             xmlns:local="clr-namespace:Maui.Controls.Sample">
 </ContentPage>
\ 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 59ce329c6e..b7744fa262 100644
--- a/src/Controls/samples/Controls.Sample.Sandbox/MainPage.xaml.cs
+++ b/src/Controls/samples/Controls.Sample.Sandbox/MainPage.xaml.cs
@@ -6,22 +6,4 @@ public partial class MainPage : ContentPage
 	{
 		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/SandboxShell.xaml b/src/Controls/samples/Controls.Sample.Sandbox/SandboxShell.xaml
index c74e426128..358b0b04ef 100644
--- a/src/Controls/samples/Controls.Sample.Sandbox/SandboxShell.xaml
+++ b/src/Controls/samples/Controls.Sample.Sandbox/SandboxShell.xaml
@@ -4,18 +4,19 @@
     x:Class="Maui.Controls.Sample.SandboxShell"
     xmlns:local="clr-namespace:Maui.Controls.Sample"
     x:Name="shell">
-    <TabBar>
-        <Tab Title="Products" Icon="groceries.png">
-            <ShellContent
-                Title="Products"
-                ContentTemplate="{DataTemplate local:MainPage}"
-                Route="products" />
+    <TabBar Shell.TabBarForegroundColor="Green"
+        Shell.TabBarUnselectedColor="Red">
+        <Tab Title="MainPage1" Icon="groceries.png">
+            <ShellContent Icon="groceries.png"
+            Title="Home"
+            ContentTemplate="{DataTemplate local:MainPage}"
+            Route="MainPage" />
         </Tab>
-        <Tab Title="Orders" Icon="dotnet_bot.png">
-            <ShellContent
-                Title="Orders"
-                ContentTemplate="{DataTemplate local:OrdersPage}"
-                Route="orders" />
+        <Tab Title="MainPage2" Icon="dotnet_bot.png">
+            <ShellContent Icon="dotnet_bot.png"
+            Title="Home"
+            ContentTemplate="{DataTemplate local:MainPage}"
+            Route="MainPage2" />
         </Tab>
     </TabBar>
 </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 73f2c49ab4..fe774d6547 100644
--- a/src/Controls/samples/Controls.Sample.Sandbox/SandboxShell.xaml.cs
+++ b/src/Controls/samples/Controls.Sample.Sandbox/SandboxShell.xaml.cs
@@ -5,13 +5,5 @@ 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/Shell/RouteRequestBuilder.cs b/src/Controls/src/Core/Shell/RouteRequestBuilder.cs
index 28cc13e101..ededde5d2e 100644
--- a/src/Controls/src/Core/Shell/RouteRequestBuilder.cs
+++ b/src/Controls/src/Core/Shell/RouteRequestBuilder.cs
@@ -61,8 +61,21 @@ namespace Microsoft.Maui.Controls
 		// Overload that records path parameters captured for this route.
 		// <paramref name="capturedParameters"/> may be null when the route had
 		// no template segments.
+		// Internal namespace prefix for route-scoped path-parameter keys. Uses control-char
+		// delimiters so the key cannot collide with any user query-string key, registered
+		// route name, or URI segment. Stored alongside plain keys in the flat _pathParameters
+		// dictionary so MergePathParameters / copy constructors keep working unchanged.
+		internal const string RouteScopedKeyPrefix = "\u0001r";
+		internal const string RouteScopedKeyDelimiter = "\u0001";
+
+		internal static string MakeRouteScopedKey(int routeIndex, string name) =>
+			RouteScopedKeyPrefix + routeIndex.ToString(System.Globalization.CultureInfo.InvariantCulture) + RouteScopedKeyDelimiter + name;
+
 		public void AddGlobalRoute(string routeName, string segment, IDictionary<string, string> capturedParameters)
 		{
+			// Capture index BEFORE appending so route-scoped keys match the route's slot.
+			int routeIndex = _globalRouteMatches.Count;
+
 			_globalRouteMatches.Add(routeName);
 			_resolvedGlobalRoutes.Add(segment);
 
@@ -75,7 +88,18 @@ namespace Microsoft.Maui.Controls
 			if (capturedParameters != null)
 			{
 				foreach (var kvp in capturedParameters)
+				{
+					// Plain key — last-wins across routes. Carries parent captures down to
+					// the final page so a child page with [QueryProperty] for an ancestor's
+					// path parameter still receives it (matches ASP.NET Core precedence).
 					_pathParameters[kvp.Key] = kvp.Value;
+
+					// Route-scoped key — lossless per-route association used by
+					// ShellNavigationManager.ApplyQueryAttributes(... routeIndex)
+					// to deliver this exact route's captures to its own page even when
+					// later routes share the parameter name.
+					_pathParameters[MakeRouteScopedKey(routeIndex, kvp.Key)] = kvp.Value;
+				}
 			}
 		}
 
@@ -277,8 +301,13 @@ 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);
+// Length guard: if the URI segment is shorter than prefix + suffix combined,
+// there's no room for an embedded value. Fall through to no-match rather
+// than throwing ArgumentOutOfRangeException.
+var paramLength = decoded.Length - seg.Prefix.Length - seg.Suffix.Length;
+if (paramLength < 0)
+return String.Empty;
+var paramValue = decoded.Substring(seg.Prefix.Length, paramLength);
 
 if (!string.IsNullOrEmpty(seg.Constraint) &&
 !RouteTemplate.SatisfiesConstraint(seg.Constraint, paramValue))
diff --git a/src/Controls/src/Core/Shell/ShellNavigationManager.cs b/src/Controls/src/Core/Shell/ShellNavigationManager.cs
index ad7fc0e6f2..c5badd9127 100644
--- a/src/Controls/src/Core/Shell/ShellNavigationManager.cs
+++ b/src/Controls/src/Core/Shell/ShellNavigationManager.cs
@@ -107,38 +107,19 @@ namespace Microsoft.Maui.Controls
 				// parameters (from GoToAsync overload) take precedence over
 				// path-extracted values. Path params still win over query strings
 				// because SetQueryStringParameters also uses this semantics.
+				//
+				// PathParameters contains BOTH plain keys (last-route-wins, for final-page
+				// inheritance) AND route-scoped internal-prefix keys
+				// (RouteRequestBuilder.MakeRouteScopedKey). Both ride into `parameters`
+				// here and are consumed downstream by ApplyQueryAttributes(... routeIndex)
+				// in ShellSection. We no longer derive "{route}.{name}" keys — that
+				// approach broke for plain non-template Pages in the chain and corrupted
+				// repeated parameter names across chained templates.
 				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);
@@ -330,6 +311,51 @@ namespace Microsoft.Maui.Controls
 
 		public static void ApplyQueryAttributes(Element element, ShellRouteParameters query, bool isLastItem, bool isPopping)
 		{
+			ApplyQueryAttributes(element, query, isLastItem, isPopping, -1);
+		}
+
+		// Overload that accepts the page's position in the GlobalRoutes list. When
+		// routeIndex >= 0, route-scoped internal-prefix keys produced by
+		// RouteRequestBuilder.MakeRouteScopedKey(routeIndex, name) are promoted to
+		// plain keys on a per-call copy of `query` BEFORE the existing prefix-filter
+		// logic runs. This delivers exactly THIS route's path captures even when an
+		// earlier or later route in the chain uses the same parameter name (lossless
+		// transport via flat-dictionary keys).
+		internal static void ApplyQueryAttributes(Element element, ShellRouteParameters query, bool isLastItem, bool isPopping, int routeIndex)
+		{
+			if (routeIndex >= 0 && query != null && query.Count > 0)
+			{
+				var scopedPrefix = RouteRequestBuilder.MakeRouteScopedKey(routeIndex, string.Empty);
+				ShellRouteParameters promoted = null;
+				bool hasAnyScopedKey = false;
+				foreach (var kvp in query)
+				{
+					if (kvp.Key.StartsWith(RouteRequestBuilder.RouteScopedKeyPrefix, StringComparison.Ordinal))
+					{
+						hasAnyScopedKey = true;
+						break;
+					}
+				}
+
+				if (hasAnyScopedKey)
+				{
+					promoted = new ShellRouteParameters();
+					// Copy non-scoped entries, then promote this route's scoped entries.
+					foreach (var kvp in query)
+					{
+						if (kvp.Key.StartsWith(RouteRequestBuilder.RouteScopedKeyPrefix, StringComparison.Ordinal))
+							continue;
+						promoted[kvp.Key] = kvp.Value;
+					}
+					foreach (var kvp in query)
+					{
+						if (kvp.Key.StartsWith(scopedPrefix, StringComparison.Ordinal))
+							promoted[kvp.Key.Substring(scopedPrefix.Length)] = kvp.Value;
+					}
+					query = promoted;
+				}
+			}
+
 			string prefix = "";
 			if (!isLastItem)
 			{
diff --git a/src/Controls/src/Core/Shell/ShellSection.cs b/src/Controls/src/Core/Shell/ShellSection.cs
index 98a59d7043..a49ec1a844 100644
--- a/src/Controls/src/Core/Shell/ShellSection.cs
+++ b/src/Controls/src/Core/Shell/ShellSection.cs
@@ -376,7 +376,7 @@ namespace Microsoft.Maui.Controls
 							continue;
 						}
 
-						var page = GetOrCreateFromRoute(globalRoutes[i], resolvedRoutes?.Count > i ? resolvedRoutes[i] : null, 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, i);
 						if (IsModal(page))
 						{
 							await PushModalAsync(page, IsNavigationAnimated(page));
@@ -419,7 +419,7 @@ namespace Microsoft.Maui.Controls
 							// if the routes do match and this is the last in the loop
 							// pop everything after this route
 							popCount = i + 2;
-							ShellNavigationManager.ApplyQueryAttributes(navPage, queryData, isLast, isRelativePopping);
+							ShellNavigationManager.ApplyQueryAttributes(navPage, queryData, isLast, isRelativePopping, i);
 
 							// Update ResolvedRoute for reused template pages
 							if (resolvedRoutes?.Count > i && Routing.IsTemplateRoute(route))
@@ -515,6 +515,11 @@ namespace Microsoft.Maui.Controls
 		}
 
 		Page GetOrCreateFromRoute(string route, string resolvedRoute, ShellRouteParameters queryData, IServiceProvider services, bool isLast, bool isPopping)
+		{
+			return GetOrCreateFromRoute(route, resolvedRoute, queryData, services, isLast, isPopping, -1);
+		}
+
+		Page GetOrCreateFromRoute(string route, string resolvedRoute, ShellRouteParameters queryData, IServiceProvider services, bool isLast, bool isPopping, int routeIndex)
 		{
 			var content = Routing.GetOrCreateContent(route, services) as Page;
 			if (content == null)
@@ -532,7 +537,7 @@ namespace Microsoft.Maui.Controls
 				Routing.SetResolvedRoute(content, resolvedRoute);
 			}
 
-			ShellNavigationManager.ApplyQueryAttributes(content, queryData, isLast, isPopping);
+			ShellNavigationManager.ApplyQueryAttributes(content, queryData, isLast, isPopping, routeIndex);
 			return content;
 		}
 
@@ -568,7 +573,7 @@ namespace Microsoft.Maui.Controls
 			for (int i = whereToStartNavigation; i < globalRoutes.Count; i++)
 			{
 				bool isLast = i == globalRoutes.Count - 1;
-				var content = GetOrCreateFromRoute(globalRoutes[i], resolvedRoutes?.Count > i ? resolvedRoutes[i] : null, queryData, services, isLast, false);
+				var content = GetOrCreateFromRoute(globalRoutes[i], resolvedRoutes?.Count > i ? resolvedRoutes[i] : null, queryData, services, isLast, false, i);
 				if (content == null)
 				{
 					break;

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

Labels

s/agent-fix-win AI found a better alternative fix than the PR s/agent-reviewed PR was reviewed by AI agent workflow (full 4-phase review)

Projects

None yet

Development

Successfully merging this pull request may close these issues.

Shell GoToAsync: no way to pass query parameters to intermediate pages in multi-segment navigation

4 participants