Skip the pseudo-class forcing lookup until something is forced - #247
Conversation
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
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
|
You're right, and thank you — fixed in a6633d1: the entry now opens a I left Worth flagging that the same question has the opposite answer in the core repository right now, which is what misled me: there, Two process notes, both mine to own:
|
|
Yes, not only does |
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
|
Both bumped in Numbers are next: this change is on a per-element, per-match path, so |
What the profile said
WithCss()(CssConfigurationExtensions.cs) wraps every non-:focuspseudo-class selector inForcingPseudoClassSelector, so a caller-forced state (set viaElementExtensions.SetPseudoClass, e.g. to preview:hover/:activefrom devtools-style tooling) can take precedence over normal matching. ItsMatch(ForcingPseudoClassSelector.cs:29-30) callsPseudoClassStateStore.TryGet— aConditionalWeakTable<IElement, Dictionary<String, Boolean>>probe — before delegating to the wrapped selector, on every attempted match. A page that has never calledSetPseudoClassstill pays that probe for every:hover,:disabled,:checked, … match attempt across an entire cascade.The mechanism
PseudoClassStateStoreis a single static store for the whole process. A "nothing has ever been forced" flag checked first letsTryGetskip the table probe entirely in the overwhelmingly common case.What I changed
Added
private static volatile Boolean _anyForcedtoPseudoClassStateStore, set inSet()and checked first inTryGet(). It only ever moves fromfalsetotrue:Remove/Cleardo 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 ofPseudoClassStateStore(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
ConditionalWeakTableentry 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.PseudoClassForcingTests(src/AngleSharp.Css.Tests/Styling/PseudoClassForcing.cs) already exercisesSet/Get/Remove/Clearand 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 theComputedStylerow. I extended that row's synthetic stylesheet (already a 400-rule cascade over a synthetic document) with one:hover/:active/:disabledrule per child class, so most sampled elements reachForcingPseudoClassSelector.Matchinstead of being short-circuited by an earlier, non-matching simple selector — without this, the existing sheet only reached the forcing wrapper via a singlea:hoverrule. I ran it once with--job shortto confirm it executes; no numbers from that run should be treated as meaningful.Note: this benchmark project depends on the published
AngleSharpNuGet package (AngleSharpVersion, currently pinned to1.5.0), not a local build, so it cannot currently exercise the companionAngleSharpcore 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
AngleSharpcore (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