Skip to content
Merged
Show file tree
Hide file tree
Changes from 3 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
27 changes: 27 additions & 0 deletions src/Components/Components/src/NavigationManager.cs
Original file line number Diff line number Diff line change
Expand Up @@ -160,9 +160,36 @@ public void NavigateTo([StringSyntax(StringSyntaxAttribute.Uri)] string uri, boo
public void NavigateTo([StringSyntax(StringSyntaxAttribute.Uri)] string uri, NavigationOptions options)
{
AssertInitialized();

if (options.PathRelative)
{
uri = ResolveRelativeToCurrentPath(uri);
}

NavigateToCore(uri, options);
}

internal string ResolveRelativeToCurrentPath(string relativeUri)
{
var currentUri = _uri!.AsSpan();

// Find the last slash in the path portion (before any query or fragment)
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());
Comment thread
ilonatommy marked this conversation as resolved.
}
Comment thread
ilonatommy marked this conversation as resolved.

/// <summary>
/// Navigates to the specified URI.
/// </summary>
Expand Down
6 changes: 6 additions & 0 deletions src/Components/Components/src/NavigationOptions.cs
Original file line number Diff line number Diff line change
Expand Up @@ -23,4 +23,10 @@ public readonly struct NavigationOptions
/// Gets or sets the state to append to the history entry.
/// </summary>
public string? HistoryEntryState { get; init; }

/// <summary>
/// If true, resolves relative URIs relative to the current path instead of the base URI.
/// If false (default), resolves relative URIs relative to the base URI.
/// </summary>
public bool PathRelative { get; init; }
}
2 changes: 2 additions & 0 deletions src/Components/Components/src/PublicAPI.Unshipped.txt
Original file line number Diff line number Diff line change
@@ -1 +1,3 @@
#nullable enable
Microsoft.AspNetCore.Components.NavigationOptions.PathRelative.get -> bool
Microsoft.AspNetCore.Components.NavigationOptions.PathRelative.init -> void
117 changes: 116 additions & 1 deletion src/Components/Components/test/NavigationManagerTest.cs
Original file line number Diff line number Diff line change
Expand Up @@ -886,7 +886,107 @@ public void OnNotFoundSubscriptionIsTriggeredWhenNotFoundCalled()
// Assert
Assert.True(notFoundTriggered, "The OnNotFound event was not triggered as expected.");
}


[Fact]
public void NavigateTo_WithPathRelative_ResolvesRelativeToCurrentPath()
{
var baseUri = "scheme://host/";
var currentUri = "scheme://host/folder1/folder2/page.html";
var testNavManager = new TestNavigationManagerWithNavigationTracking(baseUri, currentUri);

testNavManager.NavigateTo("sibling.html", new NavigationOptions { PathRelative = true });

Assert.Single(testNavManager.Navigations);
Assert.Equal("scheme://host/folder1/folder2/sibling.html", testNavManager.Navigations[0].uri);
Assert.True(testNavManager.Navigations[0].options.PathRelative);
}

[Fact]
public void NavigateTo_WithPathRelative_HandlesQueryAndFragmentInCurrentUri()
{
var baseUri = "scheme://host/";
var currentUri = "scheme://host/folder1/page.html?query=value#hash";
var testNavManager = new TestNavigationManagerWithNavigationTracking(baseUri, currentUri);

testNavManager.NavigateTo("other.html", new NavigationOptions { PathRelative = true });

Assert.Single(testNavManager.Navigations);
Assert.Equal("scheme://host/folder1/other.html", testNavManager.Navigations[0].uri);
}

[Fact]
public void NavigateTo_WithPathRelativeFalse_DoesNotResolve()
{
var baseUri = "scheme://host/base/";
var currentUri = "scheme://host/base/folder1/page.html";
var testNavManager = new TestNavigationManagerWithNavigationTracking(baseUri, currentUri);

testNavManager.NavigateTo("relative.html", new NavigationOptions { PathRelative = false });

Assert.Single(testNavManager.Navigations);
// When PathRelative is false, the URI is passed directly to NavigateToCore without resolution
Assert.Equal("relative.html", testNavManager.Navigations[0].uri);
Assert.False(testNavManager.Navigations[0].options.PathRelative);
}

[Fact]
public void NavigateTo_WithPathRelative_AtRootLevel()
{
var baseUri = "scheme://host/";
var currentUri = "scheme://host/page.html";
var testNavManager = new TestNavigationManagerWithNavigationTracking(baseUri, currentUri);

testNavManager.NavigateTo("other.html", new NavigationOptions { PathRelative = true });

Assert.Single(testNavManager.Navigations);
Assert.Equal("scheme://host/other.html", testNavManager.Navigations[0].uri);
}

[Fact]
public void NavigateTo_WithPathRelative_NestedPaths()
{
var baseUri = "scheme://host/";
var currentUri = "scheme://host/a/b/c/d/page.html";
var testNavManager = new TestNavigationManagerWithNavigationTracking(baseUri, currentUri);

testNavManager.NavigateTo("sibling.html", new NavigationOptions { PathRelative = true });

Assert.Single(testNavManager.Navigations);
Assert.Equal("scheme://host/a/b/c/d/sibling.html", testNavManager.Navigations[0].uri);
}

[Fact]
public void NavigateTo_WithPathRelative_WithQueryStringPreservesPath()
{
var baseUri = "scheme://host/";
var currentUri = "scheme://host/folder/page.html?param=value";
var testNavManager = new TestNavigationManagerWithNavigationTracking(baseUri, currentUri);

testNavManager.NavigateTo("other.html?new=param", new NavigationOptions { PathRelative = true });

Assert.Single(testNavManager.Navigations);
Assert.Equal("scheme://host/folder/other.html?new=param", testNavManager.Navigations[0].uri);
}

[Fact]
public void ResolveRelativeToCurrentPath_NoSlashFound_EdgeCase()
{
// This tests the defensive edge case where no slash is found in the URI
// We use reflection to set _uri to an invalid value (bypassing validation)
var baseUri = "scheme://host/";
var testNavManager = new TestNavigationManager(baseUri, "scheme://host/page.html");

// Use reflection to set _uri to an invalid value that has no slash
var uriField = typeof(NavigationManager).GetField("_uri", System.Reflection.BindingFlags.NonPublic | System.Reflection.BindingFlags.Instance);
uriField.SetValue(testNavManager, "invaliduri");

// Call the internal method directly (accessible because of InternalsVisibleTo)
var result = testNavManager.ResolveRelativeToCurrentPath("page.html");

// When no slash is found, it concatenates to the current URI
Assert.Equal("invaliduripage.html", result);
}

Comment thread
ilonatommy marked this conversation as resolved.
Comment thread
ilonatommy marked this conversation as resolved.
private class TestNavigationManager : NavigationManager
{
public TestNavigationManager()
Expand Down Expand Up @@ -916,6 +1016,21 @@ protected override void SetNavigationLockState(bool value)
}
}

private class TestNavigationManagerWithNavigationTracking : TestNavigationManager
{
public List<(string uri, NavigationOptions options)> Navigations { get; } = new();

public TestNavigationManagerWithNavigationTracking(string baseUri = null, string uri = null)
: base(baseUri, uri)
{
}

protected override void NavigateToCore(string uri, NavigationOptions options)
{
Navigations.Add((uri, options));
}
}

private class TestNavigationManagerWithLocationChangingExceptionTracking : TestNavigationManager
{
private readonly List<Exception> _exceptionsThrownFromLocationChangingHandlers = new();
Expand Down
1 change: 1 addition & 0 deletions src/Components/Web.JS/src/Services/NavigationManager.ts
Original file line number Diff line number Diff line change
Expand Up @@ -291,4 +291,5 @@ export interface NavigationOptions {
forceLoad: boolean;
replaceHistoryEntry: boolean;
historyEntryState?: string;
pathRelative?: boolean;
}
2 changes: 2 additions & 0 deletions src/Components/Web/src/PublicAPI.Unshipped.txt
Original file line number Diff line number Diff line change
@@ -1,4 +1,6 @@
#nullable enable
Microsoft.AspNetCore.Components.Routing.NavLink.PathRelative.get -> bool
Microsoft.AspNetCore.Components.Routing.NavLink.PathRelative.set -> void
Microsoft.AspNetCore.Components.Web.Media.FileDownload
Microsoft.AspNetCore.Components.Web.Media.FileDownload.ChildContent.get -> Microsoft.AspNetCore.Components.RenderFragment<Microsoft.AspNetCore.Components.Web.Media.FileDownloadContext!>?
Microsoft.AspNetCore.Components.Web.Media.FileDownload.ChildContent.set -> void
Expand Down
25 changes: 22 additions & 3 deletions src/Components/Web/src/Routing/NavLink.cs
Original file line number Diff line number Diff line change
Expand Up @@ -19,6 +19,7 @@ public class NavLink : ComponentBase, IDisposable

private bool _isActive;
private string? _hrefAbsolute;
private string? _hrefToRender;
private string? _class;

/// <summary>
Expand Down Expand Up @@ -52,6 +53,13 @@ public class NavLink : ComponentBase, IDisposable
[Parameter]
public NavLinkMatch Match { get; set; }

/// <summary>
Comment thread
ilonatommy marked this conversation as resolved.
/// Gets or sets whether the href should be resolved relative to the current path.
/// When true, the href is treated as relative to the current route path.
/// </summary>
[Parameter]
public bool PathRelative { get; set; }

[Inject] private NavigationManager NavigationManager { get; set; } = default!;

/// <inheritdoc />
Expand All @@ -71,7 +79,14 @@ protected override void OnParametersSet()
href = Convert.ToString(obj, CultureInfo.InvariantCulture);
}

// Resolve relative path if PathRelative is true
if (PathRelative && href != null)
{
href = NavigationManager.ResolveRelativeToCurrentPath(href);
}

_hrefAbsolute = href == null ? null : NavigationManager.ToAbsoluteUri(href).AbsoluteUri;
_hrefToRender = _hrefAbsolute;
_isActive = ShouldMatch(NavigationManager.Uri);

_class = (string?)null;
Expand Down Expand Up @@ -214,12 +229,16 @@ protected override void BuildRenderTree(RenderTreeBuilder builder)
builder.OpenElement(0, "a");

builder.AddMultipleAttributes(1, AdditionalAttributes);
builder.AddAttribute(2, "class", CssClass);
if (_hrefToRender != null)
{
builder.AddAttribute(2, "href", _hrefToRender);
}
builder.AddAttribute(3, "class", CssClass);
if (_isActive)
{
builder.AddAttribute(3, "aria-current", "page");
builder.AddAttribute(4, "aria-current", "page");
}
builder.AddContent(4, ChildContent);
builder.AddContent(5, ChildContent);

builder.CloseElement();
}
Expand Down
Loading
Loading