Skip to content

[Blazor] Error page support for Server Side Rendered Razor Component Applications #50550

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

Merged
merged 22 commits into from
Sep 20, 2023
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
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
Original file line number Diff line number Diff line change
Expand Up @@ -49,6 +49,7 @@
<Reference Include="Microsoft.AspNetCore.Antiforgery" />
<Reference Include="Microsoft.AspNetCore.Components.Authorization" />
<Reference Include="Microsoft.AspNetCore.Components.Web" />
<Reference Include="Microsoft.AspNetCore.Diagnostics.Abstractions" />
<Reference Include="Microsoft.AspNetCore.DataProtection.Extensions" />
<Reference Include="Microsoft.AspNetCore.Hosting.Abstractions" />
<Reference Include="Microsoft.AspNetCore.Http" />
Expand Down
47 changes: 32 additions & 15 deletions src/Components/Endpoints/src/RazorComponentEndpointInvoker.cs
Original file line number Diff line number Diff line change
Expand Up @@ -7,6 +7,7 @@
using System.Text.Encodings.Web;
using Microsoft.AspNetCore.Antiforgery;
using Microsoft.AspNetCore.Components.Endpoints.Rendering;
using Microsoft.AspNetCore.Diagnostics;
using Microsoft.AspNetCore.Http;
using Microsoft.AspNetCore.WebUtilities;
using Microsoft.Extensions.DependencyInjection;
Expand Down Expand Up @@ -34,15 +35,20 @@ public Task Render(HttpContext context)
private async Task RenderComponentCore(HttpContext context)
{
context.Response.ContentType = RazorComponentResultExecutor.DefaultContentType;
_renderer.InitializeStreamingRenderingFraming(context);
var isErrorHandler = context.Features.Get<IExceptionHandlerFeature>() is not null;
if (isErrorHandler)
{
Log.InteractivityDisabledForErrorHandling(_logger);
}
_renderer.InitializeStreamingRenderingFraming(context, isErrorHandler);
EndpointHtmlRenderer.MarkAsAllowingEnhancedNavigation(context);

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

var rootComponent = endpoint.Metadata.GetRequiredMetadata<RootComponentMetadata>().Type;
var pageComponent = endpoint.Metadata.GetRequiredMetadata<ComponentTypeMetadata>().Type;

Log.BeginRenderComponent(_logger, rootComponent.Name, pageComponent.Name);
Log.BeginRenderRootComponent(_logger, rootComponent.Name, pageComponent.Name);

// Metadata controls whether we require antiforgery protection for this endpoint or we should skip it.
// The default for razor component endpoints is to require the metadata, but it can be overriden by
Expand Down Expand Up @@ -83,7 +89,7 @@ await EndpointHtmlRenderer.InitializeStandardComponentServicesAsync(
context,
rootComponent,
ParameterView.Empty,
waitForQuiescence: result.IsPost);
waitForQuiescence: result.IsPost || isErrorHandler);

Task quiesceTask;
if (!result.IsPost)
Expand Down Expand Up @@ -122,8 +128,11 @@ await EndpointHtmlRenderer.InitializeStandardComponentServicesAsync(
}

// Emit comment containing state.
var componentStateHtmlContent = await _renderer.PrerenderPersistedStateAsync(context);
componentStateHtmlContent.WriteTo(bufferWriter, HtmlEncoder.Default);
if (!isErrorHandler)
{
var componentStateHtmlContent = await _renderer.PrerenderPersistedStateAsync(context);
componentStateHtmlContent.WriteTo(bufferWriter, HtmlEncoder.Default);
}

// Invoke FlushAsync to ensure any buffered content is asynchronously written to the underlying
// response asynchronously. In the absence of this line, the buffer gets synchronously written to the
Expand All @@ -133,8 +142,13 @@ await EndpointHtmlRenderer.InitializeStandardComponentServicesAsync(

private async Task<RequestValidationState> ValidateRequestAsync(HttpContext context, IAntiforgery? antiforgery)
{
var isPost = HttpMethods.IsPost(context.Request.Method);
if (isPost)
var processPost = HttpMethods.IsPost(context.Request.Method) &&
// Disable POST functionality during exception handling.
// 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;

if (processPost)
{
var valid = false;
// Respect the token validation done by the middleware _if_ it has been set, otherwise
Expand Down Expand Up @@ -187,7 +201,7 @@ private async Task<RequestValidationState> ValidateRequestAsync(HttpContext cont
await context.Request.ReadFormAsync();

var handler = GetFormHandler(context, out var isBadRequest);
return new(valid && !isBadRequest, isPost, handler);
return new(valid && !isBadRequest, processPost, handler);
}

return RequestValidationState.ValidNonPostRequest;
Expand Down Expand Up @@ -231,22 +245,25 @@ private string GetDebuggerDisplay()

public static partial class Log
{
[LoggerMessage(1, LogLevel.Debug, "Begin render root component '{componentType}' with page '{pageType}'.", EventName = "BeginRenderRootComponent")]
public static partial void BeginRenderComponent(ILogger<RazorComponentEndpointInvoker> logger, string componentType, string pageType);
[LoggerMessage(1, LogLevel.Debug, "Begin render root component '{componentType}' with page '{pageType}'.", EventName = nameof(BeginRenderRootComponent))]
public static partial void BeginRenderRootComponent(ILogger<RazorComponentEndpointInvoker> logger, string componentType, string pageType);

[LoggerMessage(2, LogLevel.Debug, "The antiforgery middleware already failed to validate the current token.", EventName = "MiddlewareAntiforgeryValidationFailed")]
[LoggerMessage(2, LogLevel.Debug, "The antiforgery middleware already failed to validate the current token.", EventName = nameof(MiddlewareAntiforgeryValidationFailed))]
public static partial void MiddlewareAntiforgeryValidationFailed(ILogger<RazorComponentEndpointInvoker> logger);

[LoggerMessage(3, LogLevel.Debug, "The antiforgery middleware already succeeded to validate the current token.", EventName = "MiddlewareAntiforgeryValidationSucceeded")]
[LoggerMessage(3, LogLevel.Debug, "The antiforgery middleware already succeeded to validate the current token.", EventName = nameof(MiddlewareAntiforgeryValidationSucceeded))]
public static partial void MiddlewareAntiforgeryValidationSucceeded(ILogger<RazorComponentEndpointInvoker> logger);

[LoggerMessage(4, LogLevel.Debug, "The endpoint disabled antiforgery token validation.", EventName = "EndpointAntiforgeryValidationDisabled")]
[LoggerMessage(4, LogLevel.Debug, "The endpoint disabled antiforgery token validation.", EventName = nameof(EndpointAntiforgeryValidationDisabled))]
public static partial void EndpointAntiforgeryValidationDisabled(ILogger<RazorComponentEndpointInvoker> logger);

[LoggerMessage(5, LogLevel.Information, "Antiforgery token validation failed for the current request.", EventName = "EndpointAntiforgeryValidationFailed")]
[LoggerMessage(5, LogLevel.Information, "Antiforgery token validation failed for the current request.", EventName = nameof(EndpointAntiforgeryValidationFailed))]
public static partial void EndpointAntiforgeryValidationFailed(ILogger<RazorComponentEndpointInvoker> logger);

[LoggerMessage(6, LogLevel.Debug, "Antiforgery token validation succeeded for the current request.", EventName = "EndpointAntiforgeryValidationSucceeded")]
[LoggerMessage(6, LogLevel.Debug, "Antiforgery token validation succeeded for the current request.", EventName = nameof(EndpointAntiforgeryValidationSucceeded))]
public static partial void EndpointAntiforgeryValidationSucceeded(ILogger<RazorComponentEndpointInvoker> logger);

[LoggerMessage(7, LogLevel.Debug, "Error handling in progress. Interactive components are not enabled.", EventName = nameof(InteractivityDisabledForErrorHandling))]
public static partial void InteractivityDisabledForErrorHandling(ILogger<RazorComponentEndpointInvoker> logger);
}
}
Original file line number Diff line number Diff line change
Expand Up @@ -17,6 +17,11 @@ internal partial class EndpointHtmlRenderer

protected override IComponent ResolveComponentForRenderMode([DynamicallyAccessedMembers(Component)] Type componentType, int? parentComponentId, IComponentActivator componentActivator, IComponentRenderMode renderMode)
{
if (_isHandlingErrors)
{
// Ignore the render mode boundary in error scenarios.
return componentActivator.CreateInstance(componentType);
}
var closestRenderModeBoundary = parentComponentId.HasValue
? GetClosestRenderModeBoundary(parentComponentId.Value)
: null;
Expand Down
Original file line number Diff line number Diff line change
Expand Up @@ -19,9 +19,11 @@ internal partial class EndpointHtmlRenderer
private TextWriter? _streamingUpdatesWriter;
private HashSet<int>? _visitedComponentIdsInCurrentStreamingBatch;
private string? _ssrFramingCommentMarkup;
private bool _isHandlingErrors;

public void InitializeStreamingRenderingFraming(HttpContext httpContext)
public void InitializeStreamingRenderingFraming(HttpContext httpContext, bool isErrorHandler)
{
_isHandlingErrors = isErrorHandler;
if (IsProgressivelyEnhancedNavigation(httpContext.Request))
{
var id = Guid.NewGuid().ToString();
Expand Down
Original file line number Diff line number Diff line change
Expand Up @@ -5,6 +5,7 @@
using System.Buffers;
using System.Text;
using System.Text.Encodings.Web;
using Microsoft.AspNetCore.Diagnostics;
using Microsoft.AspNetCore.Http;
using Microsoft.AspNetCore.WebUtilities;
using Microsoft.Extensions.DependencyInjection;
Expand Down Expand Up @@ -46,7 +47,8 @@ private static Task RenderComponentToResponse(
var endpointHtmlRenderer = httpContext.RequestServices.GetRequiredService<EndpointHtmlRenderer>();
return endpointHtmlRenderer.Dispatcher.InvokeAsync(async () =>
{
endpointHtmlRenderer.InitializeStreamingRenderingFraming(httpContext);
var isErrorHandler = httpContext.Features.Get<IExceptionHandlerFeature>() is not null;
endpointHtmlRenderer.InitializeStreamingRenderingFraming(httpContext, isErrorHandler);
EndpointHtmlRenderer.MarkAsAllowingEnhancedNavigation(httpContext);

// We could pool these dictionary instances if we wanted, and possibly even the ParameterView
Expand Down
35 changes: 35 additions & 0 deletions src/Components/Samples/BlazorUnitedApp/Pages/Error.razor
Original file line number Diff line number Diff line change
@@ -0,0 +1,35 @@
@page "/Error"
@using System.Diagnostics

<PageTitle>Error</PageTitle>

<h1 class="text-danger">Error.</h1>
<h2 class="text-danger">An error occurred while processing your request.</h2>

@if (ShowRequestId)
{
<p>
<strong>Request ID:</strong> <code>@RequestId</code>
</p>
}

<h3>Development Mode</h3>
<p>
Swapping to <strong>Development</strong> environment will display more detailed information about the error that occurred.
</p>
<p>
<strong>The Development environment shouldn't be enabled for deployed applications.</strong>
It can result in displaying sensitive information from exceptions to end users.
For local debugging, enable the <strong>Development</strong> environment by setting the <strong>ASPNETCORE_ENVIRONMENT</strong> environment variable to <strong>Development</strong>
and restarting the app.
</p>

@code{
[CascadingParameter] public HttpContext? HttpContext { get; set; }

public string? RequestId { get; set; }
public bool ShowRequestId => !string.IsNullOrEmpty(RequestId);

protected override void OnInitialized() =>
RequestId = Activity.Current?.Id ?? HttpContext?.TraceIdentifier;
}
Original file line number Diff line number Diff line change
@@ -0,0 +1,45 @@
// 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.Collections.Generic;
using System.Linq;
using System.Text;
using System.Threading.Tasks;
using Microsoft.AspNetCore.Components.E2ETest.Infrastructure.ServerFixtures;
using Microsoft.AspNetCore.Components.E2ETest.Infrastructure;
using TestServer;
using Components.TestServer.RazorComponents;
using Microsoft.AspNetCore.E2ETesting;
using Xunit.Abstractions;
using OpenQA.Selenium;

namespace Microsoft.AspNetCore.Components.E2ETests.ServerRenderingTests;

public class ErrorHandlingTest(BrowserFixture browserFixture, BasicTestAppServerSiteFixture<RazorComponentEndpointsStartup<App>> serverFixture, ITestOutputHelper output)
: ServerTestBase<BasicTestAppServerSiteFixture<RazorComponentEndpointsStartup<App>>>(browserFixture, serverFixture, output)
{

[Fact]
public async Task RendersExceptionFromComponent()
{
GoTo("Throws?suppress-autostart=true");

Browser.Equal("Error", () => Browser.Title);

Assert.Collection(
Browser.FindElements(By.CssSelector(".text-danger")),
item => Assert.Equal("Error.", item.Text),
item => Assert.Equal("An error occurred while processing your request.", item.Text));
Browser.Equal("False", () => Browser.FindElement(By.Id("is-interactive-server")).Text);
Browser.Click(By.Id("call-blazor-start"));
await Task.Delay(3000);
Browser.Exists(By.Id("blazor-started"));
Browser.Equal("False", () => Browser.FindElement(By.Id("is-interactive-server")).Text);
}

private void GoTo(string relativePath)
{
Navigate($"{ServerPathBase}/{relativePath}");
}
}
Original file line number Diff line number Diff line change
Expand Up @@ -1151,15 +1151,13 @@ private void AssertHasInternalServerError(bool suppressedEnhancedNavigation, boo
{
Browser.True(() => Browser.FindElement(By.TagName("html")).Text.Contains("There was an unhandled exception on the current request"));
}
else if (suppressedEnhancedNavigation)
{
// Chrome's built-in error UI for a 500 response when there's no response content
Browser.Exists(By.Id("main-frame-error"));
}
else
{
// The UI generated by enhanced nav when there's no response content
Browser.Contains("Error: 500", () => Browser.Exists(By.TagName("html")).Text);
// Displays the error page from the exception handler
Assert.Collection(
Browser.FindElements(By.CssSelector(".text-danger")),
item => Assert.Equal("Error.", item.Text),
item => Assert.Equal("An error occurred while processing your request.", item.Text));
}
}

Expand Down
Original file line number Diff line number Diff line change
Expand Up @@ -51,6 +51,11 @@ public void Configure(IApplicationBuilder app, IWebHostEnvironment env)

app.Map("/subdir", app =>
{
if (!env.IsDevelopment())
{
app.UseExceptionHandler("/Error", createScopeForErrors: true);
}

app.UseStaticFiles();
app.UseRouting();
UseFakeAuthState(app);
Expand Down
Original file line number Diff line number Diff line change
Expand Up @@ -65,6 +65,11 @@
if (useLongWebAssemblyTimeout) {
Blazor._internal.loadWebAssemblyQuicklyTimeout = 10000000;
}
}).then(() => {
const startedParagraph = document.createElement('p');
startedParagraph.id = 'blazor-started';
startedParagraph.style = 'display: none;';
document.body.appendChild(startedParagraph);
});
}

Expand Down
Original file line number Diff line number Diff line change
@@ -0,0 +1,37 @@
@page "/Error"
@layout ErrorLayout
@using System.Diagnostics

<PageTitle>Error</PageTitle>

<h1 class="text-danger">Error.</h1>
<h2 class="text-danger">An error occurred while processing your request.</h2>

@if (ShowRequestId)
{
<p>
<strong>Request ID:</strong> <code>@RequestId</code>
</p>
}

<h3>Development Mode</h3>
<p>
Swapping to <strong>Development</strong> environment will display more detailed information about the error that occurred.
</p>
<p>
<strong>The Development environment shouldn't be enabled for deployed applications.</strong>
It can result in displaying sensitive information from exceptions to end users.
For local debugging, enable the <strong>Development</strong> environment by setting the <strong>ASPNETCORE_ENVIRONMENT</strong> environment variable to <strong>Development</strong>
and restarting the app.
</p>

@code {
[CascadingParameter]
public HttpContext? HttpContext { get; set; }

private string? RequestId { get; set; }
private bool ShowRequestId => !string.IsNullOrEmpty(RequestId);

protected override void OnInitialized() =>
RequestId = Activity.Current?.Id ?? HttpContext?.TraceIdentifier;
}
Original file line number Diff line number Diff line change
@@ -0,0 +1,7 @@
@inherits Microsoft.AspNetCore.Components.LayoutComponentBase

<ServerInteractiveCounter />

<div>
@Body
</div>
Original file line number Diff line number Diff line change
@@ -0,0 +1,11 @@
@page "/Throws"

<PageTitle>Page that throws</PageTitle>

<p>This page throws during OnInitialize to showcase error handling via UseExceptionHandler.</p>

@code
{
protected override void OnInitialized() =>
throw new InvalidOperationException("This page throws on purpose.");
}
Original file line number Diff line number Diff line change
Expand Up @@ -47,6 +47,25 @@ public static IApplicationBuilder UseExceptionHandler(this IApplicationBuilder a
});
}

/// <summary>
/// Adds a middleware to the pipeline that will catch exceptions, log them, reset the request path, and re-execute the request.
/// The request will not be re-executed if the response has already started.
/// </summary>
/// <param name="app">The <see cref="IApplicationBuilder"/>.</param>
/// <param name="errorHandlingPath">The <see cref="string"/> path to the endpoint that will handle the exception.</param>
/// <param name="createScopeForErrors">Whether or not to create a new <see cref="IServiceProvider"/> scope.</param>
/// <returns></returns>
public static IApplicationBuilder UseExceptionHandler(this IApplicationBuilder app, string errorHandlingPath, bool createScopeForErrors)
{
ArgumentNullException.ThrowIfNull(app);

return app.UseExceptionHandler(new ExceptionHandlerOptions
{
ExceptionHandlingPath = new PathString(errorHandlingPath),
CreateScopeForErrors = createScopeForErrors
});
}

/// <summary>
/// Adds a middleware to the pipeline that will catch exceptions, log them, and re-execute the request in an alternate pipeline.
/// The request will not be re-executed if the response has already started.
Expand Down
Loading