diff --git a/CHANGELOG.md b/CHANGELOG.md index c568bdff2..34c004447 100644 --- a/CHANGELOG.md +++ b/CHANGELOG.md @@ -6,8 +6,23 @@ All notable changes to **bUnit** will be documented in this file. The project ad ## [Unreleased] +### Added + +- `INode.GetOwningComponent()` and its generic overload, which return the rendered component that rendered a DOM node. Reported by [@egil](https://github.com/egil) in #153. +- `Parent()`, `Root()`, `GetAncestors()`, `GetChildren()` and its generic overload on `IRenderedComponent`, for navigating the component tree. Reported by [@egil](https://github.com/egil) in #1180. + +### Changed + +- **Breaking:** all rendered components in a render tree now share a single DOM. Previously each rendered component parsed its own markup into its own document, so an element found through a child component had no ancestors outside that child. Consequences: + - Events raised on an element found through a child component now bubble into the markup rendered by its ancestor components, as they do in a browser. Reported by [@JelleHissink](https://github.com/JelleHissink) in #983. + - `Find` and `FindAll` on a child component evaluate positional and combinator selectors (`:first-child`, `+`, `~`, ...) against the whole tree rather than the component's markup in isolation. + - An `option` element rendered by a child component, inside a `select` element rendered by an ancestor with a matching `value` attribute, now renders as `selected`, matching what the ancestor's markup shows. + + A component whose markup starts or ends with text, or that the HTML parser relocates, still falls back to its own document, since its nodes cannot be identified in the shared one. + ### Fixed +- Events raised on an element obtained through `FindComponent` no longer stop at the child component's own markup, so a submit button in a child component triggers the `@onsubmit` handler on the `form` element rendered by its parent. Reported by [@JelleHissink](https://github.com/JelleHissink) in #983. - `BunitHtmlParser.Dispose()` no longer throws `InvalidOperationException: Collection was modified` when a parse is in flight on another thread during test teardown. Reported by [@thimobuchheister](https://github.com/thimobuchheister) in #1892. Fixed by [@linkdotnet](https://github.com/linkdotnet). ## [2.9.0] - 2026-08-03 diff --git a/docs/site/docs/interaction/trigger-event-handlers.md b/docs/site/docs/interaction/trigger-event-handlers.md index d8eb3ff8c..cd9497303 100644 --- a/docs/site/docs/interaction/trigger-event-handlers.md +++ b/docs/site/docs/interaction/trigger-event-handlers.md @@ -105,4 +105,26 @@ Example: ```csharp await cut.Find("button").ClickAsync(); -``` \ No newline at end of file +``` +## Events bubble across component boundaries + +Every component in a render tree shares one DOM, so an element found through a child +component still has its ancestors from the parent components around it. Events therefore +bubble the same way whether the element was found from the component under test or from +one of its children: + +```csharp +var cut = Render(); + +// Both of these trigger the @onsubmit handler on the
+// rendered by . +cut.Find("button").Click(); +cut.FindComponent().Find("button").Click(); +``` + +Use `GetOwningComponent()` on any element to get the component that rendered it, which is +covered in : + +```csharp +IRenderedComponent inner = cut.Find("button").GetOwningComponent(); +``` diff --git a/docs/site/docs/toc.md b/docs/site/docs/toc.md index 53fe7e811..3fafcef19 100644 --- a/docs/site/docs/toc.md +++ b/docs/site/docs/toc.md @@ -20,6 +20,7 @@ # [Verifying output](xref:verification) ## [Verify markup](xref:verify-markup) ## [Verify component state](xref:verify-component-state) +## [Find the component that rendered an element](xref:find-owning-component) ## [Customizing semantic comparison](xref:semantic-html-comparison) ## [Assertion of asynchronous changes](xref:async-assertion) diff --git a/docs/site/docs/verification/find-owning-component.md b/docs/site/docs/verification/find-owning-component.md new file mode 100644 index 000000000..0e33a483d --- /dev/null +++ b/docs/site/docs/verification/find-owning-component.md @@ -0,0 +1,78 @@ +--- +uid: find-owning-component +title: Finding the component that rendered an element +--- + +# Finding the component that rendered an element + +The DOM query API is often the quickest way to reach an interesting part of the rendered +output. +takes you from a node found that way back to the component that rendered it, so you can +assert on that component's state: + +```csharp +var cut = Render(); + +IRenderedComponent owner = cut.Find("li.done").GetOwningComponent(); + +owner.Instance.ShouldBeOfType(); +``` + +Use the generic overload, +, +when you know which component type you are after. It returns a strongly typed +, so the component instance and all the usual verification +methods are available: + +```csharp +var cut = Render(); + +IRenderedComponent item = cut.Find("li.done").GetOwningComponent(); + +item.Instance.IsDone.ShouldBeTrue(); +item.MarkupMatches("
  • Buy milk
  • "); +``` + +## Which component is returned + +The **innermost** component that rendered the node. Given a `` that renders a +`
      ` and a `` per entry, `cut.Find("ul").GetOwningComponent()` returns the +`` and `cut.Find("li").GetOwningComponent()` returns the ``. + +A few details worth knowing: + +- **Nodes that are not elements resolve through their closest element.** Calling it on a text + node returns the component that rendered the element containing that text. +- **Content passed as a `RenderFragment` belongs to the receiving component.** A + `ChildContent` written in the parent is rendered into the child's render tree, so + `GetOwningComponent()` names the child. This matches what the child's + already showed. +- **bUnit's own root component is never returned.** Wrapper components with no markup of their + own, such as `CascadingValue`, lose to the component inside them. + +The generic overload widens the search outwards: if the innermost component is not a +`TComponent`, it walks up the component tree until it finds one, and throws + if there is none. That makes it a concise +way to reach a specific ancestor: + +```csharp +// The + +@code { + [Parameter] public string ButtonType { get; set; } = "button"; + + public int ClickCount { get; set; } +} diff --git a/tests/bunit.testassets/SampleComponents/CounterChild.razor b/tests/bunit.testassets/SampleComponents/CounterChild.razor new file mode 100644 index 000000000..9ceb74bc7 --- /dev/null +++ b/tests/bunit.testassets/SampleComponents/CounterChild.razor @@ -0,0 +1,5 @@ + + +@code { + public int Count { get; set; } +} diff --git a/tests/bunit.testassets/SampleComponents/EmptyChild.razor b/tests/bunit.testassets/SampleComponents/EmptyChild.razor new file mode 100644 index 000000000..bcf78df4a --- /dev/null +++ b/tests/bunit.testassets/SampleComponents/EmptyChild.razor @@ -0,0 +1 @@ +@code { } diff --git a/tests/bunit.testassets/SampleComponents/GrandParentDivWithChildButton.razor b/tests/bunit.testassets/SampleComponents/GrandParentDivWithChildButton.razor new file mode 100644 index 000000000..14ffb0b01 --- /dev/null +++ b/tests/bunit.testassets/SampleComponents/GrandParentDivWithChildButton.razor @@ -0,0 +1,9 @@ +
      + +
      + +@code { + [Parameter] public bool StopPropagation { get; set; } + + public int ClickCount { get; set; } +} diff --git a/tests/bunit.testassets/SampleComponents/LeadingTextChild.razor b/tests/bunit.testassets/SampleComponents/LeadingTextChild.razor new file mode 100644 index 000000000..f13c43287 --- /dev/null +++ b/tests/bunit.testassets/SampleComponents/LeadingTextChild.razor @@ -0,0 +1 @@ +Hello world diff --git a/tests/bunit.testassets/SampleComponents/ParentDivWithChildButton.razor b/tests/bunit.testassets/SampleComponents/ParentDivWithChildButton.razor new file mode 100644 index 000000000..f825322e5 --- /dev/null +++ b/tests/bunit.testassets/SampleComponents/ParentDivWithChildButton.razor @@ -0,0 +1,9 @@ +
      + +
      + +@code { + [Parameter] public bool StopPropagation { get; set; } + + public int ClickCount { get; set; } +} diff --git a/tests/bunit.testassets/SampleComponents/ParentFormWithChildButton.razor b/tests/bunit.testassets/SampleComponents/ParentFormWithChildButton.razor new file mode 100644 index 000000000..32cd65176 --- /dev/null +++ b/tests/bunit.testassets/SampleComponents/ParentFormWithChildButton.razor @@ -0,0 +1,7 @@ + + + + +@code { + public int SubmitCount { get; set; } +} diff --git a/tests/bunit.testassets/SampleComponents/ParentWithEmptyChild.razor b/tests/bunit.testassets/SampleComponents/ParentWithEmptyChild.razor new file mode 100644 index 000000000..14d74d9a5 --- /dev/null +++ b/tests/bunit.testassets/SampleComponents/ParentWithEmptyChild.razor @@ -0,0 +1 @@ +
      diff --git a/tests/bunit.testassets/SampleComponents/ParentWithLeadingTextChild.razor b/tests/bunit.testassets/SampleComponents/ParentWithLeadingTextChild.razor new file mode 100644 index 000000000..33630436a --- /dev/null +++ b/tests/bunit.testassets/SampleComponents/ParentWithLeadingTextChild.razor @@ -0,0 +1 @@ +
      diff --git a/tests/bunit.testassets/SampleComponents/ParentWithTextOnlyChild.razor b/tests/bunit.testassets/SampleComponents/ParentWithTextOnlyChild.razor new file mode 100644 index 000000000..a34baa18a --- /dev/null +++ b/tests/bunit.testassets/SampleComponents/ParentWithTextOnlyChild.razor @@ -0,0 +1 @@ +

      Hello world

      diff --git a/tests/bunit.testassets/SampleComponents/SelectWithChildOptions.razor b/tests/bunit.testassets/SampleComponents/SelectWithChildOptions.razor new file mode 100644 index 000000000..ce2089b65 --- /dev/null +++ b/tests/bunit.testassets/SampleComponents/SelectWithChildOptions.razor @@ -0,0 +1,4 @@ + diff --git a/tests/bunit.testassets/SampleComponents/SiblingChildren.razor b/tests/bunit.testassets/SampleComponents/SiblingChildren.razor new file mode 100644 index 000000000..4f2cace32 --- /dev/null +++ b/tests/bunit.testassets/SampleComponents/SiblingChildren.razor @@ -0,0 +1,4 @@ +
      + + +
      diff --git a/tests/bunit.testassets/SampleComponents/TableWithChildRows.razor b/tests/bunit.testassets/SampleComponents/TableWithChildRows.razor new file mode 100644 index 000000000..78193fd34 --- /dev/null +++ b/tests/bunit.testassets/SampleComponents/TableWithChildRows.razor @@ -0,0 +1,6 @@ + + + + + +
      diff --git a/tests/bunit.testassets/SampleComponents/TextOnlyChild.razor b/tests/bunit.testassets/SampleComponents/TextOnlyChild.razor new file mode 100644 index 000000000..c3e9594dc --- /dev/null +++ b/tests/bunit.testassets/SampleComponents/TextOnlyChild.razor @@ -0,0 +1,5 @@ +@ChildText + +@code { + [Parameter] public string ChildText { get; set; } = "child-text"; +} diff --git a/tests/bunit.tests/EventDispatchExtensions/ChildComponentEventBubblingTest.cs b/tests/bunit.tests/EventDispatchExtensions/ChildComponentEventBubblingTest.cs new file mode 100644 index 000000000..e81e42be7 --- /dev/null +++ b/tests/bunit.tests/EventDispatchExtensions/ChildComponentEventBubblingTest.cs @@ -0,0 +1,67 @@ +namespace Bunit; + +/// +/// Verifies that events raised on an element found through a child components +/// bubble through the DOM rendered +/// by the child's ancestor components. See issue #983. +/// +public class ChildComponentEventBubblingTest : BunitContext +{ + [Fact(DisplayName = "Clicking a child component's element bubbles to the parent component's element")] + public void Test001() + { + var cut = Render(); + + cut.FindComponent().Find("button").Click(); + + cut.Instance.ClickCount.ShouldBe(1); + } + + [Fact(DisplayName = "Clicking a child component's submit button triggers the parent component's form")] + public void Test002() + { + var cut = Render(); + + cut.FindComponent().Find("button").Click(); + + cut.Instance.SubmitCount.ShouldBe(1); + } + + [Fact(DisplayName = "Bubbling from a child component's element is identical to bubbling from the parent's view of it")] + public void Test003() + { + var cut = Render(); + var child = cut.FindComponent(); + + cut.Find("button").Click(); + var (parentAfterFirst, childAfterFirst) = (cut.Instance.ClickCount, child.Instance.ClickCount); + + child.Find("button").Click(); + + cut.Instance.ClickCount.ShouldBe(parentAfterFirst + 1); + child.Instance.ClickCount.ShouldBe(childAfterFirst + 1); + } + + [Fact(DisplayName = "stopPropagation on a parent component's element stops bubbling from a child component's element")] + public void Test004() + { + var cut = Render(ps => ps.Add(p => p.StopPropagation, true)); + + cut.FindComponent().Find("button").Click(); + + cut.FindComponent().Instance.ClickCount.ShouldBe(1); + cut.Instance.ClickCount.ShouldBe(0); + } + + [Fact(DisplayName = "Events bubble through three levels of components")] + public void Test005() + { + var cut = Render(); + + cut.FindComponent().Find("button").Click(); + + cut.FindComponent().Instance.ClickCount.ShouldBe(1); + cut.FindComponent().Instance.ClickCount.ShouldBe(1); + cut.Instance.ClickCount.ShouldBe(1); + } +} diff --git a/tests/bunit.tests/Extensions/ElementOwningComponentTest.cs b/tests/bunit.tests/Extensions/ElementOwningComponentTest.cs new file mode 100644 index 000000000..cab114097 --- /dev/null +++ b/tests/bunit.tests/Extensions/ElementOwningComponentTest.cs @@ -0,0 +1,116 @@ +using AngleSharp.Dom; +using Bunit.Rendering; + +namespace Bunit.Extensions; + +/// +/// Verifies that a DOM node can be traced back to the component that +/// rendered it. See issue #153. +/// +public class ElementOwningComponentTest : BunitContext +{ + [Fact(DisplayName = "GetOwningComponent returns the component that rendered the element")] + public void Test001() + { + var cut = Render(); + + var owner = cut.Find("button").GetOwningComponent(); + + owner.Instance.ShouldBeOfType(); + } + + [Fact(DisplayName = "GetOwningComponent returns the same rendered component as FindComponent")] + public void Test002() + { + var cut = Render(); + var child = cut.FindComponent(); + + var owner = cut.Find("button").GetOwningComponent(); + + owner.ShouldBeSameAs(child); + } + + [Fact(DisplayName = "GetOwningComponent returns the parent component for an element it rendered")] + public void Test003() + { + var cut = Render(); + + var owner = cut.Find("#parent-div").GetOwningComponent(); + + owner.Instance.ShouldBeOfType(); + } + + [Fact(DisplayName = "GetOwningComponent walks outwards to the nearest component of that type")] + public void Test004() + { + var cut = Render(); + + var owner = cut.Find("button").GetOwningComponent(); + + owner.ShouldBeSameAs(cut.FindComponent()); + } + + [Fact(DisplayName = "GetOwningComponent throws when no ancestor of that type rendered the element")] + public void Test005() + { + var cut = Render(); + + Should.Throw( + () => cut.Find("button").GetOwningComponent()); + } + + [Fact(DisplayName = "GetOwningComponent resolves a descendant node to the component that rendered its element")] + public void Test006() + { + var cut = Render(); + + var owner = cut.Find("#child-row td").GetOwningComponent(); + + owner.Instance.ShouldBeOfType(); + } + + [Fact(DisplayName = "GetOwningComponent never returns a bUnit infrastructure component")] + public void Test007() + { + var cut = Render(); + + var owner = cut.Find("h1").GetOwningComponent(); + + owner.Instance.ShouldBeOfType(); + } + + [Fact(DisplayName = "GetOwningComponent throws for a node that was not rendered by bUnit")] + public void Test008() + { + using var parser = new Bunit.Rendering.BunitHtmlParser(); + var nodes = parser.Parse("

      foo

      "); + + Should.Throw(() => nodes[0].GetOwningComponent()); + } + + [Fact(DisplayName = "GetOwningComponent resolves an element of a component that has its own document")] + public void Test010() + { + // Markup starting with text cannot be located in the shared document, so the + // component is parsed into one of its own - both routes must still resolve. + var cut = Render(); + var child = cut.FindComponent(); + + child.Nodes[0].Owner.ShouldNotBeSameAs(cut.Nodes[0].Owner); + + child.Find("b").GetOwningComponent().ShouldBeSameAs(child); + cut.Find("b").GetOwningComponent().ShouldBeSameAs(child); + } + + [Fact(DisplayName = "GetOwningComponent resolves elements from separate render trees to their own component")] + public void Test009() + { + var first = Render(); + var second = Render(); + + first.Find("button").GetOwningComponent() + .ShouldBeSameAs(first.FindComponent()); + second.Find("button").GetOwningComponent() + .ShouldBeSameAs(second.FindComponent()); + } +} diff --git a/tests/bunit.tests/Rendering/ComponentTreeNavigationTest.cs b/tests/bunit.tests/Rendering/ComponentTreeNavigationTest.cs new file mode 100644 index 000000000..7cc940f18 --- /dev/null +++ b/tests/bunit.tests/Rendering/ComponentTreeNavigationTest.cs @@ -0,0 +1,81 @@ +namespace Bunit.Rendering; + +/// +/// Verifies navigation of the component tree. See issue #1180. +/// +public class ComponentTreeNavigationTest : BunitContext +{ + [Fact(DisplayName = "Parent returns the component that rendered this component")] + public void Test001() + { + var cut = Render(); + + var parent = cut.FindComponent().Parent(); + + parent.ShouldNotBeNull().Instance.ShouldBeOfType(); + } + + [Fact(DisplayName = "Parent of the outermost rendered component is null")] + public void Test002() + { + var cut = Render(); + + cut.Parent().ShouldBeNull(); + } + + [Fact(DisplayName = "Root returns the outermost rendered component")] + public void Test003() + { + var cut = Render(); + + var root = cut.FindComponent().Root(); + + root.ShouldBeSameAs(cut); + } + + [Fact(DisplayName = "GetChildren returns direct child components only, in render order")] + public void Test004() + { + var cut = Render(); + + var children = cut.GetChildren(); + + children.Count.ShouldBe(2); + children[0].Instance.ShouldBeOfType(); + children[1].Instance.ShouldBeOfType(); + } + + [Fact(DisplayName = "GetChildren does not return grandchildren")] + public void Test005() + { + var cut = Render(); + + var children = cut.GetChildren(); + + children.Count.ShouldBe(1); + children[0].Instance.ShouldBeOfType(); + } + + [Fact(DisplayName = "GetChildren returns only direct children of that type")] + public void Test006() + { + var cut = Render(); + + var children = cut.GetChildren(); + + children.Count.ShouldBe(1); + children[0].ShouldBeSameAs(cut.FindComponent()); + } + + [Fact(DisplayName = "GetAncestors returns components from the closest parent to the root")] + public void Test007() + { + var cut = Render(); + + var ancestors = cut.FindComponent().GetAncestors().ToArray(); + + ancestors.Length.ShouldBe(2); + ancestors[0].Instance.ShouldBeOfType(); + ancestors[1].Instance.ShouldBeOfType(); + } +} diff --git a/tests/bunit.tests/Rendering/SharedDocumentTest.cs b/tests/bunit.tests/Rendering/SharedDocumentTest.cs new file mode 100644 index 000000000..8529c767c --- /dev/null +++ b/tests/bunit.tests/Rendering/SharedDocumentTest.cs @@ -0,0 +1,142 @@ +using AngleSharp.Dom; +using Bunit.Web.AngleSharp; + +namespace Bunit.Rendering; + +/// +/// Verifies that all rendered components under the same root share one +/// AngleSharp document, and that components whose markup cannot be located +/// in it fall back to their own document. +/// +public class SharedDocumentTest : BunitContext +{ + [Fact(DisplayName = "A child component's element is the same instance as the parent component's view of it")] + public void Test001() + { + var cut = Render(); + + var fromParent = cut.Find("button").Unwrap(); + var fromChild = cut.FindComponent().Find("button").Unwrap(); + + fromChild.ShouldBeSameAs(fromParent); + } + + [Fact(DisplayName = "A child component's element has the parent component's element as its parent")] + public void Test002() + { + var cut = Render(); + + var button = cut.FindComponent().Find("button"); + + button.ParentElement.ShouldNotBeNull().Id.ShouldBe("parent-form"); + } + + [Fact(DisplayName = "Nodes of parent and child components come from the same document")] + public void Test003() + { + var cut = Render(); + + var child = cut.FindComponent(); + + child.Nodes[0].Owner.ShouldBeSameAs(cut.Nodes[0].Owner); + } + + [Fact(DisplayName = "Find on a child component only matches elements rendered by that component")] + public void Test004() + { + var cut = Render(); + + var child = cut.FindComponent(); + + Should.Throw(() => child.Find("#parent-div")); + } + + [Fact(DisplayName = "Markup of a child component is unaffected by the shared document")] + public void Test005() + { + var cut = Render(); + + var child = cut.FindComponent(); + + child.Markup.ShouldStartWith("(); + var child = cut.FindComponent(); + var initialNodes = child.Nodes; + + child.Nodes.ShouldBeSameAs(initialNodes); + } + + [Fact(DisplayName = "A component whose sibling re-renders gets its element wrapper refreshed")] + public void Test007() + { + var cut = Render(); + var button = cut.FindComponent().Find("button"); + + cut.Find("#counter-button").Click(); + + // The wrapper must re-resolve into the new document instead of + // exposing a node detached from the previous parse. + button.Unwrap().Owner.ShouldBeSameAs(cut.Nodes[0].Owner); + } + + [Fact(DisplayName = "A child component rendering a inside the parent's is found")] + public void Test010() + { + var cut = Render(); + + var rows = cut.FindComponents(); + + rows.Count.ShouldBe(2); + rows[0].Find("td").TextContent.ShouldBe("one"); + rows[1].Find("td").TextContent.ShouldBe("two"); + } + + [Fact(DisplayName = "A child component rendering an