feat: Shared Render Tree - #1910
Conversation
There was a problem hiding this comment.
🟡 Changes recommended
ConditionalWeakTable<,>.AddOrUpdate is not available on the net8.0 target, causing a compile-time break for one of the supported TFMs.
Once you've addressed the issues Copilot identified, you can request another Copilot review.
Pull request overview
This PR reworks bUnit’s rendered-component DOM model so all components under the same render-tree root share a single markup snapshot and AngleSharp document, enabling correct event bubbling across component boundaries and supporting “find owning component” and component-tree navigation APIs.
Changes:
- Introduces a root-owned
RootMarkupSnapshot(shared markup + document) and per-component “slice” views forMarkup/Nodes. - Adds public extension APIs for component tree navigation (
Parent/Root/GetAncestors/GetChildren) and for mapping DOM nodes back to their rendering component (GetOwningComponent). - Adds tests, docs, and changelog entries covering shared-document behavior, bubbling behavior changes, and the new APIs.
File summaries
| File | Description |
|---|---|
| tests/bunit.tests/Rendering/SharedDocumentTest.cs | New tests validating shared-document DOM behavior and fallback cases |
| tests/bunit.tests/Rendering/ComponentTreeNavigationTest.cs | New tests for parent/root/children/ancestor navigation APIs |
| tests/bunit.tests/Extensions/ElementOwningComponentTest.cs | New tests for INode.GetOwningComponent* resolution |
| tests/bunit.tests/EventDispatchExtensions/ChildComponentEventBubblingTest.cs | New tests confirming cross-component DOM bubbling behavior |
| tests/bunit.testassets/SampleComponents/TextOnlyChild.razor | New test asset for text-only child rendering edge case |
| tests/bunit.testassets/SampleComponents/TableWithChildRows.razor | New test asset for table/row nesting behavior |
| tests/bunit.testassets/SampleComponents/SiblingChildren.razor | New test asset for sibling re-render scenarios |
| tests/bunit.testassets/SampleComponents/SelectWithChildOptions.razor | New test asset for <select>/<option> ownership/selection behavior |
| tests/bunit.testassets/SampleComponents/ParentWithTextOnlyChild.razor | New test asset covering inlined text-only child output |
| tests/bunit.testassets/SampleComponents/ParentWithLeadingTextChild.razor | New test asset for “leading text prevents shared-node view” fallback |
| tests/bunit.testassets/SampleComponents/ParentWithEmptyChild.razor | New test asset for empty child rendering behavior |
| tests/bunit.testassets/SampleComponents/ParentFormWithChildButton.razor | New test asset for submit bubbling into parent form |
| tests/bunit.testassets/SampleComponents/ParentDivWithChildButton.razor | New test asset for click bubbling + stopPropagation |
| tests/bunit.testassets/SampleComponents/LeadingTextChild.razor | New test asset rendering markup with leading text |
| tests/bunit.testassets/SampleComponents/GrandParentDivWithChildButton.razor | New test asset for 3-level bubbling / ancestor navigation |
| tests/bunit.testassets/SampleComponents/EmptyChild.razor | New test asset for “renders nothing” scenario |
| tests/bunit.testassets/SampleComponents/CounterChild.razor | New test asset used for sibling re-render and filtering tests |
| tests/bunit.testassets/SampleComponents/ChildWithButton.razor | New test asset for basic child button interactions |
| tests/bunit.testassets/SampleComponents/ChildTableRow.razor | New test asset for <tr> rendered by child component |
| tests/bunit.testassets/SampleComponents/ChildOption.razor | New test asset for <option> rendered by child component |
| src/bunit/Rendering/RootRenderedComponent.cs | Adds root rendered component that owns the shared snapshot |
| src/bunit/Rendering/RenderedComponent.cs | Reworks per-component markup/nodes to be snapshot “slices” + invalidation signaling |
| src/bunit/Rendering/IRenderedComponent.cs | Extends internal contracts for root access + markup-updated signaling |
| src/bunit/Rendering/Internal/RootMarkupSnapshot.cs | Implements shared markup/document storage and per-component node-range views |
| src/bunit/Rendering/Internal/NodeRangeList.cs | Adds INodeList view over sibling node ranges |
| src/bunit/Rendering/Internal/Htmlizer.cs | Records component markup ranges while generating shared markup |
| src/bunit/Rendering/Internal/ComponentMarkupRange.cs | Adds range struct + Htmlizer result record type |
| src/bunit/Rendering/BunitRenderer.cs | Adds root snapshot regeneration + markup-updated propagation + private-document tracking |
| src/bunit/Extensions/RenderedComponentTreeExtensions.cs | Adds public component tree navigation extensions |
| src/bunit/Extensions/ElementOwningComponentExtensions.cs | Adds public DOM-node-to-rendered-component lookup extensions |
| docs/site/docs/verification/index.md | Adds verification docs entry for “find owning component” |
| docs/site/docs/verification/find-owning-component.md | New documentation for GetOwningComponent* and behavior details |
| docs/site/docs/toc.md | Adds doc TOC link for new verification topic |
| docs/site/docs/interaction/trigger-event-handlers.md | Documents cross-component event bubbling behavior |
| CHANGELOG.md | Notes new APIs and breaking behavior change under Unreleased |
Review details
- Files reviewed: 35/35 changed files
- Comments generated: 2
- Review effort level: Lite
💡 Add a code-review agent skill or configure MCP servers for context-aware, tailored reviews. Learn more in the docs.
| /// <summary> | ||
| /// Extension methods for navigating the component tree an | ||
| /// <see cref="IRenderedComponent{TComponent}"/> is part of. |
c595ed5 to
ccf02e8
Compare
|
@egil I started to have a For sure we need a release party somewhere :D |
ccf02e8 to
95834f3
Compare
95834f3 to
f073f0e
Compare
Shared Render Tree
This went a bit bigger than initially anticipated. Basically this PR would solve and close:
So it is kind of a heavy-hitter code-wise but also feature-wise.
And has a major breaking behavioural change.
## The state before
Every
IRenderedComponentproduced its own markup and parsed it into its own AngleSharp document:MarkupwasHtmlizer.GetHtml(ComponentId, renderer),NodeswashtmlParser.Parse(Markup), andBunitHtmlParser.Parseopens a freshIDocumentper call.So parent hat its DOM including all children, but child-elements only have theirs (plus eventually their children). So this PR will allow all 3 attached scenarios / or fix bugs.
The new now
One markup string and one document per render tree, generated by its root.
Htmlizerrecords the[start, end)character range each component occupies while itbuilds the string, in post-order. We had this once on another feature branch. So nothing new.
Markupthen is sliced regarding the components position in the overall tree.Nodesis a view over a run of sibling nodes in the shared document.Edge cases
Imagine a component hat has only text
where
TextOnlyChilddoes only contain text, no HTML element. The system then can't really tell what is the child here. The text merged with the parent. So they keep the current behavior more or less, but can't be used with the newGetOwingComponent- there is nothing to latch on here.Other things
OnMarkupUpdatedtriggers now more often - basically because of going up and down the treev3?Now we have a breaking change in behaviour. We might want go ahead with
v3where it is fine to have a breaking change behavioural wise. Breaking change being if a user "clicks" on a child element, it does in fact now bubble up! I do feel that is the better approach and more obvious, but breaks anyway. Depending on how we go ahead, we can make that clear in the docs.