Skip to content
Open
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
15 changes: 15 additions & 0 deletions CHANGELOG.md
Original file line number Diff line number Diff line change
Expand Up @@ -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
Expand Down
24 changes: 23 additions & 1 deletion docs/site/docs/interaction/trigger-event-handlers.md
Original file line number Diff line number Diff line change
Expand Up @@ -105,4 +105,26 @@ Example:

```csharp
await cut.Find("button").ClickAsync();
```
```
## 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<OuterSubmitForm>();

// Both of these trigger the @onsubmit handler on the <form>
// rendered by <OuterSubmitForm>.
cut.Find("button").Click();
cut.FindComponent<InnerSubmitForm>().Find("button").Click();
```

Use `GetOwningComponent()` on any element to get the component that rendered it, which is
covered in <xref:find-owning-component>:

```csharp
IRenderedComponent<InnerSubmitForm> inner = cut.Find("button").GetOwningComponent<InnerSubmitForm>();
```
1 change: 1 addition & 0 deletions docs/site/docs/toc.md
Original file line number Diff line number Diff line change
Expand Up @@ -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)

Expand Down
78 changes: 78 additions & 0 deletions docs/site/docs/verification/find-owning-component.md
Original file line number Diff line number Diff line change
@@ -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. <xref:Bunit.ElementOwningComponentExtensions.GetOwningComponent(AngleSharp.Dom.INode)>
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<TodoList>();

IRenderedComponent<IComponent> owner = cut.Find("li.done").GetOwningComponent();

owner.Instance.ShouldBeOfType<TodoItem>();
```

Use the generic overload,
<xref:Bunit.ElementOwningComponentExtensions.GetOwningComponent``1(AngleSharp.Dom.INode)>,
when you know which component type you are after. It returns a strongly typed
<xref:Bunit.IRenderedComponent`1>, so the component instance and all the usual verification
methods are available:

```csharp
var cut = Render<TodoList>();

IRenderedComponent<TodoItem> item = cut.Find("li.done").GetOwningComponent<TodoItem>();

item.Instance.IsDone.ShouldBeTrue();
item.MarkupMatches("<li class=\"done\">Buy milk</li>");
```

## Which component is returned

The **innermost** component that rendered the node. Given a `<TodoList>` that renders a
`<ul>` and a `<TodoItem>` per entry, `cut.Find("ul").GetOwningComponent()` returns the
`<TodoList>` and `cut.Find("li").GetOwningComponent()` returns the `<TodoItem>`.

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
<xref:Bunit.IRenderedComponent`1.Markup> already showed.
- **bUnit's own root component is never returned.** Wrapper components with no markup of their
own, such as `CascadingValue<T>`, 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
<xref:Bunit.Rendering.ComponentNotFoundException> if there is none. That makes it a concise
way to reach a specific ancestor:

```csharp
// The <button> is rendered by <TodoItem>, but the assertion is about the list around it.
IRenderedComponent<TodoList> list = cut.Find("li button").GetOwningComponent<TodoList>();
```

## Nodes that cannot be traced

Both methods throw an `InvalidOperationException` for a node that bUnit did not render, for
example one parsed from a markup string in a test. Only nodes from a rendered component tree
carry the information needed to name their component.

## How it works

All components in a render tree share one markup string and one parsed document. While
generating that markup, bUnit records the character range each component occupies in it, and
AngleSharp records the position each element was parsed from. Looking up an element's position
in those ranges identifies the innermost component that produced it - no markers are added to
the markup, so nothing about the rendered output changes.

The same shared document is what lets events bubble across component boundaries; see
<xref:trigger-event-handlers>.
1 change: 1 addition & 0 deletions docs/site/docs/verification/index.md
Original file line number Diff line number Diff line change
Expand Up @@ -9,5 +9,6 @@ This section covers the different ways to verify the result of a test scenario:

- **<xref:verify-markup>:** This covers the different ways bUnit enables verification and assertions against the rendered markup from a component.
- **<xref:verify-component-state>:** This covers how to inspect an instance of the component under test.
- **<xref:find-owning-component>:** This covers how to go from a DOM node found with `Find`/`FindAll` back to the component that rendered it.
- **<xref:semantic-html-comparison>:** This covers how to customize the semantic HTML/markup comparer included in bUnit for more stable tests.
- **<xref:async-assertion>:** This covers how to create stable tests in an asynchronous world.
126 changes: 126 additions & 0 deletions src/bunit/Extensions/ElementOwningComponentExtensions.cs
Original file line number Diff line number Diff line change
@@ -0,0 +1,126 @@
using AngleSharp.Dom;
using Bunit.Rendering;
using Bunit.Web.AngleSharp;

namespace Bunit;

/// <summary>
/// Extension methods for getting the component that rendered a DOM node.
/// </summary>
public static class ElementOwningComponentExtensions
{
/// <summary>
/// Gets the component that rendered the <paramref name="node"/>.
/// </summary>
/// <param name="node">The node to find the rendering component for.</param>
/// <exception cref="InvalidOperationException">
/// Thrown when the <paramref name="node"/> cannot be traced back to a component,
/// e.g. because it was not rendered by a <see cref="BunitRenderer"/>.
/// </exception>
public static IRenderedComponent<IComponent> GetOwningComponent(this INode node)
{
ArgumentNullException.ThrowIfNull(node);

var renderer = node.GetBunitContext()?.Renderer
?? throw new InvalidOperationException(
$"The node was not rendered by bUnit's '{nameof(BunitRenderer)}', so the component that rendered it is unknown.");

var element = FindElementWithSourceReference(node)
?? throw new InvalidOperationException(
"The node cannot be traced back to a component, because neither it nor any of its ancestors was parsed from the markup of a component.");

var (root, sourceIndexOffset) = FindRenderTreeRootFor(renderer, element.Owner);

var snapshot = root.MarkupSnapshot
?? throw new InvalidOperationException("The render tree the node belongs to has no markup.");

var sourceIndex = RootMarkupSnapshot.GetSourceIndex(element) + sourceIndexOffset;

foreach (var range in snapshot.Ranges)
{
if (!range.Contains(sourceIndex))
{
continue;
}

var candidate = renderer.GetRenderedComponent(range.ComponentId);

// Ranges are in post-order, so the first match is the innermost component.
if (candidate.Instance is not BunitRootComponent)
{
return candidate;
}
}

throw new InvalidOperationException("The node cannot be traced back to a component.");
}

/// <summary>
/// Gets the closest <typeparamref name="TComponent"/> that the <paramref name="node"/>
/// was rendered inside of.
/// </summary>
/// <typeparam name="TComponent">The type of component to find.</typeparam>
/// <param name="node">The node to find the rendering component for.</param>
/// <exception cref="ComponentNotFoundException">
/// Thrown when the <paramref name="node"/> was not rendered inside a <typeparamref name="TComponent"/>.
/// </exception>
public static IRenderedComponent<TComponent> GetOwningComponent<TComponent>(this INode node)
where TComponent : IComponent
{
var owner = node.GetOwningComponent();

for (var candidate = owner; candidate is not null; candidate = candidate.Parent())
{
if (candidate.Instance is TComponent)
{
return (IRenderedComponent<TComponent>)candidate;
}
}

throw new ComponentNotFoundException(typeof(TComponent));
}

/// <summary>
/// Gets the root of the render tree the <paramref name="document"/> was parsed for, and the
/// offset that rebases a source index in it onto the root's markup.
/// </summary>
/// <remarks>
/// A private document holds one component's markup, so its indices start at zero rather
/// than at that component's position in the root's markup.
/// </remarks>
private static (IRenderedComponentRoot Root, int SourceIndexOffset) FindRenderTreeRootFor(BunitRenderer renderer, IDocument? document)
{
if (renderer.FindRenderTreeRoot(document) is { } sharedRoot)
{
return (sharedRoot, 0);
}

if (renderer.FindPrivateDocumentOwner(document) is { } owner
&& owner.Root.MarkupSnapshot?.TryGetRange(owner.ComponentId, out var range) == true)
{
return (owner.Root, range.Start);
}

throw new InvalidOperationException(
"The node cannot be traced back to a component, because it does not belong to the document of a rendered component tree.");
}

private static IElement? FindElementWithSourceReference(INode node)
{
var candidate = node is IElement element
? element.Unwrap()
: node.ParentElement;

while (candidate is not null)
{
if (RootMarkupSnapshot.GetSourceIndex(candidate) >= 0)
{
return candidate;
}

candidate = candidate.ParentElement;
}

return null;
}
}
121 changes: 121 additions & 0 deletions src/bunit/Extensions/RenderedComponentTreeExtensions.cs
Original file line number Diff line number Diff line change
@@ -0,0 +1,121 @@
using Bunit.Rendering;

namespace Bunit;

/// <summary>
/// Extension methods for navigating the component tree an
/// <see cref="IRenderedComponent{TComponent}"/> is part of.
Comment on lines +5 to +7
/// </summary>
public static class RenderedComponentTreeExtensions
{
/// <summary>
/// Gets the component that rendered the <paramref name="renderedComponent"/>,
/// or <see langword="null"/> if it is the outermost component in its render tree.
/// </summary>
public static IRenderedComponent<IComponent>? Parent(this IRenderedComponent<IComponent> renderedComponent)
{
ArgumentNullException.ThrowIfNull(renderedComponent);

var parent = ToComponentState(renderedComponent).ParentComponentState;

return parent is null || IsBunitInfrastructure(parent)
? null
: (IRenderedComponent<IComponent>)parent;
}

/// <summary>
/// Gets the outermost component in the render tree the
/// <paramref name="renderedComponent"/> is part of.
/// </summary>
public static IRenderedComponent<IComponent> Root(this IRenderedComponent<IComponent> renderedComponent)
{
ArgumentNullException.ThrowIfNull(renderedComponent);

var result = renderedComponent;
while (result.Parent() is { } parent)
{
result = parent;
}

return result;
}

/// <summary>
/// Gets the components the <paramref name="renderedComponent"/> was rendered inside of,
/// from its closest parent to the outermost component in the render tree.
/// </summary>
public static IEnumerable<IRenderedComponent<IComponent>> GetAncestors(this IRenderedComponent<IComponent> renderedComponent)
{
ArgumentNullException.ThrowIfNull(renderedComponent);

return Iterate(renderedComponent);

static IEnumerable<IRenderedComponent<IComponent>> Iterate(IRenderedComponent<IComponent> renderedComponent)
{
for (var parent = renderedComponent.Parent(); parent is not null; parent = parent.Parent())
{
yield return parent;
}
}
}

/// <summary>
/// Gets the components rendered directly by the <paramref name="renderedComponent"/>,
/// in render order. Components rendered by those children are not included.
/// </summary>
public static IReadOnlyList<IRenderedComponent<IComponent>> GetChildren(this IRenderedComponent<IComponent> renderedComponent)
{
ArgumentNullException.ThrowIfNull(renderedComponent);

var renderer = renderedComponent.Services.GetRequiredService<BunitContext>().Renderer;

return renderer.GetChildComponents(renderedComponent.ComponentId);
}

/// <summary>
/// Gets the <typeparamref name="TComponent"/> components rendered directly by the
/// <paramref name="renderedComponent"/>, in render order.
/// </summary>
/// <typeparam name="TComponent">Type of child components to get.</typeparam>
public static IReadOnlyList<IRenderedComponent<TComponent>> GetChildren<TComponent>(this IRenderedComponent<IComponent> renderedComponent)
where TComponent : IComponent
{
var result = new List<IRenderedComponent<TComponent>>();

foreach (var child in renderedComponent.GetChildren())
{
if (child.Instance is TComponent)
{
result.Add((IRenderedComponent<TComponent>)child);
}
}

return result;
}

internal static ComponentState ToComponentState(IRenderedComponent<IComponent> renderedComponent)
=> (ComponentState)renderedComponent;

/// <summary>
/// Components bUnit wraps the component under test in are not part of the tree a test sees.
/// </summary>
/// <remarks>
/// A <see cref="CascadingValue{TValue}"/> counts as infrastructure only when everything above
/// it does too; one written by the user inside their own component is a regular part of the tree.
/// </remarks>
internal static bool IsBunitInfrastructure(ComponentState componentState)
{
if (componentState.Component is BunitRootComponent or ContainerFragment)
{
return true;
}

var componentType = componentState.Component.GetType();
if (componentType.IsGenericType && componentType.GetGenericTypeDefinition() == typeof(CascadingValue<>))
{
return componentState.ParentComponentState is { } parent && IsBunitInfrastructure(parent);
}

return false;
}
}
Loading