Skip to content

Commit 14292cb

Browse files
authored
Support NotFound content rendering for a custom Router (#62635)
* SSR streaming not started with custom router. * NotFound fragment cannot be conditionally added inside of Router and is not nullable. Use if-else instead. * Feedback: do not dispatch re-executed POST requests.
1 parent 0b2da29 commit 14292cb

18 files changed

+452
-92
lines changed

src/Components/Components/src/NavigationManager.cs

Lines changed: 1 addition & 2 deletions
Original file line numberDiff line numberDiff line change
@@ -61,7 +61,6 @@ public event EventHandler<NotFoundEventArgs> OnNotFound
6161
// The URI. Always represented an absolute URI.
6262
private string? _uri;
6363
private bool _isInitialized;
64-
internal string NotFoundPageRoute { get; set; } = string.Empty;
6564

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

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

Lines changed: 3 additions & 2 deletions
Original file line numberDiff line numberDiff line change
@@ -8,8 +8,9 @@ Microsoft.AspNetCore.Components.NavigationManager.OnNotFound -> System.EventHand
88
Microsoft.AspNetCore.Components.NavigationManager.NotFound() -> void
99
Microsoft.AspNetCore.Components.Routing.IHostEnvironmentNavigationManager.Initialize(string! baseUri, string! uri, System.Func<string!, System.Threading.Tasks.Task!>! onNavigateTo) -> void
1010
Microsoft.AspNetCore.Components.Routing.NotFoundEventArgs
11-
Microsoft.AspNetCore.Components.Routing.NotFoundEventArgs.NotFoundEventArgs(string! url) -> void
12-
Microsoft.AspNetCore.Components.Routing.NotFoundEventArgs.Path.get -> string!
11+
Microsoft.AspNetCore.Components.Routing.NotFoundEventArgs.NotFoundEventArgs() -> void
12+
Microsoft.AspNetCore.Components.Routing.NotFoundEventArgs.Path.get -> string?
13+
Microsoft.AspNetCore.Components.Routing.NotFoundEventArgs.Path.set -> void
1314
Microsoft.AspNetCore.Components.Infrastructure.ComponentStatePersistenceManager.ComponentStatePersistenceManager(Microsoft.Extensions.Logging.ILogger<Microsoft.AspNetCore.Components.Infrastructure.ComponentStatePersistenceManager!>! logger, System.IServiceProvider! serviceProvider) -> void
1415
Microsoft.AspNetCore.Components.Infrastructure.ComponentStatePersistenceManager.SetPlatformRenderMode(Microsoft.AspNetCore.Components.IComponentRenderMode! renderMode) -> void
1516
Microsoft.AspNetCore.Components.Infrastructure.RegisterPersistentComponentStateServiceCollectionExtensions

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

Lines changed: 2 additions & 11 deletions
Original file line numberDiff line numberDiff line change
@@ -9,16 +9,7 @@ namespace Microsoft.AspNetCore.Components.Routing;
99
public sealed class NotFoundEventArgs : EventArgs
1010
{
1111
/// <summary>
12-
/// Gets the path of NotFoundPage.
12+
/// Gets the path of NotFoundPage. If the path is set, it indicates that a subscriber has handled the rendering of the NotFound contents.
1313
/// </summary>
14-
public string Path { get; }
15-
16-
/// <summary>
17-
/// Initializes a new instance of <see cref="NotFoundEventArgs" />.
18-
/// </summary>
19-
public NotFoundEventArgs(string url)
20-
{
21-
Path = url;
22-
}
23-
14+
public string? Path { get; set; }
2415
}

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

Lines changed: 6 additions & 3 deletions
Original file line numberDiff line numberDiff line change
@@ -29,6 +29,7 @@ static readonly IReadOnlyDictionary<string, object> _emptyParametersDictionary
2929
string _locationAbsolute;
3030
bool _navigationInterceptionEnabled;
3131
ILogger<Router> _logger;
32+
string _notFoundPageRoute;
3233

3334
private string _updateScrollPositionForHashLastLocation;
3435
private bool _updateScrollPositionForHash;
@@ -166,7 +167,7 @@ public async Task SetParametersAsync(ParameterView parameters)
166167
var routeAttribute = (RouteAttribute)routeAttributes[0];
167168
if (routeAttribute.Template != null)
168169
{
169-
NavigationManager.NotFoundPageRoute = routeAttribute.Template;
170+
_notFoundPageRoute = routeAttribute.Template;
170171
}
171172
}
172173

@@ -388,10 +389,12 @@ private void OnLocationChanged(object sender, LocationChangedEventArgs args)
388389
}
389390
}
390391

391-
private void OnNotFound(object sender, EventArgs args)
392+
private void OnNotFound(object sender, NotFoundEventArgs args)
392393
{
393-
if (_renderHandle.IsInitialized)
394+
if (_renderHandle.IsInitialized && NotFoundPage != null)
394395
{
396+
// setting the path signals to the endpoint renderer that router handled rendering
397+
args.Path = _notFoundPageRoute;
395398
Log.DisplayingNotFound(_logger);
396399
RenderNotFound();
397400
}

src/Components/Endpoints/src/RazorComponentEndpointInvoker.cs

Lines changed: 21 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -106,7 +106,7 @@ await _renderer.InitializeStandardComponentServicesAsync(
106106
waitForQuiescence: result.IsPost || isErrorHandlerOrReExecuted);
107107

108108
Task quiesceTask;
109-
if (!result.IsPost)
109+
if (!result.IsPost || isReExecuted)
110110
{
111111
quiesceTask = htmlContent.QuiescenceTask;
112112
}
@@ -130,6 +130,11 @@ await _renderer.InitializeStandardComponentServicesAsync(
130130
}
131131
}
132132

133+
if (_renderer.NotFoundEventArgs != null)
134+
{
135+
_renderer.SetNotFoundWhenResponseNotStarted();
136+
}
137+
133138
if (!quiesceTask.IsCompleted)
134139
{
135140
// An incomplete QuiescenceTask indicates there may be streaming rendering updates.
@@ -158,6 +163,10 @@ await _renderer.InitializeStandardComponentServicesAsync(
158163
if (!quiesceTask.IsCompletedSuccessfully)
159164
{
160165
await _renderer.SendStreamingUpdatesAsync(context, quiesceTask, bufferWriter);
166+
if (_renderer.NotFoundEventArgs != null)
167+
{
168+
await _renderer.SetNotFoundWhenResponseHasStarted();
169+
}
161170
}
162171
else
163172
{
@@ -171,6 +180,17 @@ await _renderer.InitializeStandardComponentServicesAsync(
171180
componentStateHtmlContent.WriteTo(bufferWriter, HtmlEncoder.Default);
172181
}
173182

183+
if (context.Response.StatusCode == StatusCodes.Status404NotFound &&
184+
!isReExecuted &&
185+
string.IsNullOrEmpty(_renderer.NotFoundEventArgs?.Path))
186+
{
187+
// Router did not handle the NotFound event, otherwise this would not be empty.
188+
// Don't flush the response if we have an unhandled 404 rendering
189+
// This will allow the StatusCodePages middleware to re-execute the request
190+
context.Response.ContentType = null;
191+
return;
192+
}
193+
174194
// Invoke FlushAsync to ensure any buffered content is asynchronously written to the underlying
175195
// response asynchronously. In the absence of this line, the buffer gets synchronously written to the
176196
// response as part of the Dispose which has a perf impact.

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

Lines changed: 22 additions & 21 deletions
Original file line numberDiff line numberDiff line change
@@ -79,43 +79,44 @@ private Task ReturnErrorResponse(string detailedMessage)
7979
: Task.CompletedTask;
8080
}
8181

82-
internal async Task SetNotFoundResponseAsync(string baseUri, NotFoundEventArgs args)
82+
internal void SetNotFoundWhenResponseNotStarted()
8383
{
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))
88-
{
89-
if (string.IsNullOrEmpty(_notFoundUrl))
90-
{
91-
_notFoundUrl = GetNotFoundUrl(baseUri, args);
92-
}
93-
var defaultBufferSize = 16 * 1024;
94-
await using var writer = new HttpResponseStreamWriter(_httpContext.Response.Body, Encoding.UTF8, defaultBufferSize, ArrayPool<byte>.Shared, ArrayPool<char>.Shared);
95-
using var bufferWriter = new BufferedTextWriter(writer);
96-
HandleNotFoundAfterResponseStarted(bufferWriter, _httpContext, _notFoundUrl);
97-
await bufferWriter.FlushAsync();
98-
}
99-
else
84+
_httpContext.Response.StatusCode = StatusCodes.Status404NotFound;
85+
86+
// When the application triggers a NotFound event, we continue rendering the current batch.
87+
// However, after completing this batch, we do not want to process any further UI updates,
88+
// as we are going to return a 404 status and discard the UI updates generated so far.
89+
SignalRendererToFinishRendering();
90+
}
91+
92+
internal async Task SetNotFoundWhenResponseHasStarted()
93+
{
94+
if (string.IsNullOrEmpty(_notFoundUrl))
10095
{
101-
_httpContext.Response.StatusCode = StatusCodes.Status404NotFound;
96+
var baseUri = $"{_httpContext.Request.Scheme}://{_httpContext.Request.Host}{_httpContext.Request.PathBase}/";
97+
_notFoundUrl = GetNotFoundUrl(baseUri, NotFoundEventArgs);
10298
}
99+
var defaultBufferSize = 16 * 1024;
100+
await using var writer = new HttpResponseStreamWriter(_httpContext.Response.Body, Encoding.UTF8, defaultBufferSize, ArrayPool<byte>.Shared, ArrayPool<char>.Shared);
101+
using var bufferWriter = new BufferedTextWriter(writer);
102+
HandleNotFoundAfterResponseStarted(bufferWriter, _httpContext, _notFoundUrl);
103+
await bufferWriter.FlushAsync();
103104

104105
// When the application triggers a NotFound event, we continue rendering the current batch.
105106
// However, after completing this batch, we do not want to process any further UI updates,
106107
// as we are going to return a 404 status and discard the UI updates generated so far.
107108
SignalRendererToFinishRendering();
108109
}
109110

110-
private string GetNotFoundUrl(string baseUri, NotFoundEventArgs args)
111+
private string GetNotFoundUrl(string baseUri, NotFoundEventArgs? args)
111112
{
112-
string path = args.Path;
113+
string? path = args?.Path;
113114
if (string.IsNullOrEmpty(path))
114115
{
115116
var pathFormat = _httpContext.Items[nameof(StatusCodePagesOptions)] as string;
116117
if (string.IsNullOrEmpty(pathFormat))
117118
{
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+
throw new InvalidOperationException($"The {nameof(Router.NotFoundPage)} route must be specified or re-execution middleware has to be set to render not found content.");
119120
}
120121

121122
path = pathFormat;

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

Lines changed: 2 additions & 4 deletions
Original file line numberDiff line numberDiff line change
@@ -63,6 +63,7 @@ public EndpointHtmlRenderer(IServiceProvider serviceProvider, ILoggerFactory log
6363
}
6464

6565
internal HttpContext? HttpContext => _httpContext;
66+
internal NotFoundEventArgs? NotFoundEventArgs { get; private set; }
6667

6768
internal void SetHttpContext(HttpContext httpContext)
6869
{
@@ -85,10 +86,7 @@ internal async Task InitializeStandardComponentServicesAsync(
8586
var navigationManager = httpContext.RequestServices.GetRequiredService<NavigationManager>();
8687
((IHostEnvironmentNavigationManager)navigationManager)?.Initialize(GetContextBaseUri(httpContext.Request), GetFullUri(httpContext.Request), OnNavigateTo);
8788

88-
navigationManager?.OnNotFound += (sender, args) =>
89-
{
90-
_ = GetErrorHandledTask(SetNotFoundResponseAsync(navigationManager.BaseUri, args));
91-
};
89+
navigationManager?.OnNotFound += (sender, args) => NotFoundEventArgs = args;
9290

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

src/Components/Endpoints/test/EndpointHtmlRendererTest.cs

Lines changed: 3 additions & 3 deletions
Original file line numberDiff line numberDiff line change
@@ -944,9 +944,9 @@ public async Task Renderer_WhenNoNotFoundPathProvided_Throws()
944944
httpContext.Items[nameof(StatusCodePagesOptions)] = null; // simulate missing re-execution route
945945

946946
var exception = await Assert.ThrowsAsync<InvalidOperationException>(async () =>
947-
await renderer.SetNotFoundResponseAsync(httpContext, new NotFoundEventArgs(""))
947+
await renderer.SetNotFoundResponseAsync(httpContext, new NotFoundEventArgs())
948948
);
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.";
949+
string expectedError = $"The {nameof(Router.NotFoundPage)} route must be specified or re-execution middleware has to be set to render not found content.";
950950

951951
Assert.Equal(expectedError, exception.Message);
952952
}
@@ -1823,7 +1823,7 @@ protected override void ProcessPendingRender()
18231823
public async Task SetNotFoundResponseAsync(HttpContext httpContext, NotFoundEventArgs args)
18241824
{
18251825
SetHttpContext(httpContext);
1826-
await SetNotFoundResponseAsync(httpContext.Request.PathBase, args);
1826+
await SetNotFoundWhenResponseHasStarted();
18271827
}
18281828
}
18291829

0 commit comments

Comments
 (0)