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"; +}