Skip to content

Bug sweep#594

Merged
SimonCropp merged 14 commits into
mainfrom
Bug-Sweep
Jul 20, 2026
Merged

Bug sweep#594
SimonCropp merged 14 commits into
mainfrom
Bug-Sweep

Conversation

@SimonCropp

Copy link
Copy Markdown
Member

Bug-Sweep branch summary

Commits

# Commit Type
1 Fix DescendantsAndSelf yielding top-level nodes twice bug
2 Fix Descendants including the nodes themselves bug
3 Fix ScrubEmptyDivs throwing when a scrubbed div is in the passed node list bug (crash)
4 Fix TryScrubDiv relocating unwrapped content to the end of the parent bug (data)
5 Fix TryScrubDiv throwing on a div with no parent bug (crash)
6 Replace InnerHtml round trip with a direct trailing whitespace trim bug + perf
7 Make TryScrubDiv return whether the div was actually scrubbed breaking
8 Use ordinal comparison for document detection perf
9 Detect documents that do not start with an exact DOCTYPE or <html> bug (data)
10 Do not rewrite attributes that are not changing perf
11 Cache the cache buster regex and skip values that cannot match perf
12 Remove redundant flush and make Initialize atomic cleanup
13 Add benchmarks covering the performance fixes infra
14 Use an empty document as the fragment parse context perf

Production changes

Three files, +133 / −25:

File Δ What
src/Verify.AngleSharp/HtmlPrettyPrint.cs +124 / −16 Rewrote TryScrubDiv; added TrimTrailingWhitespace, TryGetOnlyElement, IsDocument; cached regex; skipped no-op attribute writes
src/Verify.AngleSharp/AngleSharpExtensions.cs +5 / −5 DescendantsAndSelf no longer double-yields; Descendants no longer includes self
src/Verify.AngleSharp/VerifyAngleSharpDiffing.cs +4 / −4 Initialize via Interlocked.Exchange

Tests

+24 tests across 3 new files, all of which fail against the pre-fix code:

  • src/Tests/ScrubEmptyDivsTests.cs — 122 lines, 15 tests (unwrap placement, whitespace, nesting, text preservation, return contract, detached div)
  • src/Tests/AngleSharpExtensionsTests.cs — 51 lines, 4 tests
  • src/Tests/PrettyPrintHtmlTests.cs — 58 lines, 4 Verify tests + verified files
  • src/Tests/ScrubAspCacheBusterTagHelperTests.cs — +1 test for the regex pre-check

Infrastructure

New src/Benchmarks project (9 files, 356 lines), added to the solution and Directory.Packages.props. Legacy.cs holds verbatim copies of the pre-fix implementations so every perf claim is a measured before/after rather than an assertion. BenchmarkDotNet.Artifacts/ gitignored.

Impact

  • Silent data loss fixed — commits 4 and 9 were discarding or misplacing markup in verified output
  • Two crashes fixed — commits 3 and 5, both on documented entry points
  • 7.3x / 2.9x faster scrubbing with 17x less allocation (commits 10, 11); empty-div scrub cost effectively eliminated (commit 6)
  • Breaking — commit 7 changes TryScrubDiv's return meaning; commits 4, 6, 9 change scrubbed output, so downstream verified files will need re-accepting

INodeList.DescendantsAndSelf() yielded each top-level node, then called
INode.DescendantsAndSelf() on it, which already includes the node itself.
Every top-level node was returned twice.
INodeList.Descendants() called INode.DescendantsAndSelf() on each node,
so the top-level nodes were returned alongside their descendants.
… list

The descendant walk is lazy, so removing a div that is a direct member of
the enumerated list invalidated the enumerator and threw
InvalidOperationException. Materialize the walk before scrubbing.
The single-child branch appended the child to the parent's end rather than
replacing the div in place, and left the emptied div behind, so
<section><div><p>x</p></div><footer/></section> became
<section><div></div><footer/><p>x</p></section>.

It also tested Children.Length (elements) while moving FirstChild (nodes),
so any leading whitespace meant the wrong node was moved. Unwrapping now
requires a single element child with only whitespace around it, which also
avoids discarding text.
element.Parent was dereferenced with a null-forgiving operator, so
unwrapping a detached div threw NullReferenceException.
Assigning div.InnerHtml serialized and re-parsed the whole subtree for every
div. That replaced every descendant node, so nodes already collected for
scrubbing were orphaned and nested divs were left unscrubbed, and it cost a
full parse per div.
Previously it returned true for any div without attributes, including divs
it left untouched, and false for divs it had already trimmed. The result now
means what the Try prefix implies: true only when the div was removed or
unwrapped.

Behaviour change for callers reading the return value.
Culture-aware comparison routes markup prefix checks through ICU collation
for no benefit. The class already defines an OrdinalIgnoreCase comparer.
The prefix check only matched "<!DOCTYPE html>" and "<html>" literally, so a
document with attributes on the html element, leading whitespace, or a legacy
doctype identifier fell through to fragment parsing. Fragment parsing drops
the html, head, and body elements, so <html lang="en"> collapsed to just its
body content. The ASP.NET Core default layout uses <html lang="en">.
ScrubAspCacheBusterTagHelper returned attr.Value for every attribute it did
not care about, and the null check treated that as a value to write, so every
attribute in the document was reassigned. Return null instead, and skip the
write when the new value matches the old one.
Regex.Replace's static overload does a lock protected cache lookup on every
call, and it ran for every href and src in the document. Hold a single Regex
instance, and skip the match entirely when the value contains no literal v=,
which the pattern requires.
StringWriter writes straight to its StringBuilder, so the explicit Flush
before disposal did nothing. Initialize used a check then set on a static
property, which two callers could both pass.
Measures each performance change against a verbatim copy of the code as it
was before, so the deltas are real rather than asserted. Also measures the
throwaway context document that fragment parsing allocates, which has not
been changed.
Fragment parsing needs a context element, which meant parsing
"<html><body></body></html>" on every call. An empty document yields the same
html, head, and body for less work, and produces identical fragment output.
@SimonCropp SimonCropp added this to the 5.0.0 milestone Jul 19, 2026
@SimonCropp
SimonCropp merged commit f734750 into main Jul 20, 2026
6 checks passed
@SimonCropp
SimonCropp deleted the Bug-Sweep branch July 20, 2026 00:02
Sign up for free to join this conversation on GitHub. Already have an account? Sign in to comment

Labels

None yet

Development

Successfully merging this pull request may close these issues.

1 participant