Skip to content

Conversation

@ilonatommy
Copy link
Member

@ilonatommy ilonatommy commented Dec 5, 2025

Summary

This change implements the PathRelative parameter for Blazor's NavigationManager.NavigateTo() and NavLink component, allowing developers to navigate to URIs relative to the current page path rather than the application's base URI.

Background

Previously, Blazor only supported navigation relative to the application's base URI. For example, calling NavigateTo("sibling.html") from /folder1/page.html would navigate to /sibling.html (at the app root), not /folder1/sibling.html. The same limitation affected NavLink components in SSR scenarios.

Fixes #23615

Changes

Public API

namespace Microsoft.AspNetCore.Components;

public partial struct NavigationOptions
{
    public bool PathRelative { get; init; }
}

public class NavLink : ComponentBase
{
    [Parameter] public bool PathRelative { get; set; }
}

Implementation Details

NavigationManager: When PathRelative is true, NavigateTo() calls ResolveRelativeToCurrentPath() to compute the absolute URI using zero-allocation span-based operations before passing it to NavigateToCore().

NavLink: When PathRelative is true, OnParametersSet() resolves the relative href server-side during rendering, ensuring the anchor tag contains the correct absolute path for SSR scenarios.

Performance: The implementation avoids creating Uri objects, using only ReadOnlySpan<char> operations and string.Concat() for efficiency.

Tests

Added 7 NavigationManager tests and 6 NavLink tests covering path-relative navigation, query/fragment handling, nested paths, and edge cases.

Usage

NavigationManager:

// Navigate to a sibling page
NavigationManager.NavigateTo("details.html", new NavigationOptions { PathRelative = true });
@inject NavigationManager NavManager

<button @onclick='() => NavManager.NavigateTo("details.html", new NavigationOptions { PathRelative = true })'>
    View Details
</button>

NavLink:

<NavLink href="details" PathRelative="true">View Details</NavLink>

@ilonatommy ilonatommy added this to the .NET 11 Planning milestone Dec 5, 2025
@ilonatommy ilonatommy self-assigned this Dec 5, 2025
Copilot AI review requested due to automatic review settings December 5, 2025 15:27
@ilonatommy ilonatommy requested a review from a team as a code owner December 5, 2025 15:27
@ilonatommy ilonatommy added the area-blazor Includes: Blazor, Razor Components label Dec 5, 2025
Copilot finished reviewing on behalf of ilonatommy December 5, 2025 15:30
Copy link
Contributor

Copilot AI left a comment

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Pull request overview

This PR adds a PathRelative navigation option to Blazor's NavigationManager, enabling developers to navigate to URIs relative to the current page's directory path rather than the application's base URI. This addresses a common pain point where navigating to sibling pages required manual path resolution.

Key Changes:

  • Adds NavigationOptions.PathRelative property for path-relative navigation
  • Implements ResolveRelativeToCurrentPath method using zero-allocation span-based operations
  • Adds TypeScript interface definition for JavaScript interop
  • Includes 7 unit tests covering basic scenarios and edge cases

Reviewed changes

Copilot reviewed 8 out of 8 changed files in this pull request and generated 4 comments.

Show a summary per file
File Description
src/Components/Components/src/NavigationOptions.cs Adds PathRelative boolean property to NavigationOptions struct with XML documentation
src/Components/Components/src/NavigationManager.cs Implements path-relative resolution logic in NavigateTo and new internal ResolveRelativeToCurrentPath method
src/Components/Components/src/PublicAPI.Unshipped.txt Registers the new public API property and its init accessor
src/Components/Components/test/NavigationManagerTest.cs Adds unit tests for path-relative navigation scenarios and helper class for tracking navigations
src/Components/Web.JS/src/Services/NavigationManager.ts Adds optional pathRelative field to TypeScript NavigationOptions interface

Comment on lines +174 to +190
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());
Copy link

Copilot AI Dec 5, 2025

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

The implementation of PathRelative navigation doesn't handle several important relative path patterns that developers might reasonably expect to work:

  1. Absolute paths (e.g., "/about.html") - These start with / and should navigate to the root, but currently would be concatenated to the current directory path
  2. Parent directory navigation (e.g., "../sibling.html") - The ../ pattern to navigate up directories is not handled
  3. Current directory references (e.g., "./page.html") - The ./ prefix is not explicitly handled

Consider either:

  • Documenting these limitations explicitly in the XML comments for PathRelative property
  • Or implementing proper relative URI resolution using standard URI path resolution rules (which would handle ../ , /, and ./ correctly)

Standard URI path resolution is typically done using new Uri(baseUri, relativeUri) which automatically handles these cases. If you want to avoid allocating Uri objects for performance, you could implement the standard path resolution algorithm manually using spans.

Suggested change
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());
// Use standard URI resolution to handle absolute paths, ../, ./, etc.
// _uri is the current absolute URI (including scheme, host, path, query, fragment)
var baseUri = new Uri(_uri!, UriKind.Absolute);
var resolvedUri = new Uri(baseUri, relativeUri);
return resolvedUri.ToString();

Copilot uses AI. Check for mistakes.
Comment on lines +890 to +989
[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);
}

Copy link

Copilot AI Dec 5, 2025

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Test coverage is missing for important edge cases in path-relative navigation:

  1. Absolute paths: What happens when navigating with PathRelative = true to "/about.html"? Currently this would incorrectly concatenate to the current path.
  2. Parent directory navigation: What happens with "../parent.html" or "../../grandparent.html"?
  3. Current directory references: What happens with "./sibling.html"?
  4. Current URI ending in directory: What happens when current URI is "scheme://host/folder/" (ends with slash) instead of a file?
  5. Fragment-only navigation: What happens with "#section"?
  6. Query-only navigation: What happens with "?param=value"?

These test cases are critical to validate the behavior and ensure developers understand what patterns are supported by this feature.

Copilot uses AI. Check for mistakes.
Comment on lines +971 to +989
[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);
}

Copy link

Copilot AI Dec 5, 2025

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Using reflection to directly test the internal ResolveRelativeToCurrentPath method and manipulate internal state (_uri field) creates a brittle test that:

  1. Depends on internal implementation details that could change
  2. Tests an edge case ("invaliduri" with no slashes) that cannot occur in real usage since Initialize() validates that URIs are absolute
  3. Would break if the internal field name or accessibility changes

Consider instead testing this edge case behavior through the public NavigateTo API, or remove this test entirely since the "no slash found" case is defensive programming for an impossible state in production code.

Suggested change
[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);
}

Copilot uses AI. Check for mistakes.
@javiercn
Copy link
Member

javiercn commented Dec 5, 2025

I don't think we should take this change.

This change implements the PathRelative parameter for Blazor's NavigationManager.NavigateTo() and NavLink component, allowing developers to navigate to URIs relative to the current page path rather than the application's base URI.

This is not a thing in the web. The original issue refers to users hosting inside MVC and not adding a base to the document, which causes relative links to be resolved relative to the current page.

This change doesn't address that and introduces a way of composing a path that doesn't make sense. There is a way of creating relative URLs which is using ../ to traverse upwards and ./ to traverse from the current URL.

Absolute URL means a complete URI with scheme and authority (for example https://example.com/page) and is unambiguous. Absolute‑path references (often called “root‑relative” in casual usage) begin with / and are not full URIs by themselves; they are resolved to an absolute URL using the page’s origin or the document base. Relative‑path references (no leading slash) are resolved against the document’s directory or the <base> href.

  • Absolute URL — includes scheme and host; independent of document location (e.g., https://cdn.example.com/a.png).
  • Absolute‑path reference — begins with /; describes a path from the origin root but lacks scheme/authority until resolved. People often call this “root‑relative,” but technically it’s a relative reference with an absolute‑path form.
  • Relative‑path reference — a path like images/pic.jpg or ../page.html resolved relative to the document’s path.
    These distinctions matter because the path component of the document URL (its directory) is what the browser uses to resolve non‑leading‑slash relative references.

When a browser sees a non‑absolute string it runs the URL resolution algorithm: if a <base href> exists, that base is used; otherwise the document’s URL (its origin and path) is used. A relative path without / is appended to the document directory; a leading / replaces the path with the absolute path on the base/origin before resolving to a full URL. The URL API and browser implementations follow this algorithm to produce the final absolute URL.

Relationship of <base> to the path

The <base href="..."> overrides the document location for resolving all relative references (images, scripts, anchors, styles) so that /foo and foo are interpreted relative to that base’s origin and path rather than the page file’s URL. That means adding <base> can change how / maps to an origin and can make previously working resource paths break if not adjusted.


Practical guidance and gotchas

  • Use full absolute URLs for external resources and when you need an origin‑independent link.
  • Use relative or absolute‑path references for internal links to keep portability, but audit when adding <base> because it affects every relative reference on the page.
  • Test after adding <base>: scripts, CSS, and images can silently fail if their resolved paths change.

@ilonatommy
Copy link
Member Author

ilonatommy commented Dec 5, 2025

Let's discuss it a bit more.

This is not a thing in the web.

I was inspired by this API: https://angular.dev/guide/routing/navigate-to-routes#routernavigate. Blazor would not be a pioneer in allowing relativePath param.

The original issue refers to users hosting inside MVC and not adding a base to the document, which causes relative links to be resolved relative to the current page.

I don't believe so, the original issue is about blazor treating nested paths not as dirs but like a route, so not recognizing that in /a/b/c -> c is a file in b, as browser would but treating it whole a/b/c as "file name/route" under the base. The original request stands for both: base: "/" and base: "/a". In both situations we would like to have a way to navigate to /a/b/d from /a/b/c, passing just d to the navigation component/method.

What do you think?

@ilonatommy ilonatommy changed the title API proposal for NavigateTo with relative path API proposal for NavigateTo and NavLink with relative path Dec 5, 2025
Sign up for free to join this conversation on GitHub. Already have an account? Sign in to comment

Labels

area-blazor Includes: Blazor, Razor Components

Projects

None yet

Development

Successfully merging this pull request may close these issues.

Blazor Relative URLs in components / Sub-navigation issues

2 participants