From 0be84e4c83006b4a1c409040470ca695161c63e4 Mon Sep 17 00:00:00 2001 From: Ilona Tomkowicz Date: Thu, 3 Apr 2025 15:28:56 +0200 Subject: [PATCH 01/21] Rise even instead of throwing. --- .../HttpNavigationManager.cs | 19 ++++++++++++++++++- .../NavigationEventArgs.cs | 14 ++++++++++++++ .../src/Rendering/EndpointHtmlRenderer.cs | 9 +++++++++ .../NoInteractivityTest.cs | 10 ++++++++++ .../Pages/Routing/RoutingTestCases.razor | 10 ++-------- .../Pages/Routing/SSRRedirection.razor | 17 +++++++++++++++++ 6 files changed, 70 insertions(+), 9 deletions(-) create mode 100644 src/Components/Endpoints/src/DependencyInjection/NavigationEventArgs.cs create mode 100644 src/Components/test/testassets/Components.TestServer/RazorComponents/Pages/Routing/SSRRedirection.razor diff --git a/src/Components/Endpoints/src/DependencyInjection/HttpNavigationManager.cs b/src/Components/Endpoints/src/DependencyInjection/HttpNavigationManager.cs index e9bb98f2d1c2..ab9e6d5e1f7a 100644 --- a/src/Components/Endpoints/src/DependencyInjection/HttpNavigationManager.cs +++ b/src/Components/Endpoints/src/DependencyInjection/HttpNavigationManager.cs @@ -7,11 +7,28 @@ namespace Microsoft.AspNetCore.Components.Endpoints; internal sealed class HttpNavigationManager : NavigationManager, IHostEnvironmentNavigationManager { + private const string EnableThrowNavigatioExceptionSwitchKey = "Microsoft.AspNetCore.Components.Endpoints.EnableThrowNavigatioException"; + private static readonly bool _throwNavigationException = AppContext.TryGetSwitch(EnableThrowNavigatioExceptionSwitchKey, out var switchValue) && switchValue; + + private EventHandler? _onNavigateTo; + public event EventHandler OnNavigateTo + { + add => _onNavigateTo += value; + remove => _onNavigateTo -= value; + } + void IHostEnvironmentNavigationManager.Initialize(string baseUri, string uri) => Initialize(baseUri, uri); protected override void NavigateToCore(string uri, NavigationOptions options) { var absoluteUriString = ToAbsoluteUri(uri).AbsoluteUri; - throw new NavigationException(absoluteUriString); + if (_throwNavigationException) + { + throw new NavigationException(absoluteUriString); + } + else + { + _onNavigateTo?.Invoke(this, new NavigationEventArgs(absoluteUriString)); + } } } diff --git a/src/Components/Endpoints/src/DependencyInjection/NavigationEventArgs.cs b/src/Components/Endpoints/src/DependencyInjection/NavigationEventArgs.cs new file mode 100644 index 000000000000..6a6881055904 --- /dev/null +++ b/src/Components/Endpoints/src/DependencyInjection/NavigationEventArgs.cs @@ -0,0 +1,14 @@ +// 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.Endpoints; + +internal class NavigationEventArgs : EventArgs +{ + public string Uri { get; } + + public NavigationEventArgs(string uri) + { + Uri = uri; + } +} \ No newline at end of file diff --git a/src/Components/Endpoints/src/Rendering/EndpointHtmlRenderer.cs b/src/Components/Endpoints/src/Rendering/EndpointHtmlRenderer.cs index 965858d807c0..a114cf2d1115 100644 --- a/src/Components/Endpoints/src/Rendering/EndpointHtmlRenderer.cs +++ b/src/Components/Endpoints/src/Rendering/EndpointHtmlRenderer.cs @@ -84,6 +84,10 @@ internal async Task InitializeStandardComponentServicesAsync( if (navigationManager != null) { navigationManager.OnNotFound += SetNotFoundResponse; + if (navigationManager is HttpNavigationManager httpNavigationManager) + { + httpNavigationManager.OnNavigateTo += OnNavigateTo; + } } var authenticationStateProvider = httpContext.RequestServices.GetService(); @@ -135,6 +139,11 @@ internal async Task InitializeStandardComponentServicesAsync( } } + private void OnNavigateTo(object? sender, NavigationEventArgs args) + { + _httpContext.Response.Redirect(args.Uri); + } + private static void InitializeResourceCollection(HttpContext httpContext) { diff --git a/src/Components/test/E2ETest/ServerRenderingTests/NoInteractivityTest.cs b/src/Components/test/E2ETest/ServerRenderingTests/NoInteractivityTest.cs index 0b30003f4b70..9cd407a3d8ca 100644 --- a/src/Components/test/E2ETest/ServerRenderingTests/NoInteractivityTest.cs +++ b/src/Components/test/E2ETest/ServerRenderingTests/NoInteractivityTest.cs @@ -64,4 +64,14 @@ public void CanUseServerAuthenticationStateByDefault() Browser.Equal("True", () => Browser.FindElement(By.Id("is-in-test-role-1")).Text); Browser.Equal("True", () => Browser.FindElement(By.Id("is-in-test-role-2")).Text); } + + [Fact] + public async Task NavigatesWithoutInteractivityByRequestRedirection() + { + Navigate($"{ServerPathBase}/routing/ssr-navigate-to"); + await Task.Delay(TimeSpan.FromSeconds(30)); + Browser.Equal("Click submit to navigate to home", () => Browser.Exists(By.Id("test-info")).Text); + Browser.Click(By.Id("redirectButton")); + Browser.Equal("Routing test cases", () => Browser.Exists(By.Id("test-info")).Text); + } } diff --git a/src/Components/test/testassets/Components.TestServer/RazorComponents/Pages/Routing/RoutingTestCases.razor b/src/Components/test/testassets/Components.TestServer/RazorComponents/Pages/Routing/RoutingTestCases.razor index e923f23ee88a..9b49bfd183ab 100644 --- a/src/Components/test/testassets/Components.TestServer/RazorComponents/Pages/Routing/RoutingTestCases.razor +++ b/src/Components/test/testassets/Components.TestServer/RazorComponents/Pages/Routing/RoutingTestCases.razor @@ -1,5 +1,5 @@ @page "/routing" -

Routing test cases

+

Routing test cases

diff --git a/src/Components/test/testassets/Components.TestServer/RazorComponents/Pages/Routing/SSRRedirection.razor b/src/Components/test/testassets/Components.TestServer/RazorComponents/Pages/Routing/SSRRedirection.razor new file mode 100644 index 000000000000..252d8675dc5f --- /dev/null +++ b/src/Components/test/testassets/Components.TestServer/RazorComponents/Pages/Routing/SSRRedirection.razor @@ -0,0 +1,17 @@ +@page "/routing/ssr-navigate-to" +@using Microsoft.AspNetCore.Components.Forms +@inject NavigationManager NavigationManager + +

Click submit to navigate to home

+
+ + + + +@code { + private void Submit() + { + NavigationManager.NavigateTo("/subdir/routing"); + } +} + From 2e5f7dc6d72073753ec0f50b951a85a30657417b Mon Sep 17 00:00:00 2001 From: Ilona Tomkowicz Date: Thu, 3 Apr 2025 15:57:40 +0200 Subject: [PATCH 02/21] Clean up, delay not needed. --- .../test/E2ETest/ServerRenderingTests/NoInteractivityTest.cs | 3 +-- 1 file changed, 1 insertion(+), 2 deletions(-) diff --git a/src/Components/test/E2ETest/ServerRenderingTests/NoInteractivityTest.cs b/src/Components/test/E2ETest/ServerRenderingTests/NoInteractivityTest.cs index 9cd407a3d8ca..5bf11211735d 100644 --- a/src/Components/test/E2ETest/ServerRenderingTests/NoInteractivityTest.cs +++ b/src/Components/test/E2ETest/ServerRenderingTests/NoInteractivityTest.cs @@ -66,10 +66,9 @@ public void CanUseServerAuthenticationStateByDefault() } [Fact] - public async Task NavigatesWithoutInteractivityByRequestRedirection() + public void NavigatesWithoutInteractivityByRequestRedirection() { Navigate($"{ServerPathBase}/routing/ssr-navigate-to"); - await Task.Delay(TimeSpan.FromSeconds(30)); Browser.Equal("Click submit to navigate to home", () => Browser.Exists(By.Id("test-info")).Text); Browser.Click(By.Id("redirectButton")); Browser.Equal("Routing test cases", () => Browser.Exists(By.Id("test-info")).Text); From 5ebaaacc57fe17262143e8a416fae6bb8250b532 Mon Sep 17 00:00:00 2001 From: Ilona Tomkowicz Date: Thu, 3 Apr 2025 16:03:01 +0200 Subject: [PATCH 03/21] Fix typo + test old way of workign as well. --- .../src/DependencyInjection/HttpNavigationManager.cs | 4 ++-- .../E2ETest/ServerRenderingTests/NoInteractivityTest.cs | 7 +++++-- 2 files changed, 7 insertions(+), 4 deletions(-) diff --git a/src/Components/Endpoints/src/DependencyInjection/HttpNavigationManager.cs b/src/Components/Endpoints/src/DependencyInjection/HttpNavigationManager.cs index ab9e6d5e1f7a..523a81d44f0e 100644 --- a/src/Components/Endpoints/src/DependencyInjection/HttpNavigationManager.cs +++ b/src/Components/Endpoints/src/DependencyInjection/HttpNavigationManager.cs @@ -7,8 +7,8 @@ namespace Microsoft.AspNetCore.Components.Endpoints; internal sealed class HttpNavigationManager : NavigationManager, IHostEnvironmentNavigationManager { - private const string EnableThrowNavigatioExceptionSwitchKey = "Microsoft.AspNetCore.Components.Endpoints.EnableThrowNavigatioException"; - private static readonly bool _throwNavigationException = AppContext.TryGetSwitch(EnableThrowNavigatioExceptionSwitchKey, out var switchValue) && switchValue; + private const string EnableThrowNavigationException = "Microsoft.AspNetCore.Components.Endpoints.EnableThrowNavigationException"; + private static readonly bool _throwNavigationException = AppContext.TryGetSwitch(EnableThrowNavigationException, out var switchValue) && switchValue; private EventHandler? _onNavigateTo; public event EventHandler OnNavigateTo diff --git a/src/Components/test/E2ETest/ServerRenderingTests/NoInteractivityTest.cs b/src/Components/test/E2ETest/ServerRenderingTests/NoInteractivityTest.cs index 5bf11211735d..9b6f1c0add2f 100644 --- a/src/Components/test/E2ETest/ServerRenderingTests/NoInteractivityTest.cs +++ b/src/Components/test/E2ETest/ServerRenderingTests/NoInteractivityTest.cs @@ -65,9 +65,12 @@ public void CanUseServerAuthenticationStateByDefault() Browser.Equal("True", () => Browser.FindElement(By.Id("is-in-test-role-2")).Text); } - [Fact] - public void NavigatesWithoutInteractivityByRequestRedirection() + [Theory] + [InlineData(true)] + [InlineData(false)] + public void NavigatesWithoutInteractivityByRequestRedirection(bool controlFlowByException) { + AppContext.SetSwitch("Microsoft.AspNetCore.Components.Endpoints.EnableThrowNavigationException", isEnabled: controlFlowByException); Navigate($"{ServerPathBase}/routing/ssr-navigate-to"); Browser.Equal("Click submit to navigate to home", () => Browser.Exists(By.Id("test-info")).Text); Browser.Click(By.Id("redirectButton")); From 1ea43be3345679b021f1c9a28072c6654c8ee062 Mon Sep 17 00:00:00 2001 From: Ilona Tomkowicz Date: Fri, 4 Apr 2025 10:37:43 +0200 Subject: [PATCH 04/21] Update name to be full namespace + remove readonly. --- .../src/DependencyInjection/HttpNavigationManager.cs | 7 ++++--- 1 file changed, 4 insertions(+), 3 deletions(-) diff --git a/src/Components/Endpoints/src/DependencyInjection/HttpNavigationManager.cs b/src/Components/Endpoints/src/DependencyInjection/HttpNavigationManager.cs index 523a81d44f0e..8f94955fd0fb 100644 --- a/src/Components/Endpoints/src/DependencyInjection/HttpNavigationManager.cs +++ b/src/Components/Endpoints/src/DependencyInjection/HttpNavigationManager.cs @@ -7,8 +7,9 @@ namespace Microsoft.AspNetCore.Components.Endpoints; internal sealed class HttpNavigationManager : NavigationManager, IHostEnvironmentNavigationManager { - private const string EnableThrowNavigationException = "Microsoft.AspNetCore.Components.Endpoints.EnableThrowNavigationException"; - private static readonly bool _throwNavigationException = AppContext.TryGetSwitch(EnableThrowNavigationException, out var switchValue) && switchValue; + private const string EnableThrowNavigationException = "Microsoft.AspNetCore.Components.Endpoints.HttpNavigationManager.EnableThrowNavigationException"; + private static bool ThrowNavigationException => + AppContext.TryGetSwitch(EnableThrowNavigationException, out var switchValue) && switchValue; private EventHandler? _onNavigateTo; public event EventHandler OnNavigateTo @@ -22,7 +23,7 @@ public event EventHandler OnNavigateTo protected override void NavigateToCore(string uri, NavigationOptions options) { var absoluteUriString = ToAbsoluteUri(uri).AbsoluteUri; - if (_throwNavigationException) + if (ThrowNavigationException) { throw new NavigationException(absoluteUriString); } From e9bd57e079b7e7e547aba5354ae5b250a8d7fcae Mon Sep 17 00:00:00 2001 From: Ilona Tomkowicz Date: Mon, 7 Apr 2025 12:03:07 +0200 Subject: [PATCH 05/21] Feedback - interactive SSR updates required exposing some methods. --- .../Components/src/NavigationManager.cs | 3 +++ .../Components/src/PublicAPI.Unshipped.txt | 5 ++++ .../IHostEnvironmentNavigationManager.cs | 8 +++++++ .../src/Routing/NavigationEventArgs.cs | 24 +++++++++++++++++++ .../HttpNavigationManager.cs | 4 ---- .../NavigationEventArgs.cs | 14 ----------- .../src/Rendering/EndpointHtmlRenderer.cs | 4 ++-- .../test/RazorComponentResultTest.cs | 8 +++++++ .../src/Circuits/RemoteNavigationManager.cs | 24 +++++++++++++++++-- .../ServerRenderingTests/InteractivityTest.cs | 12 ++++++++++ 10 files changed, 84 insertions(+), 22 deletions(-) create mode 100644 src/Components/Components/src/Routing/NavigationEventArgs.cs delete mode 100644 src/Components/Endpoints/src/DependencyInjection/NavigationEventArgs.cs diff --git a/src/Components/Components/src/NavigationManager.cs b/src/Components/Components/src/NavigationManager.cs index d721133dd02d..1a05aa6f42ee 100644 --- a/src/Components/Components/src/NavigationManager.cs +++ b/src/Components/Components/src/NavigationManager.cs @@ -61,6 +61,9 @@ public event EventHandler OnNotFound // The URI. Always represented an absolute URI. private string? _uri; private bool _isInitialized; + private const string EnableThrowNavigationException = "Microsoft.AspNetCore.Components.Endpoints.HttpNavigationManager.EnableThrowNavigationException"; + protected static bool ThrowNavigationException => + AppContext.TryGetSwitch(EnableThrowNavigationException, out var switchValue) && switchValue; /// /// Gets or sets the current base URI. The is always represented as an absolute URI in string form with trailing slash. diff --git a/src/Components/Components/src/PublicAPI.Unshipped.txt b/src/Components/Components/src/PublicAPI.Unshipped.txt index 5b2d8d1cec3f..16e01cd8bdab 100644 --- a/src/Components/Components/src/PublicAPI.Unshipped.txt +++ b/src/Components/Components/src/PublicAPI.Unshipped.txt @@ -11,3 +11,8 @@ Microsoft.AspNetCore.Components.SupplyParameterFromPersistentComponentStateAttri Microsoft.Extensions.DependencyInjection.SupplyParameterFromPersistentComponentStateProviderServiceCollectionExtensions static Microsoft.AspNetCore.Components.Infrastructure.RegisterPersistentComponentStateServiceCollectionExtensions.AddPersistentServiceRegistration(Microsoft.Extensions.DependencyInjection.IServiceCollection! services, Microsoft.AspNetCore.Components.IComponentRenderMode! componentRenderMode) -> Microsoft.Extensions.DependencyInjection.IServiceCollection! static Microsoft.Extensions.DependencyInjection.SupplyParameterFromPersistentComponentStateProviderServiceCollectionExtensions.AddSupplyValueFromPersistentComponentStateProvider(this Microsoft.Extensions.DependencyInjection.IServiceCollection! services) -> Microsoft.Extensions.DependencyInjection.IServiceCollection! +static Microsoft.AspNetCore.Components.NavigationManager.ThrowNavigationException.get -> bool +Microsoft.AspNetCore.Components.Routing.NavigationEventArgs +Microsoft.AspNetCore.Components.Routing.NavigationEventArgs.Uri.get -> string! +Microsoft.AspNetCore.Components.Routing.NavigationEventArgs.NavigationEventArgs(string! uri) -> void +Microsoft.AspNetCore.Components.Routing.IHostEnvironmentNavigationManager.OnNavigateTo -> System.EventHandler! \ No newline at end of file diff --git a/src/Components/Components/src/Routing/IHostEnvironmentNavigationManager.cs b/src/Components/Components/src/Routing/IHostEnvironmentNavigationManager.cs index cbe9274f9792..b7115b6757bd 100644 --- a/src/Components/Components/src/Routing/IHostEnvironmentNavigationManager.cs +++ b/src/Components/Components/src/Routing/IHostEnvironmentNavigationManager.cs @@ -15,4 +15,12 @@ public interface IHostEnvironmentNavigationManager /// The base URI. /// The absolute URI. void Initialize(string baseUri, string uri); + + /// + /// An event that is triggered when SSR navigation occurs. + /// + /// + /// This event allows subscribers to respond to SSR navigation actions, such as updating state or performing side effects. + /// + event EventHandler OnNavigateTo; } diff --git a/src/Components/Components/src/Routing/NavigationEventArgs.cs b/src/Components/Components/src/Routing/NavigationEventArgs.cs new file mode 100644 index 000000000000..5c7fb3b47091 --- /dev/null +++ b/src/Components/Components/src/Routing/NavigationEventArgs.cs @@ -0,0 +1,24 @@ +// 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.Routing; + +/// +/// for . +/// +public class NavigationEventArgs : EventArgs +{ + /// + /// Gets the URI of the navigation event. + /// + public string Uri { get; } + + /// + /// Initializes a new instance of . + /// + /// The URI of the navigation event. + public NavigationEventArgs(string uri) + { + Uri = uri; + } +} \ No newline at end of file diff --git a/src/Components/Endpoints/src/DependencyInjection/HttpNavigationManager.cs b/src/Components/Endpoints/src/DependencyInjection/HttpNavigationManager.cs index 8f94955fd0fb..b7137bf05f2b 100644 --- a/src/Components/Endpoints/src/DependencyInjection/HttpNavigationManager.cs +++ b/src/Components/Endpoints/src/DependencyInjection/HttpNavigationManager.cs @@ -7,10 +7,6 @@ namespace Microsoft.AspNetCore.Components.Endpoints; internal sealed class HttpNavigationManager : NavigationManager, IHostEnvironmentNavigationManager { - private const string EnableThrowNavigationException = "Microsoft.AspNetCore.Components.Endpoints.HttpNavigationManager.EnableThrowNavigationException"; - private static bool ThrowNavigationException => - AppContext.TryGetSwitch(EnableThrowNavigationException, out var switchValue) && switchValue; - private EventHandler? _onNavigateTo; public event EventHandler OnNavigateTo { diff --git a/src/Components/Endpoints/src/DependencyInjection/NavigationEventArgs.cs b/src/Components/Endpoints/src/DependencyInjection/NavigationEventArgs.cs deleted file mode 100644 index 6a6881055904..000000000000 --- a/src/Components/Endpoints/src/DependencyInjection/NavigationEventArgs.cs +++ /dev/null @@ -1,14 +0,0 @@ -// 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.Endpoints; - -internal class NavigationEventArgs : EventArgs -{ - public string Uri { get; } - - public NavigationEventArgs(string uri) - { - Uri = uri; - } -} \ No newline at end of file diff --git a/src/Components/Endpoints/src/Rendering/EndpointHtmlRenderer.cs b/src/Components/Endpoints/src/Rendering/EndpointHtmlRenderer.cs index a114cf2d1115..7686e0168e76 100644 --- a/src/Components/Endpoints/src/Rendering/EndpointHtmlRenderer.cs +++ b/src/Components/Endpoints/src/Rendering/EndpointHtmlRenderer.cs @@ -84,9 +84,9 @@ internal async Task InitializeStandardComponentServicesAsync( if (navigationManager != null) { navigationManager.OnNotFound += SetNotFoundResponse; - if (navigationManager is HttpNavigationManager httpNavigationManager) + if (navigationManager is IHostEnvironmentNavigationManager hostEnvironmentNavigationManager) { - httpNavigationManager.OnNavigateTo += OnNavigateTo; + hostEnvironmentNavigationManager.OnNavigateTo += OnNavigateTo; } } diff --git a/src/Components/Endpoints/test/RazorComponentResultTest.cs b/src/Components/Endpoints/test/RazorComponentResultTest.cs index cbeb6712402a..538271327d76 100644 --- a/src/Components/Endpoints/test/RazorComponentResultTest.cs +++ b/src/Components/Endpoints/test/RazorComponentResultTest.cs @@ -510,6 +510,14 @@ class FakeDataProtector : IDataProtector class FakeNavigationManager : NavigationManager, IHostEnvironmentNavigationManager { + private EventHandler _onNavigateTo; + + public event EventHandler OnNavigateTo + { + add => _onNavigateTo += value; + remove => _onNavigateTo -= value; + } + public new void Initialize(string baseUri, string uri) => base.Initialize(baseUri, uri); diff --git a/src/Components/Server/src/Circuits/RemoteNavigationManager.cs b/src/Components/Server/src/Circuits/RemoteNavigationManager.cs index 9fcc668d94c1..e23e7a0ac765 100644 --- a/src/Components/Server/src/Circuits/RemoteNavigationManager.cs +++ b/src/Components/Server/src/Circuits/RemoteNavigationManager.cs @@ -17,6 +17,12 @@ internal sealed partial class RemoteNavigationManager : NavigationManager, IHost private readonly ILogger _logger; private IJSRuntime _jsRuntime; private bool? _navigationLockStateBeforeJsRuntimeAttached; + private EventHandler? _onNavigateTo; + public event EventHandler OnNavigateTo + { + add => _onNavigateTo += value; + remove => _onNavigateTo -= value; + } public event EventHandler? UnhandledException; @@ -88,7 +94,14 @@ protected override void NavigateToCore(string uri, NavigationOptions options) if (_jsRuntime == null) { var absoluteUriString = ToAbsoluteUri(uri).AbsoluteUri; - throw new NavigationException(absoluteUriString); + if (ThrowNavigationException) + { + throw new NavigationException(absoluteUriString); + } + else + { + _onNavigateTo?.Invoke(this, new NavigationEventArgs(absoluteUriString)); + } } _ = PerformNavigationAsync(); @@ -129,7 +142,14 @@ public override void Refresh(bool forceReload = false) if (_jsRuntime == null) { var absoluteUriString = ToAbsoluteUri(Uri).AbsoluteUri; - throw new NavigationException(absoluteUriString); + if (ThrowNavigationException) + { + throw new NavigationException(absoluteUriString); + } + else + { + _onNavigateTo?.Invoke(this, new NavigationEventArgs(absoluteUriString)); + } } _ = RefreshAsync(); diff --git a/src/Components/test/E2ETest/ServerRenderingTests/InteractivityTest.cs b/src/Components/test/E2ETest/ServerRenderingTests/InteractivityTest.cs index 1aee9a2a03cc..f173662caab2 100644 --- a/src/Components/test/E2ETest/ServerRenderingTests/InteractivityTest.cs +++ b/src/Components/test/E2ETest/ServerRenderingTests/InteractivityTest.cs @@ -1399,4 +1399,16 @@ public void CanPersistMultiplePrerenderedStateDeclaratively_Auto_PersistsOnWebAs Browser.Equal("restored 2", () => Browser.FindElement(By.Id("auto-2")).Text); Browser.Equal("WebAssembly", () => Browser.FindElement(By.Id("render-mode-auto-2")).Text); } + + [Theory] + [InlineData(true)] + [InlineData(false)] + public void NavigatesWithInteractivityByRequestRedirection(bool controlFlowByException) + { + AppContext.SetSwitch("Microsoft.AspNetCore.Components.Endpoints.EnableThrowNavigationException", isEnabled: controlFlowByException); + Navigate($"{ServerPathBase}/routing/ssr-navigate-to"); + Browser.Equal("Click submit to navigate to home", () => Browser.Exists(By.Id("test-info")).Text); + Browser.Click(By.Id("redirectButton")); + Browser.Equal("Routing test cases", () => Browser.Exists(By.Id("test-info")).Text); + } } From ebc2537c18d75f17ee53d27582a381a54b6cd8bf Mon Sep 17 00:00:00 2001 From: Ilona Tomkowicz Date: Mon, 7 Apr 2025 13:23:53 +0200 Subject: [PATCH 06/21] Fix missing xml. --- src/Components/Components/src/NavigationManager.cs | 9 +++++++++ 1 file changed, 9 insertions(+) diff --git a/src/Components/Components/src/NavigationManager.cs b/src/Components/Components/src/NavigationManager.cs index 1a05aa6f42ee..135d6e5ad86f 100644 --- a/src/Components/Components/src/NavigationManager.cs +++ b/src/Components/Components/src/NavigationManager.cs @@ -62,6 +62,15 @@ public event EventHandler OnNotFound private string? _uri; private bool _isInitialized; private const string EnableThrowNavigationException = "Microsoft.AspNetCore.Components.Endpoints.HttpNavigationManager.EnableThrowNavigationException"; + + /// + /// Gets a value indicating whether navigation exceptions should be thrown during navigation. + /// + /// + /// This property is controlled by the AppContext switch + /// Microsoft.AspNetCore.Components.Endpoints.HttpNavigationManager.EnableThrowNavigationException. + /// When enabled, navigation operations may throw exceptions for debugging or testing purposes. + /// protected static bool ThrowNavigationException => AppContext.TryGetSwitch(EnableThrowNavigationException, out var switchValue) && switchValue; From dff3c70595b4fed71243e0c5ff715d0aefceab87 Mon Sep 17 00:00:00 2001 From: Ilona Tomkowicz Date: Mon, 7 Apr 2025 14:03:59 +0200 Subject: [PATCH 07/21] Fix build of tests. --- src/Mvc/Mvc.TagHelpers/test/ComponentTagHelperTest.cs | 8 ++++++++ 1 file changed, 8 insertions(+) diff --git a/src/Mvc/Mvc.TagHelpers/test/ComponentTagHelperTest.cs b/src/Mvc/Mvc.TagHelpers/test/ComponentTagHelperTest.cs index cefb5d70a82e..ee504e905252 100644 --- a/src/Mvc/Mvc.TagHelpers/test/ComponentTagHelperTest.cs +++ b/src/Mvc/Mvc.TagHelpers/test/ComponentTagHelperTest.cs @@ -104,6 +104,14 @@ protected override void BuildRenderTree(RenderTreeBuilder builder) class MockNavigationManager : NavigationManager, IHostEnvironmentNavigationManager { + private EventHandler? _onNavigateTo; + + public event EventHandler OnNavigateTo + { + add => _onNavigateTo += value; + remove => _onNavigateTo -= value; + } + public MockNavigationManager() { Initialize("https://localhost:85/subdir/", "https://localhost:85/subdir/path?query=value#hash"); From 19d3fe98d4efca13ded53cb07dd9b44c28ac7e8f Mon Sep 17 00:00:00 2001 From: Ilona Tomkowicz Date: Mon, 7 Apr 2025 14:42:40 +0200 Subject: [PATCH 08/21] Fix nullable. --- src/Mvc/Mvc.TagHelpers/test/ComponentTagHelperTest.cs | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/src/Mvc/Mvc.TagHelpers/test/ComponentTagHelperTest.cs b/src/Mvc/Mvc.TagHelpers/test/ComponentTagHelperTest.cs index ee504e905252..6fec80fbd1e3 100644 --- a/src/Mvc/Mvc.TagHelpers/test/ComponentTagHelperTest.cs +++ b/src/Mvc/Mvc.TagHelpers/test/ComponentTagHelperTest.cs @@ -104,7 +104,7 @@ protected override void BuildRenderTree(RenderTreeBuilder builder) class MockNavigationManager : NavigationManager, IHostEnvironmentNavigationManager { - private EventHandler? _onNavigateTo; + private EventHandler _onNavigateTo; public event EventHandler OnNavigateTo { From def3e5c4f9b3bb58ebdabbfd1e9b8272e9d432d2 Mon Sep 17 00:00:00 2001 From: Ilona Tomkowicz Date: Tue, 8 Apr 2025 16:44:09 +0200 Subject: [PATCH 09/21] Feedback - limit public API changes. --- .../Components/src/NavigationManager.cs | 12 ---------- .../Components/src/PublicAPI.Unshipped.txt | 7 +----- .../IHostEnvironmentNavigationManager.cs | 8 ------- .../src/Routing/NavigationEventArgs.cs | 24 ------------------- .../HttpNavigationManager.cs | 15 ++++++------ .../src/Rendering/EndpointHtmlRenderer.cs | 9 +++---- .../test/RazorComponentResultTest.cs | 8 ------- .../src/Circuits/RemoteNavigationManager.cs | 19 +++++++-------- .../test/ComponentTagHelperTest.cs | 8 ------- 9 files changed, 20 insertions(+), 90 deletions(-) delete mode 100644 src/Components/Components/src/Routing/NavigationEventArgs.cs diff --git a/src/Components/Components/src/NavigationManager.cs b/src/Components/Components/src/NavigationManager.cs index 135d6e5ad86f..d721133dd02d 100644 --- a/src/Components/Components/src/NavigationManager.cs +++ b/src/Components/Components/src/NavigationManager.cs @@ -61,18 +61,6 @@ public event EventHandler OnNotFound // The URI. Always represented an absolute URI. private string? _uri; private bool _isInitialized; - private const string EnableThrowNavigationException = "Microsoft.AspNetCore.Components.Endpoints.HttpNavigationManager.EnableThrowNavigationException"; - - /// - /// Gets a value indicating whether navigation exceptions should be thrown during navigation. - /// - /// - /// This property is controlled by the AppContext switch - /// Microsoft.AspNetCore.Components.Endpoints.HttpNavigationManager.EnableThrowNavigationException. - /// When enabled, navigation operations may throw exceptions for debugging or testing purposes. - /// - protected static bool ThrowNavigationException => - AppContext.TryGetSwitch(EnableThrowNavigationException, out var switchValue) && switchValue; /// /// Gets or sets the current base URI. The is always represented as an absolute URI in string form with trailing slash. diff --git a/src/Components/Components/src/PublicAPI.Unshipped.txt b/src/Components/Components/src/PublicAPI.Unshipped.txt index 16e01cd8bdab..3ce8bf496c1b 100644 --- a/src/Components/Components/src/PublicAPI.Unshipped.txt +++ b/src/Components/Components/src/PublicAPI.Unshipped.txt @@ -10,9 +10,4 @@ Microsoft.AspNetCore.Components.SupplyParameterFromPersistentComponentStateAttri Microsoft.AspNetCore.Components.SupplyParameterFromPersistentComponentStateAttribute.SupplyParameterFromPersistentComponentStateAttribute() -> void Microsoft.Extensions.DependencyInjection.SupplyParameterFromPersistentComponentStateProviderServiceCollectionExtensions static Microsoft.AspNetCore.Components.Infrastructure.RegisterPersistentComponentStateServiceCollectionExtensions.AddPersistentServiceRegistration(Microsoft.Extensions.DependencyInjection.IServiceCollection! services, Microsoft.AspNetCore.Components.IComponentRenderMode! componentRenderMode) -> Microsoft.Extensions.DependencyInjection.IServiceCollection! -static Microsoft.Extensions.DependencyInjection.SupplyParameterFromPersistentComponentStateProviderServiceCollectionExtensions.AddSupplyValueFromPersistentComponentStateProvider(this Microsoft.Extensions.DependencyInjection.IServiceCollection! services) -> Microsoft.Extensions.DependencyInjection.IServiceCollection! -static Microsoft.AspNetCore.Components.NavigationManager.ThrowNavigationException.get -> bool -Microsoft.AspNetCore.Components.Routing.NavigationEventArgs -Microsoft.AspNetCore.Components.Routing.NavigationEventArgs.Uri.get -> string! -Microsoft.AspNetCore.Components.Routing.NavigationEventArgs.NavigationEventArgs(string! uri) -> void -Microsoft.AspNetCore.Components.Routing.IHostEnvironmentNavigationManager.OnNavigateTo -> System.EventHandler! \ No newline at end of file +static Microsoft.Extensions.DependencyInjection.SupplyParameterFromPersistentComponentStateProviderServiceCollectionExtensions.AddSupplyValueFromPersistentComponentStateProvider(this Microsoft.Extensions.DependencyInjection.IServiceCollection! services) -> Microsoft.Extensions.DependencyInjection.IServiceCollection! \ No newline at end of file diff --git a/src/Components/Components/src/Routing/IHostEnvironmentNavigationManager.cs b/src/Components/Components/src/Routing/IHostEnvironmentNavigationManager.cs index b7115b6757bd..cbe9274f9792 100644 --- a/src/Components/Components/src/Routing/IHostEnvironmentNavigationManager.cs +++ b/src/Components/Components/src/Routing/IHostEnvironmentNavigationManager.cs @@ -15,12 +15,4 @@ public interface IHostEnvironmentNavigationManager /// The base URI. /// The absolute URI. void Initialize(string baseUri, string uri); - - /// - /// An event that is triggered when SSR navigation occurs. - /// - /// - /// This event allows subscribers to respond to SSR navigation actions, such as updating state or performing side effects. - /// - event EventHandler OnNavigateTo; } diff --git a/src/Components/Components/src/Routing/NavigationEventArgs.cs b/src/Components/Components/src/Routing/NavigationEventArgs.cs deleted file mode 100644 index 5c7fb3b47091..000000000000 --- a/src/Components/Components/src/Routing/NavigationEventArgs.cs +++ /dev/null @@ -1,24 +0,0 @@ -// 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.Routing; - -/// -/// for . -/// -public class NavigationEventArgs : EventArgs -{ - /// - /// Gets the URI of the navigation event. - /// - public string Uri { get; } - - /// - /// Initializes a new instance of . - /// - /// The URI of the navigation event. - public NavigationEventArgs(string uri) - { - Uri = uri; - } -} \ No newline at end of file diff --git a/src/Components/Endpoints/src/DependencyInjection/HttpNavigationManager.cs b/src/Components/Endpoints/src/DependencyInjection/HttpNavigationManager.cs index b7137bf05f2b..c34ec876c3c2 100644 --- a/src/Components/Endpoints/src/DependencyInjection/HttpNavigationManager.cs +++ b/src/Components/Endpoints/src/DependencyInjection/HttpNavigationManager.cs @@ -7,25 +7,24 @@ namespace Microsoft.AspNetCore.Components.Endpoints; internal sealed class HttpNavigationManager : NavigationManager, IHostEnvironmentNavigationManager { - private EventHandler? _onNavigateTo; - public event EventHandler OnNavigateTo - { - add => _onNavigateTo += value; - remove => _onNavigateTo -= value; - } + private const string _enableThrowNavigationException = "Microsoft.AspNetCore.Components.Endpoints.HttpNavigationManager.EnableThrowNavigationException"; + + private static bool _throwNavigationException => + AppContext.TryGetSwitch(_enableThrowNavigationException, out var switchValue) && switchValue; void IHostEnvironmentNavigationManager.Initialize(string baseUri, string uri) => Initialize(baseUri, uri); protected override void NavigateToCore(string uri, NavigationOptions options) { var absoluteUriString = ToAbsoluteUri(uri).AbsoluteUri; - if (ThrowNavigationException) + if (_throwNavigationException) { throw new NavigationException(absoluteUriString); } else { - _onNavigateTo?.Invoke(this, new NavigationEventArgs(absoluteUriString)); + Uri = absoluteUriString; + NotifyLocationChanged(isInterceptedLink: false); } } } diff --git a/src/Components/Endpoints/src/Rendering/EndpointHtmlRenderer.cs b/src/Components/Endpoints/src/Rendering/EndpointHtmlRenderer.cs index 7686e0168e76..f44941880426 100644 --- a/src/Components/Endpoints/src/Rendering/EndpointHtmlRenderer.cs +++ b/src/Components/Endpoints/src/Rendering/EndpointHtmlRenderer.cs @@ -84,10 +84,7 @@ internal async Task InitializeStandardComponentServicesAsync( if (navigationManager != null) { navigationManager.OnNotFound += SetNotFoundResponse; - if (navigationManager is IHostEnvironmentNavigationManager hostEnvironmentNavigationManager) - { - hostEnvironmentNavigationManager.OnNavigateTo += OnNavigateTo; - } + navigationManager.LocationChanged += OnNavigateTo; } var authenticationStateProvider = httpContext.RequestServices.GetService(); @@ -139,9 +136,9 @@ internal async Task InitializeStandardComponentServicesAsync( } } - private void OnNavigateTo(object? sender, NavigationEventArgs args) + private void OnNavigateTo(object? sender, LocationChangedEventArgs args) { - _httpContext.Response.Redirect(args.Uri); + _httpContext.Response.Redirect(args.Location); } private static void InitializeResourceCollection(HttpContext httpContext) diff --git a/src/Components/Endpoints/test/RazorComponentResultTest.cs b/src/Components/Endpoints/test/RazorComponentResultTest.cs index 538271327d76..cbeb6712402a 100644 --- a/src/Components/Endpoints/test/RazorComponentResultTest.cs +++ b/src/Components/Endpoints/test/RazorComponentResultTest.cs @@ -510,14 +510,6 @@ class FakeDataProtector : IDataProtector class FakeNavigationManager : NavigationManager, IHostEnvironmentNavigationManager { - private EventHandler _onNavigateTo; - - public event EventHandler OnNavigateTo - { - add => _onNavigateTo += value; - remove => _onNavigateTo -= value; - } - public new void Initialize(string baseUri, string uri) => base.Initialize(baseUri, uri); diff --git a/src/Components/Server/src/Circuits/RemoteNavigationManager.cs b/src/Components/Server/src/Circuits/RemoteNavigationManager.cs index e23e7a0ac765..c8b88936f849 100644 --- a/src/Components/Server/src/Circuits/RemoteNavigationManager.cs +++ b/src/Components/Server/src/Circuits/RemoteNavigationManager.cs @@ -17,12 +17,9 @@ internal sealed partial class RemoteNavigationManager : NavigationManager, IHost private readonly ILogger _logger; private IJSRuntime _jsRuntime; private bool? _navigationLockStateBeforeJsRuntimeAttached; - private EventHandler? _onNavigateTo; - public event EventHandler OnNavigateTo - { - add => _onNavigateTo += value; - remove => _onNavigateTo -= value; - } + private const string _enableThrowNavigationException = "Microsoft.AspNetCore.Components.Endpoints.HttpNavigationManager.EnableThrowNavigationException"; + private static bool _throwNavigationException => + AppContext.TryGetSwitch(_enableThrowNavigationException, out var switchValue) && switchValue; public event EventHandler? UnhandledException; @@ -94,13 +91,14 @@ protected override void NavigateToCore(string uri, NavigationOptions options) if (_jsRuntime == null) { var absoluteUriString = ToAbsoluteUri(uri).AbsoluteUri; - if (ThrowNavigationException) + if (_throwNavigationException) { throw new NavigationException(absoluteUriString); } else { - _onNavigateTo?.Invoke(this, new NavigationEventArgs(absoluteUriString)); + Uri = absoluteUriString; + NotifyLocationChanged(isInterceptedLink: false); } } @@ -142,13 +140,14 @@ public override void Refresh(bool forceReload = false) if (_jsRuntime == null) { var absoluteUriString = ToAbsoluteUri(Uri).AbsoluteUri; - if (ThrowNavigationException) + if (_throwNavigationException) { throw new NavigationException(absoluteUriString); } else { - _onNavigateTo?.Invoke(this, new NavigationEventArgs(absoluteUriString)); + Uri = absoluteUriString; + NotifyLocationChanged(isInterceptedLink: false); } } diff --git a/src/Mvc/Mvc.TagHelpers/test/ComponentTagHelperTest.cs b/src/Mvc/Mvc.TagHelpers/test/ComponentTagHelperTest.cs index 6fec80fbd1e3..cefb5d70a82e 100644 --- a/src/Mvc/Mvc.TagHelpers/test/ComponentTagHelperTest.cs +++ b/src/Mvc/Mvc.TagHelpers/test/ComponentTagHelperTest.cs @@ -104,14 +104,6 @@ protected override void BuildRenderTree(RenderTreeBuilder builder) class MockNavigationManager : NavigationManager, IHostEnvironmentNavigationManager { - private EventHandler _onNavigateTo; - - public event EventHandler OnNavigateTo - { - add => _onNavigateTo += value; - remove => _onNavigateTo -= value; - } - public MockNavigationManager() { Initialize("https://localhost:85/subdir/", "https://localhost:85/subdir/path?query=value#hash"); From 9f79f51891063abd12abe9c8429e2a2a3b9f7899 Mon Sep 17 00:00:00 2001 From: Ilona Tomkowicz Date: Tue, 8 Apr 2025 18:45:16 +0200 Subject: [PATCH 10/21] Handle the case when response started. --- .../EndpointHtmlRenderer.EventDispatch.cs | 21 +++++++++++++++++++ .../src/Rendering/EndpointHtmlRenderer.cs | 5 ----- .../NoInteractivityTest.cs | 10 +++++---- .../Routing/SSRRedirectionStreaming.razor | 19 +++++++++++++++++ 4 files changed, 46 insertions(+), 9 deletions(-) create mode 100644 src/Components/test/testassets/Components.TestServer/RazorComponents/Pages/Routing/SSRRedirectionStreaming.razor diff --git a/src/Components/Endpoints/src/Rendering/EndpointHtmlRenderer.EventDispatch.cs b/src/Components/Endpoints/src/Rendering/EndpointHtmlRenderer.EventDispatch.cs index 2b8455741f52..f64dd517b93a 100644 --- a/src/Components/Endpoints/src/Rendering/EndpointHtmlRenderer.EventDispatch.cs +++ b/src/Components/Endpoints/src/Rendering/EndpointHtmlRenderer.EventDispatch.cs @@ -3,6 +3,7 @@ using Microsoft.AspNetCore.Components.Rendering; using Microsoft.AspNetCore.Components.RenderTree; +using Microsoft.AspNetCore.Components.Routing; using Microsoft.AspNetCore.Http; using Microsoft.Extensions.DependencyInjection; using Microsoft.Extensions.Hosting; @@ -84,6 +85,26 @@ private void SetNotFoundResponse(object? sender, EventArgs args) SignalRendererToFinishRendering(); } + private void OnNavigateTo(object? sender, LocationChangedEventArgs args) + { + if (_httpContext.Response.HasStarted) + { + // We cannot redirect after the response has already started + RenderMetaRefreshTag(args.Location); + } + else + { + _httpContext.Response.Redirect(args.Location); + } + SignalRendererToFinishRendering(); + } + + private void RenderMetaRefreshTag(string location) + { + var metaTag = $""; + _httpContext.Response.WriteAsync(metaTag); + } + private void UpdateNamedSubmitEvents(in RenderBatch renderBatch) { if (renderBatch.NamedEventChanges is { } changes) diff --git a/src/Components/Endpoints/src/Rendering/EndpointHtmlRenderer.cs b/src/Components/Endpoints/src/Rendering/EndpointHtmlRenderer.cs index f44941880426..31e6e4d7144d 100644 --- a/src/Components/Endpoints/src/Rendering/EndpointHtmlRenderer.cs +++ b/src/Components/Endpoints/src/Rendering/EndpointHtmlRenderer.cs @@ -136,11 +136,6 @@ internal async Task InitializeStandardComponentServicesAsync( } } - private void OnNavigateTo(object? sender, LocationChangedEventArgs args) - { - _httpContext.Response.Redirect(args.Location); - } - private static void InitializeResourceCollection(HttpContext httpContext) { diff --git a/src/Components/test/E2ETest/ServerRenderingTests/NoInteractivityTest.cs b/src/Components/test/E2ETest/ServerRenderingTests/NoInteractivityTest.cs index 9b6f1c0add2f..e1a58633e1bb 100644 --- a/src/Components/test/E2ETest/ServerRenderingTests/NoInteractivityTest.cs +++ b/src/Components/test/E2ETest/ServerRenderingTests/NoInteractivityTest.cs @@ -66,12 +66,14 @@ public void CanUseServerAuthenticationStateByDefault() } [Theory] - [InlineData(true)] - [InlineData(false)] - public void NavigatesWithoutInteractivityByRequestRedirection(bool controlFlowByException) + [InlineData(true, true)] + [InlineData(true, false)] + [InlineData(false, true)] + public void NavigatesWithoutInteractivityByRequestRedirection(bool controlFlowByException, bool isStreaming) { AppContext.SetSwitch("Microsoft.AspNetCore.Components.Endpoints.EnableThrowNavigationException", isEnabled: controlFlowByException); - Navigate($"{ServerPathBase}/routing/ssr-navigate-to"); + string streaming = isStreaming ? $"streaming-" : ""; + Navigate($"{ServerPathBase}/routing/ssr-{streaming}navigate-to"); Browser.Equal("Click submit to navigate to home", () => Browser.Exists(By.Id("test-info")).Text); Browser.Click(By.Id("redirectButton")); Browser.Equal("Routing test cases", () => Browser.Exists(By.Id("test-info")).Text); diff --git a/src/Components/test/testassets/Components.TestServer/RazorComponents/Pages/Routing/SSRRedirectionStreaming.razor b/src/Components/test/testassets/Components.TestServer/RazorComponents/Pages/Routing/SSRRedirectionStreaming.razor new file mode 100644 index 000000000000..79b608fdf183 --- /dev/null +++ b/src/Components/test/testassets/Components.TestServer/RazorComponents/Pages/Routing/SSRRedirectionStreaming.razor @@ -0,0 +1,19 @@ +@page "/routing/ssr-streaming-navigate-to" +@attribute [StreamRendering] +@using Microsoft.AspNetCore.Components.Forms +@inject NavigationManager NavigationManager + +

Click submit to navigate to home

+
+ + + + +@code { + private async Task Submit() + { + await Task.Delay(1000); // Simulate some async work to let the streaming begin + NavigationManager.NavigateTo("/subdir/routing"); + } +} + From e647f2a9f4b17a534c99a45c6634f054155041c8 Mon Sep 17 00:00:00 2001 From: Ilona Tomkowicz Date: Wed, 9 Apr 2025 12:30:59 +0200 Subject: [PATCH 11/21] Proposal of fixing external navigation. --- .../HttpNavigationManager.cs | 44 ++++++++++++++++++ .../src/Circuits/RemoteNavigationManager.cs | 45 +++++++++++++++++++ 2 files changed, 89 insertions(+) diff --git a/src/Components/Endpoints/src/DependencyInjection/HttpNavigationManager.cs b/src/Components/Endpoints/src/DependencyInjection/HttpNavigationManager.cs index c34ec876c3c2..4c09b3673917 100644 --- a/src/Components/Endpoints/src/DependencyInjection/HttpNavigationManager.cs +++ b/src/Components/Endpoints/src/DependencyInjection/HttpNavigationManager.cs @@ -23,8 +23,52 @@ protected override void NavigateToCore(string uri, NavigationOptions options) } else { + if (!IsInternalUri(absoluteUriString)) + { + // it's an external navigation, avoid Uri validation exception + BaseUri = GetBaseUriFromAbsoluteUri(absoluteUriString); + } Uri = absoluteUriString; NotifyLocationChanged(isInterceptedLink: false); } } + + // ToDo: the following are copy-paste, consider refactoring to a common place + private bool IsInternalUri(string uri) + { + var normalizedBaseUri = NormalizeBaseUri(BaseUri); + return uri.StartsWith(normalizedBaseUri, StringComparison.OrdinalIgnoreCase); + } + + private static string GetBaseUriFromAbsoluteUri(string absoluteUri) + { + // Find the position of the first single slash after the scheme (e.g., "https://") + var schemeDelimiterIndex = absoluteUri.IndexOf("://", StringComparison.Ordinal); + if (schemeDelimiterIndex == -1) + { + throw new ArgumentException($"The provided URI '{absoluteUri}' is not a valid absolute URI."); + } + + // Find the end of the authority section (e.g., "https://example.com/") + var authorityEndIndex = absoluteUri.IndexOf('/', schemeDelimiterIndex + 3); + if (authorityEndIndex == -1) + { + // If no slash is found, the entire URI is the authority (e.g., "https://example.com") + return NormalizeBaseUri(absoluteUri + "/"); + } + + // Extract the base URI up to the authority section + return NormalizeBaseUri(absoluteUri.Substring(0, authorityEndIndex + 1)); + } + + private static string NormalizeBaseUri(string baseUri) + { + var lastSlashIndex = baseUri.LastIndexOf('/'); + if (lastSlashIndex >= 0) + { + baseUri = baseUri.Substring(0, lastSlashIndex + 1); + } + + return baseUri; + } } diff --git a/src/Components/Server/src/Circuits/RemoteNavigationManager.cs b/src/Components/Server/src/Circuits/RemoteNavigationManager.cs index c8b88936f849..72a0b4d7dc09 100644 --- a/src/Components/Server/src/Circuits/RemoteNavigationManager.cs +++ b/src/Components/Server/src/Circuits/RemoteNavigationManager.cs @@ -97,8 +97,14 @@ protected override void NavigateToCore(string uri, NavigationOptions options) } else { + if (!IsInternalUri(absoluteUriString)) + { + // it's an external navigation, avoid Uri validation exception + BaseUri = GetBaseUriFromAbsoluteUri(absoluteUriString); + } Uri = absoluteUriString; NotifyLocationChanged(isInterceptedLink: false); + return; } } @@ -134,6 +140,44 @@ async Task PerformNavigationAsync() } } + private bool IsInternalUri(string uri) + { + var normalizedBaseUri = NormalizeBaseUri(BaseUri); + return uri.StartsWith(normalizedBaseUri, StringComparison.OrdinalIgnoreCase); + } + + private static string GetBaseUriFromAbsoluteUri(string absoluteUri) + { + // Find the position of the first single slash after the scheme (e.g., "https://") + var schemeDelimiterIndex = absoluteUri.IndexOf("://", StringComparison.Ordinal); + if (schemeDelimiterIndex == -1) + { + throw new ArgumentException($"The provided URI '{absoluteUri}' is not a valid absolute URI."); + } + + // Find the end of the authority section (e.g., "https://example.com/") + var authorityEndIndex = absoluteUri.IndexOf('/', schemeDelimiterIndex + 3); + if (authorityEndIndex == -1) + { + // If no slash is found, the entire URI is the authority (e.g., "https://example.com") + return NormalizeBaseUri(absoluteUri + "/"); + } + + // Extract the base URI up to the authority section + return NormalizeBaseUri(absoluteUri.Substring(0, authorityEndIndex + 1)); + } + + private static string NormalizeBaseUri(string baseUri) + { + var lastSlashIndex = baseUri.LastIndexOf('/'); + if (lastSlashIndex >= 0) + { + baseUri = baseUri.Substring(0, lastSlashIndex + 1); + } + + return baseUri; + } + /// public override void Refresh(bool forceReload = false) { @@ -148,6 +192,7 @@ public override void Refresh(bool forceReload = false) { Uri = absoluteUriString; NotifyLocationChanged(isInterceptedLink: false); + return; } } From 5e6d104ec367213ec0e3862d3804d791b7be4d90 Mon Sep 17 00:00:00 2001 From: Ilona Tomkowicz <32700855+ilonatommy@users.noreply.github.com> Date: Wed, 9 Apr 2025 15:28:17 +0200 Subject: [PATCH 12/21] Update src/Components/test/testassets/Components.TestServer/RazorComponents/Pages/Routing/SSRRedirectionStreaming.razor Co-authored-by: Javier Calvarro Nelson --- .../RazorComponents/Pages/Routing/SSRRedirectionStreaming.razor | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/src/Components/test/testassets/Components.TestServer/RazorComponents/Pages/Routing/SSRRedirectionStreaming.razor b/src/Components/test/testassets/Components.TestServer/RazorComponents/Pages/Routing/SSRRedirectionStreaming.razor index 79b608fdf183..d4af78f18d39 100644 --- a/src/Components/test/testassets/Components.TestServer/RazorComponents/Pages/Routing/SSRRedirectionStreaming.razor +++ b/src/Components/test/testassets/Components.TestServer/RazorComponents/Pages/Routing/SSRRedirectionStreaming.razor @@ -12,7 +12,7 @@ @code { private async Task Submit() { - await Task.Delay(1000); // Simulate some async work to let the streaming begin + await Task.Yield(); NavigationManager.NavigateTo("/subdir/routing"); } } From aea9826f430d7e385077c0ebe2f65307bed9d05b Mon Sep 17 00:00:00 2001 From: Ilona Tomkowicz Date: Fri, 11 Apr 2025 10:42:30 +0200 Subject: [PATCH 13/21] Feedback. --- .../Components/src/PublicAPI.Unshipped.txt | 3 +- .../IHostEnvironmentNavigationManager.cs | 9 ++ .../HttpNavigationManager.cs | 58 +++------- .../EndpointHtmlRenderer.EventDispatch.cs | 21 ++-- .../src/Rendering/EndpointHtmlRenderer.cs | 3 +- .../src/Circuits/RemoteNavigationManager.cs | 106 +++++++----------- 6 files changed, 75 insertions(+), 125 deletions(-) diff --git a/src/Components/Components/src/PublicAPI.Unshipped.txt b/src/Components/Components/src/PublicAPI.Unshipped.txt index 3ce8bf496c1b..ea309858b119 100644 --- a/src/Components/Components/src/PublicAPI.Unshipped.txt +++ b/src/Components/Components/src/PublicAPI.Unshipped.txt @@ -1,6 +1,7 @@ #nullable enable Microsoft.AspNetCore.Components.NavigationManager.OnNotFound -> System.EventHandler! Microsoft.AspNetCore.Components.NavigationManager.NotFound() -> void +Microsoft.AspNetCore.Components.Routing.IHostEnvironmentNavigationManager.Initialize(string! baseUri, string! uri, System.Func! onNavigateTo) -> void Microsoft.AspNetCore.Components.Routing.NotFoundEventArgs Microsoft.AspNetCore.Components.Routing.NotFoundEventArgs.NotFoundEventArgs() -> void Microsoft.AspNetCore.Components.Infrastructure.ComponentStatePersistenceManager.ComponentStatePersistenceManager(Microsoft.Extensions.Logging.ILogger! logger, System.IServiceProvider! serviceProvider) -> void @@ -10,4 +11,4 @@ Microsoft.AspNetCore.Components.SupplyParameterFromPersistentComponentStateAttri Microsoft.AspNetCore.Components.SupplyParameterFromPersistentComponentStateAttribute.SupplyParameterFromPersistentComponentStateAttribute() -> void Microsoft.Extensions.DependencyInjection.SupplyParameterFromPersistentComponentStateProviderServiceCollectionExtensions static Microsoft.AspNetCore.Components.Infrastructure.RegisterPersistentComponentStateServiceCollectionExtensions.AddPersistentServiceRegistration(Microsoft.Extensions.DependencyInjection.IServiceCollection! services, Microsoft.AspNetCore.Components.IComponentRenderMode! componentRenderMode) -> Microsoft.Extensions.DependencyInjection.IServiceCollection! -static Microsoft.Extensions.DependencyInjection.SupplyParameterFromPersistentComponentStateProviderServiceCollectionExtensions.AddSupplyValueFromPersistentComponentStateProvider(this Microsoft.Extensions.DependencyInjection.IServiceCollection! services) -> Microsoft.Extensions.DependencyInjection.IServiceCollection! \ No newline at end of file +static Microsoft.Extensions.DependencyInjection.SupplyParameterFromPersistentComponentStateProviderServiceCollectionExtensions.AddSupplyValueFromPersistentComponentStateProvider(this Microsoft.Extensions.DependencyInjection.IServiceCollection! services) -> Microsoft.Extensions.DependencyInjection.IServiceCollection! diff --git a/src/Components/Components/src/Routing/IHostEnvironmentNavigationManager.cs b/src/Components/Components/src/Routing/IHostEnvironmentNavigationManager.cs index cbe9274f9792..c1488a9b7bc3 100644 --- a/src/Components/Components/src/Routing/IHostEnvironmentNavigationManager.cs +++ b/src/Components/Components/src/Routing/IHostEnvironmentNavigationManager.cs @@ -15,4 +15,13 @@ public interface IHostEnvironmentNavigationManager /// The base URI. /// The absolute URI. void Initialize(string baseUri, string uri); + + /// + /// Initializes the . + /// + /// The base URI. + /// The absolute URI. + /// A delegate that points to a method handling navigation events. + void Initialize(string baseUri, string uri, Func onNavigateTo) => + Initialize(baseUri, uri); } diff --git a/src/Components/Endpoints/src/DependencyInjection/HttpNavigationManager.cs b/src/Components/Endpoints/src/DependencyInjection/HttpNavigationManager.cs index 4c09b3673917..24a2d4ae333d 100644 --- a/src/Components/Endpoints/src/DependencyInjection/HttpNavigationManager.cs +++ b/src/Components/Endpoints/src/DependencyInjection/HttpNavigationManager.cs @@ -12,8 +12,16 @@ internal sealed class HttpNavigationManager : NavigationManager, IHostEnvironmen private static bool _throwNavigationException => AppContext.TryGetSwitch(_enableThrowNavigationException, out var switchValue) && switchValue; + private Func? _onNavigateTo; + void IHostEnvironmentNavigationManager.Initialize(string baseUri, string uri) => Initialize(baseUri, uri); + void IHostEnvironmentNavigationManager.Initialize(string baseUri, string uri, Func onNavigateTo) + { + _onNavigateTo = onNavigateTo; + Initialize(baseUri, uri); + } + protected override void NavigateToCore(string uri, NavigationOptions options) { var absoluteUriString = ToAbsoluteUri(uri).AbsoluteUri; @@ -23,52 +31,16 @@ protected override void NavigateToCore(string uri, NavigationOptions options) } else { - if (!IsInternalUri(absoluteUriString)) - { - // it's an external navigation, avoid Uri validation exception - BaseUri = GetBaseUriFromAbsoluteUri(absoluteUriString); - } - Uri = absoluteUriString; - NotifyLocationChanged(isInterceptedLink: false); + _ = PerformNavigationAsync(); } - } - - // ToDo: the following are copy-paste, consider refactoring to a common place - private bool IsInternalUri(string uri) - { - var normalizedBaseUri = NormalizeBaseUri(BaseUri); - return uri.StartsWith(normalizedBaseUri, StringComparison.OrdinalIgnoreCase); - } - private static string GetBaseUriFromAbsoluteUri(string absoluteUri) - { - // Find the position of the first single slash after the scheme (e.g., "https://") - var schemeDelimiterIndex = absoluteUri.IndexOf("://", StringComparison.Ordinal); - if (schemeDelimiterIndex == -1) + async Task PerformNavigationAsync() { - throw new ArgumentException($"The provided URI '{absoluteUri}' is not a valid absolute URI."); - } - - // Find the end of the authority section (e.g., "https://example.com/") - var authorityEndIndex = absoluteUri.IndexOf('/', schemeDelimiterIndex + 3); - if (authorityEndIndex == -1) - { - // If no slash is found, the entire URI is the authority (e.g., "https://example.com") - return NormalizeBaseUri(absoluteUri + "/"); - } - - // Extract the base URI up to the authority section - return NormalizeBaseUri(absoluteUri.Substring(0, authorityEndIndex + 1)); - } - - private static string NormalizeBaseUri(string baseUri) - { - var lastSlashIndex = baseUri.LastIndexOf('/'); - if (lastSlashIndex >= 0) - { - baseUri = baseUri.Substring(0, lastSlashIndex + 1); + if (_onNavigateTo == null) + { + throw new InvalidOperationException($"'{GetType().Name}' method for endpoint-based navigation has not been initialized."); + } + await _onNavigateTo(absoluteUriString); } - - return baseUri; } } diff --git a/src/Components/Endpoints/src/Rendering/EndpointHtmlRenderer.EventDispatch.cs b/src/Components/Endpoints/src/Rendering/EndpointHtmlRenderer.EventDispatch.cs index f64dd517b93a..7d2ba7438a36 100644 --- a/src/Components/Endpoints/src/Rendering/EndpointHtmlRenderer.EventDispatch.cs +++ b/src/Components/Endpoints/src/Rendering/EndpointHtmlRenderer.EventDispatch.cs @@ -1,12 +1,14 @@ // 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.Endpoints.Rendering; using Microsoft.AspNetCore.Components.Rendering; using Microsoft.AspNetCore.Components.RenderTree; -using Microsoft.AspNetCore.Components.Routing; using Microsoft.AspNetCore.Http; +using Microsoft.AspNetCore.WebUtilities; using Microsoft.Extensions.DependencyInjection; using Microsoft.Extensions.Hosting; +using System.Buffers; using System.Globalization; using System.Linq; using System.Text; @@ -85,26 +87,23 @@ private void SetNotFoundResponse(object? sender, EventArgs args) SignalRendererToFinishRendering(); } - private void OnNavigateTo(object? sender, LocationChangedEventArgs args) + private async Task OnNavigateTo(string uri) { if (_httpContext.Response.HasStarted) { - // We cannot redirect after the response has already started - RenderMetaRefreshTag(args.Location); + var defaultBufferSize = 16 * 1024; + await using var writer = new HttpResponseStreamWriter(_httpContext.Response.Body, Encoding.UTF8, defaultBufferSize, ArrayPool.Shared, ArrayPool.Shared); + using var bufferWriter = new BufferedTextWriter(writer); + HandleNavigationAfterResponseStarted(bufferWriter, _httpContext, uri); + await bufferWriter.FlushAsync(); } else { - _httpContext.Response.Redirect(args.Location); + _httpContext.Response.Redirect(uri); } SignalRendererToFinishRendering(); } - private void RenderMetaRefreshTag(string location) - { - var metaTag = $""; - _httpContext.Response.WriteAsync(metaTag); - } - private void UpdateNamedSubmitEvents(in RenderBatch renderBatch) { if (renderBatch.NamedEventChanges is { } changes) diff --git a/src/Components/Endpoints/src/Rendering/EndpointHtmlRenderer.cs b/src/Components/Endpoints/src/Rendering/EndpointHtmlRenderer.cs index 31e6e4d7144d..4af8c2634558 100644 --- a/src/Components/Endpoints/src/Rendering/EndpointHtmlRenderer.cs +++ b/src/Components/Endpoints/src/Rendering/EndpointHtmlRenderer.cs @@ -79,12 +79,11 @@ internal async Task InitializeStandardComponentServicesAsync( IFormCollection? form = null) { var navigationManager = httpContext.RequestServices.GetRequiredService(); - ((IHostEnvironmentNavigationManager)navigationManager)?.Initialize(GetContextBaseUri(httpContext.Request), GetFullUri(httpContext.Request)); + ((IHostEnvironmentNavigationManager)navigationManager)?.Initialize(GetContextBaseUri(httpContext.Request), GetFullUri(httpContext.Request), OnNavigateTo); if (navigationManager != null) { navigationManager.OnNotFound += SetNotFoundResponse; - navigationManager.LocationChanged += OnNavigateTo; } var authenticationStateProvider = httpContext.RequestServices.GetService(); diff --git a/src/Components/Server/src/Circuits/RemoteNavigationManager.cs b/src/Components/Server/src/Circuits/RemoteNavigationManager.cs index 72a0b4d7dc09..aca895aa2a4f 100644 --- a/src/Components/Server/src/Circuits/RemoteNavigationManager.cs +++ b/src/Components/Server/src/Circuits/RemoteNavigationManager.cs @@ -20,6 +20,7 @@ internal sealed partial class RemoteNavigationManager : NavigationManager, IHost private const string _enableThrowNavigationException = "Microsoft.AspNetCore.Components.Endpoints.HttpNavigationManager.EnableThrowNavigationException"; private static bool _throwNavigationException => AppContext.TryGetSwitch(_enableThrowNavigationException, out var switchValue) && switchValue; + private Func? _onNavigateTo; public event EventHandler? UnhandledException; @@ -48,6 +49,19 @@ public RemoteNavigationManager(ILogger logger) NotifyLocationChanged(isInterceptedLink: false); } + /// + /// Initializes the . + /// + /// The base URI. + /// The absolute URI. + /// A delegate that points to a method handling navigation events. + public void Initialize(string baseUri, string uri, Func onNavigateTo) + { + _onNavigateTo += onNavigateTo; + base.Initialize(baseUri, uri); + NotifyLocationChanged(isInterceptedLink: false); + } + /// /// Initializes the . /// @@ -87,33 +101,18 @@ public async ValueTask HandleLocationChangingAsync(string uri, string? sta protected override void NavigateToCore(string uri, NavigationOptions options) { Log.RequestingNavigation(_logger, uri, options); - - if (_jsRuntime == null) - { - var absoluteUriString = ToAbsoluteUri(uri).AbsoluteUri; - if (_throwNavigationException) - { - throw new NavigationException(absoluteUriString); - } - else - { - if (!IsInternalUri(absoluteUriString)) - { - // it's an external navigation, avoid Uri validation exception - BaseUri = GetBaseUriFromAbsoluteUri(absoluteUriString); - } - Uri = absoluteUriString; - NotifyLocationChanged(isInterceptedLink: false); - return; - } - } - _ = PerformNavigationAsync(); async Task PerformNavigationAsync() { try { + if (_jsRuntime == null) + { + await NavigateWithEndpoint(uri); + return; + } + var shouldContinueNavigation = await NotifyLocationChangingAsync(uri, options.HistoryEntryState, false); if (!shouldContinueNavigation) @@ -140,69 +139,40 @@ async Task PerformNavigationAsync() } } - private bool IsInternalUri(string uri) + private async Task NavigateWithEndpoint(string uri) { - var normalizedBaseUri = NormalizeBaseUri(BaseUri); - return uri.StartsWith(normalizedBaseUri, StringComparison.OrdinalIgnoreCase); - } - - private static string GetBaseUriFromAbsoluteUri(string absoluteUri) - { - // Find the position of the first single slash after the scheme (e.g., "https://") - var schemeDelimiterIndex = absoluteUri.IndexOf("://", StringComparison.Ordinal); - if (schemeDelimiterIndex == -1) - { - throw new ArgumentException($"The provided URI '{absoluteUri}' is not a valid absolute URI."); - } - - // Find the end of the authority section (e.g., "https://example.com/") - var authorityEndIndex = absoluteUri.IndexOf('/', schemeDelimiterIndex + 3); - if (authorityEndIndex == -1) + var absoluteUriString = ToAbsoluteUri(uri).AbsoluteUri; + if (_throwNavigationException) { - // If no slash is found, the entire URI is the authority (e.g., "https://example.com") - return NormalizeBaseUri(absoluteUri + "/"); + throw new NavigationException(absoluteUriString); } - - // Extract the base URI up to the authority section - return NormalizeBaseUri(absoluteUri.Substring(0, authorityEndIndex + 1)); - } - - private static string NormalizeBaseUri(string baseUri) - { - var lastSlashIndex = baseUri.LastIndexOf('/'); - if (lastSlashIndex >= 0) + else { - baseUri = baseUri.Substring(0, lastSlashIndex + 1); + if (_onNavigateTo == null) + { + throw new InvalidOperationException($"'{GetType().Name}' method for endpoint-based navigation has not been initialized."); + } + await _onNavigateTo(absoluteUriString); } - - return baseUri; } /// public override void Refresh(bool forceReload = false) { - if (_jsRuntime == null) - { - var absoluteUriString = ToAbsoluteUri(Uri).AbsoluteUri; - if (_throwNavigationException) - { - throw new NavigationException(absoluteUriString); - } - else - { - Uri = absoluteUriString; - NotifyLocationChanged(isInterceptedLink: false); - return; - } - } - _ = RefreshAsync(); async Task RefreshAsync() { try { - await _jsRuntime.InvokeVoidAsync(Interop.Refresh, forceReload); + if (_jsRuntime == null) + { + await NavigateWithEndpoint(Uri); + } + else + { + await _jsRuntime.InvokeVoidAsync(Interop.Refresh, forceReload); + } } catch (Exception ex) { From bafa93709e66e47f7067dbef17d516edd6ed2d7c Mon Sep 17 00:00:00 2001 From: Ilona Tomkowicz Date: Mon, 14 Apr 2025 10:26:33 +0200 Subject: [PATCH 14/21] More effective stopping of the renderer. --- .../Components/src/PublicAPI.Unshipped.txt | 1 + .../Components/src/RenderTree/Renderer.cs | 19 +++++++++++++++++-- .../src/Rendering/EndpointHtmlRenderer.cs | 15 ++------------- 3 files changed, 20 insertions(+), 15 deletions(-) diff --git a/src/Components/Components/src/PublicAPI.Unshipped.txt b/src/Components/Components/src/PublicAPI.Unshipped.txt index ea309858b119..21c0226e2ef5 100644 --- a/src/Components/Components/src/PublicAPI.Unshipped.txt +++ b/src/Components/Components/src/PublicAPI.Unshipped.txt @@ -12,3 +12,4 @@ Microsoft.AspNetCore.Components.SupplyParameterFromPersistentComponentStateAttri Microsoft.Extensions.DependencyInjection.SupplyParameterFromPersistentComponentStateProviderServiceCollectionExtensions static Microsoft.AspNetCore.Components.Infrastructure.RegisterPersistentComponentStateServiceCollectionExtensions.AddPersistentServiceRegistration(Microsoft.Extensions.DependencyInjection.IServiceCollection! services, Microsoft.AspNetCore.Components.IComponentRenderMode! componentRenderMode) -> Microsoft.Extensions.DependencyInjection.IServiceCollection! static Microsoft.Extensions.DependencyInjection.SupplyParameterFromPersistentComponentStateProviderServiceCollectionExtensions.AddSupplyValueFromPersistentComponentStateProvider(this Microsoft.Extensions.DependencyInjection.IServiceCollection! services) -> Microsoft.Extensions.DependencyInjection.IServiceCollection! +virtual Microsoft.AspNetCore.Components.RenderTree.Renderer.SignalRendererToFinishRendering() -> void \ No newline at end of file diff --git a/src/Components/Components/src/RenderTree/Renderer.cs b/src/Components/Components/src/RenderTree/Renderer.cs index e6c3c240c538..8f9295f15886 100644 --- a/src/Components/Components/src/RenderTree/Renderer.cs +++ b/src/Components/Components/src/RenderTree/Renderer.cs @@ -44,6 +44,7 @@ public abstract partial class Renderer : IDisposable, IAsyncDisposable private bool _rendererIsDisposed; private bool _hotReloadInitialized; + private bool _rendererIsStopped; /// /// Allows the caller to handle exceptions from the SynchronizationContext when one is available. @@ -658,6 +659,12 @@ internal void AddToRenderQueue(int componentId, RenderFragment renderFragment) { Dispatcher.AssertAccess(); + if (_rendererIsStopped) + { + // Once we're stopped, we'll disregard further attempts to queue anything + return; + } + var componentState = GetOptionalComponentState(componentId); if (componentState == null) { @@ -724,14 +731,22 @@ private ComponentState GetRequiredRootComponentState(int componentId) return componentState; } + /// + /// Stop adding render requests to the render queue. + /// + protected virtual void SignalRendererToFinishRendering() + { + _rendererIsStopped = true; + } + /// /// Processes pending renders requests from components if there are any. /// protected virtual void ProcessPendingRender() { - if (_rendererIsDisposed) + if (_rendererIsDisposed || _rendererIsStopped) { - // Once we're disposed, we'll disregard further attempts to render anything + // Once we're disposed or stopped, we'll disregard further attempts to render anything return; } diff --git a/src/Components/Endpoints/src/Rendering/EndpointHtmlRenderer.cs b/src/Components/Endpoints/src/Rendering/EndpointHtmlRenderer.cs index 4af8c2634558..1bc4c40ce0a4 100644 --- a/src/Components/Endpoints/src/Rendering/EndpointHtmlRenderer.cs +++ b/src/Components/Endpoints/src/Rendering/EndpointHtmlRenderer.cs @@ -176,21 +176,10 @@ protected override void AddPendingTask(ComponentState? componentState, Task task base.AddPendingTask(componentState, task); } - private void SignalRendererToFinishRendering() + protected override void SignalRendererToFinishRendering() { _rendererIsStopped = true; - } - - protected override void ProcessPendingRender() - { - if (_rendererIsStopped) - { - // When the application triggers a NotFound event, we continue rendering the current batch. - // However, after completing this batch, we do not want to process any further UI updates, - // as we are going to return a 404 status and discard the UI updates generated so far. - return; - } - base.ProcessPendingRender(); + base.SignalRendererToFinishRendering(); } // For tests only From 3521c8f36cdc8710769114adad33c92247c0319a Mon Sep 17 00:00:00 2001 From: Ilona Tomkowicz Date: Mon, 14 Apr 2025 12:20:11 +0200 Subject: [PATCH 15/21] POST cannot safely redirect like GET does, the body should be preserved. --- .../src/Rendering/EndpointHtmlRenderer.EventDispatch.cs | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/src/Components/Endpoints/src/Rendering/EndpointHtmlRenderer.EventDispatch.cs b/src/Components/Endpoints/src/Rendering/EndpointHtmlRenderer.EventDispatch.cs index 7d2ba7438a36..7c9af5bedaf3 100644 --- a/src/Components/Endpoints/src/Rendering/EndpointHtmlRenderer.EventDispatch.cs +++ b/src/Components/Endpoints/src/Rendering/EndpointHtmlRenderer.EventDispatch.cs @@ -89,7 +89,7 @@ private void SetNotFoundResponse(object? sender, EventArgs args) private async Task OnNavigateTo(string uri) { - if (_httpContext.Response.HasStarted) + if (_httpContext.Response.HasStarted || _httpContext.Request.Method == HttpMethods.Post) { var defaultBufferSize = 16 * 1024; await using var writer = new HttpResponseStreamWriter(_httpContext.Response.Body, Encoding.UTF8, defaultBufferSize, ArrayPool.Shared, ArrayPool.Shared); From a3ca937c9a0e95f7dabcf71ddf41657800146fe6 Mon Sep 17 00:00:00 2001 From: Ilona Tomkowicz Date: Mon, 14 Apr 2025 17:59:18 +0200 Subject: [PATCH 16/21] Reuse the logic from navigation exception. --- .../EndpointHtmlRenderer.EventDispatch.cs | 4 ++-- .../Rendering/EndpointHtmlRenderer.Prerendering.cs | 14 +++++++++++--- 2 files changed, 13 insertions(+), 5 deletions(-) diff --git a/src/Components/Endpoints/src/Rendering/EndpointHtmlRenderer.EventDispatch.cs b/src/Components/Endpoints/src/Rendering/EndpointHtmlRenderer.EventDispatch.cs index 7c9af5bedaf3..e30852703324 100644 --- a/src/Components/Endpoints/src/Rendering/EndpointHtmlRenderer.EventDispatch.cs +++ b/src/Components/Endpoints/src/Rendering/EndpointHtmlRenderer.EventDispatch.cs @@ -89,7 +89,7 @@ private void SetNotFoundResponse(object? sender, EventArgs args) private async Task OnNavigateTo(string uri) { - if (_httpContext.Response.HasStarted || _httpContext.Request.Method == HttpMethods.Post) + if (_httpContext.Response.HasStarted) { var defaultBufferSize = 16 * 1024; await using var writer = new HttpResponseStreamWriter(_httpContext.Response.Body, Encoding.UTF8, defaultBufferSize, ArrayPool.Shared, ArrayPool.Shared); @@ -99,7 +99,7 @@ private async Task OnNavigateTo(string uri) } else { - _httpContext.Response.Redirect(uri); + await HandleNavigationBeforeResponseStarted(_httpContext, uri); } SignalRendererToFinishRendering(); } diff --git a/src/Components/Endpoints/src/Rendering/EndpointHtmlRenderer.Prerendering.cs b/src/Components/Endpoints/src/Rendering/EndpointHtmlRenderer.Prerendering.cs index e331fd5707c2..66ff3dfd9587 100644 --- a/src/Components/Endpoints/src/Rendering/EndpointHtmlRenderer.Prerendering.cs +++ b/src/Components/Endpoints/src/Rendering/EndpointHtmlRenderer.Prerendering.cs @@ -206,7 +206,15 @@ public static ValueTask HandleNavigationExcepti "response and avoid using features like FlushAsync() before all components on the page have been rendered to prevent failed navigation commands.", navigationException); } - else if (IsPossibleExternalDestination(httpContext.Request, navigationException.Location) + else + { + return HandleNavigationBeforeResponseStarted(httpContext, navigationException.Location); + } + } + + private static ValueTask HandleNavigationBeforeResponseStarted(HttpContext httpContext, string destinationLocation) + { + if (IsPossibleExternalDestination(httpContext.Request, destinationLocation) && IsProgressivelyEnhancedNavigation(httpContext.Request)) { // For progressively-enhanced nav, we prefer to use opaque redirections for external URLs rather than @@ -214,12 +222,12 @@ public static ValueTask HandleNavigationExcepti // duplicated request. The client can't rely on receiving this header, though, since non-Blazor endpoints // wouldn't return it. httpContext.Response.Headers.Add("blazor-enhanced-nav-redirect-location", - OpaqueRedirection.CreateProtectedRedirectionUrl(httpContext, navigationException.Location)); + OpaqueRedirection.CreateProtectedRedirectionUrl(httpContext, destinationLocation)); return new ValueTask(PrerenderedComponentHtmlContent.Empty); } else { - httpContext.Response.Redirect(navigationException.Location); + httpContext.Response.Redirect(destinationLocation); return new ValueTask(PrerenderedComponentHtmlContent.Empty); } } From 5d7eb683869b76cdd987129c776100843a550e3c Mon Sep 17 00:00:00 2001 From: Ilona Tomkowicz Date: Mon, 14 Apr 2025 18:00:48 +0200 Subject: [PATCH 17/21] Editing the ongoing render batch is not possible - for non-streaming SSR renders re-execution should be used. --- .../E2ETest/Tests/GlobalInteractivityTest.cs | 17 +++++++---------- 1 file changed, 7 insertions(+), 10 deletions(-) diff --git a/src/Components/test/E2ETest/Tests/GlobalInteractivityTest.cs b/src/Components/test/E2ETest/Tests/GlobalInteractivityTest.cs index 2f1f70aa7511..7aa71a213760 100644 --- a/src/Components/test/E2ETest/Tests/GlobalInteractivityTest.cs +++ b/src/Components/test/E2ETest/Tests/GlobalInteractivityTest.cs @@ -22,19 +22,16 @@ public class GlobalInteractivityTest( { [Theory] - [InlineData("server", true)] - [InlineData("webassembly", true)] - [InlineData("ssr", false)] - public void CanRenderNotFoundInteractive(string renderingMode, bool isInteractive) + [InlineData("server")] + [InlineData("webassembly")] + [InlineData("ssr")] + public void CanRenderNotFoundInteractive(string renderingMode) { Navigate($"/subdir/render-not-found-{renderingMode}"); - if (isInteractive) - { - var buttonId = "trigger-not-found"; - Browser.WaitForElementToBeVisible(By.Id(buttonId)); - Browser.Exists(By.Id(buttonId)).Click(); - } + var buttonId = "trigger-not-found"; + Browser.WaitForElementToBeVisible(By.Id(buttonId)); + Browser.Exists(By.Id(buttonId)).Click(); var bodyText = Browser.FindElement(By.TagName("body")).Text; Assert.Contains("There's nothing here", bodyText); From 69426fd4240287e3671b4e67252897e06b68732e Mon Sep 17 00:00:00 2001 From: Ilona Tomkowicz Date: Tue, 15 Apr 2025 08:37:27 +0200 Subject: [PATCH 18/21] Missing change for the last commit. --- src/Components/test/E2ETest/Tests/GlobalInteractivityTest.cs | 1 - 1 file changed, 1 deletion(-) diff --git a/src/Components/test/E2ETest/Tests/GlobalInteractivityTest.cs b/src/Components/test/E2ETest/Tests/GlobalInteractivityTest.cs index 7aa71a213760..371bab218061 100644 --- a/src/Components/test/E2ETest/Tests/GlobalInteractivityTest.cs +++ b/src/Components/test/E2ETest/Tests/GlobalInteractivityTest.cs @@ -24,7 +24,6 @@ public class GlobalInteractivityTest( [Theory] [InlineData("server")] [InlineData("webassembly")] - [InlineData("ssr")] public void CanRenderNotFoundInteractive(string renderingMode) { Navigate($"/subdir/render-not-found-{renderingMode}"); From e0b407cd99a25e677634444e9d0449aa00d2a266 Mon Sep 17 00:00:00 2001 From: Ilona Tomkowicz Date: Tue, 15 Apr 2025 17:12:54 +0200 Subject: [PATCH 19/21] Rename switch to match http and remote navigator. --- .../Endpoints/src/DependencyInjection/HttpNavigationManager.cs | 2 +- src/Components/Server/src/Circuits/RemoteNavigationManager.cs | 2 +- .../test/E2ETest/ServerRenderingTests/InteractivityTest.cs | 2 +- .../test/E2ETest/ServerRenderingTests/NoInteractivityTest.cs | 2 +- 4 files changed, 4 insertions(+), 4 deletions(-) diff --git a/src/Components/Endpoints/src/DependencyInjection/HttpNavigationManager.cs b/src/Components/Endpoints/src/DependencyInjection/HttpNavigationManager.cs index 24a2d4ae333d..becfe7feeaa9 100644 --- a/src/Components/Endpoints/src/DependencyInjection/HttpNavigationManager.cs +++ b/src/Components/Endpoints/src/DependencyInjection/HttpNavigationManager.cs @@ -7,7 +7,7 @@ namespace Microsoft.AspNetCore.Components.Endpoints; internal sealed class HttpNavigationManager : NavigationManager, IHostEnvironmentNavigationManager { - private const string _enableThrowNavigationException = "Microsoft.AspNetCore.Components.Endpoints.HttpNavigationManager.EnableThrowNavigationException"; + private const string _enableThrowNavigationException = "Microsoft.AspNetCore.Components.Endpoints.NavigationManager.EnableThrowNavigationException"; private static bool _throwNavigationException => AppContext.TryGetSwitch(_enableThrowNavigationException, out var switchValue) && switchValue; diff --git a/src/Components/Server/src/Circuits/RemoteNavigationManager.cs b/src/Components/Server/src/Circuits/RemoteNavigationManager.cs index aca895aa2a4f..23056cbf0a2b 100644 --- a/src/Components/Server/src/Circuits/RemoteNavigationManager.cs +++ b/src/Components/Server/src/Circuits/RemoteNavigationManager.cs @@ -17,7 +17,7 @@ internal sealed partial class RemoteNavigationManager : NavigationManager, IHost private readonly ILogger _logger; private IJSRuntime _jsRuntime; private bool? _navigationLockStateBeforeJsRuntimeAttached; - private const string _enableThrowNavigationException = "Microsoft.AspNetCore.Components.Endpoints.HttpNavigationManager.EnableThrowNavigationException"; + private const string _enableThrowNavigationException = "Microsoft.AspNetCore.Components.Endpoints.NavigationManager.EnableThrowNavigationException"; private static bool _throwNavigationException => AppContext.TryGetSwitch(_enableThrowNavigationException, out var switchValue) && switchValue; private Func? _onNavigateTo; diff --git a/src/Components/test/E2ETest/ServerRenderingTests/InteractivityTest.cs b/src/Components/test/E2ETest/ServerRenderingTests/InteractivityTest.cs index f173662caab2..286a6de9e12d 100644 --- a/src/Components/test/E2ETest/ServerRenderingTests/InteractivityTest.cs +++ b/src/Components/test/E2ETest/ServerRenderingTests/InteractivityTest.cs @@ -1405,7 +1405,7 @@ public void CanPersistMultiplePrerenderedStateDeclaratively_Auto_PersistsOnWebAs [InlineData(false)] public void NavigatesWithInteractivityByRequestRedirection(bool controlFlowByException) { - AppContext.SetSwitch("Microsoft.AspNetCore.Components.Endpoints.EnableThrowNavigationException", isEnabled: controlFlowByException); + AppContext.SetSwitch("Microsoft.AspNetCore.Components.Endpoints.NavigationManager.EnableThrowNavigationException", isEnabled: controlFlowByException); Navigate($"{ServerPathBase}/routing/ssr-navigate-to"); Browser.Equal("Click submit to navigate to home", () => Browser.Exists(By.Id("test-info")).Text); Browser.Click(By.Id("redirectButton")); diff --git a/src/Components/test/E2ETest/ServerRenderingTests/NoInteractivityTest.cs b/src/Components/test/E2ETest/ServerRenderingTests/NoInteractivityTest.cs index e1a58633e1bb..5ad9be45f057 100644 --- a/src/Components/test/E2ETest/ServerRenderingTests/NoInteractivityTest.cs +++ b/src/Components/test/E2ETest/ServerRenderingTests/NoInteractivityTest.cs @@ -71,7 +71,7 @@ public void CanUseServerAuthenticationStateByDefault() [InlineData(false, true)] public void NavigatesWithoutInteractivityByRequestRedirection(bool controlFlowByException, bool isStreaming) { - AppContext.SetSwitch("Microsoft.AspNetCore.Components.Endpoints.EnableThrowNavigationException", isEnabled: controlFlowByException); + AppContext.SetSwitch("Microsoft.AspNetCore.Components.Endpoints.NavigationManager.EnableThrowNavigationException", isEnabled: controlFlowByException); string streaming = isStreaming ? $"streaming-" : ""; Navigate($"{ServerPathBase}/routing/ssr-{streaming}navigate-to"); Browser.Equal("Click submit to navigate to home", () => Browser.Exists(By.Id("test-info")).Text); From dc45f3e33e9a02a5e7cee1092059deb800fdc44f Mon Sep 17 00:00:00 2001 From: Ilona Tomkowicz Date: Tue, 15 Apr 2025 17:41:50 +0200 Subject: [PATCH 20/21] Adjust test for the new behavior. --- .../test/EndpointHtmlRendererTest.cs | 55 ++++++++++++++----- 1 file changed, 41 insertions(+), 14 deletions(-) diff --git a/src/Components/Endpoints/test/EndpointHtmlRendererTest.cs b/src/Components/Endpoints/test/EndpointHtmlRendererTest.cs index e54298c42208..7bd648c783c1 100644 --- a/src/Components/Endpoints/test/EndpointHtmlRendererTest.cs +++ b/src/Components/Endpoints/test/EndpointHtmlRendererTest.cs @@ -828,9 +828,12 @@ public async Task Rendering_ComponentWithJsInteropThrows() exception.Message); } - [Fact] - public async Task UriHelperRedirect_ThrowsInvalidOperationException_WhenResponseHasAlreadyStarted() + [Theory] + [InlineData(true)] + [InlineData(false)] + public async Task UriHelperRedirect_ThrowsInvalidOperationException_WhenResponseHasAlreadyStarted(bool expectException) { + AppContext.SetSwitch("Microsoft.AspNetCore.Components.Endpoints.NavigationManager.EnableThrowNavigationException", isEnabled: expectException); // Arrange var ctx = new DefaultHttpContext(); ctx.Request.Scheme = "http"; @@ -838,25 +841,49 @@ public async Task UriHelperRedirect_ThrowsInvalidOperationException_WhenResponse ctx.Request.PathBase = "/base"; ctx.Request.Path = "/path"; ctx.Request.QueryString = new QueryString("?query=value"); + ctx.Response.Body = new MemoryStream(); var responseMock = new Mock(); responseMock.Setup(r => r.HasStarted).Returns(true); ctx.Features.Set(responseMock.Object); var httpContext = GetHttpContext(ctx); + string redirectUri = "http://localhost/redirect"; // Act - var exception = await Assert.ThrowsAsync(async () => await renderer.PrerenderComponentAsync( - httpContext, - typeof(RedirectComponent), - null, - ParameterView.FromDictionary(new Dictionary - { - { "RedirectUri", "http://localhost/redirect" } - }))); + if (expectException) + { + var exception = await Assert.ThrowsAsync(async () => await renderer.PrerenderComponentAsync( + httpContext, + typeof(RedirectComponent), + null, + ParameterView.FromDictionary(new Dictionary + { + { "RedirectUri", redirectUri } + }))); - Assert.Equal("A navigation command was attempted during prerendering after the server already started sending the response. " + - "Navigation commands can not be issued during server-side prerendering after the response from the server has started. Applications must buffer the" + - "response and avoid using features like FlushAsync() before all components on the page have been rendered to prevent failed navigation commands.", - exception.Message); + Assert.Equal("A navigation command was attempted during prerendering after the server already started sending the response. " + + "Navigation commands can not be issued during server-side prerendering after the response from the server has started. Applications must buffer the" + + "response and avoid using features like FlushAsync() before all components on the page have been rendered to prevent failed navigation commands.", + exception.Message); + } + else + { + await renderer.PrerenderComponentAsync( + httpContext, + typeof(RedirectComponent), + null, + ParameterView.FromDictionary(new Dictionary + { + { "RedirectUri", redirectUri } + })); + // read the custom element from the response body + httpContext.Response.Body.Position = 0; + var reader = new StreamReader(httpContext.Response.Body); + var output = await reader.ReadToEndAsync(); + + // Assert that the output contains expected navigation instructions. + var pattern = "^