Skip to content

Commit bb7c393

Browse files
authored
Add NotFoundPage to Router (#60970)
* Add `NotFoundPage`. * Fix rebase error. * Draft of template changes. * Typo errors. * NavigationManager is not needed for SSR. * Add BOM to new teamplate files. * Move instead of exclude. * Clean up, fix tests. * Fix * Apply smallest possible changes to templates. * Missing changes to baseline. * Prevent throwing. * Fix configurations without global router. * Fix "response started" scenarios. * Fix template tests. * Fix baseline tests. * This is a draft of uneffective `UseStatusCodePagesWithReExecute`, cc @javiercn. * Update. * Fix reexecution mechanism. * Fix public API. * Args order. * Draft of test. * Per page interactivity test. * Revert unnecessary change. * Typo: we want to stop only if status pages are on. * Remove comments. * Fix tests. * Feedback. * Feedback. * Failing test - re-executed without a reason. * Add streaming test after response started. * Test SSR with no interactivity. * Stop the renderer regardless of `Response.HasStarted`. * Feedback: not checking status code works as well. * Feedback: improve handling streaming-in-process case. * Throw on NotFound without global router. * Use `IStatusCodeReExecuteFeature`. * Unify "fallback" pages - check for titles only. * Fix early return condition. * Feedback.
1 parent 7c7f56f commit bb7c393

29 files changed

+567
-73
lines changed

src/Components/Components/src/NavigationManager.cs

+11-1
Original file line numberDiff line numberDiff line change
@@ -54,6 +54,8 @@ public event EventHandler<NotFoundEventArgs> OnNotFound
5454

5555
private EventHandler<NotFoundEventArgs>? _notFound;
5656

57+
private static readonly NotFoundEventArgs _notFoundEventArgs = new NotFoundEventArgs();
58+
5759
// For the baseUri it's worth storing as a System.Uri so we can do operations
5860
// on that type. System.Uri gives us access to the original string anyway.
5961
private Uri? _baseUri;
@@ -203,7 +205,15 @@ public virtual void Refresh(bool forceReload = false)
203205

204206
private void NotFoundCore()
205207
{
206-
_notFound?.Invoke(this, new NotFoundEventArgs());
208+
if (_notFound == null)
209+
{
210+
// global router doesn't exist, no events were registered
211+
return;
212+
}
213+
else
214+
{
215+
_notFound.Invoke(this, _notFoundEventArgs);
216+
}
207217
}
208218

209219
/// <summary>

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

+3
Original file line numberDiff line numberDiff line change
@@ -1,4 +1,7 @@
11
#nullable enable
2+
3+
Microsoft.AspNetCore.Components.Routing.Router.NotFoundPage.get -> System.Type!
4+
Microsoft.AspNetCore.Components.Routing.Router.NotFoundPage.set -> void
25
Microsoft.AspNetCore.Components.NavigationManager.OnNotFound -> System.EventHandler<Microsoft.AspNetCore.Components.Routing.NotFoundEventArgs!>!
36
Microsoft.AspNetCore.Components.NavigationManager.NotFound() -> void
47
Microsoft.AspNetCore.Components.Routing.IHostEnvironmentNavigationManager.Initialize(string! baseUri, string! uri, System.Func<string!, System.Threading.Tasks.Task!>! onNavigateTo) -> void

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

+41-1
Original file line numberDiff line numberDiff line change
@@ -3,11 +3,13 @@
33

44
#nullable disable warnings
55

6+
using System.Diagnostics.CodeAnalysis;
67
using System.Reflection;
78
using System.Reflection.Metadata;
89
using System.Runtime.ExceptionServices;
910
using Microsoft.AspNetCore.Components.HotReload;
1011
using Microsoft.AspNetCore.Components.Rendering;
12+
using Microsoft.AspNetCore.Internal;
1113
using Microsoft.Extensions.Logging;
1214
using Microsoft.Extensions.DependencyInjection;
1315

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

75+
/// <summary>
76+
/// Gets or sets the page content to display when no match is found for the requested route.
77+
/// </summary>
78+
[Parameter]
79+
[DynamicallyAccessedMembers(LinkerFlags.Component)]
80+
public Type NotFoundPage { get; set; } = default!;
81+
7382
/// <summary>
7483
/// Gets or sets the content to display when a match is found for the requested route.
7584
/// </summary>
@@ -132,6 +141,22 @@ public async Task SetParametersAsync(ParameterView parameters)
132141
throw new InvalidOperationException($"The {nameof(Router)} component requires a value for the parameter {nameof(Found)}.");
133142
}
134143

144+
if (NotFoundPage != null)
145+
{
146+
if (!typeof(IComponent).IsAssignableFrom(NotFoundPage))
147+
{
148+
throw new InvalidOperationException($"The type {NotFoundPage.FullName} " +
149+
$"does not implement {typeof(IComponent).FullName}.");
150+
}
151+
152+
var routeAttributes = NotFoundPage.GetCustomAttributes(typeof(RouteAttribute), inherit: true);
153+
if (routeAttributes.Length == 0)
154+
{
155+
throw new InvalidOperationException($"The type {NotFoundPage.FullName} " +
156+
$"does not have a {typeof(RouteAttribute).FullName} applied to it.");
157+
}
158+
}
159+
135160
if (!_onNavigateCalled)
136161
{
137162
_onNavigateCalled = true;
@@ -327,7 +352,22 @@ private void OnNotFound(object sender, EventArgs args)
327352
if (_renderHandle.IsInitialized)
328353
{
329354
Log.DisplayingNotFound(_logger);
330-
_renderHandle.Render(NotFound ?? DefaultNotFoundContent);
355+
_renderHandle.Render(builder =>
356+
{
357+
if (NotFoundPage != null)
358+
{
359+
builder.OpenComponent(0, NotFoundPage);
360+
builder.CloseComponent();
361+
}
362+
else if (NotFound != null)
363+
{
364+
NotFound(builder);
365+
}
366+
else
367+
{
368+
DefaultNotFoundContent(builder);
369+
}
370+
});
331371
}
332372
}
333373

src/Components/Endpoints/src/Microsoft.AspNetCore.Components.Endpoints.csproj

+1
Original file line numberDiff line numberDiff line change
@@ -40,6 +40,7 @@
4040
<Reference Include="Microsoft.AspNetCore.Antiforgery" />
4141
<Reference Include="Microsoft.AspNetCore.Components.Authorization" />
4242
<Reference Include="Microsoft.AspNetCore.Components.Web" />
43+
<Reference Include="Microsoft.AspNetCore.Diagnostics" />
4344
<Reference Include="Microsoft.AspNetCore.Diagnostics.Abstractions" />
4445
<Reference Include="Microsoft.AspNetCore.DataProtection.Extensions" />
4546
<Reference Include="Microsoft.AspNetCore.Hosting.Abstractions" />

src/Components/Endpoints/src/RazorComponentEndpointInvoker.cs

+22-6
Original file line numberDiff line numberDiff line change
@@ -39,12 +39,18 @@ private async Task RenderComponentCore(HttpContext context)
3939
{
4040
context.Response.ContentType = RazorComponentResultExecutor.DefaultContentType;
4141
var isErrorHandler = context.Features.Get<IExceptionHandlerFeature>() is not null;
42+
var hasStatusCodePage = context.Features.Get<IStatusCodePagesFeature>() is not null;
43+
var isReExecuted = context.Features.Get<IStatusCodeReExecuteFeature>() is not null;
4244
if (isErrorHandler)
4345
{
4446
Log.InteractivityDisabledForErrorHandling(_logger);
4547
}
46-
_renderer.InitializeStreamingRenderingFraming(context, isErrorHandler);
47-
EndpointHtmlRenderer.MarkAsAllowingEnhancedNavigation(context);
48+
_renderer.InitializeStreamingRenderingFraming(context, isErrorHandler, isReExecuted);
49+
if (!isReExecuted)
50+
{
51+
// re-executed pages have Headers already set up
52+
EndpointHtmlRenderer.MarkAsAllowingEnhancedNavigation(context);
53+
}
4854

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

@@ -85,14 +91,23 @@ await _renderer.InitializeStandardComponentServicesAsync(
8591
await using var writer = new HttpResponseStreamWriter(context.Response.Body, Encoding.UTF8, defaultBufferSize, ArrayPool<byte>.Shared, ArrayPool<char>.Shared);
8692
using var bufferWriter = new BufferedTextWriter(writer);
8793

94+
bool isErrorHandlerOrReExecuted = isErrorHandler || isReExecuted;
95+
8896
// Note that we always use Static rendering mode for the top-level output from a RazorComponentResult,
8997
// because you never want to serialize the invocation of RazorComponentResultHost. Instead, that host
9098
// component takes care of switching into your desired render mode when it produces its own output.
9199
var htmlContent = await _renderer.RenderEndpointComponent(
92100
context,
93101
rootComponent,
94102
ParameterView.Empty,
95-
waitForQuiescence: result.IsPost || isErrorHandler);
103+
waitForQuiescence: result.IsPost || isErrorHandlerOrReExecuted);
104+
105+
bool avoidStartingResponse = hasStatusCodePage && !isReExecuted && context.Response.StatusCode == StatusCodes.Status404NotFound;
106+
if (avoidStartingResponse)
107+
{
108+
// the request is going to be re-executed, we should avoid writing to the response
109+
return;
110+
}
96111

97112
Task quiesceTask;
98113
if (!result.IsPost)
@@ -145,7 +160,7 @@ await _renderer.InitializeStandardComponentServicesAsync(
145160
}
146161

147162
// Emit comment containing state.
148-
if (!isErrorHandler)
163+
if (!isErrorHandlerOrReExecuted)
149164
{
150165
var componentStateHtmlContent = await _renderer.PrerenderPersistedStateAsync(context);
151166
componentStateHtmlContent.WriteTo(bufferWriter, HtmlEncoder.Default);
@@ -160,10 +175,11 @@ await _renderer.InitializeStandardComponentServicesAsync(
160175
private async Task<RequestValidationState> ValidateRequestAsync(HttpContext context, IAntiforgery? antiforgery)
161176
{
162177
var processPost = HttpMethods.IsPost(context.Request.Method) &&
163-
// Disable POST functionality during exception handling.
178+
// Disable POST functionality during exception handling and reexecution.
164179
// The exception handler middleware will not update the request method, and we don't
165180
// want to run the form handling logic against the error page.
166-
context.Features.Get<IExceptionHandlerFeature>() == null;
181+
context.Features.Get<IExceptionHandlerFeature>() == null &&
182+
context.Features.Get<IStatusCodePagesFeature>() == null;
167183

168184
if (processPost)
169185
{

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

+17-4
Original file line numberDiff line numberDiff line change
@@ -77,14 +77,27 @@ private Task ReturnErrorResponse(string detailedMessage)
7777
: Task.CompletedTask;
7878
}
7979

80-
private void SetNotFoundResponse(object? sender, EventArgs args)
80+
private async Task SetNotFoundResponseAsync(string baseUri)
8181
{
8282
if (_httpContext.Response.HasStarted)
8383
{
84-
throw new InvalidOperationException("Cannot set a NotFound response after the response has already started.");
84+
var defaultBufferSize = 16 * 1024;
85+
await using var writer = new HttpResponseStreamWriter(_httpContext.Response.Body, Encoding.UTF8, defaultBufferSize, ArrayPool<byte>.Shared, ArrayPool<char>.Shared);
86+
using var bufferWriter = new BufferedTextWriter(writer);
87+
var notFoundUri = $"{baseUri}not-found";
88+
HandleNavigationAfterResponseStarted(bufferWriter, _httpContext, notFoundUri);
89+
await bufferWriter.FlushAsync();
8590
}
86-
_httpContext.Response.StatusCode = StatusCodes.Status404NotFound;
87-
SignalRendererToFinishRendering();
91+
else
92+
{
93+
_httpContext.Response.StatusCode = StatusCodes.Status404NotFound;
94+
_httpContext.Response.ContentType = null;
95+
}
96+
97+
// When the application triggers a NotFound event, we continue rendering the current batch.
98+
// However, after completing this batch, we do not want to process any further UI updates,
99+
// as we are going to return a 404 status and discard the UI updates generated so far.
100+
SignalRendererToFinishRenderingAfterCurrentBatch();
88101
}
89102

90103
private async Task OnNavigateTo(string uri)

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

+46-2
Original file line numberDiff line numberDiff line change
@@ -8,6 +8,7 @@
88
using Microsoft.AspNetCore.Components.Web.HtmlRendering;
99
using Microsoft.AspNetCore.Html;
1010
using Microsoft.AspNetCore.Http;
11+
using Microsoft.Extensions.Logging;
1112
using static Microsoft.AspNetCore.Internal.LinkerFlags;
1213

1314
namespace Microsoft.AspNetCore.Components.Endpoints;
@@ -18,7 +19,7 @@ internal partial class EndpointHtmlRenderer
1819

1920
protected override IComponent ResolveComponentForRenderMode([DynamicallyAccessedMembers(Component)] Type componentType, int? parentComponentId, IComponentActivator componentActivator, IComponentRenderMode renderMode)
2021
{
21-
if (_isHandlingErrors)
22+
if (_isHandlingErrors || _isReExecuted)
2223
{
2324
// Ignore the render mode boundary in error scenarios.
2425
return componentActivator.CreateInstance(componentType);
@@ -166,7 +167,50 @@ private async Task WaitForResultReady(bool waitForQuiescence, PrerenderedCompone
166167
}
167168
else if (_nonStreamingPendingTasks.Count > 0)
168169
{
169-
await WaitForNonStreamingPendingTasks();
170+
if (_isReExecuted)
171+
{
172+
HandleNonStreamingTasks();
173+
}
174+
else
175+
{
176+
await WaitForNonStreamingPendingTasks();
177+
}
178+
}
179+
}
180+
181+
public void HandleNonStreamingTasks()
182+
{
183+
if (NonStreamingPendingTasksCompletion == null)
184+
{
185+
foreach (var task in _nonStreamingPendingTasks)
186+
{
187+
_ = GetErrorHandledTask(task);
188+
}
189+
190+
// Clear the pending tasks since we are handling them
191+
_nonStreamingPendingTasks.Clear();
192+
193+
NonStreamingPendingTasksCompletion = Task.CompletedTask;
194+
}
195+
}
196+
197+
private async Task GetErrorHandledTask(Task taskToHandle)
198+
{
199+
try
200+
{
201+
await taskToHandle;
202+
}
203+
catch (Exception ex)
204+
{
205+
// Ignore errors due to task cancellations.
206+
if (!taskToHandle.IsCanceled)
207+
{
208+
_logger.LogError(
209+
ex,
210+
"An exception occurred during non-streaming rendering. " +
211+
"This exception will be ignored because the response " +
212+
"is being discarded and the request is being re-executed.");
213+
}
170214
}
171215
}
172216

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

+4-2
Original file line numberDiff line numberDiff line change
@@ -23,11 +23,13 @@ internal partial class EndpointHtmlRenderer
2323
private HashSet<int>? _visitedComponentIdsInCurrentStreamingBatch;
2424
private string? _ssrFramingCommentMarkup;
2525
private bool _isHandlingErrors;
26+
private bool _isReExecuted;
2627

27-
public void InitializeStreamingRenderingFraming(HttpContext httpContext, bool isErrorHandler)
28+
public void InitializeStreamingRenderingFraming(HttpContext httpContext, bool isErrorHandler, bool isReExecuted)
2829
{
2930
_isHandlingErrors = isErrorHandler;
30-
if (IsProgressivelyEnhancedNavigation(httpContext.Request))
31+
_isReExecuted = isReExecuted;
32+
if (!isReExecuted && IsProgressivelyEnhancedNavigation(httpContext.Request))
3133
{
3234
var id = Guid.NewGuid().ToString();
3335
httpContext.Response.Headers.Add(_streamingRenderingFramingHeaderName, id);

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

+25-2
Original file line numberDiff line numberDiff line change
@@ -44,6 +44,7 @@ internal partial class EndpointHtmlRenderer : StaticHtmlRenderer, IComponentPrer
4444
private HttpContext _httpContext = default!; // Always set at the start of an inbound call
4545
private ResourceAssetCollection? _resourceCollection;
4646
private bool _rendererIsStopped;
47+
private readonly ILogger _logger;
4748

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

6163
internal HttpContext? HttpContext => _httpContext;
@@ -83,7 +85,7 @@ internal async Task InitializeStandardComponentServicesAsync(
8385

8486
if (navigationManager != null)
8587
{
86-
navigationManager.OnNotFound += SetNotFoundResponse;
88+
navigationManager.OnNotFound += async (sender, args) => await SetNotFoundResponseAsync(navigationManager.BaseUri);
8789
}
8890

8991
var authenticationStateProvider = httpContext.RequestServices.GetService<AuthenticationStateProvider>();
@@ -163,6 +165,11 @@ protected override ComponentState CreateComponentState(int componentId, ICompone
163165

164166
protected override void AddPendingTask(ComponentState? componentState, Task task)
165167
{
168+
if (_isReExecuted)
169+
{
170+
return;
171+
}
172+
166173
var streamRendering = componentState is null
167174
? false
168175
: ((EndpointComponentState)componentState).StreamRendering;
@@ -176,12 +183,28 @@ protected override void AddPendingTask(ComponentState? componentState, Task task
176183
base.AddPendingTask(componentState, task);
177184
}
178185

179-
protected override void SignalRendererToFinishRendering()
186+
private void SignalRendererToFinishRenderingAfterCurrentBatch()
180187
{
188+
// sets a deferred stop on the renderer, which will have an effect after the current batch is completed
181189
_rendererIsStopped = true;
190+
}
191+
192+
protected override void SignalRendererToFinishRendering()
193+
{
194+
SignalRendererToFinishRenderingAfterCurrentBatch();
195+
// sets a hard stop on the renderer, which will have an effect immediately
182196
base.SignalRendererToFinishRendering();
183197
}
184198

199+
protected override void ProcessPendingRender()
200+
{
201+
if (_rendererIsStopped)
202+
{
203+
return;
204+
}
205+
base.ProcessPendingRender();
206+
}
207+
185208
// For tests only
186209
internal Task? NonStreamingPendingTasksCompletion;
187210

src/Components/Endpoints/src/Results/RazorComponentResultExecutor.cs

+2-1
Original file line numberDiff line numberDiff line change
@@ -48,7 +48,8 @@ private static Task RenderComponentToResponse(
4848
return endpointHtmlRenderer.Dispatcher.InvokeAsync(async () =>
4949
{
5050
var isErrorHandler = httpContext.Features.Get<IExceptionHandlerFeature>() is not null;
51-
endpointHtmlRenderer.InitializeStreamingRenderingFraming(httpContext, isErrorHandler);
51+
var isReExecuted = httpContext.Features.Get<IStatusCodeReExecuteFeature>() is not null;
52+
endpointHtmlRenderer.InitializeStreamingRenderingFraming(httpContext, isErrorHandler, isReExecuted);
5253
EndpointHtmlRenderer.MarkAsAllowingEnhancedNavigation(httpContext);
5354

5455
// We could pool these dictionary instances if we wanted, and possibly even the ParameterView

0 commit comments

Comments
 (0)