Skip to content

Skip the pseudo-class forcing lookup until something is forced - #247

Merged
FlorianRappl merged 3 commits into
AngleSharp:develfrom
lahma:perf/selector-matching
Sep 12, 2026
Merged

Skip the pseudo-class forcing lookup until something is forced#247
FlorianRappl merged 3 commits into
AngleSharp:develfrom
lahma:perf/selector-matching

Conversation

@lahma

@lahma lahma commented Sep 12, 2026

Copy link
Copy Markdown
Contributor

What the profile said

WithCss() (CssConfigurationExtensions.cs) wraps every non-:focus pseudo-class selector in ForcingPseudoClassSelector, so a caller-forced state (set via ElementExtensions.SetPseudoClass, e.g. to preview :hover/:active from devtools-style tooling) can take precedence over normal matching. Its Match (ForcingPseudoClassSelector.cs:29-30) calls PseudoClassStateStore.TryGet — a ConditionalWeakTable<IElement, Dictionary<String, Boolean>> probe — before delegating to the wrapped selector, on every attempted match. A page that has never called SetPseudoClass still pays that probe for every :hover, :disabled, :checked, … match attempt across an entire cascade.

The mechanism

PseudoClassStateStore is a single static store for the whole process. A "nothing has ever been forced" flag checked first lets TryGet skip the table probe entirely in the overwhelmingly common case.

What I changed

Added private static volatile Boolean _anyForced to PseudoClassStateStore, set in Set() and checked first in TryGet(). It only ever moves from false to true: Remove/Clear do not clear it back, because proving that every forced state everywhere in the process has been undone would need a live per-element or per-process count, not a single flag, and getting that wrong would be a correctness bug (silently ignoring a still-forced state). So the trade-off is explicit: once anything has been forced anywhere in the process, matching goes back to paying the old per-attempt cost for the lifetime of the process. This is also a process-wide static, so it doesn't distinguish between documents or engines — forcing something in one document costs every other document in the same process the probe again. Both are pre-existing properties of PseudoClassStateStore (already a single static table shared process-wide); this change doesn't introduce either, it only trades the unforced path for something cheaper.

What I did not change, and why

I considered making the flag per-element (e.g. a marker in the ConditionalWeakTable entry itself) to avoid the process-wide blast radius, but that reintroduces the table lookup this change exists to avoid on the common path. A per-document flag would need a hook into document/engine lifetime this store doesn't currently have. Both are reasonable follow-ups if the process-wide trade-off proves too coarse in practice, but neither is evidently better today, so I kept the smallest fix that matches the brief's constraint ("may only ever go from 'nothing forced' to 'something forced', never back, unless un-forcing is proven to clear it correctly" — I couldn't prove that, so I didn't attempt it).

Tests

  • dotnet test -c Release src/AngleSharp.Css.Tests/AngleSharp.Css.Tests.csproj — 2342 passed, 0 failed.
  • No new tests added: PseudoClassForcingTests (src/AngleSharp.Css.Tests/Styling/PseudoClassForcing.cs) already exercises Set/Get/Remove/Clear and matching through :hover/:active/:visited/:focus, all passing unchanged — this is a pure fast-path addition, not a behavior change to any of them (a forced state is still found correctly; only the never-forced path got cheaper).

To be measured

dotnet run --project src/AngleSharp.Performance.Css/AngleSharp.Performance.Css.csproj -c Release --framework net10.0 -- --filter "*CssCascadeBenchmarks*", specifically the ComputedStyle row. I extended that row's synthetic stylesheet (already a 400-rule cascade over a synthetic document) with one :hover/:active/:disabled rule per child class, so most sampled elements reach ForcingPseudoClassSelector.Match instead of being short-circuited by an earlier, non-matching simple selector — without this, the existing sheet only reached the forcing wrapper via a single a:hover rule. I ran it once with --job short to confirm it executes; no numbers from that run should be treated as meaningful.

Note: this benchmark project depends on the published AngleSharp NuGet package (AngleSharpVersion, currently pinned to 1.5.0), not a local build, so it cannot currently exercise the companion AngleSharp core PR from this same stream (selector specificity caching) — only this pseudo-class fix is observable from an in-repo run today. The lead's measurement of the combined effect will need a local package override or a fresh core package.

Part of a benchmark-gated performance campaign (stream U3). A companion PR against AngleSharp core (perf/selector-matching) fixes related selector specificity recomputation in the same cascade path: AngleSharp/AngleSharp#1346

🤖 Generated with Claude Code

https://claude.ai/code/session_01WQwq9NqYqG3kk9M8CKdfdJ

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) <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01WQwq9NqYqG3kk9M8CKdfdJ
Comment thread CHANGELOG.md Outdated
@FlorianRappl FlorianRappl added this to the v1.1.3 milestone Sep 12, 2026
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 <Version> 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) <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01WQwq9NqYqG3kk9M8CKdfdJ
@lahma

lahma commented Sep 12, 2026

Copy link
Copy Markdown
Contributor Author

You're right, and thank you — fixed in a6633d1: the entry now opens a # 1.1.3 section. I checked before moving it rather than just taking the note: v1.1.2 was released on 11 September and 1.1.2 is the latest stable on NuGet, so appending to it was claiming the change had already shipped.

I left <Version> in src/Directory.Build.props at 1.1.2 deliberately, since ReleaseNotesParser takes the version from the changelog's top entry and the props value is documented as the fallback for builds that bypass Fallout — but say the word if you would rather that moved too.

Worth flagging that the same question has the opposite answer in the core repository right now, which is what misled me: there, # 1.8.2 heads the changelog while the latest release and the latest stable package are both 1.8.1, so it is the version in flight and appending to it is correct. The rule I should have applied is "check whether the top heading has actually shipped", not "append to the top heading" — I'm correcting a line I added to core's AGENTS.md accordingly.

Two process notes, both mine to own:

  • This should have been opened as a draft. My standing instruction now is that a pull request on a repository I don't maintain stays a draft until it has been reviewed on my side; this one went out before that rule existed. Apologies for the noise in your queue.
  • Performance numbers are being measured now. This change is on a per-element, per-match path, so it deserves them rather than an argument. The run is queued behind another measurement on an idle machine — paired A/B, allocation column included, default job — and I'll post the table here when it lands. If it measures as noise I'll say so and close this rather than leave it open on a plausible story.

@FlorianRappl

Copy link
Copy Markdown
Contributor

Yes, not only does src/Directory.Build.props require a change to 1.1.3, also the package.json in src/AngleSharp.Css.Docs requires such a change.

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) <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01WQwq9NqYqG3kk9M8CKdfdJ
@lahma

lahma commented Sep 12, 2026

Copy link
Copy Markdown
Contributor Author

Both bumped in bab5b2bsrc/Directory.Build.props and src/AngleSharp.Css.Docs/package.json now read 1.1.3 alongside the changelog section. Thanks for naming the second one; I had left the props value alone on the reasoning that ReleaseNotesParser reads the version from the changelog, which was true but incomplete.

Numbers are next: this change is on a per-element, per-match path, so CssCascadeBenchmarks.ComputedStyle is the row that should show it, and that benchmark does reach this fix because the perf project references AngleSharp.Css by project. I'll post the table here.

@FlorianRappl FlorianRappl left a comment

Copy link
Copy Markdown
Contributor

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

LGTM!

@FlorianRappl
FlorianRappl merged commit 650fb47 into AngleSharp:devel Sep 12, 2026
5 checks passed
@lahma
lahma deleted the perf/selector-matching branch September 12, 2026 14:28
Sign up for free to join this conversation on GitHub. Already have an account? Sign in to comment

Projects

None yet

Development

Successfully merging this pull request may close these issues.

2 participants