Skip to content

Commit a0c7939

Browse files
authored
[Blazor] Error page support for Server Side Rendered Razor Component Applications (#50550)
## Description Adds support for Error pages to Blazor Web application. * Includes a small change to the ExceptionHandler middleware to support Blazor Web scenarios. * Includes a change in Blazor Web to account for error handling scenarios and disable streaming rendering and interactivity. * Includes changes to the Blazor Web template to add an Error page similar to the one in MVC and Razor pages templates. Fixes #49853, #49854 ## Customer Impact * Customers won't be able to see errors in Blazor Web Applications without having to rely on MVC or Razor Pages for it, which adds significant complexity to their setup. ## Regression? - [ ] Yes - [X] No ## Risk - [ ] High - [X] Medium - [ ] Low * If customers hit any issues, we will be able to tell them to simply remove the ErrorPage.razor page from their projects. Luckily, we will have a little bit of time to address any critical feedback in case such comes up. As of right now, this change has been validated and it works. ## Verification - [X] Manual (required) - [X] Automated ## Packaging changes reviewed? - [ ] Yes - [ ] No - [X] N/A
1 parent e1f66a2 commit a0c7939

File tree

20 files changed

+284
-25
lines changed

20 files changed

+284
-25
lines changed

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

Lines changed: 1 addition & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -49,6 +49,7 @@
4949
<Reference Include="Microsoft.AspNetCore.Antiforgery" />
5050
<Reference Include="Microsoft.AspNetCore.Components.Authorization" />
5151
<Reference Include="Microsoft.AspNetCore.Components.Web" />
52+
<Reference Include="Microsoft.AspNetCore.Diagnostics.Abstractions" />
5253
<Reference Include="Microsoft.AspNetCore.DataProtection.Extensions" />
5354
<Reference Include="Microsoft.AspNetCore.Hosting.Abstractions" />
5455
<Reference Include="Microsoft.AspNetCore.Http" />

src/Components/Endpoints/src/RazorComponentEndpointInvoker.cs

Lines changed: 32 additions & 15 deletions
Original file line numberDiff line numberDiff line change
@@ -7,6 +7,7 @@
77
using System.Text.Encodings.Web;
88
using Microsoft.AspNetCore.Antiforgery;
99
using Microsoft.AspNetCore.Components.Endpoints.Rendering;
10+
using Microsoft.AspNetCore.Diagnostics;
1011
using Microsoft.AspNetCore.Http;
1112
using Microsoft.AspNetCore.WebUtilities;
1213
using Microsoft.Extensions.DependencyInjection;
@@ -34,15 +35,20 @@ public Task Render(HttpContext context)
3435
private async Task RenderComponentCore(HttpContext context)
3536
{
3637
context.Response.ContentType = RazorComponentResultExecutor.DefaultContentType;
37-
_renderer.InitializeStreamingRenderingFraming(context);
38+
var isErrorHandler = context.Features.Get<IExceptionHandlerFeature>() is not null;
39+
if (isErrorHandler)
40+
{
41+
Log.InteractivityDisabledForErrorHandling(_logger);
42+
}
43+
_renderer.InitializeStreamingRenderingFraming(context, isErrorHandler);
3844
EndpointHtmlRenderer.MarkAsAllowingEnhancedNavigation(context);
3945

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

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

45-
Log.BeginRenderComponent(_logger, rootComponent.Name, pageComponent.Name);
51+
Log.BeginRenderRootComponent(_logger, rootComponent.Name, pageComponent.Name);
4652

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

8894
Task quiesceTask;
8995
if (!result.IsPost)
@@ -122,8 +128,11 @@ await EndpointHtmlRenderer.InitializeStandardComponentServicesAsync(
122128
}
123129

124130
// Emit comment containing state.
125-
var componentStateHtmlContent = await _renderer.PrerenderPersistedStateAsync(context);
126-
componentStateHtmlContent.WriteTo(bufferWriter, HtmlEncoder.Default);
131+
if (!isErrorHandler)
132+
{
133+
var componentStateHtmlContent = await _renderer.PrerenderPersistedStateAsync(context);
134+
componentStateHtmlContent.WriteTo(bufferWriter, HtmlEncoder.Default);
135+
}
127136

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

134143
private async Task<RequestValidationState> ValidateRequestAsync(HttpContext context, IAntiforgery? antiforgery)
135144
{
136-
var isPost = HttpMethods.IsPost(context.Request.Method);
137-
if (isPost)
145+
var processPost = HttpMethods.IsPost(context.Request.Method) &&
146+
// Disable POST functionality during exception handling.
147+
// The exception handler middleware will not update the request method, and we don't
148+
// want to run the form handling logic against the error page.
149+
context.Features.Get<IExceptionHandlerFeature>() == null;
150+
151+
if (processPost)
138152
{
139153
var valid = false;
140154
// Respect the token validation done by the middleware _if_ it has been set, otherwise
@@ -187,7 +201,7 @@ private async Task<RequestValidationState> ValidateRequestAsync(HttpContext cont
187201
await context.Request.ReadFormAsync();
188202

189203
var handler = GetFormHandler(context, out var isBadRequest);
190-
return new(valid && !isBadRequest, isPost, handler);
204+
return new(valid && !isBadRequest, processPost, handler);
191205
}
192206

193207
return RequestValidationState.ValidNonPostRequest;
@@ -231,22 +245,25 @@ private string GetDebuggerDisplay()
231245

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

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

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

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

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

249-
[LoggerMessage(6, LogLevel.Debug, "Antiforgery token validation succeeded for the current request.", EventName = "EndpointAntiforgeryValidationSucceeded")]
263+
[LoggerMessage(6, LogLevel.Debug, "Antiforgery token validation succeeded for the current request.", EventName = nameof(EndpointAntiforgeryValidationSucceeded))]
250264
public static partial void EndpointAntiforgeryValidationSucceeded(ILogger<RazorComponentEndpointInvoker> logger);
265+
266+
[LoggerMessage(7, LogLevel.Debug, "Error handling in progress. Interactive components are not enabled.", EventName = nameof(InteractivityDisabledForErrorHandling))]
267+
public static partial void InteractivityDisabledForErrorHandling(ILogger<RazorComponentEndpointInvoker> logger);
251268
}
252269
}

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

Lines changed: 5 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -17,6 +17,11 @@ internal partial class EndpointHtmlRenderer
1717

1818
protected override IComponent ResolveComponentForRenderMode([DynamicallyAccessedMembers(Component)] Type componentType, int? parentComponentId, IComponentActivator componentActivator, IComponentRenderMode renderMode)
1919
{
20+
if (_isHandlingErrors)
21+
{
22+
// Ignore the render mode boundary in error scenarios.
23+
return componentActivator.CreateInstance(componentType);
24+
}
2025
var closestRenderModeBoundary = parentComponentId.HasValue
2126
? GetClosestRenderModeBoundary(parentComponentId.Value)
2227
: null;

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

Lines changed: 3 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -19,9 +19,11 @@ internal partial class EndpointHtmlRenderer
1919
private TextWriter? _streamingUpdatesWriter;
2020
private HashSet<int>? _visitedComponentIdsInCurrentStreamingBatch;
2121
private string? _ssrFramingCommentMarkup;
22+
private bool _isHandlingErrors;
2223

23-
public void InitializeStreamingRenderingFraming(HttpContext httpContext)
24+
public void InitializeStreamingRenderingFraming(HttpContext httpContext, bool isErrorHandler)
2425
{
26+
_isHandlingErrors = isErrorHandler;
2527
if (IsProgressivelyEnhancedNavigation(httpContext.Request))
2628
{
2729
var id = Guid.NewGuid().ToString();

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

Lines changed: 3 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -5,6 +5,7 @@
55
using System.Buffers;
66
using System.Text;
77
using System.Text.Encodings.Web;
8+
using Microsoft.AspNetCore.Diagnostics;
89
using Microsoft.AspNetCore.Http;
910
using Microsoft.AspNetCore.WebUtilities;
1011
using Microsoft.Extensions.DependencyInjection;
@@ -46,7 +47,8 @@ private static Task RenderComponentToResponse(
4647
var endpointHtmlRenderer = httpContext.RequestServices.GetRequiredService<EndpointHtmlRenderer>();
4748
return endpointHtmlRenderer.Dispatcher.InvokeAsync(async () =>
4849
{
49-
endpointHtmlRenderer.InitializeStreamingRenderingFraming(httpContext);
50+
var isErrorHandler = httpContext.Features.Get<IExceptionHandlerFeature>() is not null;
51+
endpointHtmlRenderer.InitializeStreamingRenderingFraming(httpContext, isErrorHandler);
5052
EndpointHtmlRenderer.MarkAsAllowingEnhancedNavigation(httpContext);
5153

5254
// We could pool these dictionary instances if we wanted, and possibly even the ParameterView
Lines changed: 35 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,35 @@
1+
@page "/Error"
2+
@using System.Diagnostics
3+
4+
<PageTitle>Error</PageTitle>
5+
6+
<h1 class="text-danger">Error.</h1>
7+
<h2 class="text-danger">An error occurred while processing your request.</h2>
8+
9+
@if (ShowRequestId)
10+
{
11+
<p>
12+
<strong>Request ID:</strong> <code>@RequestId</code>
13+
</p>
14+
}
15+
16+
<h3>Development Mode</h3>
17+
<p>
18+
Swapping to <strong>Development</strong> environment will display more detailed information about the error that occurred.
19+
</p>
20+
<p>
21+
<strong>The Development environment shouldn't be enabled for deployed applications.</strong>
22+
It can result in displaying sensitive information from exceptions to end users.
23+
For local debugging, enable the <strong>Development</strong> environment by setting the <strong>ASPNETCORE_ENVIRONMENT</strong> environment variable to <strong>Development</strong>
24+
and restarting the app.
25+
</p>
26+
27+
@code{
28+
[CascadingParameter] public HttpContext? HttpContext { get; set; }
29+
30+
public string? RequestId { get; set; }
31+
public bool ShowRequestId => !string.IsNullOrEmpty(RequestId);
32+
33+
protected override void OnInitialized() =>
34+
RequestId = Activity.Current?.Id ?? HttpContext?.TraceIdentifier;
35+
}
Lines changed: 45 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,45 @@
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+
using System;
5+
using System.Collections.Generic;
6+
using System.Linq;
7+
using System.Text;
8+
using System.Threading.Tasks;
9+
using Microsoft.AspNetCore.Components.E2ETest.Infrastructure.ServerFixtures;
10+
using Microsoft.AspNetCore.Components.E2ETest.Infrastructure;
11+
using TestServer;
12+
using Components.TestServer.RazorComponents;
13+
using Microsoft.AspNetCore.E2ETesting;
14+
using Xunit.Abstractions;
15+
using OpenQA.Selenium;
16+
17+
namespace Microsoft.AspNetCore.Components.E2ETests.ServerRenderingTests;
18+
19+
public class ErrorHandlingTest(BrowserFixture browserFixture, BasicTestAppServerSiteFixture<RazorComponentEndpointsStartup<App>> serverFixture, ITestOutputHelper output)
20+
: ServerTestBase<BasicTestAppServerSiteFixture<RazorComponentEndpointsStartup<App>>>(browserFixture, serverFixture, output)
21+
{
22+
23+
[Fact]
24+
public async Task RendersExceptionFromComponent()
25+
{
26+
GoTo("Throws?suppress-autostart=true");
27+
28+
Browser.Equal("Error", () => Browser.Title);
29+
30+
Assert.Collection(
31+
Browser.FindElements(By.CssSelector(".text-danger")),
32+
item => Assert.Equal("Error.", item.Text),
33+
item => Assert.Equal("An error occurred while processing your request.", item.Text));
34+
Browser.Equal("False", () => Browser.FindElement(By.Id("is-interactive-server")).Text);
35+
Browser.Click(By.Id("call-blazor-start"));
36+
await Task.Delay(3000);
37+
Browser.Exists(By.Id("blazor-started"));
38+
Browser.Equal("False", () => Browser.FindElement(By.Id("is-interactive-server")).Text);
39+
}
40+
41+
private void GoTo(string relativePath)
42+
{
43+
Navigate($"{ServerPathBase}/{relativePath}");
44+
}
45+
}

src/Components/test/E2ETest/ServerRenderingTests/FormHandlingTests/FormWithParentBindingContextTest.cs

Lines changed: 5 additions & 7 deletions
Original file line numberDiff line numberDiff line change
@@ -1151,15 +1151,13 @@ private void AssertHasInternalServerError(bool suppressedEnhancedNavigation, boo
11511151
{
11521152
Browser.True(() => Browser.FindElement(By.TagName("html")).Text.Contains("There was an unhandled exception on the current request"));
11531153
}
1154-
else if (suppressedEnhancedNavigation)
1155-
{
1156-
// Chrome's built-in error UI for a 500 response when there's no response content
1157-
Browser.Exists(By.Id("main-frame-error"));
1158-
}
11591154
else
11601155
{
1161-
// The UI generated by enhanced nav when there's no response content
1162-
Browser.Contains("Error: 500", () => Browser.Exists(By.TagName("html")).Text);
1156+
// Displays the error page from the exception handler
1157+
Assert.Collection(
1158+
Browser.FindElements(By.CssSelector(".text-danger")),
1159+
item => Assert.Equal("Error.", item.Text),
1160+
item => Assert.Equal("An error occurred while processing your request.", item.Text));
11631161
}
11641162
}
11651163

src/Components/test/testassets/Components.TestServer/RazorComponentEndpointsStartup.cs

Lines changed: 5 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -51,6 +51,11 @@ public void Configure(IApplicationBuilder app, IWebHostEnvironment env)
5151

5252
app.Map("/subdir", app =>
5353
{
54+
if (!env.IsDevelopment())
55+
{
56+
app.UseExceptionHandler("/Error", createScopeForErrors: true);
57+
}
58+
5459
app.UseStaticFiles();
5560
app.UseRouting();
5661
UseFakeAuthState(app);

src/Components/test/testassets/Components.TestServer/RazorComponents/App.razor

Lines changed: 5 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -65,6 +65,11 @@
6565
if (useLongWebAssemblyTimeout) {
6666
Blazor._internal.loadWebAssemblyQuicklyTimeout = 10000000;
6767
}
68+
}).then(() => {
69+
const startedParagraph = document.createElement('p');
70+
startedParagraph.id = 'blazor-started';
71+
startedParagraph.style = 'display: none;';
72+
document.body.appendChild(startedParagraph);
6873
});
6974
}
7075
Lines changed: 37 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,37 @@
1+
@page "/Error"
2+
@layout ErrorLayout
3+
@using System.Diagnostics
4+
5+
<PageTitle>Error</PageTitle>
6+
7+
<h1 class="text-danger">Error.</h1>
8+
<h2 class="text-danger">An error occurred while processing your request.</h2>
9+
10+
@if (ShowRequestId)
11+
{
12+
<p>
13+
<strong>Request ID:</strong> <code>@RequestId</code>
14+
</p>
15+
}
16+
17+
<h3>Development Mode</h3>
18+
<p>
19+
Swapping to <strong>Development</strong> environment will display more detailed information about the error that occurred.
20+
</p>
21+
<p>
22+
<strong>The Development environment shouldn't be enabled for deployed applications.</strong>
23+
It can result in displaying sensitive information from exceptions to end users.
24+
For local debugging, enable the <strong>Development</strong> environment by setting the <strong>ASPNETCORE_ENVIRONMENT</strong> environment variable to <strong>Development</strong>
25+
and restarting the app.
26+
</p>
27+
28+
@code {
29+
[CascadingParameter]
30+
public HttpContext? HttpContext { get; set; }
31+
32+
private string? RequestId { get; set; }
33+
private bool ShowRequestId => !string.IsNullOrEmpty(RequestId);
34+
35+
protected override void OnInitialized() =>
36+
RequestId = Activity.Current?.Id ?? HttpContext?.TraceIdentifier;
37+
}
Lines changed: 7 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,7 @@
1+
@inherits Microsoft.AspNetCore.Components.LayoutComponentBase
2+
3+
<ServerInteractiveCounter />
4+
5+
<div>
6+
@Body
7+
</div>
Lines changed: 11 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,11 @@
1+
@page "/Throws"
2+
3+
<PageTitle>Page that throws</PageTitle>
4+
5+
<p>This page throws during OnInitialize to showcase error handling via UseExceptionHandler.</p>
6+
7+
@code
8+
{
9+
protected override void OnInitialized() =>
10+
throw new InvalidOperationException("This page throws on purpose.");
11+
}

src/Middleware/Diagnostics/src/ExceptionHandler/ExceptionHandlerExtensions.cs

Lines changed: 19 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -47,6 +47,25 @@ public static IApplicationBuilder UseExceptionHandler(this IApplicationBuilder a
4747
});
4848
}
4949

50+
/// <summary>
51+
/// Adds a middleware to the pipeline that will catch exceptions, log them, reset the request path, and re-execute the request.
52+
/// The request will not be re-executed if the response has already started.
53+
/// </summary>
54+
/// <param name="app">The <see cref="IApplicationBuilder"/>.</param>
55+
/// <param name="errorHandlingPath">The <see cref="string"/> path to the endpoint that will handle the exception.</param>
56+
/// <param name="createScopeForErrors">Whether or not to create a new <see cref="IServiceProvider"/> scope.</param>
57+
/// <returns></returns>
58+
public static IApplicationBuilder UseExceptionHandler(this IApplicationBuilder app, string errorHandlingPath, bool createScopeForErrors)
59+
{
60+
ArgumentNullException.ThrowIfNull(app);
61+
62+
return app.UseExceptionHandler(new ExceptionHandlerOptions
63+
{
64+
ExceptionHandlingPath = new PathString(errorHandlingPath),
65+
CreateScopeForErrors = createScopeForErrors
66+
});
67+
}
68+
5069
/// <summary>
5170
/// Adds a middleware to the pipeline that will catch exceptions, log them, and re-execute the request in an alternate pipeline.
5271
/// The request will not be re-executed if the response has already started.

0 commit comments

Comments
 (0)