Просмотр исходного кода

Merge pull request #27995 from dotnet-maestro-bot/merge/release/5.0-to-master

[automated] Merge branch 'release/5.0' => 'master'
msftbot[bot] 5 лет назад
Родитель
Сommit
f98959f630
52 измененных файлов с 2777 добавлено и 292 удалено
  1. 1 0
      .azure/pipelines/ci.yml
  2. 3 1
      src/Components/Components/src/PublicAPI.Shipped.txt
  3. 15 0
      src/Components/Components/src/Routing/IRouteTable.cs
  4. 5 5
      src/Components/Components/src/Routing/LegacyRouteMatching/LegacyOptionalTypeRouteConstraint.cs
  5. 113 0
      src/Components/Components/src/Routing/LegacyRouteMatching/LegacyRouteConstraint.cs
  6. 146 0
      src/Components/Components/src/Routing/LegacyRouteMatching/LegacyRouteEntry.cs
  7. 31 0
      src/Components/Components/src/Routing/LegacyRouteMatching/LegacyRouteTable.cs
  8. 236 0
      src/Components/Components/src/Routing/LegacyRouteMatching/LegacyRouteTableFactory.cs
  9. 29 0
      src/Components/Components/src/Routing/LegacyRouteMatching/LegacyRouteTemplate.cs
  10. 115 0
      src/Components/Components/src/Routing/LegacyRouteMatching/LegacyTemplateParser.cs
  11. 123 0
      src/Components/Components/src/Routing/LegacyRouteMatching/LegacyTemplateSegment.cs
  12. 37 0
      src/Components/Components/src/Routing/LegacyRouteMatching/LegacyTypeRouteConstraint.cs
  13. 0 25
      src/Components/Components/src/Routing/RouteConstraint.cs
  14. 90 84
      src/Components/Components/src/Routing/RouteEntry.cs
  15. 2 2
      src/Components/Components/src/Routing/RouteTable.cs
  16. 47 47
      src/Components/Components/src/Routing/RouteTableFactory.cs
  17. 18 2
      src/Components/Components/src/Routing/Router.cs
  18. 6 1
      src/Components/Components/src/Routing/TemplateParser.cs
  19. 41 17
      src/Components/Components/src/Routing/TemplateSegment.cs
  20. 14 0
      src/Components/Components/src/Routing/TypeRouteConstraint.cs
  21. 46 0
      src/Components/Components/test/LegacyRouteMatching/LegacyRouteConstraintTest.cs
  22. 741 0
      src/Components/Components/test/LegacyRouteMatching/LegacyRouteTableFactoryTests.cs
  23. 295 0
      src/Components/Components/test/LegacyRouteMatching/LegacyTemplateParserTests.cs
  24. 0 10
      src/Components/Components/test/Routing/RouteConstraintTest.cs
  25. 349 42
      src/Components/Components/test/Routing/RouteTableFactoryTests.cs
  26. 66 2
      src/Components/Components/test/Routing/RouterTest.cs
  27. 1 1
      src/Components/Components/test/Routing/TemplateParserTests.cs
  28. 1 1
      src/Components/Samples/BlazorServerApp/App.razor
  29. 1 1
      src/Components/Web.JS/src/Platform/Mono/MonoPlatform.ts
  30. 10 5
      src/Components/WebAssembly/Sdk/integrationtests/ServiceWorkerAssert.cs
  31. 105 0
      src/Components/WebAssembly/Sdk/integrationtests/WasmPublishIntegrationTest.cs
  32. 23 7
      src/Components/WebAssembly/Sdk/src/targets/Microsoft.NET.Sdk.BlazorWebAssembly.Current.targets
  33. 2 1
      src/Components/WebAssembly/Sdk/src/targets/Microsoft.NET.Sdk.BlazorWebAssembly.ServiceWorkerAssetsManifest.targets
  34. 1 1
      src/Components/WebAssembly/Sdk/testassets/blazorwasm-minimal/App.razor
  35. 1 1
      src/Components/WebAssembly/Sdk/testassets/blazorwasm/App.razor
  36. 1 1
      src/Components/WebAssembly/testassets/StandaloneApp/App.razor
  37. 1 1
      src/Components/WebAssembly/testassets/Wasm.Authentication.Client/App.razor
  38. 1 1
      src/Components/benchmarkapps/BlazingPizza.Server/App.razor
  39. 1 1
      src/Components/benchmarkapps/Wasm.Performance/TestApp/App.razor
  40. 1 1
      src/Components/test/testassets/BasicTestApp/AuthTest/AuthRouter.razor
  41. 1 1
      src/Components/test/testassets/BasicTestApp/RouterTest/TestRouter.razor
  42. 1 1
      src/Components/test/testassets/BasicTestApp/RouterTest/TestRouterWithAdditionalAssembly.razor
  43. 1 1
      src/Components/test/testassets/BasicTestApp/RouterTest/TestRouterWithLazyAssembly.razor
  44. 1 1
      src/Components/test/testassets/BasicTestApp/RouterTest/TestRouterWithOnNavigate.razor
  45. 1 1
      src/Components/test/testassets/ComponentsApp.App/App.razor
  46. 0 1
      src/Identity/Specification.Tests/src/Microsoft.AspNetCore.Identity.Specification.Tests.csproj
  47. 0 1
      src/Identity/Specification.Tests/src/UserManagerSpecificationTests.cs
  48. 2 2
      src/ProjectTemplates/Web.ProjectTemplates/content/BlazorServerWeb-CSharp/App.razor
  49. 2 2
      src/ProjectTemplates/Web.ProjectTemplates/content/ComponentsWebAssembly-CSharp/Client/App.razor
  50. 3 2
      src/Razor/Microsoft.NET.Sdk.Razor/src/build/netstandard2.0/Sdk.Razor.CurrentVersion.targets
  51. 46 16
      src/SignalR/clients/java/signalr/core/src/main/java/com/microsoft/signalr/OkHttpWebSocketWrapper.java
  52. 0 2
      src/SignalR/clients/java/signalr/core/src/main/java/com/microsoft/signalr/WebSocketTransport.java

+ 1 - 0
.azure/pipelines/ci.yml

@@ -850,6 +850,7 @@ stages:
             - CodeSign_Xplat_Linux_arm
             - CodeSign_Xplat_Linux_arm64
             - CodeSign_Xplat_Linux_musl_x64
+            - CodeSign_Xplat_Linux_musl_arm
             - CodeSign_Xplat_Linux_musl_arm64
           # In addition to the dependencies above, ensure the build was successful overall.
           - Source_Build

+ 3 - 1
src/Components/Components/src/PublicAPI.Shipped.txt

@@ -1,4 +1,4 @@
-#nullable enable
+#nullable enable
 Microsoft.AspNetCore.Components.BindConverter
 Microsoft.AspNetCore.Components.BindElementAttribute
 Microsoft.AspNetCore.Components.BindElementAttribute.BindElementAttribute(string! element, string? suffix, string! valueAttribute, string! changeAttribute) -> void
@@ -427,3 +427,5 @@ virtual Microsoft.AspNetCore.Components.RouteView.Render(Microsoft.AspNetCore.Co
 ~Microsoft.AspNetCore.Components.RenderTree.RenderTreeFrame.MarkupContent.get -> string
 ~Microsoft.AspNetCore.Components.RenderTree.RenderTreeFrame.TextContent.get -> string
 ~override Microsoft.AspNetCore.Components.RenderTree.RenderTreeFrame.ToString() -> string
+Microsoft.AspNetCore.Components.Routing.Router.PreferExactMatches.get -> bool
+Microsoft.AspNetCore.Components.Routing.Router.PreferExactMatches.set -> void

+ 15 - 0
src/Components/Components/src/Routing/IRouteTable.cs

@@ -0,0 +1,15 @@
+// Copyright (c) .NET Foundation. All rights reserved.
+// Licensed under the Apache License, Version 2.0. See License.txt in the project root for license information.
+
+namespace Microsoft.AspNetCore.Components.Routing
+{
+    /// <summary>
+    /// Provides an abstraction over <see cref="RouteTable"/> and <see cref="LegacyRouteMatching.LegacyRouteTable"/>.
+    /// This is only an internal implementation detail of <see cref="Router"/> and can be removed once
+    /// the legacy route matching logic is removed.
+    /// </summary>
+    internal interface IRouteTable
+    {
+        void Route(RouteContext routeContext);
+    }
+}

+ 5 - 5
src/Components/Components/src/Routing/OptionalTypeRouteConstraint.cs → src/Components/Components/src/Routing/LegacyRouteMatching/LegacyOptionalTypeRouteConstraint.cs

@@ -1,20 +1,20 @@
 // Copyright (c) .NET Foundation. All rights reserved.
 // Licensed under the Apache License, Version 2.0. See License.txt in the project root for license information.
 
-namespace Microsoft.AspNetCore.Components.Routing
+namespace Microsoft.AspNetCore.Components.LegacyRouteMatching
 {
     /// <summary>
     /// A route constraint that allows the value to be null or parseable as the specified
     /// type.
     /// </summary>
     /// <typeparam name="T">The type to which the value must be parseable.</typeparam>
-    internal class OptionalTypeRouteConstraint<T> : RouteConstraint
+    internal class LegacyOptionalTypeRouteConstraint<T> : LegacyRouteConstraint
     {
-        public delegate bool TryParseDelegate(string str, out T result);
+        public delegate bool LegacyTryParseDelegate(string str, out T result);
 
-        private readonly TryParseDelegate _parser;
+        private readonly LegacyTryParseDelegate _parser;
 
-        public OptionalTypeRouteConstraint(TryParseDelegate parser)
+        public LegacyOptionalTypeRouteConstraint(LegacyTryParseDelegate parser)
         {
             _parser = parser;
         }

+ 113 - 0
src/Components/Components/src/Routing/LegacyRouteMatching/LegacyRouteConstraint.cs

@@ -0,0 +1,113 @@
+// Copyright (c) .NET Foundation. All rights reserved.
+// Licensed under the Apache License, Version 2.0. See License.txt in the project root for license information.
+
+using System;
+using System.Collections.Concurrent;
+using System.Globalization;
+
+namespace Microsoft.AspNetCore.Components.LegacyRouteMatching
+{
+    internal abstract class LegacyRouteConstraint
+    {
+        // note: the things that prevent this cache from growing unbounded is that
+        // we're the only caller to this code path, and the fact that there are only
+        // 8 possible instances that we create.
+        //
+        // The values passed in here for parsing are always static text defined in route attributes.
+        private static readonly ConcurrentDictionary<string, LegacyRouteConstraint> _cachedConstraints
+            = new ConcurrentDictionary<string, LegacyRouteConstraint>();
+
+        public abstract bool Match(string pathSegment, out object? convertedValue);
+
+        public static LegacyRouteConstraint Parse(string template, string segment, string constraint)
+        {
+            if (string.IsNullOrEmpty(constraint))
+            {
+                throw new ArgumentException($"Malformed segment '{segment}' in route '{template}' contains an empty constraint.");
+            }
+
+            if (_cachedConstraints.TryGetValue(constraint, out var cachedInstance))
+            {
+                return cachedInstance;
+            }
+            else
+            {
+                var newInstance = CreateRouteConstraint(constraint);
+                if (newInstance != null)
+                {
+                    // We've done to the work to create the constraint now, but it's possible
+                    // we're competing with another thread. GetOrAdd can ensure only a single
+                    // instance is returned so that any extra ones can be GC'ed.
+                    return _cachedConstraints.GetOrAdd(constraint, newInstance);
+                }
+                else
+                {
+                    throw new ArgumentException($"Unsupported constraint '{constraint}' in route '{template}'.");
+                }
+            }
+        }
+
+        /// <summary>
+        /// Creates a structured RouteConstraint object given a string that contains
+        /// the route constraint. A constraint is the place after the colon in a
+        /// parameter definition, for example `{age:int?}`.
+        ///
+        /// If the constraint denotes an optional, this method will return an
+        /// <see cref="LegacyOptionalTypeRouteConstraint{T}" /> which handles the appropriate checks.
+        /// </summary>
+        /// <param name="constraint">String representation of the constraint</param>
+        /// <returns>Type-specific RouteConstraint object</returns>
+        private static LegacyRouteConstraint? CreateRouteConstraint(string constraint)
+        {
+            switch (constraint)
+            {
+                case "bool":
+                    return new LegacyTypeRouteConstraint<bool>(bool.TryParse);
+                case "bool?":
+                    return new LegacyOptionalTypeRouteConstraint<bool>(bool.TryParse);
+                case "datetime":
+                    return new LegacyTypeRouteConstraint<DateTime>((string str, out DateTime result)
+                        => DateTime.TryParse(str, CultureInfo.InvariantCulture, DateTimeStyles.None, out result));
+                case "datetime?":
+                    return new LegacyOptionalTypeRouteConstraint<DateTime>((string str, out DateTime result)
+                        => DateTime.TryParse(str, CultureInfo.InvariantCulture, DateTimeStyles.None, out result));
+                case "decimal":
+                    return new LegacyTypeRouteConstraint<decimal>((string str, out decimal result)
+                        => decimal.TryParse(str, NumberStyles.Number, CultureInfo.InvariantCulture, out result));
+                case "decimal?":
+                    return new LegacyOptionalTypeRouteConstraint<decimal>((string str, out decimal result)
+                        => decimal.TryParse(str, NumberStyles.Number, CultureInfo.InvariantCulture, out result));
+                case "double":
+                    return new LegacyTypeRouteConstraint<double>((string str, out double result)
+                        => double.TryParse(str, NumberStyles.Number, CultureInfo.InvariantCulture, out result));
+                case "double?":
+                    return new LegacyOptionalTypeRouteConstraint<double>((string str, out double result)
+                        => double.TryParse(str, NumberStyles.Number, CultureInfo.InvariantCulture, out result));
+                case "float":
+                    return new LegacyTypeRouteConstraint<float>((string str, out float result)
+                        => float.TryParse(str, NumberStyles.Number, CultureInfo.InvariantCulture, out result));
+                case "float?":
+                    return new LegacyOptionalTypeRouteConstraint<float>((string str, out float result)
+                        => float.TryParse(str, NumberStyles.Number, CultureInfo.InvariantCulture, out result));
+                case "guid":
+                    return new LegacyTypeRouteConstraint<Guid>(Guid.TryParse);
+                case "guid?":
+                    return new LegacyOptionalTypeRouteConstraint<Guid>(Guid.TryParse);
+                case "int":
+                    return new LegacyTypeRouteConstraint<int>((string str, out int result)
+                        => int.TryParse(str, NumberStyles.Integer, CultureInfo.InvariantCulture, out result));
+                case "int?":
+                    return new LegacyOptionalTypeRouteConstraint<int>((string str, out int result)
+                        => int.TryParse(str, NumberStyles.Integer, CultureInfo.InvariantCulture, out result));
+                case "long":
+                    return new LegacyTypeRouteConstraint<long>((string str, out long result)
+                        => long.TryParse(str, NumberStyles.Integer, CultureInfo.InvariantCulture, out result));
+                case "long?":
+                    return new LegacyOptionalTypeRouteConstraint<long>((string str, out long result)
+                        => long.TryParse(str, NumberStyles.Integer, CultureInfo.InvariantCulture, out result));
+                default:
+                    return null;
+            }
+        }
+    }
+}

+ 146 - 0
src/Components/Components/src/Routing/LegacyRouteMatching/LegacyRouteEntry.cs

@@ -0,0 +1,146 @@
+// Copyright (c) .NET Foundation. All rights reserved.
+// Licensed under the Apache License, Version 2.0. See License.txt in the project root for license information.
+
+#nullable disable warnings
+
+using System;
+using System.Collections.Generic;
+using System.Diagnostics;
+
+// Avoid referencing the whole Microsoft.AspNetCore.Components.Routing namespace to
+// avoid the risk of accidentally relying on the non-legacy types in the legacy fork
+using RouteContext = Microsoft.AspNetCore.Components.Routing.RouteContext;
+
+namespace Microsoft.AspNetCore.Components.LegacyRouteMatching
+{
+    [DebuggerDisplay("Handler = {Handler}, Template = {Template}")]
+    internal class LegacyRouteEntry
+    {
+        public LegacyRouteEntry(LegacyRouteTemplate template, Type handler, string[] unusedRouteParameterNames)
+        {
+            Template = template;
+            UnusedRouteParameterNames = unusedRouteParameterNames;
+            Handler = handler;
+        }
+
+        public LegacyRouteTemplate Template { get; }
+
+        public string[] UnusedRouteParameterNames { get; }
+
+        public Type Handler { get; }
+
+        internal void Match(RouteContext context)
+        {
+            string? catchAllValue = null;
+
+            // If this template contains a catch-all parameter, we can concatenate the pathSegments
+            // at and beyond the catch-all segment's position. For example:
+            // Template:        /foo/bar/{*catchAll}
+            // PathSegments:    /foo/bar/one/two/three
+            if (Template.ContainsCatchAllSegment && context.Segments.Length >= Template.Segments.Length)
+            {
+                catchAllValue = string.Join('/', context.Segments[Range.StartAt(Template.Segments.Length - 1)]);
+            }
+            // If there are no optional segments on the route and the length of the route
+            // and the template do not match, then there is no chance of this matching and
+            // we can bail early.
+            else if (Template.OptionalSegmentsCount == 0 && Template.Segments.Length != context.Segments.Length)
+            {
+                return;
+            }
+
+            // Parameters will be lazily initialized.
+            Dictionary<string, object> parameters = null;
+            var numMatchingSegments = 0;
+            for (var i = 0; i < Template.Segments.Length; i++)
+            {
+                var segment = Template.Segments[i];
+
+                if (segment.IsCatchAll)
+                {
+                    numMatchingSegments += 1;
+                    parameters ??= new Dictionary<string, object>(StringComparer.Ordinal);
+                    parameters[segment.Value] = catchAllValue;
+                    break;
+                }
+
+                // If the template contains more segments than the path, then
+                // we may need to break out of this for-loop. This can happen
+                // in one of two cases:
+                //
+                // (1) If we are comparing a literal route with a literal template
+                // and the route is shorter than the template.
+                // (2) If we are comparing a template where the last value is an optional
+                // parameter that the route does not provide.
+                if (i >= context.Segments.Length)
+                {
+                    // If we are under condition (1) above then we can stop evaluating
+                    // matches on the rest of this template.
+                    if (!segment.IsParameter && !segment.IsOptional)
+                    {
+                        break;
+                    }
+                }
+
+                string pathSegment = null;
+                if (i < context.Segments.Length)
+                {
+                    pathSegment = context.Segments[i];
+                }
+
+                if (!segment.Match(pathSegment, out var matchedParameterValue))
+                {
+                    return;
+                }
+                else
+                {
+                    numMatchingSegments++;
+                    if (segment.IsParameter)
+                    {
+                        parameters ??= new Dictionary<string, object>(StringComparer.Ordinal);
+                        parameters[segment.Value] = matchedParameterValue;
+                    }
+                }
+            }
+
+            // In addition to extracting parameter values from the URL, each route entry
+            // also knows which other parameters should be supplied with null values. These
+            // are parameters supplied by other route entries matching the same handler.
+            if (!Template.ContainsCatchAllSegment && UnusedRouteParameterNames.Length > 0)
+            {
+                parameters ??= new Dictionary<string, object>(StringComparer.Ordinal);
+                for (var i = 0; i < UnusedRouteParameterNames.Length; i++)
+                {
+                    parameters[UnusedRouteParameterNames[i]] = null;
+                }
+            }
+
+            // We track the number of segments in the template that matched
+            // against this particular route then only select the route that
+            // matches the most number of segments on the route that was passed.
+            // This check is an exactness check that favors the more precise of
+            // two templates in the event that the following route table exists.
+            //  Route 1: /{anythingGoes}
+            //  Route 2: /users/{id:int}
+            // And the provided route is `/users/1`. We want to choose Route 2
+            // over Route 1.
+            // Furthermore, literal routes are preferred over parameterized routes.
+            // If the two routes below are registered in the route table.
+            // Route 1: /users/1
+            // Route 2: /users/{id:int}
+            // And the provided route is `/users/1`. We want to choose Route 1 over
+            // Route 2.
+            var allRouteSegmentsMatch = numMatchingSegments >= context.Segments.Length;
+            // Checking that all route segments have been matches does not suffice if we are
+            // comparing literal templates with literal routes. For example, the template
+            // `/this/is/a/template` and the route `/this/`. In that case, we want to ensure
+            // that all non-optional segments have matched as well.
+            var allNonOptionalSegmentsMatch = numMatchingSegments >= (Template.Segments.Length - Template.OptionalSegmentsCount);
+            if (Template.ContainsCatchAllSegment || (allRouteSegmentsMatch && allNonOptionalSegmentsMatch))
+            {
+                context.Parameters = parameters;
+                context.Handler = Handler;
+            }
+        }
+    }
+}

+ 31 - 0
src/Components/Components/src/Routing/LegacyRouteMatching/LegacyRouteTable.cs

@@ -0,0 +1,31 @@
+// Copyright (c) .NET Foundation. All rights reserved.
+// Licensed under the Apache License, Version 2.0. See License.txt in the project root for license information.
+
+// Avoid referencing the whole Microsoft.AspNetCore.Components.Routing namespace to
+// avoid the risk of accidentally relying on the non-legacy types in the legacy fork
+using RouteContext = Microsoft.AspNetCore.Components.Routing.RouteContext;
+
+namespace Microsoft.AspNetCore.Components.LegacyRouteMatching
+{
+    internal class LegacyRouteTable : Routing.IRouteTable
+    {
+        public LegacyRouteTable(LegacyRouteEntry[] routes)
+        {
+            Routes = routes;
+        }
+
+        public LegacyRouteEntry[] Routes { get; }
+
+        public void Route(RouteContext routeContext)
+        {
+            for (var i = 0; i < Routes.Length; i++)
+            {
+                Routes[i].Match(routeContext);
+                if (routeContext.Handler != null)
+                {
+                    return;
+                }
+            }
+        }
+    }
+}

+ 236 - 0
src/Components/Components/src/Routing/LegacyRouteMatching/LegacyRouteTableFactory.cs

@@ -0,0 +1,236 @@
+// Copyright (c) .NET Foundation. All rights reserved.
+// Licensed under the Apache License, Version 2.0. See License.txt in the project root for license information.
+
+using System;
+using System.Collections.Concurrent;
+using System.Collections.Generic;
+using System.Linq;
+using System.Reflection;
+
+namespace Microsoft.AspNetCore.Components.LegacyRouteMatching
+{
+    /// <summary>
+    /// Resolves components for an application.
+    /// </summary>
+    internal static class LegacyRouteTableFactory
+    {
+        private static readonly ConcurrentDictionary<Key, LegacyRouteTable> Cache =
+            new ConcurrentDictionary<Key, LegacyRouteTable>();
+        public static readonly IComparer<LegacyRouteEntry> RoutePrecedence = Comparer<LegacyRouteEntry>.Create(RouteComparison);
+
+        public static LegacyRouteTable Create(IEnumerable<Assembly> assemblies)
+        {
+            var key = new Key(assemblies.OrderBy(a => a.FullName).ToArray());
+            if (Cache.TryGetValue(key, out var resolvedComponents))
+            {
+                return resolvedComponents;
+            }
+
+            var componentTypes = key.Assemblies.SelectMany(a => a.ExportedTypes.Where(t => typeof(IComponent).IsAssignableFrom(t)));
+            var routeTable = Create(componentTypes);
+            Cache.TryAdd(key, routeTable);
+            return routeTable;
+        }
+
+        internal static LegacyRouteTable Create(IEnumerable<Type> componentTypes)
+        {
+            var templatesByHandler = new Dictionary<Type, string[]>();
+            foreach (var componentType in componentTypes)
+            {
+                // We're deliberately using inherit = false here.
+                //
+                // RouteAttribute is defined as non-inherited, because inheriting a route attribute always causes an
+                // ambiguity. You end up with two components (base class and derived class) with the same route.
+                var routeAttributes = componentType.GetCustomAttributes<RouteAttribute>(inherit: false);
+
+                var templates = routeAttributes.Select(t => t.Template).ToArray();
+                templatesByHandler.Add(componentType, templates);
+            }
+            return Create(templatesByHandler);
+        }
+
+        internal static LegacyRouteTable Create(Dictionary<Type, string[]> templatesByHandler)
+        {
+            var routes = new List<LegacyRouteEntry>();
+            foreach (var keyValuePair in templatesByHandler)
+            {
+                var parsedTemplates = keyValuePair.Value.Select(v => LegacyTemplateParser.ParseTemplate(v)).ToArray();
+                var allRouteParameterNames = parsedTemplates
+                    .SelectMany(GetParameterNames)
+                    .Distinct(StringComparer.OrdinalIgnoreCase)
+                    .ToArray();
+
+                foreach (var parsedTemplate in parsedTemplates)
+                {
+                    var unusedRouteParameterNames = allRouteParameterNames
+                        .Except(GetParameterNames(parsedTemplate), StringComparer.OrdinalIgnoreCase)
+                        .ToArray();
+                    var entry = new LegacyRouteEntry(parsedTemplate, keyValuePair.Key, unusedRouteParameterNames);
+                    routes.Add(entry);
+                }
+            }
+
+            return new LegacyRouteTable(routes.OrderBy(id => id, RoutePrecedence).ToArray());
+        }
+
+        private static string[] GetParameterNames(LegacyRouteTemplate routeTemplate)
+        {
+            return routeTemplate.Segments
+                .Where(s => s.IsParameter)
+                .Select(s => s.Value)
+                .ToArray();
+        }
+
+        /// <summary>
+        /// Route precedence algorithm.
+        /// We collect all the routes and sort them from most specific to
+        /// less specific. The specificity of a route is given by the specificity
+        /// of its segments and the position of those segments in the route.
+        /// * A literal segment is more specific than a parameter segment.
+        /// * A parameter segment with more constraints is more specific than one with fewer constraints
+        /// * Segment earlier in the route are evaluated before segments later in the route.
+        /// For example:
+        /// /Literal is more specific than /Parameter
+        /// /Route/With/{parameter} is more specific than /{multiple}/With/{parameters}
+        /// /Product/{id:int} is more specific than /Product/{id}
+        ///
+        /// Routes can be ambiguous if:
+        /// They are composed of literals and those literals have the same values (case insensitive)
+        /// They are composed of a mix of literals and parameters, in the same relative order and the
+        /// literals have the same values.
+        /// For example:
+        /// * /literal and /Literal
+        /// /{parameter}/literal and /{something}/literal
+        /// /{parameter:constraint}/literal and /{something:constraint}/literal
+        ///
+        /// To calculate the precedence we sort the list of routes as follows:
+        /// * Shorter routes go first.
+        /// * A literal wins over a parameter in precedence.
+        /// * For literals with different values (case insensitive) we choose the lexical order
+        /// * For parameters with different numbers of constraints, the one with more wins
+        /// If we get to the end of the comparison routing we've detected an ambiguous pair of routes.
+        /// </summary>
+        internal static int RouteComparison(LegacyRouteEntry x, LegacyRouteEntry y)
+        {
+            if (ReferenceEquals(x, y))
+            {
+                return 0;
+            }
+
+            var xTemplate = x.Template;
+            var yTemplate = y.Template;
+            if (xTemplate.Segments.Length != y.Template.Segments.Length)
+            {
+                return xTemplate.Segments.Length < y.Template.Segments.Length ? -1 : 1;
+            }
+            else
+            {
+                for (var i = 0; i < xTemplate.Segments.Length; i++)
+                {
+                    var xSegment = xTemplate.Segments[i];
+                    var ySegment = yTemplate.Segments[i];
+                    if (!xSegment.IsParameter && ySegment.IsParameter)
+                    {
+                        return -1;
+                    }
+                    if (xSegment.IsParameter && !ySegment.IsParameter)
+                    {
+                        return 1;
+                    }
+
+                    if (xSegment.IsParameter)
+                    {
+                        // Always favor non-optional parameters over optional ones
+                        if (!xSegment.IsOptional && ySegment.IsOptional)
+                        {
+                            return -1;
+                        }
+
+                        if (xSegment.IsOptional && !ySegment.IsOptional)
+                        {
+                            return 1;
+                        }
+
+                        if (xSegment.Constraints.Length > ySegment.Constraints.Length)
+                        {
+                            return -1;
+                        }
+                        else if (xSegment.Constraints.Length < ySegment.Constraints.Length)
+                        {
+                            return 1;
+                        }
+                    }
+                    else
+                    {
+                        var comparison = string.Compare(xSegment.Value, ySegment.Value, StringComparison.OrdinalIgnoreCase);
+                        if (comparison != 0)
+                        {
+                            return comparison;
+                        }
+                    }
+                }
+
+                throw new InvalidOperationException($@"The following routes are ambiguous:
+'{x.Template.TemplateText}' in '{x.Handler.FullName}'
+'{y.Template.TemplateText}' in '{y.Handler.FullName}'
+");
+            }
+        }
+
+        private readonly struct Key : IEquatable<Key>
+        {
+            public readonly Assembly[] Assemblies;
+
+            public Key(Assembly[] assemblies)
+            {
+                Assemblies = assemblies;
+            }
+
+            public override bool Equals(object? obj)
+            {
+                return obj is Key other ? base.Equals(other) : false;
+            }
+
+            public bool Equals(Key other)
+            {
+                if (Assemblies == null && other.Assemblies == null)
+                {
+                    return true;
+                }
+                else if ((Assemblies == null) || (other.Assemblies == null))
+                {
+                    return false;
+                }
+                else if (Assemblies.Length != other.Assemblies.Length)
+                {
+                    return false;
+                }
+
+                for (var i = 0; i < Assemblies.Length; i++)
+                {
+                    if (!Assemblies[i].Equals(other.Assemblies[i]))
+                    {
+                        return false;
+                    }
+                }
+
+                return true;
+            }
+
+            public override int GetHashCode()
+            {
+                var hash = new HashCode();
+
+                if (Assemblies != null)
+                {
+                    for (var i = 0; i < Assemblies.Length; i++)
+                    {
+                        hash.Add(Assemblies[i]);
+                    }
+                }
+
+                return hash.ToHashCode();
+            }
+        }
+    }
+}

+ 29 - 0
src/Components/Components/src/Routing/LegacyRouteMatching/LegacyRouteTemplate.cs

@@ -0,0 +1,29 @@
+// Copyright (c) .NET Foundation. All rights reserved.
+// Licensed under the Apache License, Version 2.0. See License.txt in the project root for license information.
+
+
+using System.Diagnostics;
+using System.Linq;
+
+namespace Microsoft.AspNetCore.Components.LegacyRouteMatching
+{
+    [DebuggerDisplay("{TemplateText}")]
+    internal class LegacyRouteTemplate
+    {
+        public LegacyRouteTemplate(string templateText, LegacyTemplateSegment[] segments)
+        {
+            TemplateText = templateText;
+            Segments = segments;
+            OptionalSegmentsCount = segments.Count(template => template.IsOptional);
+            ContainsCatchAllSegment = segments.Any(template => template.IsCatchAll);
+        }
+
+        public string TemplateText { get; }
+
+        public LegacyTemplateSegment[] Segments { get; }
+
+        public int OptionalSegmentsCount { get; }
+
+        public bool ContainsCatchAllSegment { get; }
+    }
+}

+ 115 - 0
src/Components/Components/src/Routing/LegacyRouteMatching/LegacyTemplateParser.cs

@@ -0,0 +1,115 @@
+// Copyright (c) .NET Foundation. All rights reserved.
+// Licensed under the Apache License, Version 2.0. See License.txt in the project root for license information.
+
+using System;
+
+namespace Microsoft.AspNetCore.Components.LegacyRouteMatching
+{
+    // This implementation is temporary, in the future we'll want to have
+    // a more performant/properly designed routing set of abstractions.
+    // To be more precise these are some things we are scoping out:
+    // * We are not doing link generation.
+    // * We are not supporting all the route constraint formats supported by ASP.NET server-side routing.
+    // The class in here just takes care of parsing a route and extracting
+    // simple parameters from it.
+    // Some differences with ASP.NET Core routes are:
+    // * We don't support complex segments.
+    // The things that we support are:
+    // * Literal path segments. (Like /Path/To/Some/Page)
+    // * Parameter path segments (Like /Customer/{Id}/Orders/{OrderId})
+    // * Catch-all parameters (Like /blog/{*slug})
+    internal class LegacyTemplateParser
+    {
+        public static readonly char[] InvalidParameterNameCharacters =
+            new char[] { '{', '}', '=', '.' };
+
+        internal static LegacyRouteTemplate ParseTemplate(string template)
+        {
+            var originalTemplate = template;
+            template = template.Trim('/');
+            if (template == string.Empty)
+            {
+                // Special case "/";
+                return new LegacyRouteTemplate("/", Array.Empty<LegacyTemplateSegment>());
+            }
+
+            var segments = template.Split('/');
+            var templateSegments = new LegacyTemplateSegment[segments.Length];
+            for (int i = 0; i < segments.Length; i++)
+            {
+                var segment = segments[i];
+                if (string.IsNullOrEmpty(segment))
+                {
+                    throw new InvalidOperationException(
+                        $"Invalid template '{template}'. Empty segments are not allowed.");
+                }
+
+                if (segment[0] != '{')
+                {
+                    if (segment[segment.Length - 1] == '}')
+                    {
+                        throw new InvalidOperationException(
+                            $"Invalid template '{template}'. Missing '{{' in parameter segment '{segment}'.");
+                    }
+                    templateSegments[i] = new LegacyTemplateSegment(originalTemplate, segment, isParameter: false);
+                }
+                else
+                {
+                    if (segment[segment.Length - 1] != '}')
+                    {
+                        throw new InvalidOperationException(
+                            $"Invalid template '{template}'. Missing '}}' in parameter segment '{segment}'.");
+                    }
+
+                    if (segment.Length < 3)
+                    {
+                        throw new InvalidOperationException(
+                            $"Invalid template '{template}'. Empty parameter name in segment '{segment}' is not allowed.");
+                    }
+
+                    var invalidCharacter = segment.IndexOfAny(InvalidParameterNameCharacters, 1, segment.Length - 2);
+                    if (invalidCharacter != -1)
+                    {
+                        throw new InvalidOperationException(
+                            $"Invalid template '{template}'. The character '{segment[invalidCharacter]}' in parameter segment '{segment}' is not allowed.");
+                    }
+
+                    templateSegments[i] = new LegacyTemplateSegment(originalTemplate, segment.Substring(1, segment.Length - 2), isParameter: true);
+                }
+            }
+
+            for (int i = 0; i < templateSegments.Length; i++)
+            {
+                var currentSegment = templateSegments[i];
+
+                if (currentSegment.IsCatchAll && i != templateSegments.Length - 1)
+                {
+                    throw new InvalidOperationException($"Invalid template '{template}'. A catch-all parameter can only appear as the last segment of the route template.");
+                }
+
+                if (!currentSegment.IsParameter)
+                {
+                    continue;
+                }
+
+                for (int j = i + 1; j < templateSegments.Length; j++)
+                {
+                    var nextSegment = templateSegments[j];
+
+                    if (currentSegment.IsOptional && !nextSegment.IsOptional)
+                    {
+                        throw new InvalidOperationException($"Invalid template '{template}'. Non-optional parameters or literal routes cannot appear after optional parameters.");
+                    }
+
+                    if (string.Equals(currentSegment.Value, nextSegment.Value, StringComparison.OrdinalIgnoreCase))
+                    {
+                        throw new InvalidOperationException(
+                            $"Invalid template '{template}'. The parameter '{currentSegment}' appears multiple times.");
+                    }
+                }
+            }
+
+            return new LegacyRouteTemplate(template, templateSegments);
+        }
+    }
+}

+ 123 - 0
src/Components/Components/src/Routing/LegacyRouteMatching/LegacyTemplateSegment.cs

@@ -0,0 +1,123 @@
+// Copyright (c) .NET Foundation. All rights reserved.
+// Licensed under the Apache License, Version 2.0. See License.txt in the project root for license information.
+
+using System;
+using System.Linq;
+
+namespace Microsoft.AspNetCore.Components.LegacyRouteMatching
+{
+    internal class LegacyTemplateSegment
+    {
+        public LegacyTemplateSegment(string template, string segment, bool isParameter)
+        {
+            IsParameter = isParameter;
+
+            IsCatchAll = segment.StartsWith('*');
+
+            if (IsCatchAll)
+            {
+                // Only one '*' currently allowed
+                Value = segment.Substring(1);
+
+                var invalidCharacter = Value.IndexOf('*');
+                if (Value.IndexOf('*') != -1)
+                {
+                    throw new InvalidOperationException($"Invalid template '{template}'. A catch-all parameter may only have one '*' at the beginning of the segment.");
+                }
+            }
+            else
+            {
+                Value = segment;
+            }
+
+            // Process segments that are not parameters or do not contain
+            // a token separating a type constraint.
+            if (!isParameter || Value.IndexOf(':') < 0)
+            {
+                // Set the IsOptional flag to true for segments that contain
+                // a parameter with no type constraints but optionality set
+                // via the '?' token.
+                if (Value.IndexOf('?') == Value.Length - 1)
+                {
+                    IsOptional = true;
+                    Value = Value.Substring(0, Value.Length - 1);
+                }
+                // If the `?` optional marker shows up in the segment but not at the very end,
+                // then throw an error.
+                else if (Value.IndexOf('?') >= 0 && Value.IndexOf('?') != Value.Length - 1)
+                {
+                    throw new ArgumentException($"Malformed parameter '{segment}' in route '{template}'. '?' character can only appear at the end of parameter name.");
+                }
+
+                Constraints = Array.Empty<LegacyRouteConstraint>();
+            }
+            else
+            {
+                var tokens = Value.Split(':');
+                if (tokens[0].Length == 0)
+                {
+                    throw new ArgumentException($"Malformed parameter '{segment}' in route '{template}' has no name before the constraints list.");
+                }
+
+                // Set the IsOptional flag to true if any type constraints
+                // for this parameter are designated as optional.
+                IsOptional = tokens.Skip(1).Any(token => token.EndsWith('?'));
+
+                Value = tokens[0];
+                Constraints = tokens.Skip(1)
+                    .Select(token => LegacyRouteConstraint.Parse(template, segment, token))
+                    .ToArray();
+            }
+
+            if (IsParameter)
+            {
+                if (IsOptional && IsCatchAll)
+                {
+                    throw new InvalidOperationException($"Invalid segment '{segment}' in route '{template}'. A catch-all parameter cannot be marked optional.");
+                }
+
+                // Moving the check for this here instead of TemplateParser so we can allow catch-all.
+                // We checked for '*' up above specifically for catch-all segments, this one checks for all others
+                if (Value.IndexOf('*') != -1)
+                {
+                    throw new InvalidOperationException($"Invalid template '{template}'. The character '*' in parameter segment '{{{segment}}}' is not allowed.");
+                }
+            }
+        }
+
+        // The value of the segment. The exact text to match when is a literal.
+        // The parameter name when its a segment
+        public string Value { get; }
+
+        public bool IsParameter { get; }
+
+        public bool IsOptional { get;  }
+
+        public bool IsCatchAll { get; }
+
+        public LegacyRouteConstraint[] Constraints { get; }
+
+        public bool Match(string pathSegment, out object? matchedParameterValue)
+        {
+            if (IsParameter)
+            {
+                matchedParameterValue = pathSegment;
+
+                foreach (var constraint in Constraints)
+                {
+                    if (!constraint.Match(pathSegment, out matchedParameterValue))
+                    {
+                        return false;
+                    }
+                }
+
+                return true;
+            }
+            else
+            {
+                matchedParameterValue = null;
+                return string.Equals(Value, pathSegment, StringComparison.OrdinalIgnoreCase);
+            }
+        }
+    }
+}

+ 37 - 0
src/Components/Components/src/Routing/LegacyRouteMatching/LegacyTypeRouteConstraint.cs

@@ -0,0 +1,37 @@
+// Copyright (c) .NET Foundation. All rights reserved.
+// Licensed under the Apache License, Version 2.0. See License.txt in the project root for license information.
+
+using System.Diagnostics.CodeAnalysis;
+
+namespace Microsoft.AspNetCore.Components.LegacyRouteMatching
+{
+    /// <summary>
+    /// A route constraint that requires the value to be parseable as a specified type.
+    /// </summary>
+    /// <typeparam name="T">The type to which the value must be parseable.</typeparam>
+    internal class LegacyTypeRouteConstraint<T> : LegacyRouteConstraint
+    {
+        public delegate bool LegacyTryParseDelegate(string str, [MaybeNullWhen(false)] out T result);
+
+        private readonly LegacyTryParseDelegate _parser;
+
+        public LegacyTypeRouteConstraint(LegacyTryParseDelegate parser)
+        {
+            _parser = parser;
+        }
+
+        public override bool Match(string pathSegment, out object? convertedValue)
+        {
+            if (_parser(pathSegment, out var result))
+            {
+                convertedValue = result;
+                return true;
+            }
+            else
+            {
+                convertedValue = null;
+                return false;
+            }
+        }
+    }
+}

+ 0 - 25
src/Components/Components/src/Routing/RouteConstraint.cs

@@ -51,9 +51,6 @@ namespace Microsoft.AspNetCore.Components.Routing
         /// Creates a structured RouteConstraint object given a string that contains
         /// the route constraint. A constraint is the place after the colon in a
         /// parameter definition, for example `{age:int?}`.
-        ///
-        /// If the constraint denotes an optional, this method will return an
-        /// <see cref="OptionalTypeRouteConstraint{T}" /> which handles the appropriate checks.
         /// </summary>
         /// <param name="constraint">String representation of the constraint</param>
         /// <returns>Type-specific RouteConstraint object</returns>
@@ -63,48 +60,26 @@ namespace Microsoft.AspNetCore.Components.Routing
             {
                 case "bool":
                     return new TypeRouteConstraint<bool>(bool.TryParse);
-                case "bool?":
-                    return new OptionalTypeRouteConstraint<bool>(bool.TryParse);
                 case "datetime":
                     return new TypeRouteConstraint<DateTime>((string str, out DateTime result)
                         => DateTime.TryParse(str, CultureInfo.InvariantCulture, DateTimeStyles.None, out result));
-                case "datetime?":
-                    return new OptionalTypeRouteConstraint<DateTime>((string str, out DateTime result)
-                        => DateTime.TryParse(str, CultureInfo.InvariantCulture, DateTimeStyles.None, out result));
                 case "decimal":
                     return new TypeRouteConstraint<decimal>((string str, out decimal result)
                         => decimal.TryParse(str, NumberStyles.Number, CultureInfo.InvariantCulture, out result));
-                case "decimal?":
-                    return new OptionalTypeRouteConstraint<decimal>((string str, out decimal result)
-                        => decimal.TryParse(str, NumberStyles.Number, CultureInfo.InvariantCulture, out result));
                 case "double":
                     return new TypeRouteConstraint<double>((string str, out double result)
                         => double.TryParse(str, NumberStyles.Number, CultureInfo.InvariantCulture, out result));
-                case "double?":
-                    return new OptionalTypeRouteConstraint<double>((string str, out double result)
-                        => double.TryParse(str, NumberStyles.Number, CultureInfo.InvariantCulture, out result));
                 case "float":
                     return new TypeRouteConstraint<float>((string str, out float result)
                         => float.TryParse(str, NumberStyles.Number, CultureInfo.InvariantCulture, out result));
-                case "float?":
-                    return new OptionalTypeRouteConstraint<float>((string str, out float result)
-                        => float.TryParse(str, NumberStyles.Number, CultureInfo.InvariantCulture, out result));
                 case "guid":
                     return new TypeRouteConstraint<Guid>(Guid.TryParse);
-                case "guid?":
-                    return new OptionalTypeRouteConstraint<Guid>(Guid.TryParse);
                 case "int":
                     return new TypeRouteConstraint<int>((string str, out int result)
                         => int.TryParse(str, NumberStyles.Integer, CultureInfo.InvariantCulture, out result));
-                case "int?":
-                    return new OptionalTypeRouteConstraint<int>((string str, out int result)
-                        => int.TryParse(str, NumberStyles.Integer, CultureInfo.InvariantCulture, out result));
                 case "long":
                     return new TypeRouteConstraint<long>((string str, out long result)
                         => long.TryParse(str, NumberStyles.Integer, CultureInfo.InvariantCulture, out result));
-                case "long?":
-                    return new OptionalTypeRouteConstraint<long>((string str, out long result)
-                        => long.TryParse(str, NumberStyles.Integer, CultureInfo.InvariantCulture, out result));
                 default:
                     return null;
             }

+ 90 - 84
src/Components/Components/src/Routing/RouteEntry.cs

@@ -29,116 +29,122 @@ namespace Microsoft.AspNetCore.Components.Routing
 
         internal void Match(RouteContext context)
         {
-            string? catchAllValue = null;
-
-            // If this template contains a catch-all parameter, we can concatenate the pathSegments
-            // at and beyond the catch-all segment's position. For example:
-            // Template:        /foo/bar/{*catchAll}
-            // PathSegments:    /foo/bar/one/two/three
-            if (Template.ContainsCatchAllSegment && context.Segments.Length >= Template.Segments.Length)
-            {
-                catchAllValue = string.Join('/', context.Segments[Range.StartAt(Template.Segments.Length - 1)]);
-            }
-            // If there are no optional segments on the route and the length of the route
-            // and the template do not match, then there is no chance of this matching and
-            // we can bail early.
-            else if (Template.OptionalSegmentsCount == 0 && Template.Segments.Length != context.Segments.Length)
-            {
-                return;
-            }
-
-            // Parameters will be lazily initialized.
+            var pathIndex = 0;
+            var templateIndex = 0;
             Dictionary<string, object> parameters = null;
-            var numMatchingSegments = 0;
-            for (var i = 0; i < Template.Segments.Length; i++)
+            // We will iterate over the path segments and the template segments until we have consumed
+            // one of them.
+            // There are three cases we need to account here for:
+            // * Path is shorter than template ->
+            //   * This can match only if we have t-p optional parameters at the end.
+            // * Path and template have the same number of segments
+            //   * This can happen when the catch-all segment matches 1 segment
+            //   * This can happen when an optional parameter has been specified.
+            //   * This can happen when the route only contains literals and parameters.
+            // * Path is longer than template -> This can only match if the parameter has a catch-all at the end.
+            //   * We still need to iterate over all the path segments if the catch-all is constrained.
+            //   * We still need to iterate over all the template/path segments before the catch-all
+            while (pathIndex < context.Segments.Length && templateIndex < Template.Segments.Length)
             {
-                var segment = Template.Segments[i];
+                var pathSegment = context.Segments[pathIndex];
+                var templateSegment = Template.Segments[templateIndex];
 
-                if (segment.IsCatchAll)
+                var matches = templateSegment.Match(pathSegment, out var match);
+                if (!matches)
                 {
-                    numMatchingSegments += 1;
-                    parameters ??= new Dictionary<string, object>(StringComparer.Ordinal);
-                    parameters[segment.Value] = catchAllValue;
-                    break;
+                    // A constraint or literal didn't match
+                    return;
                 }
 
-                // If the template contains more segments than the path, then
-                // we may need to break out of this for-loop. This can happen
-                // in one of two cases:
-                //
-                // (1) If we are comparing a literal route with a literal template
-                // and the route is shorter than the template.
-                // (2) If we are comparing a template where the last value is an optional
-                // parameter that the route does not provide.
-                if (i >= context.Segments.Length)
+                if (!templateSegment.IsCatchAll)
                 {
-                    // If we are under condition (1) above then we can stop evaluating
-                    // matches on the rest of this template.
-                    if (!segment.IsParameter && !segment.IsOptional)
+                    // We were dealing with a literal or a parameter, so just advance both cursors.
+                    pathIndex++;
+                    templateIndex++;
+
+                    if (templateSegment.IsParameter)
                     {
-                        break;
+                        parameters ??= new(StringComparer.OrdinalIgnoreCase);
+                        parameters[templateSegment.Value] = match;
                     }
                 }
-
-                string pathSegment = null;
-                if (i < context.Segments.Length)
+                else
                 {
-                    pathSegment = context.Segments[i];
+                    if (templateSegment.Constraints.Length == 0)
+                    {
+
+                        // Unconstrained catch all, we can stop early
+                        parameters ??= new(StringComparer.OrdinalIgnoreCase);
+                        parameters[templateSegment.Value] = string.Join('/', context.Segments, pathIndex, context.Segments.Length - pathIndex);
+
+                        // Mark the remaining segments as consumed.
+                        pathIndex = context.Segments.Length;
+
+                        // Catch-alls are always last.
+                        templateIndex++;
+
+                        // We are done, so break out of the loop.
+                        break;
+                    }
+                    else
+                    {
+                        // For constrained catch-alls, we advance the path index but keep the template index on the catch-all.
+                        pathIndex++;
+                        if (pathIndex == context.Segments.Length)
+                        {
+                            parameters ??= new(StringComparer.OrdinalIgnoreCase);
+                            parameters[templateSegment.Value] = string.Join('/', context.Segments, templateIndex, context.Segments.Length - templateIndex);
+
+                            // This is important to signal that we consumed the entire template.
+                            templateIndex++;
+                        }
+                    }
                 }
+            }
+
+            var hasRemainingOptionalSegments = templateIndex < Template.Segments.Length &&
+                RemainingSegmentsAreOptional(pathIndex, Template.Segments);
 
-                if (!segment.Match(pathSegment, out var matchedParameterValue))
+            if ((pathIndex == context.Segments.Length && templateIndex == Template.Segments.Length) || hasRemainingOptionalSegments)
+            {
+                if (hasRemainingOptionalSegments)
                 {
-                    return;
+                    parameters ??= new Dictionary<string, object>(StringComparer.Ordinal);
+                    AddDefaultValues(parameters, templateIndex, Template.Segments);
                 }
-                else
+                if (UnusedRouteParameterNames?.Length > 0)
                 {
-                    numMatchingSegments++;
-                    if (segment.IsParameter)
+                    parameters ??= new Dictionary<string, object>(StringComparer.Ordinal);
+                    for (var i = 0; i < UnusedRouteParameterNames.Length; i++)
                     {
-                        parameters ??= new Dictionary<string, object>(StringComparer.Ordinal);
-                        parameters[segment.Value] = matchedParameterValue;
+                        parameters[UnusedRouteParameterNames[i]] = null;
                     }
                 }
+                context.Handler = Handler;
+                context.Parameters = parameters;
             }
+        }
 
-            // In addition to extracting parameter values from the URL, each route entry
-            // also knows which other parameters should be supplied with null values. These
-            // are parameters supplied by other route entries matching the same handler.
-            if (!Template.ContainsCatchAllSegment && UnusedRouteParameterNames.Length > 0)
+        private void AddDefaultValues(Dictionary<string, object> parameters, int templateIndex, TemplateSegment[] segments)
+        {
+            for (var i = templateIndex; i < segments.Length; i++)
             {
-                parameters ??= new Dictionary<string, object>(StringComparer.Ordinal);
-                for (var i = 0; i < UnusedRouteParameterNames.Length; i++)
-                {
-                    parameters[UnusedRouteParameterNames[i]] = null;
-                }
+                var currentSegment = segments[i];
+                parameters[currentSegment.Value] = null;
             }
+        }
 
-            // We track the number of segments in the template that matched
-            // against this particular route then only select the route that
-            // matches the most number of segments on the route that was passed.
-            // This check is an exactness check that favors the more precise of
-            // two templates in the event that the following route table exists.
-            //  Route 1: /{anythingGoes}
-            //  Route 2: /users/{id:int}
-            // And the provided route is `/users/1`. We want to choose Route 2
-            // over Route 1.
-            // Furthermore, literal routes are preferred over parameterized routes.
-            // If the two routes below are registered in the route table.
-            // Route 1: /users/1
-            // Route 2: /users/{id:int}
-            // And the provided route is `/users/1`. We want to choose Route 1 over
-            // Route 2.
-            var allRouteSegmentsMatch = numMatchingSegments >= context.Segments.Length;
-            // Checking that all route segments have been matches does not suffice if we are
-            // comparing literal templates with literal routes. For example, the template
-            // `/this/is/a/template` and the route `/this/`. In that case, we want to ensure
-            // that all non-optional segments have matched as well.
-            var allNonOptionalSegmentsMatch = numMatchingSegments >= (Template.Segments.Length - Template.OptionalSegmentsCount);
-            if (Template.ContainsCatchAllSegment || (allRouteSegmentsMatch && allNonOptionalSegmentsMatch))
+        private bool RemainingSegmentsAreOptional(int index, TemplateSegment[] segments)
+        {
+            for (var i = index; index < segments.Length - 1; index++)
             {
-                context.Parameters = parameters;
-                context.Handler = Handler;
+                if (!segments[i].IsOptional)
+                {
+                    return false;
+                }
             }
+
+            return segments[^1].IsOptional || segments[^1].IsCatchAll;
         }
     }
 }

+ 2 - 2
src/Components/Components/src/Routing/RouteTable.cs

@@ -3,7 +3,7 @@
 
 namespace Microsoft.AspNetCore.Components.Routing
 {
-    internal class RouteTable
+    internal class RouteTable : IRouteTable
     {
         public RouteTable(RouteEntry[] routes)
         {
@@ -12,7 +12,7 @@ namespace Microsoft.AspNetCore.Components.Routing
 
         public RouteEntry[] Routes { get; }
 
-        internal void Route(RouteContext routeContext)
+        public void Route(RouteContext routeContext)
         {
             for (var i = 0; i < Routes.Length; i++)
             {

+ 47 - 47
src/Components/Components/src/Routing/RouteTableFactory.cs

@@ -7,7 +7,6 @@ using System.Collections.Generic;
 using System.Linq;
 using System.Reflection;
 using Microsoft.AspNetCore.Components.Routing;
-using Microsoft.Extensions.Internal;
 
 namespace Microsoft.AspNetCore.Components
 {
@@ -121,62 +120,63 @@ namespace Microsoft.AspNetCore.Components
 
             var xTemplate = x.Template;
             var yTemplate = y.Template;
-            if (xTemplate.Segments.Length != y.Template.Segments.Length)
+            var minSegments = Math.Min(xTemplate.Segments.Length, yTemplate.Segments.Length);
+            var currentResult = 0;
+            for (var i = 0; i < minSegments; i++)
             {
-                return xTemplate.Segments.Length < y.Template.Segments.Length ? -1 : 1;
-            }
-            else
-            {
-                for (var i = 0; i < xTemplate.Segments.Length; i++)
+                var xSegment = xTemplate.Segments[i];
+                var ySegment = yTemplate.Segments[i];
+
+                var xRank = GetRank(xSegment);
+                var yRank = GetRank(ySegment);
+
+                currentResult = xRank.CompareTo(yRank);
+
+                // If they are both literals we can disambiguate
+                if ((xRank, yRank) == (0, 0))
                 {
-                    var xSegment = xTemplate.Segments[i];
-                    var ySegment = yTemplate.Segments[i];
-                    if (!xSegment.IsParameter && ySegment.IsParameter)
-                    {
-                        return -1;
-                    }
-                    if (xSegment.IsParameter && !ySegment.IsParameter)
-                    {
-                        return 1;
-                    }
+                    currentResult = StringComparer.OrdinalIgnoreCase.Compare(xSegment.Value, ySegment.Value);
+                }
 
-                    if (xSegment.IsParameter)
-                    {
-                        // Always favor non-optional parameters over optional ones
-                        if (!xSegment.IsOptional && ySegment.IsOptional)
-                        {
-                            return -1;
-                        }
-
-                        if (xSegment.IsOptional && !ySegment.IsOptional)
-                        {
-                            return 1;
-                        }
-
-                        if (xSegment.Constraints.Length > ySegment.Constraints.Length)
-                        {
-                            return -1;
-                        }
-                        else if (xSegment.Constraints.Length < ySegment.Constraints.Length)
-                        {
-                            return 1;
-                        }
-                    }
-                    else
-                    {
-                        var comparison = string.Compare(xSegment.Value, ySegment.Value, StringComparison.OrdinalIgnoreCase);
-                        if (comparison != 0)
-                        {
-                            return comparison;
-                        }
-                    }
+                if (currentResult != 0)
+                {
+                    break;
                 }
+            }
+
+            if (currentResult == 0)
+            {
+                currentResult = xTemplate.Segments.Length.CompareTo(yTemplate.Segments.Length);
+            }
 
+            if (currentResult == 0)
+            {
                 throw new InvalidOperationException($@"The following routes are ambiguous:
 '{x.Template.TemplateText}' in '{x.Handler.FullName}'
 '{y.Template.TemplateText}' in '{y.Handler.FullName}'
 ");
             }
+
+            return currentResult;
+        }
+
+        private static int GetRank(TemplateSegment xSegment)
+        {
+            return xSegment switch
+            {
+                // Literal
+                { IsParameter: false } => 0,
+                // Parameter with constraints
+                { IsParameter: true, IsCatchAll: false, Constraints: { Length: > 0 } } => 1,
+                // Parameter without constraints
+                { IsParameter: true, IsCatchAll: false, Constraints: { Length: 0 } } => 2,
+                // Catch all parameter with constraints
+                { IsParameter: true, IsCatchAll: true, Constraints: { Length: > 0 } } => 3,
+                // Catch all parameter without constraints
+                { IsParameter: true, IsCatchAll: true, Constraints: { Length: 0 } } => 4,
+                // The segment is not correct
+                _ => throw new InvalidOperationException($"Unknown segment definition '{xSegment}.")
+            };
         }
 
         private readonly struct Key : IEquatable<Key>

+ 18 - 2
src/Components/Components/src/Routing/Router.cs

@@ -12,6 +12,7 @@ using System.Reflection;
 using System.Threading;
 using System.Threading.Tasks;
 using Microsoft.Extensions.Logging;
+using Microsoft.AspNetCore.Components.LegacyRouteMatching;
 
 namespace Microsoft.AspNetCore.Components.Routing
 {
@@ -75,7 +76,20 @@ namespace Microsoft.AspNetCore.Components.Routing
         /// </summary>
         [Parameter] public EventCallback<NavigationContext> OnNavigateAsync { get; set; }
 
-        private RouteTable Routes { get; set; }
+        /// <summary>
+        /// Gets or sets a flag to indicate whether route matching should prefer exact matches
+        /// over wildcards.
+        /// </summary>
+        /// <remarks>
+        /// <para>
+        /// Important: all applications should explicitly set this to true. The option to set it to false
+        /// (or leave unset, which defaults to false) is only provided for backward compatibility.
+        /// In .NET 6, this option will be removed and the router will always prefer exact matches.
+        /// </para>
+        /// </remarks>
+        [Parameter] public bool PreferExactMatches { get; set; }
+
+        private IRouteTable Routes { get; set; }
 
         /// <inheritdoc />
         public void Attach(RenderHandle renderHandle)
@@ -142,7 +156,9 @@ namespace Microsoft.AspNetCore.Components.Routing
 
             if (!_assemblies.SetEquals(assembliesSet))
             {
-                Routes = RouteTableFactory.Create(assemblies);
+                Routes = PreferExactMatches
+                    ? RouteTableFactory.Create(assemblies)
+                    : LegacyRouteTableFactory.Create(assemblies);
                 _assemblies.Clear();
                 _assemblies.UnionWith(assembliesSet);
             }

+ 6 - 1
src/Components/Components/src/Routing/TemplateParser.cs

@@ -50,6 +50,11 @@ namespace Microsoft.AspNetCore.Components.Routing
                         throw new InvalidOperationException(
                             $"Invalid template '{template}'. Missing '{{' in parameter segment '{segment}'.");
                     }
+                    if (segment[^1] == '?')
+                    {
+                        throw new InvalidOperationException(
+                            $"Invalid template '{template}'. '?' is not allowed in literal segment '{segment}'.");
+                    }
                     templateSegments[i] = new TemplateSegment(originalTemplate, segment, isParameter: false);
                 }
                 else
@@ -95,7 +100,7 @@ namespace Microsoft.AspNetCore.Components.Routing
                 {
                     var nextSegment = templateSegments[j];
 
-                    if (currentSegment.IsOptional && !nextSegment.IsOptional)
+                    if (currentSegment.IsOptional && !nextSegment.IsOptional && !nextSegment.IsCatchAll)
                     {
                         throw new InvalidOperationException($"Invalid template '{template}'. Non-optional parameters or literal routes cannot appear after optional parameters.");
                     }

+ 41 - 17
src/Components/Components/src/Routing/TemplateSegment.cs

@@ -12,15 +12,15 @@ namespace Microsoft.AspNetCore.Components.Routing
         {
             IsParameter = isParameter;
 
-            IsCatchAll = segment.StartsWith('*');
+            IsCatchAll = isParameter && segment.StartsWith('*');
 
             if (IsCatchAll)
             {
                 // Only one '*' currently allowed
-                Value = segment.Substring(1);
+                Value = segment[1..];
 
-                var invalidCharacter = Value.IndexOf('*');
-                if (Value.IndexOf('*') != -1)
+                var invalidCharacterIndex = Value.IndexOf('*');
+                if (invalidCharacterIndex != -1)
                 {
                     throw new InvalidOperationException($"Invalid template '{template}'. A catch-all parameter may only have one '*' at the beginning of the segment.");
                 }
@@ -30,21 +30,24 @@ namespace Microsoft.AspNetCore.Components.Routing
                 Value = segment;
             }
 
-            // Process segments that are not parameters or do not contain
-            // a token separating a type constraint.
-            if (!isParameter || Value.IndexOf(':') < 0)
+            // Process segments that parameters  that do not contain a token separating a type constraint.
+            if (IsParameter)
             {
+                if (Value.IndexOf(':') < 0)
+                {
+
                 // Set the IsOptional flag to true for segments that contain
                 // a parameter with no type constraints but optionality set
                 // via the '?' token.
-                if (Value.IndexOf('?') == Value.Length - 1)
+                    var questionMarkIndex = Value.IndexOf('?');
+                    if (questionMarkIndex == Value.Length - 1)
                 {
                     IsOptional = true;
-                    Value = Value.Substring(0, Value.Length - 1);
+                        Value = Value[0..^1];
                 }
                 // If the `?` optional marker shows up in the segment but not at the very end,
                 // then throw an error.
-                else if (Value.IndexOf('?') >= 0 && Value.IndexOf('?') != Value.Length - 1)
+                    else if (questionMarkIndex >= 0)
                 {
                     throw new ArgumentException($"Malformed parameter '{segment}' in route '{template}'. '?' character can only appear at the end of parameter name.");
                 }
@@ -59,14 +62,23 @@ namespace Microsoft.AspNetCore.Components.Routing
                     throw new ArgumentException($"Malformed parameter '{segment}' in route '{template}' has no name before the constraints list.");
                 }
 
-                // Set the IsOptional flag to true if any type constraints
-                // for this parameter are designated as optional.
-                IsOptional = tokens.Skip(1).Any(token => token.EndsWith('?'));
-
                 Value = tokens[0];
-                Constraints = tokens.Skip(1)
-                    .Select(token => RouteConstraint.Parse(template, segment, token))
-                    .ToArray();
+                    IsOptional = tokens[^1].EndsWith('?');
+                    if (IsOptional)
+                    {
+                        tokens[^1] = tokens[^1][0..^1];
+            }
+
+                    Constraints = new RouteConstraint[tokens.Length - 1];
+                    for (var i = 1; i < tokens.Length; i++)
+                    {
+                        Constraints[i - 1] = RouteConstraint.Parse(template, segment, tokens[i]);
+                    }
+                }
+            }
+            else
+            {
+                Constraints = Array.Empty<RouteConstraint>();
             }
 
             if (IsParameter)
@@ -119,5 +131,17 @@ namespace Microsoft.AspNetCore.Components.Routing
                 return string.Equals(Value, pathSegment, StringComparison.OrdinalIgnoreCase);
             }
         }
+
+        public override string ToString() => this switch
+        {
+            { IsParameter: true, IsOptional: false, IsCatchAll: false, Constraints: { Length: 0 } } => $"{{{Value}}}",
+            { IsParameter: true, IsOptional: false, IsCatchAll: false, Constraints: { Length: > 0 } } => $"{{{Value}:{string.Join(':', Constraints.Select(c => c.ToString()))}}}",
+            { IsParameter: true, IsOptional: true, Constraints: { Length: 0 } } => $"{{{Value}?}}",
+            { IsParameter: true, IsOptional: true, Constraints: { Length: > 0 } } => $"{{{Value}:{string.Join(':', Constraints.Select(c => c.ToString()))}?}}",
+            { IsParameter: true, IsCatchAll: true, Constraints: { Length: 0 } } => $"{{*{Value}}}",
+            { IsParameter: true, IsCatchAll: true, Constraints: { Length: > 0 } } => $"{{*{Value}:{string.Join(':', Constraints.Select(c => c.ToString()))}?}}",
+            { IsParameter: false } => Value,
+            _ => throw new InvalidOperationException("Invalid template segment.")
+        };
     }
 }

+ 14 - 0
src/Components/Components/src/Routing/TypeRouteConstraint.cs

@@ -1,6 +1,7 @@
 // Copyright (c) .NET Foundation. All rights reserved.
 // Licensed under the Apache License, Version 2.0. See License.txt in the project root for license information.
 
+using System;
 using System.Diagnostics.CodeAnalysis;
 
 namespace Microsoft.AspNetCore.Components.Routing
@@ -33,5 +34,18 @@ namespace Microsoft.AspNetCore.Components.Routing
                 return false;
             }
         }
+
+        public override string ToString() => typeof(T) switch
+        {
+            var x when x == typeof(bool) => "bool",
+            var x when x == typeof(DateTime) => "datetime",
+            var x when x == typeof(decimal) => "decimal",
+            var x when x == typeof(double) => "double",
+            var x when x == typeof(float) => "float",
+            var x when x == typeof(Guid) => "guid",
+            var x when x == typeof(int) => "int",
+            var x when x == typeof(long) => "long",
+            var x => x.Name.ToLowerInvariant()
+        };
     }
 }

+ 46 - 0
src/Components/Components/test/LegacyRouteMatching/LegacyRouteConstraintTest.cs

@@ -0,0 +1,46 @@
+// Copyright (c) .NET Foundation. All rights reserved.
+// Licensed under the Apache License, Version 2.0. See License.txt in the project root for license information.
+
+using Xunit;
+
+namespace Microsoft.AspNetCore.Components.LegacyRouteMatching
+{
+    public class LegacyRouteConstraintTest
+    {
+        [Fact]
+        public void Parse_CreatesDifferentConstraints_ForDifferentKinds()
+        {
+            // Arrange
+            var original = LegacyRouteConstraint.Parse("ignore", "ignore", "int");
+
+            // Act
+            var another = LegacyRouteConstraint.Parse("ignore", "ignore", "guid");
+
+            // Assert
+            Assert.NotSame(original, another);
+        }
+
+        [Fact]
+        public void Parse_CachesCreatedConstraint_ForSameKind()
+        {
+            // Arrange
+            var original = LegacyRouteConstraint.Parse("ignore", "ignore", "int");
+
+            // Act
+            var another = LegacyRouteConstraint.Parse("ignore", "ignore", "int");
+
+            // Assert
+            Assert.Same(original, another);
+        }
+
+        [Fact]
+        public void Parse_DoesNotThrowIfOptionalConstraint()
+        {
+            // Act
+            var exceptions = Record.Exception(() => LegacyRouteConstraint.Parse("ignore", "ignore", "int?"));
+
+            // Assert
+            Assert.Null(exceptions);
+        }
+    }
+}

+ 741 - 0
src/Components/Components/test/LegacyRouteMatching/LegacyRouteTableFactoryTests.cs

@@ -0,0 +1,741 @@
+// Copyright (c) .NET Foundation. All rights reserved.
+// Licensed under the Apache License, Version 2.0. See License.txt in the project root for license information.
+
+using System;
+using System.Collections.Generic;
+using System.Globalization;
+using System.Linq;
+using Xunit;
+
+// Avoid referencing the whole Microsoft.AspNetCore.Components.Routing namespace to
+// avoid the risk of accidentally relying on the non-legacy types in the legacy fork
+using RouteContext = Microsoft.AspNetCore.Components.Routing.RouteContext;
+
+namespace Microsoft.AspNetCore.Components.LegacyRouteMatching
+{
+    public class LegacyRouteTableFactoryTests
+    {
+        [Fact]
+        public void CanCacheRouteTable()
+        {
+            // Arrange
+            var routes1 = LegacyRouteTableFactory.Create(new[] { GetType().Assembly, });
+
+            // Act
+            var routes2 = LegacyRouteTableFactory.Create(new[] { GetType().Assembly, });
+
+            // Assert
+            Assert.Same(routes1, routes2);
+        }
+
+        [Fact]
+        public void CanCacheRouteTableWithDifferentAssembliesAndOrder()
+        {
+            // Arrange
+            var routes1 = LegacyRouteTableFactory.Create(new[] { typeof(object).Assembly, GetType().Assembly, });
+
+            // Act
+            var routes2 = LegacyRouteTableFactory.Create(new[] { GetType().Assembly, typeof(object).Assembly, });
+
+            // Assert
+            Assert.Same(routes1, routes2);
+        }
+
+        [Fact]
+        public void DoesNotCacheRouteTableForDifferentAssemblies()
+        {
+            // Arrange
+            var routes1 = LegacyRouteTableFactory.Create(new[] { GetType().Assembly, });
+
+            // Act
+            var routes2 = LegacyRouteTableFactory.Create(new[] { GetType().Assembly, typeof(object).Assembly, });
+
+            // Assert
+            Assert.NotSame(routes1, routes2);
+        }
+
+        [Fact]
+        public void CanDiscoverRoute()
+        {
+            // Arrange & Act
+            var routes = LegacyRouteTableFactory.Create(new[] { typeof(MyComponent), });
+
+            // Assert
+            Assert.Equal("Test1", Assert.Single(routes.Routes).Template.TemplateText);
+        }
+
+        [Route("Test1")]
+        private class MyComponent : ComponentBase
+        {
+        }
+
+        [Fact]
+        public void CanDiscoverRoutes_WithInheritance()
+        {
+            // Arrange & Act
+            var routes = LegacyRouteTableFactory.Create(new[] { typeof(MyComponent), typeof(MyInheritedComponent), });
+
+            // Assert
+            Assert.Collection(
+                routes.Routes.OrderBy(r => r.Template.TemplateText),
+                r => Assert.Equal("Test1", r.Template.TemplateText),
+                r => Assert.Equal("Test2", r.Template.TemplateText));
+        }
+
+        [Route("Test2")]
+        private class MyInheritedComponent : MyComponent
+        {
+        }
+
+        [Fact]
+        public void CanMatchRootTemplate()
+        {
+            // Arrange
+            var routeTable = new TestRouteTableBuilder().AddRoute("/").Build();
+            var context = new RouteContext("/");
+
+            // Act
+            routeTable.Route(context);
+
+            // Assert
+            Assert.NotNull(context.Handler);
+        }
+
+        [Fact]
+        public void CanMatchLiteralTemplate()
+        {
+            // Arrange
+            var routeTable = new TestRouteTableBuilder().AddRoute("/literal").Build();
+            var context = new RouteContext("/literal/");
+
+            // Act
+            routeTable.Route(context);
+
+            // Assert
+            Assert.NotNull(context.Handler);
+        }
+
+        [Fact]
+        public void CanMatchTemplateWithMultipleLiterals()
+        {
+            // Arrange
+            var routeTable = new TestRouteTableBuilder().AddRoute("/some/awesome/route/").Build();
+            var context = new RouteContext("/some/awesome/route");
+
+            // Act
+            routeTable.Route(context);
+
+            // Assert
+            Assert.NotNull(context.Handler);
+        }
+
+        [Fact]
+        public void RouteMatchingIsCaseInsensitive()
+        {
+            // Arrange
+            var routeTable = new TestRouteTableBuilder().AddRoute("/some/AWESOME/route/").Build();
+            var context = new RouteContext("/Some/awesome/RouTe");
+
+            // Act
+            routeTable.Route(context);
+
+            // Assert
+            Assert.NotNull(context.Handler);
+        }
+
+        [Fact]
+        public void CanMatchEncodedSegments()
+        {
+            // Arrange
+            var routeTable = new TestRouteTableBuilder().AddRoute("/some/ünicõdē/🛣/").Build();
+            var context = new RouteContext("/some/%C3%BCnic%C3%B5d%C4%93/%F0%9F%9B%A3");
+
+            // Act
+            routeTable.Route(context);
+
+            // Assert
+            Assert.NotNull(context.Handler);
+        }
+
+        [Fact]
+        public void DoesNotMatchIfSegmentsDontMatch()
+        {
+            // Arrange
+            var routeTable = new TestRouteTableBuilder().AddRoute("/some/AWESOME/route/").Build();
+            var context = new RouteContext("/some/brilliant/route");
+
+            // Act
+            routeTable.Route(context);
+
+            // Assert
+            Assert.Null(context.Handler);
+        }
+
+        [Theory]
+        [InlineData("/{value:bool}", "/maybe")]
+        [InlineData("/{value:datetime}", "/1955-01-32")]
+        [InlineData("/{value:decimal}", "/hello")]
+        [InlineData("/{value:double}", "/0.1.2")]
+        [InlineData("/{value:float}", "/0.1.2")]
+        [InlineData("/{value:guid}", "/not-a-guid")]
+        [InlineData("/{value:int}", "/3.141")]
+        [InlineData("/{value:long}", "/3.141")]
+        public void DoesNotMatchIfConstraintDoesNotMatch(string template, string contextUrl)
+        {
+            // Arrange
+            var routeTable = new TestRouteTableBuilder().AddRoute(template).Build();
+            var context = new RouteContext(contextUrl);
+
+            // Act
+            routeTable.Route(context);
+
+            // Assert
+            Assert.Null(context.Handler);
+        }
+
+        [Theory]
+        [InlineData("/some")]
+        [InlineData("/some/awesome/route/with/extra/segments")]
+        public void DoesNotMatchIfDifferentNumberOfSegments(string path)
+        {
+            // Arrange
+            var routeTable = new TestRouteTableBuilder().AddRoute("/some/awesome/route/").Build();
+            var context = new RouteContext(path);
+
+            // Act
+            routeTable.Route(context);
+
+            // Assert
+            Assert.Null(context.Handler);
+        }
+
+        [Theory]
+        [InlineData("/value1", "value1")]
+        [InlineData("/value2/", "value2")]
+        [InlineData("/d%C3%A9j%C3%A0%20vu", "déjà vu")]
+        [InlineData("/d%C3%A9j%C3%A0%20vu/", "déjà vu")]
+        [InlineData("/d%C3%A9j%C3%A0+vu", "déjà+vu")]
+        public void CanMatchParameterTemplate(string path, string expectedValue)
+        {
+            // Arrange
+            var routeTable = new TestRouteTableBuilder().AddRoute("/{parameter}").Build();
+            var context = new RouteContext(path);
+
+            // Act
+            routeTable.Route(context);
+
+            // Assert
+            Assert.NotNull(context.Handler);
+            Assert.Single(context.Parameters, p => p.Key == "parameter" && (string)p.Value == expectedValue);
+        }
+
+        [Theory]
+        [InlineData("/blog/value1", "value1")]
+        [InlineData("/blog/value1/foo%20bar", "value1/foo bar")]
+        public void CanMatchCatchAllParameterTemplate(string path, string expectedValue)
+        {
+            // Arrange
+            var routeTable = new TestRouteTableBuilder().AddRoute("/blog/{*parameter}").Build();
+            var context = new RouteContext(path);
+
+            // Act
+            routeTable.Route(context);
+
+            // Assert
+            Assert.NotNull(context.Handler);
+            Assert.Single(context.Parameters, p => p.Key == "parameter" && (string)p.Value == expectedValue);
+        }
+
+        [Fact]
+        public void CanMatchTemplateWithMultipleParameters()
+        {
+            // Arrange
+            var routeTable = new TestRouteTableBuilder().AddRoute("/{some}/awesome/{route}/").Build();
+            var context = new RouteContext("/an/awesome/path");
+
+            var expectedParameters = new Dictionary<string, object>
+            {
+                ["some"] = "an",
+                ["route"] = "path"
+            };
+
+            // Act
+            routeTable.Route(context);
+
+            // Assert
+            Assert.NotNull(context.Handler);
+            Assert.Equal(expectedParameters, context.Parameters);
+        }
+
+
+        [Fact]
+        public void CanMatchTemplateWithMultipleParametersAndCatchAllParameter()
+        {
+            // Arrange
+            var routeTable = new TestRouteTableBuilder().AddRoute("/{some}/awesome/{route}/with/{*catchAll}").Build();
+            var context = new RouteContext("/an/awesome/path/with/some/catch/all/stuff");
+
+            var expectedParameters = new Dictionary<string, object>
+            {
+                ["some"] = "an",
+                ["route"] = "path",
+                ["catchAll"] = "some/catch/all/stuff"
+            };
+
+            // Act
+            routeTable.Route(context);
+
+            // Assert
+            Assert.NotNull(context.Handler);
+            Assert.Equal(expectedParameters, context.Parameters);
+        }
+
+        public static IEnumerable<object[]> CanMatchParameterWithConstraintCases() => new object[][]
+        {
+            new object[] { "/{value:bool}", "/true", true },
+            new object[] { "/{value:bool}", "/false", false },
+            new object[] { "/{value:datetime}", "/1955-01-30", new DateTime(1955, 1, 30) },
+            new object[] { "/{value:decimal}", "/5.3", 5.3m },
+            new object[] { "/{value:double}", "/0.1", 0.1d },
+            new object[] { "/{value:float}", "/0.1", 0.1f },
+            new object[] { "/{value:guid}", "/1FCEF085-884F-416E-B0A1-71B15F3E206B", Guid.Parse("1FCEF085-884F-416E-B0A1-71B15F3E206B") },
+            new object[] { "/{value:int}", "/123", 123 },
+            new object[] { "/{value:int}", "/-123", -123},
+            new object[] { "/{value:long}", "/9223372036854775807", long.MaxValue },
+            new object[] { "/{value:long}", $"/-9223372036854775808", long.MinValue },
+        };
+
+        [Theory]
+        [MemberData(nameof(CanMatchParameterWithConstraintCases))]
+        public void CanMatchParameterWithConstraint(string template, string contextUrl, object convertedValue)
+        {
+            // Arrange
+            var routeTable = new TestRouteTableBuilder().AddRoute(template).Build();
+            var context = new RouteContext(contextUrl);
+
+            // Act
+            routeTable.Route(context);
+
+            // Assert
+            if (context.Handler == null)
+            {
+                // Make it easier to track down failing tests when using MemberData
+                throw new InvalidOperationException($"Failed to match template '{template}'.");
+            }
+            Assert.Equal(new Dictionary<string, object>
+            {
+                { "value", convertedValue }
+            }, context.Parameters);
+        }
+
+        [Fact]
+        public void CanMatchOptionalParameterWithoutConstraints()
+        {
+            // Arrange
+            var template = "/optional/{value?}";
+            var contextUrl = "/optional/";
+            string convertedValue = null;
+
+            var routeTable = new TestRouteTableBuilder().AddRoute(template).Build();
+            var context = new RouteContext(contextUrl);
+
+            // Act
+            routeTable.Route(context);
+
+            // Assert
+            if (context.Handler == null)
+            {
+                // Make it easier to track down failing tests when using MemberData
+                throw new InvalidOperationException($"Failed to match template '{template}'.");
+            }
+            Assert.Equal(new Dictionary<string, object>
+            {
+                { "value", convertedValue }
+            }, context.Parameters);
+        }
+
+        public static IEnumerable<object[]> CanMatchOptionalParameterWithConstraintCases() => new object[][]
+{
+            new object[] { "/optional/{value:bool?}", "/optional/", null },
+            new object[] { "/optional/{value:datetime?}", "/optional/", null },
+            new object[] { "/optional/{value:decimal?}", "/optional/", null },
+};
+
+        [Theory]
+        [MemberData(nameof(CanMatchOptionalParameterWithConstraintCases))]
+        public void CanMatchOptionalParameterWithConstraint(string template, string contextUrl, object convertedValue)
+        {
+            // Arrange
+            var routeTable = new TestRouteTableBuilder().AddRoute(template).Build();
+            var context = new RouteContext(contextUrl);
+
+            // Act
+            routeTable.Route(context);
+
+            // Assert
+            if (context.Handler == null)
+            {
+                // Make it easier to track down failing tests when using MemberData
+                throw new InvalidOperationException($"Failed to match template '{template}'.");
+            }
+            Assert.Equal(new Dictionary<string, object>
+            {
+                { "value", convertedValue }
+            }, context.Parameters);
+        }
+
+        [Fact]
+        public void CanMatchMultipleOptionalParameterWithConstraint()
+        {
+            // Arrange
+            var template = "/optional/{value:datetime?}/{value2:datetime?}";
+            var contextUrl = "/optional//";
+            object convertedValue = null;
+
+            var routeTable = new TestRouteTableBuilder().AddRoute(template).Build();
+            var context = new RouteContext(contextUrl);
+
+            // Act
+            routeTable.Route(context);
+
+            // Assert
+            if (context.Handler == null)
+            {
+                // Make it easier to track down failing tests when using MemberData
+                throw new InvalidOperationException($"Failed to match template '{template}'.");
+            }
+            Assert.Equal(new Dictionary<string, object>
+            {
+                { "value", convertedValue },
+                { "value2", convertedValue }
+            }, context.Parameters);
+        }
+
+        public static IEnumerable<object[]> CanMatchSegmentWithMultipleConstraintsCases() => new object[][]
+{
+            new object[] { "/{value:double:int}/", "/15", 15 },
+            new object[] { "/{value:double?:int?}/", "/", null },
+};
+
+        [Theory]
+        [MemberData(nameof(CanMatchSegmentWithMultipleConstraintsCases))]
+        public void CanMatchSegmentWithMultipleConstraints(string template, string contextUrl, object convertedValue)
+        {
+            // Arrange
+            var routeTable = new TestRouteTableBuilder().AddRoute(template).Build();
+            var context = new RouteContext(contextUrl);
+
+            // Act
+            routeTable.Route(context);
+
+            // Assert
+            Assert.Equal(new Dictionary<string, object>
+            {
+                { "value", convertedValue }
+            }, context.Parameters);
+        }
+
+        [Fact]
+        public void PrefersLiteralTemplateOverTemplateWithParameters()
+        {
+            // Arrange
+            var routeTable = new TestRouteTableBuilder()
+                .AddRoute("/an/awesome/path", typeof(TestHandler1))
+                .AddRoute("/{some}/awesome/{route}/", typeof(TestHandler2))
+                .Build();
+            var context = new RouteContext("/an/awesome/path");
+
+            // Act
+            routeTable.Route(context);
+
+            // Assert
+            Assert.NotNull(context.Handler);
+            Assert.Null(context.Parameters);
+        }
+
+        [Fact]
+        public void PrefersLiteralTemplateOverTemplateWithOptionalParameters()
+        {
+            // Arrange
+            var routeTable = new TestRouteTableBuilder()
+                .AddRoute("/users/1", typeof(TestHandler1))
+                .AddRoute("/users/{id?}", typeof(TestHandler2))
+                .Build();
+            var context = new RouteContext("/users/1");
+
+            // Act
+            routeTable.Route(context);
+
+            // Assert
+            Assert.NotNull(context.Handler);
+            Assert.Null(context.Parameters);
+        }
+
+        [Fact]
+        public void PrefersOptionalParamsOverNonOptionalParams()
+        {
+            // Arrange
+            var routeTable = new TestRouteTableBuilder()
+                .AddRoute("/users/{id}", typeof(TestHandler1))
+                .AddRoute("/users/{id?}", typeof(TestHandler2))
+                .Build();
+            var contextWithParam = new RouteContext("/users/1");
+            var contextWithoutParam = new RouteContext("/users/");
+
+            // Act
+            routeTable.Route(contextWithParam);
+            routeTable.Route(contextWithoutParam);
+
+            // Assert
+            Assert.NotNull(contextWithParam.Handler);
+            Assert.Equal(typeof(TestHandler1), contextWithParam.Handler);
+
+            Assert.NotNull(contextWithoutParam.Handler);
+            Assert.Equal(typeof(TestHandler2), contextWithoutParam.Handler);
+        }
+
+        [Fact]
+        public void PrefersOptionalParamsOverNonOptionalParamsReverseOrder()
+        {
+            // Arrange
+            var routeTable = new TestRouteTableBuilder()
+                .AddRoute("/users/{id}", typeof(TestHandler1))
+                .AddRoute("/users/{id?}", typeof(TestHandler2))
+                .Build();
+            var contextWithParam = new RouteContext("/users/1");
+            var contextWithoutParam = new RouteContext("/users/");
+
+            // Act
+            routeTable.Route(contextWithParam);
+            routeTable.Route(contextWithoutParam);
+
+            // Assert
+            Assert.NotNull(contextWithParam.Handler);
+            Assert.Equal(typeof(TestHandler1), contextWithParam.Handler);
+
+            Assert.NotNull(contextWithoutParam.Handler);
+            Assert.Equal(typeof(TestHandler2), contextWithoutParam.Handler);
+        }
+
+
+        [Fact]
+        public void PrefersLiteralTemplateOverParameterizedTemplates()
+        {
+            // Arrange
+            var routeTable = new TestRouteTableBuilder()
+                .AddRoute("/users/1/friends", typeof(TestHandler1))
+                .AddRoute("/users/{id}/{location}", typeof(TestHandler2))
+                .AddRoute("/users/1/{location}", typeof(TestHandler2))
+                .Build();
+            var context = new RouteContext("/users/1/friends");
+
+            // Act
+            routeTable.Route(context);
+
+            // Assert
+            Assert.NotNull(context.Handler);
+            Assert.Equal(typeof(TestHandler1), context.Handler);
+            Assert.Null(context.Parameters);
+        }
+
+        [Fact]
+        public void PrefersShorterRoutesOverLongerRoutes()
+        {
+            // Arrange & Act
+            var handler = typeof(int);
+            var routeTable = new TestRouteTableBuilder()
+                .AddRoute("/an/awesome/path")
+                .AddRoute("/an/awesome/", handler).Build();
+
+            // Act
+            Assert.Equal("an/awesome", routeTable.Routes[0].Template.TemplateText);
+        }
+
+        [Fact]
+        public void PrefersMoreConstraintsOverFewer()
+        {
+            // Arrange
+            var routeTable = new TestRouteTableBuilder()
+                .AddRoute("/products/{id}")
+                .AddRoute("/products/{id:int}").Build();
+            var context = new RouteContext("/products/456");
+
+            // Act
+            routeTable.Route(context);
+
+            // Assert
+            Assert.NotNull(context.Handler);
+            Assert.Equal(context.Parameters, new Dictionary<string, object>
+            {
+                { "id", 456 }
+            });
+        }
+
+        [Fact]
+        public void PrefersRoutesThatMatchMoreSegments()
+        {
+            // Arrange
+            var routeTable = new TestRouteTableBuilder()
+                .AddRoute("/{anythingGoes}", typeof(TestHandler1))
+                .AddRoute("/users/{id?}", typeof(TestHandler2))
+                .Build();
+            var context = new RouteContext("/users/1");
+
+            // Act
+            routeTable.Route(context);
+
+            // Assert
+            Assert.NotNull(context.Handler);
+            Assert.Equal(typeof(TestHandler2), context.Handler);
+            Assert.NotNull(context.Parameters);
+        }
+
+        [Fact]
+        public void ProducesAStableOrderForNonAmbiguousRoutes()
+        {
+            // Arrange & Act
+            var handler = typeof(int);
+            var routeTable = new TestRouteTableBuilder()
+                .AddRoute("/an/awesome/", handler)
+                .AddRoute("/a/brilliant/").Build();
+
+            // Act
+            Assert.Equal("a/brilliant", routeTable.Routes[0].Template.TemplateText);
+        }
+
+        [Fact]
+        public void DoesNotThrowIfStableSortComparesRouteWithItself()
+        {
+            // Test for https://github.com/dotnet/aspnetcore/issues/13313
+            // Arrange & Act
+            var builder = new TestRouteTableBuilder();
+            builder.AddRoute("r16");
+            builder.AddRoute("r05");
+            builder.AddRoute("r09");
+            builder.AddRoute("r00");
+            builder.AddRoute("r13");
+            builder.AddRoute("r02");
+            builder.AddRoute("r03");
+            builder.AddRoute("r10");
+            builder.AddRoute("r15");
+            builder.AddRoute("r14");
+            builder.AddRoute("r12");
+            builder.AddRoute("r07");
+            builder.AddRoute("r11");
+            builder.AddRoute("r08");
+            builder.AddRoute("r06");
+            builder.AddRoute("r04");
+            builder.AddRoute("r01");
+
+            var routeTable = builder.Build();
+
+            // Act
+            Assert.Equal(17, routeTable.Routes.Length);
+            for (var i = 0; i < 17; i++)
+            {
+                var templateText = "r" + i.ToString(CultureInfo.InvariantCulture).PadLeft(2, '0');
+                Assert.Equal(templateText, routeTable.Routes[i].Template.TemplateText);
+            }
+        }
+
+        [Theory]
+        [InlineData("/literal", "/Literal/")]
+        [InlineData("/{parameter}", "/{parameter}/")]
+        [InlineData("/literal/{parameter}", "/Literal/{something}")]
+        [InlineData("/{parameter}/literal/{something}", "{param}/Literal/{else}")]
+        public void DetectsAmbiguousRoutes(string left, string right)
+        {
+            // Arrange
+            var expectedMessage = $@"The following routes are ambiguous:
+'{left.Trim('/')}' in '{typeof(object).FullName}'
+'{right.Trim('/')}' in '{typeof(object).FullName}'
+";
+            // Act
+            var exception = Assert.Throws<InvalidOperationException>(() => new TestRouteTableBuilder()
+                .AddRoute(left)
+                .AddRoute(right).Build());
+
+            Assert.Equal(expectedMessage, exception.Message);
+        }
+
+        [Fact]
+        public void SuppliesNullForUnusedHandlerParameters()
+        {
+            // Arrange
+            var routeTable = new TestRouteTableBuilder()
+                .AddRoute("/", typeof(TestHandler1))
+                .AddRoute("/products/{param1:int}", typeof(TestHandler1))
+                .AddRoute("/products/{param2}/{PaRam1}", typeof(TestHandler1))
+                .AddRoute("/{unrelated}", typeof(TestHandler2))
+                .Build();
+            var context = new RouteContext("/products/456");
+
+            // Act
+            routeTable.Route(context);
+
+            // Assert
+            Assert.Collection(routeTable.Routes,
+                route =>
+                {
+                    Assert.Same(typeof(TestHandler1), route.Handler);
+                    Assert.Equal("/", route.Template.TemplateText);
+                    Assert.Equal(new[] { "param1", "param2" }, route.UnusedRouteParameterNames);
+                },
+                route =>
+                {
+                    Assert.Same(typeof(TestHandler2), route.Handler);
+                    Assert.Equal("{unrelated}", route.Template.TemplateText);
+                    Assert.Equal(Array.Empty<string>(), route.UnusedRouteParameterNames);
+                },
+                route =>
+                {
+                    Assert.Same(typeof(TestHandler1), route.Handler);
+                    Assert.Equal("products/{param1:int}", route.Template.TemplateText);
+                    Assert.Equal(new[] { "param2" }, route.UnusedRouteParameterNames);
+                },
+                route =>
+                {
+                    Assert.Same(typeof(TestHandler1), route.Handler);
+                    Assert.Equal("products/{param2}/{PaRam1}", route.Template.TemplateText);
+                    Assert.Equal(Array.Empty<string>(), route.UnusedRouteParameterNames);
+                });
+            Assert.Same(typeof(TestHandler1), context.Handler);
+            Assert.Equal(new Dictionary<string, object>
+            {
+                { "param1", 456 },
+                { "param2", null },
+            }, context.Parameters);
+        }
+
+        private class TestRouteTableBuilder
+        {
+            IList<(string Template, Type Handler)> _routeTemplates = new List<(string, Type)>();
+            Type _handler = typeof(object);
+
+            public TestRouteTableBuilder AddRoute(string template, Type handler = null)
+            {
+                _routeTemplates.Add((template, handler ?? _handler));
+                return this;
+            }
+
+            public LegacyRouteTable Build()
+            {
+                try
+                {
+                    var templatesByHandler = _routeTemplates
+                        .GroupBy(rt => rt.Handler)
+                        .ToDictionary(group => group.Key, group => group.Select(g => g.Template).ToArray());
+                    return LegacyRouteTableFactory.Create(templatesByHandler);
+                }
+                catch (InvalidOperationException ex) when (ex.InnerException is InvalidOperationException)
+                {
+                    // ToArray() will wrap our exception in its own.
+                    throw ex.InnerException;
+                }
+            }
+        }
+
+        class TestHandler1 { }
+        class TestHandler2 { }
+    }
+}

+ 295 - 0
src/Components/Components/test/LegacyRouteMatching/LegacyTemplateParserTests.cs

@@ -0,0 +1,295 @@
+// Copyright (c) .NET Foundation. All rights reserved.
+// Licensed under the Apache License, Version 2.0. See License.txt in the project root for license information.
+
+using System;
+using System.Collections.Generic;
+using System.Linq;
+using Xunit;
+
+namespace Microsoft.AspNetCore.Components.LegacyRouteMatching
+{
+    public class LegacyTemplateParserTests
+    {
+        [Fact]
+        public void Parse_SingleLiteral()
+        {
+            // Arrange
+            var expected = new ExpectedTemplateBuilder().Literal("awesome");
+
+            // Act
+            var actual = LegacyTemplateParser.ParseTemplate("awesome");
+
+            // Assert
+            Assert.Equal(expected, actual, LegacyRouteTemplateTestComparer.Instance);
+        }
+
+        [Fact]
+        public void Parse_SingleParameter()
+        {
+            // Arrange
+            var template = "{p}";
+
+            var expected = new ExpectedTemplateBuilder().Parameter("p");
+
+            // Act
+            var actual = LegacyTemplateParser.ParseTemplate(template);
+
+            // Assert
+            Assert.Equal(expected, actual, LegacyRouteTemplateTestComparer.Instance);
+        }
+
+        [Fact]
+        public void Parse_MultipleLiterals()
+        {
+            // Arrange
+            var template = "awesome/cool/super";
+
+            var expected = new ExpectedTemplateBuilder().Literal("awesome").Literal("cool").Literal("super");
+
+            // Act
+            var actual = LegacyTemplateParser.ParseTemplate(template);
+
+            // Assert
+            Assert.Equal(expected, actual, LegacyRouteTemplateTestComparer.Instance);
+        }
+
+        [Fact]
+        public void Parse_MultipleParameters()
+        {
+            // Arrange
+            var template = "{p1}/{p2}/{p3}";
+
+            var expected = new ExpectedTemplateBuilder().Parameter("p1").Parameter("p2").Parameter("p3");
+
+            // Act
+            var actual = LegacyTemplateParser.ParseTemplate(template);
+
+            // Assert
+            Assert.Equal(expected, actual, LegacyRouteTemplateTestComparer.Instance);
+        }
+
+        [Fact]
+        public void Parse_MultipleOptionalParameters()
+        {
+            // Arrange
+            var template = "{p1?}/{p2?}/{p3?}";
+
+            var expected = new ExpectedTemplateBuilder().Parameter("p1?").Parameter("p2?").Parameter("p3?");
+
+            // Act
+            var actual = LegacyTemplateParser.ParseTemplate(template);
+
+            // Assert
+            Assert.Equal(expected, actual, LegacyRouteTemplateTestComparer.Instance);
+        }
+
+        [Fact]
+        public void Parse_SingleCatchAllParameter()
+        {
+            // Arrange
+            var expected = new ExpectedTemplateBuilder().Parameter("p");
+
+            // Act
+            var actual = LegacyTemplateParser.ParseTemplate("{*p}");
+
+            // Assert
+            Assert.Equal(expected, actual, LegacyRouteTemplateTestComparer.Instance);
+        }
+
+        [Fact]
+        public void Parse_MixedLiteralAndCatchAllParameter()
+        {
+            // Arrange
+            var expected = new ExpectedTemplateBuilder().Literal("awesome").Literal("wow").Parameter("p");
+
+            // Act
+            var actual = LegacyTemplateParser.ParseTemplate("awesome/wow/{*p}");
+
+            // Assert
+            Assert.Equal(expected, actual, LegacyRouteTemplateTestComparer.Instance);
+        }
+
+        [Fact]
+        public void Parse_MixedLiteralParameterAndCatchAllParameter()
+        {
+            // Arrange
+            var expected = new ExpectedTemplateBuilder().Literal("awesome").Parameter("p1").Parameter("p2");
+
+            // Act
+            var actual = LegacyTemplateParser.ParseTemplate("awesome/{p1}/{*p2}");
+
+            // Assert
+            Assert.Equal(expected, actual, LegacyRouteTemplateTestComparer.Instance);
+        }
+
+        [Fact]
+        public void InvalidTemplate_WithRepeatedParameter()
+        {
+            var ex = Assert.Throws<InvalidOperationException>(
+                () => LegacyTemplateParser.ParseTemplate("{p1}/literal/{p1}"));
+
+            var expectedMessage = "Invalid template '{p1}/literal/{p1}'. The parameter 'Microsoft.AspNetCore.Components.LegacyRouteMatching.LegacyTemplateSegment' appears multiple times.";
+
+            Assert.Equal(expectedMessage, ex.Message);
+        }
+
+        [Theory]
+        [InlineData("p}", "Invalid template 'p}'. Missing '{' in parameter segment 'p}'.")]
+        [InlineData("{p", "Invalid template '{p'. Missing '}' in parameter segment '{p'.")]
+        [InlineData("Literal/p}", "Invalid template 'Literal/p}'. Missing '{' in parameter segment 'p}'.")]
+        [InlineData("Literal/{p", "Invalid template 'Literal/{p'. Missing '}' in parameter segment '{p'.")]
+        [InlineData("p}/Literal", "Invalid template 'p}/Literal'. Missing '{' in parameter segment 'p}'.")]
+        [InlineData("{p/Literal", "Invalid template '{p/Literal'. Missing '}' in parameter segment '{p'.")]
+        [InlineData("Another/p}/Literal", "Invalid template 'Another/p}/Literal'. Missing '{' in parameter segment 'p}'.")]
+        [InlineData("Another/{p/Literal", "Invalid template 'Another/{p/Literal'. Missing '}' in parameter segment '{p'.")]
+
+        public void InvalidTemplate_WithMismatchedBraces(string template, string expectedMessage)
+        {
+            var ex = Assert.Throws<InvalidOperationException>(
+                () => LegacyTemplateParser.ParseTemplate(template));
+
+            Assert.Equal(expectedMessage, ex.Message);
+        }
+
+        [Theory]
+        // * is only allowed at beginning for catch-all parameters
+        [InlineData("{p*}", "Invalid template '{p*}'. The character '*' in parameter segment '{p*}' is not allowed.")]
+        [InlineData("{{}", "Invalid template '{{}'. The character '{' in parameter segment '{{}' is not allowed.")]
+        [InlineData("{}}", "Invalid template '{}}'. The character '}' in parameter segment '{}}' is not allowed.")]
+        [InlineData("{=}", "Invalid template '{=}'. The character '=' in parameter segment '{=}' is not allowed.")]
+        [InlineData("{.}", "Invalid template '{.}'. The character '.' in parameter segment '{.}' is not allowed.")]
+        public void ParseRouteParameter_ThrowsIf_ParameterContainsSpecialCharacters(string template, string expectedMessage)
+        {
+            // Act & Assert
+            var ex = Assert.Throws<InvalidOperationException>(() => LegacyTemplateParser.ParseTemplate(template));
+
+            Assert.Equal(expectedMessage, ex.Message);
+        }
+
+        [Fact]
+        public void InvalidTemplate_InvalidParameterNameWithEmptyNameThrows()
+        {
+            var ex = Assert.Throws<InvalidOperationException>(() => LegacyTemplateParser.ParseTemplate("{a}/{}/{z}"));
+
+            var expectedMessage = "Invalid template '{a}/{}/{z}'. Empty parameter name in segment '{}' is not allowed.";
+
+            Assert.Equal(expectedMessage, ex.Message);
+        }
+
+        [Fact]
+        public void InvalidTemplate_ConsecutiveSeparatorsSlashSlashThrows()
+        {
+            var ex = Assert.Throws<InvalidOperationException>(() => LegacyTemplateParser.ParseTemplate("{a}//{z}"));
+
+            var expectedMessage = "Invalid template '{a}//{z}'. Empty segments are not allowed.";
+
+            Assert.Equal(expectedMessage, ex.Message);
+        }
+
+        [Fact]
+        public void InvalidTemplate_LiteralAfterOptionalParam()
+        {
+            var ex = Assert.Throws<InvalidOperationException>(() => LegacyTemplateParser.ParseTemplate("/test/{a?}/test"));
+
+            var expectedMessage = "Invalid template 'test/{a?}/test'. Non-optional parameters or literal routes cannot appear after optional parameters.";
+
+            Assert.Equal(expectedMessage, ex.Message);
+        }
+
+        [Fact]
+        public void InvalidTemplate_NonOptionalParamAfterOptionalParam()
+        {
+            var ex = Assert.Throws<InvalidOperationException>(() => LegacyTemplateParser.ParseTemplate("/test/{a?}/{b}"));
+
+            var expectedMessage = "Invalid template 'test/{a?}/{b}'. Non-optional parameters or literal routes cannot appear after optional parameters.";
+
+            Assert.Equal(expectedMessage, ex.Message);
+        }
+
+        [Fact]
+        public void InvalidTemplate_CatchAllParamWithMultipleAsterisks()
+        {
+            var ex = Assert.Throws<InvalidOperationException>(() => LegacyTemplateParser.ParseTemplate("/test/{a}/{**b}"));
+
+            var expectedMessage = "Invalid template '/test/{a}/{**b}'. A catch-all parameter may only have one '*' at the beginning of the segment.";
+
+            Assert.Equal(expectedMessage, ex.Message);
+        }
+
+        [Fact]
+        public void InvalidTemplate_CatchAllParamNotLast()
+        {
+            var ex = Assert.Throws<InvalidOperationException>(() => LegacyTemplateParser.ParseTemplate("/test/{*a}/{b}"));
+
+            var expectedMessage = "Invalid template 'test/{*a}/{b}'. A catch-all parameter can only appear as the last segment of the route template.";
+
+            Assert.Equal(expectedMessage, ex.Message);
+        }
+
+        [Fact]
+        public void InvalidTemplate_BadOptionalCharacterPosition()
+        {
+            var ex = Assert.Throws<ArgumentException>(() => LegacyTemplateParser.ParseTemplate("/test/{a?bc}/{b}"));
+
+            var expectedMessage = "Malformed parameter 'a?bc' in route '/test/{a?bc}/{b}'. '?' character can only appear at the end of parameter name.";
+
+            Assert.Equal(expectedMessage, ex.Message);
+        }
+
+        private class ExpectedTemplateBuilder
+        {
+            public IList<LegacyTemplateSegment> Segments { get; set; } = new List<LegacyTemplateSegment>();
+
+            public ExpectedTemplateBuilder Literal(string value)
+            {
+                Segments.Add(new LegacyTemplateSegment("testtemplate", value, isParameter: false));
+                return this;
+            }
+
+            public ExpectedTemplateBuilder Parameter(string value)
+            {
+                Segments.Add(new LegacyTemplateSegment("testtemplate", value, isParameter: true));
+                return this;
+            }
+
+            public LegacyRouteTemplate Build() => new LegacyRouteTemplate(string.Join('/', Segments), Segments.ToArray());
+
+            public static implicit operator LegacyRouteTemplate(ExpectedTemplateBuilder builder) => builder.Build();
+        }
+
+        private class LegacyRouteTemplateTestComparer : IEqualityComparer<LegacyRouteTemplate>
+        {
+            public static LegacyRouteTemplateTestComparer Instance { get; } = new LegacyRouteTemplateTestComparer();
+
+            public bool Equals(LegacyRouteTemplate x, LegacyRouteTemplate y)
+            {
+                if (x.Segments.Length != y.Segments.Length)
+                {
+                    return false;
+                }
+
+                for (var i = 0; i < x.Segments.Length; i++)
+                {
+                    var xSegment = x.Segments[i];
+                    var ySegment = y.Segments[i];
+                    if (xSegment.IsParameter != ySegment.IsParameter)
+                    {
+                        return false;
+                    }
+                    if (xSegment.IsOptional != ySegment.IsOptional)
+                    {
+                        return false;
+                    }
+                    if (!string.Equals(xSegment.Value, ySegment.Value, StringComparison.OrdinalIgnoreCase))
+                    {
+                        return false;
+                    }
+                }
+
+                return true;
+            }
+
+            public int GetHashCode(LegacyRouteTemplate obj) => 0;
+        }
+    }
+}

+ 0 - 10
src/Components/Components/test/Routing/RouteConstraintTest.cs

@@ -32,15 +32,5 @@ namespace Microsoft.AspNetCore.Components.Routing
             // Assert
             Assert.Same(original, another);
         }
-
-        [Fact]
-        public void Parse_DoesNotThrowIfOptionalConstraint()
-        {
-            // Act
-            var exceptions = Record.Exception(() => RouteConstraint.Parse("ignore", "ignore", "int?"));
-
-            // Assert
-            Assert.Null(exceptions);
-        }
     }
 }

+ 349 - 42
src/Components/Components/test/Routing/RouteTableFactoryTests.cs

@@ -326,6 +326,253 @@ namespace Microsoft.AspNetCore.Components.Test.Routing
             }, context.Parameters);
         }
 
+        [Fact]
+        public void MoreSpecificRoutesPrecedeMoreGeneralRoutes()
+        {
+            // Arrange
+
+            // Routes are added in reverse precedence order
+            var builder = new TestRouteTableBuilder()
+                .AddRoute("/{*last}")
+                .AddRoute("/{*last:int}")
+                .AddRoute("/{last}")
+                .AddRoute("/{last:int}")
+                .AddRoute("/literal")
+                .AddRoute("/literal/{*last}")
+                .AddRoute("/literal/{*last:int}")
+                .AddRoute("/literal/{last}")
+                .AddRoute("/literal/{last:int}")
+                .AddRoute("/literal/literal");
+
+            var expectedOrder = new[]
+            {
+                "literal",
+                "literal/literal",
+                "literal/{last:int}",
+                "literal/{last}",
+                "literal/{*last:int}",
+                "literal/{*last}",
+                "{last:int}",
+                "{last}",
+                "{*last:int}",
+                "{*last}",
+            };
+
+            // Act
+            var table = builder.Build();
+
+            // Assert
+            var tableTemplates = table.Routes.Select(p => p.Template.TemplateText).ToArray();
+            Assert.Equal(expectedOrder, tableTemplates);
+        }
+
+        [Theory]
+        [InlineData("literal", null, "literal", "literal/{parameter?}", typeof(TestHandler1))]
+        [InlineData("literal/value", "value", "literal", "literal/{parameter?}", typeof(TestHandler2))]
+        [InlineData("literal", null, "literal/{parameter?}", "literal/{*parameter}", typeof(TestHandler1))]
+        [InlineData("literal/value", "value", "literal/{parameter?}", "literal/{*parameter}", typeof(TestHandler1))]
+        [InlineData("literal/value/other", "value/other", "literal /{parameter?}", "literal/{*parameter}", typeof(TestHandler2))]
+        public void CorrectlyMatchesVariableLengthSegments(string path, string expectedValue, string first, string second, Type handler)
+        {
+            // Arrange
+
+            // Routes are added in reverse precedence order
+            var table = new TestRouteTableBuilder()
+                .AddRoute(first, typeof(TestHandler1))
+                .AddRoute(second, typeof(TestHandler2))
+                .Build();
+
+            var context = new RouteContext(path);
+
+            // Act
+            table.Route(context);
+
+            // Assert
+            Assert.Equal(handler, context.Handler);
+            var value = expectedValue != null ? Assert.Single(context.Parameters, p => p.Key == "parameter").Value : null;
+            Assert.Equal(expectedValue, value?.ToString());
+        }
+
+        [Theory]
+        [InlineData("/values/{*values:int}", "values/1/2/3/4/5")]
+        [InlineData("/{*values:int}", "1/2/3/4/5")]
+        public void CanMatchCatchAllParametersWithConstraints(string template, string path)
+        {
+            // Arrange
+
+            // Routes are added in reverse precedence order
+            var table = new TestRouteTableBuilder()
+                .AddRoute(template)
+                .Build();
+
+            var context = new RouteContext(path);
+
+            // Act
+            table.Route(context);
+
+            // Assert
+            Assert.True(context.Parameters.TryGetValue("values", out var values));
+            Assert.Equal("1/2/3/4/5", values);
+        }
+
+
+        [Fact]
+        public void CatchAllEmpty()
+        {
+            // Arrange
+
+            // Routes are added in reverse precedence order
+            var table = new TestRouteTableBuilder()
+                .AddRoute("{*catchall}")
+                .Build();
+
+            var context = new RouteContext("/");
+
+            // Act
+            table.Route(context);
+
+            // Assert
+            Assert.True(context.Parameters.TryGetValue("catchall", out var values));
+            Assert.Null(values);
+        }
+
+        [Fact]
+        public void OptionalParameterEmpty()
+        {
+            // Arrange
+
+            // Routes are added in reverse precedence order
+            var table = new TestRouteTableBuilder()
+                .AddRoute("{parameter?}")
+                .Build();
+
+            var context = new RouteContext("/");
+
+            // Act
+            table.Route(context);
+
+            // Assert
+            Assert.True(context.Parameters.TryGetValue("parameter", out var values));
+            Assert.Null(values);
+        }
+
+        [Theory]
+        [InlineData("", 0)]
+        [InlineData("1", 1)]
+        [InlineData("1/2", 2)]
+        [InlineData("1/2/3", 3)]
+        public void MultipleOptionalParameters(string path, int segments)
+        {
+            // Arrange
+
+            // Routes are added in reverse precedence order
+            var table = new TestRouteTableBuilder()
+                .AddRoute("{param1?}/{param2?}/{param3?}")
+                .Build();
+
+            var context = new RouteContext(path);
+
+            // Act
+            table.Route(context);
+
+            // Assert
+            for (int i = 1; i <= segments; i++)
+            {
+                // Segments present in the path have the corresponding value.
+                Assert.True(context.Parameters.TryGetValue($"param{i}", out var value));
+                Assert.Equal(i.ToString(CultureInfo.InvariantCulture), value);
+            }
+            for (int i = segments + 1; i <= 3; i++)
+            {
+                // Segments omitted in the path have the default null value.
+                Assert.True(context.Parameters.TryGetValue($"param{i}", out var value));
+                Assert.Null(value);
+            }
+        }
+
+        [Theory]
+        [InlineData("prefix/", 0)]
+        [InlineData("prefix/1", 1)]
+        [InlineData("prefix/1/2", 2)]
+        [InlineData("prefix/1/2/3", 3)]
+        public void MultipleOptionalParametersWithPrefix(string path, int segments)
+        {
+            // Arrange
+
+            // Routes are added in reverse precedence order
+            var table = new TestRouteTableBuilder()
+                .AddRoute("prefix/{param1?}/{param2?}/{param3?}")
+                .Build();
+
+            var context = new RouteContext(path);
+
+            // Act
+            table.Route(context);
+
+            // Assert
+            for (int i = 1; i <= segments; i++)
+            {
+                // Segments present in the path have the corresponding value.
+                Assert.True(context.Parameters.TryGetValue($"param{i}", out var value));
+                Assert.Equal(i.ToString(CultureInfo.InvariantCulture), value);
+            }
+            for (int i = segments + 1; i <= 3; i++)
+            {
+                // Segments omitted in the path have the default null value.
+                Assert.True(context.Parameters.TryGetValue($"param{i}", out var value));
+                Assert.Null(value);
+            }
+        }
+
+        [Theory]
+        [InlineData("/{parameter?}/{*catchAll}", "/", null, null)]
+        [InlineData("/{parameter?}/{*catchAll}", "/parameter", "parameter", null)]
+        [InlineData("/{parameter?}/{*catchAll}", "/value/1", "value", "1")]
+        [InlineData("/{parameter?}/{*catchAll}", "/value/1/2/3/4/5", "value", "1/2/3/4/5")]
+        [InlineData("prefix/{parameter?}/{*catchAll}", "/prefix/", null, null)]
+        [InlineData("prefix/{parameter?}/{*catchAll}", "/prefix/parameter", "parameter", null)]
+        [InlineData("prefix/{parameter?}/{*catchAll}", "/prefix/value/1", "value", "1")]
+        [InlineData("prefix/{parameter?}/{*catchAll}", "/prefix/value/1/2/3/4/5", "value", "1/2/3/4/5")]
+        public void OptionalParameterPlusCatchAllRoute(string template, string path, string parameterValue, string catchAllValue)
+        {
+            // Arrange
+
+            // Routes are added in reverse precedence order
+            var table = new TestRouteTableBuilder()
+                .AddRoute(template)
+                .Build();
+
+            var context = new RouteContext(path);
+
+            // Act
+            table.Route(context);
+
+            // Assert
+            Assert.True(context.Parameters.TryGetValue("parameter", out var parameter));
+            Assert.True(context.Parameters.TryGetValue("catchAll", out var catchAll));
+            Assert.Equal(parameterValue, parameter);
+            Assert.Equal(catchAllValue, catchAll);
+        }
+
+        [Fact]
+        public void CanMatchCatchAllParametersWithConstraints_NotMatchingRoute()
+        {
+            // Arrange
+
+            // Routes are added in reverse precedence order
+            var table = new TestRouteTableBuilder()
+                .AddRoute("/values/{*values:int}")
+                .Build();
+
+            var context = new RouteContext("values/1/2/3/4/5/A");
+
+            // Act
+            table.Route(context);
+
+            // Assert
+            Assert.Null(context.Handler);
+        }
+
         [Fact]
         public void CanMatchOptionalParameterWithoutConstraints()
         {
@@ -412,7 +659,7 @@ namespace Microsoft.AspNetCore.Components.Test.Routing
         public static IEnumerable<object[]> CanMatchSegmentWithMultipleConstraintsCases() => new object[][]
 {
             new object[] { "/{value:double:int}/", "/15", 15 },
-            new object[] { "/{value:double?:int?}/", "/", null },
+            new object[] { "/{value:double:int?}/", "/", null },
 };
 
         [Theory]
@@ -470,51 +717,110 @@ namespace Microsoft.AspNetCore.Components.Test.Routing
         }
 
         [Fact]
-        public void PrefersOptionalParamsOverNonOptionalParams()
+        public void ThrowsForOptionalParametersAndNonOptionalParameters()
         {
-            // Arrange
-            var routeTable = new TestRouteTableBuilder()
+            // Arrange, act & assert
+            Assert.Throws<InvalidOperationException>(() => new TestRouteTableBuilder()
                 .AddRoute("/users/{id}", typeof(TestHandler1))
                 .AddRoute("/users/{id?}", typeof(TestHandler2))
-                .Build();
-            var contextWithParam = new RouteContext("/users/1");
-            var contextWithoutParam = new RouteContext("/users/");
+                .Build());
+        }
 
-            // Act
-            routeTable.Route(contextWithParam);
-            routeTable.Route(contextWithoutParam);
+        [Theory]
+        [InlineData("{*catchall}/literal")]
+        [InlineData("{*catchall}/{parameter}")]
+        [InlineData("{*catchall}/{parameter?}")]
+        [InlineData("{*catchall}/{*other}")]
+        [InlineData("prefix/{*catchall}/literal")]
+        [InlineData("prefix/{*catchall}/{parameter}")]
+        [InlineData("prefix/{*catchall}/{parameter?}")]
+        [InlineData("prefix/{*catchall}/{*other}")]
+        public void ThrowsWhenCatchAllIsNotTheLastSegment(string template)
+        {
+            // Arrange, act & assert
+            Assert.Throws<InvalidOperationException>(() => new TestRouteTableBuilder()
+                .AddRoute(template)
+                .Build());
+        }
 
-            // Assert
-            Assert.NotNull(contextWithParam.Handler);
-            Assert.Equal(typeof(TestHandler1), contextWithParam.Handler);
+        [Theory]
+        [InlineData("{optional?}/literal")]
+        [InlineData("{optional?}/{parameter}")]
+        [InlineData("{optional?}/{parameter:int}")]
+        [InlineData("prefix/{optional?}/literal")]
+        [InlineData("prefix/{optional?}/{parameter}")]
+        [InlineData("prefix/{optional?}/{parameter:int}")]
+        public void ThrowsForOptionalParametersFollowedByNonOptionalParameters(string template)
+        {
+            // Arrange, act & assert
+            Assert.Throws<InvalidOperationException>(() => new TestRouteTableBuilder()
+                .AddRoute(template)
+                .Build());
+        }
 
-            Assert.NotNull(contextWithoutParam.Handler);
-            Assert.Equal(typeof(TestHandler2), contextWithoutParam.Handler);
+        [Theory]
+        [InlineData("{parameter}", "{parameter?}")]
+        [InlineData("{parameter:int}", "{parameter:bool?}")]
+        public void ThrowsForAmbiguousRoutes(string first, string second)
+        {
+            // Arrange, act & assert
+            var exception = Assert.Throws<InvalidOperationException>(() => new TestRouteTableBuilder()
+                .AddRoute(first, typeof(TestHandler1))
+                .AddRoute(second, typeof(TestHandler2))
+                .Build());
+
+            exception.Message.Contains("The following routes are ambiguous");
         }
 
-        [Fact]
-        public void PrefersOptionalParamsOverNonOptionalParamsReverseOrder()
+        // It's important the precedence is inverted here to also validate that
+        // the precedence is correct in these cases
+        [Theory]
+        [InlineData("{optional?}", "/")]
+        [InlineData("{optional?}", "literal")]
+        [InlineData("{optional?}", "{optional:int?}")]
+        [InlineData("{*catchAll:int}", "{optional?}")]
+        [InlineData("{*catchAll}", "{optional?}")]
+        [InlineData("literal/{optional?}", "/")]
+        [InlineData("literal/{optional?}", "literal")]
+        [InlineData("literal/{optional?}", "literal/{optional:int?}")]
+        [InlineData("literal/{*catchAll:int}", "literal/{optional?}")]
+        [InlineData("literal/{*catchAll}", "literal/{optional?}")]
+        [InlineData("{param}/{optional?}", "/")]
+        [InlineData("{param}/{optional?}", "{param}")]
+        [InlineData("{param}/{optional?}", "{param}/{optional:int?}")]
+        [InlineData("{param}/{*catchAll:int}", "{param}/{optional?}")]
+        [InlineData("{param}/{*catchAll}", "{param}/{optional?}")]
+        [InlineData("{param1?}/{param2?}/{param3?}/{optional?}", "/")]
+        [InlineData("{param1?}/{param2?}/{param3?}/{optional?}", "{param1?}/{param2?}/{param3?}/{optional:int?}")]
+        [InlineData("{param1?}/{param2?}/{param3?}/{optional?}", "{param1?}/{param2?}/{param3:int?}/{optional?}")]
+        [InlineData("{param1?}/{param2?}/{param3:int?}/{optional?}", "{param1?}/{param2?}")]
+        [InlineData("{param1?}/{param2?}/{param3?}/{*catchAll:int}", "{param1?}/{param2?}/{param3?}/{optional?}")]
+        [InlineData("{param1?}/{param2?}/{param3?}/{*catchAll}", "{param1?}/{param2?}/{param3?}/{optional?}")]
+        public void DoesNotThrowForNonAmbiguousRoutes(string first, string second)
         {
             // Arrange
-            var routeTable = new TestRouteTableBuilder()
-                .AddRoute("/users/{id}", typeof(TestHandler1))
-                .AddRoute("/users/{id?}", typeof(TestHandler2))
-                .Build();
-            var contextWithParam = new RouteContext("/users/1");
-            var contextWithoutParam = new RouteContext("/users/");
+            var builder = new TestRouteTableBuilder()
+                .AddRoute(first, typeof(TestHandler1))
+                .AddRoute(second, typeof(TestHandler2));
+
+            var expectedOrder = new[] { second, first };
 
             // Act
-            routeTable.Route(contextWithParam);
-            routeTable.Route(contextWithoutParam);
+            var table = builder.Build();
 
             // Assert
-            Assert.NotNull(contextWithParam.Handler);
-            Assert.Equal(typeof(TestHandler1), contextWithParam.Handler);
-
-            Assert.NotNull(contextWithoutParam.Handler);
-            Assert.Equal(typeof(TestHandler2), contextWithoutParam.Handler);
+            var tableTemplates = table.Routes.Select(p => p.Template.TemplateText).ToArray();
+            Assert.Equal(expectedOrder, tableTemplates);
         }
 
+        [Fact]
+        public void ThrowsForLiteralWithQuestionMark()
+        {
+            // Arrange, act & assert
+            Assert.Throws<InvalidOperationException>(() => new TestRouteTableBuilder()
+                .AddRoute("literal?")
+                .Build());
+        }
 
         [Fact]
         public void PrefersLiteralTemplateOverParameterizedTemplates()
@@ -661,10 +967,10 @@ namespace Microsoft.AspNetCore.Components.Test.Routing
         {
             // Arrange
             var routeTable = new TestRouteTableBuilder()
-                .AddRoute("/", typeof(TestHandler1))
-                .AddRoute("/products/{param1:int}", typeof(TestHandler1))
-                .AddRoute("/products/{param2}/{PaRam1}", typeof(TestHandler1))
                 .AddRoute("/{unrelated}", typeof(TestHandler2))
+                .AddRoute("/products/{param2}/{PaRam1}", typeof(TestHandler1))
+                .AddRoute("/products/{param1:int}", typeof(TestHandler1))
+                .AddRoute("/", typeof(TestHandler1))
                 .Build();
             var context = new RouteContext("/products/456");
 
@@ -677,26 +983,27 @@ namespace Microsoft.AspNetCore.Components.Test.Routing
                 {
                     Assert.Same(typeof(TestHandler1), route.Handler);
                     Assert.Equal("/", route.Template.TemplateText);
-                    Assert.Equal(new[] { "param1", "param2" }, route.UnusedRouteParameterNames);
-                },
-                route =>
-                {
-                    Assert.Same(typeof(TestHandler2), route.Handler);
-                    Assert.Equal("{unrelated}", route.Template.TemplateText);
-                    Assert.Equal(Array.Empty<string>(), route.UnusedRouteParameterNames);
+                    Assert.Equal(new[] { "PaRam1", "param2" }, route.UnusedRouteParameterNames.OrderBy(id => id).ToArray());
                 },
                 route =>
                 {
                     Assert.Same(typeof(TestHandler1), route.Handler);
                     Assert.Equal("products/{param1:int}", route.Template.TemplateText);
-                    Assert.Equal(new[] { "param2" }, route.UnusedRouteParameterNames);
+                    Assert.Equal(new[] { "param2" }, route.UnusedRouteParameterNames.OrderBy(id => id).ToArray());
                 },
                 route =>
                 {
                     Assert.Same(typeof(TestHandler1), route.Handler);
                     Assert.Equal("products/{param2}/{PaRam1}", route.Template.TemplateText);
-                    Assert.Equal(Array.Empty<string>(), route.UnusedRouteParameterNames);
+                    Assert.Equal(Array.Empty<string>(), route.UnusedRouteParameterNames.OrderBy(id => id).ToArray());
+                },
+                route =>
+                {
+                    Assert.Same(typeof(TestHandler2), route.Handler);
+                    Assert.Equal("{unrelated}", route.Template.TemplateText);
+                    Assert.Equal(Array.Empty<string>(), route.UnusedRouteParameterNames.OrderBy(id => id).ToArray());
                 });
+
             Assert.Same(typeof(TestHandler1), context.Handler);
             Assert.Equal(new Dictionary<string, object>
             {

+ 66 - 2
src/Components/Components/test/Routing/RouterTest.cs

@@ -2,9 +2,12 @@
 // Licensed under the Apache License, Version 2.0. See License.txt in the project root for license information.
 
 using System;
+using System.Collections.Generic;
+using System.Linq;
 using System.Reflection;
 using System.Threading;
 using System.Threading.Tasks;
+using Microsoft.AspNetCore.Components.RenderTree;
 using Microsoft.AspNetCore.Components.Routing;
 using Microsoft.AspNetCore.Components.Test.Helpers;
 using Microsoft.Extensions.DependencyInjection;
@@ -17,13 +20,15 @@ namespace Microsoft.AspNetCore.Components.Test.Routing
     public class RouterTest
     {
         private readonly Router _router;
+        private readonly TestNavigationManager _navigationManager;
         private readonly TestRenderer _renderer;
 
         public RouterTest()
         {
             var services = new ServiceCollection();
+            _navigationManager = new TestNavigationManager();
             services.AddSingleton<ILoggerFactory>(NullLoggerFactory.Instance);
-            services.AddSingleton<NavigationManager, TestNavigationManager>();
+            services.AddSingleton<NavigationManager>(_navigationManager);
             services.AddSingleton<INavigationInterception, TestNavigationInterception>();
             var serviceProvider = services.BuildServiceProvider();
 
@@ -31,7 +36,7 @@ namespace Microsoft.AspNetCore.Components.Test.Routing
             _renderer.ShouldHandleExceptions = true;
             _router = (Router)_renderer.InstantiateComponent<Router>();
             _router.AppAssembly = Assembly.GetExecutingAssembly();
-            _router.Found = routeData => (builder) => builder.AddContent(0, "Rendering route...");
+            _router.Found = routeData => (builder) => builder.AddContent(0, $"Rendering route matching {routeData.PageType}");
             _renderer.AssignRootComponentId(_router);
         }
 
@@ -177,12 +182,65 @@ namespace Microsoft.AspNetCore.Components.Test.Routing
             await feb;
         }
 
+        [Fact]
+        public async Task UsesLegacyRouteMatchingByDefault()
+        {
+            // Arrange
+            // Legacy routing prefers {*someWildcard} over any other pattern than has more segments,
+            // even if the other pattern is an exact match
+            _navigationManager.NotifyLocationChanged("https://www.example.com/subdir/a/b", false);
+            var parameters = new Dictionary<string, object>
+            {
+                { nameof(Router.AppAssembly), typeof(RouterTest).Assembly },
+                { nameof(Router.NotFound), (RenderFragment)(builder => { }) },
+            };
+
+            // Act
+            await _renderer.Dispatcher.InvokeAsync(() =>
+                _router.SetParametersAsync(ParameterView.FromDictionary(parameters)));
+
+            // Assert
+            var renderedFrame = _renderer.Batches.First().ReferenceFrames.First();
+            Assert.Equal(RenderTreeFrameType.Text, renderedFrame.FrameType);
+            Assert.Equal($"Rendering route matching {typeof(MatchAnythingComponent)}", renderedFrame.TextContent);
+        }
+
+        [Fact]
+        public async Task UsesCurrentRouteMatchingIfSpecified()
+        {
+            // Arrange
+            // Current routing prefers exactly-matched patterns over {*someWildcard}, no matter
+            // how many segments are in the exact match
+            _navigationManager.NotifyLocationChanged("https://www.example.com/subdir/a/b", false);
+            var parameters = new Dictionary<string, object>
+            {
+                { nameof(Router.AppAssembly), typeof(RouterTest).Assembly },
+                { nameof(Router.NotFound), (RenderFragment)(builder => { }) },
+                { nameof(Router.PreferExactMatches), true },
+            };
+
+            // Act
+            await _renderer.Dispatcher.InvokeAsync(() =>
+                _router.SetParametersAsync(ParameterView.FromDictionary(parameters)));
+
+            // Assert
+            var renderedFrame = _renderer.Batches.First().ReferenceFrames.First();
+            Assert.Equal(RenderTreeFrameType.Text, renderedFrame.FrameType);
+            Assert.Equal($"Rendering route matching {typeof(MultiSegmentRouteComponent)}", renderedFrame.TextContent);
+        }
+
         internal class TestNavigationManager : NavigationManager
         {
             public TestNavigationManager() =>
                 Initialize("https://www.example.com/subdir/", "https://www.example.com/subdir/jan");
 
             protected override void NavigateToCore(string uri, bool forceLoad) => throw new NotImplementedException();
+
+            public void NotifyLocationChanged(string uri, bool intercepted)
+            {
+                Uri = uri;
+                NotifyLocationChanged(intercepted);
+            }
         }
 
         internal sealed class TestNavigationInterception : INavigationInterception
@@ -200,5 +258,11 @@ namespace Microsoft.AspNetCore.Components.Test.Routing
 
         [Route("jan")]
         public class JanComponent : ComponentBase { }
+
+        [Route("{*matchAnything}")]
+        public class MatchAnythingComponent : ComponentBase { }
+
+        [Route("a/b")]
+        public class MultiSegmentRouteComponent : ComponentBase { }
     }
 }

+ 1 - 1
src/Components/Components/test/Routing/TemplateParserTests.cs

@@ -128,7 +128,7 @@ namespace Microsoft.AspNetCore.Components.Routing
             var ex = Assert.Throws<InvalidOperationException>(
                 () => TemplateParser.ParseTemplate("{p1}/literal/{p1}"));
 
-            var expectedMessage = "Invalid template '{p1}/literal/{p1}'. The parameter 'Microsoft.AspNetCore.Components.Routing.TemplateSegment' appears multiple times.";
+            var expectedMessage = "Invalid template '{p1}/literal/{p1}'. The parameter '{p1}' appears multiple times.";
 
             Assert.Equal(expectedMessage, ex.Message);
         }

+ 1 - 1
src/Components/Samples/BlazorServerApp/App.razor

@@ -1,4 +1,4 @@
-<Router AppAssembly="@typeof(Program).Assembly">
+<Router AppAssembly="@typeof(Program).Assembly" PreferExactMatches="true">
     <Found Context="routeData">
         <RouteView RouteData="@routeData" DefaultLayout="@typeof(MainLayout)" />
     </Found>

+ 1 - 1
src/Components/Web.JS/src/Platform/Mono/MonoPlatform.ts

@@ -425,7 +425,7 @@ function createEmscriptenModuleInstance(resourceLoader: WebAssemblyResourceLoade
     try {
       timeZone = Intl.DateTimeFormat().resolvedOptions().timeZone;
     } catch { }
-    MONO.mono_wasm_setenv("TZ", timeZone || "UTC");
+    MONO.mono_wasm_setenv("TZ", timeZone || 'UTC');
     // Turn off full-gc to prevent browser freezing.
     const mono_wasm_enable_on_demand_gc = cwrap('mono_wasm_enable_on_demand_gc', null, ['number']);
     mono_wasm_enable_on_demand_gc(0);

+ 10 - 5
src/Components/WebAssembly/Sdk/integrationtests/ServiceWorkerAssert.cs

@@ -12,11 +12,16 @@ namespace Microsoft.AspNetCore.Razor.Design.IntegrationTests
 {
     internal static class ServiceWorkerAssert
     {
-        internal static void VerifyServiceWorkerFiles(MSBuildResult result, string outputDirectory, string serviceWorkerPath, string serviceWorkerContent, string assetsManifestPath)
+        internal static void VerifyServiceWorkerFiles(MSBuildResult result,
+            string outputDirectory,
+            string serviceWorkerPath,
+            string serviceWorkerContent,
+            string assetsManifestPath,
+            string staticWebAssetsBasePath = "")
         {
             // Check the expected files are there
-            var serviceWorkerResolvedPath = Assert.FileExists(result, outputDirectory, serviceWorkerPath);
-            var assetsManifestResolvedPath = Assert.FileExists(result, outputDirectory, assetsManifestPath);
+            var serviceWorkerResolvedPath = Assert.FileExists(result, outputDirectory, staticWebAssetsBasePath, serviceWorkerPath);
+            var assetsManifestResolvedPath = Assert.FileExists(result, outputDirectory, staticWebAssetsBasePath, assetsManifestPath);
 
             // Check the service worker contains the expected content (which comes from the PublishedContent file)
             Assert.FileContains(result, serviceWorkerResolvedPath, serviceWorkerContent);
@@ -36,8 +41,8 @@ namespace Microsoft.AspNetCore.Razor.Design.IntegrationTests
                 // We don't list compressed files in the SWAM, as these are transparent to the client,
                 // nor do we list the service worker itself or its assets manifest, as these don't need to be fetched in the same way
                 if (IsCompressedFile(relativePath)
-                    || string.Equals(relativePath, serviceWorkerPath, StringComparison.Ordinal)
-                    || string.Equals(relativePath, assetsManifestPath, StringComparison.Ordinal))
+                    || string.Equals(relativePath, Path.Combine(staticWebAssetsBasePath, serviceWorkerPath), StringComparison.Ordinal)
+                    || string.Equals(relativePath, Path.Combine(staticWebAssetsBasePath, assetsManifestPath), StringComparison.Ordinal))
                 {
                     continue;
                 }

+ 105 - 0
src/Components/WebAssembly/Sdk/integrationtests/WasmPublishIntegrationTest.cs

@@ -251,6 +251,111 @@ namespace Microsoft.AspNetCore.Razor.Design.IntegrationTests
             VerifyCompression(result, blazorPublishDirectory);
         }
 
+        [Fact]
+        public async Task Publish_WithStaticWebBasePathWorks()
+        {
+            // Arrange
+            using var project = ProjectDirectory.Create("blazorwasm", "razorclasslibrary");
+            project.AddProjectFileContent(
+@"<PropertyGroup>
+    <StaticWebAssetBasePath>different-path/</StaticWebAssetBasePath>
+</PropertyGroup>");
+            var result = await MSBuildProcessManager.DotnetMSBuild(project, "Publish");
+
+            Assert.BuildPassed(result);
+
+            var publishDirectory = project.PublishOutputDirectory;
+
+            // Verify nothing is published directly to the wwwroot directory
+            Assert.FileCountEquals(result, 0, Path.Combine(publishDirectory, "wwwroot"), "*", SearchOption.TopDirectoryOnly);
+
+            var blazorPublishDirectory = Path.Combine(publishDirectory, "wwwroot", "different-path");
+
+            Assert.FileExists(result, blazorPublishDirectory, "_framework", "blazor.boot.json");
+            Assert.FileExists(result, blazorPublishDirectory, "_framework", "blazor.webassembly.js");
+            Assert.FileExists(result, blazorPublishDirectory, "_framework", "dotnet.wasm");
+            Assert.FileExists(result, blazorPublishDirectory, "_framework", DotNetJsFileName);
+            Assert.FileExists(result, blazorPublishDirectory, "_framework", "blazorwasm.dll");
+
+            // Verify static assets are in the publish directory
+            Assert.FileExists(result, blazorPublishDirectory, "index.html");
+
+            // Verify web.config
+            Assert.FileExists(result, publishDirectory, "web.config");
+            var webConfigContent = new StreamReader(GetType().Assembly.GetManifestResourceStream("Microsoft.NET.Sdk.BlazorWebAssembly.IntegrationTests.BlazorWasm.web.config")).ReadToEnd();
+            Assert.FileContentEquals(result, Path.Combine(publishDirectory, "web.config"), webConfigContent);
+            Assert.FileCountEquals(result, 1, publishDirectory, "*", SearchOption.TopDirectoryOnly);
+
+            // Verify static web assets from referenced projects are copied.
+            Assert.FileExists(result, blazorPublishDirectory, "_content", "RazorClassLibrary", "wwwroot", "exampleJsInterop.js");
+            Assert.FileExists(result, blazorPublishDirectory, "_content", "RazorClassLibrary", "styles.css");
+
+            VerifyBootManifestHashes(result, blazorPublishDirectory);
+            VerifyServiceWorkerFiles(result,
+                Path.Combine(publishDirectory, "wwwroot"),
+                serviceWorkerPath: Path.Combine("serviceworkers", "my-service-worker.js"),
+                serviceWorkerContent: "// This is the production service worker",
+                assetsManifestPath: "custom-service-worker-assets.js",
+                staticWebAssetsBasePath: "different-path");
+        }
+
+        [Fact]
+        public async Task Publish_Hosted_WithStaticWebBasePathWorks()
+        {
+            using var project = ProjectDirectory.Create("blazorhosted", additionalProjects: new[] { "blazorwasm", "razorclasslibrary", });
+            var wasmProject = project.GetSibling("blazorwasm");
+            wasmProject.AddProjectFileContent(
+@"<PropertyGroup>
+    <StaticWebAssetBasePath>different-path/</StaticWebAssetBasePath>
+</PropertyGroup>");
+            var result = await MSBuildProcessManager.DotnetMSBuild(project, "Publish");
+
+            Assert.BuildPassed(result);
+
+            var publishDirectory = project.PublishOutputDirectory;
+            // Make sure the main project exists
+            Assert.FileExists(result, publishDirectory, "blazorhosted.dll");
+
+            Assert.FileExists(result, publishDirectory, "RazorClassLibrary.dll");
+            Assert.FileExists(result, publishDirectory, "blazorwasm.dll");
+
+            var blazorPublishDirectory = Path.Combine(publishDirectory, "wwwroot", "different-path");
+            Assert.FileExists(result, blazorPublishDirectory, "_framework", "blazor.boot.json");
+            Assert.FileExists(result, blazorPublishDirectory, "_framework", "blazor.webassembly.js");
+            Assert.FileExists(result, blazorPublishDirectory, "_framework", "dotnet.wasm");
+            Assert.FileExists(result, blazorPublishDirectory, "_framework", DotNetJsFileName);
+            Assert.FileExists(result, blazorPublishDirectory, "_framework", "blazorwasm.dll");
+            Assert.FileExists(result, blazorPublishDirectory, "_framework", "System.Text.Json.dll"); // Verify dependencies are part of the output.
+
+            // Verify project references appear as static web assets
+            Assert.FileExists(result, blazorPublishDirectory, "_framework", "RazorClassLibrary.dll");
+            // Also verify project references to the server project appear in the publish output
+            Assert.FileExists(result, publishDirectory, "RazorClassLibrary.dll");
+
+            // Verify static assets are in the publish directory
+            Assert.FileExists(result, blazorPublishDirectory, "index.html");
+
+            // Verify static web assets from referenced projects are copied.
+            Assert.FileExists(result, publishDirectory, "wwwroot", "_content", "RazorClassLibrary", "wwwroot", "exampleJsInterop.js");
+            Assert.FileExists(result, publishDirectory, "wwwroot", "_content", "RazorClassLibrary", "styles.css");
+
+            // Verify web.config
+            Assert.FileExists(result, publishDirectory, "web.config");
+
+            VerifyBootManifestHashes(result, blazorPublishDirectory);
+
+            // Verify compression works
+            Assert.FileExists(result, blazorPublishDirectory, "_framework", "dotnet.wasm.br");
+            Assert.FileExists(result, blazorPublishDirectory, "_framework", "blazorwasm.dll.br");
+            Assert.FileExists(result, blazorPublishDirectory, "_framework", "RazorClassLibrary.dll.br");
+            Assert.FileExists(result, blazorPublishDirectory, "_framework", "System.Text.Json.dll.br");
+
+            Assert.FileExists(result, blazorPublishDirectory, "_framework", "dotnet.wasm.gz");
+            Assert.FileExists(result, blazorPublishDirectory, "_framework", "blazorwasm.dll.gz");
+            Assert.FileExists(result, blazorPublishDirectory, "_framework", "RazorClassLibrary.dll.gz");
+            Assert.FileExists(result, blazorPublishDirectory, "_framework", "System.Text.Json.dll.gz");
+        }
+
         private static void VerifyCompression(MSBuildResult result, string blazorPublishDirectory)
         {
             var original = Assert.FileExists(result, blazorPublishDirectory, "_framework", "blazor.boot.json");

+ 23 - 7
src/Components/WebAssembly/Sdk/src/targets/Microsoft.NET.Sdk.BlazorWebAssembly.Current.targets

@@ -74,7 +74,6 @@ Copyright (c) .NET Foundation. All rights reserved.
 
     <!-- Internal properties -->
     <_BlazorOutputPath>wwwroot\_framework\</_BlazorOutputPath>
-
   </PropertyGroup>
 
   <ItemGroup>
@@ -463,6 +462,12 @@ Copyright (c) .NET Foundation. All rights reserved.
       <Output TaskParameter="JoinResult" ItemName="_ResolvedSatelliteToPublish" />
     </JoinItems>
 
+    <PropertyGroup>
+      <_BlazorPublishOutputPath Condition="'$(StaticWebAssetBasePath)' != '/'">wwwroot\$(StaticWebAssetBasePath.Replace('/', '\'))</_BlazorPublishOutputPath>
+      <_BlazorPublishOutputPath Condition="'$(StaticWebAssetBasePath)' == '/'">wwwroot\</_BlazorPublishOutputPath>
+      <_BlazorFrameworkPublishPath>$(_BlazorPublishOutputPath)_framework\</_BlazorFrameworkPublishPath>
+    </PropertyGroup>
+
     <ItemGroup>
       <ResolvedFileToPublish Remove="@(_ResolvedSatelliteToPublish)" />
       <ResolvedFileToPublish Include="@(_ResolvedSatelliteToPublish)" />
@@ -478,13 +483,24 @@ Copyright (c) .NET Foundation. All rights reserved.
       <!-- Remove dotnet.js from publish output -->
       <ResolvedFileToPublish Remove="@(ResolvedFileToPublish)" Condition="'%(ResolvedFileToPublish.RelativePath)' == 'dotnet.js'" />
 
+      <!-- Remove pdbs from the publish output -->
+      <ResolvedFileToPublish Remove="@(ResolvedFileToPublish)" Condition="'$(CopyOutputSymbolsToPublishDirectory)' != 'true' AND '%(Extension)' == '.pdb'" />
+
       <!-- Retarget so that items are published to the wwwroot directory -->
+      <!--
+        This changes files (such as wwwroot/index.html) that are published to $(PublishDir)wwwroot\ -> $(PublishDir)wwwroot\$(StaticWebAssetBasePath)\.
+        Ignore any user specified web.config in the process.
+      -->
       <ResolvedFileToPublish
-        RelativePath="$(_BlazorOutputPath)%(ResolvedFileToPublish.RelativePath)"
-        Condition="'%(ResolvedFileToPublish.RelativePath)' != 'web.config' AND !$([System.String]::Copy('%(ResolvedFileToPublish.RelativePath)').Replace('\','/').StartsWith('wwwroot/'))" />
+        RelativePath="$(_BlazorPublishOutputPath)$([System.String]::Copy('%(ResolvedFileToPublish.RelativePath)').Replace('/','\').Substring(8))"
+        Condition="'$(StaticWebAssetBasePath)' != '/' AND $([System.String]::Copy('%(ResolvedFileToPublish.RelativePath)').Replace('/','\').StartsWith('wwwroot\'))" />
 
-      <!-- Remove pdbs from the publish output -->
-      <ResolvedFileToPublish Remove="@(ResolvedFileToPublish)" Condition="'$(CopyOutputSymbolsToPublishDirectory)' != 'true' AND '%(Extension)' == '.pdb'" />
+      <!--
+        Change all remaining publish output to publish to appear under the $(PublishDir)wwwroot\$(StaticWebAssetBasePath) path.
+      -->
+      <ResolvedFileToPublish
+        RelativePath="$(_BlazorFrameworkPublishPath)%(ResolvedFileToPublish.RelativePath)"
+        Condition="'%(ResolvedFileToPublish.RelativePath)' != 'web.config' AND !$([System.String]::Copy('%(ResolvedFileToPublish.RelativePath)').Replace('/','\').StartsWith('wwwroot\'))" />
     </ItemGroup>
 
     <ItemGroup Condition="'@(ResolvedFileToPublish->AnyHaveMetadataValue('RelativePath', 'web.config'))' != 'true'">
@@ -530,11 +546,11 @@ Copyright (c) .NET Foundation. All rights reserved.
     <ItemGroup>
       <ResolvedFileToPublish
         Include="$(IntermediateOutputPath)blazor.publish.boot.json"
-        RelativePath="$(_BlazorOutputPath)blazor.boot.json" />
+        RelativePath="$(_BlazorFrameworkPublishPath)blazor.boot.json" />
 
       <ResolvedFileToPublish
         Include="@(_BlazorJSFile)"
-        RelativePath="$(_BlazorOutputPath)%(FileName)%(Extension)" />
+        RelativePath="$(_BlazorFrameworkPublishPath)%(FileName)%(Extension)" />
     </ItemGroup>
   </Target>
 

+ 2 - 1
src/Components/WebAssembly/Sdk/src/targets/Microsoft.NET.Sdk.BlazorWebAssembly.ServiceWorkerAssetsManifest.targets

@@ -128,6 +128,7 @@ Copyright (c) .NET Foundation. All rights reserved.
         <ContentSourcePath Condition="'%(ServiceWorker.PublishedContent)' != ''">%(ServiceWorker.PublishedContent)</ContentSourcePath>
         <ContentSourcePath Condition="'%(ServiceWorker.PublishedContent)' == ''">%(ServiceWorker.Identity)</ContentSourcePath>
         <RelativePath>%(ServiceWorker.Identity)</RelativePath>
+        <RelativePath Condition="$([System.String]::Copy('%(ServiceWorker.Identity)').Replace('/','\').StartsWith('wwwroot\'))">$(_BlazorPublishOutputPath)$([System.String]::Copy('%(ServiceWorker.Identity)').Substring(8))</RelativePath>
       </_ServiceWorkerIntermediatePublishFile>
 
       <_ServiceWorkerPublishFile Include="@(ResolvedFileToPublish)" Condition="$([System.String]::Copy('%(ResolvedFileToPublish.RelativePath)').Replace('\','/').StartsWith('wwwroot/'))">
@@ -161,7 +162,7 @@ Copyright (c) .NET Foundation. All rights reserved.
       <ResolvedFileToPublish
         Include="$(_ServiceWorkerAssetsManifestPublishIntermediateOutputPath)"
         CopyToPublishDirectory="PreserveNewest"
-        RelativePath="wwwroot\$(ServiceWorkerAssetsManifest)"
+        RelativePath="$(_BlazorPublishOutputPath)$(ServiceWorkerAssetsManifest)"
         ExcludeFromSingleFile="true" />
     </ItemGroup>
   </Target>

+ 1 - 1
src/Components/WebAssembly/Sdk/testassets/blazorwasm-minimal/App.razor

@@ -1,4 +1,4 @@
-<Router AppAssembly="@typeof(Program).Assembly">
+<Router AppAssembly="@typeof(Program).Assembly" PreferExactMatches="true">
     <Found Context="routeData">
         <RouteView RouteData="@routeData"/>
     </Found>

+ 1 - 1
src/Components/WebAssembly/Sdk/testassets/blazorwasm/App.razor

@@ -1,4 +1,4 @@
-<Router AppAssembly="@typeof(Program).Assembly">
+<Router AppAssembly="@typeof(Program).Assembly" PreferExactMatches="true">
     <Found Context="routeData">
         <RouteView RouteData="@routeData"/>
     </Found>

+ 1 - 1
src/Components/WebAssembly/testassets/StandaloneApp/App.razor

@@ -1,4 +1,4 @@
-<Router AppAssembly=typeof(StandaloneApp.Program).Assembly>
+<Router AppAssembly="@typeof(StandaloneApp.Program).Assembly" PreferExactMatches="true">
     <Found Context="routeData">
         <RouteView RouteData="@routeData" DefaultLayout="@typeof(MainLayout)" />
     </Found>

+ 1 - 1
src/Components/WebAssembly/testassets/Wasm.Authentication.Client/App.razor

@@ -1,5 +1,5 @@
 <CascadingAuthenticationState>
-    <Router AppAssembly="@typeof(Program).Assembly">
+    <Router AppAssembly="@typeof(Program).Assembly" PreferExactMatches="true">
         <Found Context="routeData">
             <AuthorizeRouteView RouteData="@routeData" DefaultLayout="@typeof(MainLayout)">
                 <NotAuthorized>

+ 1 - 1
src/Components/benchmarkapps/BlazingPizza.Server/App.razor

@@ -1,4 +1,4 @@
-<Router AppAssembly="typeof(Program).Assembly">
+<Router AppAssembly="typeof(Program).Assembly" PreferExactMatches="true">
     <NotFound>Page not found</NotFound>
     <Found Context="routeData">
         <RouteView RouteData="routeData" DefaultLayout="typeof(MainLayout)"></RouteView>

+ 1 - 1
src/Components/benchmarkapps/Wasm.Performance/TestApp/App.razor

@@ -1,4 +1,4 @@
-<Router AppAssembly=typeof(Program).Assembly>
+<Router AppAssembly="@typeof(Program).Assembly" PreferExactMatches="true">
     <Found Context="routeData">
         <RouteView RouteData="@routeData" />
     </Found>

+ 1 - 1
src/Components/test/testassets/BasicTestApp/AuthTest/AuthRouter.razor

@@ -9,7 +9,7 @@
     and @page authorization rules.
 *@
 
-<Router AppAssembly="@typeof(BasicTestApp.Program).Assembly">
+<Router AppAssembly="@typeof(BasicTestApp.Program).Assembly" PreferExactMatches="true">
     <Found Context="routeData">
         <AuthorizeRouteView RouteData="@routeData" DefaultLayout="@typeof(AuthRouterLayout)">
             <Authorizing>Authorizing...</Authorizing>

+ 1 - 1
src/Components/test/testassets/BasicTestApp/RouterTest/TestRouter.razor

@@ -1,5 +1,5 @@
 @using Microsoft.AspNetCore.Components.Routing
-<Router AppAssembly="@typeof(BasicTestApp.Program).Assembly">
+<Router AppAssembly="@typeof(BasicTestApp.Program).Assembly" PreferExactMatches="true">
     <Found Context="routeData">
         <RouteView RouteData="@routeData" />
     </Found>

+ 1 - 1
src/Components/test/testassets/BasicTestApp/RouterTest/TestRouterWithAdditionalAssembly.razor

@@ -1,5 +1,5 @@
 @using Microsoft.AspNetCore.Components.Routing
-<Router AppAssembly="@typeof(BasicTestApp.Program).Assembly" AdditionalAssemblies="@(new[] { typeof(TestContentPackage.RouteableComponentFromPackage).Assembly, })">
+<Router AppAssembly="@typeof(BasicTestApp.Program).Assembly" AdditionalAssemblies="@(new[] { typeof(TestContentPackage.RouteableComponentFromPackage).Assembly, })" PreferExactMatches="true">
     <Found Context="routeData">
         <RouteView RouteData="@routeData" />
     </Found>

+ 1 - 1
src/Components/test/testassets/BasicTestApp/RouterTest/TestRouterWithLazyAssembly.razor

@@ -4,7 +4,7 @@
 
 @inject LazyAssemblyLoader lazyLoader
 
-<Router AppAssembly="@typeof(BasicTestApp.Program).Assembly" AdditionalAssemblies="@lazyLoadedAssemblies" OnNavigateAsync="@OnNavigateAsync">
+<Router AppAssembly="@typeof(BasicTestApp.Program).Assembly" AdditionalAssemblies="@lazyLoadedAssemblies" OnNavigateAsync="@OnNavigateAsync" PreferExactMatches="true">
     <Navigating>
         <div style="padding: 20px;background-color:blue;color:white;" id="loading-banner">
             <p>Loading the requested page...</p>

+ 1 - 1
src/Components/test/testassets/BasicTestApp/RouterTest/TestRouterWithOnNavigate.razor

@@ -4,7 +4,7 @@
 
 <button @onclick="TriggerRerender" id="trigger-rerender">Trigger Rerender</button>
 
-<Router AppAssembly="@typeof(BasicTestApp.Program).Assembly" OnNavigateAsync="@OnNavigateAsync">
+<Router AppAssembly="@typeof(BasicTestApp.Program).Assembly" OnNavigateAsync="@OnNavigateAsync" PreferExactMatches="true">
     <Navigating>
         <div style="padding: 20px;background-color:blue;color:white;" id="loading-banner">
             <p>Loading the requested page...</p>

+ 1 - 1
src/Components/test/testassets/ComponentsApp.App/App.razor

@@ -1,6 +1,6 @@
 @using Microsoft.AspNetCore.Components;
 <CascadingValue Value="Name" Name="Name" IsFixed=true>
-    <Router AppAssembly="@typeof(ComponentsApp.App.App).Assembly">
+    <Router AppAssembly="@typeof(ComponentsApp.App.App).Assembly" PreferExactMatches="true">
         <Found Context="routeData">
             <RouteView RouteData="@routeData" DefaultLayout="@typeof(MainLayout)" />
         </Found>

+ 0 - 1
src/Identity/Specification.Tests/src/Microsoft.AspNetCore.Identity.Specification.Tests.csproj

@@ -5,7 +5,6 @@
     <TargetFramework>$(DefaultNetCoreTargetFramework)</TargetFramework>
     <GenerateDocumentationFile>false</GenerateDocumentationFile>
     <PackageTags>aspnetcore;identity;membership</PackageTags>
-    <IsPackable>false</IsPackable>
   </PropertyGroup>
 
   <ItemGroup>

+ 0 - 1
src/Identity/Specification.Tests/src/UserManagerSpecificationTests.cs

@@ -8,7 +8,6 @@ using System.Linq;
 using System.Linq.Expressions;
 using System.Security.Claims;
 using System.Threading.Tasks;
-using Microsoft.AspNetCore.Testing;
 using Microsoft.AspNetCore.DataProtection;
 using Microsoft.Extensions.DependencyInjection;
 using Microsoft.Extensions.Logging;

+ 2 - 2
src/ProjectTemplates/Web.ProjectTemplates/content/BlazorServerWeb-CSharp/App.razor

@@ -1,5 +1,5 @@
 @*#if (NoAuth)
-<Router AppAssembly="@typeof(Program).Assembly">
+<Router AppAssembly="@typeof(Program).Assembly" PreferExactMatches="true">
     <Found Context="routeData">
         <RouteView RouteData="@routeData" DefaultLayout="@typeof(MainLayout)" />
     </Found>
@@ -11,7 +11,7 @@
 </Router>
 #else
 <CascadingAuthenticationState>
-    <Router AppAssembly="@typeof(Program).Assembly">
+    <Router AppAssembly="@typeof(Program).Assembly" PreferExactMatches="true">
         <Found Context="routeData">
             <AuthorizeRouteView RouteData="@routeData" DefaultLayout="@typeof(MainLayout)" />
         </Found>

+ 2 - 2
src/ProjectTemplates/Web.ProjectTemplates/content/ComponentsWebAssembly-CSharp/Client/App.razor

@@ -1,5 +1,5 @@
 @*#if (NoAuth)
-<Router AppAssembly="@typeof(Program).Assembly">
+<Router AppAssembly="@typeof(Program).Assembly" PreferExactMatches="true">
     <Found Context="routeData">
         <RouteView RouteData="@routeData" DefaultLayout="@typeof(MainLayout)" />
     </Found>
@@ -11,7 +11,7 @@
 </Router>
 #else
 <CascadingAuthenticationState>
-    <Router AppAssembly="@typeof(Program).Assembly">
+    <Router AppAssembly="@typeof(Program).Assembly" PreferExactMatches="true">
         <Found Context="routeData">
             <AuthorizeRouteView RouteData="@routeData" DefaultLayout="@typeof(MainLayout)">
                 <NotAuthorized>

+ 3 - 2
src/Razor/Microsoft.NET.Sdk.Razor/src/build/netstandard2.0/Sdk.Razor.CurrentVersion.targets

@@ -724,12 +724,13 @@ Copyright (c) .NET Foundation. All rights reserved.
       <AllPublishItemsFullPathWithTargetPath Include="@(RazorIntermediateAssembly->'%(FullPath)')">
         <TargetPath>%(Filename)%(Extension)</TargetPath>
         <CopyToPublishDirectory>PreserveNewest</CopyToPublishDirectory>
-        <ExcludeFromSingleFile>true</ExcludeFromSingleFile>
+        <!-- .Views.dll and .Views.pdb were part of the single file bundle in 3.1 apps. Lets keep this unchanged. -->
+        <ExcludeFromSingleFile Condition="'$(_TargetingNET50OrLater)' == 'true'">true</ExcludeFromSingleFile>
       </AllPublishItemsFullPathWithTargetPath>
       <AllPublishItemsFullPathWithTargetPath Include="@(_RazorDebugSymbolsIntermediatePath->'%(FullPath)')">
         <TargetPath>%(Filename)%(Extension)</TargetPath>
         <CopyToPublishDirectory>PreserveNewest</CopyToPublishDirectory>
-        <ExcludeFromSingleFile>true</ExcludeFromSingleFile>
+        <ExcludeFromSingleFile Condition="'$(_TargetingNET50OrLater)' == 'true'">true</ExcludeFromSingleFile>
       </AllPublishItemsFullPathWithTargetPath>
     </ItemGroup>
 

+ 46 - 16
src/SignalR/clients/java/signalr/core/src/main/java/com/microsoft/signalr/OkHttpWebSocketWrapper.java

@@ -30,7 +30,7 @@ class OkHttpWebSocketWrapper extends WebSocketWrapper {
     private WebSocketOnClosedCallback onClose;
     private CompletableSubject startSubject = CompletableSubject.create();
     private CompletableSubject closeSubject = CompletableSubject.create();
-    private final ReentrantLock closeLock = new ReentrantLock();
+    private final ReentrantLock stateLock = new ReentrantLock();
 
     private final Logger logger = LoggerFactory.getLogger(OkHttpWebSocketWrapper.class);
 
@@ -82,7 +82,12 @@ class OkHttpWebSocketWrapper extends WebSocketWrapper {
     private class SignalRWebSocketListener extends WebSocketListener {
         @Override
         public void onOpen(WebSocket webSocket, Response response) {
-            startSubject.onComplete();
+            stateLock.lock();
+            try {
+                startSubject.onComplete();
+            } finally {
+                stateLock.unlock();
+            }
         }
 
         @Override
@@ -97,39 +102,64 @@ class OkHttpWebSocketWrapper extends WebSocketWrapper {
 
         @Override
         public void onClosing(WebSocket webSocket, int code, String reason) {
-            onClose.invoke(code, reason);
+            boolean isOpen = false;
+            stateLock.lock();
+            try {
+                isOpen = startSubject.hasComplete();
+            } finally {
+                stateLock.unlock();
+            }
+
+            logger.info("WebSocket closing with status code '{}' and reason '{}'.", code, reason);
+
+            // Only call onClose if connection is open
+            if (isOpen) {
+                onClose.invoke(code, reason);
+            }
+
             try {
-                closeLock.lock();
+                stateLock.lock();
                 closeSubject.onComplete();
             }
             finally {
-                closeLock.unlock();
+                stateLock.unlock();
             }
-            checkStartFailure();
+            checkStartFailure(null);
         }
 
         @Override
         public void onFailure(WebSocket webSocket, Throwable t, Response response) {
-            logger.error("WebSocket closed from an error: {}.", t.getMessage());
+            logger.error("WebSocket closed from an error.", t);
 
+            boolean isOpen = false;
             try {
-                closeLock.lock();
+                stateLock.lock();
                 if (!closeSubject.hasComplete()) {
                     closeSubject.onError(new RuntimeException(t));
                 }
+
+                isOpen = startSubject.hasComplete();
             }
             finally {
-                closeLock.unlock();
+                stateLock.unlock();
             }
-            onClose.invoke(null, t.getMessage());
-            checkStartFailure();
+            // Only call onClose if connection is open
+            if (isOpen) {
+                onClose.invoke(null, t.getMessage());
+            }
+            checkStartFailure(t);
         }
 
-        private void checkStartFailure() {
-            // If the start task hasn't completed yet, then we need to complete it
-            // exceptionally.
-            if (!startSubject.hasComplete()) {
-                startSubject.onError(new RuntimeException("There was an error starting the WebSocket transport."));
+        private void checkStartFailure(Throwable t) {
+            stateLock.lock();
+            try {
+                // If the start task hasn't completed yet, then we need to complete it
+                // exceptionally.
+                if (!startSubject.hasComplete()) {
+                    startSubject.onError(new RuntimeException("There was an error starting the WebSocket transport.", t));
+                }
+            } finally {
+                stateLock.unlock();
             }
         }
     }

+ 0 - 2
src/SignalR/clients/java/signalr/core/src/main/java/com/microsoft/signalr/WebSocketTransport.java

@@ -86,8 +86,6 @@ class WebSocketTransport implements Transport {
     }
 
     void onClose(Integer code, String reason) {
-        logger.info("WebSocket connection stopping with " +
-                "code {} and reason '{}'.", code, reason);
         if (code == null || code != 1000) {
             onClose.invoke(reason);
         }