feat: Shell route templates with {param} path parameters#35110
feat: Shell route templates with {param} path parameters#35110mattleibow wants to merge 10 commits into
Conversation
|
🚀 Dogfood this PR with:
curl -fsSL https://raw.githubusercontent.com/dotnet/maui/main/eng/scripts/get-maui-pr.sh | bash -s -- 35110Or
iex "& { $(irm https://raw.githubusercontent.com/dotnet/maui/main/eng/scripts/get-maui-pr.ps1) } 35110" |
MauiBot
left a comment
There was a problem hiding this comment.
🤖 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
left a comment
There was a problem hiding this comment.
Expert Review — 8 findings
See inline comments for details.
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>
…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>
…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>
MauiBot
left a comment
There was a problem hiding this comment.
Expert Review — 15 findings
See inline comments for details.
| @@ -1 +1,143 @@ | |||
| #nullable enable | |||
| #nullable enable | |||
| Microsoft.Maui.Navigation.FakeNavigationService | |||
There was a problem hiding this comment.
[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) |
There was a problem hiding this comment.
[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)) | ||
| { |
There was a problem hiding this comment.
[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]) | |||
There was a problem hiding this comment.
[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/apple → product/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"]); | ||
| } |
There was a problem hiding this comment.
[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; | ||
| } |
There was a problem hiding this comment.
[warning] Public API / Edge case — ContainsTemplateSyntax 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, |
There was a problem hiding this comment.
[suggestion] Logic and Correctness — int.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); |
There was a problem hiding this comment.
[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)); |
There was a problem hiding this comment.
[suggestion] Public API consistency — GoBackAsync 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)); |
There was a problem hiding this comment.
[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.
🤖 AI Summary
📊 Review Session —
|
| 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.cssrc/Controls/samples/Controls.Sample.Sandbox/MainPage.xamlsrc/Controls/samples/Controls.Sample.Sandbox/MainPage.xaml.cssrc/Controls/samples/Controls.Sample.Sandbox/SandboxShell.xamlsrc/Controls/samples/Controls.Sample.Sandbox/SandboxShell.xaml.cssrc/Controls/src/Core/Routing.cssrc/Controls/src/Core/Shell/RequestDefinition.cssrc/Controls/src/Core/Shell/RouteRequestBuilder.cssrc/Controls/src/Core/Shell/ShellNavigationManager.cssrc/Controls/src/Core/Shell/ShellSection.cssrc/Controls/src/Core/Shell/ShellUriHandler.cssrc/Core/src/PublicAPI/net/PublicAPI.Unshipped.txt
New files (not reverted):
.playwright-mcp/page-2026-05-07T17-59-54-493Z.ymlsrc/Controls/samples/Controls.Sample.Sandbox/OrderDetailPage.xamlsrc/Controls/samples/Controls.Sample.Sandbox/OrderDetailPage.xaml.cssrc/Controls/samples/Controls.Sample.Sandbox/OrdersPage.xamlsrc/Controls/samples/Controls.Sample.Sandbox/OrdersPage.xaml.cssrc/Controls/samples/Controls.Sample.Sandbox/ProductPage.xamlsrc/Controls/samples/Controls.Sample.Sandbox/ProductPage.xaml.cssrc/Controls/samples/Controls.Sample.Sandbox/ReviewPage.xamlsrc/Controls/samples/Controls.Sample.Sandbox/ReviewPage.xaml.cssrc/Controls/src/Core/Shell/RouteTemplate.cssrc/Core/src/Navigation/FakeNavigationService.cssrc/Core/src/Navigation/IActiveAware.cssrc/Core/src/Navigation/IInitializeAsync.cssrc/Core/src/Navigation/INavigationAware.cssrc/Core/src/Navigation/INavigationGraphBuilder.cssrc/Core/src/Navigation/INavigationGuard.cssrc/Core/src/Navigation/INavigationParameters.cssrc/Core/src/Navigation/INavigationService.cssrc/Core/src/Navigation/NavigationContext.cssrc/Core/src/Navigation/NavigationEventArgs.cssrc/Core/src/Navigation/NavigationResult.cssrc/Core/src/Navigation/PresentationOptions.cssrc/Core/src/Navigation/RouteAttribute.cssrc/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) |
73d24f8 to
a1d46af
Compare
|
/review |
…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>
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>
a1d46af to
1e9f8ef
Compare
…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>
|
/review -b feature/regression-check |
MauiBot
left a comment
There was a problem hiding this comment.
Expert Review — 2 findings
See inline comments for details.
| parameters[kvp.Key] = kvp.Value; | ||
| } | ||
|
|
||
| // Also seed route-prefixed keys so intermediate (non-last) pages |
There was a problem hiding this comment.
[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)) |
There was a problem hiding this comment.
[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.
|
/review -b feature/refactor-copilot-yml |
MauiBot
left a comment
There was a problem hiding this comment.
🤖 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;
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:XAML works too —
{is safe because XAML only interprets markup extensions when{is the first character of the attribute value:Navigation
Navigate with values in the path instead of query strings:
Parameter Delivery
Uses existing
[QueryProperty]andIQueryAttributable— no new attributes or interfaces needed:Supported Syntax
{sku}product/{sku}matchesproduct/seed-tomato{sku?}product/{sku?}matchesproductorproduct/seed-tomato{stars=5}review/{stars=5}→ stars is "5" if you navigate to justreview{id:int}order/{id:int}rejectsorder/abc{*path}files/{*path}→docs/report.pdfproduct-{sku}product-seed-tomato→ sku = "seed-tomato"{id:int=1}Constraints:
int,long,double,bool,guid,alphaRoute 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
RegisterRoutereceives a route containing{, it parses aRouteTemplateand stores it in a parallels_routeTemplatesdictionary alongside the existings_routesentry.Route Matching (
ShellUriHandler.cs,RouteRequestBuilder.cs)FindAndAddSegmentMatchuses a two-pass approach: pass 0 checks literal routes only (unchanged), pass 1 checks template routes.GetNextSegmentMatchwalks template segments in parallel with URI segments, checking type (literal, parameter, optional, catch-all, mixed) and applying constraints. TheRouteTemplateis passed directly from the caller to handleCollapsePathprefix stripping.Parameter Delivery (
ShellNavigationManager.cs)Path parameters are seeded into
ShellRouteParametersbeforeSetQueryStringParameters(path wins). Route-prefixed keys are also seeded for intermediate page delivery through Shell's prefix-basedApplyQueryAttributesfiltering.CurrentState.Location (
ShellSection.cs,ShellNavigationManager.cs)Resolved URIs are stored in a separate internal
ResolvedRoutePropertyon the page.GetNavigationStatereadsGetResolvedRoute(page) ?? GetRoute(page). The page'sRoutekeeps 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{.CollapsePathhas one new condition that only triggers for{-prefixed segments.Known Limitations