Skip to content

Get the inner text from a partial render tree - #267

Merged
FlorianRappl merged 4 commits into
AngleSharp:develfrom
michaelthyregod:feature/innertext-render-tree
Nov 4, 2026
Merged

FlorianRappl merged 4 commits into
AngleSharp:develfrom
michaelthyregod:feature/innertext-render-tree

Conversation

@michaelthyregod

Copy link
Copy Markdown
Contributor

Types of Changes

Prerequisites

Please make sure you can check the following two boxes:

  • I have read the CONTRIBUTING document
  • My code follows the code style of this project

Contribution Type

What types of changes does your code introduce? Put an x in all the boxes that apply:

  • Bug fix (non-breaking change which fixes an issue, please reference the issue id)
  • New feature (non-breaking change which adds functionality, make sure to open an associated issue first)
  • Breaking change (fix or feature that would cause existing functionality to change)
  • My change requires a change to the documentation
  • I have updated the documentation accordingly
  • I have added tests to cover my changes
  • All new and existing tests passed

Description

The second step of #262, after #266: GetInnerText is computed from a render tree whose children are only built when they are read, so the content of hidden elements is never styled. It replaces the separate style walk GetInnerText had.

The change

  • Partial render tree: an internal RenderTreeBuilder.RenderPartial returns a node that builds its children on first read. Content gives the children without ::before / ::after, which GetInnerText reads; Children adds the pseudo-elements. Without this split, the default sheet's br::before { content: '\A' } would cascade two pseudo-elements for every <br>. Children are published with Interlocked.CompareExchange, so concurrent first reads get the same ones. Render() is unchanged and still builds the complete tree.
  • Releasing walked parts: the tree of GetInnerText(IElement) is the walk's own, so once an element is walked its children are dropped (its siblings stay, as the next sibling decides on a tab or line feed). Otherwise the whole tree stays reachable until the end and every garbage collection has to copy it: without this, the same change was 18–22% slower, all of it in GC pauses.
  • Shadow DOM, as in the spec: a shadow host gives the text of its own children, found through their render nodes in its slots. The shadow tree itself is left out, and so are children not assigned to a slot (they are not rendered). Before, the walk read the light children, assigned or not.
  • New API: GetInnerText(this ElementRenderNode node) gives the text of a rendered element, from the tree it is given.
  • The algorithm moved unchanged from ElementExtensions to an internal RenderTree/InnerText.cs.
  • Tests: shadow hosts (default and named slots, no slot, unassigned children); generated content left out; the text of a render node equals the text of its element, including a shadow host and a table; a completed partial tree equals Render(), also when Content is read first; concurrent first reads (synchronised with a Barrier).

The text is the same as before on both InnerTextBenchmarks documents (68,868 and 757,568 characters, same SHA-256).

Benchmarks

Paired builds of the same benchmark code against devel (a1cb93c), alternating which goes first each round. BenchmarkDotNet 0.15.8, net10.0, AngleSharp 1.9.0 for both, pinned to the performance cores of an Intel Core Ultra 7 165H. Median of 6 rounds:

Case devel this PR Change Allocated
InnerTextBenchmarks.GetInnerText_RealWorld_Length 75,123 μs 75,256 μs +0.2% 38,990 → 41,656 KB (+6.8%)
InnerTextBenchmarks.GetInnerText_SelectorHeavy_Length 618,604 μs 563,609 μs −8.9% 308,142 → 336,860 KB (+9.3%)
DeepTreeBenchmarks.InnerText, 100 / 500 levels 347 / 1,765 μs 341 / 1,653 μs −1.8% / −6.3% 492 → 516 / 2,310 → 2,444 KB
DeepTreeBenchmarks.IsVisible, 100 / 500 levels 9.96 / 48.3 μs 10.0 / 48.3 μs +0.5% / −0.1% same
CssCascadeBenchmarks.RenderTree 4,358 μs 4,399 μs +0.9% 4,994 → 5,015 KB
CssCascadeBenchmarks.ComputedStyle 4,145 μs 4,116 μs −0.7% same

SelectorHeavy is faster in every round (−6.5% to −12.1%); the real-world document stays within ±3% in every round. The extra allocation is the render nodes and their lists, which are released while walking. RenderTree and ComputedStyle change sign between rounds, within the ±5% that single rounds of the same build differ by.

I'll add the CHANGELOG lines under 1.4.0 in a separate commit once this PR has its number: this one, and the one for #266, which was merged before its CHANGELOG commit reached the branch.

🤖 Generated with Claude Code

GetInnerText computed the styles itself, apart from the render tree, so
both had to be optimized (AngleSharp#262). It now walks a render tree of the
element, which is only built as far as it is read.

- Build the children of a partial render node on first read, and its
  ::before and ::after only when all children are read
- Walk the element's own children, as the spec does: the shadow tree of
  a host is left out, and its light children count if they are slotted
- Add GetInnerText for a render node, e.g. of an existing render tree
- Add tests for shadow hosts, generated content, completing a partial
  tree, and concurrent first reads
@FlorianRappl FlorianRappl added this to the v1.4.0 milestone Nov 4, 2026

@FlorianRappl FlorianRappl left a comment

Copy link
Copy Markdown
Contributor

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

LGTM!

@FlorianRappl
FlorianRappl merged commit bae919a into AngleSharp:devel Nov 4, 2026
5 checks passed
@michaelthyregod
michaelthyregod deleted the feature/innertext-render-tree branch November 5, 2026 04:52
FlorianRappl pushed a commit that referenced this pull request Nov 5, 2026
A complete render node is back to the fields it had before #267: the children field holds either the children or, in a partial render tree, what builds them on first read.
Sign up for free to join this conversation on GitHub. Already have an account? Sign in to comment

Labels

None yet

Projects

None yet

Development

Successfully merging this pull request may close these issues.

2 participants