diff --git a/src/Components/Components/src/ErrorBoundaryBase.cs b/src/Components/Components/src/ErrorBoundaryBase.cs new file mode 100644 index 000000000000..f35441db7e8d --- /dev/null +++ b/src/Components/Components/src/ErrorBoundaryBase.cs @@ -0,0 +1,126 @@ +// Copyright (c) .NET Foundation. All rights reserved. +// Licensed under the Apache License, Version 2.0. See License.txt in the project root for license information. + +using System; +using System.Runtime.ExceptionServices; +using System.Threading.Tasks; + +namespace Microsoft.AspNetCore.Components +{ + /// + /// A base class for error boundary components. + /// + public abstract class ErrorBoundaryBase : ComponentBase, IErrorBoundary + { + private int _errorCount; + + /// + /// The content to be displayed when there is no error. + /// + [Parameter] public RenderFragment? ChildContent { get; set; } + + /// + /// The content to be displayed when there is an error. + /// + [Parameter] public RenderFragment? ErrorContent { get; set; } + + /// + /// The maximum number of errors that can be handled. If more errors are received, + /// they will be treated as fatal. Calling resets the count. + /// + [Parameter] public int MaximumErrorCount { get; set; } = 100; + + /// + /// Gets the current exception, or null if there is no exception. + /// + protected Exception? CurrentException { get; private set; } + + /// + /// Resets the error boundary to a non-errored state. If the error boundary is not + /// already in an errored state, the call has no effect. + /// + public void Recover() + { + if (CurrentException is not null) + { + _errorCount = 0; + CurrentException = null; + StateHasChanged(); + } + } + + /// + /// Invoked by the base class when an error is being handled. Typically, derived classes + /// should log the exception from this method. + /// + /// The being handled. + protected abstract Task OnErrorAsync(Exception exception); + + void IErrorBoundary.HandleException(Exception exception) + { + if (exception is null) + { + // This would be a framework bug if it happened. It should not be possible. + throw new ArgumentNullException(nameof(exception)); + } + + // If rendering the error content itself causes an error, then re-rendering on error risks creating an + // infinite error loop. Unfortunately it's very hard to distinguish whether the error source is "child content" + // or "error content", since the exceptions can be received asynchronously, arbitrarily long after we switched + // between normal and errored states. Without creating a very intricate coupling between ErrorBoundaryBase and + // Renderer internals, the obvious options are either: + // + // [a] Don't re-render if we're already in an error state. This is problematic because the renderer needs to + // discard the error boundary's subtree on every error, in case a custom error boundary fails to do so, and + // hence we'd be left with a blank UI if we didn't re-render. + // [b] Do re-render each time, and trust the developer not to cause errors from their error content. + // + // As a middle ground, we try to detect excessive numbers of errors arriving in between recoveries, and treat + // an excess as fatal. This also helps to expose the case where a child continues to throw (e.g., on a timer), + // which would be very inefficient. + if (++_errorCount > MaximumErrorCount) + { + ExceptionDispatchInfo.Capture(exception).Throw(); + } + + // Notify the subclass so it can begin any async operation even before we render, because (for example) + // we want logs to be written before rendering in case the rendering throws. But there's no reason to + // wait for the async operation to complete before we render. + var onErrorTask = OnErrorAsync(exception); + if (!onErrorTask.IsCompletedSuccessfully) + { + _ = HandleOnErrorExceptions(onErrorTask); + } + + CurrentException = exception; + StateHasChanged(); + } + + private async Task HandleOnErrorExceptions(Task onExceptionTask) + { + if (onExceptionTask.IsFaulted) + { + // Synchronous error handling exceptions can simply be fatal to the circuit + ExceptionDispatchInfo.Capture(onExceptionTask.Exception!).Throw(); + } + else + { + // Async exceptions are tricky because there's no natural way to bring them back + // onto the sync context within their original circuit. The closest approximation + // we have is trying to rethrow via rendering. If, in the future, we add an API for + // directly dispatching an exception from ComponentBase, we should use that here. + try + { + await onExceptionTask; + } + catch (Exception exception) + { + CurrentException = exception; + ChildContent = _ => ExceptionDispatchInfo.Capture(exception).Throw(); + ErrorContent = _ => _ => ExceptionDispatchInfo.Capture(exception).Throw(); + StateHasChanged(); + } + } + } + } +} diff --git a/src/Components/Components/src/IErrorBoundary.cs b/src/Components/Components/src/IErrorBoundary.cs new file mode 100644 index 000000000000..b433dd055737 --- /dev/null +++ b/src/Components/Components/src/IErrorBoundary.cs @@ -0,0 +1,23 @@ +// Copyright (c) .NET Foundation. All rights reserved. +// Licensed under the Apache License, Version 2.0. See License.txt in the project root for license information. + +using System; + +namespace Microsoft.AspNetCore.Components +{ + // Purpose of this interface, instead of just using ErrorBoundaryBase directly: + // + // [1] It keeps clear what is fundamental to an error boundary from the Renderer's perspective. + // Anything more specific than this is just a useful pattern inside ErrorBoundaryBase. + // [2] It improves linkability. If an application isn't using error boundaries, then all of + // ErrorBoundaryBase and its dependencies can be linked out, leaving only this interface. + // + // If we wanted, we could make this public, but it could lead to common antipatterns such as + // routinely marking all components as error boundaries (e.g., in a common base class) in an + // attempt to create "On Error Resume Next"-type behaviors. + + internal interface IErrorBoundary + { + void HandleException(Exception error); + } +} diff --git a/src/Components/Components/src/PublicAPI.Unshipped.txt b/src/Components/Components/src/PublicAPI.Unshipped.txt index 459cce22c1ad..506b89211a67 100644 --- a/src/Components/Components/src/PublicAPI.Unshipped.txt +++ b/src/Components/Components/src/PublicAPI.Unshipped.txt @@ -9,6 +9,16 @@ Microsoft.AspNetCore.Components.ComponentApplicationState.PersistAsJson( Microsoft.AspNetCore.Components.ComponentApplicationState.PersistState(string! key, byte[]! value) -> void Microsoft.AspNetCore.Components.ComponentApplicationState.TryTakeAsJson(string! key, out TValue? instance) -> bool Microsoft.AspNetCore.Components.ComponentApplicationState.TryTakePersistedState(string! key, out byte[]? value) -> bool +Microsoft.AspNetCore.Components.ErrorBoundaryBase +Microsoft.AspNetCore.Components.ErrorBoundaryBase.ChildContent.get -> Microsoft.AspNetCore.Components.RenderFragment? +Microsoft.AspNetCore.Components.ErrorBoundaryBase.ChildContent.set -> void +Microsoft.AspNetCore.Components.ErrorBoundaryBase.CurrentException.get -> System.Exception? +Microsoft.AspNetCore.Components.ErrorBoundaryBase.ErrorBoundaryBase() -> void +Microsoft.AspNetCore.Components.ErrorBoundaryBase.ErrorContent.get -> Microsoft.AspNetCore.Components.RenderFragment? +Microsoft.AspNetCore.Components.ErrorBoundaryBase.ErrorContent.set -> void +Microsoft.AspNetCore.Components.ErrorBoundaryBase.MaximumErrorCount.get -> int +Microsoft.AspNetCore.Components.ErrorBoundaryBase.MaximumErrorCount.set -> void +Microsoft.AspNetCore.Components.ErrorBoundaryBase.Recover() -> void Microsoft.AspNetCore.Components.Lifetime.ComponentApplicationLifetime Microsoft.AspNetCore.Components.Lifetime.ComponentApplicationLifetime.ComponentApplicationLifetime(Microsoft.Extensions.Logging.ILogger! logger) -> void Microsoft.AspNetCore.Components.Lifetime.ComponentApplicationLifetime.PersistStateAsync(Microsoft.AspNetCore.Components.Lifetime.IComponentApplicationStateStore! store, Microsoft.AspNetCore.Components.RenderTree.Renderer! renderer) -> System.Threading.Tasks.Task! @@ -33,6 +43,7 @@ Microsoft.AspNetCore.Components.CascadingTypeParameterAttribute Microsoft.AspNetCore.Components.CascadingTypeParameterAttribute.CascadingTypeParameterAttribute(string! name) -> void Microsoft.AspNetCore.Components.CascadingTypeParameterAttribute.Name.get -> string! Microsoft.AspNetCore.Components.RenderTree.Renderer.GetEventArgsType(ulong eventHandlerId) -> System.Type! +abstract Microsoft.AspNetCore.Components.ErrorBoundaryBase.OnErrorAsync(System.Exception! exception) -> System.Threading.Tasks.Task! override Microsoft.AspNetCore.Components.LayoutComponentBase.SetParametersAsync(Microsoft.AspNetCore.Components.ParameterView parameters) -> System.Threading.Tasks.Task! static Microsoft.AspNetCore.Components.ParameterView.FromDictionary(System.Collections.Generic.IDictionary! parameters) -> Microsoft.AspNetCore.Components.ParameterView virtual Microsoft.AspNetCore.Components.RenderTree.Renderer.DispatchEventAsync(ulong eventHandlerId, Microsoft.AspNetCore.Components.RenderTree.EventFieldInfo? fieldInfo, System.EventArgs! eventArgs) -> System.Threading.Tasks.Task! diff --git a/src/Components/Components/src/RenderTree/Renderer.cs b/src/Components/Components/src/RenderTree/Renderer.cs index ddcbf714b453..06e1724b7b5c 100644 --- a/src/Components/Components/src/RenderTree/Renderer.cs +++ b/src/Components/Components/src/RenderTree/Renderer.cs @@ -7,6 +7,7 @@ using System.Collections.Generic; using System.Diagnostics; using System.Diagnostics.CodeAnalysis; +using System.Runtime.ExceptionServices; using System.Threading; using System.Threading.Tasks; using Microsoft.AspNetCore.Components.HotReload; @@ -28,6 +29,7 @@ public abstract partial class Renderer : IDisposable, IAsyncDisposable { private readonly IServiceProvider _serviceProvider; private readonly Dictionary _componentStateById = new Dictionary(); + private readonly Dictionary _componentStateByComponent = new Dictionary(); private readonly RenderBatchBuilder _batchBuilder = new RenderBatchBuilder(); private readonly Dictionary _eventBindings = new Dictionary(); private readonly Dictionary _eventHandlerIdReplacements = new Dictionary(); @@ -290,6 +292,7 @@ private ComponentState AttachAndInitComponent(IComponent component, int parentCo var componentState = new ComponentState(this, componentId, component, parentComponentState); Log.InitializingComponent(_logger, componentState, parentComponentState); _componentStateById.Add(componentId, componentState); + _componentStateByComponent.Add(component, componentState); component.Attach(new RenderHandle(this, componentId)); return componentState; } @@ -318,6 +321,16 @@ public virtual Task DispatchEventAsync(ulong eventHandlerId, EventFieldInfo? fie var callback = GetRequiredEventCallback(eventHandlerId); Log.HandlingEvent(_logger, eventHandlerId, eventArgs); + // Try to match it up with a receiver so that, if the event handler later throws, we can route the error to the + // correct error boundary (even if the receiving component got disposed in the meantime). + ComponentState? receiverComponentState = null; + if (callback.Receiver is IComponent receiverComponent) // The receiver might be null or not an IComponent + { + // Even if the receiver is an IComponent, it might not be one of ours, or might be disposed already + // We can only route errors to error boundaries if the receiver is known and not yet disposed at this stage + _componentStateByComponent.TryGetValue(receiverComponent, out receiverComponentState); + } + if (fieldInfo != null) { var latestEquivalentEventHandlerId = FindLatestEventHandlerIdInChain(eventHandlerId); @@ -335,7 +348,7 @@ public virtual Task DispatchEventAsync(ulong eventHandlerId, EventFieldInfo? fie } catch (Exception e) { - HandleException(e); + HandleExceptionViaErrorBoundary(e, receiverComponentState); return Task.CompletedTask; } finally @@ -349,7 +362,7 @@ public virtual Task DispatchEventAsync(ulong eventHandlerId, EventFieldInfo? fie // Task completed synchronously or is still running. We already processed all of the rendering // work that was queued so let our error handler deal with it. - var result = GetErrorHandledTask(task); + var result = GetErrorHandledTask(task, receiverComponentState); return result; } @@ -389,7 +402,7 @@ internal void InstantiateChildComponentOnFrame(ref RenderTreeFrame frame, int pa frame.ComponentIdField = newComponentState.ComponentId; } - internal void AddToPendingTasks(Task task) + internal void AddToPendingTasks(Task task, ComponentState? owningComponentState) { switch (task == null ? TaskStatus.RanToCompletion : task.Status) { @@ -405,12 +418,13 @@ internal void AddToPendingTasks(Task task) // an 'async' state machine (the ones generated using async/await) where even // the synchronous exceptions will get captured and converted into a faulted // task. - HandleException(task.Exception.GetBaseException()); + var baseException = task.Exception.GetBaseException(); + HandleExceptionViaErrorBoundary(baseException, owningComponentState); break; default: // It's important to evaluate the following even if we're not going to use // handledErrorTask below, because it has the side-effect of calling HandleException. - var handledErrorTask = GetErrorHandledTask(task); + var handledErrorTask = GetErrorHandledTask(task, owningComponentState); // The pendingTasks collection is only used during prerendering to track quiescence, // so will be null at other times. @@ -691,7 +705,7 @@ private void NotifyRenderCompleted(ComponentState state, ref List batch) } else if (task.Status == TaskStatus.Faulted) { - HandleException(task.Exception); + HandleExceptionViaErrorBoundary(task.Exception, state); return; } } @@ -699,14 +713,19 @@ private void NotifyRenderCompleted(ComponentState state, ref List batch) // The Task is incomplete. // Queue up the task and we can inspect it later. batch = batch ?? new List(); - batch.Add(GetErrorHandledTask(task)); + batch.Add(GetErrorHandledTask(task, state)); } private void RenderInExistingBatch(RenderQueueEntry renderQueueEntry) { var componentState = renderQueueEntry.ComponentState; Log.RenderingComponent(_logger, componentState); - componentState.RenderIntoBatch(_batchBuilder, renderQueueEntry.RenderFragment); + componentState.RenderIntoBatch(_batchBuilder, renderQueueEntry.RenderFragment, out var renderFragmentException); + if (renderFragmentException != null) + { + // If this returns, the error was handled by an error boundary. Otherwise it throws. + HandleExceptionViaErrorBoundary(renderFragmentException, componentState); + } List exceptions = null; @@ -737,7 +756,8 @@ private void RenderInExistingBatch(RenderQueueEntry renderQueueEntry) } else { - AddToPendingTasks(GetHandledAsynchronousDisposalErrorsTask(result)); + // We set owningComponentState to null because we don't want exceptions during disposal to be recoverable + AddToPendingTasks(GetHandledAsynchronousDisposalErrorsTask(result), owningComponentState: null); async Task GetHandledAsynchronousDisposalErrorsTask(Task result) { @@ -754,6 +774,7 @@ async Task GetHandledAsynchronousDisposalErrorsTask(Task result) } _componentStateById.Remove(disposeComponentId); + _componentStateByComponent.Remove(disposeComponentState.Component); _batchBuilder.DisposedComponentIds.Append(disposeComponentId); } @@ -815,7 +836,7 @@ async Task ContinueAfterTask(ArrayRange eventHandlerIds, Task afterTaskIg } } - private async Task GetErrorHandledTask(Task taskToHandle) + private async Task GetErrorHandledTask(Task taskToHandle, ComponentState? owningComponentState) { try { @@ -823,10 +844,10 @@ private async Task GetErrorHandledTask(Task taskToHandle) } catch (Exception ex) { + // Ignore errors due to task cancellations. if (!taskToHandle.IsCanceled) { - // Ignore errors due to task cancellations. - HandleException(ex); + HandleExceptionViaErrorBoundary(ex, owningComponentState); } } } @@ -843,6 +864,47 @@ private void UpdateRenderTreeToMatchClientState(ulong eventHandlerId, EventField } } + /// + /// If the exception can be routed to an error boundary around , do so. + /// Otherwise handle it as fatal. + /// + private void HandleExceptionViaErrorBoundary(Exception error, ComponentState? errorSourceOrNull) + { + // We only get here in specific situations. Currently, all of them are when we're + // already on the sync context (and if not, we have a bug we want to know about). + Dispatcher.AssertAccess(); + + // Find the closest error boundary, if any + var candidate = errorSourceOrNull; + while (candidate is not null) + { + if (candidate.Component is IErrorBoundary errorBoundary) + { + // Don't just trust the error boundary to dispose its subtree - force it to do so by + // making it render an empty fragment. Ensures that failed components don't continue to + // operate, which would be a whole new kind of edge case to support forever. + AddToRenderQueue(candidate.ComponentId, builder => { }); + + try + { + errorBoundary.HandleException(error); + } + catch (Exception errorBoundaryException) + { + // If *notifying* about an exception fails, it's OK for that to be fatal + HandleException(errorBoundaryException); + } + + return; // Handled successfully + } + + candidate = candidate.ParentComponentState; + } + + // It's unhandled, so treat as fatal + HandleException(error); + } + /// /// Releases all resources currently used by this instance. /// @@ -896,6 +958,7 @@ protected virtual void Dispose(bool disposing) } _componentStateById.Clear(); // So we know they were all disposed + _componentStateByComponent.Clear(); _batchBuilder.Dispose(); NotifyExceptions(exceptions); diff --git a/src/Components/Components/src/Rendering/ComponentState.cs b/src/Components/Components/src/Rendering/ComponentState.cs index de620ccf8ffe..f5692f83e76b 100644 --- a/src/Components/Components/src/Rendering/ComponentState.cs +++ b/src/Components/Components/src/Rendering/ComponentState.cs @@ -20,7 +20,7 @@ internal class ComponentState : IDisposable private readonly IReadOnlyList _cascadingParameters; private readonly bool _hasCascadingParameters; private readonly bool _hasAnyCascadingParameterSubscriptions; - private RenderTreeBuilder _renderTreeBuilderPrevious; + private RenderTreeBuilder _nextRenderTree; private ArrayBuilder? _latestDirectParametersSnapshot; // Lazily instantiated private bool _componentWasDisposed; @@ -39,7 +39,7 @@ public ComponentState(Renderer renderer, int componentId, IComponent component, _renderer = renderer ?? throw new ArgumentNullException(nameof(renderer)); _cascadingParameters = CascadingParameterState.FindCascadingParameters(this); CurrentRenderTree = new RenderTreeBuilder(); - _renderTreeBuilderPrevious = new RenderTreeBuilder(); + _nextRenderTree = new RenderTreeBuilder(); if (_cascadingParameters.Count != 0) { @@ -54,8 +54,10 @@ public ComponentState(Renderer renderer, int componentId, IComponent component, public ComponentState ParentComponentState { get; } public RenderTreeBuilder CurrentRenderTree { get; private set; } - public void RenderIntoBatch(RenderBatchBuilder batchBuilder, RenderFragment renderFragment) + public void RenderIntoBatch(RenderBatchBuilder batchBuilder, RenderFragment renderFragment, out Exception? renderFragmentException) { + renderFragmentException = null; + // A component might be in the render queue already before getting disposed by an // earlier entry in the render queue. In that case, rendering is a no-op. if (_componentWasDisposed) @@ -63,19 +65,32 @@ public void RenderIntoBatch(RenderBatchBuilder batchBuilder, RenderFragment rend return; } - // Swap the old and new tree builders - (CurrentRenderTree, _renderTreeBuilderPrevious) = (_renderTreeBuilderPrevious, CurrentRenderTree); + _nextRenderTree.Clear(); - CurrentRenderTree.Clear(); - renderFragment(CurrentRenderTree); + try + { + renderFragment(_nextRenderTree); + } + catch (Exception ex) + { + // If an exception occurs in the render fragment delegate, we won't process the diff in any way, so child components, + // event handlers, etc., will all be left untouched as if this component didn't re-render at all. The Renderer will + // then forcibly clear the descendant subtree by rendering an empty fragment for this component. + renderFragmentException = ex; + return; + } - CurrentRenderTree.AssertTreeIsValid(Component); + // We don't want to make errors from this be recoverable, because there's no legitimate reason for them to happen + _nextRenderTree.AssertTreeIsValid(Component); + + // Swap the old and new tree builders + (CurrentRenderTree, _nextRenderTree) = (_nextRenderTree, CurrentRenderTree); var diff = RenderTreeDiffBuilder.ComputeDiff( _renderer, batchBuilder, ComponentId, - _renderTreeBuilderPrevious.GetFrames(), + _nextRenderTree.GetFrames(), CurrentRenderTree.GetFrames()); batchBuilder.UpdatedComponentDiffs.Append(diff); batchBuilder.InvalidateParameterViews(); @@ -164,7 +179,7 @@ public void SetDirectParameters(ParameterView parameters) parameters = parameters.WithCascadingParameters(_cascadingParameters); } - _renderer.AddToPendingTasks(Component.SetParametersAsync(parameters)); + SupplyCombinedParameters(parameters); } public void NotifyCascadingValueChanged(in ParameterViewLifetime lifetime) @@ -173,8 +188,26 @@ public void NotifyCascadingValueChanged(in ParameterViewLifetime lifetime) ? new ParameterView(lifetime, _latestDirectParametersSnapshot.Buffer, 0) : ParameterView.Empty; var allParams = directParams.WithCascadingParameters(_cascadingParameters!); - var task = Component.SetParametersAsync(allParams); - _renderer.AddToPendingTasks(task); + SupplyCombinedParameters(allParams); + } + + // This should not be called from anywhere except SetDirectParameters or NotifyCascadingValueChanged. + // Those two methods know how to correctly combine both cascading and non-cascading parameters to supply + // a consistent set to the recipient. + private void SupplyCombinedParameters(ParameterView directAndCascadingParameters) + { + // Normalise sync and async exceptions into a Task + Task setParametersAsyncTask; + try + { + setParametersAsyncTask = Component.SetParametersAsync(directAndCascadingParameters); + } + catch (Exception ex) + { + setParametersAsyncTask = Task.FromException(ex); + } + + _renderer.AddToPendingTasks(setParametersAsyncTask, owningComponentState: this); } private bool AddCascadingParameterSubscriptions() @@ -220,7 +253,7 @@ public void Dispose() private void DisposeBuffers() { - ((IDisposable)_renderTreeBuilderPrevious).Dispose(); + ((IDisposable)_nextRenderTree).Dispose(); ((IDisposable)CurrentRenderTree).Dispose(); _latestDirectParametersSnapshot?.Dispose(); } diff --git a/src/Components/Components/test/RendererTest.cs b/src/Components/Components/test/RendererTest.cs index 3c12040f9a0d..85185a710e84 100644 --- a/src/Components/Components/test/RendererTest.cs +++ b/src/Components/Components/test/RendererTest.cs @@ -4170,6 +4170,247 @@ public async Task ThrowsIfComponentProducesInvalidRenderTree() Assert.StartsWith($"Render output is invalid for component of type '{typeof(TestComponent).FullName}'. A frame of type 'Element' was left unclosed.", ex.Message); } + [Fact] + public void RenderingExceptionsCanBeHandledByClosestErrorBoundary() + { + // Arrange + var renderer = new TestRenderer(); + var exception = new InvalidTimeZoneException("Error during render"); + var rootComponentId = renderer.AssignRootComponentId(new TestComponent(builder => + { + TestErrorBoundary.RenderNestedErrorBoundaries(builder, builder => + { + builder.OpenComponent(0); + builder.AddAttribute(1, nameof(ErrorThrowingComponent.ThrowDuringRender), exception); + builder.CloseComponent(); + }); + })); + + // Act + renderer.RenderRootComponent(rootComponentId); + + // Assert + var batch = renderer.Batches.Single(); + var errorThrowingComponentId = batch.GetComponentFrames().Single().ComponentId; + var componentFrames = batch.GetComponentFrames(); + Assert.Collection(componentFrames.Select(f => (TestErrorBoundary)f.Component), + component => Assert.Null(component.ReceivedException), + component => Assert.Same(exception, component.ReceivedException)); + + // The failed subtree is disposed + Assert.Equal(errorThrowingComponentId, batch.DisposedComponentIDs.Single()); + } + + [Fact] + public void SetParametersAsyncExceptionsCanBeHandledByClosestErrorBoundary_Sync() + { + // Arrange + var renderer = new TestRenderer(); + Exception exception = null; + var rootComponent = new TestComponent(builder => + { + TestErrorBoundary.RenderNestedErrorBoundaries(builder, builder => + { + builder.OpenComponent(0); + builder.AddAttribute(1, nameof(ErrorThrowingComponent.ThrowDuringParameterSettingSync), exception); + builder.CloseComponent(); + }); + }); + var rootComponentId = renderer.AssignRootComponentId(rootComponent); + renderer.RenderRootComponent(rootComponentId); + var errorBoundaries = renderer.Batches.Single().GetComponentFrames() + .Select(f => (TestErrorBoundary)f.Component); + var errorThrowingComponentId = renderer.Batches.Single() + .GetComponentFrames().Single().ComponentId; + + // Act + exception = new InvalidTimeZoneException("Error during SetParametersAsync"); + rootComponent.TriggerRender(); + + // Assert + Assert.Equal(2, renderer.Batches.Count); + Assert.Collection(errorBoundaries, + component => Assert.Null(component.ReceivedException), + component => Assert.Same(exception, component.ReceivedException)); + + // The failed subtree is disposed + Assert.Equal(errorThrowingComponentId, renderer.Batches[1].DisposedComponentIDs.Single()); + } + + [Fact] + public async Task SetParametersAsyncExceptionsCanBeHandledByClosestErrorBoundary_Async() + { + // Arrange + var renderer = new TestRenderer(); + var exception = new InvalidTimeZoneException("Error during SetParametersAsync"); + TaskCompletionSource exceptionTcs = null; + var rootComponent = new TestComponent(builder => + { + TestErrorBoundary.RenderNestedErrorBoundaries(builder, builder => + { + builder.OpenComponent(0); + builder.AddAttribute(1, nameof(ErrorThrowingComponent.ThrowDuringParameterSettingAsync), exceptionTcs?.Task); + builder.CloseComponent(); + }); + }); + var rootComponentId = renderer.AssignRootComponentId(rootComponent); + renderer.RenderRootComponent(rootComponentId); + var errorBoundaries = renderer.Batches.Single().GetComponentFrames() + .Select(f => (TestErrorBoundary)f.Component).ToArray(); + var errorThrowingComponentId = renderer.Batches.Single() + .GetComponentFrames().Single().ComponentId; + + // Act/Assert 1: No synchronous errors + exceptionTcs = new TaskCompletionSource(); + rootComponent.TriggerRender(); + Assert.Equal(2, renderer.Batches.Count); + + // Act/Assert 2: Asynchronous error + exceptionTcs.SetException(exception); + await errorBoundaries[1].ReceivedErrorTask; + Assert.Equal(3, renderer.Batches.Count); + Assert.Collection(errorBoundaries, + component => Assert.Null(component.ReceivedException), + component => Assert.Same(exception, component.ReceivedException)); + + // The failed subtree is disposed + Assert.Equal(errorThrowingComponentId, renderer.Batches[2].DisposedComponentIDs.Single()); + } + + [Fact] + public void EventDispatchExceptionsCanBeHandledByClosestErrorBoundary_Sync() + { + // Arrange + var renderer = new TestRenderer(); + var exception = new InvalidTimeZoneException("Error during event"); + var rootComponentId = renderer.AssignRootComponentId(new TestComponent(builder => + { + TestErrorBoundary.RenderNestedErrorBoundaries(builder, builder => + { + builder.OpenComponent(0); + builder.AddAttribute(1, nameof(ErrorThrowingComponent.ThrowDuringEventSync), exception); + builder.CloseComponent(); + }); + })); + renderer.RenderRootComponent(rootComponentId); + var errorBoundaries = renderer.Batches.Single().GetComponentFrames() + .Select(f => (TestErrorBoundary)f.Component); + var errorThrowingComponentId = renderer.Batches.Single() + .GetComponentFrames().Single().ComponentId; + var eventHandlerId = renderer.Batches.Single().ReferenceFrames + .Single(f => f.FrameType == RenderTreeFrameType.Attribute && f.AttributeName == "onmakeerror") + .AttributeEventHandlerId; + + // Act + var task = renderer.DispatchEventAsync(eventHandlerId, new EventArgs()); + + // Assert + Assert.True(task.IsCompletedSuccessfully); + Assert.Equal(2, renderer.Batches.Count); + Assert.Collection(errorBoundaries, + component => Assert.Null(component.ReceivedException), + component => Assert.Same(exception, component.ReceivedException)); + + // The failed subtree is disposed + Assert.Equal(errorThrowingComponentId, renderer.Batches[1].DisposedComponentIDs.Single()); + } + + [Fact] + public async Task EventDispatchExceptionsCanBeHandledByClosestErrorBoundary_Async() + { + // Arrange + var renderer = new TestRenderer(); + var exception = new InvalidTimeZoneException("Error during event"); + var exceptionTcs = new TaskCompletionSource(); + var rootComponentId = renderer.AssignRootComponentId(new TestComponent(builder => + { + TestErrorBoundary.RenderNestedErrorBoundaries(builder, builder => + { + builder.OpenComponent(0); + builder.AddAttribute(1, nameof(ErrorThrowingComponent.ThrowDuringEventAsync), exceptionTcs.Task); + builder.CloseComponent(); + }); + })); + renderer.RenderRootComponent(rootComponentId); + var errorBoundaries = renderer.Batches.Single().GetComponentFrames() + .Select(f => (TestErrorBoundary)f.Component); + var errorThrowingComponentId = renderer.Batches.Single() + .GetComponentFrames().Single().ComponentId; + var eventHandlerId = renderer.Batches.Single().ReferenceFrames + .Single(f => f.FrameType == RenderTreeFrameType.Attribute && f.AttributeName == "onmakeerror") + .AttributeEventHandlerId; + + // Act/Assert 1: No error synchronously + var dispatchEventTask = renderer.DispatchEventAsync(eventHandlerId, new EventArgs()); + Assert.Single(renderer.Batches); + Assert.Collection(errorBoundaries, + component => Assert.Null(component.ReceivedException), + component => Assert.Null(component.ReceivedException)); + + // Act/Assert 2: Error is handled asynchronously + exceptionTcs.SetException(exception); + await dispatchEventTask; + Assert.Equal(2, renderer.Batches.Count); + Assert.Collection(errorBoundaries, + component => Assert.Null(component.ReceivedException), + component => Assert.Same(exception, component.ReceivedException)); + + // The failed subtree is disposed + Assert.Equal(errorThrowingComponentId, renderer.Batches[1].DisposedComponentIDs.Single()); + } + + [Fact] + public async Task EventDispatchExceptionsCanBeHandledByClosestErrorBoundary_AfterDisposal() + { + // Arrange + var renderer = new TestRenderer(); + var disposeChildren = false; + var exception = new InvalidTimeZoneException("Error during event"); + var exceptionTcs = new TaskCompletionSource(); + var rootComponent = new TestComponent(builder => + { + if (!disposeChildren) + { + TestErrorBoundary.RenderNestedErrorBoundaries(builder, builder => + { + builder.OpenComponent(0); + builder.AddAttribute(1, nameof(ErrorThrowingComponent.ThrowDuringEventAsync), exceptionTcs.Task); + builder.CloseComponent(); + }); + } + }); + var rootComponentId = renderer.AssignRootComponentId(rootComponent); + renderer.RenderRootComponent(rootComponentId); + var errorBoundaries = renderer.Batches.Single().GetComponentFrames() + .Select(f => (TestErrorBoundary)f.Component); + var errorThrowingComponentId = renderer.Batches.Single() + .GetComponentFrames().Single().ComponentId; + var eventHandlerId = renderer.Batches.Single().ReferenceFrames + .Single(f => f.FrameType == RenderTreeFrameType.Attribute && f.AttributeName == "onmakeerror") + .AttributeEventHandlerId; + + // Act/Assert 1: No error synchronously + var dispatchEventTask = renderer.DispatchEventAsync(eventHandlerId, new EventArgs()); + Assert.Single(renderer.Batches); + Assert.Collection(errorBoundaries, + component => Assert.Null(component.ReceivedException), + component => Assert.Null(component.ReceivedException)); + + // Act 2: Before the async error occurs, dispose the hierarchy containing the error boundary and erroring component + disposeChildren = true; + rootComponent.TriggerRender(); + Assert.Equal(2, renderer.Batches.Count); + Assert.Contains(errorThrowingComponentId, renderer.Batches.Last().DisposedComponentIDs); + + // Assert 2: Error is still handled + exceptionTcs.SetException(exception); + await dispatchEventTask; + Assert.Equal(2, renderer.Batches.Count); // Didn't re-render as the error boundary was already gone + Assert.Collection(errorBoundaries, + component => Assert.Null(component.ReceivedException), + component => Assert.Same(exception, component.ReceivedException)); + } + private class TestComponentActivator : IComponentActivator where TResult : IComponent, new() { public List RequestedComponentTypes { get; } = new List(); @@ -4401,10 +4642,9 @@ protected override void BuildRenderTree(RenderTreeBuilder builder) builder.OpenComponent(1); if (ChildParameters != null) { - var sequence = 2; foreach (var kvp in ChildParameters) { - builder.AddAttribute(sequence++, kvp.Key, kvp.Value); + builder.AddAttribute(2, kvp.Key, kvp.Value); } } builder.CloseComponent(); @@ -4620,9 +4860,8 @@ public async Task SetParametersAsync(ParameterView parameters) // Cheap closure void CreateFragment(RenderTreeBuilder builder) { - var s = 0; - builder.OpenElement(s++, "p"); - builder.AddContent(s++, n); + builder.OpenElement(0, "p"); + builder.AddContent(1, n); builder.CloseElement(); } } @@ -4701,16 +4940,15 @@ private Func CreateRenderFactory(int[] chi return component => builder => { - var s = 0; - builder.OpenElement(s++, "div"); - builder.AddContent(s++, $"Id: {component.TestId} BuildRenderTree, {Guid.NewGuid()}"); + builder.OpenElement(0, "div"); + builder.AddContent(1, $"Id: {component.TestId} BuildRenderTree, {Guid.NewGuid()}"); foreach (var child in childrenToRender) { - builder.OpenComponent(s++); - builder.AddAttribute(s++, eventActionsName, component.EventActions); - builder.AddAttribute(s++, whatToRenderName, component.WhatToRender); - builder.AddAttribute(s++, testIdName, child); - builder.AddAttribute(s++, logName, component.Log); + builder.OpenComponent(2); + builder.AddAttribute(3, eventActionsName, component.EventActions); + builder.AddAttribute(4, whatToRenderName, component.WhatToRender); + builder.AddAttribute(5, testIdName, child); + builder.AddAttribute(6, logName, component.Log); builder.CloseComponent(); } @@ -4970,5 +5208,88 @@ public Task SetParametersAsync(ParameterView parameters) return new TaskCompletionSource().Task; } } + + private class TestErrorBoundary : AutoRenderComponent, IErrorBoundary + { + private TaskCompletionSource receivedErrorTaskCompletionSource = new(); + + public Exception ReceivedException { get; private set; } + public Task ReceivedErrorTask => receivedErrorTaskCompletionSource.Task; + + [Parameter] public RenderFragment ChildContent { get; set; } + + protected override void BuildRenderTree(RenderTreeBuilder builder) + => ChildContent(builder); + + public void HandleException(Exception error) + { + ReceivedException = error; + receivedErrorTaskCompletionSource.SetResult(); + } + + public static void RenderNestedErrorBoundaries(RenderTreeBuilder builder, RenderFragment innerContent) + { + // Create an error boundary + builder.OpenComponent(0); + builder.AddAttribute(1, nameof(TestErrorBoundary.ChildContent), (RenderFragment)(builder => + { + // ... containing another error boundary, containing the content + builder.OpenComponent(0); + builder.AddAttribute(1, nameof(TestErrorBoundary.ChildContent), innerContent); + builder.CloseComponent(); + })); + builder.CloseComponent(); + } + } + + private class ErrorThrowingComponent : AutoRenderComponent, IHandleEvent + { + [Parameter] public Exception ThrowDuringRender { get; set; } + [Parameter] public Exception ThrowDuringEventSync { get; set; } + [Parameter] public Task ThrowDuringEventAsync { get; set; } + [Parameter] public Exception ThrowDuringParameterSettingSync { get; set; } + [Parameter] public Task ThrowDuringParameterSettingAsync { get; set; } + + public override async Task SetParametersAsync(ParameterView parameters) + { + _ = base.SetParametersAsync(parameters); + + if (ThrowDuringParameterSettingSync is not null) + { + throw ThrowDuringParameterSettingSync; + } + + if (ThrowDuringParameterSettingAsync is not null) + { + await ThrowDuringParameterSettingAsync; + } + } + + protected override void BuildRenderTree(RenderTreeBuilder builder) + { + if (ThrowDuringRender is not null) + { + throw ThrowDuringRender; + } + + builder.OpenElement(0, "someelem"); + builder.AddAttribute(1, "onmakeerror", EventCallback.Factory.Create(this, () => { })); + builder.AddContent(1, "Hello"); + builder.CloseElement(); + } + + public async Task HandleEventAsync(EventCallbackWorkItem item, object arg) + { + if (ThrowDuringEventSync is not null) + { + throw ThrowDuringEventSync; + } + + if (ThrowDuringEventAsync is not null) + { + await ThrowDuringEventAsync; + } + } + } } } diff --git a/src/Components/Server/src/Circuits/RemoteErrorBoundaryLogger.cs b/src/Components/Server/src/Circuits/RemoteErrorBoundaryLogger.cs new file mode 100644 index 000000000000..6fa23c40a9b9 --- /dev/null +++ b/src/Components/Server/src/Circuits/RemoteErrorBoundaryLogger.cs @@ -0,0 +1,52 @@ +// Copyright (c) .NET Foundation. All rights reserved. +// Licensed under the Apache License, Version 2.0. See License.txt in the project root for license information. + +using System; +using System.Threading.Tasks; +using Microsoft.AspNetCore.Components.Web; +using Microsoft.Extensions.Logging; +using Microsoft.Extensions.Options; +using Microsoft.JSInterop; + +namespace Microsoft.AspNetCore.Components.Server.Circuits +{ + internal class RemoteErrorBoundaryLogger : IErrorBoundaryLogger + { + private static readonly Action _exceptionCaughtByErrorBoundary = LoggerMessage.Define( + LogLevel.Warning, + 100, + "Unhandled exception rendering component: {Message}"); + + private readonly ILogger _logger; + private readonly IJSRuntime _jsRuntime; + private readonly CircuitOptions _options; + + public RemoteErrorBoundaryLogger(ILogger logger, IJSRuntime jsRuntime, IOptions options) + { + _logger = logger; + _jsRuntime = jsRuntime; + _options = options.Value; + } + + public ValueTask LogErrorAsync(Exception exception) + { + // We always log detailed information to the server-side log + _exceptionCaughtByErrorBoundary(_logger, exception.Message, exception); + + // We log to the client only if the browser is connected interactively, and even then + // we may suppress the details + var shouldLogToClient = (_jsRuntime as RemoteJSRuntime)?.IsInitialized == true; + if (shouldLogToClient) + { + var message = _options.DetailedErrors + ? exception.ToString() + : $"For more details turn on detailed exceptions in '{nameof(CircuitOptions)}.{nameof(CircuitOptions.DetailedErrors)}'"; + return _jsRuntime.InvokeVoidAsync("console.error", message); + } + else + { + return ValueTask.CompletedTask; + } + } + } +} diff --git a/src/Components/Server/src/Circuits/RemoteJSRuntime.cs b/src/Components/Server/src/Circuits/RemoteJSRuntime.cs index 3a3cb11d27c3..ffd813b5ddfe 100644 --- a/src/Components/Server/src/Circuits/RemoteJSRuntime.cs +++ b/src/Components/Server/src/Circuits/RemoteJSRuntime.cs @@ -19,6 +19,8 @@ internal class RemoteJSRuntime : JSRuntime public ElementReferenceContext ElementReferenceContext { get; } + public bool IsInitialized => _clientProxy is not null; + public RemoteJSRuntime(IOptions options, ILogger logger) { _options = options.Value; diff --git a/src/Components/Server/src/DependencyInjection/ComponentServiceCollectionExtensions.cs b/src/Components/Server/src/DependencyInjection/ComponentServiceCollectionExtensions.cs index f87dc1392852..b6b1b08db96d 100644 --- a/src/Components/Server/src/DependencyInjection/ComponentServiceCollectionExtensions.cs +++ b/src/Components/Server/src/DependencyInjection/ComponentServiceCollectionExtensions.cs @@ -10,6 +10,7 @@ using Microsoft.AspNetCore.Components.Server.BlazorPack; using Microsoft.AspNetCore.Components.Server.Circuits; using Microsoft.AspNetCore.Components.Server.ProtectedBrowserStorage; +using Microsoft.AspNetCore.Components.Web; using Microsoft.AspNetCore.SignalR.Protocol; using Microsoft.Extensions.DependencyInjection.Extensions; using Microsoft.Extensions.Options; @@ -65,6 +66,7 @@ public static IServerSideBlazorBuilder AddServerSideBlazor(this IServiceCollecti services.TryAddSingleton(); services.TryAddSingleton(); services.TryAddSingleton(); + services.TryAddScoped(); services.TryAddScoped(s => s.GetRequiredService().Circuit); services.TryAddScoped(); diff --git a/src/Components/Web/src/PublicAPI.Unshipped.txt b/src/Components/Web/src/PublicAPI.Unshipped.txt index f9125f300d01..5cdd936a61ec 100644 --- a/src/Components/Web/src/PublicAPI.Unshipped.txt +++ b/src/Components/Web/src/PublicAPI.Unshipped.txt @@ -14,6 +14,12 @@ Microsoft.AspNetCore.Components.Forms.InputText.Element.set -> void Microsoft.AspNetCore.Components.Forms.InputTextArea.Element.get -> Microsoft.AspNetCore.Components.ElementReference? Microsoft.AspNetCore.Components.Forms.InputTextArea.Element.set -> void *REMOVED*static Microsoft.AspNetCore.Components.Forms.BrowserFileExtensions.RequestImageFileAsync(this Microsoft.AspNetCore.Components.Forms.IBrowserFile! browserFile, string! format, int maxWith, int maxHeight) -> System.Threading.Tasks.ValueTask +Microsoft.AspNetCore.Components.Web.ErrorBoundary +Microsoft.AspNetCore.Components.Web.ErrorBoundary.ErrorBoundary() -> void +Microsoft.AspNetCore.Components.Web.IErrorBoundaryLogger +Microsoft.AspNetCore.Components.Web.IErrorBoundaryLogger.LogErrorAsync(System.Exception! exception) -> System.Threading.Tasks.ValueTask +override Microsoft.AspNetCore.Components.Web.ErrorBoundary.BuildRenderTree(Microsoft.AspNetCore.Components.Rendering.RenderTreeBuilder! builder) -> void +override Microsoft.AspNetCore.Components.Web.ErrorBoundary.OnErrorAsync(System.Exception! exception) -> System.Threading.Tasks.Task! static Microsoft.AspNetCore.Components.ElementReferenceExtensions.FocusAsync(this Microsoft.AspNetCore.Components.ElementReference elementReference, bool preventScroll) -> System.Threading.Tasks.ValueTask static Microsoft.AspNetCore.Components.Forms.BrowserFileExtensions.RequestImageFileAsync(this Microsoft.AspNetCore.Components.Forms.IBrowserFile! browserFile, string! format, int maxWidth, int maxHeight) -> System.Threading.Tasks.ValueTask Microsoft.AspNetCore.Components.RenderTree.WebEventDescriptor.EventName.get -> string! diff --git a/src/Components/Web/src/Web/ErrorBoundary.cs b/src/Components/Web/src/Web/ErrorBoundary.cs new file mode 100644 index 000000000000..85eef462f243 --- /dev/null +++ b/src/Components/Web/src/Web/ErrorBoundary.cs @@ -0,0 +1,55 @@ +// Copyright (c) .NET Foundation. All rights reserved. +// Licensed under the Apache License, Version 2.0. See License.txt in the project root for license information. + +using System; +using System.Threading.Tasks; +using Microsoft.AspNetCore.Components.Rendering; + +namespace Microsoft.AspNetCore.Components.Web +{ + /// + /// Captures errors thrown from its child content. + /// + public class ErrorBoundary : ErrorBoundaryBase + { + [Inject] private IErrorBoundaryLogger? ErrorBoundaryLogger { get; set; } + + /// + /// Invoked by the base class when an error is being handled. The default implementation + /// logs the error. + /// + /// The being handled. + protected override async Task OnErrorAsync(Exception exception) + { + await ErrorBoundaryLogger!.LogErrorAsync(exception); + } + + /// + protected override void BuildRenderTree(RenderTreeBuilder builder) + { + if (CurrentException is null) + { + builder.AddContent(0, ChildContent); + } + else if (ErrorContent is not null) + { + builder.AddContent(1, ErrorContent(CurrentException)); + } + else + { + // The default error UI doesn't include any content, because: + // [1] We don't know whether or not you'd be happy to show the stack trace. It depends both on + // whether DetailedErrors is enabled and whether you're in production, because even on WebAssembly + // you likely don't want to put technical data like that in the UI for end users. A reasonable way + // to toggle this is via something like "#if DEBUG" but that can only be done in user code. + // [2] We can't have any other human-readable content by default, because it would need to be valid + // for all languages. + // Instead, the default project template provides locale-specific default content via CSS. This provides + // a quick form of customization even without having to subclass this component. + builder.OpenElement(2, "div"); + builder.AddAttribute(3, "class", "blazor-error-boundary"); + builder.CloseElement(); + } + } + } +} diff --git a/src/Components/Web/src/Web/IErrorBoundaryLogger.cs b/src/Components/Web/src/Web/IErrorBoundaryLogger.cs new file mode 100644 index 000000000000..69ddd0e45f2a --- /dev/null +++ b/src/Components/Web/src/Web/IErrorBoundaryLogger.cs @@ -0,0 +1,24 @@ +// Copyright (c) .NET Foundation. All rights reserved. +// Licensed under the Apache License, Version 2.0. See License.txt in the project root for license information. + +using System; +using System.Threading.Tasks; + +namespace Microsoft.AspNetCore.Components.Web +{ + // The reason this abstraction exists is that logging behaviors differ across hosting platforms. + // For example, Blazor Server logs to both the server and client, whereas WebAssembly has only one log. + + /// + /// Logs exception information for a component. + /// + public interface IErrorBoundaryLogger + { + /// + /// Logs the supplied . + /// + /// The to log. + /// A representing the completion of the operation. + ValueTask LogErrorAsync(Exception exception); + } +} diff --git a/src/Components/WebAssembly/WebAssembly/src/Hosting/WebAssemblyHostBuilder.cs b/src/Components/WebAssembly/WebAssembly/src/Hosting/WebAssemblyHostBuilder.cs index 0d73eac48901..c42b7fce4da1 100644 --- a/src/Components/WebAssembly/WebAssembly/src/Hosting/WebAssemblyHostBuilder.cs +++ b/src/Components/WebAssembly/WebAssembly/src/Hosting/WebAssemblyHostBuilder.cs @@ -246,6 +246,7 @@ internal void InitializeDefaultServices() Services.AddSingleton(new LazyAssemblyLoader(DefaultWebAssemblyJSRuntime.Instance)); Services.AddSingleton(); Services.AddSingleton(sp => sp.GetRequiredService().State); + Services.AddSingleton(); Services.AddLogging(builder => { builder.AddProvider(new WebAssemblyConsoleLoggerProvider(DefaultWebAssemblyJSRuntime.Instance)); diff --git a/src/Components/WebAssembly/WebAssembly/src/Services/WebAssemblyErrorBoundaryLogger.cs b/src/Components/WebAssembly/WebAssembly/src/Services/WebAssemblyErrorBoundaryLogger.cs new file mode 100644 index 000000000000..eac12ba48e33 --- /dev/null +++ b/src/Components/WebAssembly/WebAssembly/src/Services/WebAssemblyErrorBoundaryLogger.cs @@ -0,0 +1,28 @@ +// Copyright (c) .NET Foundation. All rights reserved. +// Licensed under the Apache License, Version 2.0. See License.txt in the project root for license information. + +using System; +using System.Threading.Tasks; +using Microsoft.AspNetCore.Components.Web; +using Microsoft.Extensions.Logging; + +namespace Microsoft.AspNetCore.Components.WebAssembly.Services +{ + internal class WebAssemblyErrorBoundaryLogger : IErrorBoundaryLogger + { + private readonly ILogger _errorBoundaryLogger; + + public WebAssemblyErrorBoundaryLogger(ILogger errorBoundaryLogger) + { + _errorBoundaryLogger = errorBoundaryLogger ?? throw new ArgumentNullException(nameof(errorBoundaryLogger)); ; + } + + public ValueTask LogErrorAsync(Exception exception) + { + // For, client-side code, all internal state is visible to the end user. We can just + // log directly to the console. + _errorBoundaryLogger.LogError(exception.ToString()); + return ValueTask.CompletedTask; + } + } +} diff --git a/src/Components/test/E2ETest/ServerExecutionTests/TestSubclasses.cs b/src/Components/test/E2ETest/ServerExecutionTests/TestSubclasses.cs index bcc5e55c0fa7..6646b1f53751 100644 --- a/src/Components/test/E2ETest/ServerExecutionTests/TestSubclasses.cs +++ b/src/Components/test/E2ETest/ServerExecutionTests/TestSubclasses.cs @@ -107,4 +107,12 @@ public ServerEventCustomArgsTest(BrowserFixture browserFixture, ToggleExecutionM { } } + + public class ServerErrorBoundaryTest : ErrorBoundaryTest + { + public ServerErrorBoundaryTest(BrowserFixture browserFixture, ToggleExecutionModeServerFixture serverFixture, ITestOutputHelper output) + : base(browserFixture, serverFixture.WithServerExecution(), output) + { + } + } } diff --git a/src/Components/test/E2ETest/Tests/ErrorBoundaryTest.cs b/src/Components/test/E2ETest/Tests/ErrorBoundaryTest.cs new file mode 100644 index 000000000000..d1fcb7b7727d --- /dev/null +++ b/src/Components/test/E2ETest/Tests/ErrorBoundaryTest.cs @@ -0,0 +1,168 @@ +// Copyright (c) .NET Foundation. All rights reserved. +// Licensed under the Apache License, Version 2.0. See License.txt in the project root for license information. + +using System; +using System.Threading.Tasks; +using BasicTestApp; +using BasicTestApp.ErrorBoundaryTest; +using Microsoft.AspNetCore.Components.E2ETest.Infrastructure; +using Microsoft.AspNetCore.Components.E2ETest.Infrastructure.ServerFixtures; +using Microsoft.AspNetCore.E2ETesting; +using OpenQA.Selenium; +using Xunit; +using Xunit.Abstractions; + +namespace Microsoft.AspNetCore.Components.E2ETest.Tests +{ + public class ErrorBoundaryTest : ServerTestBase> + { + public ErrorBoundaryTest(BrowserFixture browserFixture, ToggleExecutionModeServerFixture serverFixture, ITestOutputHelper output) + : base(browserFixture, serverFixture, output) + { + } + + protected override void InitializeAsyncCore() + { + // Many of these tests trigger fatal exceptions, so we always have to reload + Navigate(ServerPathBase, noReload: false); + Browser.MountTestComponent(); + } + + [Theory] + [InlineData("event-sync")] + [InlineData("event-async")] + [InlineData("parametersset-sync")] + [InlineData("parametersset-async")] + [InlineData("parametersset-cascade-sync")] + [InlineData("parametersset-cascade-async")] + [InlineData("afterrender-sync")] + [InlineData("afterrender-async")] + [InlineData("while-rendering")] + public void CanHandleExceptions(string triggerId) + { + var container = Browser.Exists(By.Id("error-boundary-container")); + container.FindElement(By.Id(triggerId)).Click(); + + // The whole UI within the container is replaced by the default error UI + Browser.Collection(() => container.FindElements(By.CssSelector("*")), + elem => + { + Assert.Equal("blazor-error-boundary", elem.GetAttribute("class")); + Assert.Empty(elem.FindElements(By.CssSelector("*"))); + }); + + AssertGlobalErrorState(false); + } + + [Fact] + public void CanCreateCustomErrorBoundary() + { + var container = Browser.Exists(By.Id("custom-error-boundary-test")); + Func incrementButtonAccessor = () => container.FindElement(By.ClassName("increment-count")); + Func currentCountAccessor = () => container.FindElement(By.ClassName("current-count")).Text; + + incrementButtonAccessor().Click(); + incrementButtonAccessor().Click(); + Browser.Equal("2", currentCountAccessor); + + // If it throws, we see the custom error boundary + container.FindElement(By.ClassName("throw-counter-exception")).Click(); + Browser.Collection(() => container.FindElements(By.ClassName("received-exception")), + elem => Assert.Equal($"Exception from {nameof(ErrorCausingCounter)}", elem.Text)); + AssertGlobalErrorState(false); + + // On recovery, the count is reset, because it's a new instance + container.FindElement(By.ClassName("recover")).Click(); + Browser.Equal("0", currentCountAccessor); + incrementButtonAccessor().Click(); + Browser.Equal("1", currentCountAccessor); + Browser.Empty(() => container.FindElements(By.ClassName("received-exception"))); + } + + [Fact] + public void HandleCustomErrorBoundaryThatIgnoresErrors() + { + var container = Browser.Exists(By.Id("error-ignorer-test")); + Func incrementButtonAccessor = () => container.FindElement(By.ClassName("increment-count")); + Func currentCountAccessor = () => container.FindElement(By.ClassName("current-count")).Text; + + incrementButtonAccessor().Click(); + incrementButtonAccessor().Click(); + Browser.Equal("2", currentCountAccessor); + + // If it throws, the child content gets forcibly rebuilt even if the error boundary tries to retain it + container.FindElement(By.ClassName("throw-counter-exception")).Click(); + Browser.Equal("0", currentCountAccessor); + incrementButtonAccessor().Click(); + Browser.Equal("1", currentCountAccessor); + AssertGlobalErrorState(false); + } + + [Fact] + public void CanHandleErrorsInlineInErrorBoundaryContent() + { + var container = Browser.Exists(By.Id("inline-error-test")); + Browser.Equal("Hello!", () => container.FindElement(By.ClassName("normal-content")).Text); + Assert.Empty(container.FindElements(By.ClassName("error-message"))); + + // If ChildContent throws during rendering, the error boundary handles it + container.FindElement(By.ClassName("throw-in-childcontent")).Click(); + Browser.Contains("There was an error: System.InvalidTimeZoneException: Inline exception", () => container.FindElement(By.ClassName("error-message")).Text); + AssertGlobalErrorState(false); + + // If the ErrorContent throws during rendering, it gets caught by the "infinite error loop" detection logic and is fatal + container.FindElement(By.ClassName("throw-in-errorcontent")).Click(); + AssertGlobalErrorState(true); + } + + [Fact] + public void CanHandleErrorsAfterDisposingComponent() + { + var container = Browser.Exists(By.Id("error-after-disposal-test")); + + container.FindElement(By.ClassName("throw-after-disposing-component")).Click(); + Browser.Collection(() => container.FindElements(By.ClassName("received-exception")), + elem => Assert.Equal("Delayed asynchronous exception in OnParametersSetAsync", elem.Text)); + + AssertGlobalErrorState(false); + } + + [Fact] + public async Task CanHandleErrorsAfterDisposingErrorBoundary() + { + var container = Browser.Exists(By.Id("error-after-disposal-test")); + container.FindElement(By.ClassName("throw-after-disposing-errorboundary")).Click(); + + // Because we've actually removed the error boundary, there isn't any UI for us to assert about. + // The following delay is a cheap way to check for that - in the worst case, we could get a false + // test pass here if the delay is somehow not long enough, but this should never lead to a false + // failure (i.e., flakiness). + await Task.Delay(1000); // The test exception occurs after 500ms + + // We succeed as long as there's no global error and the rest of the UI is still there + Browser.Exists(By.Id("error-after-disposal-test")); + AssertGlobalErrorState(false); + } + + [Fact] + public void CanHandleMultipleAsyncErrorsFromDescendants() + { + var container = Browser.Exists(By.Id("multiple-child-errors-test")); + var message = "Delayed asynchronous exception in OnParametersSetAsync"; + + container.FindElement(By.ClassName("throw-in-children")).Click(); + Browser.Collection(() => container.FindElements(By.ClassName("received-exception")), + elem => Assert.Equal(message, elem.Text), + elem => Assert.Equal(message, elem.Text), + elem => Assert.Equal(message, elem.Text)); + + AssertGlobalErrorState(false); + } + + void AssertGlobalErrorState(bool hasGlobalError) + { + var globalErrorUi = Browser.Exists(By.Id("blazor-error-ui")); + Browser.Equal(hasGlobalError ? "block" : "none", () => globalErrorUi.GetCssValue("display")); + } + } +} diff --git a/src/Components/test/testassets/BasicTestApp/ErrorBoundaryTest/CustomErrorBoundary.razor b/src/Components/test/testassets/BasicTestApp/ErrorBoundaryTest/CustomErrorBoundary.razor new file mode 100644 index 000000000000..5e9252db76d5 --- /dev/null +++ b/src/Components/test/testassets/BasicTestApp/ErrorBoundaryTest/CustomErrorBoundary.razor @@ -0,0 +1,36 @@ +@inherits ErrorBoundary +@if (CurrentException is null) +{ + @ChildContent +} +else if (ErrorContent is not null) +{ + @ErrorContent(CurrentException) +} +else +{ +
+ @foreach (var exception in receivedExceptions) + { +
+ @exception.Message +
+ } +
+} + +@code { + List receivedExceptions = new(); + + protected override Task OnErrorAsync(Exception exception) + { + receivedExceptions.Add(exception); + return base.OnErrorAsync(exception); + } + + public new void Recover() + { + receivedExceptions.Clear(); + base.Recover(); + } +} diff --git a/src/Components/test/testassets/BasicTestApp/ErrorBoundaryTest/DelayedErrorCausingChild.razor b/src/Components/test/testassets/BasicTestApp/ErrorBoundaryTest/DelayedErrorCausingChild.razor new file mode 100644 index 000000000000..8af2915db75d --- /dev/null +++ b/src/Components/test/testassets/BasicTestApp/ErrorBoundaryTest/DelayedErrorCausingChild.razor @@ -0,0 +1,14 @@ +

This is a child component

+ +@code { + [Parameter] public bool ThrowOnParametersSetAsync { get; set; } + + protected override async Task OnParametersSetAsync() + { + if (ThrowOnParametersSetAsync) + { + await Task.Delay(500); + throw new InvalidTimeZoneException("Delayed asynchronous exception in OnParametersSetAsync"); + } + } +} diff --git a/src/Components/test/testassets/BasicTestApp/ErrorBoundaryTest/ErrorBoundaryCases.razor b/src/Components/test/testassets/BasicTestApp/ErrorBoundaryTest/ErrorBoundaryCases.razor new file mode 100644 index 000000000000..3b87c1e7c54f --- /dev/null +++ b/src/Components/test/testassets/BasicTestApp/ErrorBoundaryTest/ErrorBoundaryCases.razor @@ -0,0 +1,146 @@ +

Event handlers

+

These errors will be caught by the closest error boundary ancestor.

+ + + +
+

Lifecycle methods

+

These errors will be caught by the closest error boundary ancestor.

+
+
+
+
+
+
+ + + + + + + +
+

Rendering

+

These errors will be caught by the closest error boundary ancestor.

+ +@if (throwWhileRendering) +{ + throw new InvalidTimeZoneException($"Exception from {nameof(BuildRenderTree)}"); +} + +
+

Custom error boundary

+

This shows how to create a common custom error UI by subclassing ErrorBoundary.

+
+ + + + +
+ +
+

Custom error boundary that tries to ignore errors

+

This shows that, even if a custom error boundary tries to continue rendering in a non-error state after an error, the subtree will be forcibly rebuilt.

+
+ + + +
+ +
+

Exception inline in error boundary markup

+

This shows that, if an ErrorBoundary itself fails while rendering its own ChildContent, then it can catch its own exception. But if the error comes from the error content, this triggers the "infinite error loop" detection logic and becomes fatal.

+
+ + + @if (throwInline) { throw new InvalidTimeZoneException("Inline exception"); } +

Hello!

+
+ + @if (throwInErrorContent) { throw new InvalidTimeZoneException("Inline exception in error content"); } +

There was an error: @context

+
+
+ + +
+ +
+

Errors after disposal

+

Long-running tasks could fail after the component has been removed from the tree. We still want these failures to be captured by the error boundary they were inside when the task began, even if that error boundary itself has also since been disposed. Otherwise, error handling would behave differently based on whether the user has navigated away while processing was in flight, which would be very unexpected and hard to handle.

+
+ @if (!disposalTestRemoveErrorBoundary) + { + + @if (!disposalTestRemoveComponent) + { + + } + + } + + +
+ +
+

Multiple child errors

+

If several child components trigger asynchronous errors, the error boundary will receive error notifications even when it's already in an errored state. This needs to behave sensibly.

+
+ + + + + + +
+ +@code { + private bool throwInOnParametersSet; + private bool throwInOnParametersSetAsync; + private bool throwInOnParametersSetViaCascading; + private bool throwInOnParametersSetAsyncViaCascading; + private bool throwInOnAfterRender; + private bool throwInOnAfterRenderAsync; + private bool throwWhileRendering; + private bool throwInline; + private bool throwInErrorContent; + private CustomErrorBoundary customErrorBoundary; + private bool disposalTestRemoveComponent; + private bool disposalTestRemoveErrorBoundary; + private bool disposalTestBeginDelayedError; + private bool multipleChildrenBeginDelayedError; + + void EventHandlerErrorSync() + => throw new InvalidTimeZoneException("Synchronous error from event handler"); + + async Task EventHandlerErrorAsync() + { + await Task.Yield(); + throw new InvalidTimeZoneException("Asynchronous error from event handler"); + } + + async Task ThrowAfterDisposalOfComponent() + { + // Begin an async process that will result in an exception from a child component + disposalTestBeginDelayedError = true; + await Task.Yield(); + + // Before it completes, dispose that child component + disposalTestRemoveComponent = true; + } + + async Task ThrowAfterDisposalOfErrorBoundary() + { + // Begin an async process that will result in an exception from a child component + disposalTestBeginDelayedError = true; + await Task.Yield(); + + // Before it completes, dispose its enclosing error boundary + disposalTestRemoveErrorBoundary = true; + } +} diff --git a/src/Components/test/testassets/BasicTestApp/ErrorBoundaryTest/ErrorBoundaryContainer.razor b/src/Components/test/testassets/BasicTestApp/ErrorBoundaryTest/ErrorBoundaryContainer.razor new file mode 100644 index 000000000000..2f6d6f1daa79 --- /dev/null +++ b/src/Components/test/testassets/BasicTestApp/ErrorBoundaryTest/ErrorBoundaryContainer.razor @@ -0,0 +1,13 @@ +
+ + + +
+ +
+ +Recover + +@code { + ErrorBoundary errorBoundary; +} diff --git a/src/Components/test/testassets/BasicTestApp/ErrorBoundaryTest/ErrorCausingChild.razor b/src/Components/test/testassets/BasicTestApp/ErrorBoundaryTest/ErrorCausingChild.razor new file mode 100644 index 000000000000..876ed2170ef3 --- /dev/null +++ b/src/Components/test/testassets/BasicTestApp/ErrorBoundaryTest/ErrorCausingChild.razor @@ -0,0 +1,48 @@ +@code { + [Parameter] public bool ThrowOnParametersSet { get; set; } + [Parameter] public bool ThrowOnParametersSetAsync { get; set; } + [Parameter] public bool ThrowOnAfterRender { get; set; } + [Parameter] public bool ThrowOnAfterRenderAsync { get; set; } + + [CascadingParameter(Name = nameof(ThrowOnCascadingParameterNotification))] + public bool ThrowOnCascadingParameterNotification { get; set; } + + [CascadingParameter(Name = nameof(ThrowOnCascadingParameterNotificationAsync))] + public bool ThrowOnCascadingParameterNotificationAsync { get; set; } + + protected override void OnParametersSet() + { + if (ThrowOnParametersSet || ThrowOnCascadingParameterNotification) + { + throw new InvalidTimeZoneException("Synchronous exception in OnParametersSet"); + } + } + + protected override async Task OnParametersSetAsync() + { + await Task.Yield(); + + if (ThrowOnParametersSetAsync || ThrowOnCascadingParameterNotificationAsync) + { + throw new InvalidTimeZoneException("Asynchronous exception in OnParametersSetAsync"); + } + } + + protected override void OnAfterRender(bool firstRender) + { + if (ThrowOnAfterRender) + { + throw new InvalidTimeZoneException("Synchronous exception in OnAfterRender"); + } + } + + protected override async Task OnAfterRenderAsync(bool firstRender) + { + await Task.Yield(); + + if (ThrowOnAfterRenderAsync) + { + throw new InvalidTimeZoneException("Asynchronous exception in OnAfterRenderAsync"); + } + } +} diff --git a/src/Components/test/testassets/BasicTestApp/ErrorBoundaryTest/ErrorCausingCounter.razor b/src/Components/test/testassets/BasicTestApp/ErrorBoundaryTest/ErrorCausingCounter.razor new file mode 100644 index 000000000000..b4fdf4f82797 --- /dev/null +++ b/src/Components/test/testassets/BasicTestApp/ErrorBoundaryTest/ErrorCausingCounter.razor @@ -0,0 +1,14 @@ +

+ Count: @currentCount + + +

+ +@code { + private int currentCount; + + private void Throw() + { + throw new InvalidTimeZoneException($"Exception from {nameof(ErrorCausingCounter)}"); + } +} diff --git a/src/Components/test/testassets/BasicTestApp/ErrorBoundaryTest/ErrorIgnorer.razor b/src/Components/test/testassets/BasicTestApp/ErrorBoundaryTest/ErrorIgnorer.razor new file mode 100644 index 000000000000..1bb5bdfacb92 --- /dev/null +++ b/src/Components/test/testassets/BasicTestApp/ErrorBoundaryTest/ErrorIgnorer.razor @@ -0,0 +1,14 @@ +@inherits ErrorBoundaryBase +@* + A badly-behaved error boundary that tries to keep using its same ChildContent even after an error. + This is to check we still don't retain the descendant component instances. +*@ +@ChildContent + +@code { + protected override Task OnErrorAsync(Exception exception) + { + // Ignore it + return Task.CompletedTask; + } +} diff --git a/src/Components/test/testassets/BasicTestApp/Index.razor b/src/Components/test/testassets/BasicTestApp/Index.razor index 34a05f91dac4..ab3e6cdae04b 100644 --- a/src/Components/test/testassets/BasicTestApp/Index.razor +++ b/src/Components/test/testassets/BasicTestApp/Index.razor @@ -25,6 +25,7 @@ + diff --git a/src/Components/test/testassets/BasicTestApp/wwwroot/style.css b/src/Components/test/testassets/BasicTestApp/wwwroot/style.css index ea9900430b1c..232be009974f 100644 --- a/src/Components/test/testassets/BasicTestApp/wwwroot/style.css +++ b/src/Components/test/testassets/BasicTestApp/wwwroot/style.css @@ -29,3 +29,13 @@ .validation-message { color: red; } + +.blazor-error-boundary { + background: url(data:image/svg+xml;base64,PHN2ZyB3aWR0aD0iNTYiIGhlaWdodD0iNDkiIHhtbG5zPSJodHRwOi8vd3d3LnczLm9yZy8yMDAwL3N2ZyIgeG1sbnM6eGxpbms9Imh0dHA6Ly93d3cudzMub3JnLzE5OTkveGxpbmsiIG92ZXJmbG93PSJoaWRkZW4iPjxkZWZzPjxjbGlwUGF0aCBpZD0iY2xpcDAiPjxyZWN0IHg9IjIzNSIgeT0iNTEiIHdpZHRoPSI1NiIgaGVpZ2h0PSI0OSIvPjwvY2xpcFBhdGg+PC9kZWZzPjxnIGNsaXAtcGF0aD0idXJsKCNjbGlwMCkiIHRyYW5zZm9ybT0idHJhbnNsYXRlKC0yMzUgLTUxKSI+PHBhdGggZD0iTTI2My41MDYgNTFDMjY0LjcxNyA1MSAyNjUuODEzIDUxLjQ4MzcgMjY2LjYwNiA1Mi4yNjU4TDI2Ny4wNTIgNTIuNzk4NyAyNjcuNTM5IDUzLjYyODMgMjkwLjE4NSA5Mi4xODMxIDI5MC41NDUgOTIuNzk1IDI5MC42NTYgOTIuOTk2QzI5MC44NzcgOTMuNTEzIDI5MSA5NC4wODE1IDI5MSA5NC42NzgyIDI5MSA5Ny4wNjUxIDI4OS4wMzggOTkgMjg2LjYxNyA5OUwyNDAuMzgzIDk5QzIzNy45NjMgOTkgMjM2IDk3LjA2NTEgMjM2IDk0LjY3ODIgMjM2IDk0LjM3OTkgMjM2LjAzMSA5NC4wODg2IDIzNi4wODkgOTMuODA3MkwyMzYuMzM4IDkzLjAxNjIgMjM2Ljg1OCA5Mi4xMzE0IDI1OS40NzMgNTMuNjI5NCAyNTkuOTYxIDUyLjc5ODUgMjYwLjQwNyA1Mi4yNjU4QzI2MS4yIDUxLjQ4MzcgMjYyLjI5NiA1MSAyNjMuNTA2IDUxWk0yNjMuNTg2IDY2LjAxODNDMjYwLjczNyA2Ni4wMTgzIDI1OS4zMTMgNjcuMTI0NSAyNTkuMzEzIDY5LjMzNyAyNTkuMzEzIDY5LjYxMDIgMjU5LjMzMiA2OS44NjA4IDI1OS4zNzEgNzAuMDg4N0wyNjEuNzk1IDg0LjAxNjEgMjY1LjM4IDg0LjAxNjEgMjY3LjgyMSA2OS43NDc1QzI2Ny44NiA2OS43MzA5IDI2Ny44NzkgNjkuNTg3NyAyNjcuODc5IDY5LjMxNzkgMjY3Ljg3OSA2Ny4xMTgyIDI2Ni40NDggNjYuMDE4MyAyNjMuNTg2IDY2LjAxODNaTTI2My41NzYgODYuMDU0N0MyNjEuMDQ5IDg2LjA1NDcgMjU5Ljc4NiA4Ny4zMDA1IDI1OS43ODYgODkuNzkyMSAyNTkuNzg2IDkyLjI4MzcgMjYxLjA0OSA5My41Mjk1IDI2My41NzYgOTMuNTI5NSAyNjYuMTE2IDkzLjUyOTUgMjY3LjM4NyA5Mi4yODM3IDI2Ny4zODcgODkuNzkyMSAyNjcuMzg3IDg3LjMwMDUgMjY2LjExNiA4Ni4wNTQ3IDI2My41NzYgODYuMDU0N1oiIGZpbGw9IiNGRkU1MDAiIGZpbGwtcnVsZT0iZXZlbm9kZCIvPjwvZz48L3N2Zz4=) no-repeat 1rem/1.8rem, #b32121; + padding: 1rem 1rem 1rem 3.7rem; + color: white; +} + + .blazor-error-boundary::after { + content: "An error has occurred." + } diff --git a/src/Mvc/Mvc.ViewFeatures/src/DependencyInjection/MvcViewFeaturesMvcCoreBuilderExtensions.cs b/src/Mvc/Mvc.ViewFeatures/src/DependencyInjection/MvcViewFeaturesMvcCoreBuilderExtensions.cs index d98679b5e865..94d5c80c9c40 100644 --- a/src/Mvc/Mvc.ViewFeatures/src/DependencyInjection/MvcViewFeaturesMvcCoreBuilderExtensions.cs +++ b/src/Mvc/Mvc.ViewFeatures/src/DependencyInjection/MvcViewFeaturesMvcCoreBuilderExtensions.cs @@ -8,6 +8,7 @@ using Microsoft.AspNetCore.Components.Lifetime; using Microsoft.AspNetCore.Components.Rendering; using Microsoft.AspNetCore.Components.Routing; +using Microsoft.AspNetCore.Components.Web; using Microsoft.AspNetCore.Mvc; using Microsoft.AspNetCore.Mvc.ApplicationModels; using Microsoft.AspNetCore.Mvc.ApplicationParts; @@ -240,6 +241,7 @@ internal static void AddViewServices(IServiceCollection services) services.TryAddScoped(); services.TryAddScoped(); services.TryAddScoped(sp => sp.GetRequiredService().State); + services.TryAddScoped(); services.TryAddTransient(); diff --git a/src/Mvc/Mvc.ViewFeatures/src/RazorComponents/PrerenderingErrorBoundaryLogger.cs b/src/Mvc/Mvc.ViewFeatures/src/RazorComponents/PrerenderingErrorBoundaryLogger.cs new file mode 100644 index 000000000000..741bd2f88dd3 --- /dev/null +++ b/src/Mvc/Mvc.ViewFeatures/src/RazorComponents/PrerenderingErrorBoundaryLogger.cs @@ -0,0 +1,31 @@ +// Copyright (c) .NET Foundation. All rights reserved. +// Licensed under the Apache License, Version 2.0. See License.txt in the project root for license information. + +using System; +using System.Threading.Tasks; +using Microsoft.AspNetCore.Components.Web; +using Microsoft.Extensions.Logging; + +namespace Microsoft.AspNetCore.Mvc.ViewFeatures +{ + internal class PrerenderingErrorBoundaryLogger : IErrorBoundaryLogger + { + private static readonly Action _exceptionCaughtByErrorBoundary = LoggerMessage.Define( + LogLevel.Warning, + 100, + "Unhandled exception rendering component: {Message}"); + + private readonly ILogger _logger; + + public PrerenderingErrorBoundaryLogger(ILogger logger) + { + _logger = logger; + } + + public ValueTask LogErrorAsync(Exception exception) + { + _exceptionCaughtByErrorBoundary(_logger, exception.Message, exception); + return ValueTask.CompletedTask; + } + } +} diff --git a/src/ProjectTemplates/Web.ProjectTemplates/content/BlazorServerWeb-CSharp/wwwroot/css/site.css b/src/ProjectTemplates/Web.ProjectTemplates/content/BlazorServerWeb-CSharp/wwwroot/css/site.css index caebf2a4630d..3281f5f5aa65 100644 --- a/src/ProjectTemplates/Web.ProjectTemplates/content/BlazorServerWeb-CSharp/wwwroot/css/site.css +++ b/src/ProjectTemplates/Web.ProjectTemplates/content/BlazorServerWeb-CSharp/wwwroot/css/site.css @@ -48,3 +48,13 @@ a, .btn-link { right: 0.75rem; top: 0.5rem; } + +.blazor-error-boundary { + background: url(data:image/svg+xml;base64,PHN2ZyB3aWR0aD0iNTYiIGhlaWdodD0iNDkiIHhtbG5zPSJodHRwOi8vd3d3LnczLm9yZy8yMDAwL3N2ZyIgeG1sbnM6eGxpbms9Imh0dHA6Ly93d3cudzMub3JnLzE5OTkveGxpbmsiIG92ZXJmbG93PSJoaWRkZW4iPjxkZWZzPjxjbGlwUGF0aCBpZD0iY2xpcDAiPjxyZWN0IHg9IjIzNSIgeT0iNTEiIHdpZHRoPSI1NiIgaGVpZ2h0PSI0OSIvPjwvY2xpcFBhdGg+PC9kZWZzPjxnIGNsaXAtcGF0aD0idXJsKCNjbGlwMCkiIHRyYW5zZm9ybT0idHJhbnNsYXRlKC0yMzUgLTUxKSI+PHBhdGggZD0iTTI2My41MDYgNTFDMjY0LjcxNyA1MSAyNjUuODEzIDUxLjQ4MzcgMjY2LjYwNiA1Mi4yNjU4TDI2Ny4wNTIgNTIuNzk4NyAyNjcuNTM5IDUzLjYyODMgMjkwLjE4NSA5Mi4xODMxIDI5MC41NDUgOTIuNzk1IDI5MC42NTYgOTIuOTk2QzI5MC44NzcgOTMuNTEzIDI5MSA5NC4wODE1IDI5MSA5NC42NzgyIDI5MSA5Ny4wNjUxIDI4OS4wMzggOTkgMjg2LjYxNyA5OUwyNDAuMzgzIDk5QzIzNy45NjMgOTkgMjM2IDk3LjA2NTEgMjM2IDk0LjY3ODIgMjM2IDk0LjM3OTkgMjM2LjAzMSA5NC4wODg2IDIzNi4wODkgOTMuODA3MkwyMzYuMzM4IDkzLjAxNjIgMjM2Ljg1OCA5Mi4xMzE0IDI1OS40NzMgNTMuNjI5NCAyNTkuOTYxIDUyLjc5ODUgMjYwLjQwNyA1Mi4yNjU4QzI2MS4yIDUxLjQ4MzcgMjYyLjI5NiA1MSAyNjMuNTA2IDUxWk0yNjMuNTg2IDY2LjAxODNDMjYwLjczNyA2Ni4wMTgzIDI1OS4zMTMgNjcuMTI0NSAyNTkuMzEzIDY5LjMzNyAyNTkuMzEzIDY5LjYxMDIgMjU5LjMzMiA2OS44NjA4IDI1OS4zNzEgNzAuMDg4N0wyNjEuNzk1IDg0LjAxNjEgMjY1LjM4IDg0LjAxNjEgMjY3LjgyMSA2OS43NDc1QzI2Ny44NiA2OS43MzA5IDI2Ny44NzkgNjkuNTg3NyAyNjcuODc5IDY5LjMxNzkgMjY3Ljg3OSA2Ny4xMTgyIDI2Ni40NDggNjYuMDE4MyAyNjMuNTg2IDY2LjAxODNaTTI2My41NzYgODYuMDU0N0MyNjEuMDQ5IDg2LjA1NDcgMjU5Ljc4NiA4Ny4zMDA1IDI1OS43ODYgODkuNzkyMSAyNTkuNzg2IDkyLjI4MzcgMjYxLjA0OSA5My41Mjk1IDI2My41NzYgOTMuNTI5NSAyNjYuMTE2IDkzLjUyOTUgMjY3LjM4NyA5Mi4yODM3IDI2Ny4zODcgODkuNzkyMSAyNjcuMzg3IDg3LjMwMDUgMjY2LjExNiA4Ni4wNTQ3IDI2My41NzYgODYuMDU0N1oiIGZpbGw9IiNGRkU1MDAiIGZpbGwtcnVsZT0iZXZlbm9kZCIvPjwvZz48L3N2Zz4=) no-repeat 1rem/1.8rem, #b32121; + padding: 1rem 1rem 1rem 3.7rem; + color: white; +} + + .blazor-error-boundary::after { + content: "An error has occurred." + } diff --git a/src/ProjectTemplates/Web.ProjectTemplates/content/ComponentsWebAssembly-CSharp/Client/wwwroot/css/app.css b/src/ProjectTemplates/Web.ProjectTemplates/content/ComponentsWebAssembly-CSharp/Client/wwwroot/css/app.css index 82fc22a39385..b6e17eec7e49 100644 --- a/src/ProjectTemplates/Web.ProjectTemplates/content/ComponentsWebAssembly-CSharp/Client/wwwroot/css/app.css +++ b/src/ProjectTemplates/Web.ProjectTemplates/content/ComponentsWebAssembly-CSharp/Client/wwwroot/css/app.css @@ -48,3 +48,13 @@ a, .btn-link { right: 0.75rem; top: 0.5rem; } + +.blazor-error-boundary { + background: url(data:image/svg+xml;base64,PHN2ZyB3aWR0aD0iNTYiIGhlaWdodD0iNDkiIHhtbG5zPSJodHRwOi8vd3d3LnczLm9yZy8yMDAwL3N2ZyIgeG1sbnM6eGxpbms9Imh0dHA6Ly93d3cudzMub3JnLzE5OTkveGxpbmsiIG92ZXJmbG93PSJoaWRkZW4iPjxkZWZzPjxjbGlwUGF0aCBpZD0iY2xpcDAiPjxyZWN0IHg9IjIzNSIgeT0iNTEiIHdpZHRoPSI1NiIgaGVpZ2h0PSI0OSIvPjwvY2xpcFBhdGg+PC9kZWZzPjxnIGNsaXAtcGF0aD0idXJsKCNjbGlwMCkiIHRyYW5zZm9ybT0idHJhbnNsYXRlKC0yMzUgLTUxKSI+PHBhdGggZD0iTTI2My41MDYgNTFDMjY0LjcxNyA1MSAyNjUuODEzIDUxLjQ4MzcgMjY2LjYwNiA1Mi4yNjU4TDI2Ny4wNTIgNTIuNzk4NyAyNjcuNTM5IDUzLjYyODMgMjkwLjE4NSA5Mi4xODMxIDI5MC41NDUgOTIuNzk1IDI5MC42NTYgOTIuOTk2QzI5MC44NzcgOTMuNTEzIDI5MSA5NC4wODE1IDI5MSA5NC42NzgyIDI5MSA5Ny4wNjUxIDI4OS4wMzggOTkgMjg2LjYxNyA5OUwyNDAuMzgzIDk5QzIzNy45NjMgOTkgMjM2IDk3LjA2NTEgMjM2IDk0LjY3ODIgMjM2IDk0LjM3OTkgMjM2LjAzMSA5NC4wODg2IDIzNi4wODkgOTMuODA3MkwyMzYuMzM4IDkzLjAxNjIgMjM2Ljg1OCA5Mi4xMzE0IDI1OS40NzMgNTMuNjI5NCAyNTkuOTYxIDUyLjc5ODUgMjYwLjQwNyA1Mi4yNjU4QzI2MS4yIDUxLjQ4MzcgMjYyLjI5NiA1MSAyNjMuNTA2IDUxWk0yNjMuNTg2IDY2LjAxODNDMjYwLjczNyA2Ni4wMTgzIDI1OS4zMTMgNjcuMTI0NSAyNTkuMzEzIDY5LjMzNyAyNTkuMzEzIDY5LjYxMDIgMjU5LjMzMiA2OS44NjA4IDI1OS4zNzEgNzAuMDg4N0wyNjEuNzk1IDg0LjAxNjEgMjY1LjM4IDg0LjAxNjEgMjY3LjgyMSA2OS43NDc1QzI2Ny44NiA2OS43MzA5IDI2Ny44NzkgNjkuNTg3NyAyNjcuODc5IDY5LjMxNzkgMjY3Ljg3OSA2Ny4xMTgyIDI2Ni40NDggNjYuMDE4MyAyNjMuNTg2IDY2LjAxODNaTTI2My41NzYgODYuMDU0N0MyNjEuMDQ5IDg2LjA1NDcgMjU5Ljc4NiA4Ny4zMDA1IDI1OS43ODYgODkuNzkyMSAyNTkuNzg2IDkyLjI4MzcgMjYxLjA0OSA5My41Mjk1IDI2My41NzYgOTMuNTI5NSAyNjYuMTE2IDkzLjUyOTUgMjY3LjM4NyA5Mi4yODM3IDI2Ny4zODcgODkuNzkyMSAyNjcuMzg3IDg3LjMwMDUgMjY2LjExNiA4Ni4wNTQ3IDI2My41NzYgODYuMDU0N1oiIGZpbGw9IiNGRkU1MDAiIGZpbGwtcnVsZT0iZXZlbm9kZCIvPjwvZz48L3N2Zz4=) no-repeat 1rem/1.8rem, #b32121; + padding: 1rem 1rem 1rem 3.7rem; + color: white; +} + + .blazor-error-boundary::after { + content: "An error has occurred." + }