diff --git a/src/Components/Blazor/testassets/StandaloneApp/Pages/FetchData.razor b/src/Components/Blazor/testassets/StandaloneApp/Pages/FetchData.razor index 9a741bddfbbe..c640fe9fbc07 100644 --- a/src/Components/Blazor/testassets/StandaloneApp/Pages/FetchData.razor +++ b/src/Components/Blazor/testassets/StandaloneApp/Pages/FetchData.razor @@ -34,36 +34,32 @@ else

- + ◀ Previous - + Next ▶

} @code { - [Parameter] public DateTime StartDate { get; set; } + [Parameter] public DateTime? StartDate { get; set; } WeatherForecast[] forecasts; - - public override Task SetParametersAsync(ParameterCollection parameters) - { - StartDate = DateTime.Now; - return base.SetParametersAsync(parameters); - } + DateTime startDate; protected override async Task OnParametersSetAsync() { + startDate = StartDate.GetValueOrDefault(DateTime.Now); forecasts = await Http.GetJsonAsync( - $"sample-data/weather.json?date={StartDate.ToString("yyyy-MM-dd")}"); + $"sample-data/weather.json?date={startDate.ToString("yyyy-MM-dd")}"); // Because StandaloneApp doesn't really have a server endpoint to get dynamic data from, // fake the DateFormatted values here. This would not apply in a real app. for (var i = 0; i < forecasts.Length; i++) { - forecasts[i].DateFormatted = StartDate.AddDays(i).ToShortDateString(); + forecasts[i].DateFormatted = startDate.AddDays(i).ToShortDateString(); } } diff --git a/src/Components/Components/src/Reflection/MemberAssignment.cs b/src/Components/Components/src/Reflection/MemberAssignment.cs index 46d2b8056944..0ab288cedcbe 100644 --- a/src/Components/Components/src/Reflection/MemberAssignment.cs +++ b/src/Components/Components/src/Reflection/MemberAssignment.cs @@ -51,7 +51,16 @@ public PropertySetter(MethodInfo setMethod) } public void SetValue(object target, object value) - => _setterDelegate((TTarget)target, (TValue)value); + { + if (value == null) + { + _setterDelegate((TTarget)target, default); + } + else + { + _setterDelegate((TTarget)target, (TValue)value); + } + } } } } diff --git a/src/Components/Components/src/Routing/RouteEntry.cs b/src/Components/Components/src/Routing/RouteEntry.cs index cff9420bd9c0..3870ef2245ff 100644 --- a/src/Components/Components/src/Routing/RouteEntry.cs +++ b/src/Components/Components/src/Routing/RouteEntry.cs @@ -8,14 +8,17 @@ namespace Microsoft.AspNetCore.Components.Routing { internal class RouteEntry { - public RouteEntry(RouteTemplate template, Type handler) + public RouteEntry(RouteTemplate template, Type handler, string[] unusedRouteParameterNames) { Template = template; + UnusedRouteParameterNames = unusedRouteParameterNames; Handler = handler; } public RouteTemplate Template { get; } + public string[] UnusedRouteParameterNames { get; } + public Type Handler { get; } internal void Match(RouteContext context) @@ -45,6 +48,18 @@ internal void Match(RouteContext context) } } + // 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 (UnusedRouteParameterNames.Length > 0) + { + parameters ??= new Dictionary(StringComparer.Ordinal); + for (var i = 0; i < UnusedRouteParameterNames.Length; i++) + { + parameters[UnusedRouteParameterNames[i]] = null; + } + } + context.Parameters = parameters; context.Handler = Handler; } diff --git a/src/Components/Components/src/Routing/RouteTableFactory.cs b/src/Components/Components/src/Routing/RouteTableFactory.cs index eae29a7530b4..e0601bc95a9e 100644 --- a/src/Components/Components/src/Routing/RouteTableFactory.cs +++ b/src/Components/Components/src/Routing/RouteTableFactory.cs @@ -34,19 +34,38 @@ public static RouteTable Create(Assembly appAssembly) internal static RouteTable Create(IEnumerable componentTypes) { - var routes = new List(); - foreach (var type in componentTypes) + var templatesByHandler = new Dictionary(); + 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 = type.GetCustomAttributes(inherit: false); + var routeAttributes = componentType.GetCustomAttributes(inherit: false); + + var templates = routeAttributes.Select(t => t.Template).ToArray(); + templatesByHandler.Add(componentType, templates); + } + return Create(templatesByHandler); + } - foreach (var routeAttribute in routeAttributes) + internal static RouteTable Create(Dictionary templatesByHandler) + { + var routes = new List(); + foreach (var keyValuePair in templatesByHandler) + { + var parsedTemplates = keyValuePair.Value.Select(v => TemplateParser.ParseTemplate(v)).ToArray(); + var allRouteParameterNames = parsedTemplates + .SelectMany(GetParameterNames) + .Distinct(StringComparer.OrdinalIgnoreCase) + .ToArray(); + + foreach (var parsedTemplate in parsedTemplates) { - var template = TemplateParser.ParseTemplate(routeAttribute.Template); - var entry = new RouteEntry(template, type); + var unusedRouteParameterNames = allRouteParameterNames + .Except(GetParameterNames(parsedTemplate), StringComparer.OrdinalIgnoreCase) + .ToArray(); + var entry = new RouteEntry(parsedTemplate, keyValuePair.Key, unusedRouteParameterNames); routes.Add(entry); } } @@ -54,6 +73,14 @@ internal static RouteTable Create(IEnumerable componentTypes) return new RouteTable(routes.OrderBy(id => id, RoutePrecedence).ToArray()); } + private static string[] GetParameterNames(RouteTemplate routeTemplate) + { + return routeTemplate.Segments + .Where(s => s.IsParameter) + .Select(s => s.Value) + .ToArray(); + } + /// /// Route precedence algorithm. /// We collect all the routes and sort them from most specific to diff --git a/src/Components/Components/test/ParameterCollectionAssignmentExtensionsTest.cs b/src/Components/Components/test/ParameterCollectionAssignmentExtensionsTest.cs index 131697490f42..987673ae744a 100644 --- a/src/Components/Components/test/ParameterCollectionAssignmentExtensionsTest.cs +++ b/src/Components/Components/test/ParameterCollectionAssignmentExtensionsTest.cs @@ -358,6 +358,24 @@ public void DeclaredParameterClashesWithInheritedParameter_Throws() ex.Message); } + [Fact] + public void SupplyingNullWritesDefaultForType() + { + // Arrange + var parameterCollection = new ParameterCollectionBuilder + { + { nameof(HasInstanceProperties.IntProp), null }, + { nameof(HasInstanceProperties.StringProp), null }, + }.Build(); + var target = new HasInstanceProperties { IntProp = 123, StringProp = "Hello" }; + + // Act + parameterCollection.SetParameterProperties(target); + + // Assert + Assert.Equal(0, target.IntProp); + Assert.Null(target.StringProp); + } class HasInstanceProperties { diff --git a/src/Components/Components/test/Routing/RouteTableFactoryTests.cs b/src/Components/Components/test/Routing/RouteTableFactoryTests.cs index f6733d8ceb1d..8250fab16bc3 100644 --- a/src/Components/Components/test/Routing/RouteTableFactoryTests.cs +++ b/src/Components/Components/test/Routing/RouteTableFactoryTests.cs @@ -268,8 +268,9 @@ public void PrefersLiteralTemplateOverTemplateWithParameters() { // Arrange var routeTable = new TestRouteTableBuilder() - .AddRoute("/an/awesome/path") - .AddRoute("/{some}/awesome/{route}/").Build(); + .AddRoute("/an/awesome/path", typeof(TestHandler1)) + .AddRoute("/{some}/awesome/{route}/", typeof(TestHandler2)) + .Build(); var context = new RouteContext("/an/awesome/path"); // Act @@ -346,9 +347,58 @@ public void DetectsAmbiguousRoutes(string left, string right) 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(), 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(), route.UnusedRouteParameterNames); + }); + Assert.Same(typeof(TestHandler1), context.Handler); + Assert.Equal(new Dictionary + { + { "param1", 456 }, + { "param2", null }, + }, context.Parameters); + } + private class TestRouteTableBuilder { - IList<(string, Type)> _routeTemplates = new List<(string, Type)>(); + IList<(string Template, Type Handler)> _routeTemplates = new List<(string, Type)>(); Type _handler = typeof(object); public TestRouteTableBuilder AddRoute(string template, Type handler = null) @@ -361,10 +411,10 @@ public RouteTable Build() { try { - return new RouteTable(_routeTemplates - .Select(rt => new RouteEntry(TemplateParser.ParseTemplate(rt.Item1), rt.Item2)) - .OrderBy(id => id, RouteTableFactory.RoutePrecedence) - .ToArray()); + var templatesByHandler = _routeTemplates + .GroupBy(rt => rt.Handler) + .ToDictionary(group => group.Key, group => group.Select(g => g.Template).ToArray()); + return RouteTableFactory.Create(templatesByHandler); } catch (InvalidOperationException ex) when (ex.InnerException is InvalidOperationException) { @@ -373,5 +423,8 @@ public RouteTable Build() } } } + + class TestHandler1 { } + class TestHandler2 { } } } diff --git a/src/Components/test/E2ETest/Tests/RoutingTest.cs b/src/Components/test/E2ETest/Tests/RoutingTest.cs index ce0dc725e69f..8a44d704c71f 100644 --- a/src/Components/test/E2ETest/Tests/RoutingTest.cs +++ b/src/Components/test/E2ETest/Tests/RoutingTest.cs @@ -226,10 +226,6 @@ public void CanFollowLinkToPageWithParameters() AssertHighlightedLinks("With parameters", "With more parameters"); // Can remove parameters while remaining on same page - // WARNING: This only works because the WithParameters component overrides SetParametersAsync - // and explicitly resets its parameters to default when each new set of parameters arrives. - // Without that, the page would retain the old value. - // See https://github.com/aspnet/AspNetCore/issues/6864 where we reverted the logic to auto-reset. app.FindElement(By.LinkText("With parameters")).Click(); Browser.Equal("Your full name is Abc .", () => app.FindElement(By.Id("test-info")).Text); AssertHighlightedLinks("With parameters"); diff --git a/src/Components/test/testassets/BasicTestApp/RouterTest/WithParameters.razor b/src/Components/test/testassets/BasicTestApp/RouterTest/WithParameters.razor index 8b242ccbd296..a353a87c95fc 100644 --- a/src/Components/test/testassets/BasicTestApp/RouterTest/WithParameters.razor +++ b/src/Components/test/testassets/BasicTestApp/RouterTest/WithParameters.razor @@ -8,12 +8,4 @@ [Parameter] public string FirstName { get; set; } [Parameter] public string LastName { get ; set; } - - public override Task SetParametersAsync(ParameterCollection parameters) - { - // Manually reset parameters to defaults so we don't retain any from an earlier URL - FirstName = default; - LastName = default; - return base.SetParametersAsync(parameters); - } } diff --git a/src/Components/test/testassets/ComponentsApp.App/Pages/FetchData.razor b/src/Components/test/testassets/ComponentsApp.App/Pages/FetchData.razor index 239e35cb8c58..fd8493791164 100644 --- a/src/Components/test/testassets/ComponentsApp.App/Pages/FetchData.razor +++ b/src/Components/test/testassets/ComponentsApp.App/Pages/FetchData.razor @@ -34,28 +34,24 @@ else

- + ◀ Previous - + Next ▶

} @code { - [Parameter] public DateTime StartDate { get; set; } + [Parameter] public DateTime? StartDate { get; set; } WeatherForecast[] forecasts; - - public override Task SetParametersAsync(ParameterCollection parameters) - { - StartDate = DateTime.Now; - return base.SetParametersAsync(parameters); - } + DateTime startDate; protected override async Task OnParametersSetAsync() { - forecasts = await ForecastService.GetForecastAsync(StartDate); + startDate = StartDate.GetValueOrDefault(DateTime.Now); + forecasts = await ForecastService.GetForecastAsync(startDate); } }