From 1c283b92a938b5108e2a06ecae16fd4d7bb9824c Mon Sep 17 00:00:00 2001 From: Nick Stanton Date: Thu, 12 Jan 2023 14:51:21 -0700 Subject: [PATCH 1/8] Add DispatchExceptionAsync --- src/Components/Components/src/ComponentBase.cs | 8 ++++++++ .../Components/src/PublicAPI.Unshipped.txt | 2 ++ src/Components/Components/src/RenderHandle.cs | 12 ++++++++++++ src/Components/Components/src/RenderTree/Renderer.cs | 3 +++ 4 files changed, 25 insertions(+) diff --git a/src/Components/Components/src/ComponentBase.cs b/src/Components/Components/src/ComponentBase.cs index 1c172a30f54c..0287c1f25f2d 100644 --- a/src/Components/Components/src/ComponentBase.cs +++ b/src/Components/Components/src/ComponentBase.cs @@ -175,6 +175,14 @@ protected Task InvokeAsync(Action workItem) protected Task InvokeAsync(Func workItem) => _renderHandle.Dispatcher.InvokeAsync(workItem); + /// + /// Dispatches an to the renderer via the . + /// + /// 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 ea398838a45c..c378644242a1 100644 --- a/src/Components/Components/src/PublicAPI.Unshipped.txt +++ b/src/Components/Components/src/PublicAPI.Unshipped.txt @@ -1,10 +1,12 @@ #nullable enable +Microsoft.AspNetCore.Components.ComponentBase.DispatchExceptionAsync(System.Exception! exception) -> System.Threading.Tasks.Task! Microsoft.AspNetCore.Components.NavigationManager.HistoryEntryState.get -> string? Microsoft.AspNetCore.Components.NavigationManager.HistoryEntryState.set -> void Microsoft.AspNetCore.Components.NavigationManager.NotifyLocationChangingAsync(string! uri, string? state, bool isNavigationIntercepted) -> System.Threading.Tasks.ValueTask Microsoft.AspNetCore.Components.NavigationManager.RegisterLocationChangingHandler(System.Func! locationChangingHandler) -> System.IDisposable! Microsoft.AspNetCore.Components.NavigationOptions.HistoryEntryState.get -> string? Microsoft.AspNetCore.Components.NavigationOptions.HistoryEntryState.init -> void +Microsoft.AspNetCore.Components.RenderHandle.DispatchExceptionAsync(System.Exception! exception) -> System.Threading.Tasks.Task! Microsoft.AspNetCore.Components.Rendering.RenderTreeBuilder.AddContent(int sequence, Microsoft.AspNetCore.Components.MarkupString? markupContent) -> void Microsoft.AspNetCore.Components.Routing.LocationChangedEventArgs.HistoryEntryState.get -> string? Microsoft.AspNetCore.Components.Routing.LocationChangingContext 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 c1fc2b4e643f..1f3a461d9812 100644 --- a/src/Components/Components/src/RenderTree/Renderer.cs +++ b/src/Components/Components/src/RenderTree/Renderer.cs @@ -937,6 +937,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. From 96cb753aa36e11c35286c7d48bf1d600319140b2 Mon Sep 17 00:00:00 2001 From: Nick Stanton Date: Wed, 18 Jan 2023 18:14:39 -0700 Subject: [PATCH 2/8] add testing --- .../test/E2ETest/Tests/ErrorBoundaryTest.cs | 2 + .../ErrorBoundaryCases.razor | 43 +++++++++++++++++++ 2 files changed, 45 insertions(+) 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..d76a2d8cf736 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,39 @@ // Before it completes, dispose its enclosing error boundary disposalTestRemoveErrorBoundary = true; } + + async Task SyncExceptionDispatch() + { + try + { + await InvokeAsync(() => + { + throw new InvalidTimeZoneException("Synchronous exception in SyncExceptionDispatch"); + }); + } + catch (Exception e) + { + await DispatchExceptionAsync(e); + } + } + + async Task AsyncExceptionDispatch() + { + try + { + await InvokeAsync(async () => + { + await ThrowExceptionAsync(); + }); + } + catch (Exception e) + { + await DispatchExceptionAsync(e); + } + } + + private Task ThrowExceptionAsync() + { + throw new InvalidTimeZoneException("Asynchronous exception in AsyncExceptionDispatch"); + } } From 506912271dcd4e3f7de8c3c82a9cd2a933e71c2d Mon Sep 17 00:00:00 2001 From: Nick Stanton Date: Thu, 19 Jan 2023 08:14:48 -0700 Subject: [PATCH 3/8] Update src/Components/Components/src/ComponentBase.cs Co-authored-by: Steve Sanderson --- src/Components/Components/src/ComponentBase.cs | 7 ++++++- 1 file changed, 6 insertions(+), 1 deletion(-) diff --git a/src/Components/Components/src/ComponentBase.cs b/src/Components/Components/src/ComponentBase.cs index 0287c1f25f2d..d8a3721baa56 100644 --- a/src/Components/Components/src/ComponentBase.cs +++ b/src/Components/Components/src/ComponentBase.cs @@ -176,7 +176,12 @@ protected Task InvokeAsync(Func workItem) => _renderHandle.Dispatcher.InvokeAsync(workItem); /// - /// Dispatches an to the renderer via the . + /// Treats the supplied as being thrown by this component. This will cause the + /// enclosing to transition into a failed state. If there is no enclosing , + /// 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. From 82cb84475d17fb86d08a6a5bad692f147afa9e77 Mon Sep 17 00:00:00 2001 From: Nick Stanton Date: Thu, 19 Jan 2023 14:01:45 -0700 Subject: [PATCH 4/8] Remove ref since ComponentBase doesn't have access --- src/Components/Components/src/ComponentBase.cs | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/src/Components/Components/src/ComponentBase.cs b/src/Components/Components/src/ComponentBase.cs index d8a3721baa56..0496e1e028a5 100644 --- a/src/Components/Components/src/ComponentBase.cs +++ b/src/Components/Components/src/ComponentBase.cs @@ -177,7 +177,7 @@ protected Task InvokeAsync(Func workItem) /// /// Treats the supplied as being thrown by this component. This will cause the - /// enclosing to transition into a failed state. If there is no enclosing , + /// 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 From f8f9a8476d65e056b6d1c8421849e82015d2bf32 Mon Sep 17 00:00:00 2001 From: Nick Stanton Date: Thu, 19 Jan 2023 14:16:30 -0700 Subject: [PATCH 5/8] Simplify ErrorBoundary test cases --- .../ErrorBoundaryCases.razor | 30 ++----------------- 1 file changed, 3 insertions(+), 27 deletions(-) diff --git a/src/Components/test/testassets/BasicTestApp/ErrorBoundaryTest/ErrorBoundaryCases.razor b/src/Components/test/testassets/BasicTestApp/ErrorBoundaryTest/ErrorBoundaryCases.razor index d76a2d8cf736..bcc4aec249e0 100644 --- a/src/Components/test/testassets/BasicTestApp/ErrorBoundaryTest/ErrorBoundaryCases.razor +++ b/src/Components/test/testassets/BasicTestApp/ErrorBoundaryTest/ErrorBoundaryCases.razor @@ -154,36 +154,12 @@ async Task SyncExceptionDispatch() { - try - { - await InvokeAsync(() => - { - throw new InvalidTimeZoneException("Synchronous exception in SyncExceptionDispatch"); - }); - } - catch (Exception e) - { - await DispatchExceptionAsync(e); - } + await DispatchExceptionAsync(new InvalidTimeZoneException("Synchronous exception in SyncExceptionDispatch")); } async Task AsyncExceptionDispatch() { - try - { - await InvokeAsync(async () => - { - await ThrowExceptionAsync(); - }); - } - catch (Exception e) - { - await DispatchExceptionAsync(e); - } - } - - private Task ThrowExceptionAsync() - { - throw new InvalidTimeZoneException("Asynchronous exception in AsyncExceptionDispatch"); + await Task.Yield(); + await DispatchExceptionAsync(new InvalidTimeZoneException("Asynchronous exception in AsyncExceptionDispatch")); } } From c15f5fe6925194e6f1a11a8893a5cea57c89d8da Mon Sep 17 00:00:00 2001 From: Nick Stanton Date: Thu, 19 Jan 2023 16:59:36 -0700 Subject: [PATCH 6/8] API feedback: make RenderHandle.DispatchExceptionAsync internal --- src/Components/Components/src/PublicAPI.Unshipped.txt | 3 +-- src/Components/Components/src/RenderHandle.cs | 2 +- 2 files changed, 2 insertions(+), 3 deletions(-) diff --git a/src/Components/Components/src/PublicAPI.Unshipped.txt b/src/Components/Components/src/PublicAPI.Unshipped.txt index 34aa8535011c..c48f67c1e6c7 100644 --- a/src/Components/Components/src/PublicAPI.Unshipped.txt +++ b/src/Components/Components/src/PublicAPI.Unshipped.txt @@ -1,5 +1,4 @@ #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 +Microsoft.AspNetCore.Components.NavigationManager.ToAbsoluteUri(string? relativeUri) -> System.Uri! diff --git a/src/Components/Components/src/RenderHandle.cs b/src/Components/Components/src/RenderHandle.cs index fdd61f32ef3a..77182008f204 100644 --- a/src/Components/Components/src/RenderHandle.cs +++ b/src/Components/Components/src/RenderHandle.cs @@ -70,7 +70,7 @@ public void Render(RenderFragment renderFragment) /// /// The that will be dispatched to the renderer. /// A that will be completed when the exception has finished dispatching. - public Task DispatchExceptionAsync(Exception exception) + internal Task DispatchExceptionAsync(Exception exception) { var renderer = _renderer; var componentId = _componentId; From b7df998036f043121941253a660392fe1d28064c Mon Sep 17 00:00:00 2001 From: Nick Stanton Date: Fri, 20 Jan 2023 09:35:59 -0700 Subject: [PATCH 7/8] Revert "API feedback: make RenderHandle.DispatchExceptionAsync internal" This reverts commit c15f5fe6925194e6f1a11a8893a5cea57c89d8da. --- src/Components/Components/src/PublicAPI.Unshipped.txt | 3 ++- src/Components/Components/src/RenderHandle.cs | 2 +- 2 files changed, 3 insertions(+), 2 deletions(-) diff --git a/src/Components/Components/src/PublicAPI.Unshipped.txt b/src/Components/Components/src/PublicAPI.Unshipped.txt index c48f67c1e6c7..34aa8535011c 100644 --- a/src/Components/Components/src/PublicAPI.Unshipped.txt +++ b/src/Components/Components/src/PublicAPI.Unshipped.txt @@ -1,4 +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! +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 77182008f204..fdd61f32ef3a 100644 --- a/src/Components/Components/src/RenderHandle.cs +++ b/src/Components/Components/src/RenderHandle.cs @@ -70,7 +70,7 @@ public void Render(RenderFragment renderFragment) ///
/// The that will be dispatched to the renderer. /// A that will be completed when the exception has finished dispatching. - internal Task DispatchExceptionAsync(Exception exception) + public Task DispatchExceptionAsync(Exception exception) { var renderer = _renderer; var componentId = _componentId; From 08d981ffd89114a35e641df566b4fe6e0e06f5fc Mon Sep 17 00:00:00 2001 From: Nick Stanton Date: Fri, 20 Jan 2023 15:00:04 -0700 Subject: [PATCH 8/8] Add unit test --- .../Components/test/RendererTest.cs | 30 +++++++++++++++++++ 1 file changed, 30 insertions(+) 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