diff --git a/samples/StandaloneApp/App.cshtml b/samples/StandaloneApp/App.cshtml index 2336a07b22..00e26124af 100644 --- a/samples/StandaloneApp/App.cshtml +++ b/samples/StandaloneApp/App.cshtml @@ -1,2 +1,2 @@ -@using Microsoft.AspNetCore.Blazor.Layouts - +@using Microsoft.AspNetCore.Blazor.Browser.Routing + diff --git a/samples/StandaloneApp/Pages/About.cshtml b/samples/StandaloneApp/Pages/About.cshtml new file mode 100644 index 0000000000..3863c7d149 --- /dev/null +++ b/samples/StandaloneApp/Pages/About.cshtml @@ -0,0 +1,5 @@ +

About

+ +Some content goes here. + +Home diff --git a/samples/StandaloneApp/Pages/Index.cshtml b/samples/StandaloneApp/Pages/Index.cshtml new file mode 100644 index 0000000000..e5b93bf62a --- /dev/null +++ b/samples/StandaloneApp/Pages/Index.cshtml @@ -0,0 +1,5 @@ +

Hello, world!

+ +Welcome to your new app. + +About diff --git a/samples/StandaloneApp/Pages/Home.cshtml b/samples/StandaloneApp/Pages/_ViewImports.cshtml similarity index 50% rename from samples/StandaloneApp/Pages/Home.cshtml rename to samples/StandaloneApp/Pages/_ViewImports.cshtml index cb8804a77c..55ec034759 100644 --- a/samples/StandaloneApp/Pages/Home.cshtml +++ b/samples/StandaloneApp/Pages/_ViewImports.cshtml @@ -1,4 +1 @@ @(Layout()) -

Hello, world!

- -Welcome to your new app. \ No newline at end of file diff --git a/samples/StandaloneApp/wwwroot/index.html b/samples/StandaloneApp/wwwroot/index.html index 1631067273..582c00a225 100644 --- a/samples/StandaloneApp/wwwroot/index.html +++ b/samples/StandaloneApp/wwwroot/index.html @@ -3,13 +3,14 @@ Blazor standalone - - + + + Loading... - + diff --git a/src/Microsoft.AspNetCore.Blazor.Browser.JS/src/Boot.ts b/src/Microsoft.AspNetCore.Blazor.Browser.JS/src/Boot.ts index 1c62684cc4..5fde164327 100644 --- a/src/Microsoft.AspNetCore.Blazor.Browser.JS/src/Boot.ts +++ b/src/Microsoft.AspNetCore.Blazor.Browser.JS/src/Boot.ts @@ -1,6 +1,7 @@ import { platform } from './Environment'; import { getAssemblyNameFromUrl } from './Platform/DotNet'; import './Rendering/Renderer'; +import './Routing/UriHelper'; import './GlobalExports'; async function boot() { diff --git a/src/Microsoft.AspNetCore.Blazor.Browser.JS/src/Routing/UriHelper.ts b/src/Microsoft.AspNetCore.Blazor.Browser.JS/src/Routing/UriHelper.ts new file mode 100644 index 0000000000..a502075dbb --- /dev/null +++ b/src/Microsoft.AspNetCore.Blazor.Browser.JS/src/Routing/UriHelper.ts @@ -0,0 +1,73 @@ +import { registerFunction } from '../Interop/RegisteredFunction'; +import { platform } from '../Environment'; +import { MethodHandle } from '../Platform/Platform'; +const registeredFunctionPrefix = 'Microsoft.AspNetCore.Blazor.Browser.Routing.UriHelper'; +let notifyLocationChangedMethod: MethodHandle; +let hasRegisteredEventListeners = false; + +registerFunction(`${registeredFunctionPrefix}.getLocationHref`, + () => platform.toDotNetString(location.href)); + +registerFunction(`${registeredFunctionPrefix}.getBaseURI`, + () => document.baseURI ? platform.toDotNetString(document.baseURI) : null); + +registerFunction(`${registeredFunctionPrefix}.enableNavigationInteception`, () => { + if (hasRegisteredEventListeners) { + return; + } + hasRegisteredEventListeners = true; + + document.addEventListener('click', event => { + // Intercept clicks on all elements where the href is within the URI space + const anchorTarget = findClosestAncestor(event.target as Element | null, 'A'); + if (anchorTarget) { + const href = anchorTarget.getAttribute('href'); + if (isWithinBaseUriSpace(toAbsoluteUri(href))) { + event.preventDefault(); + history.pushState(null, /* ignored title */ '', href); + handleInternalNavigation(); + } + } + }); + + window.addEventListener('popstate', handleInternalNavigation); +}); + +function handleInternalNavigation() { + if (!notifyLocationChangedMethod) { + notifyLocationChangedMethod = platform.findMethod( + 'Microsoft.AspNetCore.Blazor.Browser', + 'Microsoft.AspNetCore.Blazor.Browser.Routing', + 'UriHelper', + 'NotifyLocationChanged' + ); + } + + platform.callMethod(notifyLocationChangedMethod, null, [ + platform.toDotNetString(location.href) + ]); +} + +let testAnchor: HTMLAnchorElement; +function toAbsoluteUri(relativeUri: string) { + testAnchor = testAnchor || document.createElement('a'); + testAnchor.href = relativeUri; + return testAnchor.href; +} + +function findClosestAncestor(element: Element | null, tagName: string) { + return !element + ? null + : element.tagName === tagName + ? element + : findClosestAncestor(element.parentElement, tagName) +} + +function isWithinBaseUriSpace(href: string) { + const baseUriPrefixWithTrailingSlash = toBaseUriPrefixWithTrailingSlash(document.baseURI!); // TODO: Might baseURI really be null? + return href.startsWith(baseUriPrefixWithTrailingSlash); +} + +function toBaseUriPrefixWithTrailingSlash(baseUri: string) { + return baseUri.substr(0, baseUri.lastIndexOf('/') + 1); +} diff --git a/src/Microsoft.AspNetCore.Blazor.Browser/Routing/BrowserRouter.cs b/src/Microsoft.AspNetCore.Blazor.Browser/Routing/BrowserRouter.cs new file mode 100644 index 0000000000..647c29eab0 --- /dev/null +++ b/src/Microsoft.AspNetCore.Blazor.Browser/Routing/BrowserRouter.cs @@ -0,0 +1,104 @@ +// 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. + +using System; +using System.Linq; +using System.Reflection; +using Microsoft.AspNetCore.Blazor.Components; +using Microsoft.AspNetCore.Blazor.Layouts; +using Microsoft.AspNetCore.Blazor.RenderTree; + +namespace Microsoft.AspNetCore.Blazor.Browser.Routing +{ + public class BrowserRouter : IComponent, IDisposable + { + RenderHandle _renderHandle; + string _baseUriPrefix; + string _locationAbsolute; + + public Assembly AppAssembly { get; set; } + + public string PagesNamespace { get; set; } + + public string DefaultComponentName { get; set; } = "Index"; + + public void Init(RenderHandle renderHandle) + { + _renderHandle = renderHandle; + + UriHelper.EnableNavigationInteception(); + UriHelper.OnLocationChanged += OnLocationChanged; + _baseUriPrefix = UriHelper.GetBaseUriPrefix(); + _locationAbsolute = UriHelper.GetAbsoluteUri(); + } + + public void SetParameters(ParameterCollection parameters) + { + parameters.AssignToProperties(this); + Refresh(); + } + + public void Dispose() + { + UriHelper.OnLocationChanged -= OnLocationChanged; + } + + protected virtual Type GetComponentTypeForPath(string locationPath) + { + if (AppAssembly == null) + { + throw new InvalidOperationException($"No value was specified for {nameof(AppAssembly)}."); + } + + if (string.IsNullOrEmpty(PagesNamespace)) + { + throw new InvalidOperationException($"No value was specified for {nameof(PagesNamespace)}."); + } + + var componentTypeName = $"{PagesNamespace}{locationPath.Replace('/', '.')}"; + if (componentTypeName[componentTypeName.Length - 1] == '.') + { + componentTypeName += DefaultComponentName; + } + + return FindComponentTypeInAssemblyOrReferences(AppAssembly, componentTypeName) + ?? throw new InvalidOperationException($"{nameof(BrowserRouter)} cannot find any component type with name {componentTypeName}."); + } + + private Type FindComponentTypeInAssemblyOrReferences(Assembly assembly, string typeName) + => assembly.GetType(typeName, throwOnError: false, ignoreCase: true) + ?? assembly.GetReferencedAssemblies() + .Select(Assembly.Load) + .Select(referencedAssembly => FindComponentTypeInAssemblyOrReferences(referencedAssembly, typeName)) + .FirstOrDefault(); + + protected virtual void Render(RenderTreeBuilder builder, Type matchedComponentType) + { + builder.OpenComponent(0, typeof(LayoutDisplay)); + builder.AddAttribute(1, nameof(LayoutDisplay.Page), matchedComponentType); + builder.CloseComponent(); + } + + private void Refresh() + { + var locationPath = UriHelper.ToBaseRelativePath(_baseUriPrefix, _locationAbsolute); + var matchedComponentType = GetComponentTypeForPath(locationPath); + if (!typeof(IComponent).IsAssignableFrom(matchedComponentType)) + { + throw new InvalidOperationException($"The type {matchedComponentType.FullName} " + + $"does not implement {typeof(IComponent).FullName}."); + } + + _renderHandle.Render(builder => Render(builder, matchedComponentType)); + } + + private void OnLocationChanged(object sender, string newAbsoluteUri) + { + _locationAbsolute = newAbsoluteUri; + if (_renderHandle.IsInitialized) + { + Refresh(); + } + } + } +} diff --git a/src/Microsoft.AspNetCore.Blazor.Browser/Routing/UriHelper.cs b/src/Microsoft.AspNetCore.Blazor.Browser/Routing/UriHelper.cs new file mode 100644 index 0000000000..1ab69d4f50 --- /dev/null +++ b/src/Microsoft.AspNetCore.Blazor.Browser/Routing/UriHelper.cs @@ -0,0 +1,72 @@ +// 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. + +using Microsoft.AspNetCore.Blazor.Browser.Interop; +using System; + +namespace Microsoft.AspNetCore.Blazor.Browser.Routing +{ + public static class UriHelper + { + static readonly string _functionPrefix = typeof(UriHelper).FullName; + + public static event EventHandler OnLocationChanged; + + public static void EnableNavigationInteception() + => RegisteredFunction.InvokeUnmarshalled( + $"{_functionPrefix}.enableNavigationInteception"); + + public static string GetBaseUriPrefix() + { + var baseUri = RegisteredFunction.InvokeUnmarshalled( + $"{_functionPrefix}.getBaseURI"); + return ToBaseURIPrefix(baseUri); + } + + public static string GetAbsoluteUri() + { + return RegisteredFunction.InvokeUnmarshalled( + $"{_functionPrefix}.getLocationHref"); + } + + public static string ToBaseRelativePath(string baseUriPrefix, string absoluteUri) + { + // The absolute URI must be of the form "{baseUriPrefix}/something", + // and from that we return "/something" (also stripping any querystring + // and/or hash value) + if (absoluteUri.StartsWith(baseUriPrefix, StringComparison.Ordinal) + && absoluteUri.Length > baseUriPrefix.Length + && absoluteUri[baseUriPrefix.Length] == '/') + { + // TODO: Remove querystring and/or hash + return absoluteUri.Substring(baseUriPrefix.Length); + } + + throw new ArgumentException($"The URI '{absoluteUri}' is not contained by the base URI '{baseUriPrefix}'."); + } + + private static void NotifyLocationChanged(string newAbsoluteUri) + => OnLocationChanged?.Invoke(null, newAbsoluteUri); + + /// + /// Given the href value from the document's element, returns the URI + /// prefix that can be prepended to URI paths to produce an absolute URI. + /// This is computed by removing the final slash and any following characters. + /// + /// The href value from a document's element. + /// The URI prefix + private static string ToBaseURIPrefix(string baseUri) + { + if (baseUri != null) + { + var lastSlashIndex = baseUri.LastIndexOf('/'); + if (lastSlashIndex >= 0) + { + return baseUri.Substring(0, lastSlashIndex); + } + } + + return string.Empty; + } + } +}