Skip to content

Commit

Permalink
Add DispatchExceptionAsync to ComponentBase (#46074)
Browse files Browse the repository at this point in the history
* Add DispatchExceptionAsync

* add testing

* Update src/Components/Components/src/ComponentBase.cs

Co-authored-by: Steve Sanderson <SteveSandersonMS@users.noreply.github.com>

* 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 <SteveSandersonMS@users.noreply.github.com>
  • Loading branch information
Nick-Stanton and SteveSandersonMS authored Jan 21, 2023
1 parent 1078da9 commit 9ec0753
Show file tree
Hide file tree
Showing 7 changed files with 81 additions and 0 deletions.
13 changes: 13 additions & 0 deletions src/Components/Components/src/ComponentBase.cs
Original file line number Diff line number Diff line change
Expand Up @@ -175,6 +175,19 @@ protected Task InvokeAsync(Action workItem)
protected Task InvokeAsync(Func<Task> workItem)
=> _renderHandle.Dispatcher.InvokeAsync(workItem);

/// <summary>
/// Treats the supplied <paramref name="exception"/> 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.
/// </summary>
/// <param name="exception">The <see cref="Exception"/> that will be dispatched to the renderer.</param>
/// <returns>A <see cref="Task"/> that will be completed when the exception has finished dispatching.</returns>
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
Expand Down
2 changes: 2 additions & 0 deletions src/Components/Components/src/PublicAPI.Unshipped.txt
Original file line number Diff line number Diff line change
@@ -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!
12 changes: 12 additions & 0 deletions src/Components/Components/src/RenderHandle.cs
Original file line number Diff line number Diff line change
Expand Up @@ -65,6 +65,18 @@ public void Render(RenderFragment renderFragment)
_renderer.AddToRenderQueue(_componentId, renderFragment);
}

/// <summary>
/// Dispatches an <see cref="Exception"/> to the <see cref="Renderer"/>.
/// </summary>
/// <param name="exception">The <see cref="Exception"/> that will be dispatched to the renderer.</param>
/// <returns>A <see cref="Task"/> that will be completed when the exception has finished dispatching.</returns>
public Task DispatchExceptionAsync(Exception exception)
{
var renderer = _renderer;
var componentId = _componentId;
return Dispatcher.InvokeAsync(() => renderer!.HandleComponentException(exception, componentId));
}

[DoesNotReturn]
private static void ThrowNotInitialized()
{
Expand Down
3 changes: 3 additions & 0 deletions src/Components/Components/src/RenderTree/Renderer.cs
Original file line number Diff line number Diff line change
Expand Up @@ -926,6 +926,9 @@ private void UpdateRenderTreeToMatchClientState(ulong eventHandlerId, EventField
}
}

internal void HandleComponentException(Exception exception, int componentId)
=> HandleExceptionViaErrorBoundary(exception, GetRequiredComponentState(componentId));

/// <summary>
/// If the exception can be routed to an error boundary around <paramref name="errorSourceOrNull"/>, do so.
/// Otherwise handle it as fatal.
Expand Down
30 changes: 30 additions & 0 deletions src/Components/Components/test/RendererTest.cs
Original file line number Diff line number Diff line change
Expand Up @@ -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()
{
Expand Down Expand Up @@ -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
Expand Down
2 changes: 2 additions & 0 deletions src/Components/test/E2ETest/Tests/ErrorBoundaryTest.cs
Original file line number Diff line number Diff line change
Expand Up @@ -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"));
Expand Down
Original file line number Diff line number Diff line change
Expand Up @@ -99,6 +99,14 @@
<button class="throw-in-children" @onclick="@(() => { multipleChildrenBeginDelayedError = true; })">Cause multiple errors</button>
</div>

<hr />
<h2>Dispatch exception to renderer</h2>
<p>Use DispatchExceptionAsync to see if exceptions are correctly dispatched to the renderer.</p>
<div id="exception-dispatch-async">
<button id="dispatch-sync-exception" @onclick=SyncExceptionDispatch>Cause exception from sync context</button>
<button id="dispatch-async-exception" @onclick=AsyncExceptionDispatch>Cause exception from async context</button>
</div>

@code {
private bool throwInOnParametersSet;
private bool throwInOnParametersSetAsync;
Expand Down Expand Up @@ -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"));
}
}

0 comments on commit 9ec0753

Please sign in to comment.