From 70c45014978d664890a3d4c5bd50e67f99f16ca5 Mon Sep 17 00:00:00 2001
From: Javier Calvarro Nelson <jacalvar@microsoft.com>
Date: Wed, 14 Mar 2018 16:00:58 -0700
Subject: [PATCH] [Fixes #220] Support `@page` with custom route template on
 components * Updates the router component to scan for components within
 assemblies. * Parses the templates on `[Route]` in component instances and
 builds a   route table that maps paths to components. * Uses the route table
 to map paths to components.

---
 samples/StandaloneApp/App.cshtml              |   4 +-
 samples/StandaloneApp/Pages/Counter.cshtml    |   3 +-
 samples/StandaloneApp/Pages/FetchData.cshtml  |   3 +-
 samples/StandaloneApp/Pages/Index.cshtml      |   3 +-
 .../Components/ComponentResolver.cs           |  73 ++++++
 .../ParameterCollectionExtensions.cs          |   3 +-
 .../Layouts/LayoutDisplay.cs                  |  20 +-
 .../Routing/RouteContext.cs                   |  26 +++
 .../Routing/RouteEntry.cs                     |  61 +++++
 .../Routing/RouteTable.cs                     | 130 +++++++++++
 .../Routing/RouteTemplate.cs                  |  21 ++
 .../Routing/Router.cs                         |  66 ++----
 .../Routing/TemplateParser.cs                 | 107 +++++++++
 .../Routing/TemplateSegment.cs                |  34 +++
 .../Tests/RoutingTest.cs                      |  21 +-
 .../Routing/RouteTableTests.cs                | 218 ++++++++++++++++++
 .../Routing/TemplateParserTests.cs            | 198 ++++++++++++++++
 .../BasicTestApp/RouterTest/Default.cshtml    |   3 +-
 .../BasicTestApp/RouterTest/Links.cshtml      |   5 +-
 .../BasicTestApp/RouterTest/Other.cshtml      |   3 +-
 .../BasicTestApp/RouterTest/TestRouter.cshtml |   4 +-
 .../RouterTest/WithParameters.cshtml          |  10 +
 22 files changed, 951 insertions(+), 65 deletions(-)
 create mode 100644 src/Microsoft.AspNetCore.Blazor/Components/ComponentResolver.cs
 create mode 100644 src/Microsoft.AspNetCore.Blazor/Routing/RouteContext.cs
 create mode 100644 src/Microsoft.AspNetCore.Blazor/Routing/RouteEntry.cs
 create mode 100644 src/Microsoft.AspNetCore.Blazor/Routing/RouteTable.cs
 create mode 100644 src/Microsoft.AspNetCore.Blazor/Routing/RouteTemplate.cs
 create mode 100644 src/Microsoft.AspNetCore.Blazor/Routing/TemplateParser.cs
 create mode 100644 src/Microsoft.AspNetCore.Blazor/Routing/TemplateSegment.cs
 create mode 100644 test/Microsoft.AspNetCore.Blazor.Test/Routing/RouteTableTests.cs
 create mode 100644 test/Microsoft.AspNetCore.Blazor.Test/Routing/TemplateParserTests.cs
 create mode 100644 test/testapps/BasicTestApp/RouterTest/WithParameters.cshtml

diff --git a/samples/StandaloneApp/App.cshtml b/samples/StandaloneApp/App.cshtml
index d04c20226c6..4b8a0ffa42d 100644
--- a/samples/StandaloneApp/App.cshtml
+++ b/samples/StandaloneApp/App.cshtml
@@ -2,6 +2,4 @@
     Configuring this stuff here is temporary. Later we'll move the app config
     into Program.cs, and it won't be necessary to specify AppAssembly.
 -->
-<Router
-    AppAssembly=typeof(StandaloneApp.Program).Assembly
-    PagesNamespace="StandaloneApp.Pages" />
+<Router AppAssembly=typeof(StandaloneApp.Program).Assembly />
diff --git a/samples/StandaloneApp/Pages/Counter.cshtml b/samples/StandaloneApp/Pages/Counter.cshtml
index 88367f46fa0..36772044403 100644
--- a/samples/StandaloneApp/Pages/Counter.cshtml
+++ b/samples/StandaloneApp/Pages/Counter.cshtml
@@ -1,4 +1,5 @@
-<h1>Counter</h1>
+@page "/counter"
+<h1>Counter</h1>
 
 <p>Current count: @currentCount</p>
 
diff --git a/samples/StandaloneApp/Pages/FetchData.cshtml b/samples/StandaloneApp/Pages/FetchData.cshtml
index ef6efd29584..37484dbed48 100644
--- a/samples/StandaloneApp/Pages/FetchData.cshtml
+++ b/samples/StandaloneApp/Pages/FetchData.cshtml
@@ -1,4 +1,5 @@
-@inject HttpClient Http
+@page "/fetchdata"
+@inject HttpClient Http
 
 <h1>Weather forecast</h1>
 
diff --git a/samples/StandaloneApp/Pages/Index.cshtml b/samples/StandaloneApp/Pages/Index.cshtml
index f75a462181b..86eb2813957 100644
--- a/samples/StandaloneApp/Pages/Index.cshtml
+++ b/samples/StandaloneApp/Pages/Index.cshtml
@@ -1,3 +1,4 @@
-<h1>Hello, world!</h1>
+@page "/"
+<h1>Hello, world!</h1>
 
 Welcome to your new app.
diff --git a/src/Microsoft.AspNetCore.Blazor/Components/ComponentResolver.cs b/src/Microsoft.AspNetCore.Blazor/Components/ComponentResolver.cs
new file mode 100644
index 00000000000..41efd48bbf1
--- /dev/null
+++ b/src/Microsoft.AspNetCore.Blazor/Components/ComponentResolver.cs
@@ -0,0 +1,73 @@
+// 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 System.Reflection;
+
+namespace Microsoft.AspNetCore.Blazor.Components
+{
+    /// <summary>
+    /// Resolves components for an application.
+    /// </summary>
+    internal class ComponentResolver
+    {
+        /// <summary>
+        /// Lists all the types 
+        /// </summary>
+        /// <param name="appAssembly"></param>
+        /// <returns></returns>
+        public static IEnumerable<Type> ResolveComponents(Assembly appAssembly)
+        {
+            var blazorAssembly = typeof(IComponent).Assembly;
+
+            return EnumerateAssemblies(appAssembly.GetName(), blazorAssembly, new HashSet<Assembly>(new AssemblyComparer()))
+                .SelectMany(a => a.ExportedTypes)
+                .Where(t => typeof(IComponent).IsAssignableFrom(t));
+        }
+
+        private static IEnumerable<Assembly> EnumerateAssemblies(
+            AssemblyName assemblyName,
+            Assembly blazorAssembly,
+            HashSet<Assembly> visited)
+        {
+            var assembly = Assembly.Load(assemblyName);
+            if (visited.Contains(assembly))
+            {
+                // Avoid traversing visited assemblies.
+                yield break;
+            }
+            visited.Add(assembly);
+            var references = assembly.GetReferencedAssemblies();
+            if (!references.Any(r => string.Equals(r.FullName, blazorAssembly.FullName, StringComparison.Ordinal)))
+            {
+                // Avoid traversing references that don't point to blazor (like netstandard2.0)
+                yield break;
+            }
+            else
+            {
+                yield return assembly;
+
+                // Look at the list of transitive dependencies for more components.
+                foreach (var reference in references.SelectMany(r => EnumerateAssemblies(r, blazorAssembly, visited)))
+                {
+                    yield return reference;
+                }
+            }
+        }
+
+        private class AssemblyComparer : IEqualityComparer<Assembly>
+        {
+            public bool Equals(Assembly x, Assembly y)
+            {
+                return string.Equals(x?.FullName, y?.FullName, StringComparison.Ordinal);
+            }
+
+            public int GetHashCode(Assembly obj)
+            {
+                return obj.FullName.GetHashCode();
+            }
+        }
+    }
+}
diff --git a/src/Microsoft.AspNetCore.Blazor/Components/ParameterCollectionExtensions.cs b/src/Microsoft.AspNetCore.Blazor/Components/ParameterCollectionExtensions.cs
index 4fb9d69a2ac..1f39d7df852 100644
--- a/src/Microsoft.AspNetCore.Blazor/Components/ParameterCollectionExtensions.cs
+++ b/src/Microsoft.AspNetCore.Blazor/Components/ParameterCollectionExtensions.cs
@@ -57,7 +57,8 @@ namespace Microsoft.AspNetCore.Blazor.Components
 
         private static PropertyInfo GetPropertyInfo(Type targetType, string propertyName)
         {
-            var property = targetType.GetProperty(propertyName);
+            var flags = BindingFlags.Public | BindingFlags.Instance | BindingFlags.IgnoreCase;
+            var property = targetType.GetProperty(propertyName, flags);
             if (property == null)
             {
                 throw new InvalidOperationException(
diff --git a/src/Microsoft.AspNetCore.Blazor/Layouts/LayoutDisplay.cs b/src/Microsoft.AspNetCore.Blazor/Layouts/LayoutDisplay.cs
index 80a30c55764..cb95a0aa7e3 100644
--- a/src/Microsoft.AspNetCore.Blazor/Layouts/LayoutDisplay.cs
+++ b/src/Microsoft.AspNetCore.Blazor/Layouts/LayoutDisplay.cs
@@ -2,6 +2,7 @@
 // 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.Reflection;
 using Microsoft.AspNetCore.Blazor.Components;
 using Microsoft.AspNetCore.Blazor.RenderTree;
@@ -15,13 +16,18 @@ namespace Microsoft.AspNetCore.Blazor.Layouts
     public class LayoutDisplay : IComponent
     {
         private RenderHandle _renderHandle;
-        
+
         /// <summary>
         /// Gets or sets the type of the page component to display.
         /// The type must implement <see cref="IComponent"/>.
         /// </summary>
         public Type Page { get; set; }
 
+        /// <summary>
+        /// Gets or sets the parameters to pass to the page.
+        /// </summary>
+        public IDictionary<string, string> PageParameters { get; set; }
+
         /// <inheritdoc />
         public void Init(RenderHandle renderHandle)
         {
@@ -47,7 +53,7 @@ namespace Microsoft.AspNetCore.Blazor.Layouts
             {
                 fragment = RenderComponentWithBody(layoutType, fragment);
             }
-            
+
             _renderHandle.Render(fragment);
         }
 
@@ -58,6 +64,16 @@ namespace Microsoft.AspNetCore.Blazor.Layouts
             {
                 builder.AddAttribute(1, nameof(ILayoutComponent.Body), bodyParam);
             }
+            else
+            {
+                if (PageParameters != null)
+                {
+                    foreach (var kvp in PageParameters)
+                    {
+                        builder.AddAttribute(1, kvp.Key, kvp.Value);
+                    }
+                }
+            }
             builder.CloseComponent();
         };
 
diff --git a/src/Microsoft.AspNetCore.Blazor/Routing/RouteContext.cs b/src/Microsoft.AspNetCore.Blazor/Routing/RouteContext.cs
new file mode 100644
index 00000000000..cf7fcfa4e0f
--- /dev/null
+++ b/src/Microsoft.AspNetCore.Blazor/Routing/RouteContext.cs
@@ -0,0 +1,26 @@
+// 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;
+
+namespace Microsoft.AspNetCore.Blazor.Routing
+{
+    internal class RouteContext
+    {
+        private static char[] Separator = new[] { '/' };
+
+        public RouteContext(string path)
+        {
+            // This is a simplification. We are assuming there are no paths like /a//b/. A proper routing
+            // implementation would be more sophisticated.
+            Segments = path.Trim('/').Split(Separator, StringSplitOptions.RemoveEmptyEntries);
+        }
+
+        public string[] Segments { get; }
+
+        public Type Handler { get; set; }
+
+        public IDictionary<string, string> Parameters { get; set; }
+    }
+}
\ No newline at end of file
diff --git a/src/Microsoft.AspNetCore.Blazor/Routing/RouteEntry.cs b/src/Microsoft.AspNetCore.Blazor/Routing/RouteEntry.cs
new file mode 100644
index 00000000000..3fc73ab4754
--- /dev/null
+++ b/src/Microsoft.AspNetCore.Blazor/Routing/RouteEntry.cs
@@ -0,0 +1,61 @@
+// 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;
+
+namespace Microsoft.AspNetCore.Blazor.Routing
+{
+    internal class RouteEntry
+    {
+        public RouteEntry(RouteTemplate template, Type handler)
+        {
+            Template = template;
+            Handler = handler;
+        }
+
+        public RouteTemplate Template { get; }
+
+        public Type Handler { get; }
+
+        internal void Match(RouteContext context)
+        {
+            if (Template.Segments.Length != context.Segments.Length)
+            {
+                return;
+            }
+
+            // Parameters will be lazily initialized.
+            IDictionary<string, string> parameters = null;
+            for (int i = 0; i < Template.Segments.Length; i++)
+            {
+                var segment = Template.Segments[i];
+                var pathSegment = context.Segments[i];
+                if (!segment.Match(pathSegment))
+                {
+                    return;
+                }
+                else
+                {
+                    if (segment.IsParameter)
+                    {
+                        GetParameters()[segment.Value] = pathSegment;
+                    }
+                }
+            }
+
+            context.Parameters = parameters;
+            context.Handler = Handler;
+
+            IDictionary<string, string> GetParameters()
+            {
+                if (parameters == null)
+                {
+                    parameters = new Dictionary<string, string>();
+                }
+
+                return parameters;
+            }
+        }
+    }
+}
diff --git a/src/Microsoft.AspNetCore.Blazor/Routing/RouteTable.cs b/src/Microsoft.AspNetCore.Blazor/Routing/RouteTable.cs
new file mode 100644
index 00000000000..efaeff92db3
--- /dev/null
+++ b/src/Microsoft.AspNetCore.Blazor/Routing/RouteTable.cs
@@ -0,0 +1,130 @@
+// 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 System.Reflection;
+using Microsoft.AspNetCore.Blazor.Components;
+
+namespace Microsoft.AspNetCore.Blazor.Routing
+{
+    internal class RouteTable
+    {
+        public RouteTable(RouteEntry[] routes)
+        {
+            Routes = routes;
+        }
+
+        public RouteEntry[] Routes { get; set; }
+
+        public static RouteTable Create(IEnumerable<Type> types)
+        {
+            var routes = new List<RouteEntry>();
+            foreach (var type in types)
+            {
+                var routeAttributes = type.GetCustomAttributes<RouteAttribute>(); // Inherit: true?
+                foreach (var routeAttribute in routeAttributes)
+                {
+                    var template = TemplateParser.ParseTemplate(routeAttribute.Template);
+                    var entry = new RouteEntry(template, type);
+                    routes.Add(entry);
+                }
+            }
+
+            return new RouteTable(routes.OrderBy(id => id, RoutePrecedence).ToArray());
+        }
+
+        public static IComparer<RouteEntry> RoutePrecedence { get; } = Comparer<RouteEntry>.Create(RouteComparison);
+
+        /// <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.
+        /// * 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}
+        ///
+        /// Routes can be ambigous 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
+        ///
+        /// 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 insenitive) we choose the lexical order
+        /// If we get to the end of the comparison routing we've detected an ambigous pair of routes.
+        internal static int RouteComparison(RouteEntry x, RouteEntry y)
+        {
+            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 (int 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;
+                    }
+                }
+
+                for (int 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)
+                    {
+                        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}'
+");
+            }
+        }
+
+        internal void Route(RouteContext routeContext)
+        {
+            foreach (var route in Routes)
+            {
+                route.Match(routeContext);
+                if (routeContext.Handler != null)
+                {
+                    return;
+                }
+            }
+        }
+    }
+}
diff --git a/src/Microsoft.AspNetCore.Blazor/Routing/RouteTemplate.cs b/src/Microsoft.AspNetCore.Blazor/Routing/RouteTemplate.cs
new file mode 100644
index 00000000000..d673ecb3037
--- /dev/null
+++ b/src/Microsoft.AspNetCore.Blazor/Routing/RouteTemplate.cs
@@ -0,0 +1,21 @@
+// 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.Blazor.Routing
+{
+    internal class RouteTemplate
+    {
+        public static readonly char[] Separators = new[] { '/' };
+
+        public RouteTemplate(string TemplateText, TemplateSegment[] segments)
+        {
+            this.TemplateText = TemplateText;
+            Segments = segments;
+        }
+
+        public string TemplateText { get; }
+
+        public TemplateSegment[] Segments { get; }
+    }
+}
diff --git a/src/Microsoft.AspNetCore.Blazor/Routing/Router.cs b/src/Microsoft.AspNetCore.Blazor/Routing/Router.cs
index 4691689ed52..1a9ffaa02b2 100644
--- a/src/Microsoft.AspNetCore.Blazor/Routing/Router.cs
+++ b/src/Microsoft.AspNetCore.Blazor/Routing/Router.cs
@@ -2,7 +2,7 @@
 // Licensed under the Apache License, Version 2.0. See License.txt in the project root for license information.
 
 using System;
-using System.Linq;
+using System.Collections.Generic;
 using System.Reflection;
 using Microsoft.AspNetCore.Blazor.Components;
 using Microsoft.AspNetCore.Blazor.Layouts;
@@ -31,17 +31,7 @@ namespace Microsoft.AspNetCore.Blazor.Routing
         /// </summary>
         public Assembly AppAssembly { get; set; }
 
-        /// <summary>
-        /// Gets or sets the namespace prefix that should be prepended when searching
-        /// for matching components.
-        /// </summary>
-        public string PagesNamespace { get; set; }
-
-        /// <summary>
-        /// Gets or sets the component name that will be used if the URI ends with
-        /// a slash.
-        /// </summary>
-        public string DefaultComponentName { get; set; } = "Index";
+        private RouteTable Routes { get; set; }
 
         /// <inheritdoc />
         public void Init(RenderHandle renderHandle)
@@ -56,6 +46,8 @@ namespace Microsoft.AspNetCore.Blazor.Routing
         public void SetParameters(ParameterCollection parameters)
         {
             parameters.AssignToProperties(this);
+            var types = ComponentResolver.ResolveComponents(AppAssembly);
+            Routes = RouteTable.Create(types);
             Refresh();
         }
 
@@ -65,29 +57,6 @@ namespace Microsoft.AspNetCore.Blazor.Routing
             UriHelper.OnLocationChanged -= OnLocationChanged;
         }
 
-        protected virtual Type GetComponentTypeForPath(string locationPath)
-        {
-            if (AppAssembly == null)
-            {
-                throw new InvalidOperationException($"No value was specified for {nameof(AppAssembly)}.");
-            }
-
-            if (string.IsNullOrEmpty(PagesNamespace))
-            {
-                throw new InvalidOperationException($"No value was specified for {nameof(PagesNamespace)}.");
-            }
-
-            locationPath = StringUntilAny(locationPath, _queryOrHashStartChar);
-            var componentTypeName = $"{PagesNamespace}{locationPath.Replace('/', '.')}";
-            if (componentTypeName[componentTypeName.Length - 1] == '.')
-            {
-                componentTypeName += DefaultComponentName;
-            }
-
-            return FindComponentTypeInAssemblyOrReferences(AppAssembly, componentTypeName)
-                ?? throw new InvalidOperationException($"{nameof(Router)} cannot find any component type with name {componentTypeName}.");
-        }
-
         private string StringUntilAny(string str, char[] chars)
         {
             var firstIndex = str.IndexOfAny(chars);
@@ -96,31 +65,32 @@ namespace Microsoft.AspNetCore.Blazor.Routing
                 : str.Substring(0, firstIndex);
         }
 
-        private Type FindComponentTypeInAssemblyOrReferences(Assembly assembly, string typeName)
-            => assembly.GetType(typeName, throwOnError: false, ignoreCase: true)
-            ?? assembly.GetReferencedAssemblies()
-                .Select(Assembly.Load)
-                .Select(referencedAssembly => FindComponentTypeInAssemblyOrReferences(referencedAssembly, typeName))
-                .FirstOrDefault();
-
-        protected virtual void Render(RenderTreeBuilder builder, Type matchedComponentType)
+        protected virtual void Render(RenderTreeBuilder builder, Type handler, IDictionary<string, string> parameters)
         {
             builder.OpenComponent(0, typeof(LayoutDisplay));
-            builder.AddAttribute(1, nameof(LayoutDisplay.Page), matchedComponentType);
+            builder.AddAttribute(1, nameof(LayoutDisplay.Page), handler);
+            builder.AddAttribute(2, nameof(LayoutDisplay.PageParameters), parameters);
             builder.CloseComponent();
         }
 
         private void Refresh()
         {
             var locationPath = UriHelper.ToBaseRelativePath(_baseUriPrefix, _locationAbsolute);
-            var matchedComponentType = GetComponentTypeForPath(locationPath);
-            if (!typeof(IComponent).IsAssignableFrom(matchedComponentType))
+            locationPath = StringUntilAny(locationPath, _queryOrHashStartChar);
+            var context = new RouteContext(locationPath);
+            Routes.Route(context);
+            if (context.Handler == null)
+            {
+                throw new InvalidOperationException($"'{nameof(Router)}' cannot find any component with a route for '{locationPath}'.");
+            }
+
+            if (!typeof(IComponent).IsAssignableFrom(context.Handler))
             {
-                throw new InvalidOperationException($"The type {matchedComponentType.FullName} " +
+                throw new InvalidOperationException($"The type {context.Handler.FullName} " +
                     $"does not implement {typeof(IComponent).FullName}.");
             }
 
-            _renderHandle.Render(builder => Render(builder, matchedComponentType));
+            _renderHandle.Render(builder => Render(builder, context.Handler, context.Parameters));
         }
 
         private void OnLocationChanged(object sender, string newAbsoluteUri)
diff --git a/src/Microsoft.AspNetCore.Blazor/Routing/TemplateParser.cs b/src/Microsoft.AspNetCore.Blazor/Routing/TemplateParser.cs
new file mode 100644
index 00000000000..d999c0f32ed
--- /dev/null
+++ b/src/Microsoft.AspNetCore.Blazor/Routing/TemplateParser.cs
@@ -0,0 +1,107 @@
+// 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.Blazor.Routing
+{
+    // 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 route constraints.
+    // 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 catch all parameter segments.
+    // * We don't support optional parameter segments.
+    // * 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})
+    internal class TemplateParser
+    {
+        public static readonly char[] InvalidParameterNameCharacters =
+            new char[] { '*', '?', '{', '}', '=', '.', ':' };
+
+        internal static RouteTemplate ParseTemplate(string template)
+        {
+            template = template.Trim('/');
+            if (template == "")
+            {
+                // Special case "/";
+                return new RouteTemplate("/", Array.Empty<TemplateSegment>());
+            }
+
+            var segments = template.Split('/');
+            var templateSegments = new TemplateSegment[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 TemplateSegment(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 TemplateSegment(segment.Substring(1, segment.Length - 2), isParameter: true);
+                }
+            }
+
+            for (int i = 0; i < templateSegments.Length; i++)
+            {
+                var currentSegment = templateSegments[i];
+                if (!currentSegment.IsParameter)
+                {
+                    continue;
+                }
+
+                for (int j = i + 1; j < templateSegments.Length; j++)
+                {
+                    var nextSegment = templateSegments[j];
+                    if (!nextSegment.IsParameter)
+                    {
+                        continue;
+                    }
+
+                    if (string.Equals(currentSegment.Value, nextSegment.Value, StringComparison.OrdinalIgnoreCase))
+                    {
+                        throw new InvalidOperationException(
+                            $"Invalid template '{template}'. The parameter '{currentSegment}' appears multiple times.");
+                    }
+                }
+            }
+
+            return new RouteTemplate(template, templateSegments);
+        }
+    }
+}
diff --git a/src/Microsoft.AspNetCore.Blazor/Routing/TemplateSegment.cs b/src/Microsoft.AspNetCore.Blazor/Routing/TemplateSegment.cs
new file mode 100644
index 00000000000..b7e46086c6c
--- /dev/null
+++ b/src/Microsoft.AspNetCore.Blazor/Routing/TemplateSegment.cs
@@ -0,0 +1,34 @@
+// 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.Blazor.Routing
+{
+    internal class TemplateSegment
+    {
+        public TemplateSegment(string segment, bool isParameter)
+        {
+            Value = segment;
+            IsParameter = isParameter;
+        }
+
+        // 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 Match(string pathSegment)
+        {
+            if (IsParameter)
+            {
+                return true;
+            }
+            else
+            {
+                return string.Equals(Value, pathSegment, StringComparison.OrdinalIgnoreCase);
+            }
+        }
+    }
+}
diff --git a/test/Microsoft.AspNetCore.Blazor.E2ETest/Tests/RoutingTest.cs b/test/Microsoft.AspNetCore.Blazor.E2ETest/Tests/RoutingTest.cs
index 272cd9e632c..e6f04cde609 100644
--- a/test/Microsoft.AspNetCore.Blazor.E2ETest/Tests/RoutingTest.cs
+++ b/test/Microsoft.AspNetCore.Blazor.E2ETest/Tests/RoutingTest.cs
@@ -32,6 +32,15 @@ namespace Microsoft.AspNetCore.Blazor.E2ETest.Tests
             Assert.Equal("This is the default page.", app.FindElement(By.Id("test-info")).Text);
         }
 
+        [Fact]
+        public void CanArriveAtPageWithParameters()
+        {
+            SetUrlViaPushState($"{ServerPathBase}/RouterTest/WithParameters/Name/Dan/LastName/Roth");
+
+            var app = MountTestComponent<TestRouter>();
+            Assert.Equal("Your full name is Dan Roth.", app.FindElement(By.Id("test-info")).Text);
+        }
+
         [Fact]
         public void CanArriveAtNonDefaultPage()
         {
@@ -54,13 +63,23 @@ namespace Microsoft.AspNetCore.Blazor.E2ETest.Tests
         [Fact]
         public void CanFollowLinkToOtherPageWithBaseRelativeUrl()
         {
-            SetUrlViaPushState($"{ServerPathBase}/RouterTest/");
+            SetUrlViaPushState($"{ServerPathBase}/RouterTest/");            
 
             var app = MountTestComponent<TestRouter>();
             app.FindElement(By.LinkText("Other with base-relative URL")).Click();
             Assert.Equal("This is another page.", app.FindElement(By.Id("test-info")).Text);
         }
 
+        [Fact]
+        public void CanFollowLinkToPageWithParameters()
+        {
+            SetUrlViaPushState($"{ServerPathBase}/RouterTest/Other");
+
+            var app = MountTestComponent<TestRouter>();
+            app.FindElement(By.LinkText("With parameters")).Click();
+            Assert.Equal("Your full name is Steve Sanderson.", app.FindElement(By.Id("test-info")).Text);
+        }
+
         [Fact]
         public void CanFollowLinkToDefaultPage()
         {
diff --git a/test/Microsoft.AspNetCore.Blazor.Test/Routing/RouteTableTests.cs b/test/Microsoft.AspNetCore.Blazor.Test/Routing/RouteTableTests.cs
new file mode 100644
index 00000000000..3a0087beaac
--- /dev/null
+++ b/test/Microsoft.AspNetCore.Blazor.Test/Routing/RouteTableTests.cs
@@ -0,0 +1,218 @@
+// 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 Microsoft.AspNetCore.Blazor.Routing;
+using Xunit;
+
+namespace Microsoft.AspNetCore.Blazor.Test.Routing
+{
+    public class RouteTableTests
+    {
+        [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 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("/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")]
+        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" && 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, string>
+            {
+                ["some"] = "an",
+                ["route"] = "path"
+            };
+
+            // Act
+            routeTable.Route(context);
+
+            // Assert
+            Assert.NotNull(context.Handler);
+            Assert.Equal(expectedParameters, context.Parameters);
+        }
+
+        [Fact]
+        public void PrefersLiteralTemplateOverTemplateWithParameters()
+        {
+            // Arrange
+            var routeTable = new TestRouteTableBuilder()
+                .AddRoute("/an/awesome/path")
+                .AddRoute("/{some}/awesome/{route}/").Build();
+            var context = new RouteContext("/an/awesome/path");
+
+            // Act
+            routeTable.Route(context);
+
+            // Assert
+            Assert.NotNull(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 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);
+        }
+
+        [Theory]
+        [InlineData("/literal", "/Literal/")]
+        [InlineData("/{parameter}", "/{parameter}/")]
+        [InlineData("/literal/{parameter}", "/Literal/{something}")]
+        [InlineData("/{parameter}/literal/{something}", "{param}/Literal/{else}")]
+        public void DetectsAmbigousRoutes(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);
+        }
+
+        private class TestRouteTableBuilder
+        {
+            IList<(string, Type)> _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 RouteTable Build() => new RouteTable(_routeTemplates
+                .Select(rt => new RouteEntry(TemplateParser.ParseTemplate(rt.Item1), rt.Item2))
+                .OrderBy(id => id, RouteTable.RoutePrecedence)
+                .ToArray());
+        }
+    }
+}
diff --git a/test/Microsoft.AspNetCore.Blazor.Test/Routing/TemplateParserTests.cs b/test/Microsoft.AspNetCore.Blazor.Test/Routing/TemplateParserTests.cs
new file mode 100644
index 00000000000..d9fb20a54a9
--- /dev/null
+++ b/test/Microsoft.AspNetCore.Blazor.Test/Routing/TemplateParserTests.cs
@@ -0,0 +1,198 @@
+// 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.Blazor.Routing
+{
+    public class TemplateParserTests
+    {
+        [Fact]
+        public void Parse_SingleLiteral()
+        {
+            // Arrange
+            var expected = new ExpectedTemplateBuilder().Literal("awesome");
+
+            // Act
+            var actual = TemplateParser.ParseTemplate("awesome");
+
+            // Assert
+            Assert.Equal(expected, actual, RouteTemplateTestComparer.Instance);
+        }
+
+        [Fact]
+        public void Parse_SingleParameter()
+        {
+            // Arrange
+            var template = "{p}";
+
+            var expected = new ExpectedTemplateBuilder().Parameter("p");
+
+            // Act
+            var actual = TemplateParser.ParseTemplate(template);
+
+            // Assert
+            Assert.Equal(expected, actual, RouteTemplateTestComparer.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 = TemplateParser.ParseTemplate(template);
+
+            // Assert
+            Assert.Equal(expected, actual, RouteTemplateTestComparer.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 = TemplateParser.ParseTemplate(template);
+
+            // Assert
+            Assert.Equal(expected, actual, RouteTemplateTestComparer.Instance);
+        }
+
+        [Fact]
+        public void InvalidTemplate_WithRepeatedParameter()
+        {
+            var ex = Assert.Throws<InvalidOperationException>(
+                () => TemplateParser.ParseTemplate("{p1}/literal/{p1}"));
+
+            var expectedMessage = "Invalid template '{p1}/literal/{p1}'. The parameter 'Microsoft.AspNetCore.Blazor.Routing.TemplateSegment' 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>(
+                () => TemplateParser.ParseTemplate(template));
+
+            Assert.Equal(expectedMessage, ex.Message);
+        }
+
+        [Theory]
+        [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.")]
+        [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>(() => TemplateParser.ParseTemplate(template));
+
+            Assert.Equal(expectedMessage, ex.Message);
+        }
+
+        [Fact]
+        public void InvalidTemplate_InvalidParameterNameWithEmptyNameThrows()
+        {
+            var ex = Assert.Throws<InvalidOperationException>(() => TemplateParser.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>(() => TemplateParser.ParseTemplate("{a}//{z}"));
+
+            var expectedMessage = "Invalid template '{a}//{z}'. Empty segments are not allowed.";
+
+            Assert.Equal(expectedMessage, ex.Message);
+        }
+
+        private class ExpectedTemplateBuilder
+        {
+            public IList<TemplateSegment> Segments { get; set; } = new List<TemplateSegment>();
+
+            public ExpectedTemplateBuilder Literal(string value)
+            {
+                Segments.Add(new TemplateSegment(value, isParameter: false));
+                return this;
+            }
+
+            public ExpectedTemplateBuilder Parameter(string value)
+            {
+                Segments.Add(new TemplateSegment(value, isParameter: true));
+                return this;
+            }
+
+            public RouteTemplate Build() => new RouteTemplate(string.Join('/', Segments), Segments.ToArray());
+
+            public static implicit operator RouteTemplate(ExpectedTemplateBuilder builder) => builder.Build();
+        }
+
+        private class RouteTemplateTestComparer : IEqualityComparer<RouteTemplate>
+        {
+            public static RouteTemplateTestComparer Instance { get; } = new RouteTemplateTestComparer();
+
+            public bool Equals(RouteTemplate x, RouteTemplate y)
+            {
+                if (x == null && y == null)
+                {
+                    return true;
+                }
+
+                if ((x == null) != (y == null))
+                {
+                    return false;
+                }
+
+                if (x.Segments.Length != y.Segments.Length)
+                {
+                    return false;
+                }
+
+                for (int 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 (!string.Equals(xSegment.Value, ySegment.Value, StringComparison.OrdinalIgnoreCase))
+                    {
+                        return false;
+                    }
+                }
+
+                return true;
+            }
+
+            public int GetHashCode(RouteTemplate obj) => 0;
+        }
+    }
+}
diff --git a/test/testapps/BasicTestApp/RouterTest/Default.cshtml b/test/testapps/BasicTestApp/RouterTest/Default.cshtml
index fb8b327e4d6..3118c9d3863 100644
--- a/test/testapps/BasicTestApp/RouterTest/Default.cshtml
+++ b/test/testapps/BasicTestApp/RouterTest/Default.cshtml
@@ -1,3 +1,4 @@
-@using BasicTestApp.RouterTest
+@page "/RouterTest"
+@using BasicTestApp.RouterTest
 <div id="test-info">This is the default page.</div>
 <Links />
diff --git a/test/testapps/BasicTestApp/RouterTest/Links.cshtml b/test/testapps/BasicTestApp/RouterTest/Links.cshtml
index 9490da17253..36ca9857b81 100644
--- a/test/testapps/BasicTestApp/RouterTest/Links.cshtml
+++ b/test/testapps/BasicTestApp/RouterTest/Links.cshtml
@@ -1,5 +1,5 @@
-@inject Microsoft.AspNetCore.Blazor.Services.IUriHelper uriHelper
-
+@page "/Links"
+@inject Microsoft.AspNetCore.Blazor.Services.IUriHelper uriHelper
 <ul>
     <li><a href="/subdir/RouterTest/">Default</a></li>
     <li><a href="/subdir/RouterTest/?abc=123">Default with query</a></li>
@@ -8,6 +8,7 @@
     <li><a href="RouterTest/Other">Other with base-relative URL</a></li>
     <li><a href="/subdir/RouterTest/Other?abc=123">Other with query</a></li>
     <li><a href="/subdir/RouterTest/Other#blah">Other with hash</a></li>
+    <li><a href="/subdir/RouterTest/WithParameters/Name/Steve/LastName/Sanderson">With parameters</a></li>
 </ul>
 
 <button onclick=@{ uriHelper.NavigateTo("RouterTest/Other"); }>
diff --git a/test/testapps/BasicTestApp/RouterTest/Other.cshtml b/test/testapps/BasicTestApp/RouterTest/Other.cshtml
index 72a79a1cd03..633800b0bd8 100644
--- a/test/testapps/BasicTestApp/RouterTest/Other.cshtml
+++ b/test/testapps/BasicTestApp/RouterTest/Other.cshtml
@@ -1,3 +1,4 @@
-@using BasicTestApp.RouterTest
+@page "/RouterTest/Other"
+@using BasicTestApp.RouterTest
 <div id="test-info">This is another page.</div>
 <Links />
diff --git a/test/testapps/BasicTestApp/RouterTest/TestRouter.cshtml b/test/testapps/BasicTestApp/RouterTest/TestRouter.cshtml
index d3c34ffcd46..b9c48dff7e2 100644
--- a/test/testapps/BasicTestApp/RouterTest/TestRouter.cshtml
+++ b/test/testapps/BasicTestApp/RouterTest/TestRouter.cshtml
@@ -1,4 +1,2 @@
 @using Microsoft.AspNetCore.Blazor.Routing
-<Router AppAssembly=typeof(BasicTestApp.Program).Assembly
-        PagesNamespace=@nameof(BasicTestApp)
-        DefaultComponentName="Default" />
+<Router AppAssembly=typeof(BasicTestApp.Program).Assembly />
diff --git a/test/testapps/BasicTestApp/RouterTest/WithParameters.cshtml b/test/testapps/BasicTestApp/RouterTest/WithParameters.cshtml
new file mode 100644
index 00000000000..b31a8710716
--- /dev/null
+++ b/test/testapps/BasicTestApp/RouterTest/WithParameters.cshtml
@@ -0,0 +1,10 @@
+@page "/RouterTest/WithParameters/Name/{firstName}/LastName/{lastName}"
+@using BasicTestApp.RouterTest
+<div id="test-info">Your full name is @FirstName @LastName.</div>
+
+@functions
+{
+    public string FirstName { get; set; }
+
+    public string LastName { get ; set; }
+}
-- 
GitLab