Skip to content
Merged
Show file tree
Hide file tree
Changes from 9 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
48 changes: 48 additions & 0 deletions src/Components/Components/src/NavigationManager.cs
Original file line number Diff line number Diff line change
Expand Up @@ -160,9 +160,57 @@ 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)
{
if (IsAbsoluteUri(relativeUri))
{
throw new ArgumentException(
$"The URI '{relativeUri}' is not a relative URI. When PathRelative is true, the URI must be relative (e.g., 'page.html', 'folder/page', '../other').",
nameof(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.

private static bool IsAbsoluteUri(string uri)
{
if (uri.StartsWith('/'))
{
return true;
}

var span = uri.AsSpan();
var queryOrFragmentIndex = span.IndexOfAny('?', '#');
var pathPortion = queryOrFragmentIndex >= 0 ? span[..queryOrFragmentIndex] : span;

return pathPortion.Contains("://".AsSpan(), StringComparison.Ordinal);
}
Comment thread
ilonatommy marked this conversation as resolved.
Outdated

/// <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,4 +1,6 @@
#nullable enable
Microsoft.AspNetCore.Components.NavigationOptions.PathRelative.get -> bool
Microsoft.AspNetCore.Components.NavigationOptions.PathRelative.init -> void
Microsoft.AspNetCore.Components.IComponentPropertyActivator
Microsoft.AspNetCore.Components.IComponentPropertyActivator.GetActivator(System.Type! componentType) -> System.Action<System.IServiceProvider!, Microsoft.AspNetCore.Components.IComponent!>!
*REMOVED*Microsoft.AspNetCore.Components.ResourceAsset.ResourceAsset(string! url, System.Collections.Generic.IReadOnlyList<Microsoft.AspNetCore.Components.ResourceAssetProperty!>? properties) -> void
Expand Down
222 changes: 221 additions & 1 deletion src/Components/Components/test/NavigationManagerTest.cs
Original file line number Diff line number Diff line change
Expand Up @@ -886,7 +886,212 @@ 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 NavigateTo_WithPathRelative_CurrentUriEndsWithSlash()
{
// When current URI is a directory (ends with slash), the relative path
// should be appended to that directory
var baseUri = "scheme://host/";
var currentUri = "scheme://host/folder/";
var testNavManager = new TestNavigationManagerWithNavigationTracking(baseUri, currentUri);

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

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

[Fact]
public void NavigateTo_WithPathRelative_FragmentOnly()
{
// Fragment-only navigation should append to the current directory
var baseUri = "scheme://host/";
var currentUri = "scheme://host/folder/page.html";
var testNavManager = new TestNavigationManagerWithNavigationTracking(baseUri, currentUri);

testNavManager.NavigateTo("#section", new NavigationOptions { PathRelative = true });

Assert.Single(testNavManager.Navigations);
Assert.Equal("scheme://host/folder/#section", testNavManager.Navigations[0].uri);
}
Comment thread
ilonatommy marked this conversation as resolved.
Outdated

[Fact]
public void NavigateTo_WithPathRelative_QueryOnly()
{
// Query-only navigation should append to the current directory
var baseUri = "scheme://host/";
var currentUri = "scheme://host/folder/page.html";
var testNavManager = new TestNavigationManagerWithNavigationTracking(baseUri, currentUri);

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

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

[Fact]
public void NavigateTo_WithPathRelative_ParentDirectory()
{
// Verify that ../ produces the expected (non-normalized) result that browsers will normalize
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);
// The result contains ../ which browsers normalize to /folder1/sibling.html
Assert.Equal("scheme://host/folder1/folder2/../sibling.html", testNavManager.Navigations[0].uri);
}

[Fact]
public void NavigateTo_WithPathRelative_CurrentDirectory()
{
// Verify that ./ produces the expected (non-normalized) result that browsers will normalize
var baseUri = "scheme://host/";
var currentUri = "scheme://host/folder/page.html";
var testNavManager = new TestNavigationManagerWithNavigationTracking(baseUri, currentUri);

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

Assert.Single(testNavManager.Navigations);
// The result contains ./ which browsers normalize to /folder/sibling.html
Assert.Equal("scheme://host/folder/./sibling.html", testNavManager.Navigations[0].uri);
}

[Theory]
[InlineData("https://evil.com/malware")]
[InlineData("http://example.com/page")]
[InlineData("ftp://files.example.com/file.txt")]
[InlineData("/absolute-path")]
[InlineData("/folder/page.html")]
[InlineData("//cdn.example.com/script.js")]
[InlineData("scheme://host/other-page")]
public void NavigateTo_WithPathRelative_ThrowsForAbsoluteUri(string absoluteUri)
{
var baseUri = "scheme://host/";
var currentUri = "scheme://host/folder/page.html";
var testNavManager = new TestNavigationManagerWithNavigationTracking(baseUri, currentUri);

var ex = Assert.Throws<ArgumentException>(() =>
testNavManager.NavigateTo(absoluteUri, new NavigationOptions { PathRelative = true }));

Assert.Contains("is not a relative URI", ex.Message);
Assert.Contains("PathRelative", ex.Message);
}

[Theory]
[InlineData("sibling.html")]
[InlineData("folder/page.html")]
[InlineData("../parent.html")]
[InlineData("./current.html")]
[InlineData("path/to/file.html")]
[InlineData("folder/file:with:colons.html")] // Colons after path segment separator are OK
[InlineData("page.html?query=http://example.com")] // Absolute URI in query string is OK
public void NavigateTo_WithPathRelative_AcceptsRelativeUri(string relativeUri)
{
var baseUri = "scheme://host/";
var currentUri = "scheme://host/folder/page.html";
var testNavManager = new TestNavigationManagerWithNavigationTracking(baseUri, currentUri);

// Should not throw
testNavManager.NavigateTo(relativeUri, new NavigationOptions { PathRelative = true });

Assert.Single(testNavManager.Navigations);
}

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

Assert.Throws<NullReferenceException>(() =>
testNavManager.ResolveRelativeToCurrentPath(null!));
}

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 +1121,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 @@ -309,4 +309,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
*REMOVED*Microsoft.AspNetCore.Components.Forms.RemoteBrowserFileStreamOptions
*REMOVED*Microsoft.AspNetCore.Components.Forms.RemoteBrowserFileStreamOptions.MaxBufferSize.get -> int
*REMOVED*Microsoft.AspNetCore.Components.Forms.RemoteBrowserFileStreamOptions.MaxBufferSize.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 = href;
_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