Skip to content

Commit 06ea51f

Browse files
authored
Change priority of re-execution handling and allow router to stream NotFound contents (#62178)
* Fix SSR non-streaming: rendering has higher priority than re-execution. * Add tests: move component common for global and local interactivity to shared project + redefine test cases. * Unified SSR tests for POST and GET. * Fix client navigation: always use enhanced nav for not found rendering, unless user explicitly disabled it. * Remove test duplication: when server always requests a render with enhanced nav, actual request headers do not matter. * Use client redirect with url change when user disables enhanced navigation.
1 parent d6b2d08 commit 06ea51f

38 files changed

+591
-196
lines changed

src/Components/Components/src/NavigationManager.cs

Lines changed: 2 additions & 3 deletions
Original file line numberDiff line numberDiff line change
@@ -54,15 +54,14 @@ public event EventHandler<NotFoundEventArgs> OnNotFound
5454

5555
private EventHandler<NotFoundEventArgs>? _notFound;
5656

57-
private static readonly NotFoundEventArgs _notFoundEventArgs = new NotFoundEventArgs();
58-
5957
// For the baseUri it's worth storing as a System.Uri so we can do operations
6058
// on that type. System.Uri gives us access to the original string anyway.
6159
private Uri? _baseUri;
6260

6361
// The URI. Always represented an absolute URI.
6462
private string? _uri;
6563
private bool _isInitialized;
64+
internal string NotFoundPageRoute { get; set; } = string.Empty;
6665

6766
/// <summary>
6867
/// Gets or sets the current base URI. The <see cref="BaseUri" /> is always represented as an absolute URI in string form with trailing slash.
@@ -212,7 +211,7 @@ private void NotFoundCore()
212211
}
213212
else
214213
{
215-
_notFound.Invoke(this, _notFoundEventArgs);
214+
_notFound.Invoke(this, new NotFoundEventArgs(NotFoundPageRoute));
216215
}
217216
}
218217

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

Lines changed: 2 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -6,7 +6,8 @@ Microsoft.AspNetCore.Components.NavigationManager.OnNotFound -> System.EventHand
66
Microsoft.AspNetCore.Components.NavigationManager.NotFound() -> void
77
Microsoft.AspNetCore.Components.Routing.IHostEnvironmentNavigationManager.Initialize(string! baseUri, string! uri, System.Func<string!, System.Threading.Tasks.Task!>! onNavigateTo) -> void
88
Microsoft.AspNetCore.Components.Routing.NotFoundEventArgs
9-
Microsoft.AspNetCore.Components.Routing.NotFoundEventArgs.NotFoundEventArgs() -> void
9+
Microsoft.AspNetCore.Components.Routing.NotFoundEventArgs.NotFoundEventArgs(string! url) -> void
10+
Microsoft.AspNetCore.Components.Routing.NotFoundEventArgs.Path.get -> string!
1011
Microsoft.AspNetCore.Components.Infrastructure.ComponentStatePersistenceManager.ComponentStatePersistenceManager(Microsoft.Extensions.Logging.ILogger<Microsoft.AspNetCore.Components.Infrastructure.ComponentStatePersistenceManager!>! logger, System.IServiceProvider! serviceProvider) -> void
1112
Microsoft.AspNetCore.Components.Infrastructure.ComponentStatePersistenceManager.SetPlatformRenderMode(Microsoft.AspNetCore.Components.IComponentRenderMode! renderMode) -> void
1213
Microsoft.AspNetCore.Components.Infrastructure.RegisterPersistentComponentStateServiceCollectionExtensions

src/Components/Components/src/Routing/NotFoundEventArgs.cs

Lines changed: 10 additions & 2 deletions
Original file line numberDiff line numberDiff line change
@@ -8,9 +8,17 @@ namespace Microsoft.AspNetCore.Components.Routing;
88
/// </summary>
99
public sealed class NotFoundEventArgs : EventArgs
1010
{
11+
/// <summary>
12+
/// Gets the path of NotFoundPage.
13+
/// </summary>
14+
public string Path { get; }
15+
1116
/// <summary>
1217
/// Initializes a new instance of <see cref="NotFoundEventArgs" />.
1318
/// </summary>
14-
public NotFoundEventArgs()
15-
{ }
19+
public NotFoundEventArgs(string url)
20+
{
21+
Path = url;
22+
}
23+
1624
}

src/Components/Components/src/Routing/Router.cs

Lines changed: 6 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -155,6 +155,12 @@ public async Task SetParametersAsync(ParameterView parameters)
155155
throw new InvalidOperationException($"The type {NotFoundPage.FullName} " +
156156
$"does not have a {typeof(RouteAttribute).FullName} applied to it.");
157157
}
158+
159+
var routeAttribute = (RouteAttribute)routeAttributes[0];
160+
if (routeAttribute.Template != null)
161+
{
162+
NavigationManager.NotFoundPageRoute = routeAttribute.Template;
163+
}
158164
}
159165

160166
if (!_onNavigateCalled)

src/Components/Endpoints/src/RazorComponentEndpointInvoker.cs

Lines changed: 0 additions & 7 deletions
Original file line numberDiff line numberDiff line change
@@ -111,13 +111,6 @@ await _renderer.InitializeStandardComponentServicesAsync(
111111
ParameterView.Empty,
112112
waitForQuiescence: result.IsPost || isErrorHandlerOrReExecuted);
113113

114-
bool avoidStartingResponse = hasStatusCodePage && !isReExecuted && context.Response.StatusCode == StatusCodes.Status404NotFound;
115-
if (avoidStartingResponse)
116-
{
117-
// the request is going to be re-executed, we should avoid writing to the response
118-
return;
119-
}
120-
121114
Task quiesceTask;
122115
if (!result.IsPost)
123116
{

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

Lines changed: 28 additions & 5 deletions
Original file line numberDiff line numberDiff line change
@@ -1,9 +1,11 @@
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.Builder;
45
using Microsoft.AspNetCore.Components.Endpoints.Rendering;
56
using Microsoft.AspNetCore.Components.Rendering;
67
using Microsoft.AspNetCore.Components.RenderTree;
8+
using Microsoft.AspNetCore.Components.Routing;
79
using Microsoft.AspNetCore.Http;
810
using Microsoft.AspNetCore.WebUtilities;
911
using Microsoft.Extensions.DependencyInjection;
@@ -77,21 +79,26 @@ private Task ReturnErrorResponse(string detailedMessage)
7779
: Task.CompletedTask;
7880
}
7981

80-
private async Task SetNotFoundResponseAsync(string baseUri)
82+
internal async Task SetNotFoundResponseAsync(string baseUri, NotFoundEventArgs args)
8183
{
82-
if (_httpContext.Response.HasStarted)
84+
if (_httpContext.Response.HasStarted ||
85+
// POST waits for quiescence -> rendering the NotFoundPage would be queued for the next batch
86+
// but we want to send the signal to the renderer to stop rendering future batches -> use client rendering
87+
HttpMethods.IsPost(_httpContext.Request.Method))
8388
{
89+
if (string.IsNullOrEmpty(_notFoundUrl))
90+
{
91+
_notFoundUrl = GetNotFoundUrl(baseUri, args);
92+
}
8493
var defaultBufferSize = 16 * 1024;
8594
await using var writer = new HttpResponseStreamWriter(_httpContext.Response.Body, Encoding.UTF8, defaultBufferSize, ArrayPool<byte>.Shared, ArrayPool<char>.Shared);
8695
using var bufferWriter = new BufferedTextWriter(writer);
87-
var notFoundUri = $"{baseUri}not-found";
88-
HandleNavigationAfterResponseStarted(bufferWriter, _httpContext, notFoundUri);
96+
HandleNotFoundAfterResponseStarted(bufferWriter, _httpContext, _notFoundUrl);
8997
await bufferWriter.FlushAsync();
9098
}
9199
else
92100
{
93101
_httpContext.Response.StatusCode = StatusCodes.Status404NotFound;
94-
_httpContext.Response.ContentType = null;
95102
}
96103

97104
// When the application triggers a NotFound event, we continue rendering the current batch.
@@ -100,6 +107,22 @@ private async Task SetNotFoundResponseAsync(string baseUri)
100107
SignalRendererToFinishRendering();
101108
}
102109

110+
private string GetNotFoundUrl(string baseUri, NotFoundEventArgs args)
111+
{
112+
string path = args.Path;
113+
if (string.IsNullOrEmpty(path))
114+
{
115+
var pathFormat = _httpContext.Items[nameof(StatusCodePagesOptions)] as string;
116+
if (string.IsNullOrEmpty(pathFormat))
117+
{
118+
throw new InvalidOperationException("The NotFoundPage route must be specified or re-execution middleware has to be set to render NotFoundPage when the response has started.");
119+
}
120+
121+
path = pathFormat;
122+
}
123+
return $"{baseUri}{path.TrimStart('/')}";
124+
}
125+
103126
private async Task OnNavigateTo(string uri)
104127
{
105128
if (_httpContext.Response.HasStarted)

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

Lines changed: 13 additions & 2 deletions
Original file line numberDiff line numberDiff line change
@@ -225,16 +225,27 @@ private static void HandleExceptionAfterResponseStarted(HttpContext httpContext,
225225
writer.Write("</template><blazor-ssr-end></blazor-ssr-end></blazor-ssr>");
226226
}
227227

228+
private static void HandleNotFoundAfterResponseStarted(TextWriter writer, HttpContext httpContext, string notFoundUrl)
229+
{
230+
writer.Write("<blazor-ssr><template type=\"not-found\"");
231+
WriteResponseTemplate(writer, httpContext, notFoundUrl, useEnhancedNav: true);
232+
}
233+
228234
private static void HandleNavigationAfterResponseStarted(TextWriter writer, HttpContext httpContext, string destinationUrl)
229235
{
230236
writer.Write("<blazor-ssr><template type=\"redirection\"");
237+
bool useEnhancedNav = IsProgressivelyEnhancedNavigation(httpContext.Request);
238+
WriteResponseTemplate(writer, httpContext, destinationUrl, useEnhancedNav);
239+
}
231240

232-
if (string.Equals(httpContext.Request.Method, "POST", StringComparison.OrdinalIgnoreCase))
241+
private static void WriteResponseTemplate(TextWriter writer, HttpContext httpContext, string destinationUrl, bool useEnhancedNav)
242+
{
243+
if (HttpMethods.IsPost(httpContext.Request.Method))
233244
{
234245
writer.Write(" from=\"form-post\"");
235246
}
236247

237-
if (IsProgressivelyEnhancedNavigation(httpContext.Request))
248+
if (useEnhancedNav)
238249
{
239250
writer.Write(" enhanced=\"true\"");
240251
}

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

Lines changed: 6 additions & 4 deletions
Original file line numberDiff line numberDiff line change
@@ -52,6 +52,8 @@ internal partial class EndpointHtmlRenderer : StaticHtmlRenderer, IComponentPrer
5252
// wait for the non-streaming tasks (these ones), then start streaming until full quiescence.
5353
private readonly List<Task> _nonStreamingPendingTasks = new();
5454

55+
private string _notFoundUrl = string.Empty;
56+
5557
public EndpointHtmlRenderer(IServiceProvider serviceProvider, ILoggerFactory loggerFactory)
5658
: base(serviceProvider, loggerFactory)
5759
{
@@ -62,7 +64,7 @@ public EndpointHtmlRenderer(IServiceProvider serviceProvider, ILoggerFactory log
6264

6365
internal HttpContext? HttpContext => _httpContext;
6466

65-
private void SetHttpContext(HttpContext httpContext)
67+
internal void SetHttpContext(HttpContext httpContext)
6668
{
6769
if (_httpContext is null)
6870
{
@@ -83,10 +85,10 @@ internal async Task InitializeStandardComponentServicesAsync(
8385
var navigationManager = httpContext.RequestServices.GetRequiredService<NavigationManager>();
8486
((IHostEnvironmentNavigationManager)navigationManager)?.Initialize(GetContextBaseUri(httpContext.Request), GetFullUri(httpContext.Request), OnNavigateTo);
8587

86-
if (navigationManager != null)
88+
navigationManager?.OnNotFound += (sender, args) =>
8789
{
88-
navigationManager.OnNotFound += async (sender, args) => await SetNotFoundResponseAsync(navigationManager.BaseUri);
89-
}
90+
_ = GetErrorHandledTask(SetNotFoundResponseAsync(navigationManager.BaseUri, args));
91+
};
9092

9193
var authenticationStateProvider = httpContext.RequestServices.GetService<AuthenticationStateProvider>();
9294
if (authenticationStateProvider is IHostEnvironmentAuthenticationStateProvider hostEnvironmentAuthenticationStateProvider)

src/Components/Endpoints/test/EndpointHtmlRendererTest.cs

Lines changed: 29 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -1,9 +1,11 @@
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 System;
45
using System.Text.Encodings.Web;
56
using System.Text.Json;
67
using System.Text.RegularExpressions;
8+
using Microsoft.AspNetCore.Builder;
79
using Microsoft.AspNetCore.Components.Endpoints.Forms;
810
using Microsoft.AspNetCore.Components.Endpoints.Tests.TestComponents;
911
using Microsoft.AspNetCore.Components.Forms;
@@ -12,6 +14,7 @@
1214
using Microsoft.AspNetCore.Components.Reflection;
1315
using Microsoft.AspNetCore.Components.Rendering;
1416
using Microsoft.AspNetCore.Components.RenderTree;
17+
using Microsoft.AspNetCore.Components.Routing;
1518
using Microsoft.AspNetCore.Components.Test.Helpers;
1619
using Microsoft.AspNetCore.Components.Web;
1720
using Microsoft.AspNetCore.DataProtection;
@@ -928,6 +931,26 @@ await renderer.PrerenderComponentAsync(
928931
Assert.Equal("http://localhost/redirect", ctx.Response.Headers.Location);
929932
}
930933

934+
[Fact]
935+
public async Task Renderer_WhenNoNotFoundPathProvided_Throws()
936+
{
937+
// Arrange
938+
var httpContext = GetHttpContext();
939+
var responseMock = new Mock<IHttpResponseFeature>();
940+
responseMock.Setup(r => r.HasStarted).Returns(true);
941+
responseMock.Setup(r => r.Headers).Returns(new HeaderDictionary());
942+
httpContext.Features.Set(responseMock.Object);
943+
var renderer = GetEndpointHtmlRenderer();
944+
httpContext.Items[nameof(StatusCodePagesOptions)] = null; // simulate missing re-execution route
945+
946+
var exception = await Assert.ThrowsAsync<InvalidOperationException>(async () =>
947+
await renderer.SetNotFoundResponseAsync(httpContext, new NotFoundEventArgs(""))
948+
);
949+
string expectedError = "The NotFoundPage route must be specified or re-execution middleware has to be set to render NotFoundPage when the response has started.";
950+
951+
Assert.Equal(expectedError, exception.Message);
952+
}
953+
931954
[Fact]
932955
public async Task CanRender_AsyncComponent()
933956
{
@@ -1796,6 +1819,12 @@ protected override void ProcessPendingRender()
17961819
_rendererIsStopped = true;
17971820
base.SignalRendererToFinishRendering();
17981821
}
1822+
1823+
public async Task SetNotFoundResponseAsync(HttpContext httpContext, NotFoundEventArgs args)
1824+
{
1825+
SetHttpContext(httpContext);
1826+
await SetNotFoundResponseAsync(httpContext.Request.PathBase, args);
1827+
}
17991828
}
18001829

18011830
private HttpContext GetHttpContext(HttpContext context = null)

src/Components/Web.JS/src/Rendering/StreamingRendering.ts

Lines changed: 37 additions & 27 deletions
Original file line numberDiff line numberDiff line change
@@ -45,35 +45,16 @@ class BlazorStreamingUpdate extends HTMLElement {
4545
insertStreamingContentIntoDocument(componentId, node.content);
4646
}
4747
} else {
48+
const isEnhancedNav = node.getAttribute('enhanced') === 'true';
4849
switch (node.getAttribute('type')) {
4950
case 'redirection':
50-
// We use 'replace' here because it's closest to the non-progressively-enhanced behavior, and will make the most sense
51-
// if the async delay was very short, as the user would not perceive having been on the intermediate page.
52-
const destinationUrl = toAbsoluteUri(node.content.textContent!);
53-
const isFormPost = node.getAttribute('from') === 'form-post';
54-
const isEnhancedNav = node.getAttribute('enhanced') === 'true';
55-
if (isEnhancedNav && isWithinBaseUriSpace(destinationUrl)) {
56-
// At this point the destinationUrl might be an opaque URL so we don't know whether it's internal/external or
57-
// whether it's even going to the same URL we're currently on. So we don't know how to update the history.
58-
// Defer that until the redirection is resolved by performEnhancedPageLoad.
59-
const treatAsRedirectionFromMethod = isFormPost ? 'post' : 'get';
60-
const fetchOptions = undefined;
61-
performEnhancedPageLoad(destinationUrl, /* interceptedLink */ false, fetchOptions, treatAsRedirectionFromMethod);
62-
} else {
63-
if (isFormPost) {
64-
// The URL is not yet updated. Push a whole new entry so that 'back' goes back to the pre-redirection location.
65-
// WARNING: The following check to avoid duplicating history entries won't work if the redirection is to an opaque URL.
66-
// We could change the server-side logic to return URLs in plaintext if they match the current request URL already,
67-
// but it's arguably easier to understand that history non-duplication only works for enhanced nav, which is also the
68-
// case for non-streaming responses.
69-
if (destinationUrl !== location.href) {
70-
location.assign(destinationUrl);
71-
}
72-
} else {
73-
// The URL was already updated on the original link click. Replace so that 'back' goes to the pre-redirection location.
74-
location.replace(destinationUrl);
75-
}
76-
}
51+
redirect(node, true, isEnhancedNav);
52+
break;
53+
case 'not-found':
54+
// not-found template has enhanced nav set to true by default,
55+
// check for the options to avoid overriding user settings
56+
const useEnhancedNav = isEnhancedNav && enableDomPreservation;
57+
redirect(node, false, useEnhancedNav);
7758
break;
7859
case 'error':
7960
// This is kind of brutal but matches what happens without progressive enhancement
@@ -86,6 +67,35 @@ class BlazorStreamingUpdate extends HTMLElement {
8667
}
8768
}
8869

70+
function redirect(node: HTMLTemplateElement, changeUrl: boolean, isEnhancedNav: boolean): void {
71+
// We use 'replace' here because it's closest to the non-progressively-enhanced behavior, and will make the most sense
72+
// if the async delay was very short, as the user would not perceive having been on the intermediate page.
73+
const destinationUrl = toAbsoluteUri(node.content.textContent!);
74+
const isFormPost = node.getAttribute('from') === 'form-post';
75+
if (isEnhancedNav && isWithinBaseUriSpace(destinationUrl)) {
76+
// At this point the destinationUrl might be an opaque URL so we don't know whether it's internal/external or
77+
// whether it's even going to the same URL we're currently on. So we don't know how to update the history.
78+
// Defer that until the redirection is resolved by performEnhancedPageLoad.
79+
const treatAsRedirectionFromMethod = isFormPost ? 'post' : 'get';
80+
const fetchOptions = undefined;
81+
performEnhancedPageLoad(destinationUrl, /* interceptedLink */ false, fetchOptions, treatAsRedirectionFromMethod, changeUrl);
82+
} else {
83+
if (isFormPost) {
84+
// The URL is not yet updated. Push a whole new entry so that 'back' goes back to the pre-redirection location.
85+
// WARNING: The following check to avoid duplicating history entries won't work if the redirection is to an opaque URL.
86+
// We could change the server-side logic to return URLs in plaintext if they match the current request URL already,
87+
// but it's arguably easier to understand that history non-duplication only works for enhanced nav, which is also the
88+
// case for non-streaming responses.
89+
if (destinationUrl !== location.href) {
90+
location.assign(destinationUrl);
91+
}
92+
} else {
93+
// The URL was already updated on the original link click. Replace so that 'back' goes to the pre-redirection location.
94+
location.replace(destinationUrl);
95+
}
96+
}
97+
}
98+
8999
function insertStreamingContentIntoDocument(componentIdAsString: string, docFrag: DocumentFragment): void {
90100
const markers = findStreamingMarkers(componentIdAsString);
91101
if (markers) {

0 commit comments

Comments
 (0)