From efea89f76324712f9829d9f4cf3897374fdb7f5d Mon Sep 17 00:00:00 2001 From: Michael Thyregod <2386496+michaelthyregod@users.noreply.github.com> Date: Mon, 2 Nov 2026 06:57:48 +0100 Subject: [PATCH 1/2] Limit how deep the rule index looks into :is() Indexing a rule for the cascade looks into the arguments of :is(), :where() and :matches(), parsing them again at every level, so the cost grows quadratically with the nesting: computing a style with 75 nested :is() took 45 us, 35 us with the cap (allocating 132 KB, 82 KB with it). - Look into at most 8 levels; a rule nested deeper stays ungrouped, i.e., it is a candidate for every element, which is always correct - Add tests for the boundary and for matching beyond it - Add DeepSelectorBenchmarks with 10, 50 and 75 levels --- .../Styling/StyleRuleIndexTests.cs | 5 +++ .../Dom/Internal/StyleRuleIndex.cs | 26 +++++++---- .../DeepSelectorBenchmarks.cs | 44 +++++++++++++++++++ 3 files changed, 67 insertions(+), 8 deletions(-) create mode 100644 src/AngleSharp.Performance.Css/DeepSelectorBenchmarks.cs diff --git a/src/AngleSharp.Css.Tests/Styling/StyleRuleIndexTests.cs b/src/AngleSharp.Css.Tests/Styling/StyleRuleIndexTests.cs index fcd55a33..a74b71d4 100644 --- a/src/AngleSharp.Css.Tests/Styling/StyleRuleIndexTests.cs +++ b/src/AngleSharp.Css.Tests/Styling/StyleRuleIndexTests.cs @@ -72,6 +72,7 @@ public async Task GroupedAndUngroupedRulesKeepTheCascadeOrder(string css) [TestCase(":where(.x .a, div) { color: green }", "
A
")] [TestCase(":matches(.x, .b) { color: green }", "
A
")] [TestCase(":is(:is(.x, .b)) { color: green }", "
A
")] + [TestCase(":is(:is(:is(:is(:is(:is(:is(:is(:is(.b))))))))) { color: green }", "
A
")] [TestCase(":is(.x, *) { color: green }", "
A
")] [TestCase(":is(.x, :not(.y)) { color: green }", "
A
")] [TestCase(".parent { :is(&) { color: green } }", "

A

")] @@ -89,6 +90,7 @@ public async Task RulesAreFoundViaTheirSubject(string css, string body) [TestCase(".a .b { color: red }", "

A

")] [TestCase("[data-x] { color: red }", "

A

")] [TestCase(":is(.x .a, .b) { color: red }", "

A

")] + [TestCase(":is(:is(:is(:is(:is(:is(:is(:is(:is(.x))))))))) { color: red }", "

A

")] public async Task RulesForOtherSubjectsDoNotApply(string css, string body) { var color = await ColorOfAsync(css, body); @@ -102,6 +104,9 @@ public async Task RulesForOtherSubjectsDoNotApply(string css, string body) [TestCase(":is(.x, .a) { color: red }", 1)] [TestCase(":where(.x .y, #z) { color: red }", 0)] [TestCase(":is(.x, :not(.y)) { color: red }", 1)] + // the index looks into 8 levels of :is(); a deeper rule stays ungrouped, i.e., a candidate + [TestCase(":is(:is(:is(:is(:is(:is(:is(:is(.x)))))))) { color: red }", 0)] + [TestCase(":is(:is(:is(:is(:is(:is(:is(:is(:is(.x))))))))) { color: red }", 1)] [TestCase("! { color: red }", 0)] public void OnlyRulesThatMayMatchAreCandidates(string css, int count) { diff --git a/src/AngleSharp.Css/Dom/Internal/StyleRuleIndex.cs b/src/AngleSharp.Css/Dom/Internal/StyleRuleIndex.cs index b1c59514..6e860eb6 100644 --- a/src/AngleSharp.Css/Dom/Internal/StyleRuleIndex.cs +++ b/src/AngleSharp.Css/Dom/Internal/StyleRuleIndex.cs @@ -355,17 +355,27 @@ private sealed class SubjectKeyVisitor : ISelectorVisitor PseudoClassNames.Matches, }; + // How many :is() levels are looked into. Each one parses its arguments again, so a + // deeply nested selector would cost quadratic time (and stack) to index; real ones + // nest a level or two. Deeper, the rule just stays ungrouped, which is always correct. + private const Int32 MaxArgumentDepth = 8; + private readonly ISelector _selector; private readonly ICssSelectorParser? _parser; + private readonly Int32 _depth; private SubjectKey[]? _keys; - private SubjectKeyVisitor(ISelector selector, ICssSelectorParser? parser) + private SubjectKeyVisitor(ISelector selector, ICssSelectorParser? parser, Int32 depth) { _selector = selector; _parser = parser; + _depth = depth; } - public static SubjectKey[]? GetKeys(ISelector? selector, ICssSelectorParser? parser) + public static SubjectKey[]? GetKeys(ISelector? selector, ICssSelectorParser? parser) => + GetKeys(selector, parser, 0); + + private static SubjectKey[]? GetKeys(ISelector? selector, ICssSelectorParser? parser, Int32 depth) { // a forced pseudo-class matches independent of its own selector if (selector is null || selector is ForcingPseudoClassSelector) @@ -373,7 +383,7 @@ private SubjectKeyVisitor(ISelector selector, ICssSelectorParser? parser) return null; } - var visitor = new SubjectKeyVisitor(selector, parser); + var visitor = new SubjectKeyVisitor(selector, parser, depth); selector.Accept(visitor); return visitor._keys; } @@ -409,7 +419,7 @@ void ISelectorVisitor.PseudoClass(String name) { // :is(.a .b, .c) only exposes its text, but an element matching it matches one // of the arguments, i.e., it requires one of their keys - just as a list does - var arguments = GetArguments(name); + var arguments = _depth < MaxArgumentDepth ? GetArguments(name) : null; if (arguments is not null && _parser is not null) { @@ -419,7 +429,7 @@ void ISelectorVisitor.PseudoClass(String name) // are valid only where the rule is (e.g., nested); then the keys are incomplete if (selector is not null && selector.Text.Equals(arguments, StringComparison.Ordinal)) { - _keys = GetKeys(selector, _parser); + _keys = GetKeys(selector, _parser, _depth + 1); } } } @@ -434,7 +444,7 @@ void ISelectorVisitor.List(IEnumerable selectors) foreach (var selector in selectors) { - var alternative = GetKeys(selector, _parser); + var alternative = GetKeys(selector, _parser, _depth); if (alternative is null) { @@ -449,7 +459,7 @@ void ISelectorVisitor.List(IEnumerable selectors) void ISelectorVisitor.Combinator(IEnumerable selectors, IEnumerable symbols) { - _keys = GetKeys(selectors.LastOrDefault(), _parser); + _keys = GetKeys(selectors.LastOrDefault(), _parser, _depth); } void ISelectorVisitor.Many(IEnumerable selectors) @@ -457,7 +467,7 @@ void ISelectorVisitor.Many(IEnumerable selectors) // all parts of a compound must match, so any key of a part is required foreach (var selector in selectors) { - var keys = GetKeys(selector, _parser); + var keys = GetKeys(selector, _parser, _depth); if (keys is not null && (_keys is null || IsBetter(keys, _keys))) { diff --git a/src/AngleSharp.Performance.Css/DeepSelectorBenchmarks.cs b/src/AngleSharp.Performance.Css/DeepSelectorBenchmarks.cs new file mode 100644 index 00000000..b10f9d2c --- /dev/null +++ b/src/AngleSharp.Performance.Css/DeepSelectorBenchmarks.cs @@ -0,0 +1,44 @@ +namespace AngleSharp.Performance.Css +{ + using AngleSharp; + using AngleSharp.Css.Dom; + using AngleSharp.Dom; + using AngleSharp.Html.Parser; + using BenchmarkDotNet.Attributes; + using System; + using System.Text; + + /// + /// Computing a style with a rule whose selector nests :is(): the cascade indexes the rule by + /// looking into the arguments. The depths stay within the 75 levels AngleSharp allows from + /// 1.9.1 on (Firefox's limit). + /// + [MemoryDiagnoser] + public class DeepSelectorBenchmarks + { + private IHtmlParser _parser = null!; + private String _source = null!; + + [Params(10, 50, 75)] + public Int32 Depth { get; set; } + + [GlobalSetup] + public void Setup() + { + var context = BrowsingContext.New(Configuration.Default.WithCss()); + _parser = context.GetService()!; + _source = "
x
"; + } + + [Benchmark] + public String ComputedStyle() + { + // a fresh document, so the rule is indexed again (the index is kept with the rule) + var document = _parser.ParseDocument(_source); + return document.DefaultView!.GetComputedStyle(document.QuerySelector("div")!).GetColor(); + } + + private static String Repeat(String value, Int32 count) => + new StringBuilder(value.Length * count).Insert(0, value, count).ToString(); + } +} From fda6eb0bbac4d4494a3d12ea21d96177055697eb Mon Sep 17 00:00:00 2001 From: Michael Thyregod <2386496+michaelthyregod@users.noreply.github.com> Date: Mon, 2 Nov 2026 11:50:08 +0100 Subject: [PATCH 2/2] Add the CHANGELOG entry --- CHANGELOG.md | 1 + 1 file changed, 1 insertion(+) diff --git a/CHANGELOG.md b/CHANGELOG.md index 7e073e41..0ae2a913 100644 --- a/CHANGELOG.md +++ b/CHANGELOG.md @@ -3,6 +3,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 # 1.3.1