Skip to content

Intern Context.hover/active/focus/visit per element - #2168

Open
lukasbob wants to merge 2 commits into
mainfrom
fix/context-interning
Open

Intern Context.hover/active/focus/visit per element#2168
lukasbob wants to merge 2 commits into
mainfrom
fix/context-interning

Conversation

@lukasbob

@lukasbob lukasbob commented Sep 2, 2026

Copy link
Copy Markdown

Summary

Context.focus(element) (and the other single-state static factories — .hover(), .active(), .visit()) constructed a brand-new Context instance on every call, even for the same element. alfa-cascade/alfa-style's internal caches are WeakMap-keyed on Context object identity, and those caches are scoped to the Document/device (they persist for the life of the page object, not just one rule's evaluation) — so repeated queries for the same element under the same single-state context missed the cache every time, even across two full evaluations of the same already-loaded page.

This interns the four factories (Cache<Element, Context> per state, returning the same instance for the same element). Pure caching fix — no change to which selectors match or what Context.getState/.hasState/etc. return for any given element.

Correction: this has ~no measurable production impact

The PR originally claimed a large real-world performance win based on alfa-benchmark's comparison numbers (sia-r65 -99.67% etc.). That framing was wrong. Traced the actual mechanism with instrumented call counters on Context.focus() and Cascade.get()'s non-empty-context path:

  • sia-r65 run once, cold, against a fresh page: 100% of Cascade.get() calls miss (50,278/50,278) — identical with or without this fix, ~135-140s either way.
  • sia-r65 run a second time against the same already-loaded page (which is exactly what alfa-benchmark's run.ts does: one discarded warmup pass, then the timed pass the benchmark numbers come from): with the fix, the second pass collapses to ~0.5s. Without the fix, the second pass is just as slow as the first.

The fix only pays off when the same Document/device is evaluated more than once in the same process — which is exactly the benchmark harness's warmup+timed methodology, but is not how production auditing works: every rule is evaluated exactly once per page, so there's no second pass to benefit from. This fix is confirmed to have effectively zero real-world performance impact, despite the dramatic benchmark numbers.

It's kept anyway as a correctness-neutral cache-identity fix (harmless, verified safe, technically sound), not as a performance fix. The original sia-r65/sia-r62 production performance problem that motivated this investigation remains unsolved.

Test plan

  • yarn build packages/alfa-selector (+ downstream: alfa-cascade, alfa-style, alfa-rules, alfa-act)
  • yarn test packages/alfa-selector packages/alfa-cascade packages/alfa-style packages/alfa-rules packages/alfa-act — 2146/2146 passing
  • Manual before/after outcome-count check on sia-r65 against two local fixtures — identical pass/fail/cantTell counts with and without the fix
  • Instrumented call-count check isolating a single fresh evaluation from a repeat evaluation of the same page (see correction above)
  • yarn changeset status — clean

🤖 Generated with Claude Code

Context.focus(element) (and the other single-state static factories)
constructed a brand-new Context instance on every call, even for the
same element. Since Cascade/Style's caches key on Context object
identity (WeakMap-backed), repeated single-element-context queries for
the same element never hit those caches.

sia-r65 does exactly this: for every tabbable target, it builds one
Context.focus(target) and queries dozens of candidate elements' style
under it. Interning collapses ~131.4s to ~0.4s on a 10k-node real-world
page (sia-r62 and sia-r87, which have a similar shape, also improve
substantially). Verified identical pass/fail/cantTell counts before and
after on both the small and large local fixtures - this is a caching
fix, not a change in what gets matched.
@lukasbob
lukasbob requested a review from a team as a code owner September 2, 2026 14:53
@changeset-bot

changeset-bot Bot commented Sep 2, 2026

Copy link
Copy Markdown

🦋 Changeset detected

Latest commit: eb80f6f

The changes in this PR will be included in the next version bump.

This PR includes changesets to release 76 packages
Name Type
@siteimprove/alfa-selector Patch
@siteimprove/alfa-act Patch
@siteimprove/alfa-affine Patch
@siteimprove/alfa-applicative Patch
@siteimprove/alfa-aria Patch
@siteimprove/alfa-array Patch
@siteimprove/alfa-bits Patch
@siteimprove/alfa-branched Patch
@siteimprove/alfa-cache Patch
@siteimprove/alfa-callback Patch
@siteimprove/alfa-cascade Patch
@siteimprove/alfa-clone Patch
@siteimprove/alfa-collection Patch
@siteimprove/alfa-comparable Patch
@siteimprove/alfa-continuation Patch
@siteimprove/alfa-css-feature Patch
@siteimprove/alfa-css Patch
@siteimprove/alfa-device Patch
@siteimprove/alfa-dom Patch
@siteimprove/alfa-eaa Patch
@siteimprove/alfa-earl Patch
@siteimprove/alfa-either Patch
@siteimprove/alfa-emitter Patch
@siteimprove/alfa-encoding Patch
@siteimprove/alfa-equatable Patch
@siteimprove/alfa-flags Patch
@siteimprove/alfa-fnv Patch
@siteimprove/alfa-foldable Patch
@siteimprove/alfa-functor Patch
@siteimprove/alfa-generator Patch
@siteimprove/alfa-graph Patch
@siteimprove/alfa-hash Patch
@siteimprove/alfa-http Patch
@siteimprove/alfa-iana Patch
@siteimprove/alfa-iterable Patch
@siteimprove/alfa-json-ld Patch
@siteimprove/alfa-json Patch
@siteimprove/alfa-lazy Patch
@siteimprove/alfa-list Patch
@siteimprove/alfa-map Patch
@siteimprove/alfa-mapper Patch
@siteimprove/alfa-math Patch
@siteimprove/alfa-monad Patch
@siteimprove/alfa-network Patch
@siteimprove/alfa-option Patch
@siteimprove/alfa-painting-order Patch
@siteimprove/alfa-parser Patch
@siteimprove/alfa-performance Patch
@siteimprove/alfa-predicate Patch
@siteimprove/alfa-record Patch
@siteimprove/alfa-rectangle Patch
@siteimprove/alfa-reducer Patch
@siteimprove/alfa-refinement Patch
@siteimprove/alfa-result Patch
@siteimprove/alfa-rng Patch
@siteimprove/alfa-rules Patch
@siteimprove/alfa-sarif Patch
@siteimprove/alfa-selective Patch
@siteimprove/alfa-sequence Patch
@siteimprove/alfa-set Patch
@siteimprove/alfa-slice Patch
@siteimprove/alfa-string Patch
@siteimprove/alfa-style Patch
@siteimprove/alfa-table Patch
@siteimprove/alfa-test Patch
@siteimprove/alfa-thunk Patch
@siteimprove/alfa-time Patch
@siteimprove/alfa-toolchain Patch
@siteimprove/alfa-trampoline Patch
@siteimprove/alfa-tree Patch
@siteimprove/alfa-trilean Patch
@siteimprove/alfa-tuple Patch
@siteimprove/alfa-url Patch
@siteimprove/alfa-wcag Patch
@siteimprove/alfa-web Patch
@siteimprove/alfa-xpath Patch

Not sure what this means? Click here to learn what changesets are.

Click here if you're a maintainer who wants to add another changeset to this PR

Copilot AI 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.

🟡 Changes recommended

The new identity and isolation guarantees lack direct regression tests.

Once you've addressed the issues Copilot identified, you can request another Copilot review.

Pull request overview

Interns single-state Context factories to improve downstream cache reuse and performance.

Changes:

  • Adds per-state, per-element context caches.
  • Documents behavior and measured performance gains.
File summaries
File Description
packages/alfa-selector/src/context.ts Implements context interning.
.changeset/context-interning.md Records the patch and impact.
Review details
  • Files reviewed: 2/2 changed files
  • Comments generated: 1
  • Review effort level: Balanced

💡 Add a code-review agent skill or configure MCP servers for context-aware, tailored reviews. Learn more in the docs.

*/
public static hover(element: Element): Context {
return this.empty().hover(element);
return this._hovered.get(element, () => this.empty().hover(element));
@pooya-si

pooya-si commented Sep 2, 2026

Copy link
Copy Markdown
Contributor

@lukasbob thanks a lot for this work, Mads!
I have a question around Measured impact and the After improvements. How was this measured? Was the second run performed after the first run? Or were they performed independently? I am asking because for instance in packages/alfa-rules/src/sia-r65/rule.ts, I see withFocus object is passed down to all other functions and it's the only reference to the Context so I don't fully understand how it makes such big difference.
Using the same context across several different rules definitely has bigger impact but on a single rule I am not sure how - I'd appreciate more details :)

single-audit speedup

Traced the actual mechanism with instrumented call counters: a single,
fresh evaluation of sia-r65 gets zero benefit (100% Cascade.get()
cache miss with or without interning). The ~131s -> ~0.4s figure only
happens on a *second* evaluation of the *same* already-loaded page,
because the cache is scoped to the Document/device, not to one rule
run. Since production evaluates every rule exactly once per page, this
fix has no measurable real-world performance impact - it remains
correct and worth keeping (removes a real cache-identity bug), just
not for the reason originally stated.
@lukasbob

lukasbob commented Sep 2, 2026

Copy link
Copy Markdown
Author

@lukasbob thanks a lot for this work, Mads! I have a question around Measured impact and the After improvements. How was this measured? Was the second run performed after the first run? Or were they performed independently? I am asking because for instance in packages/alfa-rules/src/sia-r65/rule.ts, I see withFocus object is passed down to all other functions and it's the only reference to the Context so I don't fully understand how it makes such big difference. Using the same context across several different rules definitely has bigger impact but on a single rule I am not sure how - I'd appreciate more details :)

You are very right to question this. I made a mistake here, by running two passes over the rules, warming the cache in the first run. This is not how the checks are run in our pipeline, so the real impact is likely negligible. Apologies!

@pooya-si

pooya-si commented Sep 3, 2026

Copy link
Copy Markdown
Contributor

@lukasbob thanks a lot for this work, Mads! I have a question around Measured impact and the After improvements. How was this measured? Was the second run performed after the first run? Or were they performed independently? I am asking because for instance in packages/alfa-rules/src/sia-r65/rule.ts, I see withFocus object is passed down to all other functions and it's the only reference to the Context so I don't fully understand how it makes such big difference. Using the same context across several different rules definitely has bigger impact but on a single rule I am not sure how - I'd appreciate more details :)

You are very right to question this. I made a mistake here, by running two passes over the rules, warming the cache in the first run. This is not how the checks are run in our pipeline, so the real impact is likely negligible. Apologies!

Thanks a lot for the details, and please don't apologize for this great work. I learned about the performance bottlenecks here and I really appreciate it :-)
I am trying find a way to test this optimization

Sign up for free to join this conversation on GitHub. Already have an account? Sign in to comment

Labels

None yet

Projects

None yet

Development

Successfully merging this pull request may close these issues.

3 participants