diff --git a/src/Components/WebAssembly/WebAssembly/src/Rendering/WebAssemblyRenderer.cs b/src/Components/WebAssembly/WebAssembly/src/Rendering/WebAssemblyRenderer.cs index a6711af411d1..b84feae77b02 100644 --- a/src/Components/WebAssembly/WebAssembly/src/Rendering/WebAssemblyRenderer.cs +++ b/src/Components/WebAssembly/WebAssembly/src/Rendering/WebAssemblyRenderer.cs @@ -20,9 +20,9 @@ internal class WebAssemblyRenderer : Renderer { private readonly ILogger _logger; private readonly int _webAssemblyRendererId; + private readonly QueueWithLast deferredIncomingEvents = new(); private bool isDispatchingEvent; - private Queue deferredIncomingEvents = new Queue(); /// /// Constructs an instance of . @@ -103,7 +103,23 @@ protected override Task UpdateDisplayAsync(in RenderBatch batch) _webAssemblyRendererId, batch); - return Task.CompletedTask; + if (deferredIncomingEvents.Count == 0) + { + // In the vast majority of cases, since the call to update the UI is synchronous, + // we just return a pre-completed task from here. + return Task.CompletedTask; + } + else + { + // However, in the rare case where JS sent us any event notifications that we had to + // defer until later, we behave as if the renderbatch isn't acknowledged until we have at + // least dispatched those event calls. This is to make the WebAssembly behavior more + // consistent with the Server behavior, which receives batch acknowledgements asynchronously + // and they are queued up with any other calls from JS such as event calls. If we didn't + // do this, then the order of execution could be inconsistent with Server, and in fact + // leads to a specific bug: https://github.com/dotnet/aspnetcore/issues/26838 + return deferredIncomingEvents.Last.StartHandlerCompletionSource.Task; + } } /// @@ -144,7 +160,7 @@ public override Task DispatchEventAsync(ulong eventHandlerId, EventFieldInfo? ev { var info = new IncomingEventInfo(eventHandlerId, eventFieldInfo, eventArgs); deferredIncomingEvents.Enqueue(info); - return info.TaskCompletionSource.Task; + return info.FinishHandlerCompletionSource.Task; } else { @@ -171,16 +187,20 @@ public override Task DispatchEventAsync(ulong eventHandlerId, EventFieldInfo? ev private async Task ProcessNextDeferredEventAsync() { var info = deferredIncomingEvents.Dequeue(); - var taskCompletionSource = info.TaskCompletionSource; try { - await DispatchEventAsync(info.EventHandlerId, info.EventFieldInfo, info.EventArgs); - taskCompletionSource.SetResult(); + var handlerTask = DispatchEventAsync(info.EventHandlerId, info.EventFieldInfo, info.EventArgs); + info.StartHandlerCompletionSource.SetResult(); + await handlerTask; + info.FinishHandlerCompletionSource.SetResult(); } catch (Exception ex) { - taskCompletionSource.SetException(ex); + // Even if the handler threw synchronously, we at least started processing, so always complete successfully + info.StartHandlerCompletionSource.TrySetResult(); + + info.FinishHandlerCompletionSource.SetException(ex); } } @@ -189,14 +209,16 @@ readonly struct IncomingEventInfo public readonly ulong EventHandlerId; public readonly EventFieldInfo? EventFieldInfo; public readonly EventArgs EventArgs; - public readonly TaskCompletionSource TaskCompletionSource; + public readonly TaskCompletionSource StartHandlerCompletionSource; + public readonly TaskCompletionSource FinishHandlerCompletionSource; public IncomingEventInfo(ulong eventHandlerId, EventFieldInfo? eventFieldInfo, EventArgs eventArgs) { EventHandlerId = eventHandlerId; EventFieldInfo = eventFieldInfo; EventArgs = eventArgs; - TaskCompletionSource = new TaskCompletionSource(TaskCreationOptions.RunContinuationsAsynchronously); + StartHandlerCompletionSource = new TaskCompletionSource(TaskCreationOptions.RunContinuationsAsynchronously); + FinishHandlerCompletionSource = new TaskCompletionSource(TaskCreationOptions.RunContinuationsAsynchronously); } } @@ -225,5 +247,30 @@ public static void UnhandledExceptionRenderingComponent(ILogger logger, Exceptio exception); } } + + private class QueueWithLast + { + private readonly Queue _items = new(); + + public int Count => _items.Count; + + public T? Last { get; private set; } + + public T Dequeue() + { + if (_items.Count == 1) + { + Last = default; + } + + return _items.Dequeue(); + } + + public void Enqueue(T item) + { + Last = item; + _items.Enqueue(item); + } + } } } diff --git a/src/Components/test/E2ETest/Tests/EventTest.cs b/src/Components/test/E2ETest/Tests/EventTest.cs index 9cbaea5c6fb1..9b2e68aeb3c3 100644 --- a/src/Components/test/E2ETest/Tests/EventTest.cs +++ b/src/Components/test/E2ETest/Tests/EventTest.cs @@ -50,6 +50,17 @@ public void FocusEvents_CanTrigger() Browser.Equal("onfocus,onfocusin,onblur,onfocusout,", () => output.Text); } + [Fact] + public void FocusEvents_CanReceiveBlurCausedByElementRemoval() + { + // Represents https://github.com/dotnet/aspnetcore/issues/26838 + + Browser.MountTestComponent(); + + Browser.FindElement(By.Id("button-that-disappears")).Click(); + Browser.Equal("True", () => Browser.FindElement(By.Id("button-received-focus-out")).Text); + } + [Fact] public void MouseOverAndMouseOut_CanTrigger() { diff --git a/src/Components/test/testassets/BasicTestApp/FocusEventComponent.razor b/src/Components/test/testassets/BasicTestApp/FocusEventComponent.razor index 3af7b37e754e..3628a6ed7980 100644 --- a/src/Components/test/testassets/BasicTestApp/FocusEventComponent.razor +++ b/src/Components/test/testassets/BasicTestApp/FocusEventComponent.razor @@ -3,7 +3,7 @@

Focus and activation

- Input: + Input:

Output: @message @@ -12,40 +12,59 @@

+

+ A button that disappears when clicked: + @if (showButtonThatDisappearsWhenClicked) + { + + } + + Received focus out: @buttonReceivedFocusOut +

+

Another input (to distract you)

@code { - + bool showButtonThatDisappearsWhenClicked = true; + bool buttonReceivedFocusOut; string message; void OnFocus(FocusEventArgs e) { message += "onfocus,"; - StateHasChanged(); } void OnBlur(FocusEventArgs e) { message += "onblur,"; - StateHasChanged(); } void OnFocusIn(FocusEventArgs e) { message += "onfocusin,"; - StateHasChanged(); } void OnFocusOut(FocusEventArgs e) { message += "onfocusout,"; - StateHasChanged(); } void Clear() { message = string.Empty; } + + void MakeButtonDisappear() + { + showButtonThatDisappearsWhenClicked = false; + } + + void DisappearingButtonFocusOut() + { + buttonReceivedFocusOut = true; + } }