Skip to content

Commit 9ec0753

Browse files
Add DispatchExceptionAsync to ComponentBase (#46074)
* Add DispatchExceptionAsync * add testing * Update src/Components/Components/src/ComponentBase.cs Co-authored-by: Steve Sanderson <[email protected]> * Remove ref since ComponentBase doesn't have access * Simplify ErrorBoundary test cases * API feedback: make RenderHandle.DispatchExceptionAsync internal * Revert "API feedback: make RenderHandle.DispatchExceptionAsync internal" This reverts commit c15f5fe. * Add unit test Co-authored-by: Steve Sanderson <[email protected]>
1 parent 1078da9 commit 9ec0753

File tree

7 files changed

+81
-0
lines changed

7 files changed

+81
-0
lines changed

src/Components/Components/src/ComponentBase.cs

+13
Original file line numberDiff line numberDiff line change
@@ -175,6 +175,19 @@ protected Task InvokeAsync(Action workItem)
175175
protected Task InvokeAsync(Func<Task> workItem)
176176
=> _renderHandle.Dispatcher.InvokeAsync(workItem);
177177

178+
/// <summary>
179+
/// Treats the supplied <paramref name="exception"/> as being thrown by this component. This will cause the
180+
/// enclosing ErrorBoundary to transition into a failed state. If there is no enclosing ErrorBoundary,
181+
/// it will be regarded as an exception from the enclosing renderer.
182+
///
183+
/// This is useful if an exception occurs outside the component lifecycle methods, but you wish to treat it
184+
/// the same as an exception from a component lifecycle method.
185+
/// </summary>
186+
/// <param name="exception">The <see cref="Exception"/> that will be dispatched to the renderer.</param>
187+
/// <returns>A <see cref="Task"/> that will be completed when the exception has finished dispatching.</returns>
188+
protected Task DispatchExceptionAsync(Exception exception)
189+
=> _renderHandle.DispatchExceptionAsync(exception);
190+
178191
void IComponent.Attach(RenderHandle renderHandle)
179192
{
180193
// This implicitly means a ComponentBase can only be associated with a single
Original file line numberDiff line numberDiff line change
@@ -1,3 +1,5 @@
11
#nullable enable
2+
Microsoft.AspNetCore.Components.ComponentBase.DispatchExceptionAsync(System.Exception! exception) -> System.Threading.Tasks.Task!
3+
Microsoft.AspNetCore.Components.RenderHandle.DispatchExceptionAsync(System.Exception! exception) -> System.Threading.Tasks.Task!
24
*REMOVED*Microsoft.AspNetCore.Components.NavigationManager.ToAbsoluteUri(string! relativeUri) -> System.Uri!
35
Microsoft.AspNetCore.Components.NavigationManager.ToAbsoluteUri(string? relativeUri) -> System.Uri!

src/Components/Components/src/RenderHandle.cs

+12
Original file line numberDiff line numberDiff line change
@@ -65,6 +65,18 @@ public void Render(RenderFragment renderFragment)
6565
_renderer.AddToRenderQueue(_componentId, renderFragment);
6666
}
6767

68+
/// <summary>
69+
/// Dispatches an <see cref="Exception"/> to the <see cref="Renderer"/>.
70+
/// </summary>
71+
/// <param name="exception">The <see cref="Exception"/> that will be dispatched to the renderer.</param>
72+
/// <returns>A <see cref="Task"/> that will be completed when the exception has finished dispatching.</returns>
73+
public Task DispatchExceptionAsync(Exception exception)
74+
{
75+
var renderer = _renderer;
76+
var componentId = _componentId;
77+
return Dispatcher.InvokeAsync(() => renderer!.HandleComponentException(exception, componentId));
78+
}
79+
6880
[DoesNotReturn]
6981
private static void ThrowNotInitialized()
7082
{

src/Components/Components/src/RenderTree/Renderer.cs

+3
Original file line numberDiff line numberDiff line change
@@ -926,6 +926,9 @@ private void UpdateRenderTreeToMatchClientState(ulong eventHandlerId, EventField
926926
}
927927
}
928928

929+
internal void HandleComponentException(Exception exception, int componentId)
930+
=> HandleExceptionViaErrorBoundary(exception, GetRequiredComponentState(componentId));
931+
929932
/// <summary>
930933
/// If the exception can be routed to an error boundary around <paramref name="errorSourceOrNull"/>, do so.
931934
/// Otherwise handle it as fatal.

src/Components/Components/test/RendererTest.cs

+30
Original file line numberDiff line numberDiff line change
@@ -3612,6 +3612,22 @@ public async Task ExceptionsThrownAsynchronouslyDuringFirstRenderCanBeHandled()
36123612
Assert.Same(exception, Assert.Single(renderer.HandledExceptions).GetBaseException());
36133613
}
36143614

3615+
[Fact]
3616+
public async Task ExceptionsDispatchedOffSyncContextCanBeHandledAsync()
3617+
{
3618+
// Arrange
3619+
var renderer = new TestRenderer { ShouldHandleExceptions = true };
3620+
var component = new NestedAsyncComponent();
3621+
var exception = new InvalidTimeZoneException("Error from outside the sync context.");
3622+
3623+
// Act
3624+
renderer.AssignRootComponentId(component);
3625+
await component.ExternalExceptionDispatch(exception);
3626+
3627+
// Assert
3628+
Assert.Same(exception, Assert.Single(renderer.HandledExceptions).GetBaseException());
3629+
}
3630+
36153631
[Fact]
36163632
public async Task ExceptionsThrownAsynchronouslyAfterFirstRenderCanBeHandled()
36173633
{
@@ -5611,6 +5627,20 @@ public enum EventType
56115627
OnAfterRenderAsyncSync,
56125628
OnAfterRenderAsyncAsync,
56135629
}
5630+
5631+
public Task ExternalExceptionDispatch(Exception exception)
5632+
{
5633+
var tcs = new TaskCompletionSource();
5634+
Task.Run(async () =>
5635+
{
5636+
// Inside Task.Run, we're outside the call stack or task chain of the lifecycle method, so
5637+
// DispatchExceptionAsync is needed to get an exception back into the component
5638+
await DispatchExceptionAsync(exception);
5639+
tcs.SetResult();
5640+
});
5641+
5642+
return tcs.Task;
5643+
}
56145644
}
56155645

56165646
private class ComponentThatAwaitsTask : ComponentBase

src/Components/test/E2ETest/Tests/ErrorBoundaryTest.cs

+2
Original file line numberDiff line numberDiff line change
@@ -35,6 +35,8 @@ protected override void InitializeAsyncCore()
3535
[InlineData("afterrender-sync")]
3636
[InlineData("afterrender-async")]
3737
[InlineData("while-rendering")]
38+
[InlineData("dispatch-sync-exception")]
39+
[InlineData("dispatch-async-exception")]
3840
public void CanHandleExceptions(string triggerId)
3941
{
4042
var container = Browser.Exists(By.Id("error-boundary-container"));

src/Components/test/testassets/BasicTestApp/ErrorBoundaryTest/ErrorBoundaryCases.razor

+19
Original file line numberDiff line numberDiff line change
@@ -99,6 +99,14 @@
9999
<button class="throw-in-children" @onclick="@(() => { multipleChildrenBeginDelayedError = true; })">Cause multiple errors</button>
100100
</div>
101101

102+
<hr />
103+
<h2>Dispatch exception to renderer</h2>
104+
<p>Use DispatchExceptionAsync to see if exceptions are correctly dispatched to the renderer.</p>
105+
<div id="exception-dispatch-async">
106+
<button id="dispatch-sync-exception" @onclick=SyncExceptionDispatch>Cause exception from sync context</button>
107+
<button id="dispatch-async-exception" @onclick=AsyncExceptionDispatch>Cause exception from async context</button>
108+
</div>
109+
102110
@code {
103111
private bool throwInOnParametersSet;
104112
private bool throwInOnParametersSetAsync;
@@ -143,4 +151,15 @@
143151
// Before it completes, dispose its enclosing error boundary
144152
disposalTestRemoveErrorBoundary = true;
145153
}
154+
155+
async Task SyncExceptionDispatch()
156+
{
157+
await DispatchExceptionAsync(new InvalidTimeZoneException("Synchronous exception in SyncExceptionDispatch"));
158+
}
159+
160+
async Task AsyncExceptionDispatch()
161+
{
162+
await Task.Yield();
163+
await DispatchExceptionAsync(new InvalidTimeZoneException("Asynchronous exception in AsyncExceptionDispatch"));
164+
}
146165
}

0 commit comments

Comments
 (0)