Skip to content

Add DispatchExceptionAsync to ComponentBase #46074

New issue

Have a question about this project? Sign up for a free GitHub account to open an issue and contact its maintainers and the community.

By clicking “Sign up for GitHub”, you agree to our terms of service and privacy statement. We’ll occasionally send you account related emails.

Already on GitHub? Sign in to your account

Merged
merged 9 commits into from
Jan 21, 2023
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"));
}
}