From e5be9b585e78c739b231e34858ad9abbe450e992 Mon Sep 17 00:00:00 2001 From: Marko Lahma Date: Sat, 12 Sep 2026 10:37:44 +0300 Subject: [PATCH 1/3] Skip the pseudo-class forcing lookup until something is forced WithCss() wraps every non-:focus pseudo-class selector in ForcingPseudoClassSelector so a caller-forced state (SetPseudoClass) can override normal matching. Its Match() probed the process-wide PseudoClassStateStore ConditionalWeakTable before delegating, on every attempted match - so a page that has never called SetPseudoClass still paid that probe for every :hover, :disabled, :checked, ... match attempt in the cascade. Add a static flag set the first time anything is forced, checked before the table probe in TryGet. It only ever moves from "nothing forced" to "something forced": Remove/Clear cannot prove every forced state everywhere has been undone (there is no per-element or per-process count to check), so it deliberately never goes back to false - once anything has been forced anywhere in the process, matching pays the old per-attempt cost again. This is a process-wide static, so it also does not distinguish between documents/engines; the trade-off favors the overwhelmingly common case of a process that never forces anything. Extended CssCascadeBenchmarks' ComputedStyle row (the existing 400-rule cascade) with one :hover/:active/:disabled rule per child class so most sampled elements reach the forcing wrapper instead of being short-circuited by an earlier, non-matching simple selector - this benchmark builds against the published AngleSharp NuGet package, so it cannot exercise the sibling AngleSharp-core specificity-caching change from this same stream without a local package override; only this pseudo-class fix is observable from an in-repo run. Co-Authored-By: Claude Opus 5 (1M context) Claude-Session: https://claude.ai/code/session_01WQwq9NqYqG3kk9M8CKdfdJ --- CHANGELOG.md | 1 + src/AngleSharp.Css/PseudoClassStateStore.cs | 16 ++++++++++++++-- .../CssCascadeBenchmarks.cs | 14 ++++++++++++++ 3 files changed, 29 insertions(+), 2 deletions(-) diff --git a/CHANGELOG.md b/CHANGELOG.md index 4f9da92..d5c5b0a 100644 --- a/CHANGELOG.md +++ b/CHANGELOG.md @@ -2,6 +2,7 @@ Released on Friday, September 11 2026 +- Improved pseudo-class matching to skip a per-element `ConditionalWeakTable` probe when no state has ever been forced via `SetPseudoClass` - Fixed issue with calculation of CSS grid style - Fixed default value of CSS gradients - Fixed issue with Point2D style computations diff --git a/src/AngleSharp.Css/PseudoClassStateStore.cs b/src/AngleSharp.Css/PseudoClassStateStore.cs index 1dd69ab..448ee59 100644 --- a/src/AngleSharp.Css/PseudoClassStateStore.cs +++ b/src/AngleSharp.Css/PseudoClassStateStore.cs @@ -14,12 +14,24 @@ static class PseudoClassStateStore { private static readonly ConditionalWeakTable> _states = new(); - public static void Set(IElement element, String pseudoClass, Boolean value) => + // WithCss() wraps every non-:focus pseudo-class selector in ForcingPseudoClassSelector, so + // TryGet runs once per pseudo-class match attempt on every element - a page that never + // calls SetPseudoClass still paid a ConditionalWeakTable probe for every :hover, :disabled, + // :checked, ... match. This flag only ever moves from "nothing forced" to "something + // forced": Remove/Clear cannot prove every forced state everywhere has been undone (there + // is no per-element or per-process count to check), so it deliberately never goes back to + // false. Once anything has been forced, the process pays the old per-match cost again. + private static volatile Boolean _anyForced; + + public static void Set(IElement element, String pseudoClass, Boolean value) + { + _anyForced = true; _states.GetValue(element, _ => new Dictionary(StringComparer.OrdinalIgnoreCase))[pseudoClass] = value; + } public static Boolean TryGet(IElement element, String pseudoClass, out Boolean value) { - if (_states.TryGetValue(element, out var state) && state.TryGetValue(pseudoClass, out value)) + if (_anyForced && _states.TryGetValue(element, out var state) && state.TryGetValue(pseudoClass, out value)) { return true; } diff --git a/src/AngleSharp.Performance.Css/CssCascadeBenchmarks.cs b/src/AngleSharp.Performance.Css/CssCascadeBenchmarks.cs index 06965c2..5043005 100644 --- a/src/AngleSharp.Performance.Css/CssCascadeBenchmarks.cs +++ b/src/AngleSharp.Performance.Css/CssCascadeBenchmarks.cs @@ -124,6 +124,20 @@ private static String BuildDocument() sb.Append("h1,h2,h3,p.big,a:hover,.c-1 span{font-weight:bold;letter-spacing:0.02em;}"); sb.Append(".hidden{display:none}.big{font-size:24px}a{color:blue}"); + + // WithCss() wraps every non-:focus pseudo-class selector so a caller-forced state + // (ElementExtensions.SetPseudoClass) can override it - so every rule below probes a + // ConditionalWeakTable per candidate element even though this benchmark never forces + // anything. One rule per child class per interaction pseudo-class keeps every sampled + // "div.child-*" reaching that probe instead of being short-circuited by an earlier, + // non-matching simple selector. + for (var i = 0; i < 20; i++) + { + sb.Append(".child-").Append(i).Append(":hover{outline:1px solid red;}"); + sb.Append(".child-").Append(i).Append(":active{outline:1px solid green;}"); + sb.Append(".child-").Append(i).Append(":disabled{outline:1px solid blue;}"); + } + sb.Append("
"); for (var s = 0; s < 25; s++) From a6633d1310a245639b210a5830d36017630b1993 Mon Sep 17 00:00:00 2001 From: Marko Lahma Date: Sat, 12 Sep 2026 11:15:52 +0300 Subject: [PATCH 2/3] Move the entry to a new 1.1.3 section 1.1.2 shipped on September 11 (GitHub release v1.1.2, and 1.1.2 is the latest stable on NuGet), so this change belongs to the next version rather than to a released one. Left in src/Directory.Build.props alone: ReleaseNotesParser takes the version from this file's top entry, and the props value is documented as the fallback for builds that bypass Fallout. Co-Authored-By: Claude Opus 5 (1M context) Claude-Session: https://claude.ai/code/session_01WQwq9NqYqG3kk9M8CKdfdJ --- CHANGELOG.md | 7 ++++++- 1 file changed, 6 insertions(+), 1 deletion(-) diff --git a/CHANGELOG.md b/CHANGELOG.md index d5c5b0a..abc1dd5 100644 --- a/CHANGELOG.md +++ b/CHANGELOG.md @@ -1,8 +1,13 @@ +# 1.1.3 + +Released on Saturday, September 12 2026 + +- Improved pseudo-class matching to skip a per-element `ConditionalWeakTable` probe when no state has ever been forced via `SetPseudoClass` + # 1.1.2 Released on Friday, September 11 2026 -- Improved pseudo-class matching to skip a per-element `ConditionalWeakTable` probe when no state has ever been forced via `SetPseudoClass` - Fixed issue with calculation of CSS grid style - Fixed default value of CSS gradients - Fixed issue with Point2D style computations From bab5b2b26ef8125b45702db6a4a02abd64a25f36 Mon Sep 17 00:00:00 2001 From: Marko Lahma Date: Sat, 12 Sep 2026 13:31:25 +0300 Subject: [PATCH 3/3] Bump the version to 1.1.3 alongside the changelog section Per review: opening the 1.1.3 section is not the whole bump - the props value and the docs package.json carry it too. Co-Authored-By: Claude Opus 5 (1M context) Claude-Session: https://claude.ai/code/session_01WQwq9NqYqG3kk9M8CKdfdJ --- src/AngleSharp.Css.Docs/package.json | 2 +- src/Directory.Build.props | 2 +- 2 files changed, 2 insertions(+), 2 deletions(-) diff --git a/src/AngleSharp.Css.Docs/package.json b/src/AngleSharp.Css.Docs/package.json index d68f95c..1459308 100644 --- a/src/AngleSharp.Css.Docs/package.json +++ b/src/AngleSharp.Css.Docs/package.json @@ -1,6 +1,6 @@ { "name": "@anglesharp/css", - "version": "1.1.2", + "version": "1.1.3", "preview": true, "description": "The doclet for the AngleSharp.Css documentation.", "keywords": [ diff --git a/src/Directory.Build.props b/src/Directory.Build.props index beb3e0d..04ce268 100644 --- a/src/Directory.Build.props +++ b/src/Directory.Build.props @@ -2,7 +2,7 @@ Extends the CSSOM from the core AngleSharp library. AngleSharp.Css - 1.1.2 + 1.1.3 enable latest true