diff --git a/src/Components/Components/src/Microsoft.AspNetCore.Components.csproj b/src/Components/Components/src/Microsoft.AspNetCore.Components.csproj
index f165e6f82489..c8cf75f4714a 100644
--- a/src/Components/Components/src/Microsoft.AspNetCore.Components.csproj
+++ b/src/Components/Components/src/Microsoft.AspNetCore.Components.csproj
@@ -26,6 +26,7 @@
+
diff --git a/src/Components/Components/src/Routing/RouteTable.cs b/src/Components/Components/src/Routing/RouteTable.cs
index f56beec826b9..fe5bc0acf67e 100644
--- a/src/Components/Components/src/Routing/RouteTable.cs
+++ b/src/Components/Components/src/Routing/RouteTable.cs
@@ -78,6 +78,8 @@ public void Route(RouteContext routeContext)
private static void ProcessParameters(InboundRouteEntry entry, RouteValueDictionary routeValues)
{
+ routeValues.Remove(ComponentsConstants.AllowRenderDuringPendingNavigationKey);
+
// Add null values for unused route parameters.
if (entry.UnusedRouteParameterNames != null)
{
diff --git a/src/Components/Components/src/Routing/Router.cs b/src/Components/Components/src/Routing/Router.cs
index ecb69fe2cf63..df7154f86303 100644
--- a/src/Components/Components/src/Routing/Router.cs
+++ b/src/Components/Components/src/Routing/Router.cs
@@ -220,11 +220,14 @@ private void ClearRouteCaches()
internal virtual void Refresh(bool isNavigationIntercepted)
{
+ var providerRouteData = RoutingStateProvider?.RouteData;
+ var allowRenderDuringPendingNavigation = TryConsumeAllowRenderDuringPendingNavigation(providerRouteData);
+
// If an `OnNavigateAsync` task is currently in progress, then wait
// for it to complete before rendering. Note: because _previousOnNavigateTask
// is initialized to a CompletedTask on initialization, this will still
// allow first-render to complete successfully.
- if (_previousOnNavigateTask.Status != TaskStatus.RanToCompletion)
+ if (_previousOnNavigateTask.Status != TaskStatus.RanToCompletion && !allowRenderDuringPendingNavigation)
{
if (Navigating != null)
{
@@ -239,7 +242,7 @@ internal virtual void Refresh(bool isNavigationIntercepted)
ComponentsActivityHandle activityHandle;
// In order to avoid routing twice we check for RouteData
- if (RoutingStateProvider?.RouteData is { } endpointRouteData)
+ if (providerRouteData is { } endpointRouteData)
{
activityHandle = RecordDiagnostics(endpointRouteData.PageType.FullName, endpointRouteData.Template);
@@ -312,6 +315,17 @@ internal virtual void Refresh(bool isNavigationIntercepted)
_renderHandle.ComponentActivitySource?.StopNavigateActivity(activityHandle, null);
}
+ private static bool TryConsumeAllowRenderDuringPendingNavigation(RouteData? routeData)
+ {
+ if (routeData?.RouteValues.TryGetValue(ComponentsConstants.AllowRenderDuringPendingNavigationKey, out var value) == true && value is true)
+ {
+ (routeData.RouteValues as IDictionary)?.Remove(ComponentsConstants.AllowRenderDuringPendingNavigationKey);
+ return true;
+ }
+
+ return false;
+ }
+
private ComponentsActivityHandle RecordDiagnostics(string componentType, string template)
{
ComponentsActivityHandle activityHandle = default;
diff --git a/src/Components/Endpoints/src/Microsoft.AspNetCore.Components.Endpoints.csproj b/src/Components/Endpoints/src/Microsoft.AspNetCore.Components.Endpoints.csproj
index de0329c94e63..bce74f5aebdf 100644
--- a/src/Components/Endpoints/src/Microsoft.AspNetCore.Components.Endpoints.csproj
+++ b/src/Components/Endpoints/src/Microsoft.AspNetCore.Components.Endpoints.csproj
@@ -33,6 +33,7 @@
+
diff --git a/src/Components/Endpoints/src/RazorComponentEndpointInvoker.cs b/src/Components/Endpoints/src/RazorComponentEndpointInvoker.cs
index 22119d0522c9..af9872137e64 100644
--- a/src/Components/Endpoints/src/RazorComponentEndpointInvoker.cs
+++ b/src/Components/Endpoints/src/RazorComponentEndpointInvoker.cs
@@ -87,7 +87,8 @@ await _renderer.InitializeStandardComponentServicesAsync(
context,
componentType: pageComponent,
handler: result.HandlerName,
- form: result.HandlerName != null && context.Request.HasFormContentType ? await context.Request.ReadFormAsync() : null);
+ form: result.HandlerName != null && context.Request.HasFormContentType ? await context.Request.ReadFormAsync() : null,
+ allowRenderingDuringPendingNavigation: isReExecuted);
// Matches MVC's MemoryPoolHttpResponseStreamWriterFactory.DefaultBufferSize
var defaultBufferSize = 16 * 1024;
diff --git a/src/Components/Endpoints/src/Rendering/EndpointHtmlRenderer.cs b/src/Components/Endpoints/src/Rendering/EndpointHtmlRenderer.cs
index 4d95f68c0fc5..08fa3c39950f 100644
--- a/src/Components/Endpoints/src/Rendering/EndpointHtmlRenderer.cs
+++ b/src/Components/Endpoints/src/Rendering/EndpointHtmlRenderer.cs
@@ -81,12 +81,13 @@ internal async Task InitializeStandardComponentServicesAsync(
HttpContext httpContext,
[DynamicallyAccessedMembers(Component)] Type? componentType = null,
string? handler = null,
- IFormCollection? form = null)
+ IFormCollection? form = null,
+ bool allowRenderingDuringPendingNavigation = false)
{
var navigationManager = httpContext.RequestServices.GetRequiredService();
((IHostEnvironmentNavigationManager)navigationManager)?.Initialize(
- GetContextBaseUri(httpContext.Request),
- GetFullUri(httpContext.Request),
+ GetContextBaseUri(httpContext.Request),
+ GetFullUri(httpContext.Request),
uri => GetErrorHandledTask(OnNavigateTo(uri)));
navigationManager?.OnNotFound += (sender, args) => NotFoundEventArgs = args;
@@ -132,7 +133,13 @@ internal async Task InitializeStandardComponentServicesAsync(
{
// Saving RouteData to avoid routing twice in Router component
var routingStateProvider = httpContext.RequestServices.GetRequiredService();
- routingStateProvider.RouteData = new RouteData(componentType, httpContext.GetRouteData().Values);
+ var routeValues = new RouteValueDictionary(httpContext.GetRouteData().Values);
+ if (allowRenderingDuringPendingNavigation)
+ {
+ routeValues[ComponentsConstants.AllowRenderDuringPendingNavigationKey] = true;
+ }
+
+ routingStateProvider.RouteData = new RouteData(componentType, routeValues);
if (httpContext.GetEndpoint() is RouteEndpoint routeEndpoint)
{
routingStateProvider.RouteData.Template = routeEndpoint.RoutePattern.RawText;
diff --git a/src/Components/test/E2ETest/ServerRenderingTests/NoInteractivityTest.cs b/src/Components/test/E2ETest/ServerRenderingTests/NoInteractivityTest.cs
index a6421eb94689..af34a1845052 100644
--- a/src/Components/test/E2ETest/ServerRenderingTests/NoInteractivityTest.cs
+++ b/src/Components/test/E2ETest/ServerRenderingTests/NoInteractivityTest.cs
@@ -2,6 +2,7 @@
// The .NET Foundation licenses this file to you under the MIT license.
using System.Net.Http;
+using System;
using Components.TestServer.RazorComponents;
using Microsoft.AspNetCore.Components.E2ETest;
using Microsoft.AspNetCore.Components.E2ETest.Infrastructure;
@@ -125,6 +126,42 @@ public void BrowserNavigationToNotExistingPath_ReExecutesTo404(bool streaming)
AssertReExecutionPageRendered();
}
+ [Fact]
+ public void BrowserNavigationToNotExistingPath_WithOnNavigateAsync_ReExecutesTo404()
+ {
+ AppContext.SetSwitch("Microsoft.AspNetCore.Components.Endpoints.NavigationManager.DisableThrowNavigationException", isEnabled: true);
+
+ // using query for controlling router parameters does not work in re-execution scenario, we have to rely on other communication channel
+ const string useOnNavigateAsyncSwitch = "Components.TestServer.RazorComponents.UseOnNavigateAsync";
+ AppContext.SetSwitch(useOnNavigateAsyncSwitch, true);
+ try
+ {
+ Navigate($"{ServerPathBase}/reexecution/not-existing-page");
+ AssertReExecutionPageRendered();
+ }
+ finally
+ {
+ AppContext.SetSwitch(useOnNavigateAsyncSwitch, false);
+ }
+ }
+
+ [Fact]
+ public void BrowserNavigationToLazyLoadedRoute_WaitsForOnNavigateAsyncGuard()
+ {
+ const string navigationGuardSwitch = "Components.TestServer.RazorComponents.UseNavigationCompletionGuard";
+ AppContext.SetSwitch(navigationGuardSwitch, true);
+
+ try
+ {
+ Navigate($"{ServerPathBase}/routing/with-lazy-assembly");
+ Browser.Equal("Lazy route rendered", () => Browser.Exists(By.Id("lazy-route-status")).Text);
+ }
+ finally
+ {
+ AppContext.SetSwitch(navigationGuardSwitch, false);
+ }
+ }
+
private void AssertReExecutionPageRendered() =>
Browser.Equal("Welcome On Page Re-executed After Not Found Event", () => Browser.Exists(By.Id("test-info")).Text);
@@ -192,7 +229,7 @@ public void NotFoundSetOnInitialization_ResponseNotStarted_SSR(bool hasReExecuti
[InlineData(false, true)]
[InlineData(false, false)]
// This tests the application subscribing to OnNotFound event and setting NotFoundEventArgs.Path, opposed to the framework doing it for the app.
- public void NotFoundSetOnInitialization_ApplicationSubscribesToNotFoundEventToSetNotFoundPath_SSR (bool streaming, bool customRouter)
+ public void NotFoundSetOnInitialization_ApplicationSubscribesToNotFoundEventToSetNotFoundPath_SSR(bool streaming, bool customRouter)
{
string streamingPath = streaming ? "-streaming" : "";
string testUrl = $"{ServerPathBase}/set-not-found-ssr{streamingPath}?useCustomRouter={customRouter}&appSetsEventArgsPath=true";
diff --git a/src/Components/test/testassets/Components.TestServer/RazorComponents/App.razor b/src/Components/test/testassets/Components.TestServer/RazorComponents/App.razor
index f547144c8fdd..c21fa05d746a 100644
--- a/src/Components/test/testassets/Components.TestServer/RazorComponents/App.razor
+++ b/src/Components/test/testassets/Components.TestServer/RazorComponents/App.razor
@@ -3,6 +3,10 @@
@using Components.WasmMinimal.Pages.NotFound
@using TestContentPackage.NotFound
@using Components.TestServer.RazorComponents
+@using Microsoft.AspNetCore.Components
+@using Microsoft.AspNetCore.Components.Routing
+@using Microsoft.AspNetCore.Components.Web
+@using System.Threading.Tasks
@code {
[Parameter]
@@ -17,8 +21,12 @@
[SupplyParameterFromQuery(Name = "appSetsEventArgsPath")]
public bool AppSetsEventArgsPath { get; set; }
+ private const string UseOnNavigateAsyncSwitchName = "Components.TestServer.RazorComponents.UseOnNavigateAsync";
+
private Type? NotFoundPageType { get; set; }
private NavigationManager _navigationManager = default!;
+ private bool ShouldDelayOnNavigateAsync =>
+ AppContext.TryGetSwitch(UseOnNavigateAsyncSwitchName, out var switchEnabled) && switchEnabled;
[Inject]
private NavigationManager NavigationManager
@@ -70,6 +78,26 @@
_navigationManager.OnNotFound -= OnNotFoundEvent;
}
}
+
+ private Task HandleOnNavigateAsync(NavigationContext args)
+ {
+ if (NavigationCompletionTracker.TryGetGuardTask(args.Path, out var guardTask))
+ {
+ return guardTask;
+ }
+
+ if (!ShouldDelayOnNavigateAsync)
+ {
+ return Task.CompletedTask;
+ }
+
+ return PerformOnNavigateAsyncWork();
+ }
+
+ private async Task PerformOnNavigateAsyncWork()
+ {
+ await Task.Yield();
+ }
}
@@ -93,7 +121,7 @@
{
@if (NotFoundPageType is not null)
{
-
+
@@ -102,7 +130,7 @@
}
else
{
-
+
diff --git a/src/Components/test/testassets/Components.TestServer/RazorComponents/NavigationCompletionTracker.cs b/src/Components/test/testassets/Components.TestServer/RazorComponents/NavigationCompletionTracker.cs
new file mode 100644
index 000000000000..0f22080a7f3b
--- /dev/null
+++ b/src/Components/test/testassets/Components.TestServer/RazorComponents/NavigationCompletionTracker.cs
@@ -0,0 +1,65 @@
+// Licensed to the .NET Foundation under one or more agreements.
+// The .NET Foundation licenses this file to you under the MIT license.
+
+using System;
+using System.Threading;
+using System.Threading.Tasks;
+
+namespace Components.TestServer.RazorComponents;
+
+internal static class NavigationCompletionTracker
+{
+ internal const string GuardSwitchName = "Components.TestServer.RazorComponents.UseNavigationCompletionGuard";
+
+ private const string TrackedPathSuffix = "with-lazy-assembly";
+ private static int _isNavigationTracked;
+ private static int _isNavigationCompleted;
+
+ public static bool TryGetGuardTask(string? path, out Task guardTask)
+ {
+ if (!IsGuardEnabledForPath(path))
+ {
+ guardTask = Task.CompletedTask;
+ return false;
+ }
+
+ guardTask = TrackNavigationAsync();
+ return true;
+ }
+
+ public static void AssertNavigationCompleted()
+ {
+ if (Volatile.Read(ref _isNavigationTracked) == 1 && Volatile.Read(ref _isNavigationCompleted) == 0)
+ {
+ throw new InvalidOperationException("Navigation finished before OnNavigateAsync work completed.");
+ }
+
+ Volatile.Write(ref _isNavigationTracked, 0);
+ }
+
+ private static bool IsGuardEnabledForPath(string? path)
+ {
+ if (!AppContext.TryGetSwitch(GuardSwitchName, out var isEnabled) || !isEnabled)
+ {
+ return false;
+ }
+
+ return path is not null && path.EndsWith(TrackedPathSuffix, StringComparison.OrdinalIgnoreCase);
+ }
+
+ private static async Task TrackNavigationAsync()
+ {
+ Volatile.Write(ref _isNavigationTracked, 1);
+ Volatile.Write(ref _isNavigationCompleted, 0);
+
+ try
+ {
+ await Task.Yield();
+ await Task.Delay(TimeSpan.FromMilliseconds(50)).ConfigureAwait(false);
+ }
+ finally
+ {
+ Volatile.Write(ref _isNavigationCompleted, 1);
+ }
+ }
+}
diff --git a/src/Components/test/testassets/Components.TestServer/RazorComponents/Pages/Routing/WithLazyAssembly.razor b/src/Components/test/testassets/Components.TestServer/RazorComponents/Pages/Routing/WithLazyAssembly.razor
new file mode 100644
index 000000000000..7abc58385177
--- /dev/null
+++ b/src/Components/test/testassets/Components.TestServer/RazorComponents/Pages/Routing/WithLazyAssembly.razor
@@ -0,0 +1,12 @@
+@page "/routing/with-lazy-assembly"
+@using Components.TestServer.RazorComponents;
+
+Lazy route rendered
+
+@code
+{
+ protected override void OnInitialized()
+ {
+ NavigationCompletionTracker.AssertNavigationCompleted();
+ }
+}
diff --git a/src/Shared/Components/ComponentsConstants.cs b/src/Shared/Components/ComponentsConstants.cs
new file mode 100644
index 000000000000..1db716a22f10
--- /dev/null
+++ b/src/Shared/Components/ComponentsConstants.cs
@@ -0,0 +1,9 @@
+// Licensed to the .NET Foundation under one or more agreements.
+// The .NET Foundation licenses this file to you under the MIT license.
+
+namespace Microsoft.AspNetCore.Components;
+
+internal static class ComponentsConstants
+{
+ internal const string AllowRenderDuringPendingNavigationKey = "__BlazorAllowRenderDuringPendingNavigation";
+}