Skip to content

Support NotFound content rendering for a custom Router #62635

New issue

Have a question about this project? Sign up for a free GitHub account to open an issue and contact its maintainers and the community.

By clicking “Sign up for GitHub”, you agree to our terms of service and privacy statement. We’ll occasionally send you account related emails.

Already on GitHub? Sign in to your account

Draft
wants to merge 9 commits into
base: main
Choose a base branch
from
4 changes: 2 additions & 2 deletions src/Components/Components/src/NavigationManager.cs
Original file line number Diff line number Diff line change
Expand Up @@ -61,7 +61,7 @@ public event EventHandler<NotFoundEventArgs> OnNotFound
// The URI. Always represented an absolute URI.
private string? _uri;
private bool _isInitialized;
internal string NotFoundPageRoute { get; set; } = string.Empty;
private readonly NotFoundEventArgs _notFoundEventArgs = new();

/// <summary>
/// Gets or sets the current base URI. The <see cref="BaseUri" /> is always represented as an absolute URI in string form with trailing slash.
Expand Down Expand Up @@ -211,7 +211,7 @@ private void NotFoundCore()
}
else
{
_notFound.Invoke(this, new NotFoundEventArgs(NotFoundPageRoute));
_notFound.Invoke(this, _notFoundEventArgs);
}
}

Expand Down
5 changes: 3 additions & 2 deletions src/Components/Components/src/PublicAPI.Unshipped.txt
Original file line number Diff line number Diff line change
Expand Up @@ -8,8 +8,9 @@ Microsoft.AspNetCore.Components.NavigationManager.OnNotFound -> System.EventHand
Microsoft.AspNetCore.Components.NavigationManager.NotFound() -> void
Microsoft.AspNetCore.Components.Routing.IHostEnvironmentNavigationManager.Initialize(string! baseUri, string! uri, System.Func<string!, System.Threading.Tasks.Task!>! onNavigateTo) -> void
Microsoft.AspNetCore.Components.Routing.NotFoundEventArgs
Microsoft.AspNetCore.Components.Routing.NotFoundEventArgs.NotFoundEventArgs(string! url) -> void
Microsoft.AspNetCore.Components.Routing.NotFoundEventArgs.Path.get -> string!
Microsoft.AspNetCore.Components.Routing.NotFoundEventArgs.NotFoundEventArgs() -> void
Microsoft.AspNetCore.Components.Routing.NotFoundEventArgs.Path.get -> string?
Microsoft.AspNetCore.Components.Routing.NotFoundEventArgs.Path.set -> void
Microsoft.AspNetCore.Components.Infrastructure.ComponentStatePersistenceManager.ComponentStatePersistenceManager(Microsoft.Extensions.Logging.ILogger<Microsoft.AspNetCore.Components.Infrastructure.ComponentStatePersistenceManager!>! logger, System.IServiceProvider! serviceProvider) -> void
Microsoft.AspNetCore.Components.Infrastructure.ComponentStatePersistenceManager.SetPlatformRenderMode(Microsoft.AspNetCore.Components.IComponentRenderMode! renderMode) -> void
Microsoft.AspNetCore.Components.Infrastructure.RegisterPersistentComponentStateServiceCollectionExtensions
Expand Down
13 changes: 2 additions & 11 deletions src/Components/Components/src/Routing/NotFoundEventArgs.cs
Original file line number Diff line number Diff line change
Expand Up @@ -9,16 +9,7 @@ namespace Microsoft.AspNetCore.Components.Routing;
public sealed class NotFoundEventArgs : EventArgs
{
/// <summary>
/// Gets the path of NotFoundPage.
/// Gets the path of NotFoundPage. If the path is set, it indicates that the router has handled the rendering of the NotFound contents.
/// </summary>
public string Path { get; }

/// <summary>
/// Initializes a new instance of <see cref="NotFoundEventArgs" />.
/// </summary>
public NotFoundEventArgs(string url)
{
Path = url;
}

public string? Path { get; set; }
}
9 changes: 6 additions & 3 deletions src/Components/Components/src/Routing/Router.cs
Original file line number Diff line number Diff line change
Expand Up @@ -29,6 +29,7 @@ static readonly IReadOnlyDictionary<string, object> _emptyParametersDictionary
string _locationAbsolute;
bool _navigationInterceptionEnabled;
ILogger<Router> _logger;
string _notFoundPageRoute;

private string _updateScrollPositionForHashLastLocation;
private bool _updateScrollPositionForHash;
Expand Down Expand Up @@ -159,7 +160,7 @@ public async Task SetParametersAsync(ParameterView parameters)
var routeAttribute = (RouteAttribute)routeAttributes[0];
if (routeAttribute.Template != null)
{
NavigationManager.NotFoundPageRoute = routeAttribute.Template;
_notFoundPageRoute = routeAttribute.Template;
}
}

Expand Down Expand Up @@ -381,10 +382,12 @@ private void OnLocationChanged(object sender, LocationChangedEventArgs args)
}
}

private void OnNotFound(object sender, EventArgs args)
private void OnNotFound(object sender, NotFoundEventArgs args)
{
if (_renderHandle.IsInitialized)
if (_renderHandle.IsInitialized && NotFoundPage != null)
{
// setting the path signals to the endpoint renderer that router handled rendering
args.Path = _notFoundPageRoute;
Log.DisplayingNotFound(_logger);
RenderNotFound();
}
Expand Down
22 changes: 21 additions & 1 deletion src/Components/Endpoints/src/RazorComponentEndpointInvoker.cs
Original file line number Diff line number Diff line change
Expand Up @@ -121,7 +121,7 @@ await _renderer.InitializeStandardComponentServicesAsync(
try
{
var isBadRequest = false;
quiesceTask = _renderer.DispatchSubmitEventAsync(result.HandlerName, out isBadRequest);
quiesceTask = _renderer.DispatchSubmitEventAsync(result.HandlerName, out isBadRequest, isReExecuted);
if (isBadRequest)
{
return;
Expand All @@ -136,6 +136,11 @@ await _renderer.InitializeStandardComponentServicesAsync(
}
}

if (_renderer.NotFoundEventArgs != null)
{
_renderer.SetNotFoundWhenResponseNotStarted();
}

if (!quiesceTask.IsCompleted)
{
// An incomplete QuiescenceTask indicates there may be streaming rendering updates.
Expand All @@ -155,6 +160,10 @@ await _renderer.InitializeStandardComponentServicesAsync(
if (!quiesceTask.IsCompletedSuccessfully)
{
await _renderer.SendStreamingUpdatesAsync(context, quiesceTask, bufferWriter);
if (_renderer.NotFoundEventArgs != null)
{
await _renderer.SetNotFoundWhenResponseHasStarted();
}
}
else
{
Expand All @@ -168,6 +177,17 @@ await _renderer.InitializeStandardComponentServicesAsync(
componentStateHtmlContent.WriteTo(bufferWriter, HtmlEncoder.Default);
}

if (context.Response.StatusCode == StatusCodes.Status404NotFound &&
!isReExecuted &&
string.IsNullOrEmpty(_renderer.NotFoundEventArgs?.Path))
{
// Router did not handle the NotFound event, otherwise this would not be empty.
// Don't flush the response if we have an unhandled 404 rendering
// This will allow the StatusCodePages middleware to re-execute the request
context.Response.ContentType = null;
return;
}

// Invoke FlushAsync to ensure any buffered content is asynchronously written to the underlying
// response asynchronously. In the absence of this line, the buffer gets synchronously written to the
// response as part of the Dispose which has a perf impact.
Expand Down
Original file line number Diff line number Diff line change
Expand Up @@ -22,7 +22,7 @@ internal partial class EndpointHtmlRenderer
private readonly Dictionary<(int ComponentId, int FrameIndex), string> _namedSubmitEventsByLocation = new();
private readonly Dictionary<string, HashSet<(int ComponentId, int FrameIndex)>> _namedSubmitEventsByScopeQualifiedName = new(StringComparer.Ordinal);

internal Task DispatchSubmitEventAsync(string? handlerName, out bool isBadRequest)
internal Task DispatchSubmitEventAsync(string? handlerName, out bool isBadRequest, bool isReExecuted = false)
{
if (string.IsNullOrEmpty(handlerName))
{
Expand All @@ -34,6 +34,14 @@ internal Task DispatchSubmitEventAsync(string? handlerName, out bool isBadReques

if (!_namedSubmitEventsByScopeQualifiedName.TryGetValue(handlerName, out var locationsForName) || locationsForName.Count == 0)
{
if (isReExecuted)
{
// If we are re-executing, we do not expect to find a new form on the page we re-execute to.
// This does not mean that the request is bad, we want the re-execution to succeed.
isBadRequest = false;
return Task.CompletedTask;
}

// This may happen if you deploy an app update and someone still on the old page submits a form,
// or if you're dynamically building the UI and the submitted form doesn't exist the next time
// the page is rendered
Expand Down Expand Up @@ -79,37 +87,38 @@ private Task ReturnErrorResponse(string detailedMessage)
: Task.CompletedTask;
}

internal async Task SetNotFoundResponseAsync(string baseUri, NotFoundEventArgs args)
internal void SetNotFoundWhenResponseNotStarted()
{
if (_httpContext.Response.HasStarted ||
// POST waits for quiescence -> rendering the NotFoundPage would be queued for the next batch
// but we want to send the signal to the renderer to stop rendering future batches -> use client rendering
HttpMethods.IsPost(_httpContext.Request.Method))
{
if (string.IsNullOrEmpty(_notFoundUrl))
{
_notFoundUrl = GetNotFoundUrl(baseUri, args);
}
var defaultBufferSize = 16 * 1024;
await using var writer = new HttpResponseStreamWriter(_httpContext.Response.Body, Encoding.UTF8, defaultBufferSize, ArrayPool<byte>.Shared, ArrayPool<char>.Shared);
using var bufferWriter = new BufferedTextWriter(writer);
HandleNotFoundAfterResponseStarted(bufferWriter, _httpContext, _notFoundUrl);
await bufferWriter.FlushAsync();
}
else
_httpContext.Response.StatusCode = StatusCodes.Status404NotFound;

// 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.
SignalRendererToFinishRendering();
}

internal async Task SetNotFoundWhenResponseHasStarted()
{
if (string.IsNullOrEmpty(_notFoundUrl))
{
_httpContext.Response.StatusCode = StatusCodes.Status404NotFound;
var baseUri = $"{_httpContext.Request.Scheme}://{_httpContext.Request.Host}{_httpContext.Request.PathBase}/";
_notFoundUrl = GetNotFoundUrl(baseUri, NotFoundEventArgs);
}
var defaultBufferSize = 16 * 1024;
await using var writer = new HttpResponseStreamWriter(_httpContext.Response.Body, Encoding.UTF8, defaultBufferSize, ArrayPool<byte>.Shared, ArrayPool<char>.Shared);
using var bufferWriter = new BufferedTextWriter(writer);
HandleNotFoundAfterResponseStarted(bufferWriter, _httpContext, _notFoundUrl);
await bufferWriter.FlushAsync();

// 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.
SignalRendererToFinishRendering();
}

private string GetNotFoundUrl(string baseUri, NotFoundEventArgs args)
private string GetNotFoundUrl(string baseUri, NotFoundEventArgs? args)
{
string path = args.Path;
string? path = args?.Path;
if (string.IsNullOrEmpty(path))
{
var pathFormat = _httpContext.Items[nameof(StatusCodePagesOptions)] as string;
Expand Down
Original file line number Diff line number Diff line change
Expand Up @@ -63,6 +63,7 @@ public EndpointHtmlRenderer(IServiceProvider serviceProvider, ILoggerFactory log
}

internal HttpContext? HttpContext => _httpContext;
internal NotFoundEventArgs? NotFoundEventArgs { get; private set; }

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

navigationManager?.OnNotFound += (sender, args) =>
{
_ = GetErrorHandledTask(SetNotFoundResponseAsync(navigationManager.BaseUri, args));
};
navigationManager?.OnNotFound += (sender, args) => NotFoundEventArgs = args;

var authenticationStateProvider = httpContext.RequestServices.GetService<AuthenticationStateProvider>();
if (authenticationStateProvider is IHostEnvironmentAuthenticationStateProvider hostEnvironmentAuthenticationStateProvider)
Expand Down
32 changes: 30 additions & 2 deletions src/Components/Endpoints/test/EndpointHtmlRendererTest.cs
Original file line number Diff line number Diff line change
Expand Up @@ -944,7 +944,7 @@ public async Task Renderer_WhenNoNotFoundPathProvided_Throws()
httpContext.Items[nameof(StatusCodePagesOptions)] = null; // simulate missing re-execution route

var exception = await Assert.ThrowsAsync<InvalidOperationException>(async () =>
await renderer.SetNotFoundResponseAsync(httpContext, new NotFoundEventArgs(""))
await renderer.SetNotFoundResponseAsync(httpContext, new NotFoundEventArgs())
);
string expectedError = "The NotFoundPage route must be specified or re-execution middleware has to be set to render NotFoundPage when the response has started.";

Expand Down Expand Up @@ -1076,6 +1076,34 @@ await renderer.Dispatcher.InvokeAsync(async () =>
await new StreamReader(bodyStream).ReadToEndAsync());
}

[Fact]
public async Task Dispatching_OnReExecution_WhenNamedEventDoesNotExists_Passes()
{
// Arrange
var renderer = GetEndpointHtmlRenderer();
var isBadRequest = false;
var httpContext = new DefaultHttpContext();
var bodyStream = new MemoryStream();
httpContext.Response.Body = bodyStream;
httpContext.RequestServices = new ServiceCollection()
.AddSingleton<IHostEnvironment>(new TestEnvironment(Environments.Development))
.BuildServiceProvider();

await renderer.Dispatcher.InvokeAsync(async () =>
{
await renderer.RenderEndpointComponent(httpContext, typeof(NamedEventHandlerComponent), ParameterView.Empty, true);

// Act
await renderer.DispatchSubmitEventAsync("other", out isBadRequest, isReExecuted: true);
});

httpContext.Response.Body.Position = 0;

Assert.False(isBadRequest);
Assert.Equal(200, httpContext.Response.StatusCode);
Assert.Empty(await new StreamReader(bodyStream).ReadToEndAsync());
}

[Fact]
public async Task Dispatching_WhenComponentHasRerendered_UsesCurrentDelegate()
{
Expand Down Expand Up @@ -1823,7 +1851,7 @@ protected override void ProcessPendingRender()
public async Task SetNotFoundResponseAsync(HttpContext httpContext, NotFoundEventArgs args)
{
SetHttpContext(httpContext);
await SetNotFoundResponseAsync(httpContext.Request.PathBase, args);
await SetNotFoundWhenResponseHasStarted();
}
}

Expand Down
Loading
Loading