Skip to content

Commit 79c95e9

Browse files
committed
Client streams-in the NotFoundPage if it's provided to the Router.
1 parent 707683f commit 79c95e9

File tree

7 files changed

+110
-14
lines changed

7 files changed

+110
-14
lines changed

src/Components/Components/src/NavigationManager.cs

Lines changed: 3 additions & 3 deletions
Original file line numberDiff line numberDiff line change
@@ -54,15 +54,15 @@ public event EventHandler<NotFoundEventArgs> OnNotFound
5454

5555
private EventHandler<NotFoundEventArgs>? _notFound;
5656

57-
private static readonly NotFoundEventArgs _notFoundEventArgs = new NotFoundEventArgs();
58-
5957
// For the baseUri it's worth storing as a System.Uri so we can do operations
6058
// on that type. System.Uri gives us access to the original string anyway.
6159
private Uri? _baseUri;
6260

6361
// The URI. Always represented an absolute URI.
6462
private string? _uri;
6563
private bool _isInitialized;
64+
internal string NotFoundPageRoute { get; set; } = string.Empty;
65+
internal Type NotFoundPageType { get; set; } = default!;
6666

6767
/// <summary>
6868
/// Gets or sets the current base URI. The <see cref="BaseUri" /> is always represented as an absolute URI in string form with trailing slash.
@@ -212,7 +212,7 @@ private void NotFoundCore()
212212
}
213213
else
214214
{
215-
_notFound.Invoke(this, _notFoundEventArgs);
215+
_notFound.Invoke(this, new NotFoundEventArgs(NotFoundPageRoute, NotFoundPageType)); // ToDo: sending only the type would be enough and then we would get the route later
216216
}
217217
}
218218

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

Lines changed: 3 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -6,7 +6,9 @@ Microsoft.AspNetCore.Components.NavigationManager.OnNotFound -> System.EventHand
66
Microsoft.AspNetCore.Components.NavigationManager.NotFound() -> void
77
Microsoft.AspNetCore.Components.Routing.IHostEnvironmentNavigationManager.Initialize(string! baseUri, string! uri, System.Func<string!, System.Threading.Tasks.Task!>! onNavigateTo) -> void
88
Microsoft.AspNetCore.Components.Routing.NotFoundEventArgs
9-
Microsoft.AspNetCore.Components.Routing.NotFoundEventArgs.NotFoundEventArgs() -> void
9+
Microsoft.AspNetCore.Components.Routing.NotFoundEventArgs.NotFoundEventArgs(string! url, System.Type! notFoundPageType) -> void
10+
Microsoft.AspNetCore.Components.Routing.NotFoundEventArgs.NotFoundPageType.get -> System.Type!
11+
Microsoft.AspNetCore.Components.Routing.NotFoundEventArgs.Path.get -> string!
1012
Microsoft.AspNetCore.Components.Infrastructure.ComponentStatePersistenceManager.ComponentStatePersistenceManager(Microsoft.Extensions.Logging.ILogger<Microsoft.AspNetCore.Components.Infrastructure.ComponentStatePersistenceManager!>! logger, System.IServiceProvider! serviceProvider) -> void
1113
Microsoft.AspNetCore.Components.Infrastructure.ComponentStatePersistenceManager.SetPlatformRenderMode(Microsoft.AspNetCore.Components.IComponentRenderMode! renderMode) -> void
1214
Microsoft.AspNetCore.Components.Infrastructure.RegisterPersistentComponentStateServiceCollectionExtensions

src/Components/Components/src/Routing/NotFoundEventArgs.cs

Lines changed: 16 additions & 2 deletions
Original file line numberDiff line numberDiff line change
@@ -8,9 +8,23 @@ namespace Microsoft.AspNetCore.Components.Routing;
88
/// </summary>
99
public sealed class NotFoundEventArgs : EventArgs
1010
{
11+
/// <summary>
12+
/// Gets the path of NotFoundPage.
13+
/// </summary>
14+
public string Path { get; }
15+
16+
/// <summary>
17+
/// Gets the type of NotFoundPage component.
18+
/// </summary>
19+
public Type NotFoundPageType { get; }
20+
1121
/// <summary>
1222
/// Initializes a new instance of <see cref="NotFoundEventArgs" />.
1323
/// </summary>
14-
public NotFoundEventArgs()
15-
{ }
24+
public NotFoundEventArgs(string url, Type notFoundPageType)
25+
{
26+
Path = url;
27+
NotFoundPageType = notFoundPageType;
28+
}
29+
1630
}

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

Lines changed: 29 additions & 7 deletions
Original file line numberDiff line numberDiff line change
@@ -4,6 +4,7 @@
44
using Microsoft.AspNetCore.Components.Endpoints.Rendering;
55
using Microsoft.AspNetCore.Components.Rendering;
66
using Microsoft.AspNetCore.Components.RenderTree;
7+
using Microsoft.AspNetCore.Components.Routing;
78
using Microsoft.AspNetCore.Http;
89
using Microsoft.AspNetCore.WebUtilities;
910
using Microsoft.Extensions.DependencyInjection;
@@ -77,21 +78,42 @@ private Task ReturnErrorResponse(string detailedMessage)
7778
: Task.CompletedTask;
7879
}
7980

80-
private void SetNotFoundResponse(object? sender, EventArgs args)
81+
private async Task SetNotFoundResponseAsync(string baseUri, NotFoundEventArgs args)
8182
{
8283
if (_httpContext.Response.HasStarted)
8384
{
84-
// We're expecting the Router to continue streaming the NotFound contents
85+
if (args.NotFoundPageType == null || string.IsNullOrEmpty(args.Path))
86+
{
87+
throw new InvalidOperationException("The NotFoundPageType and Path must be specified in the NotFoundEventArgs to render NotFoundPage when the response has started.");
88+
}
89+
var instance = Activator.CreateInstance(args.NotFoundPageType) as IComponent;
90+
if (instance == null)
91+
{
92+
throw new InvalidOperationException($"The type {args.NotFoundPageType.FullName} does not implement IComponent.");
93+
}
94+
if (_notFoundComponentId == -1)
95+
{
96+
_notFoundComponentId = AssignRootComponentId(instance);
97+
}
98+
if (string.IsNullOrEmpty(_notFoundUrl))
99+
{
100+
_notFoundUrl = $"{baseUri}{args.Path.TrimStart('/')}";
101+
}
102+
var defaultBufferSize = 16 * 1024;
103+
await using var writer = new HttpResponseStreamWriter(_httpContext.Response.Body, Encoding.UTF8, defaultBufferSize, ArrayPool<byte>.Shared, ArrayPool<char>.Shared);
104+
using var bufferWriter = new BufferedTextWriter(writer);
105+
HandleNotFoundAfterResponseStarted(bufferWriter, _httpContext, _notFoundUrl, _notFoundComponentId);
106+
await bufferWriter.FlushAsync();
85107
}
86108
else
87109
{
88110
_httpContext.Response.StatusCode = StatusCodes.Status404NotFound;
89-
90-
// When the application triggers a NotFound event, we continue rendering the current batch.
91-
// However, after completing this batch, we do not want to process any further UI updates,
92-
// as we are going to return a 404 status and discard the UI updates generated so far.
93-
SignalRendererToFinishRendering();
94111
}
112+
113+
// When the application triggers a NotFound event, we continue rendering the current batch.
114+
// However, after completing this batch, we do not want to process any further UI updates,
115+
// as we are going to return a 404 status and discard the UI updates generated so far.
116+
SignalRendererToFinishRendering();
95117
}
96118

97119
private async Task OnNavigateTo(string uri)

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

Lines changed: 9 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -226,6 +226,15 @@ private static void HandleExceptionAfterResponseStarted(HttpContext httpContext,
226226
writer.Write("</template><blazor-ssr-end></blazor-ssr-end></blazor-ssr>");
227227
}
228228

229+
private static void HandleNotFoundAfterResponseStarted(TextWriter writer, HttpContext httpContext, string notFoundUrl, int componentId)
230+
{
231+
writer.Write("<blazor-ssr><template type=\"not-found\"");
232+
writer.Write($" componentId=\"{componentId}\"");
233+
writer.Write(">");
234+
writer.Write(HtmlEncoder.Default.Encode(OpaqueRedirection.CreateProtectedRedirectionUrl(httpContext, notFoundUrl)));
235+
writer.Write("</template><blazor-ssr-end></blazor-ssr-end></blazor-ssr>");
236+
}
237+
229238
private static void HandleNavigationAfterResponseStarted(TextWriter writer, HttpContext httpContext, string destinationUrl)
230239
{
231240
writer.Write("<blazor-ssr><template type=\"redirection\"");

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

Lines changed: 4 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -52,6 +52,9 @@ internal partial class EndpointHtmlRenderer : StaticHtmlRenderer, IComponentPrer
5252
// wait for the non-streaming tasks (these ones), then start streaming until full quiescence.
5353
private readonly List<Task> _nonStreamingPendingTasks = new();
5454

55+
private int _notFoundComponentId = -1;
56+
private string _notFoundUrl = string.Empty;
57+
5558
public EndpointHtmlRenderer(IServiceProvider serviceProvider, ILoggerFactory loggerFactory)
5659
: base(serviceProvider, loggerFactory)
5760
{
@@ -85,7 +88,7 @@ internal async Task InitializeStandardComponentServicesAsync(
8588

8689
if (navigationManager != null)
8790
{
88-
navigationManager.OnNotFound += SetNotFoundResponse;
91+
navigationManager.OnNotFound += async (sender, args) => await SetNotFoundResponseAsync(navigationManager.BaseUri, args);
8992
}
9093

9194
var authenticationStateProvider = httpContext.RequestServices.GetService<AuthenticationStateProvider>();

src/Components/Web.JS/src/Rendering/StreamingRendering.ts

Lines changed: 46 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -8,6 +8,8 @@ import { synchronizeDomContent } from './DomMerging/DomSync';
88

99
let enableDomPreservation = true;
1010
let navigationEnhancementCallbacks: NavigationEnhancementCallbacks;
11+
let currentNotFoundRenderAbortController: AbortController | null;
12+
const acceptHeader = 'text/html; blazor-enhanced-nav=on';
1113

1214
export function attachStreamingRenderingListener(options: SsrStartOptions | undefined, callbacks: NavigationEnhancementCallbacks) {
1315
navigationEnhancementCallbacks = callbacks;
@@ -75,6 +77,34 @@ class BlazorStreamingUpdate extends HTMLElement {
7577
}
7678
}
7779
break;
80+
case 'not-found':
81+
const componentId = node.getAttribute('componentId');
82+
if (!componentId) {
83+
console.error('Streaming content for not-found response does not have a componentId attribute.');
84+
break;
85+
}
86+
wrapDocumentInStreamingMarkers(componentId);
87+
const notFoundUrl = toAbsoluteUri(node.content.textContent!);
88+
89+
if (componentId) {
90+
currentNotFoundRenderAbortController?.abort();
91+
currentNotFoundRenderAbortController = new AbortController();
92+
const abortSignal = currentNotFoundRenderAbortController.signal;
93+
fetch(notFoundUrl, Object.assign(<RequestInit>{
94+
signal: abortSignal,
95+
mode: 'no-cors',
96+
headers: {
97+
'accept': acceptHeader,
98+
},
99+
}, undefined))
100+
.then(response => response.text())
101+
.then(html => {
102+
const docFrag = document.createRange().createContextualFragment(html);
103+
insertStreamingContentIntoDocument(componentId, docFrag);
104+
})
105+
.catch(error => console.error('Failed to fetch not-found content:', error));
106+
}
107+
break;
78108
case 'error':
79109
// This is kind of brutal but matches what happens without progressive enhancement
80110
replaceDocumentWithPlainText(node.content.textContent || 'Error');
@@ -86,6 +116,22 @@ class BlazorStreamingUpdate extends HTMLElement {
86116
}
87117
}
88118

119+
function wrapDocumentInStreamingMarkers(componentIdAsString: string): void {
120+
// Add a comment before the <head>
121+
const markerStart = document.createComment(`bl:${componentIdAsString}`);
122+
const head = document.querySelector('head');
123+
document.documentElement.insertBefore(markerStart, head);
124+
125+
// Add a comment after the <body> or after the <head> if <body> is null
126+
const markerEnd = document.createComment(`/bl:${componentIdAsString}`);
127+
const body = document.querySelector('body');
128+
if (body && body.parentNode) {
129+
body.parentNode.insertBefore(markerEnd, body.nextSibling);
130+
} else if (head && head.parentNode) {
131+
head.parentNode.insertBefore(markerEnd, head.nextSibling);
132+
}
133+
}
134+
89135
function insertStreamingContentIntoDocument(componentIdAsString: string, docFrag: DocumentFragment): void {
90136
const markers = findStreamingMarkers(componentIdAsString);
91137
if (markers) {

0 commit comments

Comments
 (0)