From 1584135ec87d60c3152f5670d3ced900dd760c54 Mon Sep 17 00:00:00 2001 From: Michael Thyregod <2386496+michaelthyregod@users.noreply.github.com> Date: Mon, 2 Nov 2026 15:00:17 +0100 Subject: [PATCH 1/3] Remember inherited lookups on the way up A computed style looks inherited declarations up through the snapshots of its parents. A property that no ancestor declares, e.g. visibility, was looked up through every ancestor for every element, so GetInnerText and IsVisible on the render tree grew quadratically with the depth (#262). - Keep the nearest declaration found for a name, or that there is none, on the snapshots passed; they never change, so each one is walked once per name. The entries are copied on write and published atomically - Leave the lookup of explicitly inherited declarations as it was - Add tests for deep chains, read deepest first and twice - Add DeepTreeBenchmarks for GetInnerText and IsVisible at 100 and 500 levels --- .../Styling/InheritanceChainTests.cs | 73 +++++++++++ .../Dom/Internal/CssStyleDeclaration.cs | 113 +++++++++++++++++- .../DeepTreeBenchmarks.cs | 73 +++++++++++ 3 files changed, 258 insertions(+), 1 deletion(-) create mode 100644 src/AngleSharp.Performance.Css/DeepTreeBenchmarks.cs diff --git a/src/AngleSharp.Css.Tests/Styling/InheritanceChainTests.cs b/src/AngleSharp.Css.Tests/Styling/InheritanceChainTests.cs index 0fcdb8f4..c9c148b8 100644 --- a/src/AngleSharp.Css.Tests/Styling/InheritanceChainTests.cs +++ b/src/AngleSharp.Css.Tests/Styling/InheritanceChainTests.cs @@ -6,6 +6,7 @@ namespace AngleSharp.Css.Tests.Styling using AngleSharp.Dom; using AngleSharp.Html.Parser; using NUnit.Framework; + using System; using System.Collections.Generic; using System.Linq; @@ -143,5 +144,77 @@ public void MutatingAChildStyleDoesNotChangeItsParent() Assert.AreEqual("rgba(0, 0, 255, 1)", child.ComputedStyle.GetPropertyValue("color")); Assert.AreEqual("rgba(255, 0, 0, 1)", parent.ComputedStyle.GetPropertyValue("color")); } + + // Lookups through the parents are remembered on the way, so the levels are read deepest + // first and then again: a value remembered below a declaration must not leak above it. + [Test] + public void DeepChainInheritsFromTheNearestDeclaringAncestor() + { + var levels = BuildChain(300, level => level switch + { + 100 => "color: red", + 200 => "color: blue", + _ => null, + }); + + for (var pass = 0; pass < 2; pass++) + { + for (var level = levels.Count - 1; level >= 0; level--) + { + // above the first declaration nothing is inherited (a computed style leaves it empty) + var expected = level >= 200 ? "rgba(0, 0, 255, 1)" : level >= 100 ? "rgba(255, 0, 0, 1)" : String.Empty; + Assert.AreEqual(expected, levels[level].ComputedStyle.GetPropertyValue("color"), $"level {level}, pass {pass}"); + } + } + } + + [Test] + public void DeepChainWithoutADeclarationKeepsTheInitialValue() + { + var levels = BuildChain(300, level => level == 150 ? "visibility: hidden" : null); + + for (var pass = 0; pass < 2; pass++) + { + for (var level = levels.Count - 1; level >= 0; level--) + { + Assert.AreEqual(level < 150, levels[level].IsVisible(), $"level {level}, pass {pass}"); + } + } + + Assert.AreEqual(String.Empty, levels[0].ComputedStyle.GetPropertyValue("--undeclared")); + } + + // a chain of divs built with the DOM API, with the given inline style per level + private static List BuildChain(int depth, Func style) + { + var document = Parse(""); + IElement current = document.Body; + + for (var level = 0; level < depth; level++) + { + var child = document.CreateElement("div"); + child.Id = "level" + level; + + if (style(level) is string css) + { + child.SetAttribute("style", css); + } + + current.AppendChild(child); + current = child; + } + + current.TextContent = "x"; + var levels = new List(); + var node = Find(document.DefaultView.Render(), "level0"); + + while (node is not null) + { + levels.Add(node); + node = node.Children.OfType().FirstOrDefault(); + } + + return levels; + } } } diff --git a/src/AngleSharp.Css/Dom/Internal/CssStyleDeclaration.cs b/src/AngleSharp.Css/Dom/Internal/CssStyleDeclaration.cs index e6dd6ab1..f0fac4d8 100644 --- a/src/AngleSharp.Css/Dom/Internal/CssStyleDeclaration.cs +++ b/src/AngleSharp.Css/Dom/Internal/CssStyleDeclaration.cs @@ -10,6 +10,7 @@ namespace AngleSharp.Css.Dom using System.Collections.Generic; using System.IO; using System.Linq; + using System.Threading; /// /// Represents a single CSS declaration block. @@ -582,6 +583,8 @@ private sealed class Snapshot private readonly Snapshot _parent; private readonly HashSet _forced; private List _merged; + // the names looked up through this snapshot, with the nearest declaration found + private NearestEntry[] _nearest; public Snapshot(List declarations, Dictionary index, Snapshot parent, HashSet forced) { @@ -602,8 +605,15 @@ public Snapshot(List declarations, Dictionary index /// public static ICssProperty Inherit(Snapshot parent, HashSet forced, String name) { + if (forced is null || !forced.Contains(name)) + { + // the nearest declaration decides, and a non-inheritable one is not taken + var nearest = parent.Nearest(name); + return nearest is not null && nearest.CanBeInherited ? nearest : null; + } + // a non-inheritable declaration is only taken if forced on every level - var allForced = forced is not null && forced.Contains(name); + var allForced = true; for (var current = parent; current is not null; current = current._parent) { @@ -667,6 +677,107 @@ public static void Merge(List declarations, Dictionary + /// Finds the nearest declaration with the given name, this snapshot's own or an + /// ancestor's. The chain is as deep as the DOM, and a name declared nowhere (e.g. + /// visibility) would be looked up through all of it for every element, so the + /// answer is kept on the snapshots passed: they never change. + /// + private ICssProperty Nearest(String name) + { + var result = default(ICssProperty); + var stop = default(Snapshot); + + for (var current = this; current is not null; current = current._parent) + { + result = current.FindOwn(name); + + if (result is not null || current.TryGetNearest(name, out result)) + { + stop = current; + break; + } + } + + for (var current = this; current != stop; current = current._parent) + { + current.AddNearest(name, result); + } + + return result; + } + + private Boolean TryGetNearest(String name, out ICssProperty declaration) + { + var entries = Volatile.Read(ref _nearest); + + if (entries is not null) + { + for (var i = 0; i < entries.Length; i++) + { + if (String.Equals(entries[i].Name, name, StringComparison.Ordinal)) + { + declaration = entries[i].Declaration; + return true; + } + } + } + + declaration = null; + return false; + } + + private void AddNearest(String name, ICssProperty declaration) + { + // only a few names are looked up, so the entries are copied on write; a + // concurrent reader of the same computed style sees either list + var entries = Volatile.Read(ref _nearest); + + while (true) + { + var count = entries?.Length ?? 0; + + for (var i = 0; i < count; i++) + { + if (String.Equals(entries[i].Name, name, StringComparison.Ordinal)) + { + return; + } + } + + var updated = new NearestEntry[count + 1]; + + if (count > 0) + { + Array.Copy(entries, updated, count); + } + + updated[count] = new NearestEntry(name, declaration); + var previous = Interlocked.CompareExchange(ref _nearest, updated, entries); + + if (previous == entries) + { + return; + } + + entries = previous; + } + } + + private readonly struct NearestEntry + { + public NearestEntry(String name, ICssProperty declaration) + { + Name = name; + Declaration = declaration; + } + + public String Name { get; } + + // null if no snapshot up the chain declares the name + public ICssProperty Declaration { get; } + } + private ICssProperty FindOwn(String name) { if (_index.TryGetValue(name, out var i) && i < _declarations.Count) diff --git a/src/AngleSharp.Performance.Css/DeepTreeBenchmarks.cs b/src/AngleSharp.Performance.Css/DeepTreeBenchmarks.cs new file mode 100644 index 00000000..3f6aa837 --- /dev/null +++ b/src/AngleSharp.Performance.Css/DeepTreeBenchmarks.cs @@ -0,0 +1,73 @@ +namespace AngleSharp.Performance.Css +{ + using AngleSharp; + using AngleSharp.Css; + using AngleSharp.Css.RenderTree; + using AngleSharp.Dom; + using AngleSharp.Html.Parser; + using BenchmarkDotNet.Attributes; + using System; + using System.Collections.Generic; + + /// + /// Reading inherited properties on a deep tree built with the DOM API: visibility is + /// inherited and declared nowhere, so it is looked up through all ancestors. The depths stay + /// within what the recursive walks over the tree survive on a 1 MB stack. + /// + [MemoryDiagnoser] + public class DeepTreeBenchmarks + { + private IDocument _document = null!; + private IRenderNode _tree = null!; + + [Params(100, 500)] + public Int32 Depth { get; set; } + + [GlobalSetup] + public void Setup() + { + var context = BrowsingContext.New(Configuration.Default.WithCss()); + _document = context.GetService()!.ParseDocument(""); + IElement current = _document.Body!; + + for (var i = 0; i < Depth; i++) + { + var child = _document.CreateElement("div"); + current.AppendChild(child); + current = child; + } + + current.TextContent = "x"; + _tree = _document.DefaultView!.Render(); + } + + [Benchmark] + public Int32 InnerText() => _document.Body!.GetInnerText().Length; + + [Benchmark] + public Int32 IsVisible() + { + // the render tree is built once, so only the lookups are measured + var pending = new Stack(); + var visible = 0; + pending.Push(_tree); + + while (pending.Count > 0) + { + var node = pending.Pop(); + + if (node.IsVisible()) + { + visible++; + } + + foreach (var child in node.Children) + { + pending.Push(child); + } + } + + return visible; + } + } +} From d4b568739a48cd9529a27a67e209a8c3c99ac19b Mon Sep 17 00:00:00 2001 From: Michael Thyregod <2386496+michaelthyregod@users.noreply.github.com> Date: Mon, 2 Nov 2026 16:04:05 +0100 Subject: [PATCH 2/3] Add the CHANGELOG entry --- CHANGELOG.md | 1 + 1 file changed, 1 insertion(+) diff --git a/CHANGELOG.md b/CHANGELOG.md index 0ae2a913..bc4d594f 100644 --- a/CHANGELOG.md +++ b/CHANGELOG.md @@ -4,6 +4,7 @@ Released on Monday, November 2 2026. - Improved the parser with a nesting limit for values and conditions (`MaxValueNestingDepth`) (#260) @michaelthyregod - Improved indexing rules with deeply nested `:is()`, `:where()`, or `:matches()` (#261) @michaelthyregod +- Improved reading inherited properties on deep trees (e.g. `visibility` in `IsVisible` and `GetInnerText`) (#263) @michaelthyregod # 1.3.1 From f159df108bd0dfa4d1bac946a459ba5aefe7a528 Mon Sep 17 00:00:00 2001 From: Michael Thyregod <2386496+michaelthyregod@users.noreply.github.com> Date: Mon, 2 Nov 2026 18:14:38 +0100 Subject: [PATCH 3/3] Grow fewer lists while cascading The cascade allocated a new list for the matching rules of every element and grew the explicit style one declaration at a time; on a real document this growth and the garbage it leaves dominate the render tree. - Reuse the list of matching rules from element to element; it is taken and given back atomically, so a concurrent cascade gets its own - Size the explicit style for the declarations of the matching rules --- .../Dom/Internal/CssStyleDeclaration.cs | 11 ++++++ .../Dom/Internal/StyleCollection.cs | 20 +++++++++++ .../Extensions/StyleCollectionExtensions.cs | 35 ++++++++++++++++--- 3 files changed, 62 insertions(+), 4 deletions(-) diff --git a/src/AngleSharp.Css/Dom/Internal/CssStyleDeclaration.cs b/src/AngleSharp.Css/Dom/Internal/CssStyleDeclaration.cs index f0fac4d8..49231163 100644 --- a/src/AngleSharp.Css/Dom/Internal/CssStyleDeclaration.cs +++ b/src/AngleSharp.Css/Dom/Internal/CssStyleDeclaration.cs @@ -51,6 +51,17 @@ public CssStyleDeclaration(IBrowsingContext context) _context = context; } + /// + /// Creates the declarations with room for the given number of entries, e.g. for + /// a cascade that knows how many declarations its matching rules bring. + /// + internal CssStyleDeclaration(IBrowsingContext context, Int32 capacity) + { + _declarations = new List(capacity); + _declarationIndex = new Dictionary(capacity, StringComparer.Ordinal); + _context = context; + } + public CssStyleDeclaration(ICssRule parent) : this(parent.Owner?.Context) { diff --git a/src/AngleSharp.Css/Dom/Internal/StyleCollection.cs b/src/AngleSharp.Css/Dom/Internal/StyleCollection.cs index 34010344..d24fc73c 100644 --- a/src/AngleSharp.Css/Dom/Internal/StyleCollection.cs +++ b/src/AngleSharp.Css/Dom/Internal/StyleCollection.cs @@ -5,6 +5,7 @@ namespace AngleSharp.Css.Dom using System.Collections; using System.Collections.Generic; using System.Linq; + using System.Threading; sealed class StyleCollection : IStyleCollection { @@ -16,6 +17,7 @@ sealed class StyleCollection : IStyleCollection private List? _rules; private List? _layerRanks; private StyleRuleIndex? _index; + private List? _matches; private Int32 _defaultRuleCount; #endregion @@ -69,6 +71,24 @@ public StyleCollection(IEnumerable defaultSheets, IEnumerable internal StyleRuleIndex GetIndex() => _index ??= new StyleRuleIndex(GetRules()); + /// + /// Takes the list for the rules matching an element, which is reused from + /// element to element instead of growing a new one each time. The list is + /// taken atomically, so a concurrent cascade on the same collection gets + /// a new one. + /// + internal List TakeMatches() => + Interlocked.Exchange(ref _matches, null) ?? new List(); + + /// + /// Gives back the list from TakeMatches once the element's style is computed. + /// + internal void ReturnMatches(List matches) + { + matches.Clear(); + Volatile.Write(ref _matches, matches); + } + /// /// Gets the rank of the cascade layer of the rule at the given index of /// GetRules: rules of a lower rank lose against rules of a higher rank diff --git a/src/AngleSharp.Css/Extensions/StyleCollectionExtensions.cs b/src/AngleSharp.Css/Extensions/StyleCollectionExtensions.cs index 00fa3908..c3c7aae8 100644 --- a/src/AngleSharp.Css/Extensions/StyleCollectionExtensions.cs +++ b/src/AngleSharp.Css/Extensions/StyleCollectionExtensions.cs @@ -134,8 +134,8 @@ public static ICssStyleDeclaration ComputeCascadedStyle(this IStyleCollection st public static ICssStyleDeclaration ComputeExplicitStyle(this IStyleCollection styles, IElement element) { var ctx = element.Owner?.Context ?? throw new InvalidOperationException("The element must be associated with a browsing context."); - var computedStyle = new CssStyleDeclaration(ctx); var matches = styles.SortBySpecificity(element); + var computedStyle = new CssStyleDeclaration(ctx, CountDeclarations(matches)); var i = 0; for (; i < matches.Count && matches[i].IsDefault; i++) @@ -151,6 +151,7 @@ public static ICssStyleDeclaration ComputeExplicitStyle(this IStyleCollection st } ApplyImportantLayerOrder(computedStyle, matches); + ReturnMatches(styles, matches); if (element is IHtmlElement || element is ISvgElement) { @@ -221,6 +222,7 @@ internal static ICssStyleDeclaration ComputeHostStyle(this IStyleCollection styl } ApplyImportantLayerOrder(computedStyle, matches); + ReturnMatches(styles, matches); computedStyle.SetDeclarations(host.GetStyle()); computedStyle.SetDeclarations(shadowStyle.Where(m => m.IsImportant).ToList()); return computedStyle; @@ -312,8 +314,9 @@ private static List SortBySpecificity(this IStyleCollection styles, I var defaultRuleCount = collection.GetDefaultRuleCount(); var candidates = collection.GetIndex().GetCandidates(element); // not sized for the candidates: the unkeyed rules (e.g., [data-theme] or :root) - // are candidates for every element, but only a few of them match - matches = new List(); + // are candidates for every element, but only a few of them match; the list + // is reused from element to element instead + matches = collection.TakeMatches(); // the candidates keep the order of the rules, as required by the cascade foreach (var i in candidates) @@ -337,6 +340,30 @@ private static List SortBySpecificity(this IStyleCollection styles, I return matches; } + /// + /// Counts the declarations of the matching rules: the cascade keeps at most + /// these, so the explicit style does not grow while they are applied. + /// + private static Int32 CountDeclarations(List matches) + { + var count = 0; + + foreach (var match in matches) + { + count += match.Rule.Style.Length; + } + + return count; + } + + private static void ReturnMatches(IStyleCollection styles, List matches) + { + if (styles is StyleCollection collection) + { + collection.ReturnMatches(matches); + } + } + private static void MapPriority(ICssStyleRule rule, IElement element, IElement? scope, Boolean isDefault, Int32 layerRank, List matches) { if (rule.TryMatch(element, scope, out var specificity)) @@ -355,7 +382,7 @@ private static void MapPriority(ICssStyleRule rule, IElement element, IElement? } } - private readonly struct RuleMatch + internal readonly struct RuleMatch { public RuleMatch(ICssStyleRule rule, Boolean isDefault, Int32 layerRank, Priority priority, Int32 index) {