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"),