Skip to content
Closed
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
127 changes: 125 additions & 2 deletions src/Components/Components/src/NavigationManager.cs
Original file line number Diff line number Diff line change
Expand Up @@ -2,7 +2,11 @@
// The .NET Foundation licenses this file to you under the MIT license.

using System.Buffers;
using System.Collections.Concurrent;
using System.Diagnostics.CodeAnalysis;
using System.Linq;
using System.Reflection;
using System.Text;
using Microsoft.AspNetCore.Components.Routing;

namespace Microsoft.AspNetCore.Components;
Expand Down Expand Up @@ -206,14 +210,14 @@ internal string ResolveRelativeToCurrentPath(string relativeUri)
var queryOrFragmentIndex = currentUri.IndexOfAny('?', '#');
var pathOnlyLength = queryOrFragmentIndex >= 0 ? queryOrFragmentIndex : currentUri.Length;
var lastSlashIndex = currentUri[..pathOnlyLength].LastIndexOf('/');

if (lastSlashIndex < 0)
{
// No slash found - this shouldn't happen for valid absolute URIs
// In this edge case, just append to the current URI
return string.Concat(_uri, relativeUri);
}

// Keep everything up to and including the last slash, then append the relative URI
var basePathLength = lastSlashIndex + 1;
return string.Concat(currentUri[..basePathLength], relativeUri.AsSpan());
Expand Down Expand Up @@ -640,6 +644,125 @@ private static bool TryGetLengthOfBaseUriPrefix(Uri baseUri, string uri, out int
return false;
}

// Cache for component route templates to avoid repeated reflection
private static readonly ConcurrentDictionary<Type, string[]> _componentRouteTemplates = new();

/// <summary>
/// Gets the URI for the specified component type with optional route parameters.
/// </summary>
/// <typeparam name="TComponent">The component type to navigate to.</typeparam>
/// <param name="parameters">The route parameters to include in the URI.</param>
/// <returns>The URI for the specified component.</returns>
/// <exception cref="InvalidOperationException">Thrown when the component type does not have a route attribute.</exception>
public string GetUri<TComponent>(params object[] parameters)
{
var componentType = typeof(TComponent);
var routeTemplates = GetComponentRouteTemplates(componentType);

if (routeTemplates.Length == 0)
{
throw new InvalidOperationException($"Component '{componentType.FullName}' does not have a Route attribute.");
}

// For now, use the first route template (future enhancement: allow specifying which route to use)
var routeTemplate = routeTemplates[0];
return BindParametersToRouteTemplate(routeTemplate, parameters);
}

/// <summary>
/// Navigates to the specified component type with optional route parameters.
/// </summary>
/// <typeparam name="TComponent">The component type to navigate to.</typeparam>
/// <param name="parameters">The route parameters to include in the URI.</param>
/// <exception cref="InvalidOperationException">Thrown when the component type does not have a route attribute.</exception>
public void NavigateTo<TComponent>(params object[] parameters)
{
var uri = GetUri<TComponent>(parameters);
NavigateTo(uri);
}

/// <summary>
/// Gets the route templates for the specified component type.
/// </summary>
/// <param name="componentType">The component type.</param>
/// <returns>An array of route templates.</returns>
private static string[] GetComponentRouteTemplates(Type componentType)
{
return _componentRouteTemplates.GetOrAdd(componentType, type =>
{
var routeAttributes = type.GetCustomAttributes<RouteAttribute>(inherit: false);
var routeAttributesArray = routeAttributes as RouteAttribute[] ?? routeAttributes.ToArray();
if (routeAttributesArray.Length == 0)
{
return Array.Empty<string>();
}

var templates = new string[routeAttributesArray.Length];
for (var i = 0; i < routeAttributesArray.Length; i++)
{
templates[i] = routeAttributesArray[i].Template;
}

return templates;
});
}

/// <summary>
/// Binds parameters to a route template.
/// </summary>
/// <param name="routeTemplate">The route template.</param>
/// <param name="parameters">The parameters to bind.</param>
/// <returns>The route template with parameters bound.</returns>
private static string BindParametersToRouteTemplate(string routeTemplate, object[] parameters)
{
if (parameters == null || parameters.Length == 0)
{
return routeTemplate;
}

var sb = new StringBuilder(capacity: routeTemplate.Length + parameters.Length * 8);
var pos = 0;
var paramIndex = 0;

while (pos < routeTemplate.Length)
{
var open = routeTemplate.IndexOf('{', pos);
if (open < 0)
{
// No more placeholders
sb.Append(routeTemplate.AsSpan(pos));
break;
}

// Append literal text before the placeholder
sb.Append(routeTemplate.AsSpan(pos, open - pos));

var close = routeTemplate.IndexOf('}', open + 1);
if (close < 0)
{
// Malformed template; append rest and break
sb.Append(routeTemplate.AsSpan(open));
break;
}

// If no more parameter values provided, leave placeholder as-is
if (paramIndex >= parameters.Length)
{
sb.Append(routeTemplate.AsSpan(open, close - open + 1));
}
else
{
var parameterValue = parameters[paramIndex]?.ToString() ?? string.Empty;
sb.Append(parameterValue);
paramIndex++;
}

pos = close + 1;
}

return sb.ToString();
}

private static void Validate(Uri? baseUri, string uri)
{
if (baseUri == null || uri == null)
Expand Down
2 changes: 2 additions & 0 deletions src/Components/Components/src/PublicAPI.Unshipped.txt
Original file line number Diff line number Diff line change
Expand Up @@ -5,6 +5,8 @@ Microsoft.AspNetCore.Components.CascadingParameterSubscription
Microsoft.AspNetCore.Components.CascadingParameterSubscription.CascadingParameterSubscription() -> void
Microsoft.AspNetCore.Components.Rendering.RenderTreeBuilder.SetAttributeValue(int frameIndex, object? value) -> void
static Microsoft.AspNetCore.Components.NavigationManagerExtensions.GetUriWithHash(this Microsoft.AspNetCore.Components.NavigationManager! navigationManager, string! hash) -> string!
Microsoft.AspNetCore.Components.NavigationManager.GetUri<TComponent>(params object![]! parameters) -> string!
Microsoft.AspNetCore.Components.NavigationManager.NavigateTo<TComponent>(params object![]! parameters) -> void
Microsoft.AspNetCore.Components.NavigationOptions.RelativeToCurrentUri.get -> bool
Microsoft.AspNetCore.Components.NavigationOptions.RelativeToCurrentUri.init -> void
Microsoft.AspNetCore.Components.IComponentPropertyActivator
Expand Down
94 changes: 93 additions & 1 deletion src/Components/Components/test/NavigationManagerTest.cs
Original file line number Diff line number Diff line change
Expand Up @@ -1109,6 +1109,98 @@ public void ResolveRelativeToCurrentPath_ThrowsForNullUri()
testNavManager.ResolveRelativeToCurrentPath(null!));
}

// Type-safe navigation tests (valid when type-safe APIs from the PR are present)
[Fact]
public void GetUri_Type_NoParameters_ReturnsExpectedAbsoluteUri()
{
var nav = new TestNavigationManager("http://example.com/");

var uri = nav.GetUri<ListSampleComponent>();

Assert.Equal("/list", uri);
}

[Fact]
public void GetUri_Type_SingleParameter_ReturnsExpectedAbsoluteUri()
{
var nav = new TestNavigationManager("http://example.com/");

var uri = nav.GetUri<UserSampleComponent>("123");

Assert.Equal("/user/123", uri);
}

[Fact]
public void GetUri_Type_MultipleParameters_ReturnsExpectedAbsoluteUri()
{
var nav = new TestNavigationManager("http://example.com/");

var uri = nav.GetUri<ProductSampleComponent>("p1", "books");

Assert.Equal("/product/p1/books", uri);
}

[Fact]
public void NavigateTo_Generic_InvokesNavigateToCore_WithResolvedUri()
{
var nav = new TestNavigationManagerWithNavigationTracking("http://example.com/");

nav.NavigateTo<ListSampleComponent>();

Assert.Single(nav.Navigations);
Assert.Equal("/list", nav.Navigations[0].uri);
}

[Fact]
public void NavigateTo_Generic_WithParameters_InvokesNavigateToCore_WithResolvedUri()
{
var nav = new TestNavigationManagerWithNavigationTracking("http://example.com/");

nav.NavigateTo<UserSampleComponent>("123");

Assert.Single(nav.Navigations);
Assert.Equal("/user/123", nav.Navigations[0].uri);
}

[Fact]
public void GetUri_WithoutRoute_ThrowsInvalidOperationException()
{
var nav = new TestNavigationManager("http://example.com/");

var ex = Assert.Throws<InvalidOperationException>(() => nav.GetUri<NoRouteComponent>());

Assert.Contains("route", ex.Message, StringComparison.OrdinalIgnoreCase);
}

// Sample component types used for type-safe navigation tests
[Route("/list")]
private class ListSampleComponent : IComponent
{
public void Attach(RenderHandle renderHandle) { }
public Task SetParametersAsync(ParameterView parameters) => Task.CompletedTask;
}

[Route("/user/{userId}")]
private class UserSampleComponent : IComponent
{
public void Attach(RenderHandle renderHandle) { }
public Task SetParametersAsync(ParameterView parameters) => Task.CompletedTask;
}

[Route("/product/{productId}/{category}")]
private class ProductSampleComponent : IComponent
{
public void Attach(RenderHandle renderHandle) { }
public Task SetParametersAsync(ParameterView parameters) => Task.CompletedTask;
}

// Component with no route attribute to assert negative behavior
private class NoRouteComponent : IComponent
{
public void Attach(RenderHandle renderHandle) { }
public Task SetParametersAsync(ParameterView parameters) => Task.CompletedTask;
}

private class TestNavigationManager : NavigationManager
{
public TestNavigationManager()
Expand All @@ -1130,7 +1222,7 @@ public async Task<bool> RunNotifyLocationChangingAsync(string uri, string state,

protected override void NavigateToCore(string uri, bool forceLoad)
{
throw new System.NotImplementedException();
base.NavigateToCore(uri, forceLoad);
}

protected override void SetNavigationLockState(bool value)
Expand Down
Loading