Clean up use of relative URLs. Fixes #844 and #845 (#878)

* Handle links to empty-string href, resolved against base href

Needed to change the URLs used in E2E tests to be able to cover this (i.e., removed the /RouterTest prefixes so the default relative URL became an empty string)

* Change links in StandaloneApp sample to be relative

* Standardize on base URIs having trailing slash everywhere

Hence also change terminology from "base URI prefix" to simply "base URI"

* Handle link highlighting when visiting base-href-but-without-trailing-slash

* Removing leading slashes from base-relative URLs in templates
This commit is contained in:
Steve Sanderson 2018-05-24 09:54:43 +01:00 committed by GitHub
parent 7f3ba97fb7
commit 353da42cce
19 changed files with 193 additions and 146 deletions

View File

@ -34,10 +34,10 @@ else
</tbody> </tbody>
</table> </table>
<p> <p>
<a href="/fetchdata/@StartDate.AddDays(-5).ToString("yyyy-MM-dd")" class="btn btn-secondary float-left"> <a href="fetchdata/@StartDate.AddDays(-5).ToString("yyyy-MM-dd")" class="btn btn-secondary float-left">
◀ Previous ◀ Previous
</a> </a>
<a href="/fetchdata/@StartDate.AddDays(5).ToString("yyyy-MM-dd")" class="btn btn-secondary float-right"> <a href="fetchdata/@StartDate.AddDays(5).ToString("yyyy-MM-dd")" class="btn btn-secondary float-right">
Next ▶ Next ▶
</a> </a>
</p> </p>
@ -57,7 +57,7 @@ else
protected override async Task OnParametersSetAsync() protected override async Task OnParametersSetAsync()
{ {
forecasts = await Http.GetJsonAsync<WeatherForecast[]>( forecasts = await Http.GetJsonAsync<WeatherForecast[]>(
$"/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, // 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. // fake the DateFormatted values here. This would not apply in a real app.

View File

@ -1,5 +1,5 @@
<div class="top-row pl-4 navbar navbar-dark"> <div class="top-row pl-4 navbar navbar-dark">
<a class="navbar-brand" href="/">Blazor app</a> <a class="navbar-brand" href="">Blazor app</a>
<button class="navbar-toggler" onclick=@ToggleNavMenu> <button class="navbar-toggler" onclick=@ToggleNavMenu>
<span class="navbar-toggler-icon"></span> <span class="navbar-toggler-icon"></span>
</button> </button>
@ -8,17 +8,17 @@
<div class=@(collapseNavMenu ? "collapse" : null) onclick=@ToggleNavMenu> <div class=@(collapseNavMenu ? "collapse" : null) onclick=@ToggleNavMenu>
<ul class="nav flex-column"> <ul class="nav flex-column">
<li class="nav-item px-3"> <li class="nav-item px-3">
<NavLink class="nav-link" href="/" Match=NavLinkMatch.All> <NavLink class="nav-link" href="" Match=NavLinkMatch.All>
<span class="oi oi-home" aria-hidden="true"></span> Home <span class="oi oi-home" aria-hidden="true"></span> Home
</NavLink> </NavLink>
</li> </li>
<li class="nav-item px-3"> <li class="nav-item px-3">
<NavLink class="nav-link" href="/counter"> <NavLink class="nav-link" href="counter">
<span class="oi oi-plus" aria-hidden="true"></span> Counter <span class="oi oi-plus" aria-hidden="true"></span> Counter
</NavLink> </NavLink>
</li> </li>
<li class="nav-item px-3"> <li class="nav-item px-3">
<NavLink class="nav-link" href="/fetchdata"> <NavLink class="nav-link" href="fetchdata">
<span class="oi oi-list-rich" aria-hidden="true"></span> Fetch data <span class="oi oi-list-rich" aria-hidden="true"></span> Fetch data
</NavLink> </NavLink>
</li> </li>

View File

@ -1,4 +1,4 @@
import { registerFunction } from '../Interop/RegisteredFunction'; import { registerFunction } from '../Interop/RegisteredFunction';
import { platform } from '../Environment'; import { platform } from '../Environment';
import { MethodHandle, System_String } from '../Platform/Platform'; import { MethodHandle, System_String } from '../Platform/Platform';
const registeredFunctionPrefix = 'Microsoft.AspNetCore.Blazor.Browser.Services.BrowserUriHelper'; const registeredFunctionPrefix = 'Microsoft.AspNetCore.Blazor.Browser.Services.BrowserUriHelper';
@ -22,9 +22,10 @@ registerFunction(`${registeredFunctionPrefix}.enableNavigationInterception`, ()
const anchorTarget = findClosestAncestor(event.target as Element | null, 'A'); const anchorTarget = findClosestAncestor(event.target as Element | null, 'A');
if (anchorTarget) { if (anchorTarget) {
const href = anchorTarget.getAttribute('href'); const href = anchorTarget.getAttribute('href');
if (isWithinBaseUriSpace(toAbsoluteUri(href))) { const absoluteHref = toAbsoluteUri(href);
if (isWithinBaseUriSpace(absoluteHref)) {
event.preventDefault(); event.preventDefault();
performInternalNavigation(href); performInternalNavigation(absoluteHref);
} }
} }
}); });
@ -37,15 +38,16 @@ registerFunction(`${registeredFunctionPrefix}.navigateTo`, (uriDotNetString: Sys
}); });
export function navigateTo(uri: string) { export function navigateTo(uri: string) {
if (isWithinBaseUriSpace(toAbsoluteUri(uri))) { const absoluteUri = toAbsoluteUri(uri);
performInternalNavigation(uri); if (isWithinBaseUriSpace(absoluteUri)) {
performInternalNavigation(absoluteUri);
} else { } else {
location.href = uri; location.href = uri;
} }
} }
function performInternalNavigation(href: string) { function performInternalNavigation(absoluteInternalHref: string) {
history.pushState(null, /* ignored title */ '', href); history.pushState(null, /* ignored title */ '', absoluteInternalHref);
handleInternalNavigation(); handleInternalNavigation();
} }
@ -80,10 +82,10 @@ function findClosestAncestor(element: Element | null, tagName: string) {
} }
function isWithinBaseUriSpace(href: string) { function isWithinBaseUriSpace(href: string) {
const baseUriPrefixWithTrailingSlash = toBaseUriPrefixWithTrailingSlash(document.baseURI!); // TODO: Might baseURI really be null? const baseUriWithTrailingSlash = toBaseUriWithTrailingSlash(document.baseURI!); // TODO: Might baseURI really be null?
return href.startsWith(baseUriPrefixWithTrailingSlash); return href.startsWith(baseUriWithTrailingSlash);
} }
function toBaseUriPrefixWithTrailingSlash(baseUri: string) { function toBaseUriWithTrailingSlash(baseUri: string) {
return baseUri.substr(0, baseUri.lastIndexOf('/') + 1); return baseUri.substr(0, baseUri.lastIndexOf('/') + 1);
} }

View File

@ -1,4 +1,4 @@
// Copyright (c) .NET Foundation. All rights reserved. // Copyright (c) .NET Foundation. All rights reserved.
// Licensed under the Apache License, Version 2.0. See License.txt in the project root for license information. // Licensed under the Apache License, Version 2.0. See License.txt in the project root for license information.
using Microsoft.AspNetCore.Blazor.Browser.Http; using Microsoft.AspNetCore.Blazor.Browser.Http;
@ -46,7 +46,7 @@ namespace Microsoft.AspNetCore.Blazor.Browser.Services
serviceCollection.AddSingleton<IUriHelper>(uriHelper); serviceCollection.AddSingleton<IUriHelper>(uriHelper);
serviceCollection.AddSingleton(new HttpClient(new BrowserHttpMessageHandler()) serviceCollection.AddSingleton(new HttpClient(new BrowserHttpMessageHandler())
{ {
BaseAddress = new Uri(uriHelper.GetBaseUriPrefix()) BaseAddress = new Uri(uriHelper.GetBaseUri())
}); });
} }
} }

View File

@ -1,4 +1,4 @@
// Copyright (c) .NET Foundation. All rights reserved. // Copyright (c) .NET Foundation. All rights reserved.
// Licensed under the Apache License, Version 2.0. See License.txt in the project root for license information. // Licensed under the Apache License, Version 2.0. See License.txt in the project root for license information.
using Microsoft.AspNetCore.Blazor.Browser.Interop; using Microsoft.AspNetCore.Blazor.Browser.Interop;
@ -22,8 +22,11 @@ namespace Microsoft.AspNetCore.Blazor.Browser.Services
static bool _hasEnabledNavigationInterception; static bool _hasEnabledNavigationInterception;
static string _cachedAbsoluteUri; static string _cachedAbsoluteUri;
static EventHandler<string> _onLocationChanged; static EventHandler<string> _onLocationChanged;
static string _baseUriStringNoTrailingSlash; // No trailing slash so we can just prepend it to suffixes
static Uri _baseUriWithTrailingSlash; // With trailing slash so it can be used in new Uri(base, relative) // These two are always kept in sync. We store both representations to
// avoid having to convert between them on demand.
static Uri _baseUriWithTrailingSlash;
static string _baseUriStringWithTrailingSlash;
/// <inheritdoc /> /// <inheritdoc />
public event EventHandler<string> OnLocationChanged public event EventHandler<string> OnLocationChanged
@ -44,10 +47,10 @@ namespace Microsoft.AspNetCore.Blazor.Browser.Services
} }
/// <inheritdoc /> /// <inheritdoc />
public string GetBaseUriPrefix() public string GetBaseUri()
{ {
EnsureBaseUriPopulated(); EnsureBaseUriPopulated();
return _baseUriStringNoTrailingSlash; return _baseUriStringWithTrailingSlash;
} }
/// <inheritdoc /> /// <inheritdoc />
@ -81,27 +84,25 @@ namespace Microsoft.AspNetCore.Blazor.Browser.Services
} }
/// <inheritdoc /> /// <inheritdoc />
public string ToBaseRelativePath(string baseUriPrefix, string absoluteUri) public string ToBaseRelativePath(string baseUri, string absoluteUri)
{ {
if (absoluteUri.Equals(baseUriPrefix, StringComparison.Ordinal)) if (absoluteUri.StartsWith(baseUri, StringComparison.Ordinal))
{ {
// Special case: if you're exactly at the base URI, treat it as if you // The absolute URI must be of the form "{baseUri}something" (where
// were at "{baseUriPrefix}/" (i.e., with a following slash). It's a bit // baseUri ends with a slash), and from that we return "something"
// ambiguous because we don't know whether the server would return the return absoluteUri.Substring(baseUri.Length);
// same page whether or not the slash is present, but ASP.NET Core at } else if ($"{absoluteUri}/".Equals(baseUri, StringComparison.Ordinal))
// least does by default when using PathBase.
return "/";
}
else if (absoluteUri.StartsWith(baseUriPrefix, StringComparison.Ordinal)
&& absoluteUri.Length > baseUriPrefix.Length
&& absoluteUri[baseUriPrefix.Length] == '/')
{ {
// The absolute URI must be of the form "{baseUriPrefix}/something", // Special case: for the base URI "/something/", if you're at
// and from that we return "/something" // "/something" then treat it as if you were at "/something/" (i.e.,
return absoluteUri.Substring(baseUriPrefix.Length); // with the trailing slash). It's a bit ambiguous because we don't know
// whether the server would return the same page whether or not the
// slash is present, but ASP.NET Core at least does by default when
// using PathBase.
return string.Empty;
} }
throw new ArgumentException($"The URI '{absoluteUri}' is not contained by the base URI '{baseUriPrefix}'."); throw new ArgumentException($"The URI '{absoluteUri}' is not contained by the base URI '{baseUri}'.");
} }
/// <inheritdoc /> /// <inheritdoc />
@ -118,12 +119,12 @@ namespace Microsoft.AspNetCore.Blazor.Browser.Services
private static void EnsureBaseUriPopulated() private static void EnsureBaseUriPopulated()
{ {
// The <base href> is fixed for the lifetime of the page, so just cache it // The <base href> is fixed for the lifetime of the page, so just cache it
if (_baseUriStringNoTrailingSlash == null) if (_baseUriStringWithTrailingSlash == null)
{ {
var baseUri = RegisteredFunction.InvokeUnmarshalled<string>( var baseUriAbsolute = RegisteredFunction.InvokeUnmarshalled<string>(
$"{_functionPrefix}.getBaseURI"); $"{_functionPrefix}.getBaseURI");
_baseUriStringNoTrailingSlash = ToBaseUriPrefix(baseUri); _baseUriStringWithTrailingSlash = ToBaseUri(baseUriAbsolute);
_baseUriWithTrailingSlash = new Uri(_baseUriStringNoTrailingSlash + "/"); _baseUriWithTrailingSlash = new Uri(_baseUriStringWithTrailingSlash);
} }
} }
@ -147,25 +148,25 @@ namespace Microsoft.AspNetCore.Blazor.Browser.Services
} }
/// <summary> /// <summary>
/// Given the document's document.baseURI value, returns the URI prefix /// Given the document's document.baseURI value, returns the URI
/// that can be prepended to URI paths to produce an absolute URI. /// that can be prepended to relative URI paths to produce an absolute URI.
/// This is computed by removing the final slash and any following characters. /// This is computed by removing anything after the final slash.
/// Internal for tests. /// Internal for tests.
/// </summary> /// </summary>
/// <param name="baseUri">The page's document.baseURI value.</param> /// <param name="absoluteBaseUri">The page's document.baseURI value.</param>
/// <returns>The URI prefix</returns> /// <returns>The URI prefix</returns>
internal static string ToBaseUriPrefix(string baseUri) internal static string ToBaseUri(string absoluteBaseUri)
{ {
if (baseUri != null) if (absoluteBaseUri != null)
{ {
var lastSlashIndex = baseUri.LastIndexOf('/'); var lastSlashIndex = absoluteBaseUri.LastIndexOf('/');
if (lastSlashIndex >= 0) if (lastSlashIndex >= 0)
{ {
return baseUri.Substring(0, lastSlashIndex); return absoluteBaseUri.Substring(0, lastSlashIndex + 1);
} }
} }
return string.Empty; return "/";
} }
} }
} }

View File

@ -40,6 +40,6 @@ else
protected override async Task OnInitAsync() protected override async Task OnInitAsync()
{ {
forecasts = await Http.GetJsonAsync<WeatherForecast[]>("/api/SampleData/WeatherForecasts"); forecasts = await Http.GetJsonAsync<WeatherForecast[]>("api/SampleData/WeatherForecasts");
} }
} }

View File

@ -1,5 +1,5 @@
<div class="top-row pl-4 navbar navbar-dark"> <div class="top-row pl-4 navbar navbar-dark">
<a class="navbar-brand" href="/">BlazorHosted-CSharp</a> <a class="navbar-brand" href="">BlazorHosted-CSharp</a>
<button class="navbar-toggler" onclick=@ToggleNavMenu> <button class="navbar-toggler" onclick=@ToggleNavMenu>
<span class="navbar-toggler-icon"></span> <span class="navbar-toggler-icon"></span>
</button> </button>
@ -8,17 +8,17 @@
<div class=@(collapseNavMenu ? "collapse" : null) onclick=@ToggleNavMenu> <div class=@(collapseNavMenu ? "collapse" : null) onclick=@ToggleNavMenu>
<ul class="nav flex-column"> <ul class="nav flex-column">
<li class="nav-item px-3"> <li class="nav-item px-3">
<NavLink class="nav-link" href="/" Match=NavLinkMatch.All> <NavLink class="nav-link" href="" Match=NavLinkMatch.All>
<span class="oi oi-home" aria-hidden="true"></span> Home <span class="oi oi-home" aria-hidden="true"></span> Home
</NavLink> </NavLink>
</li> </li>
<li class="nav-item px-3"> <li class="nav-item px-3">
<NavLink class="nav-link" href="/counter"> <NavLink class="nav-link" href="counter">
<span class="oi oi-plus" aria-hidden="true"></span> Counter <span class="oi oi-plus" aria-hidden="true"></span> Counter
</NavLink> </NavLink>
</li> </li>
<li class="nav-item px-3"> <li class="nav-item px-3">
<NavLink class="nav-link" href="/fetchdata"> <NavLink class="nav-link" href="fetchdata">
<span class="oi oi-list-rich" aria-hidden="true"></span> Fetch data <span class="oi oi-list-rich" aria-hidden="true"></span> Fetch data
</NavLink> </NavLink>
</li> </li>

View File

@ -39,7 +39,7 @@ else
protected override async Task OnInitAsync() protected override async Task OnInitAsync()
{ {
forecasts = await Http.GetJsonAsync<WeatherForecast[]>("/sample-data/weather.json"); forecasts = await Http.GetJsonAsync<WeatherForecast[]>("sample-data/weather.json");
} }
class WeatherForecast class WeatherForecast

View File

@ -1,5 +1,5 @@
<div class="top-row pl-4 navbar navbar-dark"> <div class="top-row pl-4 navbar navbar-dark">
<a class="navbar-brand" href="/">BlazorStandalone-CSharp</a> <a class="navbar-brand" href="">BlazorStandalone-CSharp</a>
<button class="navbar-toggler" onclick=@ToggleNavMenu> <button class="navbar-toggler" onclick=@ToggleNavMenu>
<span class="navbar-toggler-icon"></span> <span class="navbar-toggler-icon"></span>
</button> </button>
@ -8,17 +8,17 @@
<div class=@(collapseNavMenu ? "collapse" : null) onclick=@ToggleNavMenu> <div class=@(collapseNavMenu ? "collapse" : null) onclick=@ToggleNavMenu>
<ul class="nav flex-column"> <ul class="nav flex-column">
<li class="nav-item px-3"> <li class="nav-item px-3">
<NavLink class="nav-link" href="/" Match=NavLinkMatch.All> <NavLink class="nav-link" href="" Match=NavLinkMatch.All>
<span class="oi oi-home" aria-hidden="true"></span> Home <span class="oi oi-home" aria-hidden="true"></span> Home
</NavLink> </NavLink>
</li> </li>
<li class="nav-item px-3"> <li class="nav-item px-3">
<NavLink class="nav-link" href="/counter"> <NavLink class="nav-link" href="counter">
<span class="oi oi-plus" aria-hidden="true"></span> Counter <span class="oi oi-plus" aria-hidden="true"></span> Counter
</NavLink> </NavLink>
</li> </li>
<li class="nav-item px-3"> <li class="nav-item px-3">
<NavLink class="nav-link" href="/fetchdata"> <NavLink class="nav-link" href="fetchdata">
<span class="oi oi-list-rich" aria-hidden="true"></span> Fetch data <span class="oi oi-list-rich" aria-hidden="true"></span> Fetch data
</NavLink> </NavLink>
</li> </li>

View File

@ -1,4 +1,4 @@
// Copyright (c) .NET Foundation. All rights reserved. // Copyright (c) .NET Foundation. All rights reserved.
// Licensed under the Apache License, Version 2.0. See License.txt in the project root for license information. // Licensed under the Apache License, Version 2.0. See License.txt in the project root for license information.
using Microsoft.AspNetCore.Blazor.Components; using Microsoft.AspNetCore.Blazor.Components;
@ -96,18 +96,45 @@ namespace Microsoft.AspNetCore.Blazor.Routing
private bool ShouldMatch(string currentUriAbsolute) private bool ShouldMatch(string currentUriAbsolute)
{ {
if (Match == NavLinkMatch.Prefix) if (EqualsHrefExactlyOrIfTrailingSlashAdded(currentUriAbsolute))
{ {
return StartsWithAndHasSeparator(currentUriAbsolute, _hrefAbsolute); return true;
} }
else if (Match == NavLinkMatch.All)
if (Match == NavLinkMatch.Prefix
&& IsStrictlyPrefixWithSeparator(currentUriAbsolute, _hrefAbsolute))
{ {
return string.Equals(currentUriAbsolute, _hrefAbsolute, StringComparison.Ordinal); return true;
} }
else
return false;
}
private bool EqualsHrefExactlyOrIfTrailingSlashAdded(string currentUriAbsolute)
{
if (string.Equals(currentUriAbsolute, _hrefAbsolute, StringComparison.Ordinal))
{ {
throw new InvalidOperationException($"Unsupported {nameof(NavLinkMatch)} value: {Match}"); return true;
} }
if (currentUriAbsolute.Length == _hrefAbsolute.Length - 1)
{
// Special case: highlight links to http://host/path/ even if you're
// at http://host/path (with no trailing slash)
//
// This is because the router accepts an absolute URI value of "same
// as base URI but without trailing slash" as equivalent to "base URI",
// which in turn is because it's common for servers to return the same page
// for http://host/vdir as they do for host://host/vdir/ as it's no
// good to display a blank page in that case.
if (_hrefAbsolute[_hrefAbsolute.Length - 1] == '/'
&& _hrefAbsolute.StartsWith(currentUriAbsolute, StringComparison.Ordinal))
{
return true;
}
}
return false;
} }
private void Render(RenderTreeBuilder builder) private void Render(RenderTreeBuilder builder)
@ -134,15 +161,10 @@ namespace Microsoft.AspNetCore.Blazor.Routing
=> str1 == null ? str2 => str1 == null ? str2
: (str2 == null ? str1 : $"{str1} {str2}"); : (str2 == null ? str1 : $"{str1} {str2}");
private static bool StartsWithAndHasSeparator(string value, string prefix) private static bool IsStrictlyPrefixWithSeparator(string value, string prefix)
{ {
var valueLength = value.Length;
var prefixLength = prefix.Length; var prefixLength = prefix.Length;
if (prefixLength == valueLength) if (value.Length > prefixLength)
{
return string.Equals(value, prefix, StringComparison.Ordinal);
}
else if (valueLength > prefixLength)
{ {
return value.StartsWith(prefix, StringComparison.Ordinal) return value.StartsWith(prefix, StringComparison.Ordinal)
&& ( && (

View File

@ -1,4 +1,4 @@
// Copyright (c) .NET Foundation. All rights reserved. // Copyright (c) .NET Foundation. All rights reserved.
// Licensed under the Apache License, Version 2.0. See License.txt in the project root for license information. // Licensed under the Apache License, Version 2.0. See License.txt in the project root for license information.
using System; using System;
@ -20,7 +20,7 @@ namespace Microsoft.AspNetCore.Blazor.Routing
static readonly char[] _queryOrHashStartChar = new[] { '?', '#' }; static readonly char[] _queryOrHashStartChar = new[] { '?', '#' };
RenderHandle _renderHandle; RenderHandle _renderHandle;
string _baseUriPrefix; string _baseUri;
string _locationAbsolute; string _locationAbsolute;
[Inject] private IUriHelper UriHelper { get; set; } [Inject] private IUriHelper UriHelper { get; set; }
@ -37,7 +37,7 @@ namespace Microsoft.AspNetCore.Blazor.Routing
public void Init(RenderHandle renderHandle) public void Init(RenderHandle renderHandle)
{ {
_renderHandle = renderHandle; _renderHandle = renderHandle;
_baseUriPrefix = UriHelper.GetBaseUriPrefix(); _baseUri = UriHelper.GetBaseUri();
_locationAbsolute = UriHelper.GetAbsoluteUri(); _locationAbsolute = UriHelper.GetAbsoluteUri();
UriHelper.OnLocationChanged += OnLocationChanged; UriHelper.OnLocationChanged += OnLocationChanged;
} }
@ -76,13 +76,13 @@ namespace Microsoft.AspNetCore.Blazor.Routing
private void Refresh() private void Refresh()
{ {
var locationPath = UriHelper.ToBaseRelativePath(_baseUriPrefix, _locationAbsolute); var locationPath = UriHelper.ToBaseRelativePath(_baseUri, _locationAbsolute);
locationPath = StringUntilAny(locationPath, _queryOrHashStartChar); locationPath = StringUntilAny(locationPath, _queryOrHashStartChar);
var context = new RouteContext(locationPath); var context = new RouteContext(locationPath);
Routes.Route(context); Routes.Route(context);
if (context.Handler == null) if (context.Handler == null)
{ {
throw new InvalidOperationException($"'{nameof(Router)}' cannot find any component with a route for '{locationPath}'."); throw new InvalidOperationException($"'{nameof(Router)}' cannot find any component with a route for '/{locationPath}'.");
} }
if (!typeof(IComponent).IsAssignableFrom(context.Handler)) if (!typeof(IComponent).IsAssignableFrom(context.Handler))

View File

@ -1,4 +1,4 @@
// Copyright (c) .NET Foundation. All rights reserved. // Copyright (c) .NET Foundation. All rights reserved.
// Licensed under the Apache License, Version 2.0. See License.txt in the project root for license information. // Licensed under the Apache License, Version 2.0. See License.txt in the project root for license information.
using System; using System;
@ -30,26 +30,26 @@ namespace Microsoft.AspNetCore.Blazor.Services
Uri ToAbsoluteUri(string href); Uri ToAbsoluteUri(string href);
/// <summary> /// <summary>
/// Gets the URI prefix that can be prepended before URI paths to produce an absolute URI. /// Gets the base URI (with trailing slash) that can be prepended before relative URI paths to produce an absolute URI.
/// Typically this corresponds to the 'href' attribute on the document's &lt;base&gt; element. /// Typically this corresponds to the 'href' attribute on the document's &lt;base&gt; element.
/// </summary> /// </summary>
/// <returns>The URI prefix.</returns> /// <returns>The URI prefix, which has a trailing slash.</returns>
string GetBaseUriPrefix(); string GetBaseUri();
/// <summary> /// <summary>
/// Given a base URI prefix (e.g., one previously returned by <see cref="GetBaseUriPrefix"/>), /// Given a base URI (e.g., one previously returned by <see cref="GetBaseUri"/>),
/// converts an absolute URI into one relative to the base URI prefix. /// converts an absolute URI into one relative to the base URI prefix.
/// </summary> /// </summary>
/// <param name="baseUriPrefix">The base URI prefix (e.g., previously returned by <see cref="GetBaseUriPrefix"/>).</param> /// <param name="baseUri">The base URI prefix (e.g., previously returned by <see cref="GetBaseUri"/>).</param>
/// <param name="locationAbsolute">An absolute URI that is within the space of the base URI prefix.</param> /// <param name="locationAbsolute">An absolute URI that is within the space of the base URI.</param>
/// <returns>A relative URI path.</returns> /// <returns>A relative URI path.</returns>
string ToBaseRelativePath(string baseUriPrefix, string locationAbsolute); string ToBaseRelativePath(string baseUri, string locationAbsolute);
/// <summary> /// <summary>
/// Navigates to the specified URI. /// Navigates to the specified URI.
/// </summary> /// </summary>
/// <param name="uri">The destination URI. This can be absolute, or relative to the base URI /// <param name="uri">The destination URI. This can be absolute, or relative to the base URI
/// prefix (as returned by <see cref="GetBaseUriPrefix"/>).</param> /// (as returned by <see cref="GetBaseUri"/>).</param>
void NavigateTo(string uri); void NavigateTo(string uri);
} }
} }

View File

@ -12,41 +12,43 @@ namespace Microsoft.AspNetCore.Blazor.Browser.Test
private BrowserUriHelper _browserUriHelper = new BrowserUriHelper(); private BrowserUriHelper _browserUriHelper = new BrowserUriHelper();
[Theory] [Theory]
[InlineData("scheme://host/", "scheme://host")] [InlineData("scheme://host/", "scheme://host/")]
[InlineData("scheme://host:123/", "scheme://host:123")] [InlineData("scheme://host:123/", "scheme://host:123/")]
[InlineData("scheme://host/path", "scheme://host")] [InlineData("scheme://host/path", "scheme://host/")]
[InlineData("scheme://host/path/", "scheme://host/path")] [InlineData("scheme://host/path/", "scheme://host/path/")]
[InlineData("scheme://host/path/page?query=string&another=here", "scheme://host/path")] [InlineData("scheme://host/path/page?query=string&another=here", "scheme://host/path/")]
public void ComputesCorrectBaseUriPrefix(string baseUri, string expectedResult) public void ComputesCorrectBaseUri(string baseUri, string expectedResult)
{ {
var actualResult = BrowserUriHelper.ToBaseUriPrefix(baseUri); var actualResult = BrowserUriHelper.ToBaseUri(baseUri);
Assert.Equal(expectedResult, actualResult); Assert.Equal(expectedResult, actualResult);
} }
[Theory] [Theory]
[InlineData("scheme://host", "scheme://host/", "/")] [InlineData("scheme://host/", "scheme://host", "")]
[InlineData("scheme://host", "scheme://host/path", "/path")] [InlineData("scheme://host/", "scheme://host/", "")]
[InlineData("scheme://host/path", "scheme://host/path/", "/")] [InlineData("scheme://host/", "scheme://host/path", "path")]
[InlineData("scheme://host/path", "scheme://host/path/more", "/more")] [InlineData("scheme://host/path/", "scheme://host/path/", "")]
[InlineData("scheme://host/path", "scheme://host/path", "/")] [InlineData("scheme://host/path/", "scheme://host/path/more", "more")]
public void ComputesCorrectValidBaseRelativePaths(string baseUriPrefix, string absoluteUri, string expectedResult) [InlineData("scheme://host/path/", "scheme://host/path", "")]
public void ComputesCorrectValidBaseRelativePaths(string baseUri, string absoluteUri, string expectedResult)
{ {
var actualResult = _browserUriHelper.ToBaseRelativePath(baseUriPrefix, absoluteUri); var actualResult = _browserUriHelper.ToBaseRelativePath(baseUri, absoluteUri);
Assert.Equal(expectedResult, actualResult); Assert.Equal(expectedResult, actualResult);
} }
[Theory] [Theory]
[InlineData("scheme://host", "otherscheme://host/")] // Mismatched prefix is error [InlineData("scheme://host/", "otherscheme://host/")]
[InlineData("scheme://host", "scheme://otherhost/")] // Mismatched prefix is error [InlineData("scheme://host/", "scheme://otherhost/")]
public void ThrowsForInvalidBaseRelativePaths(string baseUriPrefix, string absoluteUri) [InlineData("scheme://host/path/", "scheme://host/")]
public void ThrowsForInvalidBaseRelativePaths(string baseUri, string absoluteUri)
{ {
var ex = Assert.Throws<ArgumentException>(() => var ex = Assert.Throws<ArgumentException>(() =>
{ {
_browserUriHelper.ToBaseRelativePath(baseUriPrefix, absoluteUri); _browserUriHelper.ToBaseRelativePath(baseUri, absoluteUri);
}); });
Assert.Equal( Assert.Equal(
$"The URI '{absoluteUri}' is not contained by the base URI '{baseUriPrefix}'.", $"The URI '{absoluteUri}' is not contained by the base URI '{baseUri}'.",
ex.Message); ex.Message);
} }
} }

View File

@ -1,4 +1,4 @@
// Copyright (c) .NET Foundation. All rights reserved. // Copyright (c) .NET Foundation. All rights reserved.
// Licensed under the Apache License, Version 2.0. See License.txt in the project root for license information. // Licensed under the Apache License, Version 2.0. See License.txt in the project root for license information.
using System; using System;
@ -31,27 +31,41 @@ namespace Microsoft.AspNetCore.Blazor.E2ETest.Tests
[Fact] [Fact]
public void CanArriveAtDefaultPage() public void CanArriveAtDefaultPage()
{ {
SetUrlViaPushState($"{ServerPathBase}/RouterTest/"); SetUrlViaPushState($"{ServerPathBase}/");
var app = MountTestComponent<TestRouter>(); var app = MountTestComponent<TestRouter>();
Assert.Equal("This is the default page.", app.FindElement(By.Id("test-info")).Text); Assert.Equal("This is the default page.", app.FindElement(By.Id("test-info")).Text);
AssertHighlightedLinks("Default (matches all)"); AssertHighlightedLinks("Default (matches all)", "Default with base-relative URL (matches all)");
}
[Fact]
public void CanArriveAtDefaultPageWithoutTrailingSlash()
{
// This is a bit of a degenerate case because ideally devs would configure their
// servers to enforce a canonical URL (with trailing slash) for the homepage.
// But in case they don't want to, we need to handle it the same as if the URL does
// have a trailing slash.
SetUrlViaPushState($"{ServerPathBase}");
var app = MountTestComponent<TestRouter>();
Assert.Equal("This is the default page.", app.FindElement(By.Id("test-info")).Text);
AssertHighlightedLinks("Default (matches all)", "Default with base-relative URL (matches all)");
} }
[Fact] [Fact]
public void CanArriveAtPageWithParameters() public void CanArriveAtPageWithParameters()
{ {
SetUrlViaPushState($"{ServerPathBase}/RouterTest/WithParameters/Name/Dan/LastName/Roth"); SetUrlViaPushState($"{ServerPathBase}/WithParameters/Name/Ghi/LastName/O'Jkl");
var app = MountTestComponent<TestRouter>(); var app = MountTestComponent<TestRouter>();
Assert.Equal("Your full name is Dan Roth.", app.FindElement(By.Id("test-info")).Text); Assert.Equal("Your full name is Ghi O'Jkl.", app.FindElement(By.Id("test-info")).Text);
AssertHighlightedLinks(); AssertHighlightedLinks();
} }
[Fact] [Fact]
public void CanArriveAtNonDefaultPage() public void CanArriveAtNonDefaultPage()
{ {
SetUrlViaPushState($"{ServerPathBase}/RouterTest/Other"); SetUrlViaPushState($"{ServerPathBase}/Other");
var app = MountTestComponent<TestRouter>(); var app = MountTestComponent<TestRouter>();
Assert.Equal("This is another page.", app.FindElement(By.Id("test-info")).Text); Assert.Equal("This is another page.", app.FindElement(By.Id("test-info")).Text);
@ -61,7 +75,7 @@ namespace Microsoft.AspNetCore.Blazor.E2ETest.Tests
[Fact] [Fact]
public void CanFollowLinkToOtherPage() public void CanFollowLinkToOtherPage()
{ {
SetUrlViaPushState($"{ServerPathBase}/RouterTest/"); SetUrlViaPushState($"{ServerPathBase}/");
var app = MountTestComponent<TestRouter>(); var app = MountTestComponent<TestRouter>();
app.FindElement(By.LinkText("Other")).Click(); app.FindElement(By.LinkText("Other")).Click();
@ -72,7 +86,7 @@ namespace Microsoft.AspNetCore.Blazor.E2ETest.Tests
[Fact] [Fact]
public void CanFollowLinkToOtherPageWithBaseRelativeUrl() public void CanFollowLinkToOtherPageWithBaseRelativeUrl()
{ {
SetUrlViaPushState($"{ServerPathBase}/RouterTest/"); SetUrlViaPushState($"{ServerPathBase}/");
var app = MountTestComponent<TestRouter>(); var app = MountTestComponent<TestRouter>();
app.FindElement(By.LinkText("Other with base-relative URL (matches all)")).Click(); app.FindElement(By.LinkText("Other with base-relative URL (matches all)")).Click();
@ -80,32 +94,43 @@ namespace Microsoft.AspNetCore.Blazor.E2ETest.Tests
AssertHighlightedLinks("Other", "Other with base-relative URL (matches all)"); AssertHighlightedLinks("Other", "Other with base-relative URL (matches all)");
} }
[Fact]
public void CanFollowLinkToEmptyStringHrefAsBaseRelativeUrl()
{
SetUrlViaPushState($"{ServerPathBase}/Other");
var app = MountTestComponent<TestRouter>();
app.FindElement(By.LinkText("Default with base-relative URL (matches all)")).Click();
Assert.Equal("This is the default page.", app.FindElement(By.Id("test-info")).Text);
AssertHighlightedLinks("Default (matches all)", "Default with base-relative URL (matches all)");
}
[Fact] [Fact]
public void CanFollowLinkToPageWithParameters() public void CanFollowLinkToPageWithParameters()
{ {
SetUrlViaPushState($"{ServerPathBase}/RouterTest/Other"); SetUrlViaPushState($"{ServerPathBase}/Other");
var app = MountTestComponent<TestRouter>(); var app = MountTestComponent<TestRouter>();
app.FindElement(By.LinkText("With parameters")).Click(); app.FindElement(By.LinkText("With parameters")).Click();
Assert.Equal("Your full name is Steve Sanderson.", app.FindElement(By.Id("test-info")).Text); Assert.Equal("Your full name is Abc McDef.", app.FindElement(By.Id("test-info")).Text);
AssertHighlightedLinks("With parameters"); AssertHighlightedLinks("With parameters");
} }
[Fact] [Fact]
public void CanFollowLinkToDefaultPage() public void CanFollowLinkToDefaultPage()
{ {
SetUrlViaPushState($"{ServerPathBase}/RouterTest/Other"); SetUrlViaPushState($"{ServerPathBase}/Other");
var app = MountTestComponent<TestRouter>(); var app = MountTestComponent<TestRouter>();
app.FindElement(By.LinkText("Default (matches all)")).Click(); app.FindElement(By.LinkText("Default (matches all)")).Click();
Assert.Equal("This is the default page.", app.FindElement(By.Id("test-info")).Text); Assert.Equal("This is the default page.", app.FindElement(By.Id("test-info")).Text);
AssertHighlightedLinks("Default (matches all)"); AssertHighlightedLinks("Default (matches all)", "Default with base-relative URL (matches all)");
} }
[Fact] [Fact]
public void CanFollowLinkToOtherPageWithQueryString() public void CanFollowLinkToOtherPageWithQueryString()
{ {
SetUrlViaPushState($"{ServerPathBase}/RouterTest/"); SetUrlViaPushState($"{ServerPathBase}/");
var app = MountTestComponent<TestRouter>(); var app = MountTestComponent<TestRouter>();
app.FindElement(By.LinkText("Other with query")).Click(); app.FindElement(By.LinkText("Other with query")).Click();
@ -116,7 +141,7 @@ namespace Microsoft.AspNetCore.Blazor.E2ETest.Tests
[Fact] [Fact]
public void CanFollowLinkToDefaultPageWithQueryString() public void CanFollowLinkToDefaultPageWithQueryString()
{ {
SetUrlViaPushState($"{ServerPathBase}/RouterTest/Other"); SetUrlViaPushState($"{ServerPathBase}/Other");
var app = MountTestComponent<TestRouter>(); var app = MountTestComponent<TestRouter>();
app.FindElement(By.LinkText("Default with query")).Click(); app.FindElement(By.LinkText("Default with query")).Click();
@ -127,7 +152,7 @@ namespace Microsoft.AspNetCore.Blazor.E2ETest.Tests
[Fact] [Fact]
public void CanFollowLinkToOtherPageWithHash() public void CanFollowLinkToOtherPageWithHash()
{ {
SetUrlViaPushState($"{ServerPathBase}/RouterTest/"); SetUrlViaPushState($"{ServerPathBase}/");
var app = MountTestComponent<TestRouter>(); var app = MountTestComponent<TestRouter>();
app.FindElement(By.LinkText("Other with hash")).Click(); app.FindElement(By.LinkText("Other with hash")).Click();
@ -138,7 +163,7 @@ namespace Microsoft.AspNetCore.Blazor.E2ETest.Tests
[Fact] [Fact]
public void CanFollowLinkToDefaultPageWithHash() public void CanFollowLinkToDefaultPageWithHash()
{ {
SetUrlViaPushState($"{ServerPathBase}/RouterTest/Other"); SetUrlViaPushState($"{ServerPathBase}/Other");
var app = MountTestComponent<TestRouter>(); var app = MountTestComponent<TestRouter>();
app.FindElement(By.LinkText("Default with hash")).Click(); app.FindElement(By.LinkText("Default with hash")).Click();
@ -149,7 +174,7 @@ namespace Microsoft.AspNetCore.Blazor.E2ETest.Tests
[Fact] [Fact]
public void CanNavigateProgrammatically() public void CanNavigateProgrammatically()
{ {
SetUrlViaPushState($"{ServerPathBase}/RouterTest/"); SetUrlViaPushState($"{ServerPathBase}/");
var app = MountTestComponent<TestRouter>(); var app = MountTestComponent<TestRouter>();
app.FindElement(By.TagName("button")).Click(); app.FindElement(By.TagName("button")).Click();
@ -168,7 +193,7 @@ namespace Microsoft.AspNetCore.Blazor.E2ETest.Tests
{ {
var jsExecutor = (IJavaScriptExecutor)Browser; var jsExecutor = (IJavaScriptExecutor)Browser;
var absoluteUri = new Uri(_server.RootUri, relativeUri); var absoluteUri = new Uri(_server.RootUri, relativeUri);
jsExecutor.ExecuteScript($"Blazor.navigateTo('{absoluteUri.ToString()}')"); jsExecutor.ExecuteScript($"Blazor.navigateTo('{absoluteUri.ToString().Replace("'", "\\'")}')");
} }
private void AssertHighlightedLinks(params string[] linkTexts) private void AssertHighlightedLinks(params string[] linkTexts)

View File

@ -1,4 +1,3 @@
@page "/RouterTest" @page "/"
@using BasicTestApp.RouterTest
<div id="test-info">This is the default page.</div> <div id="test-info">This is the default page.</div>
<Links /> <Links />

View File

@ -1,18 +1,18 @@
@page "/Links"
@using Microsoft.AspNetCore.Blazor.Routing @using Microsoft.AspNetCore.Blazor.Routing
@inject Microsoft.AspNetCore.Blazor.Services.IUriHelper uriHelper @inject Microsoft.AspNetCore.Blazor.Services.IUriHelper uriHelper
<style type="text/css">a.active { background-color: yellow; font-weight: bold; }</style> <style type="text/css">a.active { background-color: yellow; font-weight: bold; }</style>
<ul> <ul>
<li><NavLink href="/subdir/RouterTest/" Match=NavLinkMatch.All>Default (matches all)</NavLink></li> <li><NavLink href="/subdir/" Match=NavLinkMatch.All>Default (matches all)</NavLink></li>
<li><NavLink href="/subdir/RouterTest/?abc=123">Default with query</NavLink></li> <li><NavLink href="" Match=NavLinkMatch.All>Default with base-relative URL (matches all)</NavLink></li>
<li><NavLink href="/subdir/RouterTest/#blah">Default with hash</NavLink></li> <li><NavLink href="/subdir/?abc=123">Default with query</NavLink></li>
<li><NavLink href="/subdir/RouterTest/Other">Other</NavLink></li> <li><NavLink href="/subdir/#blah">Default with hash</NavLink></li>
<li><NavLink href="RouterTest/Other" Match=NavLinkMatch.All>Other with base-relative URL (matches all)</NavLink></li> <li><NavLink href="/subdir/Other">Other</NavLink></li>
<li><NavLink href="/subdir/RouterTest/Other?abc=123">Other with query</NavLink></li> <li><NavLink href="Other" Match=NavLinkMatch.All>Other with base-relative URL (matches all)</NavLink></li>
<li><NavLink href="/subdir/RouterTest/Other#blah">Other with hash</NavLink></li> <li><NavLink href="/subdir/Other?abc=123">Other with query</NavLink></li>
<li><NavLink href="/subdir/RouterTest/WithParameters/Name/Steve/LastName/Sanderson">With parameters</NavLink></li> <li><NavLink href="/subdir/Other#blah">Other with hash</NavLink></li>
<li><NavLink href="/subdir/WithParameters/Name/Abc/LastName/McDef">With parameters</NavLink></li>
</ul> </ul>
<button onclick=@(x => uriHelper.NavigateTo("RouterTest/Other"))> <button onclick=@(x => uriHelper.NavigateTo("Other"))>
Programmatic navigation Programmatic navigation
</button> </button>

View File

@ -1,4 +1,3 @@
@page "/RouterTest/Other" @page "/Other"
@using BasicTestApp.RouterTest
<div id="test-info">This is another page.</div> <div id="test-info">This is another page.</div>
<Links /> <Links />

View File

@ -1,2 +1 @@
@using Microsoft.AspNetCore.Blazor.Routing
<Router AppAssembly=typeof(BasicTestApp.Program).Assembly /> <Router AppAssembly=typeof(BasicTestApp.Program).Assembly />

View File

@ -1,6 +1,4 @@
@page "/RouterTest/WithParameters/Name/{firstName}/LastName/{lastName}" @page "/WithParameters/Name/{firstName}/LastName/{lastName}"
@using BasicTestApp.RouterTest
@using Microsoft.AspNetCore.Blazor.Components
<div id="test-info">Your full name is @FirstName @LastName.</div> <div id="test-info">Your full name is @FirstName @LastName.</div>
<Links /> <Links />