diff --git a/src/Components/Components/src/ComponentBase.cs b/src/Components/Components/src/ComponentBase.cs index 1c172a30f54c..0496e1e028a5 100644 --- a/src/Components/Components/src/ComponentBase.cs +++ b/src/Components/Components/src/ComponentBase.cs @@ -175,6 +175,19 @@ protected Task InvokeAsync(Action workItem) protected Task InvokeAsync(Func workItem) => _renderHandle.Dispatcher.InvokeAsync(workItem); + /// + /// Treats the supplied as being thrown by this component. This will cause the + /// enclosing ErrorBoundary to transition into a failed state. If there is no enclosing ErrorBoundary, + /// it will be regarded as an exception from the enclosing renderer. + /// + /// This is useful if an exception occurs outside the component lifecycle methods, but you wish to treat it + /// the same as an exception from a component lifecycle method. + /// + /// The that will be dispatched to the renderer. + /// A that will be completed when the exception has finished dispatching. + protected Task DispatchExceptionAsync(Exception exception) + => _renderHandle.DispatchExceptionAsync(exception); + void IComponent.Attach(RenderHandle renderHandle) { // This implicitly means a ComponentBase can only be associated with a single diff --git a/src/Components/Components/src/PublicAPI.Unshipped.txt b/src/Components/Components/src/PublicAPI.Unshipped.txt index d71ee2d249fc..34aa8535011c 100644 --- a/src/Components/Components/src/PublicAPI.Unshipped.txt +++ b/src/Components/Components/src/PublicAPI.Unshipped.txt @@ -1,3 +1,5 @@ #nullable enable +Microsoft.AspNetCore.Components.ComponentBase.DispatchExceptionAsync(System.Exception! exception) -> System.Threading.Tasks.Task! +Microsoft.AspNetCore.Components.RenderHandle.DispatchExceptionAsync(System.Exception! exception) -> System.Threading.Tasks.Task! *REMOVED*Microsoft.AspNetCore.Components.NavigationManager.ToAbsoluteUri(string! relativeUri) -> System.Uri! Microsoft.AspNetCore.Components.NavigationManager.ToAbsoluteUri(string? relativeUri) -> System.Uri! \ No newline at end of file diff --git a/src/Components/Components/src/RenderHandle.cs b/src/Components/Components/src/RenderHandle.cs index 62edaab1d7a2..fdd61f32ef3a 100644 --- a/src/Components/Components/src/RenderHandle.cs +++ b/src/Components/Components/src/RenderHandle.cs @@ -65,6 +65,18 @@ public void Render(RenderFragment renderFragment) _renderer.AddToRenderQueue(_componentId, renderFragment); } + /// + /// Dispatches an to the . + /// + /// The that will be dispatched to the renderer. + /// A that will be completed when the exception has finished dispatching. + public Task DispatchExceptionAsync(Exception exception) + { + var renderer = _renderer; + var componentId = _componentId; + return Dispatcher.InvokeAsync(() => renderer!.HandleComponentException(exception, componentId)); + } + [DoesNotReturn] private static void ThrowNotInitialized() { diff --git a/src/Components/Components/src/RenderTree/Renderer.cs b/src/Components/Components/src/RenderTree/Renderer.cs index b0e8ad00e7b0..dd39f0936ac1 100644 --- a/src/Components/Components/src/RenderTree/Renderer.cs +++ b/src/Components/Components/src/RenderTree/Renderer.cs @@ -926,6 +926,9 @@ private void UpdateRenderTreeToMatchClientState(ulong eventHandlerId, EventField } } + internal void HandleComponentException(Exception exception, int componentId) + => HandleExceptionViaErrorBoundary(exception, GetRequiredComponentState(componentId)); + /// /// If the exception can be routed to an error boundary around , do so. /// Otherwise handle it as fatal. diff --git a/src/Components/Components/test/RendererTest.cs b/src/Components/Components/test/RendererTest.cs index a6efff3b8951..6816812c8713 100644 --- a/src/Components/Components/test/RendererTest.cs +++ b/src/Components/Components/test/RendererTest.cs @@ -3612,6 +3612,22 @@ public async Task ExceptionsThrownAsynchronouslyDuringFirstRenderCanBeHandled() Assert.Same(exception, Assert.Single(renderer.HandledExceptions).GetBaseException()); } + [Fact] + public async Task ExceptionsDispatchedOffSyncContextCanBeHandledAsync() + { + // Arrange + var renderer = new TestRenderer { ShouldHandleExceptions = true }; + var component = new NestedAsyncComponent(); + var exception = new InvalidTimeZoneException("Error from outside the sync context."); + + // Act + renderer.AssignRootComponentId(component); + await component.ExternalExceptionDispatch(exception); + + // Assert + Assert.Same(exception, Assert.Single(renderer.HandledExceptions).GetBaseException()); + } + [Fact] public async Task ExceptionsThrownAsynchronouslyAfterFirstRenderCanBeHandled() { @@ -5611,6 +5627,20 @@ public enum EventType OnAfterRenderAsyncSync, OnAfterRenderAsyncAsync, } + + public Task ExternalExceptionDispatch(Exception exception) + { + var tcs = new TaskCompletionSource(); + Task.Run(async () => + { + // Inside Task.Run, we're outside the call stack or task chain of the lifecycle method, so + // DispatchExceptionAsync is needed to get an exception back into the component + await DispatchExceptionAsync(exception); + tcs.SetResult(); + }); + + return tcs.Task; + } } private class ComponentThatAwaitsTask : ComponentBase diff --git a/src/Components/test/E2ETest/Tests/ErrorBoundaryTest.cs b/src/Components/test/E2ETest/Tests/ErrorBoundaryTest.cs index ba62819484cd..9ff97acdb14c 100644 --- a/src/Components/test/E2ETest/Tests/ErrorBoundaryTest.cs +++ b/src/Components/test/E2ETest/Tests/ErrorBoundaryTest.cs @@ -35,6 +35,8 @@ protected override void InitializeAsyncCore() [InlineData("afterrender-sync")] [InlineData("afterrender-async")] [InlineData("while-rendering")] + [InlineData("dispatch-sync-exception")] + [InlineData("dispatch-async-exception")] public void CanHandleExceptions(string triggerId) { var container = Browser.Exists(By.Id("error-boundary-container")); diff --git a/src/Components/test/testassets/BasicTestApp/ErrorBoundaryTest/ErrorBoundaryCases.razor b/src/Components/test/testassets/BasicTestApp/ErrorBoundaryTest/ErrorBoundaryCases.razor index 3b87c1e7c54f..bcc4aec249e0 100644 --- a/src/Components/test/testassets/BasicTestApp/ErrorBoundaryTest/ErrorBoundaryCases.razor +++ b/src/Components/test/testassets/BasicTestApp/ErrorBoundaryTest/ErrorBoundaryCases.razor @@ -99,6 +99,14 @@ +
+

Dispatch exception to renderer

+

Use DispatchExceptionAsync to see if exceptions are correctly dispatched to the renderer.

+
+ + +
+ @code { private bool throwInOnParametersSet; private bool throwInOnParametersSetAsync; @@ -143,4 +151,15 @@ // Before it completes, dispose its enclosing error boundary disposalTestRemoveErrorBoundary = true; } + + async Task SyncExceptionDispatch() + { + await DispatchExceptionAsync(new InvalidTimeZoneException("Synchronous exception in SyncExceptionDispatch")); + } + + async Task AsyncExceptionDispatch() + { + await Task.Yield(); + await DispatchExceptionAsync(new InvalidTimeZoneException("Asynchronous exception in AsyncExceptionDispatch")); + } }