Skip to content

Commit 75bd705

Browse files
committed
feat: Add FindComponent on IElement
1 parent 47f9c22 commit 75bd705

File tree

10 files changed

+370
-2
lines changed

10 files changed

+370
-2
lines changed

src/bunit.generators.internal/Web.AngleSharp/IElementWrapper.cs

Lines changed: 5 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -14,5 +14,10 @@ public interface IElementWrapper<out TElement> where TElement : class, IElement
1414
/// Gets the wrapped element.
1515
/// </summary>
1616
TElement WrappedElement { get; }
17+
18+
/// <summary>
19+
/// Gets the element wrapper factory used by this wrapper.
20+
/// </summary>
21+
IElementWrapperFactory Factory { get; }
1722
}
1823
#nullable restore

src/bunit.generators.internal/Web.AngleSharp/WrapperBase.cs

Lines changed: 10 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -33,6 +33,16 @@ public TElement WrappedElement
3333
}
3434
}
3535

36+
/// <summary>
37+
/// Gets the element wrapper factory used by this wrapper.
38+
/// </summary>
39+
[DebuggerNonUserCode]
40+
public IElementWrapperFactory Factory
41+
{
42+
[MethodImpl(MethodImplOptions.AggressiveInlining)]
43+
get => elementFactory;
44+
}
45+
3646
/// <summary>
3747
/// Creates an instance of the <see cref="WrapperBase{T}"/> class.
3848
/// </summary>

src/bunit.web.query/ByLabelTextElementFactory.cs

Lines changed: 3 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -3,14 +3,16 @@
33

44
namespace Bunit;
55

6-
internal sealed class ByLabelTextElementFactory : IElementWrapperFactory
6+
internal sealed class ByLabelTextElementFactory : IElementWrapperFactory, IComponentAccessor
77
{
88
private readonly IRenderedComponent<IComponent> testTarget;
99
private readonly string labelText;
1010
private readonly ByLabelTextOptions options;
1111

1212
public Action? OnElementReplaced { get; set; }
1313

14+
public IRenderedComponent<IComponent> Component => testTarget;
15+
1416
public ByLabelTextElementFactory(IRenderedComponent<IComponent> testTarget, string labelText, ByLabelTextOptions options)
1517
{
1618
this.testTarget = testTarget;
Lines changed: 44 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,44 @@
1+
using AngleSharp.Dom;
2+
using Bunit.Rendering;
3+
4+
namespace Bunit;
5+
6+
/// <summary>
7+
/// Provides extension methods for <see cref="IElement"/> to find components rendered inside them.
8+
/// </summary>
9+
public static class ElementExtensions
10+
{
11+
/// <summary>
12+
/// Retrieves the first component of type <typeparamref name="TComponent"/> that is rendered inside the specified <paramref name="element"/>.
13+
/// </summary>
14+
public static IRenderedComponent<TComponent> FindComponent<TComponent>(this IElement element)
15+
where TComponent : IComponent
16+
{
17+
ArgumentNullException.ThrowIfNull(element);
18+
19+
var componentAccessor = element.GetComponentAccessor();
20+
if (componentAccessor is null)
21+
{
22+
throw new InvalidOperationException(
23+
$"Unable to find component of type {typeof(TComponent).Name} for the given element.");
24+
}
25+
26+
var component = componentAccessor.Component;
27+
if (component.Instance is TComponent)
28+
{
29+
return (IRenderedComponent<TComponent>)component;
30+
}
31+
32+
var renderer = GetRendererFromComponent(component);
33+
var foundComponent = renderer.FindComponentForElement<TComponent>(element);
34+
if (foundComponent is not null)
35+
{
36+
return foundComponent;
37+
}
38+
39+
throw new InvalidOperationException($"Unable to find component of type {typeof(TComponent).Name} for the given element.");
40+
}
41+
42+
private static BunitRenderer GetRendererFromComponent(IRenderedComponent<IComponent> component)
43+
=> component.Services.GetRequiredService<BunitContext>().Renderer;
44+
}

src/bunit/Extensions/Internal/CssSelectorElementFactory.cs

Lines changed: 3 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -3,13 +3,15 @@
33

44
namespace Bunit;
55

6-
internal sealed class CssSelectorElementFactory : IElementWrapperFactory
6+
internal sealed class CssSelectorElementFactory : IElementWrapperFactory, IComponentAccessor
77
{
88
private readonly IRenderedComponent<IComponent> testTarget;
99
private readonly string cssSelector;
1010

1111
public Action? OnElementReplaced { get; set; }
1212

13+
public IRenderedComponent<IComponent> Component => testTarget;
14+
1315
public CssSelectorElementFactory(IRenderedComponent<IComponent> testTarget, string cssSelector)
1416
{
1517
this.testTarget = testTarget;
Lines changed: 26 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,26 @@
1+
using AngleSharp.Dom;
2+
using Bunit.Web.AngleSharp;
3+
4+
namespace Bunit;
5+
6+
/// <summary>
7+
/// Internal extensions for working with wrapped elements.
8+
/// </summary>
9+
internal static class ElementFactoryAccessor
10+
{
11+
/// <summary>
12+
/// Attempts to get the component accessor from an element factory.
13+
/// </summary>
14+
/// <param name="element">The element to get the component from.</param>
15+
/// <returns>The component accessor if available, null otherwise.</returns>
16+
internal static IComponentAccessor? GetComponentAccessor(this IElement element)
17+
{
18+
var factory = element.GetElementFactory();
19+
return factory as IComponentAccessor;
20+
}
21+
22+
private static IElementWrapperFactory? GetElementFactory(this IElement element)
23+
{
24+
return element is IElementWrapper<IElement> wrapper ? wrapper.Factory : null;
25+
}
26+
}
Lines changed: 12 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,12 @@
1+
namespace Bunit;
2+
3+
/// <summary>
4+
/// Interface for accessing the component that owns an element.
5+
/// </summary>
6+
internal interface IComponentAccessor
7+
{
8+
/// <summary>
9+
/// Gets the component that owns this element.
10+
/// </summary>
11+
IRenderedComponent<IComponent> Component { get; }
12+
}

src/bunit/InternalsVisibleTo.cs

Lines changed: 1 addition & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -1 +1,2 @@
11
[assembly: System.Runtime.CompilerServices.InternalsVisibleTo("Bunit.Tests, PublicKey=002400000480000094000000060200000024000052534131000400000100010001be6b1a2ca57b09b7040e2ab0993e515296ae22aef4031a4fe388a1336fe21f69c7e8610e9935de6ed18d94b5c98429f99ef62ce3d0af28a7088f856239368ea808ad4c448aa2a8075ed581f989f36ed0d0b8b1cfcaf1ff6a4506c8a99b7024b6eb56996d08e3c9c1cf5db59bff96fcc63ccad155ef7fc63aab6a69862437b6")]
2+
[assembly: System.Runtime.CompilerServices.InternalsVisibleTo("Bunit.Web.Query, PublicKey=002400000480000094000000060200000024000052534131000400000100010001be6b1a2ca57b09b7040e2ab0993e515296ae22aef4031a4fe388a1336fe21f69c7e8610e9935de6ed18d94b5c98429f99ef62ce3d0af28a7088f856239368ea808ad4c448aa2a8075ed581f989f36ed0d0b8b1cfcaf1ff6a4506c8a99b7024b6eb56996d08e3c9c1cf5db59bff96fcc63ccad155ef7fc63aab6a69862437b6")]

src/bunit/Rendering/BunitRenderer.cs

Lines changed: 100 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -3,6 +3,7 @@
33
using System.Reflection;
44
using System.Runtime.CompilerServices;
55
using System.Runtime.ExceptionServices;
6+
using AngleSharp.Dom;
67
using Microsoft.Extensions.Logging;
78

89
namespace Bunit.Rendering;
@@ -26,6 +27,7 @@ public sealed class BunitRenderer : Renderer
2627

2728
private readonly HashSet<int> returnedRenderedComponentIds = new();
2829
private readonly List<BunitRootComponent> rootComponents = new();
30+
private readonly Dictionary<string, int> elementReferenceToComponentId = new();
2931
private readonly ILogger<BunitRenderer> logger;
3032
private bool disposed;
3133
private TaskCompletionSource<Exception> unhandledExceptionTsc = new(TaskCreationOptions.RunContinuationsAsynchronously);
@@ -453,6 +455,7 @@ protected override Task UpdateDisplayAsync(in RenderBatch renderBatch)
453455
var id = renderBatch.DisposedComponentIDs.Array[i];
454456
disposedComponentIds.Add(id);
455457
returnedRenderedComponentIds.Remove(id);
458+
RemoveElementReferencesForComponent(id);
456459
}
457460

458461
for (var i = 0; i < renderBatch.UpdatedComponents.Count; i++)
@@ -467,6 +470,8 @@ protected override Task UpdateDisplayAsync(in RenderBatch renderBatch)
467470
var componentState = GetComponentState(diff.ComponentId);
468471
var renderedComponent = (IRenderedComponent)componentState;
469472

473+
TrackElementReferencesForComponent(diff.ComponentId);
474+
470475
if (returnedRenderedComponentIds.Contains(diff.ComponentId))
471476
{
472477
renderedComponent.UpdateState(hasRendered: true, isMarkupGenerationRequired: diff.Edits.Count > 0);
@@ -519,6 +524,101 @@ static bool IsParentComponentAlreadyUpdated(int componentId, in RenderBatch rend
519524
}
520525
}
521526

527+
private void TrackElementReferencesForComponent(int componentId)
528+
{
529+
var frames = GetCurrentRenderTreeFrames(componentId);
530+
TrackElementReferencesInFrames(frames, componentId);
531+
}
532+
533+
private void TrackElementReferencesInFrames(ArrayRange<RenderTreeFrame> frames, int componentId)
534+
{
535+
for (var i = 0; i < frames.Count; i++)
536+
{
537+
ref var frame = ref frames.Array[i];
538+
539+
if (frame.FrameType == RenderTreeFrameType.ElementReferenceCapture)
540+
{
541+
var elementReferenceId = frame.ElementReferenceCaptureId;
542+
if (elementReferenceId != null)
543+
{
544+
elementReferenceToComponentId[elementReferenceId] = componentId;
545+
}
546+
}
547+
else if (frame.FrameType == RenderTreeFrameType.Component)
548+
{
549+
TrackElementReferencesForComponent(frame.ComponentId);
550+
}
551+
}
552+
}
553+
554+
private void RemoveElementReferencesForComponent(int componentId)
555+
{
556+
var keysToRemove = elementReferenceToComponentId
557+
.Where(kvp => kvp.Value == componentId)
558+
.Select(kvp => kvp.Key)
559+
.ToList();
560+
561+
foreach (var key in keysToRemove)
562+
{
563+
elementReferenceToComponentId.Remove(key);
564+
}
565+
}
566+
567+
internal IRenderedComponent<TComponent>? FindComponentForElement<TComponent>(IElement element)
568+
where TComponent : IComponent
569+
{
570+
var elementReferenceId = element.GetAttribute("blazor:elementReference");
571+
if (elementReferenceId is not null && elementReferenceToComponentId.TryGetValue(elementReferenceId, out var componentId))
572+
{
573+
return GetRenderedComponent<TComponent>(componentId);
574+
}
575+
576+
return FindComponentByElementContainment<TComponent>(element);
577+
}
578+
579+
private IRenderedComponent<TComponent>? FindComponentByElementContainment<TComponent>(IElement element)
580+
where TComponent : IComponent
581+
{
582+
List<int> renderedComponentIdsWhenStarted = [..returnedRenderedComponentIds];
583+
var components = new List<IRenderedComponent<TComponent>>(returnedRenderedComponentIds.Count);
584+
585+
foreach (var parentComponent in renderedComponentIdsWhenStarted.Select(GetRenderedComponent<IComponent>))
586+
{
587+
components.AddRange(FindComponents<TComponent>(parentComponent));
588+
}
589+
590+
return components.FirstOrDefault(component => ComponentContainsElement(component, element));
591+
}
592+
593+
private static bool ComponentContainsElement<TComponent>(IRenderedComponent<TComponent> component, IElement element)
594+
where TComponent : IComponent
595+
{
596+
foreach (var node in component.Nodes)
597+
{
598+
if (node is IElement nodeElement && nodeElement.Equals(element))
599+
{
600+
return true;
601+
}
602+
if (IsDescendantOf(element, node))
603+
{
604+
return true;
605+
}
606+
}
607+
return false;
608+
}
609+
610+
private static bool IsDescendantOf(IElement element, INode potentialAncestor)
611+
{
612+
var current = element.Parent;
613+
while (current is not null)
614+
{
615+
if (current == potentialAncestor)
616+
return true;
617+
current = current.Parent;
618+
}
619+
return false;
620+
}
621+
522622
/// <inheritdoc/>
523623
internal new ArrayRange<RenderTreeFrame> GetCurrentRenderTreeFrames(int componentId)
524624
=> base.GetCurrentRenderTreeFrames(componentId);

0 commit comments

Comments
 (0)