Skip to content

Instantly share code, notes, and snippets.

@Yarith
Last active July 4, 2025 08:43
Show Gist options
  • Save Yarith/a524e5fc5fd89e356642f299d07f4209 to your computer and use it in GitHub Desktop.
Save Yarith/a524e5fc5fd89e356642f299d07f4209 to your computer and use it in GitHub Desktop.
Globally apply custom ErrorBoundary to all pages with @rendermode InteractiveServer

Globally apply custom ErrorBoundary to all pages with @rendermode InteractiveServer

This gist applies to Blazor on .NET 9

Problem

Normally it is not possible to wrap the Layout.Body with InteractiveServer content within the ErrorBoundary component. This gist demonstrates, how you can still apply them globally to every InteractiveServer page. Normally you can not do this in the Layout, because the Layout may be a static component, while the Body is sometimes interactive. If you tried to simple wrap the @Body you will get an exception, that the ChildContent can not be serialized.

Workaround

We need to wrap the page type inside another InteractiveServer component. This is simple done, by creating a generic ContentWrapper<> type, which contains the page type as an argument. The simple BuildRenderTree method then contains the CustomErrorBoundary which sets the ChildContent to the page type. This new content wrapper type will be passed to a new instance of the RouteView to automate the wrapping of the pages automatically. Now exceptions can be caught in interactive mode like they were in the static rendering.

In this example i am already using the DeveloperExceptionPageMiddleware to display a the fancy page, like we would get in static rendering. The html will be generated by the CustomErrorBoundary component and applied via JavaScript.

With this CustomErrorBoundary you can simply replace @Body with @CustomErrorBoundary.Wrap(Body) in your *Layout.razor file.

In the previous revision i thought the RouteView requires the @page Attribute, but this does not seem to be the case. Made it harder for myself then it should. At least the new solution is now far simpler. I hope there are no other pitfalls available, which need to complicate the implementation of the ContentWrapper<> type.

@using System.Runtime.ExceptionServices
@using System.Diagnostics
@using System.Text.Json
@inherits ErrorBoundary
@inject IServiceProvider serviceProvider
@inject IWebHostEnvironment environment
@inject ILogger<CustomErrorBoundary> Logger
@if (CurrentException is null)
{
@ChildContent
}
else if (DeveloperExceptionPageScript is not null)
{
<script>
@DeveloperExceptionPageScript
</script>
}
else if (ErrorContent is not null)
{
@ErrorContent(CurrentException)
}
@code {
private RenderFragment DefaultErrorContent(Exception value) =>
@<div class="unhandled-exception-content">Sorry! Sadly an unexpected error occurred. 😳</div>;
private string? DeveloperExceptionPageScript { get; set; }
protected override async Task OnErrorAsync(Exception ex)
{
Logger.LogError(ex, "😈 A rotten gremlin got us. Sorry!");
if (environment.IsDevelopment())
{
UpdateDeveloperExceptionPageScript(
await BuildExceptionPage(ex));
}
}
}
using Microsoft.AspNetCore.Components.Rendering;
using Microsoft.AspNetCore.Components;
using Microsoft.AspNetCore.Diagnostics;
using Microsoft.AspNetCore.Http.Features;
using Microsoft.Extensions.Options;
using System.Collections.Concurrent;
using System.Diagnostics;
using System.Diagnostics.CodeAnalysis;
using System.Text;
using System.Text.Json;
using Microsoft.AspNetCore.Components.Web;
using System.Reflection;
using Microsoft.AspNetCore.Http;
using Microsoft.AspNetCore.Builder;
using Microsoft.AspNetCore.Hosting;
using Microsoft.Extensions.Logging;
using Microsoft.Extensions.DependencyInjection;
namespace WebApplication01.Components;
public partial class CustomErrorBoundary
{
private static readonly ConcurrentDictionary<Type, bool> isBodyInInteractiveServerRenderMode = new();
private static readonly ConcurrentDictionary<Type, Type> surroundPageContentWithCustomErrorBoundaryInterceptorTypes = new();
public CustomErrorBoundary()
{
ErrorContent = DefaultErrorContent;
}
private async Task<string> BuildExceptionPage(Exception ex)
{
Task Next(HttpContext next)
{
return Task.FromException(ex);
}
var middleware = new DeveloperExceptionPageMiddleware(
Next,
serviceProvider.GetRequiredService<IOptions<DeveloperExceptionPageOptions>>(),
serviceProvider.GetRequiredService<ILoggerFactory>(),
serviceProvider.GetRequiredService<IWebHostEnvironment>(),
serviceProvider.GetRequiredService<DiagnosticSource>(),
serviceProvider.GetServices<IDeveloperPageExceptionFilter>());
FeatureCollection features = [];
var requestFeature = new HttpRequestFeature();
requestFeature.Headers.Accept = "text/html";
features.Set<IHttpRequestFeature>(requestFeature);
features.Set<IHttpResponseFeature>(
new HttpResponseFeature());
MemoryStream responseBody = new();
features.Set<IHttpResponseBodyFeature>(
new StreamResponseBodyFeature(responseBody));
var context = new DefaultHttpContext(features);
await middleware.Invoke(context);
return Encoding.UTF8.GetString(responseBody.ToArray());
}
private void UpdateDeveloperExceptionPageScript(string html)
{
var json = JsonSerializer.Serialize(new { html });
DeveloperExceptionPageScript = $$"""
(function (data) {
document.documentElement.innerHTML = data.{{nameof(html)}};
for (const script of [ ... document.querySelectorAll("script") ]) {
const newScript = document.createElement("script");
newScript.textContent = script.textContent;
script.replaceWith(newScript);
}
})({{json}});
""";
StateHasChanged();
}
/// <summary>
/// With this method you can find out if the component is InteractiveServer, sadly i found no
/// way to globally apply <CustomErrorBoundary>, because ChildContent can't be serialized.
/// Here is someone else who tried it and was not able to:
/// https://github.com/dotnet/aspnetcore/issues/56413#issuecomment-2322732045
/// You can not simply wrap the @Body with another component, instead look at the
/// <see cref="SurroundPageContentWithCustomErrorBoundary"/> method, which generates a
/// type on runtime to surround the page content with the <see cref="CustomErrorBoundary"/>
/// </summary>
private static bool IsBodyInInteractiveServerRenderMode([NotNullWhen(true)] RenderFragment? body) =>
body is { Target: RouteView { RouteData.PageType: var pageType } }
&& isBodyInInteractiveServerRenderMode.GetOrAdd(pageType,
static type =>
{
if (type.GetCustomAttributes() is { } attributes)
{
foreach (var attribute in attributes)
{
if (attribute is RenderModeAttribute { Mode: InteractiveServerRenderMode })
{
return true;
}
}
}
return false;
});
/// <summary>
/// You need to wrap the <see cref="RenderMode.InteractiveServer"/> page component type within the
/// <see cref="ErrorBoundary"/> inside a new <see cref="RenderMode.InteractiveServer"/> component type.
/// If a callback is handled by blazor it only saw the actual page component and skipped the containing
/// <see cref="ErrorBoundary"/>.
/// </summary>
private static Type SurroundPageContentWithCustomErrorBoundary(Type pageComponentType) =>
surroundPageContentWithCustomErrorBoundaryInterceptorTypes.GetOrAdd(pageComponentType,
static type =>
{
return typeof(ContentWrapper<>).MakeGenericType(type);
});
private static RenderFragment WrapWithCustomErrorBoundary(RenderFragment body)
{
if (body.Target is not RouteView
{
RouteData: var routeData,
DefaultLayout: var defaultLayout,
})
return body;
var newPageType = SurroundPageContentWithCustomErrorBoundary(routeData.PageType);
#pragma warning disable IDE0079 // IDE tells us that BL0005 supression does have no effect, which is not the case
#pragma warning disable BL0005 // Component parameter should not be set outside of its component.
RouteView interceptingRouteView = new()
{
DefaultLayout = defaultLayout,
RouteData = new(newPageType, routeData.RouteValues)
{
Template = routeData.Template,
},
};
#pragma warning restore BL0005 // Component parameter should not be set outside of its component.
#pragma warning restore IDE0079 // IDE tells us that BL0005 supression does have no effect, which is not the case
var result = (RenderFragment)Delegate.CreateDelegate(
type: typeof(RenderFragment),
firstArgument: interceptingRouteView,
method: body.Method);
return result;
}
/// <summary>
/// Wraps the content of the given body with the <see cref="CustomErrorBoundary"/>, if it
/// is refering to a <see cref="RouteView"> with @rendermode <see cref="RenderMode.InteractiveServer">.
/// </summary>
public static RenderFragment? Wrap(
RenderFragment? body) =>
IsBodyInInteractiveServerRenderMode(body)
? WrapWithCustomErrorBoundary(body)
: body;
[__PrivateComponentRenderMode]
private sealed class ContentWrapper<TComponent> : ComponentBase
where TComponent : notnull, IComponent
{
private IReadOnlyDictionary<string, object?>? capturedParameters;
public override Task SetParametersAsync(ParameterView parameters)
{
capturedParameters = parameters.ToDictionary();
return base.SetParametersAsync(ParameterView.Empty);
}
protected override void BuildRenderTree(RenderTreeBuilder builder)
{
builder.OpenComponent<CustomErrorBoundary>(0);
builder.AddAttribute(1, nameof(CustomErrorBoundary.ChildContent),
(RenderFragment)(builder2 =>
{
builder2.OpenComponent(2, typeof(TComponent));
// Passing the route parameters to the actual page component.
// The parameters are already in the correct shape, because the
// RouteView was initially build for our TComponent. So no
// further processing is needed.
// Cascading values are as of .NET 9 not supported between static
// and interactive render boundaries. It is intended, that we
// use this wrapper inside the static Layout component. So we can
// just ignore all cascading parameters for now.
// I think there is nothing else what we need to pass here.
// If you want to wrap other kinds of components, you may need
// to figure out, if and what you need to extend here to make
// them fully work.
builder2.AddMultipleAttributes(3, capturedParameters!);
builder2.CloseComponent();
}));
builder.CloseComponent();
}
}
#pragma warning disable IDE1006 // Benennungsstile
private sealed class __PrivateComponentRenderModeAttribute : RenderModeAttribute
#pragma warning restore IDE1006 // Benennungsstile
{
private static IComponentRenderMode ModeImpl => RenderMode.InteractiveServer;
public override IComponentRenderMode Mode => ModeImpl;
}
}
.unhandled-exception-content {
background: #111111DE;
border-radius: 1rem;
margin: 1rem;
padding: 2rem;
font-size: 1.8rem;
font-family: sans-serif;
color: rgb(194, 66, 66);
font-weight: bolder;
}
Sign up for free to join this conversation on GitHub. Already have an account? Sign in to comment