Skip to content
Merged
Show file tree
Hide file tree
Changes from 9 commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
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