Skip to content
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

Add NotFoundPage to Router #60970

Open
wants to merge 34 commits into
base: main
Choose a base branch
from
Open
Show file tree
Hide file tree
Changes from all commits
Commits
Show all changes
34 commits
Select commit Hold shift + click to select a range
11956ec
Add `NotFoundPage`.
ilonatommy Mar 18, 2025
216ec31
Fix rebase error.
ilonatommy Mar 19, 2025
3168993
Draft of template changes.
ilonatommy Mar 21, 2025
067cfd2
Typo errors.
ilonatommy Mar 21, 2025
6d22aad
NavigationManager is not needed for SSR.
ilonatommy Mar 21, 2025
6ea7082
Add BOM to new teamplate files.
ilonatommy Mar 24, 2025
25e8e66
Move instead of exclude.
ilonatommy Mar 24, 2025
1060b4d
Clean up, fix tests.
ilonatommy Mar 24, 2025
fc696c3
Fix
ilonatommy Mar 24, 2025
44e7d8e
Apply smallest possible changes to templates.
ilonatommy Mar 25, 2025
ae68011
Missing changes to baseline.
ilonatommy Mar 25, 2025
39deb2b
Prevent throwing.
ilonatommy Mar 25, 2025
6b620d5
Fix configurations without global router.
ilonatommy Mar 25, 2025
6876252
Merge branch 'main' into fix-58815
ilonatommy Mar 25, 2025
74e3eae
Fix "response started" scenarios.
ilonatommy Mar 26, 2025
e10b90c
Fix template tests.
ilonatommy Mar 26, 2025
b660d19
Fix baseline tests.
ilonatommy Mar 26, 2025
d566c4b
This is a draft of uneffective `UseStatusCodePagesWithReExecute`, cc …
ilonatommy Mar 26, 2025
d41bd5b
Update.
ilonatommy Mar 27, 2025
005f217
Fix reexecution mechanism.
ilonatommy Mar 31, 2025
8c7b6d2
Fix public API.
ilonatommy Mar 31, 2025
f876e4d
Args order.
ilonatommy Mar 31, 2025
8744e8b
Draft of test.
ilonatommy Mar 31, 2025
aee53a9
Per page interactivity test.
ilonatommy Apr 1, 2025
de91b4b
Revert unnecessary change.
ilonatommy Apr 1, 2025
7e7f1fe
Typo: we want to stop only if status pages are on.
ilonatommy Apr 2, 2025
6a10062
Remove comments.
ilonatommy Apr 2, 2025
5518812
Fix tests.
ilonatommy Apr 2, 2025
c8eb629
Feedback.
ilonatommy Apr 7, 2025
66b563c
Feedback.
ilonatommy Apr 7, 2025
1da92f9
Failing test - re-executed without a reason.
ilonatommy Apr 7, 2025
b7775ef
Add streaming test after response started.
ilonatommy Apr 8, 2025
cb32f94
Test SSR with no interactivity.
ilonatommy Apr 8, 2025
8273758
Stop the renderer regardless of `Response.HasStarted`.
ilonatommy Apr 8, 2025
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
12 changes: 11 additions & 1 deletion src/Components/Components/src/NavigationManager.cs
Original file line number Diff line number Diff line change
Expand Up @@ -54,6 +54,8 @@ public event EventHandler<NotFoundEventArgs> OnNotFound

private EventHandler<NotFoundEventArgs>? _notFound;

private static readonly NotFoundEventArgs _notFoundEventArgs = new NotFoundEventArgs();

// For the baseUri it's worth storing as a System.Uri so we can do operations
// on that type. System.Uri gives us access to the original string anyway.
private Uri? _baseUri;
Expand Down Expand Up @@ -203,7 +205,15 @@ public virtual void Refresh(bool forceReload = false)

private void NotFoundCore()
{
_notFound?.Invoke(this, new NotFoundEventArgs());
if (_notFound == null)
{
// global router doesn't exist, no events were registered
NavigateTo($"{BaseUri}not-found");
}
else
{
_notFound.Invoke(this, _notFoundEventArgs);
}
}

/// <summary>
Expand Down
3 changes: 3 additions & 0 deletions src/Components/Components/src/PublicAPI.Unshipped.txt
Original file line number Diff line number Diff line change
@@ -1,4 +1,7 @@
#nullable enable

Microsoft.AspNetCore.Components.Routing.Router.NotFoundPage.get -> System.Type!
Microsoft.AspNetCore.Components.Routing.Router.NotFoundPage.set -> void
Microsoft.AspNetCore.Components.NavigationManager.OnNotFound -> System.EventHandler<Microsoft.AspNetCore.Components.Routing.NotFoundEventArgs!>!
Microsoft.AspNetCore.Components.NavigationManager.NotFound() -> void
Microsoft.AspNetCore.Components.Routing.NotFoundEventArgs
Expand Down
42 changes: 41 additions & 1 deletion src/Components/Components/src/Routing/Router.cs
Original file line number Diff line number Diff line change
Expand Up @@ -3,11 +3,13 @@

#nullable disable warnings

using System.Diagnostics.CodeAnalysis;
using System.Reflection;
using System.Reflection.Metadata;
using System.Runtime.ExceptionServices;
using Microsoft.AspNetCore.Components.HotReload;
using Microsoft.AspNetCore.Components.Rendering;
using Microsoft.AspNetCore.Internal;
using Microsoft.Extensions.Logging;
using Microsoft.Extensions.DependencyInjection;

Expand Down Expand Up @@ -70,6 +72,13 @@ static readonly IReadOnlyDictionary<string, object> _emptyParametersDictionary
[Parameter]
public RenderFragment NotFound { get; set; }

/// <summary>
/// Gets or sets the page content to display when no match is found for the requested route.
/// </summary>
[Parameter]
[DynamicallyAccessedMembers(LinkerFlags.Component)]
public Type NotFoundPage { get; set; } = default!;

/// <summary>
/// Gets or sets the content to display when a match is found for the requested route.
/// </summary>
Expand Down Expand Up @@ -132,6 +141,22 @@ public async Task SetParametersAsync(ParameterView parameters)
throw new InvalidOperationException($"The {nameof(Router)} component requires a value for the parameter {nameof(Found)}.");
}

if (NotFoundPage != null)
{
if (!typeof(IComponent).IsAssignableFrom(NotFoundPage))
{
throw new InvalidOperationException($"The type {NotFoundPage.FullName} " +
$"does not implement {typeof(IComponent).FullName}.");
}

var routeAttributes = NotFoundPage.GetCustomAttributes(typeof(RouteAttribute), inherit: true);
if (routeAttributes.Length == 0)
{
throw new InvalidOperationException($"The type {NotFoundPage.FullName} " +
$"does not have a {typeof(RouteAttribute).FullName} applied to it.");
}
}

if (!_onNavigateCalled)
{
_onNavigateCalled = true;
Expand Down Expand Up @@ -327,7 +352,22 @@ private void OnNotFound(object sender, EventArgs args)
if (_renderHandle.IsInitialized)
{
Log.DisplayingNotFound(_logger);
_renderHandle.Render(NotFound ?? DefaultNotFoundContent);
_renderHandle.Render(builder =>
{
if (NotFoundPage != null)
{
builder.OpenComponent(0, NotFoundPage);
builder.CloseComponent();
}
else if (NotFound != null)
{
NotFound(builder);
}
else
{
DefaultNotFoundContent(builder);
}
});
}
}

Expand Down
30 changes: 24 additions & 6 deletions src/Components/Endpoints/src/RazorComponentEndpointInvoker.cs
Original file line number Diff line number Diff line change
Expand Up @@ -39,12 +39,17 @@ private async Task RenderComponentCore(HttpContext context)
{
context.Response.ContentType = RazorComponentResultExecutor.DefaultContentType;
var isErrorHandler = context.Features.Get<IExceptionHandlerFeature>() is not null;
var hasStatusCodePage = context.Features.Get<IStatusCodePagesFeature>() is not null;
if (isErrorHandler)
{
Log.InteractivityDisabledForErrorHandling(_logger);
}
_renderer.InitializeStreamingRenderingFraming(context, isErrorHandler);
EndpointHtmlRenderer.MarkAsAllowingEnhancedNavigation(context);
_renderer.InitializeStreamingRenderingFraming(context, isErrorHandler, hasStatusCodePage);
bool avoidEditingHeaders = hasStatusCodePage && context.Response.StatusCode == StatusCodes.Status404NotFound;
if (!avoidEditingHeaders)
{
EndpointHtmlRenderer.MarkAsAllowingEnhancedNavigation(context);
}

var endpoint = context.GetEndpoint() ?? throw new InvalidOperationException($"An endpoint must be set on the '{nameof(HttpContext)}'.");

Expand Down Expand Up @@ -85,14 +90,26 @@ await _renderer.InitializeStandardComponentServicesAsync(
await using var writer = new HttpResponseStreamWriter(context.Response.Body, Encoding.UTF8, defaultBufferSize, ArrayPool<byte>.Shared, ArrayPool<char>.Shared);
using var bufferWriter = new BufferedTextWriter(writer);

int originalStatusCode = context.Response.StatusCode;
bool isErrorHandlerOrHasStatusCodePage = isErrorHandler || hasStatusCodePage;

// Note that we always use Static rendering mode for the top-level output from a RazorComponentResult,
// because you never want to serialize the invocation of RazorComponentResultHost. Instead, that host
// component takes care of switching into your desired render mode when it produces its own output.
var htmlContent = await _renderer.RenderEndpointComponent(
context,
rootComponent,
ParameterView.Empty,
waitForQuiescence: result.IsPost || isErrorHandler);
waitForQuiescence: result.IsPost || isErrorHandlerOrHasStatusCodePage);

bool requiresReexecution = originalStatusCode != context.Response.StatusCode && hasStatusCodePage;
if (requiresReexecution)
{
// If the response is a 404, we don't want to write any content.
// This is because the 404 status code is used by the routing middleware
// to indicate that no endpoint was found for the request.
return;
}

Task quiesceTask;
if (!result.IsPost)
Expand Down Expand Up @@ -145,7 +162,7 @@ await _renderer.InitializeStandardComponentServicesAsync(
}

// Emit comment containing state.
if (!isErrorHandler)
if (!isErrorHandlerOrHasStatusCodePage)
{
var componentStateHtmlContent = await _renderer.PrerenderPersistedStateAsync(context);
componentStateHtmlContent.WriteTo(bufferWriter, HtmlEncoder.Default);
Expand All @@ -160,10 +177,11 @@ await _renderer.InitializeStandardComponentServicesAsync(
private async Task<RequestValidationState> ValidateRequestAsync(HttpContext context, IAntiforgery? antiforgery)
{
var processPost = HttpMethods.IsPost(context.Request.Method) &&
// Disable POST functionality during exception handling.
// Disable POST functionality during exception handling and reexecution.
// The exception handler middleware will not update the request method, and we don't
// want to run the form handling logic against the error page.
context.Features.Get<IExceptionHandlerFeature>() == null;
context.Features.Get<IExceptionHandlerFeature>() == null &&
context.Features.Get<IStatusCodePagesFeature>() == null;

if (processPost)
{
Expand Down
Original file line number Diff line number Diff line change
Expand Up @@ -78,9 +78,16 @@ private void SetNotFoundResponse(object? sender, EventArgs args)
{
if (_httpContext.Response.HasStarted)
{
throw new InvalidOperationException("Cannot set a NotFound response after the response has already started.");
// We cannot set a NotFound code after the response has already started
var navigationManager = _httpContext.RequestServices.GetRequiredService<NavigationManager>();
var notFoundUri = $"{navigationManager.BaseUri}not-found";
navigationManager.NavigateTo(notFoundUri);
}
else
{
_httpContext.Response.StatusCode = StatusCodes.Status404NotFound;
_httpContext.Response.ContentType = null;
}
_httpContext.Response.StatusCode = StatusCodes.Status404NotFound;
SignalRendererToFinishRendering();
}

Expand Down
Original file line number Diff line number Diff line change
Expand Up @@ -8,17 +8,19 @@
using Microsoft.AspNetCore.Components.Web.HtmlRendering;
using Microsoft.AspNetCore.Html;
using Microsoft.AspNetCore.Http;
using Microsoft.Extensions.Logging;
using static Microsoft.AspNetCore.Internal.LinkerFlags;

namespace Microsoft.AspNetCore.Components.Endpoints;

internal partial class EndpointHtmlRenderer
{
private static readonly object ComponentSequenceKey = new object();
private bool _isHandlingNotFound;

protected override IComponent ResolveComponentForRenderMode([DynamicallyAccessedMembers(Component)] Type componentType, int? parentComponentId, IComponentActivator componentActivator, IComponentRenderMode renderMode)
{
if (_isHandlingErrors)
if (_isHandlingErrors || _hasStatusCodePage)
{
// Ignore the render mode boundary in error scenarios.
return componentActivator.CreateInstance(componentType);
Expand Down Expand Up @@ -146,6 +148,7 @@ internal async ValueTask<PrerenderedComponentHtmlContent> RenderEndpointComponen
{
var component = BeginRenderingComponent(rootComponentType, parameters);
var result = new PrerenderedComponentHtmlContent(Dispatcher, component);
_isHandlingNotFound = httpContext.Response.StatusCode == StatusCodes.Status404NotFound && _hasStatusCodePage;

await WaitForResultReady(waitForQuiescence, result);

Expand All @@ -166,7 +169,50 @@ private async Task WaitForResultReady(bool waitForQuiescence, PrerenderedCompone
}
else if (_nonStreamingPendingTasks.Count > 0)
{
await WaitForNonStreamingPendingTasks();
if (_isHandlingNotFound)
{
HandleNonStreamingTasks();
}
else
{
await WaitForNonStreamingPendingTasks();
}
}
}

public void HandleNonStreamingTasks()
{
if (NonStreamingPendingTasksCompletion == null)
{
foreach (var task in _nonStreamingPendingTasks)
{
_ = GetErrorHandledTask(task);
}

// Clear the pending tasks since we are handling them
_nonStreamingPendingTasks.Clear();

NonStreamingPendingTasksCompletion = Task.CompletedTask;
}
}

private async Task GetErrorHandledTask(Task taskToHandle)
{
try
{
await taskToHandle;
}
catch (Exception ex)
{
// Ignore errors due to task cancellations.
if (!taskToHandle.IsCanceled)
{
_logger.LogError(
ex,
"An exception occurred during non-streaming rendering. " +
"This exception will be ignored because the response " +
"is being discarded and the request is being re-executed.");
}
}
}

Expand Down
Original file line number Diff line number Diff line change
Expand Up @@ -22,11 +22,14 @@ internal partial class EndpointHtmlRenderer
private HashSet<int>? _visitedComponentIdsInCurrentStreamingBatch;
private string? _ssrFramingCommentMarkup;
private bool _isHandlingErrors;
private bool _hasStatusCodePage;

public void InitializeStreamingRenderingFraming(HttpContext httpContext, bool isErrorHandler)
public void InitializeStreamingRenderingFraming(HttpContext httpContext, bool isErrorHandler, bool hasStatusCodePage)
{
_isHandlingErrors = isErrorHandler;
if (IsProgressivelyEnhancedNavigation(httpContext.Request))
_hasStatusCodePage = hasStatusCodePage;
bool avoidEditingHeaders = hasStatusCodePage && httpContext.Response.StatusCode == StatusCodes.Status404NotFound;
Copy link
Member

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Is this effectively disabling streaming renders in 404 responses? Is there a reason for us to do it?

Copy link
Member Author

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

If we start writing the response, we are effectively blocking the middleware's re-execution, a similar case to
#60970 (comment)
but with context.Response.HasStarted.

if (!avoidEditingHeaders && IsProgressivelyEnhancedNavigation(httpContext.Request))
{
var id = Guid.NewGuid().ToString();
httpContext.Response.Headers.Add(_streamingRenderingFramingHeaderName, id);
Expand Down
Original file line number Diff line number Diff line change
Expand Up @@ -44,6 +44,7 @@ internal partial class EndpointHtmlRenderer : StaticHtmlRenderer, IComponentPrer
private HttpContext _httpContext = default!; // Always set at the start of an inbound call
private ResourceAssetCollection? _resourceCollection;
private bool _rendererIsStopped;
private readonly ILogger _logger;

// The underlying Renderer always tracks the pending tasks representing *full* quiescence, i.e.,
// when everything (regardless of streaming SSR) is fully complete. In this subclass we also track
Expand All @@ -56,6 +57,7 @@ public EndpointHtmlRenderer(IServiceProvider serviceProvider, ILoggerFactory log
{
_services = serviceProvider;
_options = serviceProvider.GetRequiredService<IOptions<RazorComponentsServiceOptions>>().Value;
_logger = loggerFactory.CreateLogger("Microsoft.AspNetCore.Components.RenderTree.Renderer");
}

internal HttpContext? HttpContext => _httpContext;
Expand Down Expand Up @@ -163,6 +165,11 @@ protected override ComponentState CreateComponentState(int componentId, ICompone

protected override void AddPendingTask(ComponentState? componentState, Task task)
{
if (_isHandlingNotFound)
{
return;
}

var streamRendering = componentState is null
? false
: ((EndpointComponentState)componentState).StreamRendering;
Expand Down
Original file line number Diff line number Diff line change
Expand Up @@ -48,7 +48,8 @@ private static Task RenderComponentToResponse(
return endpointHtmlRenderer.Dispatcher.InvokeAsync(async () =>
{
var isErrorHandler = httpContext.Features.Get<IExceptionHandlerFeature>() is not null;
endpointHtmlRenderer.InitializeStreamingRenderingFraming(httpContext, isErrorHandler);
var hasStatusCodePage = httpContext.Features.Get<IStatusCodePagesFeature>() is not null;
endpointHtmlRenderer.InitializeStreamingRenderingFraming(httpContext, isErrorHandler, hasStatusCodePage);
EndpointHtmlRenderer.MarkAsAllowingEnhancedNavigation(httpContext);

// We could pool these dictionary instances if we wanted, and possibly even the ParameterView
Expand Down
Original file line number Diff line number Diff line change
Expand Up @@ -3,6 +3,7 @@

using System.Net.Http;
using Components.TestServer.RazorComponents;
using Microsoft.AspNetCore.Components.E2ETest;
using Microsoft.AspNetCore.Components.E2ETest.Infrastructure;
using Microsoft.AspNetCore.Components.E2ETest.Infrastructure.ServerFixtures;
using Microsoft.AspNetCore.E2ETesting;
Expand Down Expand Up @@ -64,4 +65,32 @@ public void CanUseServerAuthenticationStateByDefault()
Browser.Equal("True", () => Browser.FindElement(By.Id("is-in-test-role-1")).Text);
Browser.Equal("True", () => Browser.FindElement(By.Id("is-in-test-role-2")).Text);
}

[Fact]
public void CanRenderNotFoundPageAfterStreamingStarted()
{
Navigate($"{ServerPathBase}/streaming-set-not-found");
Browser.WaitForElementToBeVisible(By.Id("test-info"));
Browser.Equal("Default Not Found Page", () => Browser.Exists(By.Id("test-info")).Text);
}

[Theory]
[InlineData(true)]
[InlineData(false)]
public void CanRenderNotFoundPageNoStreaming(bool useCustomNotFoundPage)
{
string query = useCustomNotFoundPage ? "&useCustomNotFoundPage=true" : "";
Navigate($"{ServerPathBase}/set-not-found?shouldSet=true{query}");

if (useCustomNotFoundPage)
{
var infoText = Browser.FindElement(By.Id("test-info")).Text;
Assert.Contains("Welcome On Custom Not Found Page", infoText);
}
else
{
var bodyText = Browser.FindElement(By.TagName("body")).Text;
Assert.Contains("There's nothing here", bodyText);
}
}
}
Loading
Loading