Skip to content

[Blazor] Allow enhanced navigation when an interactive router is present #50012

New issue

Have a question about this project? Sign up for a free GitHub account to open an issue and contact its maintainers and the community.

By clicking “Sign up for GitHub”, you agree to our terms of service and privacy statement. We’ll occasionally send you account related emails.

Already on GitHub? Sign in to your account

Closed
wants to merge 1 commit into from
Closed
Show file tree
Hide file tree
Changes from all 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
6 changes: 6 additions & 0 deletions src/Components/Components/src/NavigationOptions.cs
Original file line number Diff line number Diff line change
Expand Up @@ -13,6 +13,12 @@ public readonly struct NavigationOptions
/// </summary>
public bool ForceLoad { get; init; }

/// <summary>
/// If true, uses enhanced navigation even if an interactive router is present.
/// If enhanced navigation is not available, this option is ignored.
/// </summary>
public bool PreferEnhancedNavigation { get; init; }

/// <summary>
/// If true, replaces the currently entry in the history stack.
/// If false, appends the new entry to the history stack.
Expand Down
3 changes: 3 additions & 0 deletions src/Components/Components/src/PublicAPI.Unshipped.txt
Original file line number Diff line number Diff line change
Expand Up @@ -19,6 +19,8 @@ Microsoft.AspNetCore.Components.CascadingValueSource<TValue>.NotifyChangedAsync(
Microsoft.AspNetCore.Components.ComponentBase.DispatchExceptionAsync(System.Exception! exception) -> System.Threading.Tasks.Task!
Microsoft.AspNetCore.Components.IComponentRenderMode
Microsoft.AspNetCore.Components.Infrastructure.ComponentStatePersistenceManager.PersistStateAsync(Microsoft.AspNetCore.Components.IPersistentComponentStateStore! store, Microsoft.AspNetCore.Components.Dispatcher! dispatcher) -> System.Threading.Tasks.Task!
Microsoft.AspNetCore.Components.NavigationOptions.PreferEnhancedNavigation.get -> bool
Microsoft.AspNetCore.Components.NavigationOptions.PreferEnhancedNavigation.init -> void
Microsoft.AspNetCore.Components.ParameterView.ToDictionary() -> System.Collections.Generic.IReadOnlyDictionary<string!, object?>!
*REMOVED*Microsoft.AspNetCore.Components.ParameterView.ToDictionary() -> System.Collections.Generic.IReadOnlyDictionary<string!, object!>!
Microsoft.AspNetCore.Components.RenderHandle.DispatchExceptionAsync(System.Exception! exception) -> System.Threading.Tasks.Task!
Expand Down Expand Up @@ -51,6 +53,7 @@ Microsoft.AspNetCore.Components.RouteData.RouteData(System.Type! pageType, Syste
Microsoft.AspNetCore.Components.RouteData.RouteValues.get -> System.Collections.Generic.IReadOnlyDictionary<string!, object?>!
Microsoft.AspNetCore.Components.RouteData.Template.get -> string?
Microsoft.AspNetCore.Components.RouteData.Template.set -> void
Microsoft.AspNetCore.Components.Routing.INavigationInterception.DisableNavigationInterceptionAsync() -> System.Threading.Tasks.Task!
Microsoft.AspNetCore.Components.Routing.IRoutingStateProvider
Microsoft.AspNetCore.Components.Routing.IRoutingStateProvider.RouteData.get -> Microsoft.AspNetCore.Components.RouteData?
Microsoft.AspNetCore.Components.RenderModeAttribute
Expand Down
Original file line number Diff line number Diff line change
Expand Up @@ -13,4 +13,11 @@ public interface INavigationInterception
/// </summary>
/// <returns>A <see cref="Task" /> that represents the asynchronous operation.</returns>
Task EnableNavigationInterceptionAsync();

/// <summary>
/// Disables navigation interception on the client.
/// </summary>
/// <returns>A <see cref="Task"/> that represents the asynchronous operation.</returns>
Task DisableNavigationInterceptionAsync()
=> Task.CompletedTask;
}
13 changes: 12 additions & 1 deletion src/Components/Components/src/Routing/Router.cs
Original file line number Diff line number Diff line change
Expand Up @@ -16,7 +16,7 @@ namespace Microsoft.AspNetCore.Components.Routing;
/// <summary>
/// A component that supplies route data corresponding to the current navigation state.
/// </summary>
public partial class Router : IComponent, IHandleAfterRender, IDisposable
public partial class Router : IComponent, IHandleAfterRender, IDisposable, IAsyncDisposable
{
// Dictionary is intentionally used instead of ReadOnlyDictionary to reduce Blazor size
static readonly IReadOnlyDictionary<string, object> _emptyParametersDictionary
Expand Down Expand Up @@ -143,12 +143,23 @@ public async Task SetParametersAsync(ParameterView parameters)

/// <inheritdoc />
public void Dispose()
{
_ = ((IAsyncDisposable)this).DisposeAsync().Preserve();
}

/// <inheritdoc />
async ValueTask IAsyncDisposable.DisposeAsync()
{
NavigationManager.LocationChanged -= OnLocationChanged;
if (HotReloadManager.Default.MetadataUpdateSupported)
{
HotReloadManager.Default.OnDeltaApplied -= ClearRouteCaches;
}

if (_navigationInterceptionEnabled)
{
await NavigationInterception.DisableNavigationInterceptionAsync();
}
}

private static ReadOnlySpan<char> TrimQueryOrHash(ReadOnlySpan<char> str)
Expand Down
5 changes: 5 additions & 0 deletions src/Components/Components/test/Routing/RouterTest.cs
Original file line number Diff line number Diff line change
Expand Up @@ -285,6 +285,11 @@ public Task EnableNavigationInterceptionAsync()
{
return Task.CompletedTask;
}

public Task DisableNavigationInterceptionAsync()
{
return Task.CompletedTask;
}
}

internal sealed class TestScrollToLocationHash : IScrollToLocationHash
Expand Down
Original file line number Diff line number Diff line change
Expand Up @@ -12,4 +12,7 @@ public Task EnableNavigationInterceptionAsync()
throw new InvalidOperationException("Navigation interception calls cannot be issued during server-side static rendering, because the page has not yet loaded in the browser. " +
"Statically rendered components must wrap any navigation interception calls in conditional logic to ensure those interop calls are not attempted during static rendering.");
}

public Task DisableNavigationInterceptionAsync()
=> Task.CompletedTask;
}
Original file line number Diff line number Diff line change
Expand Up @@ -9,8 +9,15 @@ namespace Microsoft.AspNetCore.Components.Server.Circuits;

internal sealed class RemoteNavigationInterception : INavigationInterception
{
private readonly NavigationManager _navigationManager;

private IJSRuntime _jsRuntime;

public RemoteNavigationInterception(NavigationManager navigationManager)
{
_navigationManager = navigationManager;
}

public void AttachJSRuntime(IJSRuntime jsRuntime)
{
if (HasAttachedJSRuntime)
Expand All @@ -35,6 +42,22 @@ public async Task EnableNavigationInterceptionAsync()
"attempted during prerendering or while the client is disconnected.");
}

await _jsRuntime.InvokeAsync<object>(Interop.EnableNavigationInterception);
await _jsRuntime.InvokeAsync<object>(Interop.EnableNavigationInterception, _navigationManager.Uri);
}

public async Task DisableNavigationInterceptionAsync()
{
if (!HasAttachedJSRuntime)
{
return;
}

try
{
await _jsRuntime.InvokeAsync<object>(Interop.DisableNavigationInterception);
}
catch (JSDisconnectedException)
{
Copy link
Member

Choose a reason for hiding this comment

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

Is it OK for us to consume this exception?

Copy link
Member Author

Choose a reason for hiding this comment

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

Yep. This happens if the browser is no longer available, in which case there's no browser-side state to clean up. In other words, if this exception gets thrown, it means there's no work to do.

}
}
}
Original file line number Diff line number Diff line change
Expand Up @@ -10,6 +10,8 @@ internal static class BrowserNavigationManagerInterop

public const string EnableNavigationInterception = Prefix + "enableNavigationInterception";

public const string DisableNavigationInterception = Prefix + "disableNavigationInterception";

public const string GetLocationHref = Prefix + "getLocationHref";

public const string GetBaseUri = Prefix + "getBaseURI";
Expand Down
2 changes: 1 addition & 1 deletion src/Components/Web.JS/dist/Release/blazor.server.js

Large diffs are not rendered by default.

2 changes: 1 addition & 1 deletion src/Components/Web.JS/dist/Release/blazor.web.js

Large diffs are not rendered by default.

2 changes: 1 addition & 1 deletion src/Components/Web.JS/dist/Release/blazor.webview.js

Large diffs are not rendered by default.

4 changes: 0 additions & 4 deletions src/Components/Web.JS/src/Services/NavigationEnhancement.ts
Original file line number Diff line number Diff line change
Expand Up @@ -57,10 +57,6 @@ export function detachProgressivelyEnhancedNavigationListener() {
}

function performProgrammaticEnhancedNavigation(absoluteInternalHref: string, replace: boolean) {
if (hasInteractiveRouter()) {
return;
}

if (replace) {
history.replaceState(null, /* ignored title */ '', absoluteInternalHref);
} else {
Expand Down
24 changes: 21 additions & 3 deletions src/Components/Web.JS/src/Services/NavigationManager.ts
Original file line number Diff line number Diff line change
Expand Up @@ -21,7 +21,8 @@ let resolveCurrentNavigation: ((shouldContinueNavigation: boolean) => void) | nu
// These are the functions we're making available for invocation from .NET
export const internalFunctions = {
listenForNavigationEvents,
enableNavigationInterception: setHasInteractiveRouter,
enableNavigationInterception,
disableNavigationInterception,
setHasLocationChangingListeners,
endLocationChanging,
navigateTo: navigateToFromDotNet,
Expand All @@ -46,6 +47,20 @@ function listenForNavigationEvents(
currentHistoryIndex = history.state?._index ?? 0;
}

async function enableNavigationInterception(uriInDotNet?: string) {
setHasInteractiveRouter(true);
if (uriInDotNet && location.href !== uriInDotNet) {
Copy link
Member

Choose a reason for hiding this comment

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

In what scenarios does this happen?

Copy link
Member Author

@MackinnonBuck MackinnonBuck Aug 10, 2023

Choose a reason for hiding this comment

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

Here's an example:

  1. The user is currently on a page with an interactive router
  2. The user navigates to a page without an interactive router, disposing the router and disabling navigation interception
  3. The user continues to click around, all while .NET is not getting notified of location changes (because no interactive router is present)
  4. The user navigates back to a page with an interactive router. Navigation interception gets enabled, but the itneractive router didn't get a chance to detect the navigation that just happened, so the interactive .NET NavigationManager still has the wrong URL and the router will therefore render content for the wrong page (or hit the "not found" route).

// The location known by .NET is out of sync with the actual browser location.
// Therefore, we should notify .NET that the location has changed so that any
// interactive router can react accordingly.
await notifyLocationChanged(false);
}
}

function disableNavigationInterception() {
setHasInteractiveRouter(false);
}

function setHasLocationChangingListeners(hasListeners: boolean) {
hasLocationChangingEventListeners = hasListeners;
}
Expand Down Expand Up @@ -101,7 +116,7 @@ export function navigateTo(uri: string, forceLoadOrOptions: NavigationOptions |
// Normalize the parameters to the newer overload (i.e., using NavigationOptions)
const options: NavigationOptions = forceLoadOrOptions instanceof Object
? forceLoadOrOptions
: { forceLoad: forceLoadOrOptions, replaceHistoryEntry: replaceIfUsingOldOverload };
: { forceLoad: forceLoadOrOptions, preferEnhancedNavigation: false, replaceHistoryEntry: replaceIfUsingOldOverload };

navigateToCore(uri, options);
}
Expand All @@ -115,7 +130,9 @@ function navigateToFromDotNet(uri: string, options: NavigationOptions): void {
function navigateToCore(uri: string, options: NavigationOptions, skipLocationChangingCallback = false): void {
const absoluteUri = toAbsoluteUri(uri);

if (!options.forceLoad && isWithinBaseUriSpace(absoluteUri)) {
if (options.preferEnhancedNavigation && hasProgrammaticEnhancedNavigationHandler() && isWithinBaseUriSpace(absoluteUri)) {
performProgrammaticEnhancedNavigation(absoluteUri, options.replaceHistoryEntry);
} else if (!options.forceLoad && isWithinBaseUriSpace(absoluteUri)) {
if (shouldUseClientSideRouting()) {
performInternalNavigation(absoluteUri, false, options.replaceHistoryEntry, options.historyEntryState, skipLocationChangingCallback);
} else {
Expand Down Expand Up @@ -273,6 +290,7 @@ function shouldUseClientSideRouting() {
// Keep in sync with Components/src/NavigationOptions.cs
export interface NavigationOptions {
forceLoad: boolean;
preferEnhancedNavigation: boolean;
replaceHistoryEntry: boolean;
historyEntryState?: string;
}
4 changes: 2 additions & 2 deletions src/Components/Web.JS/src/Services/NavigationUtils.ts
Original file line number Diff line number Diff line change
Expand Up @@ -117,6 +117,6 @@ export function hasInteractiveRouter(): boolean {
return hasInteractiveRouterValue;
}

export function setHasInteractiveRouter() {
hasInteractiveRouterValue = true;
export function setHasInteractiveRouter(hasInteractiveRouter: boolean) {
hasInteractiveRouterValue = hasInteractiveRouter;
}
Original file line number Diff line number Diff line change
Expand Up @@ -255,7 +255,7 @@ internal void InitializeDefaultServices()
{
Services.AddSingleton<IJSRuntime>(DefaultWebAssemblyJSRuntime.Instance);
Services.AddSingleton<NavigationManager>(WebAssemblyNavigationManager.Instance);
Services.AddSingleton<INavigationInterception>(WebAssemblyNavigationInterception.Instance);
Services.AddSingleton<INavigationInterception, WebAssemblyNavigationInterception>();
Services.AddSingleton<IScrollToLocationHash>(WebAssemblyScrollToLocationHash.Instance);
Services.AddSingleton(new LazyAssemblyLoader(DefaultWebAssemblyJSRuntime.Instance));
Services.AddSingleton<RootComponentTypeCache>(_ => _rootComponentCache ?? new());
Expand Down
Original file line number Diff line number Diff line change
Expand Up @@ -9,7 +9,9 @@ internal interface IInternalJSImportMethods

string GetApplicationEnvironment();

void NavigationManager_EnableNavigationInterception();
void NavigationManager_EnableNavigationInterception(string uri);

void NavigationManager_DisableNavigationInterception();

void NavigationManager_ScrollToElement(string id);

Expand Down
Original file line number Diff line number Diff line change
Expand Up @@ -19,8 +19,11 @@ public string GetPersistedState()
public string GetApplicationEnvironment()
=> GetApplicationEnvironmentCore();

public void NavigationManager_EnableNavigationInterception()
=> NavigationManager_EnableNavigationInterceptionCore();
public void NavigationManager_EnableNavigationInterception(string uri)
=> NavigationManager_EnableNavigationInterceptionCore(uri);

public void NavigationManager_DisableNavigationInterception()
=> NavigationManager_DisableNavigationInterceptionCore();

public void NavigationManager_ScrollToElement(string id)
=> NavigationManager_ScrollToElementCore(id);
Expand Down Expand Up @@ -56,7 +59,10 @@ public string RegisteredComponents_GetParameterValues(int id)
private static partial string GetApplicationEnvironmentCore();

[JSImport(BrowserNavigationManagerInterop.EnableNavigationInterception, "blazor-internal")]
private static partial void NavigationManager_EnableNavigationInterceptionCore();
private static partial void NavigationManager_EnableNavigationInterceptionCore(string uri);

[JSImport(BrowserNavigationManagerInterop.DisableNavigationInterception, "blazor-internal")]
private static partial void NavigationManager_DisableNavigationInterceptionCore();

[JSImport(BrowserNavigationManagerInterop.ScrollToElement, "blazor-internal")]
private static partial void NavigationManager_ScrollToElementCore(string id);
Expand Down
Original file line number Diff line number Diff line change
Expand Up @@ -7,11 +7,22 @@ namespace Microsoft.AspNetCore.Components.WebAssembly.Services;

internal sealed class WebAssemblyNavigationInterception : INavigationInterception
{
public static readonly WebAssemblyNavigationInterception Instance = new WebAssemblyNavigationInterception();
private readonly NavigationManager _navigationManager;

public WebAssemblyNavigationInterception(NavigationManager navigationManager)
{
_navigationManager = navigationManager;
}

public Task EnableNavigationInterceptionAsync()
{
InternalJSImportMethods.Instance.NavigationManager_EnableNavigationInterception();
InternalJSImportMethods.Instance.NavigationManager_EnableNavigationInterception(_navigationManager.Uri);
return Task.CompletedTask;
}

public Task DisableNavigationInterceptionAsync()
{
InternalJSImportMethods.Instance.NavigationManager_DisableNavigationInterception();
return Task.CompletedTask;
}
}
Original file line number Diff line number Diff line change
Expand Up @@ -20,7 +20,9 @@ public string GetApplicationEnvironment()
public string GetPersistedState()
=> null;

public void NavigationManager_EnableNavigationInterception() { }
public void NavigationManager_EnableNavigationInterception(string uri) { }

public void NavigationManager_DisableNavigationInterception() { }

public void NavigationManager_ScrollToElement(string id) { }

Expand Down
Original file line number Diff line number Diff line change
Expand Up @@ -10,4 +10,6 @@ internal sealed class WebViewNavigationInterception : INavigationInterception
// On this platform, it's sufficient for the JS-side code to enable it unconditionally,
// so there's no need to send a notification.
public Task EnableNavigationInterceptionAsync() => Task.CompletedTask;

public Task DisableNavigationInterceptionAsync() => Task.CompletedTask;
}
Original file line number Diff line number Diff line change
@@ -0,0 +1,10 @@
@page "/routing/interactive"
@using TestContentPackage

<nav>
<NavLink href="routing/interactive">Home</NavLink>
<NavLink href="routing/interactive/navigation">Navigation</NavLink>
</nav>
<hr/>

<InteractiveRouter @rendermode="@(new ServerRenderMode(false))" />
Original file line number Diff line number Diff line change
@@ -0,0 +1,8 @@
@page "/routing/interactive/{pageName}"

<h1>Fallback page for <span id="page-name">@PageName</span>!</h1>

@code {
[Parameter]
public string PageName { get; set; }
}
Original file line number Diff line number Diff line change
@@ -0,0 +1,8 @@
@using Microsoft.AspNetCore.Components.Routing

<Router AppAssembly="@typeof(InteractiveRouter).Assembly">
<Found Context="routeData">
<RouteView RouteData="@routeData" />
</Found>
<NotFound>Not found using interactive router</NotFound>
</Router>
Original file line number Diff line number Diff line change
@@ -0,0 +1,3 @@
@page "/routing/interactive"

<h1>Hello from index!</h1>
Original file line number Diff line number Diff line change
@@ -0,0 +1,37 @@
@page "/routing/interactive/navigation"
@inject NavigationManager Navigation

<h3>Hello from navigation page!</h3>

<button id="normal-refresh" @onclick="NormalRefresh">Normal refresh</button>
<button id="enhanced-refresh" @onclick="EnhancedRefresh">Enhanced refresh</button>
<button id="enhanced-refresh-fallback" @onclick="EnhancedRefreshWithFallback">Enhanced refresh with fallback</button>

@code {
private void NormalRefresh()
{
Navigation.NavigateTo(Navigation.Uri, new NavigationOptions()
{
ReplaceHistoryEntry = true,
});
}

private void EnhancedRefresh()
{
Navigation.NavigateTo(Navigation.Uri, new NavigationOptions()
{
ReplaceHistoryEntry = true,
PreferEnhancedNavigation = true,
});
}

private void EnhancedRefreshWithFallback()
{
Navigation.NavigateTo(Navigation.Uri, new NavigationOptions()
{
ReplaceHistoryEntry = true,
PreferEnhancedNavigation = true,
ForceLoad = true,
});
}
}