Walk the tree once per form.elements read instead of re-running a LINQ query - #1350
Merged
FlorianRappl merged 6 commits intoSep 14, 2026
Merged
Conversation
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
marked this pull request as ready for review
September 14, 2026 07:48
FlorianRappl
left a comment
Contributor
There was a problem hiding this comment.
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
approved these changes
Sep 14, 2026
This file contains hidden or bidirectional Unicode text that may be interpreted or compiled differently than what appears below. To review, open the file in an editor that reveals hidden Unicode characters.
Learn more about bidirectional Unicode characters
Sign up for free
to join this conversation on GitHub.
Already have an account?
Sign in to comment
Add this suggestion to a batch that can be applied as a single commit.This suggestion is invalid because no changes were made to the code.Suggestions cannot be applied while the pull request is closed.Suggestions cannot be applied while viewing a subset of changes.Only one suggestion per line can be applied in a batch.Add this suggestion to a batch that can be applied as a single commit.Applying suggestions on deleted lines is not supported.You must change the existing code in this line in order to create a valid suggestion.Outdated suggestions cannot be applied.This suggestion has been applied or marked resolved.Suggestions cannot be applied from pending reviews.Suggestions cannot be applied on multi-line comments.Suggestions cannot be applied while the pull request is queued to merge.Suggestion cannot be applied right now. Please check back later.
HtmlFormControlsCollectionheld one deferred LINQ query and re-executed it on every member:So
form.elements.lengthwas a whole-documentNodeEnumerablewalk — aStack<EnumerationFrame>, anOfTypeiterator, aWhereiterator and a closure — andform.elements[i]was another one. A loop overlengthand[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
structwalker and one predicate. Thecollection 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 childindices, 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
ElementTreeEnumeratoritself, 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 isuntyped and unfiltered, and it allocates its index stack with
new Int32[16]per walk. Composing on itwould have been about a third of the code and left one array per read. Making it pooled would have
fixed
QuerySelector/QuerySelectorAlltoo, but it would push a disposal contract into the selectorengine, where a missed or doubled
Disposehands 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:
NodeEnumerableyields descendants only, never the starting node, which is what the new walkerdoes — a fieldset is excluded from its own collection, as
TheControlsMustRootAtTheFieldsetElementalready pinned.
an element-rooted subtree are always elements. That makes the walk equivalent to
NodeEnumerable.OfType<HtmlFormControlElement>()without visiting character data.CollectionExtensions.GetElementById's rule exactly — an id match alwayswins, even over a name match seen earlier in tree order.
ArgumentOutOfRangeExceptionoutside the range, asGetItemByIndexdid.input type=imageis still excluded; it is a listed element but a submit button rather than a memberof
form.elements.Tests
Eight new cases in
LiveCollectionTests, written and run against the unmodified base first so theyare 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
nullinstead of throwing, and memoizingLength— and each break fired on exactly its own property.The memoizing break is caught by
HtmlFormLiveCollectionFollowsAppendRemoveAndReparent, which is the onethat matters most here: it is what says the collection is still live.
4075 -> 4083cases, 0 failed, in both text-source modes (prefetched=falseandprefetched=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, eacharm 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-roundpercentage differences and the interval is a percentile bootstrap over them.
FormControlsCollectionBenchmark.FormElementsLengthFormControlsCollectionBenchmark.FormElementsIndexedFormControlsCollectionBenchmark.FormElementsNamedFormControlsCollectionBenchmark.FormElementsEnumerateCollectionReadBenchmark.FormElementsNamedLookupFormControlsCollectionBenchmark.GetElementsByTagNameControlCollectionReadBenchmark.QuerySelectorAllControlCollectionReadBenchmark.GetElementsByClassNameSweepCollectionReadBenchmark.GetElementsByTagNameSweepCollectionReadBenchmark.DocumentFormsReadFormElementsNamedLookupis not a row this PR wrote — it came with #1345 and hits this collectionthrough a different member. It reads the same magnitude independently, which is the closest thing to a
second opinion a single box can give.
FormElementsEnumeratekeeps 5,600 B because enumerating throughIEnumerable<IHtmlElement>boxes thestruct 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 intervalonly 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.Formis the larger remaining cost and it is untouched here. For a controlcarrying
form="id"it walks to the root and then doesroot.ChildNodes.GetElementById(formId)— therecursive scan — once per candidate per read, and there is no id index anywhere in this DOM
(
Document.GetElementByIdis literally that same scan). Soform.elements.lengthis O(K·N) for Kexplicitly-associated controls. The obvious pre-filter — a candidate with no
formattribute must havethe form as an ancestor — needs care around
form="", a non-form id target and a disconnected root, allof which
FormOwnerTestsalready 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