Skip to content

Commit 43bef09

Browse files
ilonatommyjaviercn
andauthored
HttpNavigationManager no longer uses NavigationException (#61306)
* Rise even instead of throwing. * Clean up, delay not needed. * Fix typo + test old way of workign as well. * Update name to be full namespace + remove readonly. * Feedback - interactive SSR updates required exposing some methods. * Fix missing xml. * Fix build of tests. * Fix nullable. * Feedback - limit public API changes. * Handle the case when response started. * Proposal of fixing external navigation. * Update src/Components/test/testassets/Components.TestServer/RazorComponents/Pages/Routing/SSRRedirectionStreaming.razor Co-authored-by: Javier Calvarro Nelson <[email protected]> * Feedback. * More effective stopping of the renderer. * POST cannot safely redirect like GET does, the body should be preserved. * Reuse the logic from navigation exception. * Editing the ongoing render batch is not possible - for non-streaming SSR renders re-execution should be used. * Missing change for the last commit. * Rename switch to match http and remote navigator. * Adjust test for the new behavior. * Fix exception - driven navigation. --------- Co-authored-by: Javier Calvarro Nelson <[email protected]>
1 parent eedcac3 commit 43bef09

File tree

15 files changed

+240
-54
lines changed

15 files changed

+240
-54
lines changed

src/Components/Components/src/PublicAPI.Unshipped.txt

Lines changed: 2 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -1,6 +1,7 @@
11
#nullable enable
22
Microsoft.AspNetCore.Components.NavigationManager.OnNotFound -> System.EventHandler<Microsoft.AspNetCore.Components.Routing.NotFoundEventArgs!>!
33
Microsoft.AspNetCore.Components.NavigationManager.NotFound() -> void
4+
Microsoft.AspNetCore.Components.Routing.IHostEnvironmentNavigationManager.Initialize(string! baseUri, string! uri, System.Func<string!, System.Threading.Tasks.Task!>! onNavigateTo) -> void
45
Microsoft.AspNetCore.Components.Routing.NotFoundEventArgs
56
Microsoft.AspNetCore.Components.Routing.NotFoundEventArgs.NotFoundEventArgs() -> void
67
Microsoft.AspNetCore.Components.Infrastructure.ComponentStatePersistenceManager.ComponentStatePersistenceManager(Microsoft.Extensions.Logging.ILogger<Microsoft.AspNetCore.Components.Infrastructure.ComponentStatePersistenceManager!>! logger, System.IServiceProvider! serviceProvider) -> void
@@ -11,3 +12,4 @@ Microsoft.AspNetCore.Components.SupplyParameterFromPersistentComponentStateAttri
1112
Microsoft.Extensions.DependencyInjection.SupplyParameterFromPersistentComponentStateProviderServiceCollectionExtensions
1213
static Microsoft.AspNetCore.Components.Infrastructure.RegisterPersistentComponentStateServiceCollectionExtensions.AddPersistentServiceRegistration<TService>(Microsoft.Extensions.DependencyInjection.IServiceCollection! services, Microsoft.AspNetCore.Components.IComponentRenderMode! componentRenderMode) -> Microsoft.Extensions.DependencyInjection.IServiceCollection!
1314
static Microsoft.Extensions.DependencyInjection.SupplyParameterFromPersistentComponentStateProviderServiceCollectionExtensions.AddSupplyValueFromPersistentComponentStateProvider(this Microsoft.Extensions.DependencyInjection.IServiceCollection! services) -> Microsoft.Extensions.DependencyInjection.IServiceCollection!
15+
virtual Microsoft.AspNetCore.Components.RenderTree.Renderer.SignalRendererToFinishRendering() -> void

src/Components/Components/src/RenderTree/Renderer.cs

Lines changed: 17 additions & 2 deletions
Original file line numberDiff line numberDiff line change
@@ -46,6 +46,7 @@ public abstract partial class Renderer : IDisposable, IAsyncDisposable
4646
private bool _rendererIsDisposed;
4747

4848
private bool _hotReloadInitialized;
49+
private bool _rendererIsStopped;
4950

5051
/// <summary>
5152
/// Allows the caller to handle exceptions from the SynchronizationContext when one is available.
@@ -664,6 +665,12 @@ internal void AddToRenderQueue(int componentId, RenderFragment renderFragment)
664665
{
665666
Dispatcher.AssertAccess();
666667

668+
if (_rendererIsStopped)
669+
{
670+
// Once we're stopped, we'll disregard further attempts to queue anything
671+
return;
672+
}
673+
667674
var componentState = GetOptionalComponentState(componentId);
668675
if (componentState == null)
669676
{
@@ -730,14 +737,22 @@ private ComponentState GetRequiredRootComponentState(int componentId)
730737
return componentState;
731738
}
732739

740+
/// <summary>
741+
/// Stop adding render requests to the render queue.
742+
/// </summary>
743+
protected virtual void SignalRendererToFinishRendering()
744+
{
745+
_rendererIsStopped = true;
746+
}
747+
733748
/// <summary>
734749
/// Processes pending renders requests from components if there are any.
735750
/// </summary>
736751
protected virtual void ProcessPendingRender()
737752
{
738-
if (_rendererIsDisposed)
753+
if (_rendererIsDisposed || _rendererIsStopped)
739754
{
740-
// Once we're disposed, we'll disregard further attempts to render anything
755+
// Once we're disposed or stopped, we'll disregard further attempts to render anything
741756
return;
742757
}
743758

src/Components/Components/src/Routing/IHostEnvironmentNavigationManager.cs

Lines changed: 9 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -15,4 +15,13 @@ public interface IHostEnvironmentNavigationManager
1515
/// <param name="baseUri">The base URI.</param>
1616
/// <param name="uri">The absolute URI.</param>
1717
void Initialize(string baseUri, string uri);
18+
19+
/// <summary>
20+
/// Initializes the <see cref="NavigationManager" />.
21+
/// </summary>
22+
/// <param name="baseUri">The base URI.</param>
23+
/// <param name="uri">The absolute URI.</param>
24+
/// <param name="onNavigateTo">A delegate that points to a method handling navigation events. </param>
25+
void Initialize(string baseUri, string uri, Func<string, Task> onNavigateTo) =>
26+
Initialize(baseUri, uri);
1827
}

src/Components/Endpoints/src/DependencyInjection/HttpNavigationManager.cs

Lines changed: 30 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -7,11 +7,40 @@ namespace Microsoft.AspNetCore.Components.Endpoints;
77

88
internal sealed class HttpNavigationManager : NavigationManager, IHostEnvironmentNavigationManager
99
{
10+
private const string _enableThrowNavigationException = "Microsoft.AspNetCore.Components.Endpoints.NavigationManager.EnableThrowNavigationException";
11+
12+
private static bool _throwNavigationException =>
13+
AppContext.TryGetSwitch(_enableThrowNavigationException, out var switchValue) && switchValue;
14+
15+
private Func<string, Task>? _onNavigateTo;
16+
1017
void IHostEnvironmentNavigationManager.Initialize(string baseUri, string uri) => Initialize(baseUri, uri);
1118

19+
void IHostEnvironmentNavigationManager.Initialize(string baseUri, string uri, Func<string, Task> onNavigateTo)
20+
{
21+
_onNavigateTo = onNavigateTo;
22+
Initialize(baseUri, uri);
23+
}
24+
1225
protected override void NavigateToCore(string uri, NavigationOptions options)
1326
{
1427
var absoluteUriString = ToAbsoluteUri(uri).AbsoluteUri;
15-
throw new NavigationException(absoluteUriString);
28+
if (_throwNavigationException)
29+
{
30+
throw new NavigationException(absoluteUriString);
31+
}
32+
else
33+
{
34+
_ = PerformNavigationAsync();
35+
}
36+
37+
async Task PerformNavigationAsync()
38+
{
39+
if (_onNavigateTo == null)
40+
{
41+
throw new InvalidOperationException($"'{GetType().Name}' method for endpoint-based navigation has not been initialized.");
42+
}
43+
await _onNavigateTo(absoluteUriString);
44+
}
1645
}
1746
}

src/Components/Endpoints/src/Rendering/EndpointHtmlRenderer.EventDispatch.cs

Lines changed: 20 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -1,11 +1,14 @@
11
// Licensed to the .NET Foundation under one or more agreements.
22
// The .NET Foundation licenses this file to you under the MIT license.
33

4+
using Microsoft.AspNetCore.Components.Endpoints.Rendering;
45
using Microsoft.AspNetCore.Components.Rendering;
56
using Microsoft.AspNetCore.Components.RenderTree;
67
using Microsoft.AspNetCore.Http;
8+
using Microsoft.AspNetCore.WebUtilities;
79
using Microsoft.Extensions.DependencyInjection;
810
using Microsoft.Extensions.Hosting;
11+
using System.Buffers;
912
using System.Globalization;
1013
using System.Linq;
1114
using System.Text;
@@ -84,6 +87,23 @@ private void SetNotFoundResponse(object? sender, EventArgs args)
8487
SignalRendererToFinishRendering();
8588
}
8689

90+
private async Task OnNavigateTo(string uri)
91+
{
92+
if (_httpContext.Response.HasStarted)
93+
{
94+
var defaultBufferSize = 16 * 1024;
95+
await using var writer = new HttpResponseStreamWriter(_httpContext.Response.Body, Encoding.UTF8, defaultBufferSize, ArrayPool<byte>.Shared, ArrayPool<char>.Shared);
96+
using var bufferWriter = new BufferedTextWriter(writer);
97+
HandleNavigationAfterResponseStarted(bufferWriter, _httpContext, uri);
98+
await bufferWriter.FlushAsync();
99+
}
100+
else
101+
{
102+
await HandleNavigationBeforeResponseStarted(_httpContext, uri);
103+
}
104+
SignalRendererToFinishRendering();
105+
}
106+
87107
private void UpdateNamedSubmitEvents(in RenderBatch renderBatch)
88108
{
89109
if (renderBatch.NamedEventChanges is { } changes)

src/Components/Endpoints/src/Rendering/EndpointHtmlRenderer.Prerendering.cs

Lines changed: 11 additions & 3 deletions
Original file line numberDiff line numberDiff line change
@@ -206,20 +206,28 @@ public static ValueTask<PrerenderedComponentHtmlContent> HandleNavigationExcepti
206206
"response and avoid using features like FlushAsync() before all components on the page have been rendered to prevent failed navigation commands.",
207207
navigationException);
208208
}
209-
else if (IsPossibleExternalDestination(httpContext.Request, navigationException.Location)
209+
else
210+
{
211+
return HandleNavigationBeforeResponseStarted(httpContext, navigationException.Location);
212+
}
213+
}
214+
215+
private static ValueTask<PrerenderedComponentHtmlContent> HandleNavigationBeforeResponseStarted(HttpContext httpContext, string destinationLocation)
216+
{
217+
if (IsPossibleExternalDestination(httpContext.Request, destinationLocation)
210218
&& IsProgressivelyEnhancedNavigation(httpContext.Request))
211219
{
212220
// For progressively-enhanced nav, we prefer to use opaque redirections for external URLs rather than
213221
// forcing the request to be retried, since that allows post-redirect-get to work, plus avoids a
214222
// duplicated request. The client can't rely on receiving this header, though, since non-Blazor endpoints
215223
// wouldn't return it.
216224
httpContext.Response.Headers.Add("blazor-enhanced-nav-redirect-location",
217-
OpaqueRedirection.CreateProtectedRedirectionUrl(httpContext, navigationException.Location));
225+
OpaqueRedirection.CreateProtectedRedirectionUrl(httpContext, destinationLocation));
218226
return new ValueTask<PrerenderedComponentHtmlContent>(PrerenderedComponentHtmlContent.Empty);
219227
}
220228
else
221229
{
222-
httpContext.Response.Redirect(navigationException.Location);
230+
httpContext.Response.Redirect(destinationLocation);
223231
return new ValueTask<PrerenderedComponentHtmlContent>(PrerenderedComponentHtmlContent.Empty);
224232
}
225233
}

src/Components/Endpoints/src/Rendering/EndpointHtmlRenderer.cs

Lines changed: 3 additions & 14 deletions
Original file line numberDiff line numberDiff line change
@@ -79,7 +79,7 @@ internal async Task InitializeStandardComponentServicesAsync(
7979
IFormCollection? form = null)
8080
{
8181
var navigationManager = httpContext.RequestServices.GetRequiredService<NavigationManager>();
82-
((IHostEnvironmentNavigationManager)navigationManager)?.Initialize(GetContextBaseUri(httpContext.Request), GetFullUri(httpContext.Request));
82+
((IHostEnvironmentNavigationManager)navigationManager)?.Initialize(GetContextBaseUri(httpContext.Request), GetFullUri(httpContext.Request), OnNavigateTo);
8383

8484
if (navigationManager != null)
8585
{
@@ -176,21 +176,10 @@ protected override void AddPendingTask(ComponentState? componentState, Task task
176176
base.AddPendingTask(componentState, task);
177177
}
178178

179-
private void SignalRendererToFinishRendering()
179+
protected override void SignalRendererToFinishRendering()
180180
{
181181
_rendererIsStopped = true;
182-
}
183-
184-
protected override void ProcessPendingRender()
185-
{
186-
if (_rendererIsStopped)
187-
{
188-
// When the application triggers a NotFound event, we continue rendering the current batch.
189-
// However, after completing this batch, we do not want to process any further UI updates,
190-
// as we are going to return a 404 status and discard the UI updates generated so far.
191-
return;
192-
}
193-
base.ProcessPendingRender();
182+
base.SignalRendererToFinishRendering();
194183
}
195184

196185
// For tests only

src/Components/Endpoints/test/EndpointHtmlRendererTest.cs

Lines changed: 41 additions & 14 deletions
Original file line numberDiff line numberDiff line change
@@ -828,35 +828,62 @@ public async Task Rendering_ComponentWithJsInteropThrows()
828828
exception.Message);
829829
}
830830

831-
[Fact]
832-
public async Task UriHelperRedirect_ThrowsInvalidOperationException_WhenResponseHasAlreadyStarted()
831+
[Theory]
832+
[InlineData(true)]
833+
[InlineData(false)]
834+
public async Task UriHelperRedirect_ThrowsInvalidOperationException_WhenResponseHasAlreadyStarted(bool expectException)
833835
{
836+
AppContext.SetSwitch("Microsoft.AspNetCore.Components.Endpoints.NavigationManager.EnableThrowNavigationException", isEnabled: expectException);
834837
// Arrange
835838
var ctx = new DefaultHttpContext();
836839
ctx.Request.Scheme = "http";
837840
ctx.Request.Host = new HostString("localhost");
838841
ctx.Request.PathBase = "/base";
839842
ctx.Request.Path = "/path";
840843
ctx.Request.QueryString = new QueryString("?query=value");
844+
ctx.Response.Body = new MemoryStream();
841845
var responseMock = new Mock<IHttpResponseFeature>();
842846
responseMock.Setup(r => r.HasStarted).Returns(true);
843847
ctx.Features.Set(responseMock.Object);
844848
var httpContext = GetHttpContext(ctx);
849+
string redirectUri = "http://localhost/redirect";
845850

846851
// Act
847-
var exception = await Assert.ThrowsAsync<InvalidOperationException>(async () => await renderer.PrerenderComponentAsync(
848-
httpContext,
849-
typeof(RedirectComponent),
850-
null,
851-
ParameterView.FromDictionary(new Dictionary<string, object>
852-
{
853-
{ "RedirectUri", "http://localhost/redirect" }
854-
})));
852+
if (expectException)
853+
{
854+
var exception = await Assert.ThrowsAsync<InvalidOperationException>(async () => await renderer.PrerenderComponentAsync(
855+
httpContext,
856+
typeof(RedirectComponent),
857+
null,
858+
ParameterView.FromDictionary(new Dictionary<string, object>
859+
{
860+
{ "RedirectUri", redirectUri }
861+
})));
855862

856-
Assert.Equal("A navigation command was attempted during prerendering after the server already started sending the response. " +
857-
"Navigation commands can not be issued during server-side prerendering after the response from the server has started. Applications must buffer the" +
858-
"response and avoid using features like FlushAsync() before all components on the page have been rendered to prevent failed navigation commands.",
859-
exception.Message);
863+
Assert.Equal("A navigation command was attempted during prerendering after the server already started sending the response. " +
864+
"Navigation commands can not be issued during server-side prerendering after the response from the server has started. Applications must buffer the" +
865+
"response and avoid using features like FlushAsync() before all components on the page have been rendered to prevent failed navigation commands.",
866+
exception.Message);
867+
}
868+
else
869+
{
870+
await renderer.PrerenderComponentAsync(
871+
httpContext,
872+
typeof(RedirectComponent),
873+
null,
874+
ParameterView.FromDictionary(new Dictionary<string, object>
875+
{
876+
{ "RedirectUri", redirectUri }
877+
}));
878+
// read the custom element from the response body
879+
httpContext.Response.Body.Position = 0;
880+
var reader = new StreamReader(httpContext.Response.Body);
881+
var output = await reader.ReadToEndAsync();
882+
883+
// Assert that the output contains expected navigation instructions.
884+
var pattern = "^<blazor-ssr><template type=\"redirection\".*>.*<\\/template><blazor-ssr-end><\\/blazor-ssr-end><\\/blazor-ssr>$";
885+
Assert.Matches(pattern, output);
886+
}
860887
}
861888

862889
[Fact]

src/Components/Server/src/Circuits/RemoteNavigationManager.cs

Lines changed: 37 additions & 2 deletions
Original file line numberDiff line numberDiff line change
@@ -17,6 +17,10 @@ internal sealed partial class RemoteNavigationManager : NavigationManager, IHost
1717
private readonly ILogger<RemoteNavigationManager> _logger;
1818
private IJSRuntime _jsRuntime;
1919
private bool? _navigationLockStateBeforeJsRuntimeAttached;
20+
private const string _enableThrowNavigationException = "Microsoft.AspNetCore.Components.Endpoints.NavigationManager.EnableThrowNavigationException";
21+
private static bool _throwNavigationException =>
22+
AppContext.TryGetSwitch(_enableThrowNavigationException, out var switchValue) && switchValue;
23+
private Func<string, Task>? _onNavigateTo;
2024

2125
public event EventHandler<Exception>? UnhandledException;
2226

@@ -45,6 +49,19 @@ public RemoteNavigationManager(ILogger<RemoteNavigationManager> logger)
4549
NotifyLocationChanged(isInterceptedLink: false);
4650
}
4751

52+
/// <summary>
53+
/// Initializes the <see cref="NavigationManager" />.
54+
/// </summary>
55+
/// <param name="baseUri">The base URI.</param>
56+
/// <param name="uri">The absolute URI.</param>
57+
/// <param name="onNavigateTo">A delegate that points to a method handling navigation events. </param>
58+
public void Initialize(string baseUri, string uri, Func<string, Task> onNavigateTo)
59+
{
60+
_onNavigateTo += onNavigateTo;
61+
base.Initialize(baseUri, uri);
62+
NotifyLocationChanged(isInterceptedLink: false);
63+
}
64+
4865
/// <summary>
4966
/// Initializes the <see cref="RemoteNavigationManager"/>.
5067
/// </summary>
@@ -88,7 +105,16 @@ protected override void NavigateToCore(string uri, NavigationOptions options)
88105
if (_jsRuntime == null)
89106
{
90107
var absoluteUriString = ToAbsoluteUri(uri).AbsoluteUri;
91-
throw new NavigationException(absoluteUriString);
108+
if (_throwNavigationException)
109+
{
110+
throw new NavigationException(absoluteUriString);
111+
}
112+
if (_onNavigateTo == null)
113+
{
114+
throw new InvalidOperationException($"'{GetType().Name}' method for endpoint-based navigation has not been initialized.");
115+
}
116+
_ = _onNavigateTo(absoluteUriString);
117+
return;
92118
}
93119

94120
_ = PerformNavigationAsync();
@@ -129,7 +155,16 @@ public override void Refresh(bool forceReload = false)
129155
if (_jsRuntime == null)
130156
{
131157
var absoluteUriString = ToAbsoluteUri(Uri).AbsoluteUri;
132-
throw new NavigationException(absoluteUriString);
158+
if (_throwNavigationException)
159+
{
160+
throw new NavigationException(absoluteUriString);
161+
}
162+
if (_onNavigateTo == null)
163+
{
164+
throw new InvalidOperationException($"'{GetType().Name}' method for endpoint-based navigation has not been initialized.");
165+
}
166+
_ = _onNavigateTo(absoluteUriString);
167+
return;
133168
}
134169

135170
_ = RefreshAsync();

src/Components/test/E2ETest/ServerRenderingTests/InteractivityTest.cs

Lines changed: 12 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -1399,4 +1399,16 @@ public void CanPersistMultiplePrerenderedStateDeclaratively_Auto_PersistsOnWebAs
13991399
Browser.Equal("restored 2", () => Browser.FindElement(By.Id("auto-2")).Text);
14001400
Browser.Equal("WebAssembly", () => Browser.FindElement(By.Id("render-mode-auto-2")).Text);
14011401
}
1402+
1403+
[Theory]
1404+
[InlineData(true)]
1405+
[InlineData(false)]
1406+
public void NavigatesWithInteractivityByRequestRedirection(bool controlFlowByException)
1407+
{
1408+
AppContext.SetSwitch("Microsoft.AspNetCore.Components.Endpoints.NavigationManager.EnableThrowNavigationException", isEnabled: controlFlowByException);
1409+
Navigate($"{ServerPathBase}/routing/ssr-navigate-to");
1410+
Browser.Equal("Click submit to navigate to home", () => Browser.Exists(By.Id("test-info")).Text);
1411+
Browser.Click(By.Id("redirectButton"));
1412+
Browser.Equal("Routing test cases", () => Browser.Exists(By.Id("test-info")).Text);
1413+
}
14021414
}

src/Components/test/E2ETest/ServerRenderingTests/NoInteractivityTest.cs

Lines changed: 14 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -64,4 +64,18 @@ public void CanUseServerAuthenticationStateByDefault()
6464
Browser.Equal("True", () => Browser.FindElement(By.Id("is-in-test-role-1")).Text);
6565
Browser.Equal("True", () => Browser.FindElement(By.Id("is-in-test-role-2")).Text);
6666
}
67+
68+
[Theory]
69+
[InlineData(true, true)]
70+
[InlineData(true, false)]
71+
[InlineData(false, true)]
72+
public void NavigatesWithoutInteractivityByRequestRedirection(bool controlFlowByException, bool isStreaming)
73+
{
74+
AppContext.SetSwitch("Microsoft.AspNetCore.Components.Endpoints.NavigationManager.EnableThrowNavigationException", isEnabled: controlFlowByException);
75+
string streaming = isStreaming ? $"streaming-" : "";
76+
Navigate($"{ServerPathBase}/routing/ssr-{streaming}navigate-to");
77+
Browser.Equal("Click submit to navigate to home", () => Browser.Exists(By.Id("test-info")).Text);
78+
Browser.Click(By.Id("redirectButton"));
79+
Browser.Equal("Routing test cases", () => Browser.Exists(By.Id("test-info")).Text);
80+
}
6781
}

0 commit comments

Comments
 (0)