Skip to content

Walk the tree once per form.elements read instead of re-running a LINQ query - #1350

Merged
FlorianRappl merged 6 commits into
AngleSharp:develfrom
lahma:perf/form-controls-collection
Sep 14, 2026
Merged

FlorianRappl merged 6 commits into
AngleSharp:develfrom
lahma:perf/form-controls-collection

Conversation

@lahma

@lahma lahma commented Sep 14, 2026

Copy link
Copy Markdown
Contributor

HtmlFormControlsCollection held one deferred LINQ query and re-executed it on every member:

_elements = root.GetNodes<HtmlFormControlElement>().Where(m => { ... m.Form ... });
...
public Int32 Length => _elements.Count();
public HtmlFormControlElement this[Int32 index] => _elements.GetItemByIndex(index);

So form.elements.length was a whole-document NodeEnumerable walk — a Stack<EnumerationFrame>, an
OfType iterator, a Where iterator and a closure — and form.elements[i] was another one. A loop over
length and [i], which is what a page does, paid all of it twice per iteration.

This keeps only the root to walk and the form to match against, and drives all four members — the length,
the indexed read, the named read and enumeration — through one struct walker and one predicate. The
collection stays exactly as live as it was: nothing is memoized, no result is materialized, every read
still queries the tree as it is at that moment.

The walker is the technique already in ElementTreeEnumerator — parent links plus a stack of child
indices, so the stack holds value types and is bounded by the tree's depth rather than its width — with
the index stack rented from ArrayPool<Int32>, which is what takes a read to zero allocations.

Why not reuse ElementTreeEnumerator itself, since it is most of this: it yields its root (and a
<fieldset> is itself a form control, so it must not appear in its own collection), its element is
untyped and unfiltered, and it allocates its index stack with new Int32[16] per walk. Composing on it
would have been about a third of the code and left one array per read. Making it pooled would have
fixed QuerySelector/QuerySelectorAll too, but it would push a disposal contract into the selector
engine, where a missed or doubled Dispose hands one buffer to two walkers and corrupts both silently.
That is not a drive-by change, so it is not in this one.

Semantics

Unchanged, and the equivalences are worth stating because they are what a reviewer would otherwise have
to re-derive:

  • NodeEnumerable yields descendants only, never the starting node, which is what the new walker
    does — a fieldset is excluded from its own collection, as TheControlsMustRootAtTheFieldsetElement
    already pinned.
  • Descending only into elements finds every element descendant, because a form control's ancestors inside
    an element-rooted subtree are always elements. That makes the walk equivalent to
    NodeEnumerable.OfType<HtmlFormControlElement>() without visiting character data.
  • The named read reproduces CollectionExtensions.GetElementById's rule exactly — an id match always
    wins, even over a name match seen earlier in tree order.
  • The indexed read still throws ArgumentOutOfRangeException outside the range, as GetItemByIndex did.
  • input type=image is still excluded; it is a listed element but a submit button rather than a member
    of form.elements.

Tests

Eight new cases in LiveCollectionTests, written and run against the unmodified base first so they
are equivalence tests rather than tests of the new code. Then each was confirmed load-bearing by breaking
the implementation on purpose in five ways — dropping the image exclusion, letting a name match overwrite
an id match, yielding the root, rooting the walk at the form instead of the document element, returning
null instead of throwing, and memoizing Length — and each break fired on exactly its own property.
The memoizing break is caught by HtmlFormLiveCollectionFollowsAppendRemoveAndReparent, which is the one
that matters most here: it is what says the collection is still live.

4075 -> 4083 cases, 0 failed, in both text-source modes (prefetched=false and prefetched=true).
The library builds warning-free on all five TFMs.

Measured

Paired A/B against eb3925b9d, 6 rounds, arms alternating every round, DefaultJob, idle box, each
arm from its own worktree — the baseline carrying this PR's new benchmark file untracked, so both arms
run the same rows and differ by exactly HtmlFormControlsCollection.cs. The median is of the per-round
percentage differences and the interval is a percentile bootstrap over them.

Row median 95 % CI allocated, base → cand
FormControlsCollectionBenchmark.FormElementsLength −56.05 % [−57.10 %, −54.43 %] 61.72 KB → 0 B
FormControlsCollectionBenchmark.FormElementsIndexed −53.83 % [−56.00 %, −52.45 %] 67.19 KB → 0 B
FormControlsCollectionBenchmark.FormElementsNamed −52.25 % [−54.89 %, −51.80 %] 67.19 KB → 0 B
FormControlsCollectionBenchmark.FormElementsEnumerate −49.02 % [−50.40 %, −44.29 %] 67.19 KB → 5,600 B
CollectionReadBenchmark.FormElementsNamedLookup −54.01 % [−55.21 %, −53.36 %] 688 B → 0 B
FormControlsCollectionBenchmark.GetElementsByTagNameControl −0.93 % [−6.03 %, +0.27 %] unchanged
CollectionReadBenchmark.QuerySelectorAllControl +0.94 % [−2.85 %, +1.63 %] unchanged
CollectionReadBenchmark.GetElementsByClassNameSweep −2.68 % [−4.19 %, +14.08 %] unchanged
CollectionReadBenchmark.GetElementsByTagNameSweep +0.25 % [−2.66 %, +23.46 %] unchanged
CollectionReadBenchmark.DocumentFormsRead −0.87 % [−5.57 %, −0.19 %] unchanged

FormElementsNamedLookup is not a row this PR wrote — it came with #1345 and hits this collection
through a different member. It reads the same magnitude independently, which is the closest thing to a
second opinion a single box can give.

FormElementsEnumerate keeps 5,600 B because enumerating through IEnumerable<IHtmlElement> boxes the
struct enumerator once per enumeration; that is the interface's cost, not the walk's, and the other three
rows show what the walk itself now costs. DocumentFormsRead's −0.87 % is not claimed: its interval
only just excludes zero, which on this box is inside the band where a six-round paired bootstrap cannot
be trusted on its own.

Follow-up, deliberately not in this PR

HtmlFormControlElement.Form is the larger remaining cost and it is untouched here. For a control
carrying form="id" it walks to the root and then does root.ChildNodes.GetElementById(formId) — the
recursive scan — once per candidate per read, and there is no id index anywhere in this DOM
(Document.GetElementById is literally that same scan). So form.elements.length is O(K·N) for K
explicitly-associated controls. The obvious pre-filter — a candidate with no form attribute must have
the form as an ancestor — needs care around form="", a non-form id target and a disconnected root, all
of which FormOwnerTests already pins, and it touches files that currently have another PR open on them.
Happy to take it as a separate change.

🤖 Generated with Claude Code

https://claude.ai/code/session_01WQwq9NqYqG3kk9M8CKdfdJ

lahma and others added 5 commits September 14, 2026 09:31
form.elements is covered today by a handful of cases spread over DOMActions
and FormOwner - an image input on its own, a control assigned by form="id",
a fieldset-rooted collection - and by nothing that states the traversal
itself. Adds seven cases beside the existing form collection test:

- tree order over the whole document, not the form's descendants first:
  a control associated by form="id" that precedes the form precedes the
  controls inside it, and one nested in a div inside the form is found.
- a control inside a form but owned by another form is in the other form's
  collection and in neither's twice.
- an <input type=image> between two controls is skipped, so everything
  after it shifts down by one index.
- the named read prefers an id anywhere over a name seen earlier, takes the
  first of two id matches, and otherwise the first name match in tree order.
- an index outside the range throws ArgumentOutOfRangeException, either end.
- appending, re-parenting out of the form, re-parenting back ahead of an
  existing control, and removing all change what the SAME collection object
  answers on the next read - the property that makes it a live collection.

All seven pass as they stand; they describe the behaviour the current
implementation already has.

Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01WQwq9NqYqG3kk9M8CKdfdJ
HtmlFormControlsCollection captured one deferred LINQ query in its
constructor - root.GetNodes<HtmlFormControlElement>().Where(closure) - and
every member re-executed it: Length was Count(), the indexed read was
GetItemByIndex, the named read was GetElementById, and both GetEnumerator
overloads handed out its iterator. So a single form.elements.length was a
whole-document descendant walk plus a NodeEnumerable, a Stack<T> of frames
that grows with the depth of the tree, an OfType iterator, two Where
iterators and the captured closure - about half a kilobyte of garbage per
read, and form.elements[i] was another one.

Replaces the query with a struct walker over the same tree. It uses the
technique already in ElementTreeEnumerator - parent links plus a stack of
child indices, bounded by the depth of the tree rather than its width -
with three differences that make it its own walker rather than a reuse of
that one: the root is never yielded (a fieldset is itself a form control,
and fieldset.elements is rooted at the fieldset), the element it yields is
typed and filtered by the one ownership predicate, and the index stack is
rented from ArrayPool<Int32>.Shared rather than allocated, so a read
allocates nothing at all. The rent is safe to take here because every
caller of the walk is either in this file or a foreach over one of the
interface enumerators, and both dispose it.

Length, the indexed read, the named read and enumeration now all drive
that one walk and that one predicate. The collection stays live: it holds
only the root and the form, nothing is memoized, and every read re-walks
the tree as it is at that moment.

Behaviour is unchanged, deliberately including the parts that are easy to
lose: the walk is rooted at the document element for a form and at the
fieldset for a fieldset, so tree order is document order and a control
assigned by form="id" ahead of the form comes first; <input type=image> is
excluded; an out-of-range index throws ArgumentOutOfRangeException as
GetItemByIndex did; and the named read keeps the single-pass id-beats-name
rule of CollectionExtensions.GetElementById.

What this does NOT change: HtmlFormControlElement.Form, which the predicate
calls once per candidate and which, for a control carrying form="id", runs
a whole-tree GetElementById of its own. That is the larger cost on this
path and it wants its own change.

Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01WQwq9NqYqG3kk9M8CKdfdJ
There is a collection-read gate already (CollectionReadBenchmark, beside
QuerySelectorBenchmark), but its only form.elements row is a single named
lookup - nothing there reads a length, an index or an enumeration, which is
where the traversal cost is. This adds the class the form-controls
collection needs of its own.

One document for every row, built once in GlobalSetup: every row is a read,
so nothing a row does decides a later row's cost, and sharing the document
is what makes the control row comparable - it walks the same tree. The
fixture is page.html with a hundred controls appended, spread over fieldsets
and rows so the walk has depth as well as width and so most of the document
sits ahead of the form in tree order, which is where a document-rooted walk
spends its time on a real page.

Rows: a GetElementsByTagName control (a live collection over the same
document, on a code path form.elements does not share - it is the baseline
and must not move), a length loop, an indexed loop over every index, a named
read of the last control's id (the worst case, which walks to the end of the
document), and a full enumeration through IEnumerable<IHtmlElement> so the
interface enumerator is measured as well as the walk.

No control is associated by form="id": resolving that runs a whole-tree
GetElementById per candidate per read inside HtmlFormControlElement.Form,
which is a separate cost on a separate path, and including one would hide
the traversal these rows measure behind it.

Ran once locally with --job short to confirm all five rows execute. The
numbers from that run are not meaningful and are not quoted.

Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01WQwq9NqYqG3kk9M8CKdfdJ
fieldset.elements hands HtmlFormControlsCollection a null form - so the
collection is the controls under the fieldset that have no form owner
either - and roots the walk at the fieldset, which is itself a form control
and therefore never a member of its own collection. Nothing covered the
null-form case; every fieldset test in the suite sits inside a form.

Passes on the implementation before this branch as well as after it.

Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01WQwq9NqYqG3kk9M8CKdfdJ
Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01WQwq9NqYqG3kk9M8CKdfdJ
@lahma
lahma marked this pull request as ready for review September 14, 2026 07:48
Comment thread CHANGELOG.md Outdated
@FlorianRappl FlorianRappl added this to the v1.8.2 milestone Sep 14, 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.

This is fine but the changelog should be less detailed - technical details are always to be found in the referenced issue or PR

Review feedback: the changelog should not carry technical detail, which
belongs in the referenced issue or pull request.

Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01WQwq9NqYqG3kk9M8CKdfdJ
lahma added a commit to lahma/AngleSharp that referenced this pull request Sep 14, 2026
Same review feedback as AngleSharp#1350: the changelog should not carry technical
detail, which belongs in the referenced issue or pull request.

Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01WQwq9NqYqG3kk9M8CKdfdJ
@FlorianRappl
FlorianRappl merged commit ea6d390 into AngleSharp:devel Sep 14, 2026
4 checks passed
@lahma
lahma deleted the perf/form-controls-collection branch September 14, 2026 09:44
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