Skip to content

Commit a84b3e2

Browse files
committed
feat: Shared DOM
1 parent f19e38c commit a84b3e2

File tree

10 files changed

+343
-1
lines changed

10 files changed

+343
-1
lines changed

src/bunit/Extensions/RenderedComponentExtensions.cs

Lines changed: 17 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -92,7 +92,9 @@ public static IRenderedComponent<TChildComponent> FindComponent<TChildComponent>
9292
ArgumentNullException.ThrowIfNull(renderedComponent);
9393

9494
var renderer = renderedComponent.Services.GetRequiredService<BunitContext>().Renderer;
95-
return renderer.FindComponent<TChildComponent>(renderedComponent);
95+
var found = renderer.FindComponent<TChildComponent>(renderedComponent);
96+
SetupSharedDom(renderedComponent, found);
97+
return found;
9698
}
9799

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

114+
foreach (var component in components)
115+
{
116+
SetupSharedDom(renderedComponent, component);
117+
}
118+
112119
return components.ToArray();
113120
}
114121

@@ -121,4 +128,13 @@ public static IReadOnlyList<IRenderedComponent<TChildComponent>> FindComponents<
121128
/// <returns>True if the <paramref name="renderedComponent"/> contains the <typeparamref name="TChildComponent"/>; otherwise false.</returns>
122129
public static bool HasComponent<TChildComponent>(this IRenderedComponent<IComponent> renderedComponent)
123130
where TChildComponent : IComponent => FindComponents<TChildComponent>(renderedComponent).Count > 0;
131+
132+
private static void SetupSharedDom<TChildComponent>(IRenderedComponent<IComponent> parentComponent, IRenderedComponent<TChildComponent> childComponent)
133+
where TChildComponent : IComponent
134+
{
135+
var parent = (IRenderedComponent)parentComponent;
136+
var child = (IRenderedComponent)childComponent;
137+
var effectiveRootId = parent.RootComponentId ?? parentComponent.ComponentId;
138+
child.SetRootComponentId(effectiveRootId);
139+
}
124140
}

src/bunit/Rendering/BunitRenderer.cs

Lines changed: 21 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -1,4 +1,5 @@
11
using Microsoft.Extensions.Logging;
2+
using AngleSharp.Dom;
23
using System.Collections.Concurrent;
34
using System.Diagnostics;
45
using System.Reflection;
@@ -538,6 +539,25 @@ static bool IsParentComponentAlreadyUpdated(int componentId, in RenderBatch rend
538539
internal new ArrayRange<RenderTreeFrame> GetCurrentRenderTreeFrames(int componentId)
539540
=> base.GetCurrentRenderTreeFrames(componentId);
540541

542+
private readonly Dictionary<int, INodeList> boundaryNodesCache = new();
543+
544+
internal INodeList GetBoundaryNodesForComponent(int componentId)
545+
{
546+
if (boundaryNodesCache.TryGetValue(componentId, out var cached))
547+
{
548+
return cached;
549+
}
550+
551+
var htmlParser = services.GetRequiredService<BunitHtmlParser>();
552+
var boundaryHtml = Htmlizer.GetHtmlWithComponentBoundaries(componentId, this);
553+
var nodes = htmlParser.Parse(boundaryHtml);
554+
boundaryNodesCache[componentId] = nodes;
555+
return nodes;
556+
}
557+
558+
internal void InvalidateBoundaryNodesCache(int componentId)
559+
=> boundaryNodesCache.Remove(componentId);
560+
541561
/// <inheritdoc/>
542562
protected override void Dispose(bool disposing)
543563
{
@@ -615,6 +635,7 @@ private List<IRenderedComponent<TComponent>> FindComponents<TComponent>(IRendere
615635
{
616636
ObjectDisposedException.ThrowIf(disposed, this);
617637
FindComponentsInRenderTree(parentComponent.ComponentId);
638+
618639
foreach (var rc in result)
619640
{
620641
((IRenderedComponent)rc).UpdateState(hasRendered: false, isMarkupGenerationRequired: true);

src/bunit/Rendering/IRenderedComponent.cs

Lines changed: 10 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -9,8 +9,18 @@ internal interface IRenderedComponent : IDisposable
99
/// </summary>
1010
int ComponentId { get; }
1111

12+
/// <summary>
13+
/// Gets the root component ID for shared DOM resolution, or <c>null</c> if this is a root component.
14+
/// </summary>
15+
int? RootComponentId { get; }
16+
1217
/// <summary>
1318
/// Called by the owning <see cref="BunitRenderer"/> when it finishes a render.
1419
/// </summary>
1520
void UpdateState(bool hasRendered, bool isMarkupGenerationRequired);
21+
22+
/// <summary>
23+
/// Sets the root component ID for shared DOM resolution.
24+
/// </summary>
25+
void SetRootComponentId(int rootComponentId);
1626
}
Lines changed: 69 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,69 @@
1+
using AngleSharp.Dom;
2+
3+
namespace Bunit.Rendering;
4+
5+
internal static class ComponentBoundaryNodeExtractor
6+
{
7+
private const string BoundaryStartPrefix = "bl:";
8+
private const string BoundaryEndPrefix = "/bl:";
9+
10+
internal static string StartMarkerFor(int componentId) => $"{BoundaryStartPrefix}{componentId}";
11+
12+
internal static string EndMarkerFor(int componentId) => $"{BoundaryEndPrefix}{componentId}";
13+
14+
internal static INodeList Extract(INodeList rootNodes, int componentId)
15+
{
16+
var startMarker = StartMarkerFor(componentId);
17+
var endMarker = EndMarkerFor(componentId);
18+
var result = new List<INode>();
19+
20+
CollectNodesBetweenMarkers(rootNodes, startMarker, endMarker, result);
21+
22+
return new ReadOnlyNodeList(result);
23+
}
24+
25+
private static bool CollectNodesBetweenMarkers(
26+
INodeList nodes,
27+
string startMarker,
28+
string endMarker,
29+
List<INode> result)
30+
{
31+
for (var i = 0; i < nodes.Length; i++)
32+
{
33+
var node = nodes[i];
34+
35+
if (node is IComment comment && string.Equals(comment.Data, startMarker, StringComparison.Ordinal))
36+
{
37+
for (var j = i + 1; j < nodes.Length; j++)
38+
{
39+
var sibling = nodes[j];
40+
if (sibling is IComment endComment && string.Equals(endComment.Data, endMarker, StringComparison.Ordinal))
41+
{
42+
return true;
43+
}
44+
45+
if (sibling is IComment nestedMarker && IsBoundaryComment(nestedMarker))
46+
{
47+
continue;
48+
}
49+
50+
result.Add(sibling);
51+
}
52+
53+
return true;
54+
}
55+
56+
if (node.HasChildNodes
57+
&& CollectNodesBetweenMarkers(node.ChildNodes, startMarker, endMarker, result))
58+
{
59+
return true;
60+
}
61+
}
62+
63+
return false;
64+
}
65+
66+
private static bool IsBoundaryComment(IComment comment) =>
67+
comment.Data.StartsWith(BoundaryStartPrefix, StringComparison.Ordinal)
68+
|| comment.Data.StartsWith(BoundaryEndPrefix, StringComparison.Ordinal);
69+
}

src/bunit/Rendering/Internal/Htmlizer.cs

Lines changed: 29 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -61,6 +61,15 @@ public static string GetHtml(int componentId, BunitRenderer renderer)
6161
return context.Result.ToString();
6262
}
6363

64+
public static string GetHtmlWithComponentBoundaries(int componentId, BunitRenderer renderer)
65+
{
66+
var context = new HtmlRenderingContext(renderer) { IncludeComponentBoundaries = true };
67+
var frames = context.GetRenderTreeFrames(componentId);
68+
var newPosition = RenderFrames(context, frames, 0, frames.Count);
69+
Debug.Assert(newPosition == frames.Count);
70+
return context.Result.ToString();
71+
}
72+
6473
private static int RenderFrames(
6574
HtmlRenderingContext context,
6675
ArrayRange<RenderTreeFrame> frames,
@@ -131,8 +140,26 @@ int position
131140
)
132141
{
133142
var frame = frames.Array[position];
143+
144+
if (context.IncludeComponentBoundaries)
145+
{
146+
context.Result
147+
.Append("<!--")
148+
.Append(ComponentBoundaryNodeExtractor.StartMarkerFor(frame.ComponentId))
149+
.Append("-->");
150+
}
151+
134152
var childFrames = context.GetRenderTreeFrames(frame.ComponentId);
135153
RenderFrames(context, childFrames, 0, childFrames.Count);
154+
155+
if (context.IncludeComponentBoundaries)
156+
{
157+
context.Result
158+
.Append("<!--")
159+
.Append(ComponentBoundaryNodeExtractor.EndMarkerFor(frame.ComponentId))
160+
.Append("-->");
161+
}
162+
136163
return position + frame.ComponentSubtreeLength;
137164
}
138165

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

404431
public string? ClosestSelectValueAsString { get; set; }
432+
433+
public bool IncludeComponentBoundaries { get; init; }
405434
}
406435
}
Lines changed: 30 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,30 @@
1+
using System.Collections;
2+
using AngleSharp;
3+
using AngleSharp.Dom;
4+
5+
namespace Bunit.Rendering;
6+
7+
internal sealed class ReadOnlyNodeList : INodeList, IReadOnlyList<INode>
8+
{
9+
private readonly List<INode> nodes;
10+
11+
public ReadOnlyNodeList(List<INode> nodes) => this.nodes = nodes;
12+
13+
public INode this[int index] => nodes[index];
14+
15+
public int Length => nodes.Count;
16+
17+
public int Count => nodes.Count;
18+
19+
public IEnumerator<INode> GetEnumerator() => nodes.GetEnumerator();
20+
21+
IEnumerator IEnumerable.GetEnumerator() => GetEnumerator();
22+
23+
public void ToHtml(TextWriter writer, IMarkupFormatter formatter)
24+
{
25+
foreach (var node in nodes)
26+
{
27+
node.ToHtml(writer, formatter);
28+
}
29+
}
30+
}

src/bunit/Rendering/RenderedComponent.cs

Lines changed: 22 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -19,6 +19,7 @@ internal sealed class RenderedComponent<TComponent> : ComponentState, IRenderedC
1919

2020
private string markup = string.Empty;
2121
private INodeList? latestRenderNodes;
22+
private int? rootComponentId;
2223

2324
/// <summary>
2425
/// Gets the component under test.
@@ -68,6 +69,12 @@ public INodeList Nodes
6869
get
6970
{
7071
EnsureComponentNotDisposed();
72+
73+
if (rootComponentId.HasValue)
74+
{
75+
return latestRenderNodes ??= ResolveNodesFromRootDom();
76+
}
77+
7178
return latestRenderNodes ??= htmlParser.Parse(Markup);
7279
}
7380
}
@@ -133,6 +140,7 @@ public void UpdateState(bool hasRendered, bool isMarkupGenerationRequired)
133140
private void UpdateMarkup()
134141
{
135142
latestRenderNodes = null;
143+
renderer.InvalidateBoundaryNodesCache(ComponentId);
136144
var newMarkup = Htmlizer.GetHtml(ComponentId, renderer);
137145

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

153+
public int? RootComponentId => rootComponentId;
154+
155+
public void SetRootComponentId(int rootComponentId)
156+
{
157+
this.rootComponentId = rootComponentId;
158+
latestRenderNodes = null;
159+
}
160+
161+
private INodeList ResolveNodesFromRootDom()
162+
{
163+
var fullDom = renderer.GetBoundaryNodesForComponent(rootComponentId!.Value);
164+
return ComponentBoundaryNodeExtractor.Extract(fullDom, ComponentId);
165+
}
166+
145167
/// <summary>
146168
/// Ensures that the underlying component behind the
147169
/// fragment has not been removed from the render tree.
Lines changed: 8 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,8 @@
1+
@namespace Bunit.TestAssets.SampleComponents
2+
3+
<button type="submit" id="child-submit-button">Submit</button>
4+
5+
@code {
6+
[Parameter]
7+
public bool ShowExtraContent { get; set; }
8+
}
Lines changed: 14 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,14 @@
1+
@namespace Bunit.TestAssets.SampleComponents
2+
3+
<form @onsubmit="OnFormSubmit">
4+
<ChildSubmitButton />
5+
</form>
6+
7+
@code {
8+
public bool FormSubmitted { get; private set; }
9+
10+
private void OnFormSubmit()
11+
{
12+
FormSubmitted = true;
13+
}
14+
}

0 commit comments

Comments
 (0)