Skip to content
Merged
Show file tree
Hide file tree
Changes from 3 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
51 changes: 38 additions & 13 deletions src/Components/Web/src/Routing/NavigationLock.cs
Original file line number Diff line number Diff line change
Expand Up @@ -9,13 +9,13 @@ namespace Microsoft.AspNetCore.Components.Routing;
/// <summary>
/// A component that can be used to intercept navigation events.
/// </summary>
public sealed class NavigationLock : IComponent, IAsyncDisposable
public sealed class NavigationLock : IComponent, IHandleAfterRender, IAsyncDisposable
{
private readonly string _id = Guid.NewGuid().ToString("D", CultureInfo.InvariantCulture);

private RenderHandle _renderHandle;
private IDisposable? _locationChangingRegistration;

private bool HasOnBeforeInternalNavigationCallback => OnBeforeInternalNavigation.HasDelegate;
private bool _lastConfirmExternalNavigation;

[Inject]
private IJSRuntime JSRuntime { get; set; } = default!;
Expand All @@ -38,26 +38,45 @@ public sealed class NavigationLock : IComponent, IAsyncDisposable

void IComponent.Attach(RenderHandle renderHandle)
{
_renderHandle = renderHandle;
}

async Task IComponent.SetParametersAsync(ParameterView parameters)
Task IComponent.SetParametersAsync(ParameterView parameters)
{
var lastHasOnBeforeInternalNavigationCallback = HasOnBeforeInternalNavigationCallback;
var lastConfirmExternalNavigation = ConfirmExternalNavigation;
foreach (var parameter in parameters)
{
if (parameter.Name.Equals(nameof(OnBeforeInternalNavigation), StringComparison.OrdinalIgnoreCase))
{
OnBeforeInternalNavigation = (EventCallback<LocationChangingContext>)parameter.Value;
}
else if (parameter.Name.Equals(nameof(ConfirmExternalNavigation), StringComparison.OrdinalIgnoreCase))
{
ConfirmExternalNavigation = (bool)parameter.Value;
}
else
{
throw new ArgumentException($"The component '{nameof(NavigationLock)}' does not accept a parameter with the name '{parameter.Name}'.");
}
}

parameters.SetParameterProperties(this);
_renderHandle.Render(static builder => { });
return Task.CompletedTask;
}

var hasOnBeforeInternalNavigationCallback = HasOnBeforeInternalNavigationCallback;
if (hasOnBeforeInternalNavigationCallback != lastHasOnBeforeInternalNavigationCallback)
async Task IHandleAfterRender.OnAfterRenderAsync()
{
var lastHasLocationChangingHandler = _locationChangingRegistration is not null;
var hasLocationChangingHandler = OnBeforeInternalNavigation.HasDelegate;
if (lastHasLocationChangingHandler != hasLocationChangingHandler)
{
_locationChangingRegistration?.Dispose();
_locationChangingRegistration = hasOnBeforeInternalNavigationCallback
_locationChangingRegistration = hasLocationChangingHandler
? NavigationManager.RegisterLocationChangingHandler(OnLocationChanging)
: null;
}

var confirmExternalNavigation = ConfirmExternalNavigation;
if (confirmExternalNavigation != lastConfirmExternalNavigation)
if (_lastConfirmExternalNavigation != confirmExternalNavigation)
{
if (confirmExternalNavigation)
{
Expand All @@ -67,17 +86,23 @@ async Task IComponent.SetParametersAsync(ParameterView parameters)
{
await JSRuntime.InvokeVoidAsync(NavigationLockInterop.DisableNavigationPrompt, _id);
}

_lastConfirmExternalNavigation = confirmExternalNavigation;
}
}

async ValueTask OnLocationChanging(LocationChangingContext context)
private async ValueTask OnLocationChanging(LocationChangingContext context)
{
await OnBeforeInternalNavigation.InvokeAsync(context);
}

async ValueTask IAsyncDisposable.DisposeAsync()
{
_locationChangingRegistration?.Dispose();
await JSRuntime.InvokeVoidAsync(NavigationLockInterop.DisableNavigationPrompt, _id);

if (_lastConfirmExternalNavigation)
{
await JSRuntime.InvokeVoidAsync(NavigationLockInterop.DisableNavigationPrompt, _id);
}
}
}
Original file line number Diff line number Diff line change
@@ -0,0 +1,50 @@
// Licensed to the .NET Foundation under one or more agreements.
// The .NET Foundation licenses this file to you under the MIT license.

using Microsoft.AspNetCore.Components.E2ETest.Infrastructure;
using Microsoft.AspNetCore.Components.E2ETest.Infrastructure.ServerFixtures;
using Microsoft.AspNetCore.E2ETesting;
using OpenQA.Selenium;
using TestServer;
using Xunit.Abstractions;

namespace Microsoft.AspNetCore.Components.E2ETest.ServerExecutionTests;

public class NavigationLockPrerenderingTest : ServerTestBase<BasicTestAppServerSiteFixture<LockedNavigationStartup>>
{
public NavigationLockPrerenderingTest(
BrowserFixture browserFixture,
BasicTestAppServerSiteFixture<LockedNavigationStartup> serverFixture,
ITestOutputHelper output)
: base(browserFixture, serverFixture, output)
{
}

[Fact]
public void NavigationIsLockedAfterPrerendering()
{
Navigate("/locked-navigation");

// Assert that the component rendered successfully
Browser.Equal("Prevented navigations: 0", () => Browser.FindElement(By.Id("num-prevented-navigations")).Text);

BeginInteractivity();

// Assert that internal navigations are blocked
Browser.Click(By.Id("internal-navigation-link"));
Browser.Equal("Prevented navigations: 1", () => Browser.FindElement(By.Id("num-prevented-navigations")).Text);

// Assert that external navigations are blocked
Browser.Navigate().GoToUrl("about:blank");
Browser.SwitchTo().Alert().Dismiss();
Browser.Equal("Prevented navigations: 1", () => Browser.FindElement(By.Id("num-prevented-navigations")).Text);
}

private void BeginInteractivity()
{
Browser.Exists(By.Id("load-boot-script")).Click();

var javascript = (IJavaScriptExecutor)Browser;
Browser.True(() => (bool)javascript.ExecuteScript("return window['__aspnetcore__testing__blazor__started__'] === true;"));
}
}
Original file line number Diff line number Diff line change
@@ -0,0 +1,28 @@
@using Microsoft.AspNetCore.Components.Routing

@inject INavigationInterception NavigationInterception

<a id="internal-navigation-link" href="should-never-get-here">Internal navigation</a>

<span id="num-prevented-navigations">Prevented navigations: @_numPreventedNavigations</span>

<NavigationLock OnBeforeInternalNavigation="HandleBeforeInternalNavigationAsync" ConfirmExternalNavigation />

@code {
private int _numPreventedNavigations = 0;

protected override async Task OnAfterRenderAsync(bool firstRender)
{
if (firstRender)
{
await NavigationInterception.EnableNavigationInterceptionAsync();
}
}

private Task HandleBeforeInternalNavigationAsync(LocationChangingContext context)
{
_numPreventedNavigations++;
context.PreventNavigation();
return Task.CompletedTask;
}
}
Original file line number Diff line number Diff line change
@@ -0,0 +1,46 @@
// Licensed to the .NET Foundation under one or more agreements.
// The .NET Foundation licenses this file to you under the MIT license.

using System.Globalization;
using Microsoft.AspNetCore.Authentication.Cookies;

namespace TestServer;

public class LockedNavigationStartup
{
// This method gets called by the runtime. Use this method to add services to the container.
public void ConfigureServices(IServiceCollection services)
{
services.AddMvc();
services.AddServerSideBlazor();
services.AddAuthentication(CookieAuthenticationDefaults.AuthenticationScheme).AddCookie();
}

// This method gets called by the runtime. Use this method to configure the HTTP request pipeline.
public void Configure(IApplicationBuilder app, IWebHostEnvironment env)
{
var enUs = new CultureInfo("en-US");
CultureInfo.DefaultThreadCurrentCulture = enUs;
CultureInfo.DefaultThreadCurrentUICulture = enUs;

if (env.IsDevelopment())
{
app.UseDeveloperExceptionPage();
}

app.Map("/locked-navigation", app =>
{
app.UseStaticFiles();

app.UseAuthentication();

app.UseRouting();
app.UseEndpoints(endpoints =>
{
endpoints.MapRazorPages();
endpoints.MapFallbackToPage("/LockedNavigationHost");
endpoints.MapBlazorHub();
});
});
}
}
Original file line number Diff line number Diff line change
@@ -0,0 +1,33 @@
@page "/locked-navigation"
@using BasicTestApp.RouterTest

<!DOCTYPE html>
<html>
<head>
<title>Locked navigation</title>
<base href="~/" />
</head>
<body>
<app><component type="typeof(LockNavigationOnPageLoad)" render-mode="ServerPrerendered" /></app>

@*
So that E2E tests can make assertions about both the prerendered and
interactive states, we only load the .js file when told to.
*@
<hr />

<button id="load-boot-script" onclick="start()">Load boot script</button>

<script src="_framework/blazor.server.js" autostart="false"></script>

<script>
function start() {
Blazor.start({
logLevel: 1 // LogLevel.Debug
}).then(function () {
window['__aspnetcore__testing__blazor__started__'] = true;
});
}
</script>
</body>
</html>
1 change: 1 addition & 0 deletions src/Components/test/testassets/TestServer/Program.cs
Original file line number Diff line number Diff line change
Expand Up @@ -20,6 +20,7 @@ public static async Task Main(string[] args)
["CORS (WASM)"] = (BuildWebHost<CorsStartup>(CreateAdditionalArgs(args)), "/subdir"),
["Prerendering (Server-side)"] = (BuildWebHost<PrerenderedStartup>(CreateAdditionalArgs(args)), "/prerendered"),
["Deferred component content (Server-side)"] = (BuildWebHost<DeferredComponentContentStartup>(CreateAdditionalArgs(args)), "/deferred-component-content"),
["Locked navigation (Server-side)"] = (BuildWebHost<LockedNavigationStartup>(CreateAdditionalArgs(args)), "/locked-navigation"),
["Client-side with fallback"] = (BuildWebHost<StartupWithMapFallbackToClientSideBlazor>(CreateAdditionalArgs(args)), "/fallback"),
["Multiple components (Server-side)"] = (BuildWebHost<MultipleComponents>(CreateAdditionalArgs(args)), "/multiple-components"),
["Save state"] = (BuildWebHost<SaveState>(CreateAdditionalArgs(args)), "/save-state"),
Expand Down