-
Notifications
You must be signed in to change notification settings - Fork 10.4k
Fix event during onafterrender #32017
New issue
Have a question about this project? Sign up for a free GitHub account to open an issue and contact its maintainers and the community.
By clicking “Sign up for GitHub”, you agree to our terms of service and privacy statement. We’ll occasionally send you account related emails.
Already on GitHub? Sign in to your account
Changes from all commits
96e5bf5
c70b8bc
333f27d
7b8ac82
59bd0fd
2fb8346
82b774c
9076bd2
File filter
Filter by extension
Conversations
Jump to
Diff view
Diff view
There are no files selected for viewing
Original file line number | Diff line number | Diff line change |
---|---|---|
@@ -0,0 +1,70 @@ | ||
// 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.Collections.Generic; | ||
|
||
namespace Microsoft.AspNetCore.Components.WebAssembly.Hosting | ||
{ | ||
// A mechanism for queuing JS-to-.NET calls so they aren't nested on the call stack and hence | ||
// have the same ordering behaviors as in Blazor Server. This eliminates serveral inconsistency | ||
// problems and bugs that otherwise require special-case solutions in other parts of the code. | ||
// | ||
// The reason for not using an actual SynchronizationContext for this is that, historically, | ||
// Blazor WebAssembly has not enforced any rule around having to dispatch to a sync context. | ||
// Adding such a rule now would be too breaking, given how component libraries may be reliant | ||
// on being able to render at any time without InvokeAsync. If we add true multithreading in the | ||
// future, we should start enforcing dispatch if (and only if) multithreading is enabled. | ||
// For now, this minimal work queue is an internal detail of how the framework dispatches | ||
// incoming JS->.NET calls and makes sure they get deferred if a renderbatch is in process. | ||
|
||
internal static class WebAssemblyCallQueue | ||
There was a problem hiding this comment. Choose a reason for hiding this commentThe reason will be displayed to describe this comment to others. Learn more. Super nit suggestion: You could consider making this a singleton to make unit testing this type easier. There was a problem hiding this comment. Choose a reason for hiding this commentThe reason will be displayed to describe this comment to others. Learn more. This looks very similar to the RendererSynchronizationContext is there a way we could "reuse" that here instead of having a separate work queue? These things are tricky to test implement and can lead to subtle bugs that are hard to track down There was a problem hiding this comment. Choose a reason for hiding this commentThe reason will be displayed to describe this comment to others. Learn more. The main issue with using a sync context is the back-compat about enforcement of being "on" the sync context (discussed above), but even besides that, the existence of synchronous interop makes WebAssembly a different kind of runtime environment for which we can't assume that I know your concern is more future-thinking and looks towards a scenario where we have real multithreading. My expectation is that when/if we get to that, we'll be in a position to recreate the same threading semantics as Server pretty much exactly (assuming we want to), but that's not the case today and shouldn't be considered in scope for fixing this bug. My goal in this PR is to wipe out the largest class of possible inconsistency issues I can while not regressing compat or perf. |
||
{ | ||
private static bool _isCallInProgress; | ||
private static readonly Queue<Action> _pendingWork = new(); | ||
|
||
public static bool IsInProgress => _isCallInProgress; | ||
public static bool HasUnstartedWork => _pendingWork.Count > 0; | ||
|
||
/// <summary> | ||
/// Runs the supplied callback when possible. If the call queue is empty, the callback is executed | ||
/// synchronously. If some call is already executing within the queue, the callback is added to the | ||
/// back of the queue and will be executed in turn. | ||
/// </summary> | ||
/// <typeparam name="T">The type of a state parameter for the callback</typeparam> | ||
/// <param name="state">A state parameter for the callback. If the callback is able to execute synchronously, this allows us to avoid any allocations for the closure.</param> | ||
/// <param name="callback">The callback to run.</param> | ||
/// <remarks> | ||
/// In most cases this should only be used for callbacks that will not throw, because | ||
/// [1] Unhandled exceptions will be fatal to the application, as the work queue will no longer process | ||
/// further items (just like unhandled hub exceptions in Blazor Server) | ||
/// [2] The exception will be thrown at the point of the top-level caller, which is not necessarily the | ||
/// code that scheduled the callback, so you may not be able to observe it. | ||
/// | ||
/// We could change this to return a Task and do the necessary try/catch things to direct exceptions back | ||
/// to the code that scheduled the callback, but it's not required for current use cases and would require | ||
/// at least an extra allocation and layer of try/catch per call, plus more work to schedule continuations | ||
/// at the call site. | ||
/// </remarks> | ||
public static void Schedule<T>(T state, Action<T> callback) | ||
There was a problem hiding this comment. Choose a reason for hiding this commentThe reason will be displayed to describe this comment to others. Learn more. nit: should the method be aligned with the class? WebAssemblyCallScheduler / Schedule, or WebAssemblyCallQueue.Enqueue ? Although since this is static, there's a bit of redundancy, maybe WebAssemblyDispatcher.Schedule? There was a problem hiding this comment. Choose a reason for hiding this commentThe reason will be displayed to describe this comment to others. Learn more. TBH with this being internal I'd be inclined to leave it. Trying to communicate something like "run it maybe right away or maybe later" is hard to put into a single word. We already use the term "dispatcher" for something different so I was going for "call queue" as the most literal statement of what it is. |
||
{ | ||
if (_isCallInProgress) | ||
{ | ||
_pendingWork.Enqueue(() => callback(state)); | ||
There was a problem hiding this comment. Choose a reason for hiding this commentThe reason will be displayed to describe this comment to others. Learn more. If you wanted to avoid the allocation, you could make this an There was a problem hiding this comment. Choose a reason for hiding this commentThe reason will be displayed to describe this comment to others. Learn more. I didn't spot a good way to do that without forcing an extra allocation if Since 99% of the time the call will be executed synchronously, I didn't think it worth making the code that much more to maintain and risk future inconsistencies. Please let me know if you disagree or have ideas for how to avoid that drawback. There was a problem hiding this comment. Choose a reason for hiding this commentThe reason will be displayed to describe this comment to others. Learn more. None in particular. I guess if we profile this and see additional allocations, we could devise something interesting. |
||
} | ||
else | ||
{ | ||
_isCallInProgress = true; | ||
callback(state); | ||
|
||
// Now run any queued work items | ||
while (_pendingWork.TryDequeue(out var nextWorkItem)) | ||
{ | ||
nextWorkItem(); | ||
} | ||
|
||
_isCallInProgress = false; | ||
} | ||
} | ||
} | ||
} |
Original file line number | Diff line number | Diff line change |
---|---|---|
|
@@ -2,10 +2,10 @@ | |
// Licensed under the Apache License, Version 2.0. See License.txt in the project root for license information. | ||
|
||
using System; | ||
using System.Collections.Generic; | ||
using System.Diagnostics.CodeAnalysis; | ||
using System.Threading.Tasks; | ||
using Microsoft.AspNetCore.Components.RenderTree; | ||
using Microsoft.AspNetCore.Components.WebAssembly.Hosting; | ||
using Microsoft.AspNetCore.Components.WebAssembly.Services; | ||
using Microsoft.Extensions.Logging; | ||
using static Microsoft.AspNetCore.Internal.LinkerFlags; | ||
|
@@ -20,9 +20,6 @@ internal class WebAssemblyRenderer : Renderer | |
{ | ||
private readonly ILogger _logger; | ||
private readonly int _webAssemblyRendererId; | ||
private readonly QueueWithLast<IncomingEventInfo> deferredIncomingEvents = new(); | ||
|
||
private bool isDispatchingEvent; | ||
|
||
/// <summary> | ||
/// Constructs an instance of <see cref="WebAssemblyRenderer"/>. | ||
|
@@ -95,6 +92,32 @@ protected override void Dispose(bool disposing) | |
RendererRegistry.TryRemove(_webAssemblyRendererId); | ||
} | ||
|
||
/// <inheritdoc /> | ||
protected override void ProcessPendingRender() | ||
{ | ||
// For historical reasons, Blazor WebAssembly doesn't enforce that you use InvokeAsync | ||
// to dispatch calls that originated from outside the system. Changing that now would be | ||
// too breaking, at least until we can make it a prerequisite for multithreading. | ||
// So, we don't have a way to guarantee that calls to here are already on our work queue. | ||
// | ||
// We do need rendering to happen on the work queue so that incoming events can be deferred | ||
// until we've finished this rendering process (and other similar cases where we want | ||
// execution order to be consistent with Blazor Server, which queues all JS->.NET calls). | ||
// | ||
// So, if we find that we're here and are not yet on the work queue, get onto it. Either | ||
// way, rendering must continue synchronously here and is not deferred until later. | ||
if (WebAssemblyCallQueue.IsInProgress) | ||
{ | ||
base.ProcessPendingRender(); | ||
} | ||
else | ||
{ | ||
WebAssemblyCallQueue.Schedule(this, static @this => @this.CallBaseProcessPendingRender()); | ||
} | ||
} | ||
|
||
private void CallBaseProcessPendingRender() => base.ProcessPendingRender(); | ||
|
||
/// <inheritdoc /> | ||
protected override Task UpdateDisplayAsync(in RenderBatch batch) | ||
{ | ||
|
@@ -103,22 +126,21 @@ protected override Task UpdateDisplayAsync(in RenderBatch batch) | |
_webAssemblyRendererId, | ||
batch); | ||
|
||
if (deferredIncomingEvents.Count == 0) | ||
if (WebAssemblyCallQueue.HasUnstartedWork) | ||
{ | ||
// 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; | ||
// Because further incoming calls from JS to .NET are already queued (e.g., event notifications), | ||
// we have to delay the renderbatch acknowledgement until it gets to the front of that queue. | ||
// This is for consistency with Blazor Server which queues all JS-to-.NET calls relative to each | ||
// other, and because various bits of cleanup logic rely on this ordering. | ||
var tcs = new TaskCompletionSource(); | ||
There was a problem hiding this comment. Choose a reason for hiding this commentThe reason will be displayed to describe this comment to others. Learn more. Should this specify RunContinuationsAsynchronously? There was a problem hiding this comment. Choose a reason for hiding this commentThe reason will be displayed to describe this comment to others. Learn more. I'm not aware of any need for that here. |
||
WebAssemblyCallQueue.Schedule(tcs, static tcs => tcs.SetResult()); | ||
return tcs.Task; | ||
} | ||
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; | ||
// Nothing else is pending, so we can treat the renderbatch as acknowledged synchronously. | ||
// This lets upstream code skip an expensive code path and avoids some allocations. | ||
return Task.CompletedTask; | ||
} | ||
} | ||
|
||
|
@@ -138,90 +160,6 @@ protected override void HandleException(Exception exception) | |
} | ||
} | ||
|
||
/// <inheritdoc /> | ||
public override Task DispatchEventAsync(ulong eventHandlerId, EventFieldInfo? eventFieldInfo, EventArgs eventArgs) | ||
There was a problem hiding this comment. Choose a reason for hiding this commentThe reason will be displayed to describe this comment to others. Learn more. Can you help me understand what the consequences of removing this API are? There was a problem hiding this comment. Choose a reason for hiding this commentThe reason will be displayed to describe this comment to others. Learn more. The API remains, it's just this override that is no longer needed, since the base class implementation is now sufficient. |
||
{ | ||
// Be sure we only run one event handler at once. Although they couldn't run | ||
// simultaneously anyway (there's only one thread), they could run nested on | ||
// the stack if somehow one event handler triggers another event synchronously. | ||
// We need event handlers not to overlap because (a) that's consistent with | ||
// server-side Blazor which uses a sync context, and (b) the rendering logic | ||
// relies completely on the idea that within a given scope it's only building | ||
// or processing one batch at a time. | ||
// | ||
// The only currently known case where this makes a difference is in the E2E | ||
// tests in ReorderingFocusComponent, where we hit what seems like a Chrome bug | ||
// where mutating the DOM cause an element's "change" to fire while its "input" | ||
// handler is still running (i.e., nested on the stack) -- this doesn't happen | ||
// in Firefox. Possibly a future version of Chrome may fix this, but even then, | ||
// it's conceivable that DOM mutation events could trigger this too. | ||
|
||
if (isDispatchingEvent) | ||
{ | ||
var info = new IncomingEventInfo(eventHandlerId, eventFieldInfo, eventArgs); | ||
deferredIncomingEvents.Enqueue(info); | ||
return info.FinishHandlerCompletionSource.Task; | ||
} | ||
else | ||
{ | ||
try | ||
{ | ||
isDispatchingEvent = true; | ||
return base.DispatchEventAsync(eventHandlerId, eventFieldInfo, eventArgs); | ||
} | ||
finally | ||
{ | ||
isDispatchingEvent = false; | ||
|
||
if (deferredIncomingEvents.Count > 0) | ||
{ | ||
// Fire-and-forget because the task we return from this method should only reflect the | ||
// completion of its own event dispatch, not that of any others that happen to be queued. | ||
// Also, ProcessNextDeferredEventAsync deals with its own async errors. | ||
_ = ProcessNextDeferredEventAsync(); | ||
} | ||
} | ||
} | ||
} | ||
|
||
private async Task ProcessNextDeferredEventAsync() | ||
{ | ||
var info = deferredIncomingEvents.Dequeue(); | ||
|
||
try | ||
{ | ||
var handlerTask = DispatchEventAsync(info.EventHandlerId, info.EventFieldInfo, info.EventArgs); | ||
info.StartHandlerCompletionSource.SetResult(); | ||
await handlerTask; | ||
info.FinishHandlerCompletionSource.SetResult(); | ||
} | ||
catch (Exception ex) | ||
{ | ||
// Even if the handler threw synchronously, we at least started processing, so always complete successfully | ||
info.StartHandlerCompletionSource.TrySetResult(); | ||
|
||
info.FinishHandlerCompletionSource.SetException(ex); | ||
} | ||
} | ||
|
||
readonly struct IncomingEventInfo | ||
{ | ||
public readonly ulong EventHandlerId; | ||
public readonly EventFieldInfo? EventFieldInfo; | ||
public readonly EventArgs EventArgs; | ||
public readonly TaskCompletionSource StartHandlerCompletionSource; | ||
public readonly TaskCompletionSource FinishHandlerCompletionSource; | ||
|
||
public IncomingEventInfo(ulong eventHandlerId, EventFieldInfo? eventFieldInfo, EventArgs eventArgs) | ||
{ | ||
EventHandlerId = eventHandlerId; | ||
EventFieldInfo = eventFieldInfo; | ||
EventArgs = eventArgs; | ||
StartHandlerCompletionSource = new TaskCompletionSource(TaskCreationOptions.RunContinuationsAsynchronously); | ||
FinishHandlerCompletionSource = new TaskCompletionSource(TaskCreationOptions.RunContinuationsAsynchronously); | ||
} | ||
} | ||
|
||
private static class Log | ||
{ | ||
private static readonly Action<ILogger, string, Exception> _unhandledExceptionRenderingComponent; | ||
|
@@ -247,30 +185,5 @@ public static void UnhandledExceptionRenderingComponent(ILogger logger, Exceptio | |
exception); | ||
} | ||
} | ||
|
||
private class QueueWithLast<T> | ||
{ | ||
private readonly Queue<T> _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); | ||
} | ||
} | ||
} | ||
} |
Uh oh!
There was an error while loading. Please reload this page.