Skip to content

Change priority of re-execution handling and allow router to stream NotFound contents #62178

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

Open
wants to merge 25 commits into
base: main
Choose a base branch
from
Open
Show file tree
Hide file tree
Changes from all commits
Commits
Show all changes
25 commits
Select commit Hold shift + click to select a range
599f846
Fix SSR non-streaming: rendering has higher priority than re-execution.
ilonatommy May 30, 2025
842941e
Add tests: move component common for global and local interactivity t…
ilonatommy May 30, 2025
079b66f
Missing build fixes for the previous commit.
ilonatommy May 30, 2025
301efac
Enable `Router` to stream in the `NotFound` content.
ilonatommy May 30, 2025
e7ce5ba
Remove debugging delay.
ilonatommy May 30, 2025
a2bfccf
Merge branch 'main' into fix-reexecution-priority-and-streaming-renering
ilonatommy Jun 2, 2025
526dbb1
Merge branch 'main' into fix-reexecution-priority-and-streaming-renering
ilonatommy Jun 5, 2025
b166369
Fix tests added with this PR.
ilonatommy Jun 10, 2025
1704811
Refactor test.
ilonatommy Jun 11, 2025
8184dd0
Fix enhanced navigation tests.
ilonatommy Jun 11, 2025
707683f
Markers for: `Router`, `Found`, `RouteView`, `RoutedPage` are now pre…
ilonatommy Jun 11, 2025
79c95e9
Client streams-in the NotFoundPage if it's provided to the Router.
ilonatommy Jun 12, 2025
3dfd293
Merge branch 'main' into fix-reexecution-priority-and-streaming-renering
ilonatommy Jun 12, 2025
82db52f
Update: client rendering the NotFoundPage if it's provided to the Rou…
ilonatommy Jun 12, 2025
a586b7f
Remove the streaming attribute.
ilonatommy Jun 13, 2025
60995b7
Fix the parameters order.
ilonatommy Jun 13, 2025
96287a4
unit test exception.
ilonatommy Jun 13, 2025
e8aab27
Remove not used code.
ilonatommy Jun 13, 2025
c6465d0
Fix POST rendering without disabling the "stop render" signal.
ilonatommy Jun 13, 2025
ab0d029
Unified SSR tests for POST and GET.
ilonatommy Jun 13, 2025
3d20fa9
Remove comments, test on CI
ilonatommy Jun 13, 2025
1caeaa6
Revert unnecessary changes.
ilonatommy Jun 13, 2025
fed0e5a
Clean up global interactivity tests.
ilonatommy Jun 13, 2025
1c6f97d
Trying to fix "Failed to load resource"
ilonatommy Jun 13, 2025
9d52115
Fix tests with custom not found page.
ilonatommy Jun 13, 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
5 changes: 2 additions & 3 deletions src/Components/Components/src/NavigationManager.cs
Original file line number Diff line number Diff line change
Expand Up @@ -54,15 +54,14 @@ 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;

// The URI. Always represented an absolute URI.
private string? _uri;
private bool _isInitialized;
internal string NotFoundPageRoute { get; set; } = string.Empty;

/// <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 @@ -212,7 +211,7 @@ private void NotFoundCore()
}
else
{
_notFound.Invoke(this, _notFoundEventArgs);
_notFound.Invoke(this, new NotFoundEventArgs(NotFoundPageRoute));
}
}

Expand Down
3 changes: 2 additions & 1 deletion src/Components/Components/src/PublicAPI.Unshipped.txt
Original file line number Diff line number Diff line change
Expand Up @@ -6,7 +6,8 @@ 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() -> void
Microsoft.AspNetCore.Components.Routing.NotFoundEventArgs.NotFoundEventArgs(string! url) -> void
Microsoft.AspNetCore.Components.Routing.NotFoundEventArgs.Path.get -> string!
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
12 changes: 10 additions & 2 deletions src/Components/Components/src/Routing/NotFoundEventArgs.cs
Original file line number Diff line number Diff line change
Expand Up @@ -8,9 +8,17 @@ namespace Microsoft.AspNetCore.Components.Routing;
/// </summary>
public sealed class NotFoundEventArgs : EventArgs
{
/// <summary>
/// Gets the path of NotFoundPage.
/// </summary>
public string Path { get; }

/// <summary>
/// Initializes a new instance of <see cref="NotFoundEventArgs" />.
/// </summary>
public NotFoundEventArgs()
{ }
public NotFoundEventArgs(string url)
{
Path = url;
}

}
6 changes: 6 additions & 0 deletions src/Components/Components/src/Routing/Router.cs
Original file line number Diff line number Diff line change
Expand Up @@ -155,6 +155,12 @@ public async Task SetParametersAsync(ParameterView parameters)
throw new InvalidOperationException($"The type {NotFoundPage.FullName} " +
$"does not have a {typeof(RouteAttribute).FullName} applied to it.");
}

var routeAttribute = (RouteAttribute)routeAttributes[0];
if (routeAttribute.Template != null)
{
NavigationManager.NotFoundPageRoute = routeAttribute.Template;
}
}

if (!_onNavigateCalled)
Expand Down
Original file line number Diff line number Diff line change
Expand Up @@ -111,13 +111,6 @@ await _renderer.InitializeStandardComponentServicesAsync(
ParameterView.Empty,
waitForQuiescence: result.IsPost || isErrorHandlerOrReExecuted);

bool avoidStartingResponse = hasStatusCodePage && !isReExecuted && context.Response.StatusCode == StatusCodes.Status404NotFound;
if (avoidStartingResponse)
{
// the request is going to be re-executed, we should avoid writing to the response
return;
}

Task quiesceTask;
if (!result.IsPost)
{
Expand Down
Original file line number Diff line number Diff line change
@@ -1,9 +1,11 @@
// Licensed to the .NET Foundation under one or more agreements.
// The .NET Foundation licenses this file to you under the MIT license.

using Microsoft.AspNetCore.Builder;
using Microsoft.AspNetCore.Components.Endpoints.Rendering;
using Microsoft.AspNetCore.Components.Rendering;
using Microsoft.AspNetCore.Components.RenderTree;
using Microsoft.AspNetCore.Components.Routing;
using Microsoft.AspNetCore.Http;
using Microsoft.AspNetCore.WebUtilities;
using Microsoft.Extensions.DependencyInjection;
Expand Down Expand Up @@ -77,21 +79,26 @@ private Task ReturnErrorResponse(string detailedMessage)
: Task.CompletedTask;
}

private async Task SetNotFoundResponseAsync(string baseUri)
internal async Task SetNotFoundResponseAsync(string baseUri, NotFoundEventArgs args)
{
if (_httpContext.Response.HasStarted)
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
string.Equals(_httpContext.Request.Method, "POST", StringComparison.OrdinalIgnoreCase))
{
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);
var notFoundUri = $"{baseUri}not-found";
HandleNavigationAfterResponseStarted(bufferWriter, _httpContext, notFoundUri);
HandleNotFoundAfterResponseStarted(bufferWriter, _httpContext, _notFoundUrl);
await bufferWriter.FlushAsync();
}
else
{
_httpContext.Response.StatusCode = StatusCodes.Status404NotFound;
_httpContext.Response.ContentType = null;
}

// When the application triggers a NotFound event, we continue rendering the current batch.
Expand All @@ -100,6 +107,22 @@ private async Task SetNotFoundResponseAsync(string baseUri)
SignalRendererToFinishRendering();
}

private string GetNotFoundUrl(string baseUri, NotFoundEventArgs args)
{
string path = args.Path;
if (string.IsNullOrEmpty(path))
{
var pathFormat = _httpContext.Items[nameof(StatusCodePagesOptions)] as string;
if (string.IsNullOrEmpty(pathFormat))
{
throw new InvalidOperationException("The NotFoundPage route must be specified or re-execution middleware has to be set to render NotFoundPage when the response has started.");
}

path = pathFormat;
}
return $"{baseUri}{path.TrimStart('/')}";
}

private async Task OnNavigateTo(string uri)
{
if (_httpContext.Response.HasStarted)
Expand Down
Original file line number Diff line number Diff line change
Expand Up @@ -226,10 +226,20 @@ private static void HandleExceptionAfterResponseStarted(HttpContext httpContext,
writer.Write("</template><blazor-ssr-end></blazor-ssr-end></blazor-ssr>");
}

private static void HandleNotFoundAfterResponseStarted(TextWriter writer, HttpContext httpContext, string notFoundUrl)
{
writer.Write("<blazor-ssr><template type=\"not-found\"");
WriteResponseTemplate(writer, httpContext, notFoundUrl);
}

private static void HandleNavigationAfterResponseStarted(TextWriter writer, HttpContext httpContext, string destinationUrl)
{
writer.Write("<blazor-ssr><template type=\"redirection\"");
WriteResponseTemplate(writer, httpContext, destinationUrl);
}

private static void WriteResponseTemplate(TextWriter writer, HttpContext httpContext, string destinationUrl)
{
if (string.Equals(httpContext.Request.Method, "POST", StringComparison.OrdinalIgnoreCase))
{
writer.Write(" from=\"form-post\"");
Expand Down
Original file line number Diff line number Diff line change
Expand Up @@ -52,6 +52,8 @@ internal partial class EndpointHtmlRenderer : StaticHtmlRenderer, IComponentPrer
// wait for the non-streaming tasks (these ones), then start streaming until full quiescence.
private readonly List<Task> _nonStreamingPendingTasks = new();

private string _notFoundUrl = string.Empty;

public EndpointHtmlRenderer(IServiceProvider serviceProvider, ILoggerFactory loggerFactory)
: base(serviceProvider, loggerFactory)
{
Expand All @@ -62,7 +64,7 @@ public EndpointHtmlRenderer(IServiceProvider serviceProvider, ILoggerFactory log

internal HttpContext? HttpContext => _httpContext;

private void SetHttpContext(HttpContext httpContext)
internal void SetHttpContext(HttpContext httpContext)
{
if (_httpContext is null)
{
Expand All @@ -85,7 +87,8 @@ internal async Task InitializeStandardComponentServicesAsync(

if (navigationManager != null)
{
navigationManager.OnNotFound += async (sender, args) => await SetNotFoundResponseAsync(navigationManager.BaseUri);
navigationManager.OnNotFound += async (sender, args) =>
await GetErrorHandledTask(SetNotFoundResponseAsync(navigationManager.BaseUri, args));
}

var authenticationStateProvider = httpContext.RequestServices.GetService<AuthenticationStateProvider>();
Expand Down
29 changes: 29 additions & 0 deletions src/Components/Endpoints/test/EndpointHtmlRendererTest.cs
Original file line number Diff line number Diff line change
@@ -1,9 +1,11 @@
// Licensed to the .NET Foundation under one or more agreements.
// The .NET Foundation licenses this file to you under the MIT license.

using System;
using System.Text.Encodings.Web;
using System.Text.Json;
using System.Text.RegularExpressions;
using Microsoft.AspNetCore.Builder;
using Microsoft.AspNetCore.Components.Endpoints.Forms;
using Microsoft.AspNetCore.Components.Endpoints.Tests.TestComponents;
using Microsoft.AspNetCore.Components.Forms;
Expand All @@ -12,6 +14,7 @@
using Microsoft.AspNetCore.Components.Reflection;
using Microsoft.AspNetCore.Components.Rendering;
using Microsoft.AspNetCore.Components.RenderTree;
using Microsoft.AspNetCore.Components.Routing;
using Microsoft.AspNetCore.Components.Test.Helpers;
using Microsoft.AspNetCore.Components.Web;
using Microsoft.AspNetCore.DataProtection;
Expand Down Expand Up @@ -934,6 +937,26 @@ await renderer.PrerenderComponentAsync(
Assert.Equal("http://localhost/redirect", ctx.Response.Headers.Location);
}

[Fact]
public async Task Renderer_WhenNoNotFoundPathProvided_Throws()
{
// Arrange
var httpContext = GetHttpContext();
var responseMock = new Mock<IHttpResponseFeature>();
responseMock.Setup(r => r.HasStarted).Returns(true);
responseMock.Setup(r => r.Headers).Returns(new HeaderDictionary());
httpContext.Features.Set(responseMock.Object);
var renderer = GetEndpointHtmlRenderer();
httpContext.Items[nameof(StatusCodePagesOptions)] = null; // simulate missing re-execution route

var exception = await Assert.ThrowsAsync<InvalidOperationException>(async () =>
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.";

Assert.Equal(expectedError, exception.Message);
}

[Fact]
public async Task CanRender_AsyncComponent()
{
Expand Down Expand Up @@ -1802,6 +1825,12 @@ protected override void ProcessPendingRender()
_rendererIsStopped = true;
base.SignalRendererToFinishRendering();
}

public async Task SetNotFoundResponseAsync(HttpContext httpContext, NotFoundEventArgs args)
{
SetHttpContext(httpContext);
await SetNotFoundResponseAsync(httpContext.Request.PathBase, args);
}
}

private HttpContext GetHttpContext(HttpContext context = null)
Expand Down
61 changes: 34 additions & 27 deletions src/Components/Web.JS/src/Rendering/StreamingRendering.ts
Original file line number Diff line number Diff line change
Expand Up @@ -47,33 +47,10 @@ class BlazorStreamingUpdate extends HTMLElement {
} else {
switch (node.getAttribute('type')) {
case 'redirection':
// We use 'replace' here because it's closest to the non-progressively-enhanced behavior, and will make the most sense
// if the async delay was very short, as the user would not perceive having been on the intermediate page.
const destinationUrl = toAbsoluteUri(node.content.textContent!);
const isFormPost = node.getAttribute('from') === 'form-post';
const isEnhancedNav = node.getAttribute('enhanced') === 'true';
if (isEnhancedNav && isWithinBaseUriSpace(destinationUrl)) {
// At this point the destinationUrl might be an opaque URL so we don't know whether it's internal/external or
// whether it's even going to the same URL we're currently on. So we don't know how to update the history.
// Defer that until the redirection is resolved by performEnhancedPageLoad.
const treatAsRedirectionFromMethod = isFormPost ? 'post' : 'get';
const fetchOptions = undefined;
performEnhancedPageLoad(destinationUrl, /* interceptedLink */ false, fetchOptions, treatAsRedirectionFromMethod);
} else {
if (isFormPost) {
// The URL is not yet updated. Push a whole new entry so that 'back' goes back to the pre-redirection location.
// WARNING: The following check to avoid duplicating history entries won't work if the redirection is to an opaque URL.
// We could change the server-side logic to return URLs in plaintext if they match the current request URL already,
// but it's arguably easier to understand that history non-duplication only works for enhanced nav, which is also the
// case for non-streaming responses.
if (destinationUrl !== location.href) {
location.assign(destinationUrl);
}
} else {
// The URL was already updated on the original link click. Replace so that 'back' goes to the pre-redirection location.
location.replace(destinationUrl);
}
}
redirect(node, true);
break;
case 'not-found':
redirect(node, false);
break;
case 'error':
// This is kind of brutal but matches what happens without progressive enhancement
Expand All @@ -86,6 +63,36 @@ class BlazorStreamingUpdate extends HTMLElement {
}
}

function redirect(node: HTMLTemplateElement, changeUrl: boolean): void {
// We use 'replace' here because it's closest to the non-progressively-enhanced behavior, and will make the most sense
// if the async delay was very short, as the user would not perceive having been on the intermediate page.
const destinationUrl = toAbsoluteUri(node.content.textContent!);
const isFormPost = node.getAttribute('from') === 'form-post';
const isEnhancedNav = node.getAttribute('enhanced') === 'true';
if (isEnhancedNav && isWithinBaseUriSpace(destinationUrl)) {
// At this point the destinationUrl might be an opaque URL so we don't know whether it's internal/external or
// whether it's even going to the same URL we're currently on. So we don't know how to update the history.
// Defer that until the redirection is resolved by performEnhancedPageLoad.
const treatAsRedirectionFromMethod = isFormPost ? 'post' : 'get';
const fetchOptions = undefined;
performEnhancedPageLoad(destinationUrl, /* interceptedLink */ false, fetchOptions, treatAsRedirectionFromMethod, changeUrl);
} else {
if (isFormPost) {
// The URL is not yet updated. Push a whole new entry so that 'back' goes back to the pre-redirection location.
// WARNING: The following check to avoid duplicating history entries won't work if the redirection is to an opaque URL.
// We could change the server-side logic to return URLs in plaintext if they match the current request URL already,
// but it's arguably easier to understand that history non-duplication only works for enhanced nav, which is also the
// case for non-streaming responses.
if (destinationUrl !== location.href) {
location.assign(destinationUrl);
}
} else {
// The URL was already updated on the original link click. Replace so that 'back' goes to the pre-redirection location.
location.replace(destinationUrl);
}
}
}

function insertStreamingContentIntoDocument(componentIdAsString: string, docFrag: DocumentFragment): void {
const markers = findStreamingMarkers(componentIdAsString);
if (markers) {
Expand Down
12 changes: 7 additions & 5 deletions src/Components/Web.JS/src/Services/NavigationEnhancement.ts
Original file line number Diff line number Diff line change
Expand Up @@ -192,7 +192,7 @@ function onDocumentSubmit(event: SubmitEvent) {
}
}

export async function performEnhancedPageLoad(internalDestinationHref: string, interceptedLink: boolean, fetchOptions?: RequestInit, treatAsRedirectionFromMethod?: 'get' | 'post') {
export async function performEnhancedPageLoad(internalDestinationHref: string, interceptedLink: boolean, fetchOptions?: RequestInit, treatAsRedirectionFromMethod?: 'get' | 'post', changeUrl: boolean = true) {
performingEnhancedPageLoad = true;

// First, stop any preceding enhanced page load
Expand Down Expand Up @@ -257,7 +257,7 @@ export async function performEnhancedPageLoad(internalDestinationHref: string, i
// For 301/302/etc redirections to internal URLs, the browser will already have followed the chain of redirections
// to the end, and given us the final content. We do still need to update the current URL to match the final location,
// then let the rest of enhanced nav logic run to patch the new content into the DOM.
if (response.redirected || treatAsRedirectionFromMethod) {
if (changeUrl && (response.redirected || treatAsRedirectionFromMethod)) {
const treatAsGet = treatAsRedirectionFromMethod ? (treatAsRedirectionFromMethod === 'get') : isGetRequest;
if (treatAsGet) {
// For gets, the intermediate (redirecting) URL is already in the address bar, so we have to use 'replace'
Expand All @@ -274,12 +274,12 @@ export async function performEnhancedPageLoad(internalDestinationHref: string, i

// For enhanced nav redirecting to an external URL, we'll get a special Blazor-specific redirection command
const externalRedirectionUrl = response.headers.get('blazor-enhanced-nav-redirect-location');
if (externalRedirectionUrl) {
if (changeUrl && externalRedirectionUrl) {
location.replace(externalRedirectionUrl);
return;
}

if (!response.redirected && !isGetRequest && isSuccessResponse) {
if (changeUrl && !response.redirected && !isGetRequest && isSuccessResponse) {
// If this is the result of a form post that didn't trigger a redirection.
if (!isForSamePath(response.url, currentContentUrl)) {
// In this case we don't want to push the currentContentUrl to the history stack because we don't know if this is a location
Expand All @@ -296,7 +296,9 @@ export async function performEnhancedPageLoad(internalDestinationHref: string, i
}

// Set the currentContentUrl to the location of the last completed navigation.
currentContentUrl = response.url;
if (changeUrl) {
currentContentUrl = response.url;
}

const responseContentType = response.headers.get('content-type');
if (responseContentType?.startsWith('text/html') && initialContent) {
Expand Down
Loading
Loading