Skip to content
Draft
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
18 changes: 17 additions & 1 deletion src/bunit/Extensions/RenderedComponentExtensions.cs
Original file line number Diff line number Diff line change
Expand Up @@ -92,7 +92,9 @@ public static IRenderedComponent<TChildComponent> FindComponent<TChildComponent>
ArgumentNullException.ThrowIfNull(renderedComponent);

var renderer = renderedComponent.Services.GetRequiredService<BunitContext>().Renderer;
return renderer.FindComponent<TChildComponent>(renderedComponent);
var found = renderer.FindComponent<TChildComponent>(renderedComponent);
SetupSharedDom(renderedComponent, found);
return found;
}

/// <summary>
Expand All @@ -109,6 +111,11 @@ public static IReadOnlyList<IRenderedComponent<TChildComponent>> FindComponents<
var renderer = renderedComponent.Services.GetRequiredService<BunitContext>().Renderer;
var components = renderer.FindComponents<TChildComponent>(renderedComponent);

foreach (var component in components)
{
SetupSharedDom(renderedComponent, component);
}

return components.ToArray();
}

Expand All @@ -121,4 +128,13 @@ public static IReadOnlyList<IRenderedComponent<TChildComponent>> FindComponents<
/// <returns>True if the <paramref name="renderedComponent"/> contains the <typeparamref name="TChildComponent"/>; otherwise false.</returns>
public static bool HasComponent<TChildComponent>(this IRenderedComponent<IComponent> renderedComponent)
where TChildComponent : IComponent => FindComponents<TChildComponent>(renderedComponent).Count > 0;

private static void SetupSharedDom<TChildComponent>(IRenderedComponent<IComponent> parentComponent, IRenderedComponent<TChildComponent> childComponent)
where TChildComponent : IComponent
{
var parent = (IRenderedComponent)parentComponent;
var child = (IRenderedComponent)childComponent;
var effectiveRootId = parent.RootComponentId ?? parentComponent.ComponentId;
child.SetRootComponentId(effectiveRootId);
}
}
21 changes: 21 additions & 0 deletions src/bunit/Rendering/BunitRenderer.cs
Original file line number Diff line number Diff line change
@@ -1,4 +1,5 @@
using Microsoft.Extensions.Logging;
using AngleSharp.Dom;
using System.Collections.Concurrent;
using System.Diagnostics;
using System.Reflection;
Expand Down Expand Up @@ -538,6 +539,25 @@ static bool IsParentComponentAlreadyUpdated(int componentId, in RenderBatch rend
internal new ArrayRange<RenderTreeFrame> GetCurrentRenderTreeFrames(int componentId)
=> base.GetCurrentRenderTreeFrames(componentId);

private readonly Dictionary<int, INodeList> boundaryNodesCache = new();

internal INodeList GetBoundaryNodesForComponent(int componentId)
{
if (boundaryNodesCache.TryGetValue(componentId, out var cached))
{
return cached;
}

var htmlParser = services.GetRequiredService<BunitHtmlParser>();
var boundaryHtml = Htmlizer.GetHtmlWithComponentBoundaries(componentId, this);
var nodes = htmlParser.Parse(boundaryHtml);
boundaryNodesCache[componentId] = nodes;
return nodes;
}

internal void InvalidateBoundaryNodesCache(int componentId)
=> boundaryNodesCache.Remove(componentId);

/// <inheritdoc/>
protected override void Dispose(bool disposing)
{
Expand Down Expand Up @@ -615,6 +635,7 @@ private List<IRenderedComponent<TComponent>> FindComponents<TComponent>(IRendere
{
ObjectDisposedException.ThrowIf(disposed, this);
FindComponentsInRenderTree(parentComponent.ComponentId);

foreach (var rc in result)
{
((IRenderedComponent)rc).UpdateState(hasRendered: false, isMarkupGenerationRequired: true);
Expand Down
10 changes: 10 additions & 0 deletions src/bunit/Rendering/IRenderedComponent.cs
Original file line number Diff line number Diff line change
Expand Up @@ -9,8 +9,18 @@ internal interface IRenderedComponent : IDisposable
/// </summary>
int ComponentId { get; }

/// <summary>
/// Gets the root component ID for shared DOM resolution, or <c>null</c> if this is a root component.
/// </summary>
int? RootComponentId { get; }

/// <summary>
/// Called by the owning <see cref="BunitRenderer"/> when it finishes a render.
/// </summary>
void UpdateState(bool hasRendered, bool isMarkupGenerationRequired);

/// <summary>
/// Sets the root component ID for shared DOM resolution.
/// </summary>
void SetRootComponentId(int rootComponentId);
}
69 changes: 69 additions & 0 deletions src/bunit/Rendering/Internal/ComponentBoundaryNodeExtractor.cs
Original file line number Diff line number Diff line change
@@ -0,0 +1,69 @@
using AngleSharp.Dom;

namespace Bunit.Rendering;

internal static class ComponentBoundaryNodeExtractor
{
private const string BoundaryStartPrefix = "bl:";
private const string BoundaryEndPrefix = "/bl:";

internal static string StartMarkerFor(int componentId) => $"{BoundaryStartPrefix}{componentId}";

internal static string EndMarkerFor(int componentId) => $"{BoundaryEndPrefix}{componentId}";

internal static INodeList Extract(INodeList rootNodes, int componentId)
{
var startMarker = StartMarkerFor(componentId);
var endMarker = EndMarkerFor(componentId);
var result = new List<INode>();

CollectNodesBetweenMarkers(rootNodes, startMarker, endMarker, result);

return new ReadOnlyNodeList(result);
}

private static bool CollectNodesBetweenMarkers(
INodeList nodes,
string startMarker,
string endMarker,
List<INode> result)
{
for (var i = 0; i < nodes.Length; i++)
{
var node = nodes[i];

if (node is IComment comment && string.Equals(comment.Data, startMarker, StringComparison.Ordinal))
{
for (var j = i + 1; j < nodes.Length; j++)
{
var sibling = nodes[j];
if (sibling is IComment endComment && string.Equals(endComment.Data, endMarker, StringComparison.Ordinal))
{
return true;
}

if (sibling is IComment nestedMarker && IsBoundaryComment(nestedMarker))
{
continue;
}

result.Add(sibling);
}

return true;
}

if (node.HasChildNodes
&& CollectNodesBetweenMarkers(node.ChildNodes, startMarker, endMarker, result))
{
return true;
}
}

return false;
}

private static bool IsBoundaryComment(IComment comment) =>
comment.Data.StartsWith(BoundaryStartPrefix, StringComparison.Ordinal)
|| comment.Data.StartsWith(BoundaryEndPrefix, StringComparison.Ordinal);
}
29 changes: 29 additions & 0 deletions src/bunit/Rendering/Internal/Htmlizer.cs
Original file line number Diff line number Diff line change
Expand Up @@ -61,6 +61,15 @@ public static string GetHtml(int componentId, BunitRenderer renderer)
return context.Result.ToString();
}

public static string GetHtmlWithComponentBoundaries(int componentId, BunitRenderer renderer)
{
var context = new HtmlRenderingContext(renderer) { IncludeComponentBoundaries = true };
var frames = context.GetRenderTreeFrames(componentId);
var newPosition = RenderFrames(context, frames, 0, frames.Count);
Debug.Assert(newPosition == frames.Count);
return context.Result.ToString();
}

private static int RenderFrames(
HtmlRenderingContext context,
ArrayRange<RenderTreeFrame> frames,
Expand Down Expand Up @@ -131,8 +140,26 @@ int position
)
{
var frame = frames.Array[position];

if (context.IncludeComponentBoundaries)
{
context.Result
.Append("<!--")
.Append(ComponentBoundaryNodeExtractor.StartMarkerFor(frame.ComponentId))
.Append("-->");
}

var childFrames = context.GetRenderTreeFrames(frame.ComponentId);
RenderFrames(context, childFrames, 0, childFrames.Count);

if (context.IncludeComponentBoundaries)
{
context.Result
.Append("<!--")
.Append(ComponentBoundaryNodeExtractor.EndMarkerFor(frame.ComponentId))
.Append("-->");
}

return position + frame.ComponentSubtreeLength;
}

Expand Down Expand Up @@ -402,5 +429,7 @@ public ArrayRange<RenderTreeFrame> GetRenderTreeFrames(int componentId)
public StringBuilder Result { get; } = new();

public string? ClosestSelectValueAsString { get; set; }

public bool IncludeComponentBoundaries { get; init; }
}
}
30 changes: 30 additions & 0 deletions src/bunit/Rendering/Internal/ReadOnlyNodeList.cs
Original file line number Diff line number Diff line change
@@ -0,0 +1,30 @@
using System.Collections;
using AngleSharp;
using AngleSharp.Dom;

namespace Bunit.Rendering;

internal sealed class ReadOnlyNodeList : INodeList, IReadOnlyList<INode>
{
private readonly List<INode> nodes;

public ReadOnlyNodeList(List<INode> nodes) => this.nodes = nodes;

public INode this[int index] => nodes[index];

public int Length => nodes.Count;

public int Count => nodes.Count;

public IEnumerator<INode> GetEnumerator() => nodes.GetEnumerator();

IEnumerator IEnumerable.GetEnumerator() => GetEnumerator();

public void ToHtml(TextWriter writer, IMarkupFormatter formatter)
{
foreach (var node in nodes)
{
node.ToHtml(writer, formatter);
}
}
}
22 changes: 22 additions & 0 deletions src/bunit/Rendering/RenderedComponent.cs
Original file line number Diff line number Diff line change
Expand Up @@ -19,6 +19,7 @@ internal sealed class RenderedComponent<TComponent> : ComponentState, IRenderedC

private string markup = string.Empty;
private INodeList? latestRenderNodes;
private int? rootComponentId;

/// <summary>
/// Gets the component under test.
Expand Down Expand Up @@ -68,6 +69,12 @@ public INodeList Nodes
get
{
EnsureComponentNotDisposed();

if (rootComponentId.HasValue)
{
return latestRenderNodes ??= ResolveNodesFromRootDom();
}

return latestRenderNodes ??= htmlParser.Parse(Markup);
}
}
Expand Down Expand Up @@ -133,6 +140,7 @@ public void UpdateState(bool hasRendered, bool isMarkupGenerationRequired)
private void UpdateMarkup()
{
latestRenderNodes = null;
renderer.InvalidateBoundaryNodesCache(ComponentId);
var newMarkup = Htmlizer.GetHtml(ComponentId, renderer);

// Volatile write is necessary to ensure the updated markup
Expand All @@ -142,6 +150,20 @@ private void UpdateMarkup()
Volatile.Write(ref markup, newMarkup);
}

public int? RootComponentId => rootComponentId;

public void SetRootComponentId(int rootComponentId)
{
this.rootComponentId = rootComponentId;
latestRenderNodes = null;
}

private INodeList ResolveNodesFromRootDom()
{
var fullDom = renderer.GetBoundaryNodesForComponent(rootComponentId!.Value);
return ComponentBoundaryNodeExtractor.Extract(fullDom, ComponentId);
}

/// <summary>
/// Ensures that the underlying component behind the
/// fragment has not been removed from the render tree.
Expand Down
Original file line number Diff line number Diff line change
@@ -0,0 +1,8 @@
@namespace Bunit.TestAssets.SampleComponents

<button type="submit" id="child-submit-button">Submit</button>

@code {
[Parameter]
public bool ShowExtraContent { get; set; }
}
Original file line number Diff line number Diff line change
@@ -0,0 +1,14 @@
@namespace Bunit.TestAssets.SampleComponents

<form @onsubmit="OnFormSubmit">
<ChildSubmitButton />
</form>

@code {
public bool FormSubmitted { get; private set; }

private void OnFormSubmit()
{
FormSubmitted = true;
}
}
Loading
Loading