Skip to content

Commit 9817a81

Browse files
Fix event during onafterrender (#32017)
Co-authored-by: Pranav K <[email protected]>
1 parent cc1eb31 commit 9817a81

File tree

6 files changed

+203
-134
lines changed

6 files changed

+203
-134
lines changed
Lines changed: 70 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,70 @@
1+
// Copyright (c) .NET Foundation. All rights reserved.
2+
// Licensed under the Apache License, Version 2.0. See License.txt in the project root for license information.
3+
4+
using System;
5+
using System.Collections.Generic;
6+
7+
namespace Microsoft.AspNetCore.Components.WebAssembly.Hosting
8+
{
9+
// A mechanism for queuing JS-to-.NET calls so they aren't nested on the call stack and hence
10+
// have the same ordering behaviors as in Blazor Server. This eliminates serveral inconsistency
11+
// problems and bugs that otherwise require special-case solutions in other parts of the code.
12+
//
13+
// The reason for not using an actual SynchronizationContext for this is that, historically,
14+
// Blazor WebAssembly has not enforced any rule around having to dispatch to a sync context.
15+
// Adding such a rule now would be too breaking, given how component libraries may be reliant
16+
// on being able to render at any time without InvokeAsync. If we add true multithreading in the
17+
// future, we should start enforcing dispatch if (and only if) multithreading is enabled.
18+
// For now, this minimal work queue is an internal detail of how the framework dispatches
19+
// incoming JS->.NET calls and makes sure they get deferred if a renderbatch is in process.
20+
21+
internal static class WebAssemblyCallQueue
22+
{
23+
private static bool _isCallInProgress;
24+
private static readonly Queue<Action> _pendingWork = new();
25+
26+
public static bool IsInProgress => _isCallInProgress;
27+
public static bool HasUnstartedWork => _pendingWork.Count > 0;
28+
29+
/// <summary>
30+
/// Runs the supplied callback when possible. If the call queue is empty, the callback is executed
31+
/// synchronously. If some call is already executing within the queue, the callback is added to the
32+
/// back of the queue and will be executed in turn.
33+
/// </summary>
34+
/// <typeparam name="T">The type of a state parameter for the callback</typeparam>
35+
/// <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>
36+
/// <param name="callback">The callback to run.</param>
37+
/// <remarks>
38+
/// In most cases this should only be used for callbacks that will not throw, because
39+
/// [1] Unhandled exceptions will be fatal to the application, as the work queue will no longer process
40+
/// further items (just like unhandled hub exceptions in Blazor Server)
41+
/// [2] The exception will be thrown at the point of the top-level caller, which is not necessarily the
42+
/// code that scheduled the callback, so you may not be able to observe it.
43+
///
44+
/// We could change this to return a Task and do the necessary try/catch things to direct exceptions back
45+
/// to the code that scheduled the callback, but it's not required for current use cases and would require
46+
/// at least an extra allocation and layer of try/catch per call, plus more work to schedule continuations
47+
/// at the call site.
48+
/// </remarks>
49+
public static void Schedule<T>(T state, Action<T> callback)
50+
{
51+
if (_isCallInProgress)
52+
{
53+
_pendingWork.Enqueue(() => callback(state));
54+
}
55+
else
56+
{
57+
_isCallInProgress = true;
58+
callback(state);
59+
60+
// Now run any queued work items
61+
while (_pendingWork.TryDequeue(out var nextWorkItem))
62+
{
63+
nextWorkItem();
64+
}
65+
66+
_isCallInProgress = false;
67+
}
68+
}
69+
}
70+
}

src/Components/WebAssembly/WebAssembly/src/Hosting/WebAssemblyHost.cs

Lines changed: 21 additions & 6 deletions
Original file line numberDiff line numberDiff line change
@@ -158,13 +158,28 @@ internal async Task RunAsyncCore(CancellationToken cancellationToken, WebAssembl
158158
var loggerFactory = Services.GetRequiredService<ILoggerFactory>();
159159
_renderer = new WebAssemblyRenderer(Services, loggerFactory);
160160

161-
var rootComponents = _rootComponents;
162-
for (var i = 0; i < rootComponents.Length; i++)
161+
var initializationTcs = new TaskCompletionSource();
162+
WebAssemblyCallQueue.Schedule((_rootComponents, _renderer, initializationTcs), static async state =>
163163
{
164-
var rootComponent = rootComponents[i];
165-
await _renderer.AddComponentAsync(rootComponent.ComponentType, rootComponent.Selector, rootComponent.Parameters);
166-
}
167-
164+
var (rootComponents, renderer, initializationTcs) = state;
165+
166+
try
167+
{
168+
for (var i = 0; i < rootComponents.Length; i++)
169+
{
170+
var rootComponent = rootComponents[i];
171+
await renderer.AddComponentAsync(rootComponent.ComponentType, rootComponent.Selector, rootComponent.Parameters);
172+
}
173+
174+
initializationTcs.SetResult();
175+
}
176+
catch (Exception ex)
177+
{
178+
initializationTcs.SetException(ex);
179+
}
180+
});
181+
182+
await initializationTcs.Task;
168183
store.ExistingState.Clear();
169184

170185
await tcs.Task;

src/Components/WebAssembly/WebAssembly/src/Rendering/WebAssemblyRenderer.cs

Lines changed: 38 additions & 125 deletions
Original file line numberDiff line numberDiff line change
@@ -2,10 +2,10 @@
22
// Licensed under the Apache License, Version 2.0. See License.txt in the project root for license information.
33

44
using System;
5-
using System.Collections.Generic;
65
using System.Diagnostics.CodeAnalysis;
76
using System.Threading.Tasks;
87
using Microsoft.AspNetCore.Components.RenderTree;
8+
using Microsoft.AspNetCore.Components.WebAssembly.Hosting;
99
using Microsoft.AspNetCore.Components.WebAssembly.Services;
1010
using Microsoft.Extensions.Logging;
1111
using static Microsoft.AspNetCore.Internal.LinkerFlags;
@@ -20,9 +20,6 @@ internal class WebAssemblyRenderer : Renderer
2020
{
2121
private readonly ILogger _logger;
2222
private readonly int _webAssemblyRendererId;
23-
private readonly QueueWithLast<IncomingEventInfo> deferredIncomingEvents = new();
24-
25-
private bool isDispatchingEvent;
2623

2724
/// <summary>
2825
/// Constructs an instance of <see cref="WebAssemblyRenderer"/>.
@@ -95,6 +92,32 @@ protected override void Dispose(bool disposing)
9592
RendererRegistry.TryRemove(_webAssemblyRendererId);
9693
}
9794

95+
/// <inheritdoc />
96+
protected override void ProcessPendingRender()
97+
{
98+
// For historical reasons, Blazor WebAssembly doesn't enforce that you use InvokeAsync
99+
// to dispatch calls that originated from outside the system. Changing that now would be
100+
// too breaking, at least until we can make it a prerequisite for multithreading.
101+
// So, we don't have a way to guarantee that calls to here are already on our work queue.
102+
//
103+
// We do need rendering to happen on the work queue so that incoming events can be deferred
104+
// until we've finished this rendering process (and other similar cases where we want
105+
// execution order to be consistent with Blazor Server, which queues all JS->.NET calls).
106+
//
107+
// So, if we find that we're here and are not yet on the work queue, get onto it. Either
108+
// way, rendering must continue synchronously here and is not deferred until later.
109+
if (WebAssemblyCallQueue.IsInProgress)
110+
{
111+
base.ProcessPendingRender();
112+
}
113+
else
114+
{
115+
WebAssemblyCallQueue.Schedule(this, static @this => @this.CallBaseProcessPendingRender());
116+
}
117+
}
118+
119+
private void CallBaseProcessPendingRender() => base.ProcessPendingRender();
120+
98121
/// <inheritdoc />
99122
protected override Task UpdateDisplayAsync(in RenderBatch batch)
100123
{
@@ -103,22 +126,21 @@ protected override Task UpdateDisplayAsync(in RenderBatch batch)
103126
_webAssemblyRendererId,
104127
batch);
105128

106-
if (deferredIncomingEvents.Count == 0)
129+
if (WebAssemblyCallQueue.HasUnstartedWork)
107130
{
108-
// In the vast majority of cases, since the call to update the UI is synchronous,
109-
// we just return a pre-completed task from here.
110-
return Task.CompletedTask;
131+
// Because further incoming calls from JS to .NET are already queued (e.g., event notifications),
132+
// we have to delay the renderbatch acknowledgement until it gets to the front of that queue.
133+
// This is for consistency with Blazor Server which queues all JS-to-.NET calls relative to each
134+
// other, and because various bits of cleanup logic rely on this ordering.
135+
var tcs = new TaskCompletionSource();
136+
WebAssemblyCallQueue.Schedule(tcs, static tcs => tcs.SetResult());
137+
return tcs.Task;
111138
}
112139
else
113140
{
114-
// However, in the rare case where JS sent us any event notifications that we had to
115-
// defer until later, we behave as if the renderbatch isn't acknowledged until we have at
116-
// least dispatched those event calls. This is to make the WebAssembly behavior more
117-
// consistent with the Server behavior, which receives batch acknowledgements asynchronously
118-
// and they are queued up with any other calls from JS such as event calls. If we didn't
119-
// do this, then the order of execution could be inconsistent with Server, and in fact
120-
// leads to a specific bug: https://github.com/dotnet/aspnetcore/issues/26838
121-
return deferredIncomingEvents.Last.StartHandlerCompletionSource.Task;
141+
// Nothing else is pending, so we can treat the renderbatch as acknowledged synchronously.
142+
// This lets upstream code skip an expensive code path and avoids some allocations.
143+
return Task.CompletedTask;
122144
}
123145
}
124146

@@ -138,90 +160,6 @@ protected override void HandleException(Exception exception)
138160
}
139161
}
140162

141-
/// <inheritdoc />
142-
public override Task DispatchEventAsync(ulong eventHandlerId, EventFieldInfo? eventFieldInfo, EventArgs eventArgs)
143-
{
144-
// Be sure we only run one event handler at once. Although they couldn't run
145-
// simultaneously anyway (there's only one thread), they could run nested on
146-
// the stack if somehow one event handler triggers another event synchronously.
147-
// We need event handlers not to overlap because (a) that's consistent with
148-
// server-side Blazor which uses a sync context, and (b) the rendering logic
149-
// relies completely on the idea that within a given scope it's only building
150-
// or processing one batch at a time.
151-
//
152-
// The only currently known case where this makes a difference is in the E2E
153-
// tests in ReorderingFocusComponent, where we hit what seems like a Chrome bug
154-
// where mutating the DOM cause an element's "change" to fire while its "input"
155-
// handler is still running (i.e., nested on the stack) -- this doesn't happen
156-
// in Firefox. Possibly a future version of Chrome may fix this, but even then,
157-
// it's conceivable that DOM mutation events could trigger this too.
158-
159-
if (isDispatchingEvent)
160-
{
161-
var info = new IncomingEventInfo(eventHandlerId, eventFieldInfo, eventArgs);
162-
deferredIncomingEvents.Enqueue(info);
163-
return info.FinishHandlerCompletionSource.Task;
164-
}
165-
else
166-
{
167-
try
168-
{
169-
isDispatchingEvent = true;
170-
return base.DispatchEventAsync(eventHandlerId, eventFieldInfo, eventArgs);
171-
}
172-
finally
173-
{
174-
isDispatchingEvent = false;
175-
176-
if (deferredIncomingEvents.Count > 0)
177-
{
178-
// Fire-and-forget because the task we return from this method should only reflect the
179-
// completion of its own event dispatch, not that of any others that happen to be queued.
180-
// Also, ProcessNextDeferredEventAsync deals with its own async errors.
181-
_ = ProcessNextDeferredEventAsync();
182-
}
183-
}
184-
}
185-
}
186-
187-
private async Task ProcessNextDeferredEventAsync()
188-
{
189-
var info = deferredIncomingEvents.Dequeue();
190-
191-
try
192-
{
193-
var handlerTask = DispatchEventAsync(info.EventHandlerId, info.EventFieldInfo, info.EventArgs);
194-
info.StartHandlerCompletionSource.SetResult();
195-
await handlerTask;
196-
info.FinishHandlerCompletionSource.SetResult();
197-
}
198-
catch (Exception ex)
199-
{
200-
// Even if the handler threw synchronously, we at least started processing, so always complete successfully
201-
info.StartHandlerCompletionSource.TrySetResult();
202-
203-
info.FinishHandlerCompletionSource.SetException(ex);
204-
}
205-
}
206-
207-
readonly struct IncomingEventInfo
208-
{
209-
public readonly ulong EventHandlerId;
210-
public readonly EventFieldInfo? EventFieldInfo;
211-
public readonly EventArgs EventArgs;
212-
public readonly TaskCompletionSource StartHandlerCompletionSource;
213-
public readonly TaskCompletionSource FinishHandlerCompletionSource;
214-
215-
public IncomingEventInfo(ulong eventHandlerId, EventFieldInfo? eventFieldInfo, EventArgs eventArgs)
216-
{
217-
EventHandlerId = eventHandlerId;
218-
EventFieldInfo = eventFieldInfo;
219-
EventArgs = eventArgs;
220-
StartHandlerCompletionSource = new TaskCompletionSource(TaskCreationOptions.RunContinuationsAsynchronously);
221-
FinishHandlerCompletionSource = new TaskCompletionSource(TaskCreationOptions.RunContinuationsAsynchronously);
222-
}
223-
}
224-
225163
private static class Log
226164
{
227165
private static readonly Action<ILogger, string, Exception> _unhandledExceptionRenderingComponent;
@@ -247,30 +185,5 @@ public static void UnhandledExceptionRenderingComponent(ILogger logger, Exceptio
247185
exception);
248186
}
249187
}
250-
251-
private class QueueWithLast<T>
252-
{
253-
private readonly Queue<T> _items = new();
254-
255-
public int Count => _items.Count;
256-
257-
public T? Last { get; private set; }
258-
259-
public T Dequeue()
260-
{
261-
if (_items.Count == 1)
262-
{
263-
Last = default;
264-
}
265-
266-
return _items.Dequeue();
267-
}
268-
269-
public void Enqueue(T item)
270-
{
271-
Last = item;
272-
_items.Enqueue(item);
273-
}
274-
}
275188
}
276189
}

src/Components/WebAssembly/WebAssembly/src/Services/DefaultWebAssemblyJSRuntime.cs

Lines changed: 15 additions & 2 deletions
Original file line numberDiff line numberDiff line change
@@ -4,6 +4,7 @@
44
using System.Diagnostics.CodeAnalysis;
55
using System.Globalization;
66
using System.Text.Json;
7+
using Microsoft.AspNetCore.Components.WebAssembly.Hosting;
78
using Microsoft.JSInterop.Infrastructure;
89
using Microsoft.JSInterop.WebAssembly;
910

@@ -35,7 +36,14 @@ private DefaultWebAssemblyJSRuntime()
3536

3637
// Invoked via Mono's JS interop mechanism (invoke_method)
3738
public static void EndInvokeJS(string argsJson)
38-
=> DotNetDispatcher.EndInvokeJS(Instance, argsJson);
39+
{
40+
WebAssemblyCallQueue.Schedule(argsJson, static argsJson =>
41+
{
42+
// This is not expected to throw, as it takes care of converting any unhandled user code
43+
// exceptions into a failure on the Task that was returned when calling InvokeAsync.
44+
DotNetDispatcher.EndInvokeJS(Instance, argsJson);
45+
});
46+
}
3947

4048
// Invoked via Mono's JS interop mechanism (invoke_method)
4149
public static void BeginInvokeDotNet(string callId, string assemblyNameOrDotNetObjectId, string methodIdentifier, string argsJson)
@@ -57,7 +65,12 @@ public static void BeginInvokeDotNet(string callId, string assemblyNameOrDotNetO
5765
}
5866

5967
var callInfo = new DotNetInvocationInfo(assemblyName, methodIdentifier, dotNetObjectId, callId);
60-
DotNetDispatcher.BeginInvokeDotNet(Instance, callInfo, argsJson);
68+
WebAssemblyCallQueue.Schedule((callInfo, argsJson), static state =>
69+
{
70+
// This is not expected to throw, as it takes care of converting any unhandled user code
71+
// exceptions into a failure on the JS Promise object.
72+
DotNetDispatcher.BeginInvokeDotNet(Instance, state.callInfo, state.argsJson);
73+
});
6174
}
6275
}
6376
}

src/Components/test/E2ETest/Tests/ComponentRenderingTest.cs

Lines changed: 22 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -444,6 +444,28 @@ public void CanUseFocusExtensionToFocusElementPreventScroll()
444444
long getPageYOffset() => (long)((IJavaScriptExecutor)Browser).ExecuteScript("return window.pageYOffset");
445445
}
446446

447+
[Theory]
448+
[InlineData("focus-button-onafterrender-invoke")]
449+
[InlineData("focus-button-onafterrender-await")]
450+
public void CanFocusDuringOnAfterRenderAsyncWithFocusInEvent(string triggerButton)
451+
{
452+
// Represents https://github.com/dotnet/aspnetcore/issues/30070, plus a more complicated
453+
// variant where the initial rendering doesn't start from a JS interop call and hence
454+
// isn't automatically part of the WebAssemblyCallQueue.
455+
456+
var appElement = Browser.MountTestComponent<ElementFocusComponent>();
457+
var didReceiveFocusLabel = appElement.FindElement(By.Id("focus-event-received"));
458+
Browser.Equal("False", () => didReceiveFocusLabel.Text);
459+
460+
appElement.FindElement(By.Id(triggerButton)).Click();
461+
Browser.Equal("True", () => didReceiveFocusLabel.Text);
462+
Browser.Equal("focus-input-onafterrender", () => Browser.SwitchTo().ActiveElement().GetAttribute("id"));
463+
464+
// As well as actually focusing and triggering the onfocusin event, we should not be seeing any errors
465+
var log = Browser.Manage().Logs.GetLog(LogType.Browser);
466+
Assert.DoesNotContain(log, entry => entry.Level == LogLevel.Severe);
467+
}
468+
447469
[Fact]
448470
public void CanCaptureReferencesToDynamicallyAddedElements()
449471
{

0 commit comments

Comments
 (0)