Skip to content

Commit a21770d

Browse files
committed
fix: TriggerEventAsync runs in renderer sync context to avoid race condition with DOM updates
fixes #687
1 parent dccdfa0 commit a21770d

5 files changed

Lines changed: 78 additions & 8 deletions

File tree

CHANGELOG.md

Lines changed: 2 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -14,6 +14,8 @@ All notable changes to **bUnit** will be documented in this file. The project ad
1414

1515
- A race condition existed between `WaitForState` / `WaitForAssertion` and `FindComponents`, if the first used the latter. Reported by [@rmihael](https://github.com/rmihael), [@SviatoslavK](https://github.com/SviatoslavK), and [@RaphaelMarcouxCTRL](https://github.com/RaphaelMarcouxCTRL). Fixed by [@egil](https://github.com/egil) and [@linkdotnet](https://github.com/linkdotnet).
1616

17+
- Triggering of event handlers now runs entirely inside the renderers synchronization context, avoiding race condition between elements in the DOM tree being updated by the renderer and the event triggering logic traversing the DOM tree to find event handlers to trigger. Reported by [@FlukeFan](https://github.com/FlukeFan). Fixed by [@egil](https://github.com/egil).
18+
1719
## [1.8.15] - 2022-05-19
1820

1921
### Added

src/bunit.web/EventDispatchExtensions/TriggerEventDispatchExtensions.cs

Lines changed: 35 additions & 6 deletions
Original file line numberDiff line numberDiff line change
@@ -1,4 +1,5 @@
11
using System.Globalization;
2+
using System.Runtime.ExceptionServices;
23
using AngleSharp.Dom;
34
using AngleSharp.Html.Dom;
45
using AngleSharpWrappers;
@@ -56,7 +57,6 @@ public static void TriggerEvent(this IElement element, string eventName, EventAr
5657
/// <param name="eventName">The name of the event to raise (using on-form, e.g. <c>onclick</c>).</param>
5758
/// <param name="eventArgs">The event arguments to pass to the event handler.</param>
5859
/// <returns>A <see cref="Task"/> that completes when the render caused by the triggering of the event finishes.</returns>
59-
[SuppressMessage("Globalization", "CA1308:Normalize strings to uppercase", Justification = "HTML events are standardize to lower case and safe in this context.")]
6060
public static Task TriggerEventAsync(this IElement element, string eventName, EventArgs eventArgs)
6161
{
6262
if (element is null)
@@ -67,18 +67,32 @@ public static Task TriggerEventAsync(this IElement element, string eventName, Ev
6767
var renderer = element.GetTestContext()?.Renderer
6868
?? throw new InvalidOperationException($"Blazor events can only be raised on elements rendered with the Blazor test renderer '{nameof(ITestRenderer)}'.");
6969

70-
var isNonBubblingEvent = NonBubblingEvents.Contains(eventName.ToLowerInvariant());
70+
// TriggerEventsAsync will traverse the DOM tree to find
71+
// all event handlers that needs to be triggered. This is done
72+
// in the renderes synchronization context to avoid a race condition
73+
// between the DOM tree being updated and traversed.
74+
var result = renderer.Dispatcher.InvokeAsync(
75+
() => TriggerEventsAsync(renderer, element, eventName, eventArgs));
76+
77+
ThrowIfFailedSynchronously(result);
78+
79+
return result;
80+
}
7181

82+
[SuppressMessage("Globalization", "CA1308:Normalize strings to uppercase", Justification = "HTML events are standardize to lower case and safe in this context.")]
83+
private static Task TriggerEventsAsync(ITestRenderer renderer, IElement element, string eventName, EventArgs eventArgs)
84+
{
85+
var isNonBubblingEvent = NonBubblingEvents.Contains(eventName.ToLowerInvariant());
7286
var unwrappedElement = element.Unwrap();
7387
if (isNonBubblingEvent)
7488
return TriggerNonBubblingEventAsync(renderer, unwrappedElement, eventName, eventArgs);
7589

7690
return unwrappedElement switch
7791
{
78-
IHtmlInputElement { Type: "submit", Form: not null } input when eventName is "onclick" =>
79-
TriggerFormSubmitBubblingEventAsync(renderer, input, eventArgs, input.Form),
80-
IHtmlButtonElement { Type: "submit", Form: not null } button when eventName is "onclick" =>
81-
TriggerFormSubmitBubblingEventAsync(renderer, button, eventArgs, button.Form),
92+
IHtmlInputElement { Type: "submit", Form: not null } input when eventName is "onclick"
93+
=> TriggerFormSubmitBubblingEventAsync(renderer, input, eventArgs, input.Form),
94+
IHtmlButtonElement { Type: "submit", Form: not null } button when eventName is "onclick"
95+
=> TriggerFormSubmitBubblingEventAsync(renderer, button, eventArgs, button.Form),
8296
_ => TriggerBubblingEventAsync(renderer, unwrappedElement, eventName, eventArgs)
8397
};
8498
}
@@ -166,4 +180,19 @@ private static bool TryGetEventId(this IElement element, string blazorEventName,
166180
var eventId = element.GetAttribute(blazorEventName);
167181
return ulong.TryParse(eventId, NumberStyles.Integer, CultureInfo.InvariantCulture, out id);
168182
}
183+
184+
private static void ThrowIfFailedSynchronously(Task result)
185+
{
186+
if (result.IsFaulted && result.Exception is not null)
187+
{
188+
if (result.Exception.InnerExceptions.Count == 1)
189+
{
190+
ExceptionDispatchInfo.Capture(result.Exception.InnerExceptions[0]).Throw();
191+
}
192+
else
193+
{
194+
ExceptionDispatchInfo.Capture(result.Exception).Throw();
195+
}
196+
}
197+
}
169198
}

tests/.editorconfig

Lines changed: 2 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -42,3 +42,5 @@ dotnet_diagnostic.CA2007.severity = none # https://github.com/atc-net
4242

4343
dotnet_diagnostic.CA1819.severity = suggestion # CA1819: Properties should not return arrays
4444
dotnet_diagnostic.CA1849.severity = suggestion # CA1849: Call async methods when in an async method
45+
46+
dotnet_diagnostic.xUnit1026.severity = none # xUnit1026: Theory methods should use all of their parameters
Lines changed: 23 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,23 @@
1+
<h1>Counter Dynamic</h1>
2+
@foreach (var index in _values)
3+
{
4+
<button
5+
data-id=@index
6+
@onclick="() => OnClick(index)">Click</button>
7+
}
8+
9+
@code {
10+
private int[] _values = new int[0];
11+
12+
protected override async Task OnInitializedAsync()
13+
{
14+
await Task.Delay(1);
15+
_values = new[] { 1 };
16+
await InvokeAsync(StateHasChanged);
17+
}
18+
19+
private void OnClick(int index)
20+
{
21+
_values = Enumerable.Range(1, index + 1).ToArray();
22+
}
23+
}

tests/bunit.web.tests/EventDispatchExtensions/GeneralEventDispatchExtensionsTest.cs

Lines changed: 16 additions & 2 deletions
Original file line numberDiff line numberDiff line change
@@ -266,8 +266,22 @@ public void Test306()
266266
Should.Throw<InvalidOperationException>(() => cut.Find("button").Submit());
267267
}
268268

269+
public static IEnumerable<object[]> GetTenNumbers() => Enumerable.Range(0, 10)
270+
.Select(i => new object[] { i });
271+
272+
// Runs the test multiple times to trigger the race condition
273+
// reliably.
274+
[SuppressMessage("Style", "IDE0060:Remove unused parameter", Justification = "Needed to trigger multiple reruns of test.")]
275+
[Theory(DisplayName = "TriggerEventAsync avoids race condition with DOM tree updates")]
276+
[MemberData(nameof(GetTenNumbers))]
277+
public void Test400(int i)
278+
{
279+
var cut = RenderComponent<CounterComponentDynamic>();
280+
281+
cut.WaitForAssertion(() => cut.Find("[data-id=1]"));
282+
283+
cut.InvokeAsync(() => cut.Find("[data-id=1]").Click());
269284

270-
Should.Throw<InvalidOperationException>(() => cut.Find("button").Submit());
271-
}
285+
cut.WaitForAssertion(() => cut.Find("[data-id=2]"));
272286
}
273287
}

0 commit comments

Comments
 (0)