Skip to content

Commit e5bab37

Browse files
committed
fix: better handling of disposed event handlers with bubbling events
fixes #518 and #517.
1 parent b8624d0 commit e5bab37

8 files changed

Lines changed: 144 additions & 23 deletions

File tree

src/bunit.core/Rendering/TestRenderer.cs

Lines changed: 1 addition & 5 deletions
Original file line numberDiff line numberDiff line change
@@ -80,11 +80,7 @@ public IRenderedComponentBase<TComponent> RenderComponent<TComponent>(ComponentP
8080
}
8181
catch (ArgumentException ex) when (string.Equals(ex.Message, $"There is no event handler associated with this event. EventId: '{eventHandlerId}'. (Parameter 'eventHandlerId')", StringComparison.Ordinal))
8282
{
83-
var betterExceptionMsg = new ArgumentException($"There is no event handler with ID '{eventHandlerId}' associated with the '{fieldInfo.FieldValue}' event " +
84-
"in the current render tree. This can happen, for example, when using cut.FindAll(), and calling event trigger methods " +
85-
"on the found elements after a re-render of the render tree. The workaround is to use re-issue the cut.FindAll() after " +
86-
"each render of a component, this ensures you have the latest version of the render tree and DOM tree available in your test code.", ex);
87-
83+
var betterExceptionMsg = new UnknownEventHandlerIdException(eventHandlerId, fieldInfo, ex);
8884
return Task.FromException(betterExceptionMsg);
8985
}
9086
});
Lines changed: 31 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,31 @@
1+
using System;
2+
using System.Runtime.Serialization;
3+
using Microsoft.AspNetCore.Components.RenderTree;
4+
5+
namespace Bunit.Rendering
6+
{
7+
/// <summary>
8+
/// Represents an exception that is thrown when the Blazor <see cref="Renderer"/>
9+
/// does not have any event handler with the specified a given ID.
10+
/// </summary>
11+
[Serializable]
12+
public sealed class UnknownEventHandlerIdException : Exception
13+
{
14+
/// <summary>
15+
/// Initializes a new instance of the <see cref="UnknownEventHandlerIdException"/> class.
16+
/// </summary>
17+
public UnknownEventHandlerIdException(ulong eventHandlerId, EventFieldInfo fieldInfo, Exception innerException)
18+
: base(CreateMessage(eventHandlerId, fieldInfo), innerException)
19+
{
20+
}
21+
22+
private UnknownEventHandlerIdException(SerializationInfo serializationInfo, StreamingContext streamingContext)
23+
: base(serializationInfo, streamingContext) { }
24+
25+
private static string CreateMessage(ulong eventHandlerId, EventFieldInfo fieldInfo)
26+
=> $"There is no event handler with ID '{eventHandlerId}' associated with the '{fieldInfo.FieldValue}' event " +
27+
"in the current render tree. This can happen, for example, when using cut.FindAll(), and calling event trigger methods " +
28+
"on the found elements after a re-render of the render tree. The workaround is to use re-issue the cut.FindAll() after " +
29+
"each render of a component, this ensures you have the latest version of the render tree and DOM tree available in your test code.";
30+
}
31+
}

src/bunit.web/EventDispatchExtensions/TriggerEventDispatchExtensions.cs

Lines changed: 15 additions & 6 deletions
Original file line numberDiff line numberDiff line change
@@ -61,25 +61,34 @@ private static Task TriggerBubblingEventAsync(ITestRenderer renderer, IElement e
6161
{
6262
var eventAttrName = Htmlizer.ToBlazorAttribute(eventName);
6363
var eventStopPropergationAttrName = $"{eventAttrName}:stoppropagation";
64-
var eventIds = new List<ulong>();
64+
var eventTasks = new List<Task>();
6565

6666
foreach (var candidate in element.GetParentsAndSelf())
6767
{
6868
if (candidate.TryGetEventId(eventAttrName, out var id))
69-
eventIds.Add(id);
69+
{
70+
try
71+
{
72+
var info = new EventFieldInfo() { FieldValue = eventName };
73+
eventTasks.Add(renderer.DispatchEventAsync(id, info, eventArgs));
74+
}
75+
catch (UnknownEventHandlerIdException) when (eventTasks.Count > 0)
76+
{
77+
// Capture and ignore NoEventHandlerException for bubbling events
78+
// if at least one event handler has been triggered without throwing.
79+
}
80+
}
7081

7182
if (candidate.HasAttribute(eventStopPropergationAttrName) || candidate.EventIsDisabled(eventName))
7283
{
7384
break;
7485
}
7586
}
7687

77-
if (eventIds.Count == 0)
88+
if (eventTasks.Count == 0)
7889
throw new MissingEventHandlerException(element, eventName);
7990

80-
var triggerTasks = eventIds.Select(id => renderer.DispatchEventAsync(id, new EventFieldInfo() { FieldValue = eventName }, eventArgs));
81-
82-
return Task.WhenAll(triggerTasks.ToArray());
91+
return Task.WhenAll(eventTasks);
8392
}
8493

8594
private static Task TriggerNonBubblingEventAsync(ITestRenderer renderer, IElement element, string eventName, EventArgs eventArgs)
Lines changed: 13 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,13 @@
1+
<div @onclick="() => TopDivClicked = true">
2+
@if (!BtnClicked)
3+
{
4+
<div @onclick="() => MiddleDivClicked = true">
5+
<button @onclick="() => BtnClicked = true">Click me!</button>
6+
</div>
7+
}
8+
</div>
9+
@code {
10+
public bool BtnClicked { get; private set; } = false;
11+
public bool MiddleDivClicked { get; private set; } = false;
12+
public bool TopDivClicked { get; private set; } = false;
13+
}
Lines changed: 19 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,19 @@
1+
<div @onclick="() => TopDivClicked = true">
2+
<div @onclick=@MiddleDivHandler>
3+
<button @onclick="() => BtnClicked = true">Click me!</button>
4+
</div>
5+
</div>
6+
7+
<p>@BtnClicked</p>
8+
<p>@MiddleDivClicked</p>
9+
<p>@TopDivClicked</p>
10+
11+
@code {
12+
[Parameter] public string ExceptionMessage { get; set; }
13+
14+
public bool BtnClicked { get; private set; } = false;
15+
public bool MiddleDivClicked { get; private set; } = false;
16+
public bool TopDivClicked { get; private set; } = false;
17+
18+
private void MiddleDivHandler() => throw new Exception(ExceptionMessage);
19+
}
Lines changed: 9 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,9 @@
1+
<button id="first" @onclick="() => visible = false"></button>
2+
@if (visible)
3+
{
4+
<button id="second" @onclick="() => SecondButtonClicked = true"></button>
5+
}
6+
@code {
7+
private bool visible = true;
8+
public bool SecondButtonClicked { get; private set; }
9+
}
Lines changed: 5 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,5 @@
1+
<button @onclick="Handler"></button>
2+
@code {
3+
[Parameter] public string ExceptionMessage { get; set; }
4+
private void Handler() => throw new Exception(ExceptionMessage);
5+
}

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

Lines changed: 51 additions & 12 deletions
Original file line numberDiff line numberDiff line change
@@ -1,8 +1,10 @@
11
using System;
2+
using System.Linq;
23
using System.Reflection;
34
using System.Threading.Tasks;
45
using AngleSharp;
56
using AngleSharp.Dom;
7+
using AutoFixture.Xunit2;
68
using Bunit.Rendering;
79
using Bunit.TestAssets.SampleComponents;
810
using Moq;
@@ -175,18 +177,6 @@ public async Task Test114(
175177
cut.Instance.GrandParentTriggerCount.ShouldBe(1);
176178
}
177179

178-
179-
[Fact(DisplayName = "TriggerEventAsync throws when invoked with an unknown event handler ID")]
180-
public void Test115()
181-
{
182-
var eventName = "onclick";
183-
var cut = RenderComponent<Wrapper>(ps => ps.AddChildContent("<div />"));
184-
var div = cut.Find("div");
185-
div.SetAttribute(Htmlizer.ToBlazorAttribute(eventName), "42");
186-
187-
Should.Throw<ArgumentException>(() => div.TriggerEventAsync(eventName, EventArgs.Empty));
188-
}
189-
190180
#if NET6_0_OR_GREATER
191181
[Fact(DisplayName = "TriggerEvent can trigger custom events")]
192182
public void Test201()
@@ -202,5 +192,54 @@ public void Test201()
202192
cut.Find("p:last-child").MarkupMatches("<p>You pasted: FOO</p>");
203193
}
204194
#endif
195+
196+
[Fact(DisplayName = "TriggerEventAsync throws NoEventHandlerException when invoked with an unknown event handler ID")]
197+
public void Test300()
198+
{
199+
var cut = RenderComponent<ClickRemovesEventHandler>();
200+
var buttons = cut.FindAll("button");
201+
buttons[0].Click();
202+
203+
Should.Throw<UnknownEventHandlerIdException>(() => buttons[1].Click());
204+
}
205+
206+
[Fact(DisplayName = "Removed bubbled event handled NoEventHandlerException are ignored")]
207+
public void Test301()
208+
{
209+
var cut = RenderComponent<BubbleEventsRemoveTriggers>();
210+
211+
cut.Find("button").Click();
212+
213+
// When middle div clicked event handlers is disposed, the
214+
// NoEventHandlerException is ignored and the top div clicked event
215+
// handler is still invoked.
216+
cut.Instance.BtnClicked.ShouldBeTrue();
217+
cut.Instance.MiddleDivClicked.ShouldBeFalse();
218+
cut.Instance.TopDivClicked.ShouldBeTrue();
219+
}
220+
221+
[Theory(DisplayName = "When bubbling event throws, no other event handlers are triggered")]
222+
[AutoData]
223+
public void Test302(string exceptionMessage)
224+
{
225+
var cut = RenderComponent<BubbleEventsThrows>(ps => ps.Add(p => p.ExceptionMessage, exceptionMessage));
226+
227+
Should.Throw<Exception>(() => cut.Find("button").Click())
228+
.Message.ShouldBe(exceptionMessage);
229+
230+
cut.Instance.BtnClicked.ShouldBeTrue();
231+
cut.Instance.MiddleDivClicked.ShouldBeFalse();
232+
cut.Instance.TopDivClicked.ShouldBeFalse();
233+
}
234+
235+
[Theory(DisplayName = "When event handler throws, the exception is passed up to test")]
236+
[AutoData]
237+
public void Test303(string exceptionMessage)
238+
{
239+
var cut = RenderComponent<EventHandlerThrows>(ps => ps.Add(p => p.ExceptionMessage, exceptionMessage));
240+
241+
Should.Throw<Exception>(() => cut.Find("button").Click())
242+
.Message.ShouldBe(exceptionMessage);
243+
}
205244
}
206245
}

0 commit comments

Comments
 (0)