From cddbc2e8885b9c1ff941ae9863afff04178cb44a Mon Sep 17 00:00:00 2001 From: Pranav K Date: Wed, 13 Feb 2019 14:22:46 -0800 Subject: [PATCH] Improve Components error handling (#7165) * Improve Components error handling * Change event handlers IHandleEvent, IHandleAfterEvent to be async. * Return faulted tasks to Renderer instead of handling exceptions in ComponentBase * Use ILogger in RemoteRenderer, and log to console in WebAssemblyRenderer * Cleaning up touched files Fixes https://github.com/aspnet/AspNetCore/issues/4964 --- .../WebAssemblyBlazorApplicationBuilder.cs | 7 +- .../Blazor/src/Hosting/WebAssemblyHost.cs | 10 +- .../src/Rendering/WebAssemblyRenderer.cs | 45 +- .../Build/test/RazorIntegrationTestBase.cs | 5 + .../Browser.JS/src/package-lock.json | 19 +- .../src/RendererRegistryEventDispatcher.cs | 8 +- .../perf/RenderTreeDiffBuilderBenchmark.cs | 5 + .../Components/src/ComponentBase.cs | 201 ++---- .../Components/src/IHandleAfterRender.cs | 7 +- src/Components/Components/src/IHandleEvent.cs | 7 +- .../src/Rendering/ComponentState.cs | 61 +- .../Components/src/Rendering/HtmlRenderer.cs | 61 +- .../Components/src/Rendering/Renderer.cs | 191 +++--- .../Components/test/ComponentBaseTest.cs | 148 ++++- .../Components/test/RenderTreeBuilderTest.cs | 3 + .../test/RenderTreeDiffBuilderTest.cs | 10 +- .../Components/test/RendererTest.cs | 604 ++++++++++++++++-- .../test/Rendering/HtmlRendererTests.cs | 60 +- .../Server/src/Circuits/CircuitHost.cs | 2 +- .../src/Circuits/DefaultCircuitFactory.cs | 9 +- .../Server/src/Circuits/RemoteRenderer.cs | 39 +- src/Components/Server/src/LoggerExtensions.cs | 29 + .../Server/test/Circuits/CircuitHostTest.cs | 3 +- .../test/Circuits/RenderBatchWriterTest.cs | 5 + src/Components/Shared/test/TestRenderer.cs | 40 +- .../SeleniumStandaloneServer.cs | 5 +- .../Interop.FunctionalTests/H2SpecTests.cs | 2 +- 27 files changed, 1088 insertions(+), 498 deletions(-) create mode 100644 src/Components/Server/src/LoggerExtensions.cs diff --git a/src/Components/Blazor/Blazor/src/Hosting/WebAssemblyBlazorApplicationBuilder.cs b/src/Components/Blazor/Blazor/src/Hosting/WebAssemblyBlazorApplicationBuilder.cs index 13d0c70602..a77d66ce13 100644 --- a/src/Components/Blazor/Blazor/src/Hosting/WebAssemblyBlazorApplicationBuilder.cs +++ b/src/Components/Blazor/Blazor/src/Hosting/WebAssemblyBlazorApplicationBuilder.cs @@ -3,6 +3,7 @@ using System; using System.Collections.Generic; +using System.Threading.Tasks; using Microsoft.AspNetCore.Blazor.Rendering; using Microsoft.AspNetCore.Components.Builder; @@ -35,13 +36,13 @@ namespace Microsoft.AspNetCore.Blazor.Hosting Entries.Add((componentType, domElementSelector)); } - public WebAssemblyRenderer CreateRenderer() + public async Task CreateRendererAsync() { var renderer = new WebAssemblyRenderer(Services); for (var i = 0; i < Entries.Count; i++) { - var entry = Entries[i]; - renderer.AddComponent(entry.componentType, entry.domElementSelector); + var (componentType, domElementSelector) = Entries[i]; + await renderer.AddComponentAsync(componentType, domElementSelector); } return renderer; diff --git a/src/Components/Blazor/Blazor/src/Hosting/WebAssemblyHost.cs b/src/Components/Blazor/Blazor/src/Hosting/WebAssemblyHost.cs index bda84cc2da..19e0e67f87 100644 --- a/src/Components/Blazor/Blazor/src/Hosting/WebAssemblyHost.cs +++ b/src/Components/Blazor/Blazor/src/Hosting/WebAssemblyHost.cs @@ -42,6 +42,11 @@ namespace Microsoft.AspNetCore.Blazor.Hosting JSRuntime.SetCurrentJSRuntime(_runtime); SetBrowserHttpMessageHandlerAsDefault(); + return StartAsyncAwaited(); + } + + private async Task StartAsyncAwaited() + { var scopeFactory = Services.GetRequiredService(); _scope = scopeFactory.CreateScope(); @@ -61,7 +66,7 @@ namespace Microsoft.AspNetCore.Blazor.Hosting var builder = new WebAssemblyBlazorApplicationBuilder(_scope.ServiceProvider); startup.Configure(builder, _scope.ServiceProvider); - _renderer = builder.CreateRenderer(); + _renderer = await builder.CreateRendererAsync(); } catch { @@ -76,9 +81,6 @@ namespace Microsoft.AspNetCore.Blazor.Hosting throw; } - - - return Task.CompletedTask; } public Task StopAsync(CancellationToken cancellationToken = default) diff --git a/src/Components/Blazor/Blazor/src/Rendering/WebAssemblyRenderer.cs b/src/Components/Blazor/Blazor/src/Rendering/WebAssemblyRenderer.cs index 70da290ab3..df9d65cff5 100644 --- a/src/Components/Blazor/Blazor/src/Rendering/WebAssemblyRenderer.cs +++ b/src/Components/Blazor/Blazor/src/Rendering/WebAssemblyRenderer.cs @@ -1,14 +1,13 @@ // Copyright (c) .NET Foundation. All rights reserved. // Licensed under the Apache License, Version 2.0. See License.txt in the project root for license information. +using System; +using System.Threading.Tasks; using Microsoft.AspNetCore.Components; using Microsoft.AspNetCore.Components.Browser; using Microsoft.AspNetCore.Components.Rendering; using Microsoft.JSInterop; using Mono.WebAssembly.Interop; -using System; -using System.Threading; -using System.Threading.Tasks; namespace Microsoft.AspNetCore.Blazor.Rendering { @@ -24,7 +23,7 @@ namespace Microsoft.AspNetCore.Blazor.Rendering /// Constructs an instance of . /// /// The to use when initializing components. - public WebAssemblyRenderer(IServiceProvider serviceProvider): base(serviceProvider) + public WebAssemblyRenderer(IServiceProvider serviceProvider) : base(serviceProvider) { // The browser renderer registers and unregisters itself with the static // registry. This works well with the WebAssembly runtime, and is simple for the @@ -38,11 +37,13 @@ namespace Microsoft.AspNetCore.Blazor.Rendering /// /// The type of the component. /// A CSS selector that uniquely identifies a DOM element. - public void AddComponent(string domElementSelector) - where TComponent: IComponent - { - AddComponent(typeof(TComponent), domElementSelector); - } + /// A that represents the asynchronous rendering of the added component. + /// + /// Callers of this method may choose to ignore the returned if they do not + /// want to await the rendering of the added component. + /// + public Task AddComponentAsync(string domElementSelector) where TComponent : IComponent + => AddComponentAsync(typeof(TComponent), domElementSelector); /// /// Associates the with the , @@ -50,7 +51,12 @@ namespace Microsoft.AspNetCore.Blazor.Rendering /// /// The type of the component. /// A CSS selector that uniquely identifies a DOM element. - public void AddComponent(Type componentType, string domElementSelector) + /// A that represents the asynchronous rendering of the added component. + /// + /// Callers of this method may choose to ignore the returned if they do not + /// want to await the rendering of the added component. + /// + public Task AddComponentAsync(Type componentType, string domElementSelector) { var component = InstantiateComponent(componentType); var componentId = AssignRootComponentId(component); @@ -66,7 +72,7 @@ namespace Microsoft.AspNetCore.Blazor.Rendering domElementSelector, componentId); - RenderRootComponent(componentId); + return RenderRootComponentAsync(componentId); } /// @@ -93,5 +99,22 @@ namespace Microsoft.AspNetCore.Blazor.Rendering throw new NotImplementedException($"{nameof(WebAssemblyRenderer)} is supported only with in-process JS runtimes."); } } + + /// + protected override void HandleException(Exception exception) + { + Console.Error.WriteLine($"Unhandled exception rendering component:"); + if (exception is AggregateException aggregateException) + { + foreach (var innerException in aggregateException.Flatten().InnerExceptions) + { + Console.Error.WriteLine(innerException); + } + } + else + { + Console.Error.WriteLine(exception); + } + } } } diff --git a/src/Components/Blazor/Build/test/RazorIntegrationTestBase.cs b/src/Components/Blazor/Build/test/RazorIntegrationTestBase.cs index ae1be48bf3..c05ca16c7b 100644 --- a/src/Components/Blazor/Build/test/RazorIntegrationTestBase.cs +++ b/src/Components/Blazor/Build/test/RazorIntegrationTestBase.cs @@ -443,6 +443,11 @@ namespace Microsoft.AspNetCore.Blazor.Build.Test public void AttachComponent(IComponent component) => AssignRootComponentId(component); + protected override void HandleException(Exception exception) + { + throw new NotImplementedException(); + } + protected override Task UpdateDisplayAsync(in RenderBatch renderBatch) { LatestBatchReferenceFrames = renderBatch.ReferenceFrames.ToArray(); diff --git a/src/Components/Browser.JS/src/package-lock.json b/src/Components/Browser.JS/src/package-lock.json index 57af8cb83d..3272ef3e77 100644 --- a/src/Components/Browser.JS/src/package-lock.json +++ b/src/Components/Browser.JS/src/package-lock.json @@ -1546,14 +1546,12 @@ "balanced-match": { "version": "1.0.0", "bundled": true, - "dev": true, - "optional": true + "dev": true }, "brace-expansion": { "version": "1.1.11", "bundled": true, "dev": true, - "optional": true, "requires": { "balanced-match": "^1.0.0", "concat-map": "0.0.1" @@ -1573,14 +1571,12 @@ "concat-map": { "version": "0.0.1", "bundled": true, - "dev": true, - "optional": true + "dev": true }, "console-control-strings": { "version": "1.1.0", "bundled": true, - "dev": true, - "optional": true + "dev": true }, "core-util-is": { "version": "1.0.2", @@ -1697,8 +1693,7 @@ "inherits": { "version": "2.0.3", "bundled": true, - "dev": true, - "optional": true + "dev": true }, "ini": { "version": "1.3.5", @@ -1724,7 +1719,6 @@ "version": "3.0.4", "bundled": true, "dev": true, - "optional": true, "requires": { "brace-expansion": "^1.1.7" } @@ -1732,14 +1726,12 @@ "minimist": { "version": "0.0.8", "bundled": true, - "dev": true, - "optional": true + "dev": true }, "minipass": { "version": "2.2.4", "bundled": true, "dev": true, - "optional": true, "requires": { "safe-buffer": "^5.1.1", "yallist": "^3.0.0" @@ -1758,7 +1750,6 @@ "version": "0.5.1", "bundled": true, "dev": true, - "optional": true, "requires": { "minimist": "0.0.8" } diff --git a/src/Components/Browser/src/RendererRegistryEventDispatcher.cs b/src/Components/Browser/src/RendererRegistryEventDispatcher.cs index 92b2966115..1004b1e1d4 100644 --- a/src/Components/Browser/src/RendererRegistryEventDispatcher.cs +++ b/src/Components/Browser/src/RendererRegistryEventDispatcher.cs @@ -1,9 +1,10 @@ // Copyright (c) .NET Foundation. All rights reserved. // Licensed under the Apache License, Version 2.0. See License.txt in the project root for license information. +using System; +using System.Threading.Tasks; using Microsoft.AspNetCore.Components.Rendering; using Microsoft.JSInterop; -using System; namespace Microsoft.AspNetCore.Components.Browser { @@ -16,12 +17,13 @@ namespace Microsoft.AspNetCore.Components.Browser /// For framework use only. /// [JSInvokable(nameof(DispatchEvent))] - public static void DispatchEvent( + public static Task DispatchEvent( BrowserEventDescriptor eventDescriptor, string eventArgsJson) { var eventArgs = ParseEventArgsJson(eventDescriptor.EventArgsType, eventArgsJson); var renderer = RendererRegistry.Current.Find(eventDescriptor.BrowserRendererId); - renderer.DispatchEvent( + + return renderer.DispatchEventAsync( eventDescriptor.ComponentId, eventDescriptor.EventHandlerId, eventArgs); diff --git a/src/Components/Components/perf/RenderTreeDiffBuilderBenchmark.cs b/src/Components/Components/perf/RenderTreeDiffBuilderBenchmark.cs index cace1e79bc..406491e8d5 100644 --- a/src/Components/Components/perf/RenderTreeDiffBuilderBenchmark.cs +++ b/src/Components/Components/perf/RenderTreeDiffBuilderBenchmark.cs @@ -91,6 +91,11 @@ namespace Microsoft.AspNetCore.Components.Performance { } + protected override void HandleException(Exception exception) + { + throw new NotImplementedException(); + } + protected override Task UpdateDisplayAsync(in RenderBatch renderBatch) => Task.CompletedTask; } diff --git a/src/Components/Components/src/ComponentBase.cs b/src/Components/Components/src/ComponentBase.cs index e492c81f17..7c8b491b10 100644 --- a/src/Components/Components/src/ComponentBase.cs +++ b/src/Components/Components/src/ComponentBase.cs @@ -32,7 +32,7 @@ namespace Microsoft.AspNetCore.Components private readonly RenderFragment _renderFragment; private RenderHandle _renderHandle; - private bool _hasCalledInit; + private bool _initialized; private bool _hasNeverRendered = true; private bool _hasPendingQueuedRender; @@ -177,188 +177,111 @@ namespace Microsoft.AspNetCore.Components public virtual Task SetParametersAsync(ParameterCollection parameters) { parameters.SetParameterProperties(this); - if (!_hasCalledInit) + if (!_initialized) { - return RunInitAndSetParameters(); + _initialized = true; + + return RunInitAndSetParametersAsync(); } else { - OnParametersSet(); - // If you override OnInitAsync or OnParametersSetAsync and return a noncompleted task, - // then by default we automatically re-render once each of those tasks completes. - var isAsync = false; - Task parametersTask = null; - (isAsync, parametersTask) = ProcessLifeCycletask(OnParametersSetAsync()); - StateHasChanged(); - // We call StateHasChanged here so that we render after OnParametersSet and after the - // synchronous part of OnParametersSetAsync has run, and in case there is async work - // we trigger another render. - if (isAsync) - { - return parametersTask; - } - - return Task.CompletedTask; + return CallOnParametersSetAsync(); } } - private async Task RunInitAndSetParameters() + private async Task RunInitAndSetParametersAsync() { - _hasCalledInit = true; - var initIsAsync = false; - OnInit(); - Task initTask = null; - (initIsAsync, initTask) = ProcessLifeCycletask(OnInitAsync()); - if (initIsAsync) + var task = OnInitAsync(); + + if (task.Status != TaskStatus.RanToCompletion && task.Status != TaskStatus.Canceled) { // Call state has changed here so that we render after the sync part of OnInitAsync has run // and wait for it to finish before we continue. If no async work has been done yet, we want // to defer calling StateHasChanged up until the first bit of async code happens or until - // the end. + // the end. Additionally, we want to avoid calling StateHasChanged if no + // async work is to be performed. StateHasChanged(); - await initTask; + + try + { + await task; + } + catch when (task.IsCanceled) + { + // Ignore exceptions from task cancelletions. + // Awaiting a canceled task may produce either an OperationCanceledException (if produced as a consequence of + // CancellationToken.ThrowIfCancellationRequested()) or a TaskCanceledException (produced as a consequence of awaiting Task.FromCanceled). + // It's much easier to check the state of the Task (i.e. Task.IsCanceled) rather than catch two distinct exceptions. + } + + // Don't call StateHasChanged here. CallOnParametersSetAsync should handle that for us. } - OnParametersSet(); - Task parametersTask = null; - var setParametersIsAsync = false; - (setParametersIsAsync, parametersTask) = ProcessLifeCycletask(OnParametersSetAsync()); - // We always call StateHasChanged here as we want to trigger a rerender after OnParametersSet and - // the synchronous part of OnParametersSetAsync has run, triggering another re-render in case there - // is additional async work. - StateHasChanged(); - if (setParametersIsAsync) - { - await parametersTask; - } + await CallOnParametersSetAsync(); } - private (bool isAsync, Task asyncTask) ProcessLifeCycletask(Task task) + private Task CallOnParametersSetAsync() { - if (task == null) - { - throw new ArgumentNullException(nameof(task)); - } + OnParametersSet(); + var task = OnParametersSetAsync(); + // If no async work is to be performed, i.e. the task has already ran to completion + // or was canceled by the time we got to inspect it, avoid going async and re-invoking + // StateHasChanged at the culmination of the async work. + var shouldAwaitTask = task.Status != TaskStatus.RanToCompletion && + task.Status != TaskStatus.Canceled; - switch (task.Status) - { - // If it's already completed synchronously, no need to await and no - // need to issue a further render (we already rerender synchronously). - // Just need to make sure we propagate any errors. - case TaskStatus.RanToCompletion: - case TaskStatus.Canceled: - return (false, null); - case TaskStatus.Faulted: - HandleException(task.Exception); - return (false, null); - // For incomplete tasks, automatically re-render on successful completion - default: - return (true, ReRenderAsyncTask(task)); - } + // We always call StateHasChanged here as we want to trigger a rerender after OnParametersSet and + // the synchronous part of OnParametersSetAsync has run. + StateHasChanged(); + + return shouldAwaitTask ? + CallStateHasChangedOnAsyncCompletion(task) : + Task.CompletedTask; } - private async Task ReRenderAsyncTask(Task task) + private async Task CallStateHasChangedOnAsyncCompletion(Task task) { try { await task; - StateHasChanged(); } - catch (Exception ex) + catch when (task.IsCanceled) { - // Either the task failed, or it was cancelled, or StateHasChanged threw. - // We want to report task failure or StateHasChanged exceptions only. - if (!task.IsCanceled) - { - HandleException(ex); - } + // Ignore exceptions from task cancelletions, but don't bother issuing a state change. + return; } + + StateHasChanged(); } - private async void ContinueAfterLifecycleTask(Task task) - { - switch (task == null ? TaskStatus.RanToCompletion : task.Status) - { - // If it's already completed synchronously, no need to await and no - // need to issue a further render (we already rerender synchronously). - // Just need to make sure we propagate any errors. - case TaskStatus.RanToCompletion: - case TaskStatus.Canceled: - break; - case TaskStatus.Faulted: - HandleException(task.Exception); - break; - - // For incomplete tasks, automatically re-render on successful completion - default: - try - { - await task; - StateHasChanged(); - } - catch (Exception ex) - { - // Either the task failed, or it was cancelled, or StateHasChanged threw. - // We want to report task failure or StateHasChanged exceptions only. - if (!task.IsCanceled) - { - HandleException(ex); - } - } - - break; - } - } - - private static void HandleException(Exception ex) - { - if (ex is AggregateException && ex.InnerException != null) - { - ex = ex.InnerException; // It's more useful - } - - // TODO: Need better global exception handling - Console.Error.WriteLine($"[{ex.GetType().FullName}] {ex.Message}\n{ex.StackTrace}"); - } - - void IHandleEvent.HandleEvent(EventHandlerInvoker binding, UIEventArgs args) + Task IHandleEvent.HandleEventAsync(EventHandlerInvoker binding, UIEventArgs args) { var task = binding.Invoke(args); - ContinueAfterLifecycleTask(task); + var shouldAwaitTask = task.Status != TaskStatus.RanToCompletion && + task.Status != TaskStatus.Canceled; // After each event, we synchronously re-render (unless !ShouldRender()) // This just saves the developer the trouble of putting "StateHasChanged();" // at the end of every event callback. StateHasChanged(); + + return shouldAwaitTask ? + CallStateHasChangedOnAsyncCompletion(task) : + Task.CompletedTask; } - void IHandleAfterRender.OnAfterRender() + Task IHandleAfterRender.OnAfterRenderAsync() { OnAfterRender(); - var onAfterRenderTask = OnAfterRenderAsync(); - if (onAfterRenderTask != null && onAfterRenderTask.Status != TaskStatus.RanToCompletion) - { - // Note that we don't call StateHasChanged to trigger a render after - // handling this, because that would be an infinite loop. The only - // reason we have OnAfterRenderAsync is so that the developer doesn't - // have to use "async void" and do their own exception handling in - // the case where they want to start an async task. - var taskWithHandledException = HandleAfterRenderException(onAfterRenderTask); - } - } + return OnAfterRenderAsync(); - private async Task HandleAfterRenderException(Task parentTask) - { - try - { - await parentTask; - } - catch (Exception e) - { - HandleException(e); - } + // Note that we don't call StateHasChanged to trigger a render after + // handling this, because that would be an infinite loop. The only + // reason we have OnAfterRenderAsync is so that the developer doesn't + // have to use "async void" and do their own exception handling in + // the case where they want to start an async task. } } } diff --git a/src/Components/Components/src/IHandleAfterRender.cs b/src/Components/Components/src/IHandleAfterRender.cs index 13dea20005..349138519f 100644 --- a/src/Components/Components/src/IHandleAfterRender.cs +++ b/src/Components/Components/src/IHandleAfterRender.cs @@ -1,6 +1,8 @@ -// Copyright (c) .NET Foundation. All rights reserved. +// Copyright (c) .NET Foundation. All rights reserved. // Licensed under the Apache License, Version 2.0. See License.txt in the project root for license information. +using System.Threading.Tasks; + namespace Microsoft.AspNetCore.Components { /// @@ -11,6 +13,7 @@ namespace Microsoft.AspNetCore.Components /// /// Notifies the component that it has been rendered. /// - void OnAfterRender(); + /// A that represents the asynchronous event handling operation. + Task OnAfterRenderAsync(); } } diff --git a/src/Components/Components/src/IHandleEvent.cs b/src/Components/Components/src/IHandleEvent.cs index 647c186292..7fc50c6b1e 100644 --- a/src/Components/Components/src/IHandleEvent.cs +++ b/src/Components/Components/src/IHandleEvent.cs @@ -1,6 +1,8 @@ -// Copyright (c) .NET Foundation. All rights reserved. +// Copyright (c) .NET Foundation. All rights reserved. // Licensed under the Apache License, Version 2.0. See License.txt in the project root for license information. +using System.Threading.Tasks; + namespace Microsoft.AspNetCore.Components { /// @@ -13,6 +15,7 @@ namespace Microsoft.AspNetCore.Components /// /// The event binding. /// Arguments for the event handler. - void HandleEvent(EventHandlerInvoker binding, UIEventArgs args); + /// A that represents the asynchronous event handling operation. + Task HandleEventAsync(EventHandlerInvoker binding, UIEventArgs args); } } diff --git a/src/Components/Components/src/Rendering/ComponentState.cs b/src/Components/Components/src/Rendering/ComponentState.cs index 7dd758055b..3145280c05 100644 --- a/src/Components/Components/src/Rendering/ComponentState.cs +++ b/src/Components/Components/src/Rendering/ComponentState.cs @@ -4,7 +4,6 @@ using System; using System.Collections.Generic; using System.Threading.Tasks; -using Microsoft.AspNetCore.Components; using Microsoft.AspNetCore.Components.RenderTree; namespace Microsoft.AspNetCore.Components.Rendering @@ -16,22 +15,13 @@ namespace Microsoft.AspNetCore.Components.Rendering /// internal class ComponentState { - private readonly int _componentId; // TODO: Change the type to 'long' when the Mono runtime has more complete support for passing longs in .NET->JS calls - private readonly ComponentState _parentComponentState; - private readonly IComponent _component; private readonly Renderer _renderer; private readonly IReadOnlyList _cascadingParameters; private readonly bool _hasAnyCascadingParameterSubscriptions; - private RenderTreeBuilder _renderTreeBuilderCurrent; private RenderTreeBuilder _renderTreeBuilderPrevious; private ArrayBuilder _latestDirectParametersSnapshot; // Lazily instantiated private bool _componentWasDisposed; - public int ComponentId => _componentId; - public IComponent Component => _component; - public ComponentState ParentComponentState => _parentComponentState; - public RenderTreeBuilder CurrrentRenderTree => _renderTreeBuilderCurrent; - /// /// Constructs an instance of . /// @@ -41,12 +31,12 @@ namespace Microsoft.AspNetCore.Components.Rendering /// The for the parent component, or null if this is a root component. public ComponentState(Renderer renderer, int componentId, IComponent component, ComponentState parentComponentState) { - _componentId = componentId; - _parentComponentState = parentComponentState; - _component = component ?? throw new ArgumentNullException(nameof(component)); + ComponentId = componentId; + ParentComponentState = parentComponentState; + Component = component ?? throw new ArgumentNullException(nameof(component)); _renderer = renderer ?? throw new ArgumentNullException(nameof(renderer)); _cascadingParameters = CascadingParameterState.FindCascadingParameters(this); - _renderTreeBuilderCurrent = new RenderTreeBuilder(renderer); + CurrrentRenderTree = new RenderTreeBuilder(renderer); _renderTreeBuilderPrevious = new RenderTreeBuilder(renderer); if (_cascadingParameters != null) @@ -55,6 +45,12 @@ namespace Microsoft.AspNetCore.Components.Rendering } } + // TODO: Change the type to 'long' when the Mono runtime has more complete support for passing longs in .NET->JS calls + public int ComponentId { get; } + public IComponent Component { get; } + public ComponentState ParentComponentState { get; } + public RenderTreeBuilder CurrrentRenderTree { get; private set; } + public void RenderIntoBatch(RenderBatchBuilder batchBuilder, RenderFragment renderFragment) { // A component might be in the render queue already before getting disposed by an @@ -65,31 +61,31 @@ namespace Microsoft.AspNetCore.Components.Rendering } // Swap the old and new tree builders - (_renderTreeBuilderCurrent, _renderTreeBuilderPrevious) = (_renderTreeBuilderPrevious, _renderTreeBuilderCurrent); + (CurrrentRenderTree, _renderTreeBuilderPrevious) = (_renderTreeBuilderPrevious, CurrrentRenderTree); - _renderTreeBuilderCurrent.Clear(); - renderFragment(_renderTreeBuilderCurrent); + CurrrentRenderTree.Clear(); + renderFragment(CurrrentRenderTree); var diff = RenderTreeDiffBuilder.ComputeDiff( _renderer, batchBuilder, - _componentId, + ComponentId, _renderTreeBuilderPrevious.GetFrames(), - _renderTreeBuilderCurrent.GetFrames()); + CurrrentRenderTree.GetFrames()); batchBuilder.UpdatedComponentDiffs.Append(diff); } public void DisposeInBatch(RenderBatchBuilder batchBuilder) { _componentWasDisposed = true; - + // TODO: Handle components throwing during dispose. Shouldn't break the whole render batch. - if (_component is IDisposable disposable) + if (Component is IDisposable disposable) { disposable.Dispose(); } - RenderTreeDiffBuilder.DisposeFrames(batchBuilder, _renderTreeBuilderCurrent.GetFrames()); + RenderTreeDiffBuilder.DisposeFrames(batchBuilder, CurrrentRenderTree.GetFrames()); if (_hasAnyCascadingParameterSubscriptions) { @@ -97,22 +93,29 @@ namespace Microsoft.AspNetCore.Components.Rendering } } - public void DispatchEvent(EventHandlerInvoker binding, UIEventArgs eventArgs) + public Task DispatchEventAsync(EventHandlerInvoker binding, UIEventArgs eventArgs) { - if (_component is IHandleEvent handleEventComponent) + if (Component is IHandleEvent handleEventComponent) { - handleEventComponent.HandleEvent(binding, eventArgs); + return handleEventComponent.HandleEventAsync(binding, eventArgs); } else { throw new InvalidOperationException( - $"The component of type {_component.GetType().FullName} cannot receive " + + $"The component of type {Component.GetType().FullName} cannot receive " + $"events because it does not implement {typeof(IHandleEvent).FullName}."); } } - public void NotifyRenderCompleted() - => (_component as IHandleAfterRender)?.OnAfterRender(); + public Task NotifyRenderCompletedAsync() + { + if (Component is IHandleAfterRender handlerAfterRender) + { + return handlerAfterRender.OnAfterRenderAsync(); + } + + return Task.CompletedTask; + } public void SetDirectParameters(ParameterCollection parameters) { @@ -167,7 +170,7 @@ namespace Microsoft.AspNetCore.Components.Rendering hasSubscription = true; } } - + return hasSubscription; } diff --git a/src/Components/Components/src/Rendering/HtmlRenderer.cs b/src/Components/Components/src/Rendering/HtmlRenderer.cs index ca9cf99bf9..97d4de4561 100644 --- a/src/Components/Components/src/Rendering/HtmlRenderer.cs +++ b/src/Components/Components/src/Rendering/HtmlRenderer.cs @@ -4,6 +4,7 @@ using System; using System.Collections.Generic; using System.Diagnostics; +using System.Runtime.ExceptionServices; using System.Threading.Tasks; using Microsoft.AspNetCore.Components.RenderTree; @@ -39,49 +40,13 @@ namespace Microsoft.AspNetCore.Components.Rendering return Task.CompletedTask; } - /// - /// Renders a component into a sequence of fragments that represent the textual representation - /// of the HTML produced by the component. - /// - /// The type of the . - /// A with the initial parameters to render the component. - /// A sequence of fragments that represent the HTML text of the component. - public IEnumerable RenderComponent(ParameterCollection initialParameters) where T : IComponent - { - return RenderComponent(typeof(T), initialParameters); - } - /// /// Renders a component into a sequence of fragments that represent the textual representation /// of the HTML produced by the component. /// /// The type of the . /// A with the initial parameters to render the component. - /// A sequence of fragments that represent the HTML text of the component. - private IEnumerable RenderComponent(Type componentType, ParameterCollection initialParameters) - { - var frames = CreateInitialRender(componentType, initialParameters); - - if (frames.Count == 0) - { - return Array.Empty(); - } - else - { - var result = new List(); - var newPosition = RenderFrames(result, frames, 0, frames.Count); - Debug.Assert(newPosition == frames.Count); - return result; - } - } - - /// - /// Renders a component into a sequence of fragments that represent the textual representation - /// of the HTML produced by the component. - /// - /// The type of the . - /// A with the initial parameters to render the component. - /// A sequence of fragments that represent the HTML text of the component. + /// A that on completion returns a sequence of fragments that represent the HTML text of the component. public async Task> RenderComponentAsync(Type componentType, ParameterCollection initialParameters) { var frames = await CreateInitialRenderAsync(componentType, initialParameters); @@ -103,14 +68,18 @@ namespace Microsoft.AspNetCore.Components.Rendering /// Renders a component into a sequence of fragments that represent the textual representation /// of the HTML produced by the component. /// - /// The type of the . + /// The type of the . /// A with the initial parameters to render the component. - /// A sequence of fragments that represent the HTML text of the component. - public Task> RenderComponentAsync(ParameterCollection initialParameters) where T : IComponent + /// A that on completion returns a sequence of fragments that represent the HTML text of the component. + public Task> RenderComponentAsync(ParameterCollection initialParameters) where TComponent : IComponent { - return RenderComponentAsync(typeof(T), initialParameters); + return RenderComponentAsync(typeof(TComponent), initialParameters); } + /// + protected override void HandleException(Exception exception) + => ExceptionDispatchInfo.Capture(exception).Throw(); + private int RenderFrames(List result, ArrayRange frames, int position, int maxElements) { var nextPosition = position; @@ -258,16 +227,6 @@ namespace Microsoft.AspNetCore.Components.Rendering return position + maxElements; } - private ArrayRange CreateInitialRender(Type componentType, ParameterCollection initialParameters) - { - var component = InstantiateComponent(componentType); - var componentId = AssignRootComponentId(component); - - RenderRootComponent(componentId, initialParameters); - - return GetCurrentRenderTreeFrames(componentId); - } - private async Task> CreateInitialRenderAsync(Type componentType, ParameterCollection initialParameters) { var component = InstantiateComponent(componentType); diff --git a/src/Components/Components/src/Rendering/Renderer.cs b/src/Components/Components/src/Rendering/Renderer.cs index 3a84412492..07c9a56181 100644 --- a/src/Components/Components/src/Rendering/Renderer.cs +++ b/src/Components/Components/src/Rendering/Renderer.cs @@ -4,7 +4,6 @@ using System; using System.Collections.Generic; using System.Diagnostics; -using System.Runtime.ExceptionServices; using System.Threading; using System.Threading.Tasks; using Microsoft.AspNetCore.Components.RenderTree; @@ -100,80 +99,6 @@ namespace Microsoft.AspNetCore.Components.Rendering /// The representing the current render tree. private protected ArrayRange GetCurrentRenderTreeFrames(int componentId) => GetRequiredComponentState(componentId).CurrrentRenderTree.GetFrames(); - /// - /// Performs the first render for a root component. After this, the root component - /// makes its own decisions about when to re-render, so there is no need to call - /// this more than once. - /// - /// The ID returned by . - protected void RenderRootComponent(int componentId) - { - RenderRootComponent(componentId, ParameterCollection.Empty); - } - - /// - /// Performs the first render for a root component. After this, the root component - /// makes its own decisions about when to re-render, so there is no need to call - /// this more than once. - /// - /// The ID returned by . - /// The with the initial parameters to use for rendering. - protected void RenderRootComponent(int componentId, ParameterCollection initialParameters) - { - ReportAsyncExceptions(RenderRootComponentAsync(componentId, initialParameters)); - } - - private async void ReportAsyncExceptions(Task task) - { - switch (task.Status) - { - // If it's already completed synchronously, no need to await and no - // need to issue a further render (we already rerender synchronously). - // Just need to make sure we propagate any errors. - case TaskStatus.RanToCompletion: - case TaskStatus.Canceled: - _pendingTasks = null; - break; - case TaskStatus.Faulted: - _pendingTasks = null; - HandleException(task.Exception); - break; - - default: - try - { - await task; - } - catch (Exception ex) - { - // Either the task failed, or it was cancelled. - // We want to report task failure exceptions only. - if (!task.IsCanceled) - { - HandleException(ex); - } - } - finally - { - // Clear the list after we are done rendering the root component or an async exception has ocurred. - _pendingTasks = null; - } - - break; - } - } - - private static void HandleException(Exception ex) - { - if (ex is AggregateException && ex.InnerException != null) - { - ex = ex.InnerException; // It's more useful - } - - // TODO: Need better global exception handling - Console.Error.WriteLine($"[{ex.GetType().FullName}] {ex.Message}\n{ex.StackTrace}"); - } - /// /// Performs the first render for a root component, waiting for this component and all /// children components to finish rendering in case there is any asynchronous work being @@ -182,6 +107,10 @@ namespace Microsoft.AspNetCore.Components.Rendering /// this more than once. /// /// The ID returned by . + /// + /// Rendering a root component is an asynchronous operation. Clients may choose to not await the returned task to + /// start, but not wait for the entire render to complete. + /// protected Task RenderRootComponentAsync(int componentId) { return RenderRootComponentAsync(componentId, ParameterCollection.Empty); @@ -196,13 +125,17 @@ namespace Microsoft.AspNetCore.Components.Rendering /// /// The ID returned by . /// The with the initial parameters to use for rendering. + /// + /// Rendering a root component is an asynchronous operation. Clients may choose to not await the returned task to + /// start, but not wait for the entire render to complete. + /// protected async Task RenderRootComponentAsync(int componentId, ParameterCollection initialParameters) { - if (_pendingTasks != null) + if (Interlocked.CompareExchange(ref _pendingTasks, new List(), null) != null) { throw new InvalidOperationException("There is an ongoing rendering in progress."); } - _pendingTasks = new List(); + // During the rendering process we keep a list of components performing work in _pendingTasks. // _renderer.AddToPendingTasks will be called by ComponentState.SetDirectParameters to add the // the Task produced by Component.SetParametersAsync to _pendingTasks in order to track the @@ -211,8 +144,8 @@ namespace Microsoft.AspNetCore.Components.Rendering // work to finish as it will simply trigger new renders that will be handled afterwards. // During the asynchronous rendering process we want to wait up untill al components have // finished rendering so that we can produce the complete output. - GetRequiredComponentState(componentId) - .SetDirectParameters(initialParameters); + var componentState = GetRequiredComponentState(componentId); + componentState.SetDirectParameters(initialParameters); try { @@ -225,15 +158,20 @@ namespace Microsoft.AspNetCore.Components.Rendering } } + /// + /// Allows derived types to handle exceptions during rendering. Defaults to rethrowing the original exception. + /// + /// The . + protected abstract void HandleException(Exception exception); + private async Task ProcessAsynchronousWork() { // Child components SetParametersAsync are stored in the queue of pending tasks, // which might trigger further renders. while (_pendingTasks.Count > 0) { - Task pendingWork; // Create a Task that represents the remaining ongoing work for the rendering process - pendingWork = Task.WhenAll(_pendingTasks); + var pendingWork = Task.WhenAll(_pendingTasks); // Clear all pending work. _pendingTasks.Clear(); @@ -241,7 +179,7 @@ namespace Microsoft.AspNetCore.Components.Rendering // new work might be added before we check again as a result of waiting for all // the child components to finish executing SetParametersAsync await pendingWork; - }; + } } private ComponentState AttachAndInitComponent(IComponent component, int parentComponentId) @@ -267,7 +205,8 @@ namespace Microsoft.AspNetCore.Components.Rendering /// The unique identifier for the component within the scope of this . /// The value from the original event attribute. /// Arguments to be passed to the event handler. - public void DispatchEvent(int componentId, int eventHandlerId, UIEventArgs eventArgs) + /// A representing the asynchronous execution operation. + public Task DispatchEventAsync(int componentId, int eventHandlerId, UIEventArgs eventArgs) { EnsureSynchronizationContext(); @@ -275,16 +214,20 @@ namespace Microsoft.AspNetCore.Components.Rendering { // The event handler might request multiple renders in sequence. Capture them // all in a single batch. + var componentState = GetRequiredComponentState(componentId); + Task task = null; try { _isBatchInProgress = true; - GetRequiredComponentState(componentId).DispatchEvent(binding, eventArgs); + task = componentState.DispatchEventAsync(binding, eventArgs); } finally { _isBatchInProgress = false; ProcessRenderQueue(); } + + return GetErrorHandledTask(task); } else { @@ -329,8 +272,7 @@ namespace Microsoft.AspNetCore.Components.Rendering // This is for example when we run on a system with a single thread, like WebAssembly. if (_dispatcher == null) { - workItem(); - return Task.CompletedTask; + return workItem(); } if (SynchronizationContext.Current == _dispatcher) @@ -373,12 +315,12 @@ namespace Microsoft.AspNetCore.Components.Rendering case TaskStatus.Canceled: break; case TaskStatus.Faulted: - // We want to throw immediately if the task failed synchronously instead of + // We want to immediately handle exceptions if the task failed synchronously instead of // waiting for it to throw later. This can happen if the task is produced by // an 'async' state machine (the ones generated using async/await) where even // the synchronous exceptions will get captured and converted into a faulted // task. - ExceptionDispatchInfo.Capture(task.Exception.InnerException).Throw(); + HandleException(task.Exception.GetBaseException()); break; default: // We are not in rendering the root component. @@ -386,7 +328,8 @@ namespace Microsoft.AspNetCore.Components.Rendering { return; } - _pendingTasks.Add(task); + + _pendingTasks.Add(GetErrorHandledTask(task)); break; } } @@ -472,7 +415,10 @@ namespace Microsoft.AspNetCore.Components.Rendering var batch = _batchBuilder.ToBatch(); updateDisplayTask = UpdateDisplayAsync(batch); - InvokeRenderCompletedCalls(batch.UpdatedComponents); + + // Fire off the execution of OnAfterRenderAsync, but don't wait for it + // if there is async work to be done. + _ = InvokeRenderCompletedCalls(batch.UpdatedComponents); } finally { @@ -482,15 +428,45 @@ namespace Microsoft.AspNetCore.Components.Rendering } } - private void InvokeRenderCompletedCalls(ArrayRange updatedComponents) + private Task InvokeRenderCompletedCalls(ArrayRange updatedComponents) { + List batch = null; var array = updatedComponents.Array; for (var i = 0; i < updatedComponents.Count; i++) { - // The component might be rendered and disposed in the same batch (if its parent - // was rendered later in the batch, and removed the child from the tree). - GetOptionalComponentState(array[i].ComponentId)?.NotifyRenderCompleted(); + var componentState = GetOptionalComponentState(array[i].ComponentId); + if (componentState != null) + { + // The component might be rendered and disposed in the same batch (if its parent + // was rendered later in the batch, and removed the child from the tree). + var task = componentState.NotifyRenderCompletedAsync(); + + // We want to avoid allocations per rendering. Avoid allocating a state machine or an accumulator + // unless we absolutely have to. + if (task.IsCompleted) + { + if (task.Status == TaskStatus.RanToCompletion || task.Status == TaskStatus.Canceled) + { + // Nothing to do here. + continue; + } + else if (task.Status == TaskStatus.Faulted) + { + HandleException(task.Exception); + continue; + } + } + + // The Task is incomplete. + // Queue up the task and we can inspect it later. + batch = batch ?? new List(); + batch.Add(GetErrorHandledTask(task)); + } } + + return batch != null ? + Task.WhenAll(batch) : + Task.CompletedTask; } private void RenderInExistingBatch(RenderQueueEntry renderQueueEntry) @@ -536,14 +512,28 @@ namespace Microsoft.AspNetCore.Components.Rendering } } + private async Task GetErrorHandledTask(Task taskToHandle) + { + try + { + await taskToHandle; + } + catch (Exception ex) + { + if (!taskToHandle.IsCanceled) + { + // Ignore errors due to task cancellations. + HandleException(ex); + } + } + } + /// /// Releases all resources currently used by this instance. /// /// if this method is being invoked by , otherwise . protected virtual void Dispose(bool disposing) { - List exceptions = null; - foreach (var componentState in _componentStateById.Values) { if (componentState.Component is IDisposable disposable) @@ -554,17 +544,10 @@ namespace Microsoft.AspNetCore.Components.Rendering } catch (Exception exception) { - // Capture exceptions thrown by individual components and rethrow as an aggregate. - exceptions = exceptions ?? new List(); - exceptions.Add(exception); + HandleException(exception); } } } - - if (exceptions != null) - { - throw new AggregateException(exceptions); - } } /// diff --git a/src/Components/Components/test/ComponentBaseTest.cs b/src/Components/Components/test/ComponentBaseTest.cs index bca1d60ccf..7d14fc8c34 100644 --- a/src/Components/Components/test/ComponentBaseTest.cs +++ b/src/Components/Components/test/ComponentBaseTest.cs @@ -2,6 +2,7 @@ // Licensed under the Apache License, Version 2.0. See License.txt in the project root for license information. using System; +using System.Threading; using System.Threading.Tasks; using Microsoft.AspNetCore.Components.RenderTree; using Microsoft.AspNetCore.Components.Test.Helpers; @@ -18,7 +19,7 @@ namespace Microsoft.AspNetCore.Components.Test var renderer = new TestRenderer(); var component = new TestComponent(); - int onInitRuns = 0; + var onInitRuns = 0; component.OnInitLogic = c => onInitRuns++; // Act @@ -36,7 +37,7 @@ namespace Microsoft.AspNetCore.Components.Test var renderer = new TestRenderer(); var component = new TestComponent(); - int onInitAsyncRuns = 0; + var onInitAsyncRuns = 0; component.RunsBaseOnInitAsync = false; component.OnInitAsyncLogic = c => { @@ -60,7 +61,7 @@ namespace Microsoft.AspNetCore.Components.Test var renderer = new TestRenderer(); var component = new TestComponent(); - int onInitAsyncRuns = 0; + var onInitAsyncRuns = 0; component.RunsBaseOnInitAsync = true; component.OnInitAsyncLogic = c => { @@ -84,7 +85,7 @@ namespace Microsoft.AspNetCore.Components.Test var renderer = new TestRenderer(); var component = new TestComponent(); - int onParametersSetRuns = 0; + var onParametersSetRuns = 0; component.OnParametersSetLogic = c => onParametersSetRuns++; // Act @@ -103,7 +104,7 @@ namespace Microsoft.AspNetCore.Components.Test var renderer = new TestRenderer(); var component = new TestComponent(); - int onParametersSetAsyncRuns = 0; + var onParametersSetAsyncRuns = 0; component.RunsBaseOnParametersSetAsync = false; component.OnParametersSetAsyncLogic = c => { @@ -127,7 +128,7 @@ namespace Microsoft.AspNetCore.Components.Test var renderer = new TestRenderer(); var component = new TestComponent(); - int onParametersSetAsyncRuns = 0; + var onParametersSetAsyncRuns = 0; component.RunsBaseOnParametersSetAsync = true; component.OnParametersSetAsyncLogic = c => { @@ -145,7 +146,7 @@ namespace Microsoft.AspNetCore.Components.Test } [Fact] - public void RendersAfterParametersSetAsyncTaskIsCompleted() + public async Task RendersAfterParametersSetAsyncTaskIsCompleted() { // Arrange var renderer = new TestRenderer(); @@ -158,7 +159,7 @@ namespace Microsoft.AspNetCore.Components.Test // Act var componentId = renderer.AssignRootComponentId(component); - renderer.RenderRootComponent(componentId); + var renderTask = renderer.RenderRootComponentAsync(componentId); // Assert Assert.Single(renderer.Batches); @@ -167,12 +168,14 @@ namespace Microsoft.AspNetCore.Components.Test component.Counter = 2; parametersSetTask.SetResult(true); + await renderTask; + // Component should be rendered again Assert.Equal(2, renderer.Batches.Count); } [Fact] - public void RendersAfterParametersSetAndInitAsyncTasksAreCompleted() + public async Task RendersAfterParametersSetAndInitAsyncTasksAreCompleted() { // Arrange var renderer = new TestRenderer(); @@ -188,31 +191,32 @@ namespace Microsoft.AspNetCore.Components.Test // Act var componentId = renderer.AssignRootComponentId(component); - renderer.RenderRootComponent(componentId); + var renderTask = renderer.RenderRootComponentAsync(componentId); // Assert + // A rendering should have happened after the synchronous execution of Init Assert.Single(renderer.Batches); // Completes task started by OnInitAsync component.Counter = 2; initTask.SetResult(true); - // Component should be rendered again 2 times - // after on init async - // after set parameters - Assert.Equal(3, renderer.Batches.Count); + // Component should be rendered once, after set parameters + Assert.Equal(2, renderer.Batches.Count); // Completes task started by OnParametersSetAsync component.Counter = 3; parametersSetTask.SetResult(false); + await renderTask; + // Component should be rendered again // after the async part of onparameterssetasync completes - Assert.Equal(4, renderer.Batches.Count); + Assert.Equal(3, renderer.Batches.Count); } [Fact] - public void DoesNotRenderAfterOnInitAsyncTaskIsCancelled() + public async Task DoesNotRenderAfterOnInitAsyncTaskIsCancelled() { // Arrange var renderer = new TestRenderer(); @@ -222,22 +226,49 @@ namespace Microsoft.AspNetCore.Components.Test // Act var componentId = renderer.AssignRootComponentId(component); - renderer.RenderRootComponent(componentId); + var renderTask = renderer.RenderRootComponentAsync(componentId); // Assert + Assert.False(renderTask.IsCompleted); Assert.Single(renderer.Batches); // Cancel task started by OnInitAsync component.Counter = 2; initTask.SetCanceled(); + await renderTask; + // Component should only be rendered again due to // the call to StateHasChanged after SetParametersAsync - Assert.Equal(2,renderer.Batches.Count); + Assert.Equal(2, renderer.Batches.Count); } [Fact] - public void DoesNotRenderAfterOnParametersSetAsyncTaskIsCancelled() + public async Task DoesNotRenderAfterOnInitAsyncTaskIsCancelledUsingCancellationToken() + { + // Arrange + var renderer = new TestRenderer(); + var component = new TestComponent() { Counter = 1 }; + + var cts = new CancellationTokenSource(); + cts.Cancel(); + component.OnInitAsyncLogic = async _ => + { + await Task.Yield(); + cts.Token.ThrowIfCancellationRequested(); + }; + + // Act + var componentId = renderer.AssignRootComponentId(component); + await renderer.RenderRootComponentAsync(componentId); + + // Assert + // At least one call to StateHasChanged depending on how OnInitAsyncLogic gets scheduled. + Assert.NotEmpty(renderer.Batches); + } + + [Fact] + public async Task DoesNotRenderAfterOnParametersSetAsyncTaskIsCanceled() { // Arrange var renderer = new TestRenderer(); @@ -247,7 +278,7 @@ namespace Microsoft.AspNetCore.Components.Test // Act var componentId = renderer.AssignRootComponentId(component); - renderer.RenderRootComponent(componentId); + var renderTask = renderer.RenderRootComponentAsync(componentId); // Assert Assert.Single(renderer.Batches); @@ -256,8 +287,75 @@ namespace Microsoft.AspNetCore.Components.Test component.Counter = 2; onParametersSetTask.SetCanceled(); + await renderTask; + // Component should not be rendered again Assert.Single(renderer.Batches); + + } + + [Fact] + public async Task RenderRootComponentAsync_ReportsErrorDuringOnInit() + { + // Arrange + var expected = new TimeZoneNotFoundException(); + var renderer = new TestRenderer(); + var component = new TestComponent { OnInitLogic = _ => throw expected }; + + // Act & Assert + var componentId = renderer.AssignRootComponentId(component); + var actual = await Assert.ThrowsAsync(() => renderer.RenderRootComponentAsync(componentId)); + + // Assert + Assert.Same(expected, actual); + } + + [Fact] + public async Task RenderRootComponentAsync_ReportsErrorDuringOnInitAsync() + { + // Arrange + var expected = new TimeZoneNotFoundException(); + var renderer = new TestRenderer(); + var component = new TestComponent { OnInitAsyncLogic = _ => Task.FromException(expected) }; + + // Act & Assert + var componentId = renderer.AssignRootComponentId(component); + var actual = await Assert.ThrowsAsync(() => renderer.RenderRootComponentAsync(componentId)); + + // Assert + Assert.Same(expected, actual); + } + + [Fact] + public async Task RenderRootComponentAsync_ReportsErrorDuringOnParameterSet() + { + // Arrange + var expected = new TimeZoneNotFoundException(); + var renderer = new TestRenderer(); + var component = new TestComponent { OnParametersSetLogic = _ => throw expected }; + + // Act & Assert + var componentId = renderer.AssignRootComponentId(component); + var actual = await Assert.ThrowsAsync(() => renderer.RenderRootComponentAsync(componentId)); + + // Assert + Assert.Same(expected, actual); + } + + [Fact] + public async Task RenderRootComponentAsync_ReportsErrorDuringOnParameterSetAsync() + { + // Arrange + var expected = new TimeZoneNotFoundException(); + var renderer = new TestRenderer(); + var component = new TestComponent { OnParametersSetAsyncLogic = _ => Task.FromException(expected) }; + + // Act & Assert + var componentId = renderer.AssignRootComponentId(component); + var actual = await Assert.ThrowsAsync(() => renderer.RenderRootComponentAsync(componentId)); + + // Assert + Assert.Same(expected, actual); } private class TestComponent : ComponentBase @@ -305,7 +403,10 @@ namespace Microsoft.AspNetCore.Components.Test await base.OnInitAsync(); } - await OnInitAsyncLogic?.Invoke(this); + if (OnInitAsyncLogic != null) + { + await OnInitAsyncLogic.Invoke(this); + } } protected override void OnParametersSet() @@ -325,7 +426,10 @@ namespace Microsoft.AspNetCore.Components.Test await base.OnParametersSetAsync(); } - await OnParametersSetAsyncLogic?.Invoke(this); + if (OnParametersSetAsyncLogic != null) + { + await OnParametersSetAsyncLogic(this); + } } } } diff --git a/src/Components/Components/test/RenderTreeBuilderTest.cs b/src/Components/Components/test/RenderTreeBuilderTest.cs index 2f18b2d848..fe535c793c 100644 --- a/src/Components/Components/test/RenderTreeBuilderTest.cs +++ b/src/Components/Components/test/RenderTreeBuilderTest.cs @@ -1061,6 +1061,9 @@ namespace Microsoft.AspNetCore.Components.Test { } + protected override void HandleException(Exception exception) + => throw new NotImplementedException(); + protected override Task UpdateDisplayAsync(in RenderBatch renderBatch) => throw new NotImplementedException(); } diff --git a/src/Components/Components/test/RenderTreeDiffBuilderTest.cs b/src/Components/Components/test/RenderTreeDiffBuilderTest.cs index 0f7f986dd6..2c477d0893 100644 --- a/src/Components/Components/test/RenderTreeDiffBuilderTest.cs +++ b/src/Components/Components/test/RenderTreeDiffBuilderTest.cs @@ -1,14 +1,13 @@ // Copyright (c) .NET Foundation. All rights reserved. // Licensed under the Apache License, Version 2.0. See License.txt in the project root for license information. -using Microsoft.AspNetCore.Components; -using Microsoft.AspNetCore.Components.Rendering; -using Microsoft.AspNetCore.Components.RenderTree; -using Microsoft.AspNetCore.Components.Test.Helpers; using System; using System.Collections.Generic; using System.Linq; using System.Threading.Tasks; +using Microsoft.AspNetCore.Components.Rendering; +using Microsoft.AspNetCore.Components.RenderTree; +using Microsoft.AspNetCore.Components.Test.Helpers; using Xunit; namespace Microsoft.AspNetCore.Components.Test @@ -1531,6 +1530,9 @@ namespace Microsoft.AspNetCore.Components.Test { } + protected override void HandleException(Exception exception) + => throw new NotImplementedException(); + protected override Task UpdateDisplayAsync(in RenderBatch renderBatch) => Task.CompletedTask; } diff --git a/src/Components/Components/test/RendererTest.cs b/src/Components/Components/test/RendererTest.cs index 8e51132278..c0cb1c45a9 100644 --- a/src/Components/Components/test/RendererTest.cs +++ b/src/Components/Components/test/RendererTest.cs @@ -6,10 +6,12 @@ using System.Collections.Concurrent; using System.Collections.Generic; using System.Linq; using System.Runtime.ExceptionServices; +using System.Threading; using System.Threading.Tasks; using Microsoft.AspNetCore.Components.Rendering; using Microsoft.AspNetCore.Components.RenderTree; using Microsoft.AspNetCore.Components.Test.Helpers; +using Microsoft.AspNetCore.Testing; using Microsoft.AspNetCore.Testing.xunit; using Xunit; @@ -173,13 +175,17 @@ namespace Microsoft.AspNetCore.Components.Test { // Arrange var renderer = new TestRenderer(); - var component = new AsyncComponent(5); // Triggers n renders, the first one creating

n

and the n-1 renders asynchronously update the value. + var tcs = new TaskCompletionSource(); + var component = new AsyncComponent(tcs.Task, 5); // Triggers n renders, the first one creating

n

and the n-1 renders asynchronously update the value. // Act var componentId = renderer.AssignRootComponentId(component); - await renderer.InvokeAsync(() => renderer.RenderRootComponentAsync(componentId)); + var renderTask = renderer.InvokeAsync(() => renderer.RenderRootComponentAsync(componentId)); // Assert + Assert.False(renderTask.IsCompleted); + tcs.SetResult(0); + await renderTask; Assert.Equal(5, renderer.Batches.Count); // First render @@ -195,7 +201,7 @@ namespace Microsoft.AspNetCore.Components.Test AssertFrame.Text(create.ReferenceFrames[1], "5"); // Second render - for (int i = 1; i < 5; i++) + for (var i = 1; i < 5; i++) { var update = renderer.Batches[i]; @@ -413,6 +419,36 @@ namespace Microsoft.AspNetCore.Components.Test AssertStream(3, logForThirdChild); } + [Fact] + public void DispatchingEventsWithoutAsyncWorkShouldCompleteSynchronously() + { + // Arrange: Render a component with an event handler + var renderer = new TestRenderer(); + UIEventArgs receivedArgs = null; + + var component = new EventComponent + { + OnTest = args => { receivedArgs = args; } + }; + var componentId = renderer.AssignRootComponentId(component); + component.TriggerRender(); + + var eventHandlerId = renderer.Batches.Single() + .ReferenceFrames + .First(frame => frame.AttributeValue != null) + .AttributeEventHandlerId; + + // Assert: Event not yet fired + Assert.Null(receivedArgs); + + // Act/Assert: Event can be fired + var eventArgs = new UIEventArgs(); + var task = renderer.DispatchEventAsync(componentId, eventHandlerId, eventArgs); + + // This should always be run synchronously + Assert.True(task.IsCompletedSuccessfully); + } + [Fact] public void CanDispatchEventsToTopLevelComponents() { @@ -437,7 +473,9 @@ namespace Microsoft.AspNetCore.Components.Test // Act/Assert: Event can be fired var eventArgs = new UIEventArgs(); - renderer.DispatchEvent(componentId, eventHandlerId, eventArgs); + var renderTask = renderer.DispatchEventAsync(componentId, eventHandlerId, eventArgs); + + Assert.True(renderTask.IsCompletedSuccessfully); Assert.Same(eventArgs, receivedArgs); } @@ -465,7 +503,9 @@ namespace Microsoft.AspNetCore.Components.Test // Act/Assert: Event can be fired var eventArgs = new UIMouseEventArgs(); - renderer.DispatchEvent(componentId, eventHandlerId, eventArgs); + var renderTask = renderer.DispatchEventAsync(componentId, eventHandlerId, eventArgs); + + Assert.True(renderTask.IsCompletedSuccessfully); Assert.Same(eventArgs, receivedArgs); } @@ -493,7 +533,9 @@ namespace Microsoft.AspNetCore.Components.Test // Act/Assert: Event can be fired var eventArgs = new UIMouseEventArgs(); - renderer.DispatchEvent(componentId, eventHandlerId, eventArgs); + var renderTask = renderer.DispatchEventAsync(componentId, eventHandlerId, eventArgs); + + Assert.True(renderTask.IsCompletedSuccessfully); Assert.NotNull(receivedArgs); } @@ -532,7 +574,9 @@ namespace Microsoft.AspNetCore.Components.Test // Act/Assert: Event can be fired var eventArgs = new UIEventArgs(); - renderer.DispatchEvent(nestedComponentId, eventHandlerId, eventArgs); + var renderTask = renderer.DispatchEventAsync(nestedComponentId, eventHandlerId, eventArgs); + + Assert.True(renderTask.IsCompletedSuccessfully); Assert.Same(eventArgs, receivedArgs); } @@ -559,7 +603,11 @@ namespace Microsoft.AspNetCore.Components.Test var eventArgs = new UIEventArgs(); // Act/Assert - var ex = Assert.Throws(() => renderer.DispatchEvent(componentId, eventHandlerId, eventArgs)); + var ex = Assert.Throws(() => + { + // Verifies that the exception is thrown synchronously. + _ = renderer.DispatchEventAsync(componentId, eventHandlerId, eventArgs); + }); Assert.Equal($"The component of type {typeof(TestComponent).FullName} cannot receive " + $"events because it does not implement {typeof(IHandleEvent).FullName}.", ex.Message); } @@ -571,7 +619,11 @@ namespace Microsoft.AspNetCore.Components.Test var renderer = new TestRenderer(); // Act/Assert - Assert.Throws(() => renderer.DispatchEvent(123, 0, new UIEventArgs())); + Assert.Throws(() => + { + // Intentionally written this way to verify that the exception is thrown synchronously. + _ = renderer.DispatchEventAsync(123, 0, new UIEventArgs()); + }); } [Fact] @@ -790,7 +842,8 @@ namespace Microsoft.AspNetCore.Components.Test // Act/Assert 1: Event handler fires when we trigger it Assert.Equal(0, eventCount); - renderer.DispatchEvent(componentId, origEventHandlerId, args: null); + var renderTask = renderer.DispatchEventAsync(componentId, origEventHandlerId, args: null); + Assert.True(renderTask.IsCompletedSuccessfully); Assert.Equal(1, eventCount); // Now change the attribute value @@ -799,10 +852,16 @@ namespace Microsoft.AspNetCore.Components.Test component.TriggerRender(); // Act/Assert 2: Can no longer fire the original event, but can fire the new event - Assert.Throws(() => renderer.DispatchEvent(componentId, origEventHandlerId, args: null)); + Assert.Throws(() => + { + // Verifies that the exception is thrown synchronously. + _ = renderer.DispatchEventAsync(componentId, origEventHandlerId, args: null); + }); + Assert.Equal(1, eventCount); Assert.Equal(0, newEventCount); - renderer.DispatchEvent(componentId, origEventHandlerId + 1, args: null); + renderTask = renderer.DispatchEventAsync(componentId, origEventHandlerId + 1, args: null); + Assert.True(renderTask.IsCompletedSuccessfully); Assert.Equal(1, newEventCount); } @@ -824,7 +883,8 @@ namespace Microsoft.AspNetCore.Components.Test // Act/Assert 1: Event handler fires when we trigger it Assert.Equal(0, eventCount); - renderer.DispatchEvent(componentId, origEventHandlerId, args: null); + var renderTask = renderer.DispatchEventAsync(componentId, origEventHandlerId, args: null); + Assert.True(renderTask.IsCompletedSuccessfully); Assert.Equal(1, eventCount); // Now remove the event attribute @@ -832,7 +892,11 @@ namespace Microsoft.AspNetCore.Components.Test component.TriggerRender(); // Act/Assert 2: Can no longer fire the original event - Assert.Throws(() => renderer.DispatchEvent(componentId, origEventHandlerId, args: null)); + Assert.Throws(() => + { + // Verifies that the exception is thrown synchronously. + _ = renderer.DispatchEventAsync(componentId, origEventHandlerId, args: null); + }); Assert.Equal(1, eventCount); } @@ -870,7 +934,8 @@ namespace Microsoft.AspNetCore.Components.Test // Act/Assert 1: Event handler fires when we trigger it Assert.Equal(0, eventCount); - renderer.DispatchEvent(childComponentId, eventHandlerId, args: null); + var renderTask = renderer.DispatchEventAsync(childComponentId, eventHandlerId, args: null); + Assert.True(renderTask.IsCompletedSuccessfully); Assert.Equal(1, eventCount); // Now remove the EventComponent @@ -878,7 +943,11 @@ namespace Microsoft.AspNetCore.Components.Test component.TriggerRender(); // Act/Assert 2: Can no longer fire the original event - Assert.Throws(() => renderer.DispatchEvent(eventHandlerId, eventHandlerId, args: null)); + Assert.Throws(() => + { + // Verifies that the exception is thrown synchronously. + _ = renderer.DispatchEventAsync(eventHandlerId, eventHandlerId, args: null); + }); Assert.Equal(1, eventCount); } @@ -900,7 +969,8 @@ namespace Microsoft.AspNetCore.Components.Test // Act/Assert 1: Event handler fires when we trigger it Assert.Equal(0, eventCount); - renderer.DispatchEvent(componentId, origEventHandlerId, args: null); + var renderTask = renderer.DispatchEventAsync(componentId, origEventHandlerId, args: null); + Assert.True(renderTask.IsCompletedSuccessfully); Assert.Equal(1, eventCount); // Now remove the ancestor element @@ -908,7 +978,11 @@ namespace Microsoft.AspNetCore.Components.Test component.TriggerRender(); // Act/Assert 2: Can no longer fire the original event - Assert.Throws(() => renderer.DispatchEvent(componentId, origEventHandlerId, args: null)); + Assert.Throws(() => + { + // Verifies that the exception is thrown synchronously. + _ = renderer.DispatchEventAsync(componentId, origEventHandlerId, args: null); + }); Assert.Equal(1, eventCount); } @@ -947,9 +1021,10 @@ namespace Microsoft.AspNetCore.Components.Test Assert.Single(renderer.Batches); // Act - renderer.DispatchEvent(childComponentId, origEventHandlerId, args: null); + var renderTask = renderer.DispatchEventAsync(childComponentId, origEventHandlerId, args: null); // Assert + Assert.True(renderTask.IsCompletedSuccessfully); Assert.Equal(2, renderer.Batches.Count); var batch = renderer.Batches.Last(); Assert.Collection(batch.DiffsInOrder, @@ -1137,9 +1212,10 @@ namespace Microsoft.AspNetCore.Components.Test // Act // The fact that there's no error here is the main thing we're testing - renderer.DispatchEvent(childComponentId, origEventHandlerId, args: null); + var renderTask = renderer.DispatchEventAsync(childComponentId, origEventHandlerId, args: null); // Assert: correct render result + Assert.True(renderTask.IsCompletedSuccessfully); var newBatch = renderer.Batches.Skip(1).Single(); Assert.Equal(1, newBatch.DisposedComponentIDs.Count); Assert.Equal(1, newBatch.DiffsByComponentId.Count); @@ -1168,7 +1244,9 @@ namespace Microsoft.AspNetCore.Components.Test // Act: Toggle the checkbox var eventArgs = new UIChangeEventArgs { Value = true }; - renderer.DispatchEvent(componentId, checkboxChangeEventHandlerId, eventArgs); + var renderTask = renderer.DispatchEventAsync(componentId, checkboxChangeEventHandlerId, eventArgs); + + Assert.True(renderTask.IsCompletedSuccessfully); var latestBatch = renderer.Batches.Last(); var latestDiff = latestBatch.DiffsInOrder.Single(); var referenceFrames = latestBatch.ReferenceFrames; @@ -1349,28 +1427,444 @@ namespace Microsoft.AspNetCore.Components.Test // Act/Assert 1: Event can be fired for the first time var render1TCS = new TaskCompletionSource(); renderer.NextUpdateDisplayReturnTask = render1TCS.Task; - renderer.DispatchEvent(componentId, eventHandlerId, new UIEventArgs()); + await renderer.DispatchEventAsync(componentId, eventHandlerId, new UIEventArgs()); Assert.Equal(1, numEventsFired); // Act/Assert 2: *Same* event handler ID can be reused prior to completion of // preceding UI update var render2TCS = new TaskCompletionSource(); renderer.NextUpdateDisplayReturnTask = render2TCS.Task; - renderer.DispatchEvent(componentId, eventHandlerId, new UIEventArgs()); + await renderer.DispatchEventAsync(componentId, eventHandlerId, new UIEventArgs()); Assert.Equal(2, numEventsFired); // Act/Assert 3: After we complete the first UI update in which a given // event handler ID is disposed, we can no longer reuse that event handler ID render1TCS.SetResult(null); await Task.Delay(500); // From here we can't see when the async disposal is completed. Just give it plenty of time (Task.Yield isn't enough). - var ex = Assert.Throws(() => - { - renderer.DispatchEvent(componentId, eventHandlerId, new UIEventArgs()); - }); + var ex = await Assert.ThrowsAsync(() => + renderer.DispatchEventAsync(componentId, eventHandlerId, new UIEventArgs())); Assert.Equal($"There is no event handler with ID {eventHandlerId}", ex.Message); Assert.Equal(2, numEventsFired); } + [Fact] + public void ExceptionsThrownSynchronouslyCanBeHandledSynchronously() + { + // Arrange + var renderer = new TestRenderer { ShouldHandleExceptions = true }; + var component = new NestedAsyncComponent(); + var exception = new InvalidTimeZoneException(); + + // Act/Assert + var componentId = renderer.AssignRootComponentId(component); + var task = renderer.RenderRootComponentAsync(componentId, ParameterCollection.FromDictionary(new Dictionary + { + [nameof(NestedAsyncComponent.EventActions)] = new Dictionary> + { + [0] = new[] + { + new NestedAsyncComponent.ExecutionAction + { + Event = NestedAsyncComponent.EventType.OnInitAsyncAsync, + EventAction = () => throw exception, + }, + } + }, + [nameof(NestedAsyncComponent.WhatToRender)] = new Dictionary> + { + [0] = CreateRenderFactory(Array.Empty()), + }, + })); + + Assert.True(task.IsCompletedSuccessfully); + Assert.Equal(new[] { exception }, renderer.HandledExceptions); + } + + [Fact] + public void ExceptionsThrownSynchronouslyCanBeHandled() + { + // Arrange + var renderer = new TestRenderer { ShouldHandleExceptions = true }; + var component = new NestedAsyncComponent(); + var exception = new InvalidTimeZoneException(); + + // Act/Assert + var componentId = renderer.AssignRootComponentId(component); + var renderTask = renderer.RenderRootComponentAsync(componentId, ParameterCollection.FromDictionary(new Dictionary + { + [nameof(NestedAsyncComponent.EventActions)] = new Dictionary> + { + [0] = new[] + { + new NestedAsyncComponent.ExecutionAction + { + Event = NestedAsyncComponent.EventType.OnInitAsyncAsync, + EventAction = () => throw exception, + }, + } + }, + [nameof(NestedAsyncComponent.WhatToRender)] = new Dictionary> + { + [0] = CreateRenderFactory(Array.Empty()), + }, + })); + + Assert.True(renderTask.IsCompletedSuccessfully); + Assert.Equal(new[] { exception }, renderer.HandledExceptions); + } + + [Fact] + public void ExceptionsReturnedUsingTaskFromExceptionCanBeHandled() + { + // Arrange + var renderer = new TestRenderer { ShouldHandleExceptions = true }; + var component = new NestedAsyncComponent(); + var exception = new InvalidTimeZoneException(); + + // Act/Assert + var componentId = renderer.AssignRootComponentId(component); + var renderTask = renderer.RenderRootComponentAsync(componentId, ParameterCollection.FromDictionary(new Dictionary + { + [nameof(NestedAsyncComponent.EventActions)] = new Dictionary> + { + [0] = new[] + { + new NestedAsyncComponent.ExecutionAction + { + Event = NestedAsyncComponent.EventType.OnInitAsyncAsync, + EventAction = () => Task.FromException<(int, NestedAsyncComponent.EventType)>(exception), + }, + } + }, + [nameof(NestedAsyncComponent.WhatToRender)] = new Dictionary> + { + [0] = CreateRenderFactory(Array.Empty()), + }, + })); + + Assert.True(renderTask.IsCompletedSuccessfully); + Assert.Equal(new[] { exception }, renderer.HandledExceptions); + } + + [Fact] + public async Task ExceptionsThrownAsynchronouslyCanBeHandled() + { + // Arrange + var renderer = new TestRenderer { ShouldHandleExceptions = true }; + var component = new NestedAsyncComponent(); + var tcs = new TaskCompletionSource(); + var exception = new InvalidTimeZoneException(); + + // Act/Assert + var componentId = renderer.AssignRootComponentId(component); + var renderTask = renderer.RenderRootComponentAsync(componentId, ParameterCollection.FromDictionary(new Dictionary + { + [nameof(NestedAsyncComponent.EventActions)] = new Dictionary> + { + [0] = new[] + { + new NestedAsyncComponent.ExecutionAction + { + Event = NestedAsyncComponent.EventType.OnInitAsyncAsync, + EventAction = async () => + { + await tcs.Task; + throw exception; + } + }, + } + }, + [nameof(NestedAsyncComponent.WhatToRender)] = new Dictionary> + { + [0] = CreateRenderFactory(Array.Empty()), + }, + })); + + Assert.False(renderTask.IsCompleted); + tcs.SetResult(0); + await renderTask; + Assert.Same(exception, Assert.Single(renderer.HandledExceptions).GetBaseException()); + } + + [Fact] + public async Task ExceptionsThrownAsynchronouslyFromMultipleComponentsCanBeHandled() + { + // Arrange + var renderer = new TestRenderer { ShouldHandleExceptions = true }; + var component = new NestedAsyncComponent(); + var exception1 = new InvalidTimeZoneException(); + var exception2 = new UriFormatException(); + var tcs = new TaskCompletionSource(); + + // Act/Assert + var componentId = renderer.AssignRootComponentId(component); + var renderTask = renderer.RenderRootComponentAsync(componentId, ParameterCollection.FromDictionary(new Dictionary + { + [nameof(NestedAsyncComponent.EventActions)] = new Dictionary> + { + [0] = Array.Empty(), + [1] = new List + { + new NestedAsyncComponent.ExecutionAction + { + Event = NestedAsyncComponent.EventType.OnInitAsyncAsync, + EventAction = async () => + { + await tcs.Task; + throw exception1; + } + }, + }, + [2] = new List + { + new NestedAsyncComponent.ExecutionAction + { + Event = NestedAsyncComponent.EventType.OnInitAsyncAsync, + EventAction = async () => + { + await tcs.Task; + throw exception2; + } + }, + }, + }, + [nameof(NestedAsyncComponent.WhatToRender)] = new Dictionary> + { + [0] = CreateRenderFactory(new[] { 1, 2, }), + [1] = CreateRenderFactory(Array.Empty()), + [2] = CreateRenderFactory(Array.Empty()), + }, + })); + + Assert.False(renderTask.IsCompleted); + tcs.SetResult(0); + + await renderTask; + Assert.Equal(2, renderer.HandledExceptions.Count); + Assert.Contains(exception1, renderer.HandledExceptions); + Assert.Contains(exception2, renderer.HandledExceptions); + } + + [Fact] + public void ExceptionsThrownSynchronouslyFromMultipleComponentsCanBeHandled() + { + // Arrange + var renderer = new TestRenderer { ShouldHandleExceptions = true }; + var component = new NestedAsyncComponent(); + var exception1 = new InvalidTimeZoneException(); + var exception2 = new UriFormatException(); + + // Act/Assert + var componentId = renderer.AssignRootComponentId(component); + var renderTask = renderer.RenderRootComponentAsync(componentId, ParameterCollection.FromDictionary(new Dictionary + { + [nameof(NestedAsyncComponent.EventActions)] = new Dictionary> + { + [0] = Array.Empty(), + [1] = new List + { + new NestedAsyncComponent.ExecutionAction + { + Event = NestedAsyncComponent.EventType.OnInitAsyncAsync, + EventAction = () => + { + throw exception1; + } + }, + }, + [2] = new List + { + new NestedAsyncComponent.ExecutionAction + { + Event = NestedAsyncComponent.EventType.OnInitAsyncAsync, + EventAction = () => + { + throw exception2; + } + }, + }, + }, + [nameof(NestedAsyncComponent.WhatToRender)] = new Dictionary> + { + [0] = CreateRenderFactory(new[] { 1, 2, }), + [1] = CreateRenderFactory(Array.Empty()), + [2] = CreateRenderFactory(Array.Empty()), + }, + })); + + Assert.True(renderTask.IsCompletedSuccessfully); + + Assert.Equal(2, renderer.HandledExceptions.Count); + Assert.Contains(exception1, renderer.HandledExceptions); + Assert.Contains(exception2, renderer.HandledExceptions); + } + + [Fact] + public async Task ExceptionsThrownFromHandleAfterRender_AreHandled() + { + // Arrange + var renderer = new TestRenderer { ShouldHandleExceptions = true }; + var component = new NestedAsyncComponent(); + var exception = new InvalidTimeZoneException(); + + var taskCompletionSource = new TaskCompletionSource(); + + // Act/Assert + var componentId = renderer.AssignRootComponentId(component); + var renderTask = renderer.RenderRootComponentAsync(componentId, ParameterCollection.FromDictionary(new Dictionary + { + [nameof(NestedAsyncComponent.EventActions)] = new Dictionary> + { + [0] = new[] + { + new NestedAsyncComponent.ExecutionAction + { + Event = NestedAsyncComponent.EventType.OnAfterRenderAsync, + EventAction = () => + { + throw exception; + }, + } + }, + [1] = new[] + { + new NestedAsyncComponent.ExecutionAction + { + Event = NestedAsyncComponent.EventType.OnAfterRenderAsync, + EventAction = async () => + { + await Task.Yield(); + taskCompletionSource.TrySetResult(0); + return (1, NestedAsyncComponent.EventType.OnAfterRenderAsync); + }, + } + } + }, + [nameof(NestedAsyncComponent.WhatToRender)] = new Dictionary> + { + [0] = CreateRenderFactory(new[] { 1 }), + [1] = CreateRenderFactory(Array.Empty()), + }, + })); + + Assert.True(renderTask.IsCompletedSuccessfully); + + // OnAfterRenderAsync happens in the background. Make it more predictable, by gating it until we're ready to capture exceptions. + await taskCompletionSource.Task.TimeoutAfter(TimeSpan.FromSeconds(10)); + Assert.Same(exception, Assert.Single(renderer.HandledExceptions).GetBaseException()); + } + + [Fact] + public void SynchronousCancelledTasks_HandleAfterRender_Works() + { + // Arrange + var renderer = new TestRenderer { ShouldHandleExceptions = true }; + var component = new NestedAsyncComponent(); + var tcs = new TaskCompletionSource<(int, NestedAsyncComponent.EventType)>(); + tcs.TrySetCanceled(); + + // Act/Assert + var componentId = renderer.AssignRootComponentId(component); + var renderTask = renderer.RenderRootComponentAsync(componentId, ParameterCollection.FromDictionary(new Dictionary + { + [nameof(NestedAsyncComponent.EventActions)] = new Dictionary> + { + [0] = new[] + { + new NestedAsyncComponent.ExecutionAction + { + Event = NestedAsyncComponent.EventType.OnAfterRenderAsync, + EventAction = () => tcs.Task, + } + }, + }, + [nameof(NestedAsyncComponent.WhatToRender)] = new Dictionary> + { + [0] = CreateRenderFactory(Array.Empty()), + }, + })); + + // Rendering should finish synchronously + Assert.True(renderTask.IsCompletedSuccessfully); + Assert.Empty(renderer.HandledExceptions); + } + + [Fact] + public void AsynchronousCancelledTasks_HandleAfterRender_Works() + { + // Arrange + var renderer = new TestRenderer { ShouldHandleExceptions = true }; + var component = new NestedAsyncComponent(); + var tcs = new TaskCompletionSource<(int, NestedAsyncComponent.EventType)>(); + + // Act/Assert + var componentId = renderer.AssignRootComponentId(component); + var renderTask = renderer.RenderRootComponentAsync(componentId, ParameterCollection.FromDictionary(new Dictionary + { + [nameof(NestedAsyncComponent.EventActions)] = new Dictionary> + { + [0] = new[] + { + new NestedAsyncComponent.ExecutionAction + { + Event = NestedAsyncComponent.EventType.OnAfterRenderAsync, + EventAction = () => tcs.Task, + } + }, + }, + [nameof(NestedAsyncComponent.WhatToRender)] = new Dictionary> + { + [0] = CreateRenderFactory(Array.Empty()), + }, + })); + + // Rendering should be complete. + Assert.True(renderTask.IsCompletedSuccessfully); + tcs.TrySetCanceled(); + Assert.Empty(renderer.HandledExceptions); + } + + [Fact] + public async Task CanceledTasksInHandleAfterRender_AreIgnored() + { + // Arrange + var renderer = new TestRenderer { ShouldHandleExceptions = true }; + var component = new NestedAsyncComponent(); + var taskCompletionSource = new TaskCompletionSource(); + var cancellationTokenSource = new CancellationTokenSource(); + cancellationTokenSource.Cancel(); + + // Act/Assert + var componentId = renderer.AssignRootComponentId(component); + await renderer.RenderRootComponentAsync(componentId, ParameterCollection.FromDictionary(new Dictionary + { + [nameof(NestedAsyncComponent.EventActions)] = new Dictionary> + { + [0] = new[] + { + new NestedAsyncComponent.ExecutionAction + { + Event = NestedAsyncComponent.EventType.OnAfterRenderAsync, + EventAction = () => + { + taskCompletionSource.TrySetResult(0); + cancellationTokenSource.Token.ThrowIfCancellationRequested(); + return default; + }, + } + }, + }, + [nameof(NestedAsyncComponent.WhatToRender)] = new Dictionary> + { + [0] = CreateRenderFactory(Array.Empty()), + }, + })); + + await taskCompletionSource.Task.TimeoutAfter(TimeSpan.FromSeconds(10)); + + Assert.Empty(renderer.HandledExceptions); + } + [Fact] public void DisposingRenderer_DisposesTopLevelComponents() { @@ -1416,7 +1910,7 @@ namespace Microsoft.AspNetCore.Components.Test public void DisposingRenderer_CapturesExceptionsFromAllRegisteredComponents() { // Arrange - var renderer = new TestRenderer(); + var renderer = new TestRenderer { ShouldHandleExceptions = true }; var exception1 = new Exception(); var exception2 = new Exception(); var component = new TestComponent(builder => @@ -1434,13 +1928,13 @@ namespace Microsoft.AspNetCore.Components.Test component.TriggerRender(); // Act &A Assert - var aggregate = Assert.Throws(renderer.Dispose); + renderer.Dispose(); // All components must be disposed even if some throw as part of being diposed. Assert.True(component.Disposed); - Assert.Equal(2, aggregate.InnerExceptions.Count); - Assert.Contains(exception1, aggregate.InnerExceptions); - Assert.Contains(exception2, aggregate.InnerExceptions); + Assert.Equal(2, renderer.HandledExceptions.Count); + Assert.Contains(exception1, renderer.HandledExceptions); + Assert.Contains(exception2, renderer.HandledExceptions); } private class NoOpRenderer : Renderer @@ -1452,6 +1946,9 @@ namespace Microsoft.AspNetCore.Components.Test public new int AssignRootComponentId(IComponent component) => base.AssignRootComponentId(component); + protected override void HandleException(Exception exception) + => throw new NotImplementedException(); + protected override Task UpdateDisplayAsync(in RenderBatch renderBatch) => Task.CompletedTask; } @@ -1572,9 +2069,10 @@ namespace Microsoft.AspNetCore.Components.Test builder.AddContent(6, $"Render count: {++renderCount}"); } - public void HandleEvent(EventHandlerInvoker binding, UIEventArgs args) + public Task HandleEventAsync(EventHandlerInvoker binding, UIEventArgs args) { binding.Invoke(args); + return Task.CompletedTask; } } @@ -1642,9 +2140,9 @@ namespace Microsoft.AspNetCore.Components.Test return Task.CompletedTask; } - public void HandleEvent(EventHandlerInvoker binding, UIEventArgs args) + public async Task HandleEventAsync(EventHandlerInvoker binding, UIEventArgs args) { - var task = binding.Invoke(args); + await binding.Invoke(args); Render(); } @@ -1687,10 +2185,11 @@ namespace Microsoft.AspNetCore.Components.Test public bool CheckboxEnabled; public string SomeStringProperty; - public void HandleEvent(EventHandlerInvoker binding, UIEventArgs args) + public Task HandleEventAsync(EventHandlerInvoker binding, UIEventArgs args) { binding.Invoke(args); TriggerRender(); + return Task.CompletedTask; } protected override void BuildRenderTree(RenderTreeBuilder builder) @@ -1712,9 +2211,10 @@ namespace Microsoft.AspNetCore.Components.Test { public int OnAfterRenderCallCount { get; private set; } - public void OnAfterRender() + public Task OnAfterRenderAsync() { OnAfterRenderCallCount++; + return Task.CompletedTask; } Task IComponent.SetParametersAsync(ParameterCollection parameters) @@ -1761,11 +2261,14 @@ namespace Microsoft.AspNetCore.Components.Test { private RenderHandle _renderHandler; - public AsyncComponent(int number) + public AsyncComponent(Task taskToAwait, int number) { + _taskToAwait = taskToAwait; Number = number; } + private readonly Task _taskToAwait; + public int Number { get; set; } public void Configure(RenderHandle renderHandle) @@ -1781,7 +2284,7 @@ namespace Microsoft.AspNetCore.Components.Test n = Number; _renderHandler.Render(CreateFragment); Number--; - await Task.Yield(); + await _taskToAwait; }; // Cheap closure @@ -1833,7 +2336,7 @@ namespace Microsoft.AspNetCore.Components.Test return component => builder => { - int s = 0; + var s = 0; builder.OpenElement(s++, "div"); builder.AddContent(s++, $"Id: {component.TestId} BuildRenderTree, {Guid.NewGuid()}"); foreach (var child in childrenToRender) @@ -1852,13 +2355,6 @@ namespace Microsoft.AspNetCore.Components.Test private class NestedAsyncComponent : ComponentBase { - private RenderHandle _renderHandle; - - public void Configure(RenderHandle renderHandle) - { - _renderHandle = renderHandle; - } - [Parameter] public IDictionary> EventActions { get; set; } [Parameter] public IDictionary> WhatToRender { get; set; } @@ -1924,6 +2420,15 @@ namespace Microsoft.AspNetCore.Components.Test renderFactory(this)(builder); } + protected override async Task OnAfterRenderAsync() + { + if (TryGetEntry(EventType.OnAfterRenderAsync, out var entry)) + { + var result = await entry.EventAction(); + LogResult(result); + } + } + private bool TryGetEntry(EventType eventType, out ExecutionAction entry) { var entries = EventActions[TestId]; @@ -1937,7 +2442,7 @@ namespace Microsoft.AspNetCore.Components.Test private void LogResult((int, EventType) entry) { - Log.Enqueue(entry); + Log?.Enqueue(entry); } public class ExecutionAction @@ -1977,7 +2482,8 @@ namespace Microsoft.AspNetCore.Components.Test OnInitAsyncAsync, OnParametersSet, OnParametersSetAsyncSync, - OnParametersSetAsyncAsync + OnParametersSetAsyncAsync, + OnAfterRenderAsync, } } } diff --git a/src/Components/Components/test/Rendering/HtmlRendererTests.cs b/src/Components/Components/test/Rendering/HtmlRendererTests.cs index 18bcfec6bd..f473f7cdd6 100644 --- a/src/Components/Components/test/Rendering/HtmlRendererTests.cs +++ b/src/Components/Components/test/Rendering/HtmlRendererTests.cs @@ -17,7 +17,7 @@ namespace Microsoft.AspNetCore.Components.Rendering private static readonly Func _encoder = (string t) => HtmlEncoder.Default.Encode(t); [Fact] - public void RenderComponent_CanRenderEmptyElement() + public void RenderComponentAsync_CanRenderEmptyElement() { // Arrange var dispatcher = Renderer.CreateDefaultDispatcher(); @@ -30,14 +30,14 @@ namespace Microsoft.AspNetCore.Components.Rendering var htmlRenderer = new HtmlRenderer(serviceProvider, _encoder, dispatcher); // Act - var result = GetResult(dispatcher.Invoke(() => htmlRenderer.RenderComponent(ParameterCollection.Empty))); + var result = GetResult(dispatcher.InvokeAsync(() => htmlRenderer.RenderComponentAsync(ParameterCollection.Empty))); // Assert Assert.Equal(expectedHtml, result); } [Fact] - public void RenderComponent_CanRenderSimpleComponent() + public void RenderComponentAsync_CanRenderSimpleComponent() { // Arrange var dispatcher = Renderer.CreateDefaultDispatcher(); @@ -51,14 +51,14 @@ namespace Microsoft.AspNetCore.Components.Rendering var htmlRenderer = new HtmlRenderer(serviceProvider, _encoder, dispatcher); // Act - var result = GetResult(dispatcher.Invoke(() => htmlRenderer.RenderComponent(ParameterCollection.Empty))); + var result = GetResult(dispatcher.InvokeAsync(() => htmlRenderer.RenderComponentAsync(ParameterCollection.Empty))); // Assert Assert.Equal(expectedHtml, result); } [Fact] - public void RenderComponent_HtmlEncodesContent() + public void RenderComponentAsync_HtmlEncodesContent() { // Arrange var dispatcher = Renderer.CreateDefaultDispatcher(); @@ -72,7 +72,7 @@ namespace Microsoft.AspNetCore.Components.Rendering var htmlRenderer = new HtmlRenderer(serviceProvider, _encoder, dispatcher); // Act - var result = GetResult(dispatcher.Invoke(() => htmlRenderer.RenderComponent(ParameterCollection.Empty))); + var result = GetResult(dispatcher.InvokeAsync(() => htmlRenderer.RenderComponentAsync(ParameterCollection.Empty))); // Assert Assert.Equal(expectedHtml, result); @@ -80,7 +80,7 @@ namespace Microsoft.AspNetCore.Components.Rendering [Fact] - public void RenderComponent_DoesNotEncodeMarkup() + public void RenderComponentAsync_DoesNotEncodeMarkup() { // Arrange var dispatcher = Renderer.CreateDefaultDispatcher(); @@ -94,7 +94,7 @@ namespace Microsoft.AspNetCore.Components.Rendering var htmlRenderer = new HtmlRenderer(serviceProvider, _encoder, dispatcher); // Act - var result = GetResult(dispatcher.Invoke(() => htmlRenderer.RenderComponent(ParameterCollection.Empty))); + var result = GetResult(dispatcher.InvokeAsync(() => htmlRenderer.RenderComponentAsync(ParameterCollection.Empty))); // Assert Assert.Equal(expectedHtml, result); @@ -102,7 +102,7 @@ namespace Microsoft.AspNetCore.Components.Rendering [Fact] - public void RenderComponent_CanRenderWithAttributes() + public void RenderComponentAsync_CanRenderWithAttributes() { // Arrange var dispatcher = Renderer.CreateDefaultDispatcher(); @@ -118,14 +118,14 @@ namespace Microsoft.AspNetCore.Components.Rendering var htmlRenderer = new HtmlRenderer(serviceProvider, _encoder, dispatcher); // Act - var result = GetResult(dispatcher.Invoke(() => htmlRenderer.RenderComponent(ParameterCollection.Empty))); + var result = GetResult(dispatcher.InvokeAsync(() => htmlRenderer.RenderComponentAsync(ParameterCollection.Empty))); // Assert Assert.Equal(expectedHtml, result); } [Fact] - public void RenderComponent_HtmlEncodesAttributeValues() + public void RenderComponentAsync_HtmlEncodesAttributeValues() { // Arrange var dispatcher = Renderer.CreateDefaultDispatcher(); @@ -141,14 +141,14 @@ namespace Microsoft.AspNetCore.Components.Rendering var htmlRenderer = new HtmlRenderer(serviceProvider, _encoder, dispatcher); // Act - var result = GetResult(dispatcher.Invoke(() => htmlRenderer.RenderComponent(ParameterCollection.Empty))); + var result = GetResult(dispatcher.InvokeAsync(() => htmlRenderer.RenderComponentAsync(ParameterCollection.Empty))); // Assert Assert.Equal(expectedHtml, result); } [Fact] - public void RenderComponent_CanRenderBooleanAttributes() + public void RenderComponentAsync_CanRenderBooleanAttributes() { // Arrange var dispatcher = Renderer.CreateDefaultDispatcher(); @@ -163,14 +163,14 @@ namespace Microsoft.AspNetCore.Components.Rendering var htmlRenderer = new HtmlRenderer(serviceProvider, _encoder, dispatcher); // Act - var result = GetResult(dispatcher.Invoke(() => htmlRenderer.RenderComponent(ParameterCollection.Empty))); + var result = GetResult(dispatcher.InvokeAsync(() => htmlRenderer.RenderComponentAsync(ParameterCollection.Empty))); // Assert Assert.Equal(expectedHtml, result); } [Fact] - public void RenderComponent_DoesNotRenderBooleanAttributesWhenValueIsFalse() + public void RenderComponentAsync_DoesNotRenderBooleanAttributesWhenValueIsFalse() { // Arrange var dispatcher = Renderer.CreateDefaultDispatcher(); @@ -185,14 +185,14 @@ namespace Microsoft.AspNetCore.Components.Rendering var htmlRenderer = new HtmlRenderer(serviceProvider, _encoder, dispatcher); // Act - var result = GetResult(dispatcher.Invoke(() => htmlRenderer.RenderComponent(ParameterCollection.Empty))); + var result = GetResult(dispatcher.InvokeAsync(() => htmlRenderer.RenderComponentAsync(ParameterCollection.Empty))); // Assert Assert.Equal(expectedHtml, result); } [Fact] - public void RenderComponent_CanRenderWithChildren() + public void RenderComponentAsync_CanRenderWithChildren() { // Arrange var dispatcher = Renderer.CreateDefaultDispatcher(); @@ -209,14 +209,14 @@ namespace Microsoft.AspNetCore.Components.Rendering var htmlRenderer = new HtmlRenderer(serviceProvider, _encoder, dispatcher); // Act - var result = GetResult(dispatcher.Invoke(() => htmlRenderer.RenderComponent(ParameterCollection.Empty))); + var result = GetResult(dispatcher.InvokeAsync(() => htmlRenderer.RenderComponentAsync(ParameterCollection.Empty))); // Assert Assert.Equal(expectedHtml, result); } [Fact] - public void RenderComponent_CanRenderWithMultipleChildren() + public void RenderComponentAsync_CanRenderWithMultipleChildren() { // Arrange var dispatcher = Renderer.CreateDefaultDispatcher(); @@ -240,14 +240,14 @@ namespace Microsoft.AspNetCore.Components.Rendering var htmlRenderer = new HtmlRenderer(serviceProvider, _encoder, dispatcher); // Act - var result = GetResult(dispatcher.Invoke(() => htmlRenderer.RenderComponent(ParameterCollection.Empty))); + var result = GetResult(dispatcher.InvokeAsync(() => htmlRenderer.RenderComponentAsync(ParameterCollection.Empty))); // Assert Assert.Equal(expectedHtml, result); } [Fact] - public void RenderComponent_CanRenderComponentWithChildrenComponents() + public void RenderComponentAsync_CanRenderComponentAsyncWithChildrenComponents() { // Arrange var dispatcher = Renderer.CreateDefaultDispatcher(); @@ -270,14 +270,14 @@ namespace Microsoft.AspNetCore.Components.Rendering var htmlRenderer = new HtmlRenderer(serviceProvider, _encoder, dispatcher); // Act - var result = GetResult(dispatcher.Invoke(() => htmlRenderer.RenderComponent(ParameterCollection.Empty))); + var result = GetResult(dispatcher.InvokeAsync(() => htmlRenderer.RenderComponentAsync(ParameterCollection.Empty))); // Assert Assert.Equal(expectedHtml, result); } [Fact] - public void RenderComponent_ComponentReferenceNoops() + public void RenderComponentAsync_ComponentReferenceNoops() { // Arrange var dispatcher = Renderer.CreateDefaultDispatcher(); @@ -301,14 +301,14 @@ namespace Microsoft.AspNetCore.Components.Rendering var htmlRenderer = new HtmlRenderer(serviceProvider, _encoder, dispatcher); // Act - var result = GetResult(dispatcher.Invoke(() => htmlRenderer.RenderComponent(ParameterCollection.Empty))); + var result = GetResult(dispatcher.InvokeAsync(() => htmlRenderer.RenderComponentAsync(ParameterCollection.Empty))); // Assert Assert.Equal(expectedHtml, result); } [Fact] - public void RenderComponent_CanPassParameters() + public void RenderComponentAsync_CanPassParameters() { // Arrange var dispatcher = Renderer.CreateDefaultDispatcher(); @@ -333,7 +333,7 @@ namespace Microsoft.AspNetCore.Components.Rendering Action change = (UIChangeEventArgs changeArgs) => throw new InvalidOperationException(); // Act - var result = GetResult(dispatcher.Invoke(() => htmlRenderer.RenderComponent( + var result = GetResult(dispatcher.InvokeAsync(() => htmlRenderer.RenderComponentAsync( new ParameterCollection(new[] { RenderTreeFrame.Element(0,string.Empty), RenderTreeFrame.Attribute(1,"update",change), @@ -345,7 +345,7 @@ namespace Microsoft.AspNetCore.Components.Rendering } [Fact] - public void RenderComponent_CanRenderComponentWithRenderFragmentContent() + public void RenderComponentAsync_CanRenderComponentAsyncWithRenderFragmentContent() { // Arrange var dispatcher = Renderer.CreateDefaultDispatcher(); @@ -365,14 +365,14 @@ namespace Microsoft.AspNetCore.Components.Rendering var htmlRenderer = new HtmlRenderer(serviceProvider, _encoder, dispatcher); // Act - var result = GetResult(dispatcher.Invoke(() => htmlRenderer.RenderComponent(ParameterCollection.Empty))); + var result = GetResult(dispatcher.InvokeAsync(() => htmlRenderer.RenderComponentAsync(ParameterCollection.Empty))); // Assert Assert.Equal(expectedHtml, result); } [Fact] - public void RenderComponent_ElementRefsNoops() + public void RenderComponentAsync_ElementRefsNoops() { // Arrange var dispatcher = Renderer.CreateDefaultDispatcher(); @@ -393,7 +393,7 @@ namespace Microsoft.AspNetCore.Components.Rendering var htmlRenderer = new HtmlRenderer(serviceProvider, _encoder, dispatcher); // Act - var result = GetResult(dispatcher.Invoke(() => htmlRenderer.RenderComponent(ParameterCollection.Empty))); + var result = GetResult(dispatcher.InvokeAsync(() => htmlRenderer.RenderComponentAsync(ParameterCollection.Empty))); // Assert Assert.Equal(expectedHtml, result); diff --git a/src/Components/Server/src/Circuits/CircuitHost.cs b/src/Components/Server/src/Circuits/CircuitHost.cs index 9e4366c180..b5d485b0e6 100644 --- a/src/Components/Server/src/Circuits/CircuitHost.cs +++ b/src/Components/Server/src/Circuits/CircuitHost.cs @@ -100,7 +100,7 @@ namespace Microsoft.AspNetCore.Components.Server.Circuits for (var i = 0; i < builder.Entries.Count; i++) { var (componentType, domElementSelector) = builder.Entries[i]; - Renderer.AddComponent(componentType, domElementSelector); + await Renderer.AddComponentAsync(componentType, domElementSelector); } for (var i = 0; i < _circuitHandlers.Length; i++) diff --git a/src/Components/Server/src/Circuits/DefaultCircuitFactory.cs b/src/Components/Server/src/Circuits/DefaultCircuitFactory.cs index 7e91d230a2..68bbef8d9d 100644 --- a/src/Components/Server/src/Circuits/DefaultCircuitFactory.cs +++ b/src/Components/Server/src/Circuits/DefaultCircuitFactory.cs @@ -9,6 +9,7 @@ using Microsoft.AspNetCore.Components.Rendering; using Microsoft.AspNetCore.Http; using Microsoft.AspNetCore.SignalR; using Microsoft.Extensions.DependencyInjection; +using Microsoft.Extensions.Logging; using Microsoft.Extensions.Options; namespace Microsoft.AspNetCore.Components.Server.Circuits @@ -17,10 +18,12 @@ namespace Microsoft.AspNetCore.Components.Server.Circuits { private readonly IServiceScopeFactory _scopeFactory; private readonly DefaultCircuitFactoryOptions _options; + private readonly ILoggerFactory _loggerFactory; public DefaultCircuitFactory( IServiceScopeFactory scopeFactory, - IOptions options) + IOptions options, + ILoggerFactory loggerFactory) { if (options == null) { @@ -29,6 +32,7 @@ namespace Microsoft.AspNetCore.Components.Server.Circuits _scopeFactory = scopeFactory ?? throw new ArgumentNullException(nameof(scopeFactory)); _options = options.Value; + _loggerFactory = loggerFactory; } public override CircuitHost CreateCircuitHost(HttpContext httpContext, IClientProxy client) @@ -48,7 +52,8 @@ namespace Microsoft.AspNetCore.Components.Server.Circuits rendererRegistry, jsRuntime, client, - dispatcher); + dispatcher, + _loggerFactory.CreateLogger()); var circuitHandlers = scope.ServiceProvider.GetServices() .OrderBy(h => h.Order) diff --git a/src/Components/Server/src/Circuits/RemoteRenderer.cs b/src/Components/Server/src/Circuits/RemoteRenderer.cs index f40da44b2f..46d21e5fbf 100644 --- a/src/Components/Server/src/Circuits/RemoteRenderer.cs +++ b/src/Components/Server/src/Circuits/RemoteRenderer.cs @@ -7,8 +7,10 @@ using System.Threading; using System.Threading.Tasks; using MessagePack; using Microsoft.AspNetCore.Components.Rendering; +using Microsoft.AspNetCore.Components.Server; using Microsoft.AspNetCore.Components.Server.Circuits; using Microsoft.AspNetCore.SignalR; +using Microsoft.Extensions.Logging; using Microsoft.JSInterop; namespace Microsoft.AspNetCore.Components.Browser.Rendering @@ -25,6 +27,7 @@ namespace Microsoft.AspNetCore.Components.Browser.Rendering private readonly RendererRegistry _rendererRegistry; private readonly ConcurrentDictionary> _pendingRenders = new ConcurrentDictionary>(); + private readonly ILogger _logger; private long _nextRenderId = 1; /// @@ -45,7 +48,8 @@ namespace Microsoft.AspNetCore.Components.Browser.Rendering RendererRegistry rendererRegistry, IJSRuntime jsRuntime, IClientProxy client, - IDispatcher dispatcher) + IDispatcher dispatcher, + ILogger logger) : base(serviceProvider, dispatcher) { _rendererRegistry = rendererRegistry; @@ -53,18 +57,7 @@ namespace Microsoft.AspNetCore.Components.Browser.Rendering _client = client; _id = _rendererRegistry.Add(this); - } - - /// - /// Attaches a new root component to the renderer, - /// causing it to be displayed in the specified DOM element. - /// - /// The type of the component. - /// A CSS selector that uniquely identifies a DOM element. - public void AddComponent(string domElementSelector) - where TComponent : IComponent - { - AddComponent(typeof(TComponent), domElementSelector); + _logger = logger; } /// @@ -73,7 +66,7 @@ namespace Microsoft.AspNetCore.Components.Browser.Rendering /// /// The type of the component. /// A CSS selector that uniquely identifies a DOM element. - public void AddComponent(Type componentType, string domElementSelector) + public Task AddComponentAsync(Type componentType, string domElementSelector) { var component = InstantiateComponent(componentType); var componentId = AssignRootComponentId(component); @@ -85,7 +78,23 @@ namespace Microsoft.AspNetCore.Components.Browser.Rendering componentId); CaptureAsyncExceptions(attachComponentTask); - RenderRootComponent(componentId); + return RenderRootComponentAsync(componentId); + } + + /// + protected override void HandleException(Exception exception) + { + if (exception is AggregateException aggregateException) + { + foreach (var innerException in aggregateException.Flatten().InnerExceptions) + { + _logger.UnhandledExceptionRenderingComponent(innerException); + } + } + else + { + _logger.UnhandledExceptionRenderingComponent(exception); + } } /// diff --git a/src/Components/Server/src/LoggerExtensions.cs b/src/Components/Server/src/LoggerExtensions.cs new file mode 100644 index 0000000000..54e731fdf4 --- /dev/null +++ b/src/Components/Server/src/LoggerExtensions.cs @@ -0,0 +1,29 @@ +// Copyright (c) .NET Foundation. All rights reserved. +// Licensed under the Apache License, Version 2.0. See License.txt in the project root for license information. + +using System; +using Microsoft.Extensions.Logging; + +namespace Microsoft.AspNetCore.Components.Server +{ + internal static class LoggerExtensions + { + private static readonly Action _unhandledExceptionRenderingComponent; + + static LoggerExtensions() + { + _unhandledExceptionRenderingComponent = LoggerMessage.Define( + LogLevel.Warning, + new EventId(1, "ExceptionRenderingComponent"), + "Unhandled exception rendering component: {Message}"); + } + + public static void UnhandledExceptionRenderingComponent(this ILogger logger, Exception exception) + { + _unhandledExceptionRenderingComponent( + logger, + exception.Message, + exception); + } + } +} diff --git a/src/Components/Server/test/Circuits/CircuitHostTest.cs b/src/Components/Server/test/Circuits/CircuitHostTest.cs index 0b8685c9aa..60efec07cb 100644 --- a/src/Components/Server/test/Circuits/CircuitHostTest.cs +++ b/src/Components/Server/test/Circuits/CircuitHostTest.cs @@ -8,6 +8,7 @@ using Microsoft.AspNetCore.Components.Browser; using Microsoft.AspNetCore.Components.Browser.Rendering; using Microsoft.AspNetCore.SignalR; using Microsoft.Extensions.DependencyInjection; +using Microsoft.Extensions.Logging.Abstractions; using Microsoft.JSInterop; using Moq; using Xunit; @@ -155,7 +156,7 @@ namespace Microsoft.AspNetCore.Components.Server.Circuits private class TestRemoteRenderer : RemoteRenderer { public TestRemoteRenderer(IServiceProvider serviceProvider, RendererRegistry rendererRegistry, IJSRuntime jsRuntime, IClientProxy client) - : base(serviceProvider, rendererRegistry, jsRuntime, client, CreateDefaultDispatcher()) + : base(serviceProvider, rendererRegistry, jsRuntime, client, CreateDefaultDispatcher(), NullLogger.Instance) { } diff --git a/src/Components/Server/test/Circuits/RenderBatchWriterTest.cs b/src/Components/Server/test/Circuits/RenderBatchWriterTest.cs index 0edf120433..ce70a4edfc 100644 --- a/src/Components/Server/test/Circuits/RenderBatchWriterTest.cs +++ b/src/Components/Server/test/Circuits/RenderBatchWriterTest.cs @@ -377,6 +377,11 @@ namespace Microsoft.AspNetCore.Components.Server { } + protected override void HandleException(Exception exception) + { + throw new NotImplementedException(); + } + protected override Task UpdateDisplayAsync(in RenderBatch renderBatch) => throw new NotImplementedException(); } diff --git a/src/Components/Shared/test/TestRenderer.cs b/src/Components/Shared/test/TestRenderer.cs index 7efeb51e56..1caaf260c4 100644 --- a/src/Components/Shared/test/TestRenderer.cs +++ b/src/Components/Shared/test/TestRenderer.cs @@ -6,7 +6,6 @@ using System.Collections.Generic; using System.Linq; using System.Runtime.ExceptionServices; using System.Threading.Tasks; -using Microsoft.AspNetCore.Components; using Microsoft.AspNetCore.Components.Rendering; using Xunit; @@ -31,11 +30,18 @@ namespace Microsoft.AspNetCore.Components.Test.Helpers public List Batches { get; } = new List(); + public List HandledExceptions { get; } = new List(); + + public bool ShouldHandleExceptions { get; set; } + public new int AssignRootComponentId(IComponent component) => base.AssignRootComponentId(component); - public new void RenderRootComponent(int componentId) - => Invoke(() => base.RenderRootComponent(componentId)); + public void RenderRootComponent(int componentId, ParameterCollection? parameters = default) + { + var task = InvokeAsync(() => base.RenderRootComponentAsync(componentId, parameters ?? ParameterCollection.Empty)); + UnwrapTask(task); + } public new Task RenderRootComponentAsync(int componentId) => InvokeAsync(() => base.RenderRootComponentAsync(componentId)); @@ -43,25 +49,43 @@ namespace Microsoft.AspNetCore.Components.Test.Helpers public new Task RenderRootComponentAsync(int componentId, ParameterCollection parameters) => InvokeAsync(() => base.RenderRootComponentAsync(componentId, parameters)); - public new void DispatchEvent(int componentId, int eventHandlerId, UIEventArgs args) + public new Task DispatchEventAsync(int componentId, int eventHandlerId, UIEventArgs args) + { + var task = InvokeAsync(() => base.DispatchEventAsync(componentId, eventHandlerId, args)); + return UnwrapTask(task); + } + + private static Task UnwrapTask(Task task) { - var t = Invoke(() => base.DispatchEvent(componentId, eventHandlerId, args)); // This should always be run synchronously - Assert.True(t.IsCompleted); - if (t.IsFaulted) + Assert.True(task.IsCompleted); + if (task.IsFaulted) { - var exception = t.Exception.Flatten().InnerException; + var exception = task.Exception.Flatten().InnerException; while (exception is AggregateException e) { exception = e.InnerException; } + ExceptionDispatchInfo.Capture(exception).Throw(); } + + return task; } public T InstantiateComponent() where T : IComponent => (T)InstantiateComponent(typeof(T)); + protected override void HandleException(Exception exception) + { + if (!ShouldHandleExceptions) + { + throw exception; + } + + HandledExceptions.Add(exception); + } + protected override Task UpdateDisplayAsync(in RenderBatch renderBatch) { OnUpdateDisplay?.Invoke(renderBatch); diff --git a/src/Components/test/E2ETest/Infrastructure/SeleniumStandaloneServer.cs b/src/Components/test/E2ETest/Infrastructure/SeleniumStandaloneServer.cs index e263e4e5ea..ed59345b52 100644 --- a/src/Components/test/E2ETest/Infrastructure/SeleniumStandaloneServer.cs +++ b/src/Components/test/E2ETest/Infrastructure/SeleniumStandaloneServer.cs @@ -107,14 +107,13 @@ namespace Microsoft.AspNetCore.Components.E2ETest.Infrastructure { } - await Task.Delay(1000); - + await Task.Delay(1000); } }); try { - waitForStart.TimeoutAfter(Timeout).Wait(); + waitForStart.TimeoutAfter(Timeout).Wait(1000); } catch (Exception ex) { diff --git a/src/Servers/Kestrel/test/Interop.FunctionalTests/H2SpecTests.cs b/src/Servers/Kestrel/test/Interop.FunctionalTests/H2SpecTests.cs index 8de79a438b..615aea8331 100644 --- a/src/Servers/Kestrel/test/Interop.FunctionalTests/H2SpecTests.cs +++ b/src/Servers/Kestrel/test/Interop.FunctionalTests/H2SpecTests.cs @@ -23,7 +23,7 @@ namespace Interop.FunctionalTests SkipReason = "Missing Windows ALPN support: https://en.wikipedia.org/wiki/Application-Layer_Protocol_Negotiation#Support")] public class H2SpecTests : LoggedTest { - [ConditionalTheory] + [ConditionalTheory(Skip = "https://github.com/aspnet/AspNetCore/issues/6691")] [MemberData(nameof(H2SpecTestCases))] [SkipOnHelix] // https://github.com/aspnet/AspNetCore/issues/7299 public async Task RunIndividualTestCase(H2SpecTestCase testCase)