diff --git a/src/Components/Web/src/Routing/NavigationLock.cs b/src/Components/Web/src/Routing/NavigationLock.cs index 2dd9aac504f6..e8b5566531de 100644 --- a/src/Components/Web/src/Routing/NavigationLock.cs +++ b/src/Components/Web/src/Routing/NavigationLock.cs @@ -9,13 +9,16 @@ namespace Microsoft.AspNetCore.Components.Routing; /// /// A component that can be used to intercept navigation events. /// -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 _hasLocationChangingHandler; + private bool _confirmExternalNavigation; - private bool HasOnBeforeInternalNavigationCallback => OnBeforeInternalNavigation.HasDelegate; + private bool HasLocationChangingHandler => OnBeforeInternalNavigation.HasDelegate; [Inject] private IJSRuntime JSRuntime { get; set; } = default!; @@ -38,28 +41,51 @@ 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)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); + if (_hasLocationChangingHandler != HasLocationChangingHandler || + _confirmExternalNavigation != ConfirmExternalNavigation) + { + _renderHandle.Render(static builder => { }); + } + + return Task.CompletedTask; + } - var hasOnBeforeInternalNavigationCallback = HasOnBeforeInternalNavigationCallback; - if (hasOnBeforeInternalNavigationCallback != lastHasOnBeforeInternalNavigationCallback) + async Task IHandleAfterRender.OnAfterRenderAsync() + { + if (_hasLocationChangingHandler != HasLocationChangingHandler) { + _hasLocationChangingHandler = HasLocationChangingHandler; _locationChangingRegistration?.Dispose(); - _locationChangingRegistration = hasOnBeforeInternalNavigationCallback + _locationChangingRegistration = _hasLocationChangingHandler ? NavigationManager.RegisterLocationChangingHandler(OnLocationChanging) : null; } - var confirmExternalNavigation = ConfirmExternalNavigation; - if (confirmExternalNavigation != lastConfirmExternalNavigation) + if (_confirmExternalNavigation != ConfirmExternalNavigation) { - if (confirmExternalNavigation) + _confirmExternalNavigation = ConfirmExternalNavigation; + if (_confirmExternalNavigation) { await JSRuntime.InvokeVoidAsync(NavigationLockInterop.EnableNavigationPrompt, _id); } @@ -70,7 +96,7 @@ async Task IComponent.SetParametersAsync(ParameterView parameters) } } - async ValueTask OnLocationChanging(LocationChangingContext context) + private async ValueTask OnLocationChanging(LocationChangingContext context) { await OnBeforeInternalNavigation.InvokeAsync(context); } @@ -78,6 +104,10 @@ async ValueTask OnLocationChanging(LocationChangingContext context) async ValueTask IAsyncDisposable.DisposeAsync() { _locationChangingRegistration?.Dispose(); - await JSRuntime.InvokeVoidAsync(NavigationLockInterop.DisableNavigationPrompt, _id); + + if (_confirmExternalNavigation) + { + await JSRuntime.InvokeVoidAsync(NavigationLockInterop.DisableNavigationPrompt, _id); + } } } diff --git a/src/Components/test/E2ETest/ServerExecutionTests/NavigationLockPrerenderingTest.cs b/src/Components/test/E2ETest/ServerExecutionTests/NavigationLockPrerenderingTest.cs new file mode 100644 index 000000000000..e59585d80533 --- /dev/null +++ b/src/Components/test/E2ETest/ServerExecutionTests/NavigationLockPrerenderingTest.cs @@ -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> +{ + public NavigationLockPrerenderingTest( + BrowserFixture browserFixture, + BasicTestAppServerSiteFixture 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;")); + } +} diff --git a/src/Components/test/testassets/BasicTestApp/RouterTest/LockNavigationOnPageLoad.razor b/src/Components/test/testassets/BasicTestApp/RouterTest/LockNavigationOnPageLoad.razor new file mode 100644 index 000000000000..14f901b38685 --- /dev/null +++ b/src/Components/test/testassets/BasicTestApp/RouterTest/LockNavigationOnPageLoad.razor @@ -0,0 +1,28 @@ +@using Microsoft.AspNetCore.Components.Routing + +@inject INavigationInterception NavigationInterception + +Internal navigation + +Prevented navigations: @_numPreventedNavigations + + + +@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; + } +} diff --git a/src/Components/test/testassets/TestServer/LockedNavigationStartup.cs b/src/Components/test/testassets/TestServer/LockedNavigationStartup.cs new file mode 100644 index 000000000000..13a3c95fc053 --- /dev/null +++ b/src/Components/test/testassets/TestServer/LockedNavigationStartup.cs @@ -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(); + }); + }); + } +} diff --git a/src/Components/test/testassets/TestServer/Pages/LockedNavigationHost.cshtml b/src/Components/test/testassets/TestServer/Pages/LockedNavigationHost.cshtml new file mode 100644 index 000000000000..5fe918596090 --- /dev/null +++ b/src/Components/test/testassets/TestServer/Pages/LockedNavigationHost.cshtml @@ -0,0 +1,33 @@ +@page "/locked-navigation" +@using BasicTestApp.RouterTest + + + + + Locked navigation + + + + + + @* + So that E2E tests can make assertions about both the prerendered and + interactive states, we only load the .js file when told to. + *@ +
+ + + + + + + + diff --git a/src/Components/test/testassets/TestServer/Program.cs b/src/Components/test/testassets/TestServer/Program.cs index 73b7875fbfdf..80c07b6da0a9 100644 --- a/src/Components/test/testassets/TestServer/Program.cs +++ b/src/Components/test/testassets/TestServer/Program.cs @@ -20,6 +20,7 @@ public static async Task Main(string[] args) ["CORS (WASM)"] = (BuildWebHost(CreateAdditionalArgs(args)), "/subdir"), ["Prerendering (Server-side)"] = (BuildWebHost(CreateAdditionalArgs(args)), "/prerendered"), ["Deferred component content (Server-side)"] = (BuildWebHost(CreateAdditionalArgs(args)), "/deferred-component-content"), + ["Locked navigation (Server-side)"] = (BuildWebHost(CreateAdditionalArgs(args)), "/locked-navigation"), ["Client-side with fallback"] = (BuildWebHost(CreateAdditionalArgs(args)), "/fallback"), ["Multiple components (Server-side)"] = (BuildWebHost(CreateAdditionalArgs(args)), "/multiple-components"), ["Save state"] = (BuildWebHost(CreateAdditionalArgs(args)), "/save-state"),