diff --git a/src/bunit/Extensions/RenderedComponentExtensions.cs b/src/bunit/Extensions/RenderedComponentExtensions.cs index 55a85d27b..6b2687490 100644 --- a/src/bunit/Extensions/RenderedComponentExtensions.cs +++ b/src/bunit/Extensions/RenderedComponentExtensions.cs @@ -92,7 +92,9 @@ public static IRenderedComponent FindComponent ArgumentNullException.ThrowIfNull(renderedComponent); var renderer = renderedComponent.Services.GetRequiredService().Renderer; - return renderer.FindComponent(renderedComponent); + var found = renderer.FindComponent(renderedComponent); + SetupSharedDom(renderedComponent, found); + return found; } /// @@ -109,6 +111,11 @@ public static IReadOnlyList> FindComponents< var renderer = renderedComponent.Services.GetRequiredService().Renderer; var components = renderer.FindComponents(renderedComponent); + foreach (var component in components) + { + SetupSharedDom(renderedComponent, component); + } + return components.ToArray(); } @@ -121,4 +128,13 @@ public static IReadOnlyList> FindComponents< /// True if the contains the ; otherwise false. public static bool HasComponent(this IRenderedComponent renderedComponent) where TChildComponent : IComponent => FindComponents(renderedComponent).Count > 0; + + private static void SetupSharedDom(IRenderedComponent parentComponent, IRenderedComponent childComponent) + where TChildComponent : IComponent + { + var parent = (IRenderedComponent)parentComponent; + var child = (IRenderedComponent)childComponent; + var effectiveRootId = parent.RootComponentId ?? parentComponent.ComponentId; + child.SetRootComponentId(effectiveRootId); + } } diff --git a/src/bunit/Rendering/BunitRenderer.cs b/src/bunit/Rendering/BunitRenderer.cs index 5a9d47731..1e3a2abe6 100644 --- a/src/bunit/Rendering/BunitRenderer.cs +++ b/src/bunit/Rendering/BunitRenderer.cs @@ -1,4 +1,5 @@ using Microsoft.Extensions.Logging; +using AngleSharp.Dom; using System.Collections.Concurrent; using System.Diagnostics; using System.Reflection; @@ -538,6 +539,25 @@ static bool IsParentComponentAlreadyUpdated(int componentId, in RenderBatch rend internal new ArrayRange GetCurrentRenderTreeFrames(int componentId) => base.GetCurrentRenderTreeFrames(componentId); + private readonly Dictionary boundaryNodesCache = new(); + + internal INodeList GetBoundaryNodesForComponent(int componentId) + { + if (boundaryNodesCache.TryGetValue(componentId, out var cached)) + { + return cached; + } + + var htmlParser = services.GetRequiredService(); + var boundaryHtml = Htmlizer.GetHtmlWithComponentBoundaries(componentId, this); + var nodes = htmlParser.Parse(boundaryHtml); + boundaryNodesCache[componentId] = nodes; + return nodes; + } + + internal void InvalidateBoundaryNodesCache(int componentId) + => boundaryNodesCache.Remove(componentId); + /// protected override void Dispose(bool disposing) { @@ -615,6 +635,7 @@ private List> FindComponents(IRendere { ObjectDisposedException.ThrowIf(disposed, this); FindComponentsInRenderTree(parentComponent.ComponentId); + foreach (var rc in result) { ((IRenderedComponent)rc).UpdateState(hasRendered: false, isMarkupGenerationRequired: true); diff --git a/src/bunit/Rendering/IRenderedComponent.cs b/src/bunit/Rendering/IRenderedComponent.cs index dc7e0e7ee..119ac800a 100644 --- a/src/bunit/Rendering/IRenderedComponent.cs +++ b/src/bunit/Rendering/IRenderedComponent.cs @@ -9,8 +9,18 @@ internal interface IRenderedComponent : IDisposable /// int ComponentId { get; } + /// + /// Gets the root component ID for shared DOM resolution, or null if this is a root component. + /// + int? RootComponentId { get; } + /// /// Called by the owning when it finishes a render. /// void UpdateState(bool hasRendered, bool isMarkupGenerationRequired); + + /// + /// Sets the root component ID for shared DOM resolution. + /// + void SetRootComponentId(int rootComponentId); } diff --git a/src/bunit/Rendering/Internal/ComponentBoundaryNodeExtractor.cs b/src/bunit/Rendering/Internal/ComponentBoundaryNodeExtractor.cs new file mode 100644 index 000000000..ca4237ec9 --- /dev/null +++ b/src/bunit/Rendering/Internal/ComponentBoundaryNodeExtractor.cs @@ -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(); + + CollectNodesBetweenMarkers(rootNodes, startMarker, endMarker, result); + + return new ReadOnlyNodeList(result); + } + + private static bool CollectNodesBetweenMarkers( + INodeList nodes, + string startMarker, + string endMarker, + List 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); +} diff --git a/src/bunit/Rendering/Internal/Htmlizer.cs b/src/bunit/Rendering/Internal/Htmlizer.cs index 9f31c4bd0..06e6394e5 100644 --- a/src/bunit/Rendering/Internal/Htmlizer.cs +++ b/src/bunit/Rendering/Internal/Htmlizer.cs @@ -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 frames, @@ -131,8 +140,26 @@ int position ) { var frame = frames.Array[position]; + + if (context.IncludeComponentBoundaries) + { + context.Result + .Append(""); + } + var childFrames = context.GetRenderTreeFrames(frame.ComponentId); RenderFrames(context, childFrames, 0, childFrames.Count); + + if (context.IncludeComponentBoundaries) + { + context.Result + .Append(""); + } + return position + frame.ComponentSubtreeLength; } @@ -402,5 +429,7 @@ public ArrayRange GetRenderTreeFrames(int componentId) public StringBuilder Result { get; } = new(); public string? ClosestSelectValueAsString { get; set; } + + public bool IncludeComponentBoundaries { get; init; } } } diff --git a/src/bunit/Rendering/Internal/ReadOnlyNodeList.cs b/src/bunit/Rendering/Internal/ReadOnlyNodeList.cs new file mode 100644 index 000000000..a481ede89 --- /dev/null +++ b/src/bunit/Rendering/Internal/ReadOnlyNodeList.cs @@ -0,0 +1,30 @@ +using System.Collections; +using AngleSharp; +using AngleSharp.Dom; + +namespace Bunit.Rendering; + +internal sealed class ReadOnlyNodeList : INodeList, IReadOnlyList +{ + private readonly List nodes; + + public ReadOnlyNodeList(List nodes) => this.nodes = nodes; + + public INode this[int index] => nodes[index]; + + public int Length => nodes.Count; + + public int Count => nodes.Count; + + public IEnumerator GetEnumerator() => nodes.GetEnumerator(); + + IEnumerator IEnumerable.GetEnumerator() => GetEnumerator(); + + public void ToHtml(TextWriter writer, IMarkupFormatter formatter) + { + foreach (var node in nodes) + { + node.ToHtml(writer, formatter); + } + } +} diff --git a/src/bunit/Rendering/RenderedComponent.cs b/src/bunit/Rendering/RenderedComponent.cs index afd868453..dd0cd5eef 100644 --- a/src/bunit/Rendering/RenderedComponent.cs +++ b/src/bunit/Rendering/RenderedComponent.cs @@ -19,6 +19,7 @@ internal sealed class RenderedComponent : ComponentState, IRenderedC private string markup = string.Empty; private INodeList? latestRenderNodes; + private int? rootComponentId; /// /// Gets the component under test. @@ -68,6 +69,12 @@ public INodeList Nodes get { EnsureComponentNotDisposed(); + + if (rootComponentId.HasValue) + { + return latestRenderNodes ??= ResolveNodesFromRootDom(); + } + return latestRenderNodes ??= htmlParser.Parse(Markup); } } @@ -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 @@ -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); + } + /// /// Ensures that the underlying component behind the /// fragment has not been removed from the render tree. diff --git a/tests/bunit.testassets/SampleComponents/ChildSubmitButton.razor b/tests/bunit.testassets/SampleComponents/ChildSubmitButton.razor new file mode 100644 index 000000000..87a8a7d49 --- /dev/null +++ b/tests/bunit.testassets/SampleComponents/ChildSubmitButton.razor @@ -0,0 +1,8 @@ +@namespace Bunit.TestAssets.SampleComponents + + + +@code { + [Parameter] + public bool ShowExtraContent { get; set; } +} diff --git a/tests/bunit.testassets/SampleComponents/FormWrapperWithChildSubmitButton.razor b/tests/bunit.testassets/SampleComponents/FormWrapperWithChildSubmitButton.razor new file mode 100644 index 000000000..374c82374 --- /dev/null +++ b/tests/bunit.testassets/SampleComponents/FormWrapperWithChildSubmitButton.razor @@ -0,0 +1,14 @@ +@namespace Bunit.TestAssets.SampleComponents + +
+ + + +@code { + public bool FormSubmitted { get; private set; } + + private void OnFormSubmit() + { + FormSubmitted = true; + } +} diff --git a/tests/bunit.tests/Rendering/RenderedComponentTest.cs b/tests/bunit.tests/Rendering/RenderedComponentTest.cs index bdf04d193..ae4bb2577 100644 --- a/tests/bunit.tests/Rendering/RenderedComponentTest.cs +++ b/tests/bunit.tests/Rendering/RenderedComponentTest.cs @@ -312,4 +312,127 @@ protected override void BuildRenderTree(RenderTreeBuilder builder) builder.AddContent(0, "derived"); } } + + [Fact(DisplayName = "FindComponent child button click triggers parent form onsubmit (#983)")] + public void Test030() + { + var cut = Render(); + var child = cut.FindComponent(); + + child.Find("#child-submit-button").Click(); + + cut.Instance.FormSubmitted.ShouldBeTrue(); + } + + [Fact(DisplayName = "FindComponent child nodes have correct parent form element (#983)")] + public void Test031() + { + var cut = Render(); + var child = cut.FindComponent(); + + var button = child.Find("#child-submit-button"); + var parentForm = button.Closest("form"); + + parentForm.ShouldNotBeNull(); + parentForm.ShouldBeAssignableTo(); + } + + [Fact(DisplayName = "FindComponent child Markup only contains child own HTML")] + public void Test032() + { + var cut = Render(); + var child = cut.FindComponent(); + + child.Markup.ShouldNotContain("(); + + var form = cut.Find("form"); + + form.ShouldNotBeNull(); + form.ShouldBeAssignableTo(); + } + + [Fact(DisplayName = "FindComponent child nodes have parent context after parent re-render")] + public void Test034() + { + var cut = Render(); + var child = cut.FindComponent(); + + child.Find("#child-submit-button").Click(); + cut.Instance.FormSubmitted.ShouldBeTrue(); + + var button = child.Find("#child-submit-button"); + button.Closest("form").ShouldNotBeNull(); + } + + [Fact(DisplayName = "FindComponent direct Find on parent still triggers form submit")] + public void Test035() + { + var cut = Render(); + + cut.Find("#child-submit-button").Click(); + + cut.Instance.FormSubmitted.ShouldBeTrue(); + } + + [Fact(DisplayName = "Nested FindComponent preserves root DOM context")] + public void Test036() + { + var wrapper = Render(builder => builder + .Add(p => p.First, wrapper => wrapper + .AddChildContent(simple1 => simple1 + .Add(p => p.Header, "nested"))) + .Add(p => p.Second, simple1 => simple1 + .Add(p => p.Header, "Second"))); + + var wrapperComponent = wrapper.FindComponent(); + var simple1 = wrapperComponent.FindComponent(); + + simple1.Instance.Header.ShouldBe("nested"); + simple1.Find("h1").TextContent.ShouldBe("nested"); + } + + [Fact(DisplayName = "FindComponent child Nodes length matches isolated rendering")] + public void Test037() + { + var cut = Render(); + var child = cut.FindComponent(); + + child.Nodes.Length.ShouldBeGreaterThan(0); + child.Find("#child-submit-button").ShouldNotBeNull(); + } + + [Fact(DisplayName = "Multiple FindComponent calls return components with correct shared DOM")] + public void Test038() + { + var wrapper = Render(builder => builder + .Add(p => p.First, s => s.Add(p => p.Header, "First")) + .Add(p => p.Second, s => s.Add(p => p.Header, "Second"))); + + var components = wrapper.FindComponents(); + + components.Count.ShouldBe(2); + components[0].Find("h1").TextContent.ShouldBe("First"); + components[1].Find("h1").TextContent.ShouldBe("Second"); + } + + [Fact(DisplayName = "FindComponent child has parent element from root DOM tree")] + public void Test039() + { + var wrapper = Render(builder => builder + .Add(p => p.First, s => s.Add(p => p.Header, "InDiv")) + .Add(p => p.Second)); + + var child = wrapper.FindComponent(); + var heading = child.Find("h1"); + + heading.ParentElement.ShouldNotBeNull(); + heading.ParentElement!.LocalName.ShouldBe("div"); + } }