Skip to content

Commit e92baae

Browse files
committed
Interactive NotFound event + SSR status code.
1 parent a2fd165 commit e92baae

File tree

13 files changed

+417
-1
lines changed

13 files changed

+417
-1
lines changed

src/Components/Components/src/NavigationManager.cs

Lines changed: 201 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -35,6 +35,29 @@ public event EventHandler<LocationChangedEventArgs> LocationChanged
3535

3636
private CancellationTokenSource? _locationChangingCts;
3737

38+
/// <summary>
39+
/// An event that fires when the page is not found.
40+
/// </summary>
41+
public event EventHandler<NotFoundEventArgs> NotFoundEvent
42+
{
43+
add
44+
{
45+
AssertInitialized();
46+
_notFound += value;
47+
}
48+
remove
49+
{
50+
AssertInitialized();
51+
_notFound -= value;
52+
}
53+
}
54+
55+
private EventHandler<NotFoundEventArgs>? _notFound;
56+
57+
private readonly List<Func<NotFoundContext, ValueTask>> _notFoundHandlers = new();
58+
59+
private CancellationTokenSource? _notFoundCts;
60+
3861
// For the baseUri it's worth storing as a System.Uri so we can do operations
3962
// on that type. System.Uri gives us access to the original string anyway.
4063
private Uri? _baseUri;
@@ -177,6 +200,16 @@ protected virtual void NavigateToCore([StringSyntax(StringSyntaxAttribute.Uri)]
177200
public virtual void Refresh(bool forceReload = false)
178201
=> NavigateTo(Uri, forceLoad: true, replace: true);
179202

203+
/// <summary>
204+
/// TODO
205+
/// </summary>
206+
public virtual void NotFound() => NotFoundCore();
207+
208+
/// <summary>
209+
/// TODO
210+
/// </summary>
211+
protected virtual void NotFoundCore() => throw new NotImplementedException();
212+
180213
/// <summary>
181214
/// Called to initialize BaseURI and current URI before these values are used for the first time.
182215
/// Override <see cref="EnsureInitialized" /> and call this method to dynamically calculate these values.
@@ -308,6 +341,26 @@ protected void NotifyLocationChanged(bool isInterceptedLink)
308341
}
309342
}
310343

344+
/// <summary>
345+
/// Triggers the <see cref="NotFound"/> event with the current URI value.
346+
/// </summary>
347+
protected void NotifyNotFound(bool isInterceptedLink)
348+
{
349+
try
350+
{
351+
_notFound?.Invoke(
352+
this,
353+
new NotFoundEventArgs(isInterceptedLink)
354+
{
355+
HistoryEntryState = HistoryEntryState
356+
});
357+
}
358+
catch (Exception ex)
359+
{
360+
throw new NotFoundRenderingException("An exception occurred while dispatching a NotFound event.", ex);
361+
}
362+
}
363+
311364
/// <summary>
312365
/// Notifies the registered handlers of the current location change.
313366
/// </summary>
@@ -433,12 +486,135 @@ protected async ValueTask<bool> NotifyLocationChangingAsync(string uri, string?
433486
cts.Dispose();
434487

435488
if (_locationChangingCts == cts)
436-
{
489+
{
437490
_locationChangingCts = null;
438491
}
439492
}
440493
}
441494

495+
/// <summary>
496+
/// Notifies the registered handlers of the current ot found event.
497+
/// </summary>
498+
/// <param name="isNavigationIntercepted">Whether this not found was intercepted from a link.</param>
499+
/// <returns>A <see cref="ValueTask{TResult}"/> representing the completion of the operation. If the result is <see langword="true"/>, the navigation should continue.</returns>
500+
protected async ValueTask<bool> NotifyNotFoundAsync(bool isNavigationIntercepted)
501+
{
502+
_notFoundCts?.Cancel();
503+
_notFoundCts = null;
504+
505+
var handlerCount = _notFoundHandlers.Count;
506+
507+
if (handlerCount == 0)
508+
{
509+
return true;
510+
}
511+
512+
var cts = new CancellationTokenSource();
513+
514+
_notFoundCts = cts;
515+
516+
var cancellationToken = cts.Token;
517+
var context = new NotFoundContext
518+
{
519+
// HistoryEntryState = state,
520+
IsNavigationIntercepted = isNavigationIntercepted,
521+
CancellationToken = cancellationToken,
522+
};
523+
524+
try
525+
{
526+
if (handlerCount == 1)
527+
{
528+
var handlerTask = InvokeNotFoundHandlerAsync(_notFoundHandlers[0], context);
529+
530+
if (handlerTask.IsFaulted)
531+
{
532+
await handlerTask;
533+
return false; // Unreachable because the previous line will throw.
534+
}
535+
536+
if (context.DidPreventRendering)
537+
{
538+
return false;
539+
}
540+
541+
if (!handlerTask.IsCompletedSuccessfully)
542+
{
543+
await handlerTask.AsTask().WaitAsync(cancellationToken);
544+
}
545+
}
546+
else
547+
{
548+
var notFoundHandlersCopy = ArrayPool<Func<NotFoundContext, ValueTask>>.Shared.Rent(handlerCount);
549+
550+
try
551+
{
552+
_notFoundHandlers.CopyTo(notFoundHandlersCopy);
553+
554+
var notFoundTasks = new HashSet<Task>();
555+
556+
for (var i = 0; i < handlerCount; i++)
557+
{
558+
var handlerTask = InvokeNotFoundHandlerAsync(notFoundHandlersCopy[i], context);
559+
560+
if (handlerTask.IsFaulted)
561+
{
562+
await handlerTask;
563+
return false; // Unreachable because the previous line will throw.
564+
}
565+
566+
if (context.DidPreventRendering)
567+
{
568+
return false;
569+
}
570+
571+
notFoundTasks.Add(handlerTask.AsTask());
572+
}
573+
574+
while (notFoundTasks.Count != 0)
575+
{
576+
var completedHandlerTask = await Task.WhenAny(notFoundTasks).WaitAsync(cancellationToken);
577+
578+
if (completedHandlerTask.IsFaulted)
579+
{
580+
await completedHandlerTask;
581+
return false; // Unreachable because the previous line will throw.
582+
}
583+
584+
notFoundTasks.Remove(completedHandlerTask);
585+
}
586+
}
587+
finally
588+
{
589+
ArrayPool<Func<NotFoundContext, ValueTask>>.Shared.Return(notFoundHandlersCopy);
590+
}
591+
}
592+
593+
return !context.DidPreventRendering;
594+
}
595+
catch (TaskCanceledException ex)
596+
{
597+
if (ex.CancellationToken == cancellationToken)
598+
{
599+
// This navigation was in progress when a successive navigation occurred.
600+
// We treat this as a canceled navigation.
601+
return false;
602+
}
603+
604+
throw;
605+
}
606+
finally
607+
{
608+
cts.Cancel();
609+
cts.Dispose();
610+
611+
if (_notFoundCts == cts)
612+
{
613+
_notFoundCts = null;
614+
}
615+
}
616+
}
617+
442618
private async ValueTask InvokeLocationChangingHandlerAsync(Func<LocationChangingContext, ValueTask> handler, LocationChangingContext context)
443619
{
444620
try
@@ -455,6 +631,22 @@ private async ValueTask InvokeLocationChangingHandlerAsync(Func<LocationChanging
455631
}
456632
}
457633

634+
private async ValueTask InvokeNotFoundHandlerAsync(Func<NotFoundContext, ValueTask> handler, NotFoundContext context)
635+
{
636+
try
637+
{
638+
await handler(context);
639+
}
640+
catch (OperationCanceledException)
641+
{
642+
// Ignore exceptions caused by cancellations.
643+
}
644+
catch (Exception ex)
645+
{
646+
HandleNotFoundHandlerException(ex, context);
647+
}
648+
}
649+
458650
/// <summary>
459651
/// Handles exceptions thrown in location changing handlers.
460652
/// </summary>
@@ -463,6 +655,14 @@ private async ValueTask InvokeLocationChangingHandlerAsync(Func<LocationChanging
463655
protected virtual void HandleLocationChangingHandlerException(Exception ex, LocationChangingContext context)
464656
=> throw new InvalidOperationException($"To support navigation locks, {GetType().Name} must override {nameof(HandleLocationChangingHandlerException)}");
465657

658+
/// <summary>
659+
/// Handles exceptions thrown in NotFound rendering handlers.
660+
/// </summary>
661+
/// <param name="ex">The exception to handle.</param>
662+
/// <param name="context">The context passed to the handler.</param>
663+
protected virtual void HandleNotFoundHandlerException(Exception ex, NotFoundContext context)
664+
=> throw new InvalidOperationException($"To support not found rendering locks, {GetType().Name} must override {nameof(HandleNotFoundHandlerException)}");
665+
466666
/// <summary>
467667
/// Sets whether navigation is currently locked. If it is, then implementations should not update <see cref="Uri"/> and call
468668
/// <see cref="NotifyLocationChanged(bool)"/> until they have first confirmed the navigation by calling
Lines changed: 20 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,20 @@
1+
// Licensed to the .NET Foundation under one or more agreements.
2+
// The .NET Foundation licenses this file to you under the MIT license.
3+
4+
namespace Microsoft.AspNetCore.Components;
5+
6+
/// <summary>
7+
/// Exception thrown when an <see cref="NavigationManager"/> is not able to render not found page.
8+
/// </summary>
9+
public class NotFoundRenderingException : Exception
10+
{
11+
/// <summary>
12+
/// Creates a new instance of <see cref="NotFoundRenderingException"/>.
13+
/// </summary>
14+
/// <param name="message">The exception message.</param>
15+
/// <param name="innerException">The inner exception.</param>
16+
public NotFoundRenderingException(string message, Exception innerException)
17+
: base(message, innerException)
18+
{
19+
}
20+
}
Lines changed: 20 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -1 +1,21 @@
11
#nullable enable
2+
3+
virtual Microsoft.AspNetCore.Components.NavigationManager.NotFound() -> void
4+
virtual Microsoft.AspNetCore.Components.NavigationManager.NotFoundCore() -> void
5+
Microsoft.AspNetCore.Components.NavigationManager.NotFoundEvent -> System.EventHandler<Microsoft.AspNetCore.Components.Routing.NotFoundEventArgs!>!
6+
Microsoft.AspNetCore.Components.Routing.NotFoundEventArgs
7+
Microsoft.AspNetCore.Components.Routing.NotFoundEventArgs.NotFoundEventArgs(bool isNavigationIntercepted) -> void
8+
Microsoft.AspNetCore.Components.Routing.NotFoundEventArgs.IsNavigationIntercepted.get -> bool
9+
Microsoft.AspNetCore.Components.Routing.NotFoundEventArgs.HistoryEntryState.get -> string?
10+
Microsoft.AspNetCore.Components.Routing.NotFoundContext
11+
Microsoft.AspNetCore.Components.Routing.NotFoundContext.NotFoundContext() -> void
12+
Microsoft.AspNetCore.Components.Routing.NotFoundContext.CancellationToken.get -> System.Threading.CancellationToken
13+
Microsoft.AspNetCore.Components.Routing.NotFoundContext.CancellationToken.init -> void
14+
Microsoft.AspNetCore.Components.Routing.NotFoundContext.IsNavigationIntercepted.get -> bool
15+
Microsoft.AspNetCore.Components.Routing.NotFoundContext.IsNavigationIntercepted.init -> void
16+
Microsoft.AspNetCore.Components.Routing.NotFoundContext.PreventRendering() -> void
17+
Microsoft.AspNetCore.Components.NavigationManager.NotifyNotFound(bool isInterceptedLink) -> void
18+
Microsoft.AspNetCore.Components.NavigationManager.NotifyNotFoundAsync(bool isNavigationIntercepted) -> System.Threading.Tasks.ValueTask<bool>
19+
virtual Microsoft.AspNetCore.Components.NavigationManager.HandleNotFoundHandlerException(System.Exception! ex, Microsoft.AspNetCore.Components.Routing.NotFoundContext! context) -> void
20+
Microsoft.AspNetCore.Components.NotFoundRenderingException
21+
Microsoft.AspNetCore.Components.NotFoundRenderingException.NotFoundRenderingException(string! message, System.Exception! innerException) -> void
Lines changed: 32 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,32 @@
1+
// Licensed to the .NET Foundation under one or more agreements.
2+
// The .NET Foundation licenses this file to you under the MIT license.
3+
4+
namespace Microsoft.AspNetCore.Components.Routing;
5+
6+
/// <summary>
7+
/// Contains context for a change to the browser's current location.
8+
/// </summary>
9+
public sealed class NotFoundContext
10+
{
11+
internal bool DidPreventRendering { get; private set; }
12+
13+
/// <summary>
14+
/// Gets a <see cref="System.Threading.CancellationToken"/> that can be used to determine if this navigation was canceled
15+
/// (for example, because the user has triggered a different navigation).
16+
/// </summary>
17+
public CancellationToken CancellationToken { get; init; }
18+
19+
/// <summary>
20+
/// Gets whether this navigation was intercepted from a link.
21+
/// </summary>
22+
public bool IsNavigationIntercepted { get; init; }
23+
24+
/// <summary>
25+
/// Prevents this navigation from continuing.
26+
/// </summary>
27+
public void PreventRendering()
28+
{
29+
DidPreventRendering = true;
30+
}
31+
32+
}
Lines changed: 29 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,29 @@
1+
// Licensed to the .NET Foundation under one or more agreements.
2+
// The .NET Foundation licenses this file to you under the MIT license.
3+
4+
namespace Microsoft.AspNetCore.Components.Routing;
5+
6+
/// <summary>
7+
/// <see cref="EventArgs" /> for <see cref="NavigationManager.NotFound" />.
8+
/// </summary>
9+
public class NotFoundEventArgs : EventArgs
10+
{
11+
/// <summary>
12+
/// Initializes a new instance of <see cref="NotFoundEventArgs" />.
13+
/// </summary>
14+
/// <param name="isNavigationIntercepted">A value that determines if navigation for the link was intercepted.</param>
15+
public NotFoundEventArgs(bool isNavigationIntercepted)
16+
{
17+
IsNavigationIntercepted = isNavigationIntercepted;
18+
}
19+
20+
/// <summary>
21+
/// Gets a value that determines if navigation to NotFound page was intercepted.
22+
/// </summary>
23+
public bool IsNavigationIntercepted { get; }
24+
25+
/// <summary>
26+
/// Gets the state associated with the current history entry.
27+
/// </summary>
28+
public string? HistoryEntryState { get; internal init; }
29+
}

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

Lines changed: 14 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -105,6 +105,7 @@ public void Attach(RenderHandle renderHandle)
105105
_baseUri = NavigationManager.BaseUri;
106106
_locationAbsolute = NavigationManager.Uri;
107107
NavigationManager.LocationChanged += OnLocationChanged;
108+
NavigationManager.NotFoundEvent += OnNotFound;
108109
RoutingStateProvider = ServiceProvider.GetService<IRoutingStateProvider>();
109110

110111
if (HotReloadManager.Default.MetadataUpdateSupported)
@@ -146,6 +147,7 @@ public async Task SetParametersAsync(ParameterView parameters)
146147
public void Dispose()
147148
{
148149
NavigationManager.LocationChanged -= OnLocationChanged;
150+
NavigationManager.NotFoundEvent -= OnNotFound;
149151
if (HotReloadManager.Default.MetadataUpdateSupported)
150152
{
151153
HotReloadManager.Default.OnDeltaApplied -= ClearRouteCaches;
@@ -320,6 +322,15 @@ private void OnLocationChanged(object sender, LocationChangedEventArgs args)
320322
}
321323
}
322324

325+
private void OnNotFound(object sender, NotFoundEventArgs args)
326+
{
327+
if (_renderHandle.IsInitialized)
328+
{
329+
Log.DisplayingNotFound(_logger);
330+
_renderHandle.Render(NotFound ?? DefaultNotFoundContent);
331+
}
332+
}
333+
323334
async Task IHandleAfterRender.OnAfterRenderAsync()
324335
{
325336
if (!_navigationInterceptionEnabled)
@@ -340,6 +351,9 @@ private static partial class Log
340351
[LoggerMessage(1, LogLevel.Debug, $"Displaying {nameof(NotFound)} because path '{{Path}}' with base URI '{{BaseUri}}' does not match any component route", EventName = "DisplayingNotFound")]
341352
internal static partial void DisplayingNotFound(ILogger logger, string path, string baseUri);
342353

354+
[LoggerMessage(1, LogLevel.Debug, $"Displaying {nameof(NotFound)} on request", EventName = "DisplayingNotFoundOnRequest")]
355+
internal static partial void DisplayingNotFound(ILogger logger);
356+
343357
[LoggerMessage(2, LogLevel.Debug, "Navigating to component {ComponentType} in response to path '{Path}' with base URI '{BaseUri}'", EventName = "NavigatingToComponent")]
344358
internal static partial void NavigatingToComponent(ILogger logger, Type componentType, string path, string baseUri);
345359

0 commit comments

Comments
 (0)