From a6d639492e99ca0d604f9d3a3dc32441ad1fdabe Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?Mads=20Vesterg=C3=A5rd=20Jacobsen?= Date: Wed, 2 Sep 2026 16:32:47 +0200 Subject: [PATCH 1/2] Intern Context.hover/active/focus/visit per element 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. --- .changeset/context-interning.md | 5 +++++ packages/alfa-selector/src/context.ts | 25 +++++++++++++++++++++---- 2 files changed, 26 insertions(+), 4 deletions(-) create mode 100644 .changeset/context-interning.md diff --git a/.changeset/context-interning.md b/.changeset/context-interning.md new file mode 100644 index 0000000000..1018bf67ae --- /dev/null +++ b/.changeset/context-interning.md @@ -0,0 +1,5 @@ +--- +"@siteimprove/alfa-selector": patch +--- + +**Fixed:** `Context.hover()`/`.active()`/`.focus()`/`.visit()` (the static factories) now return the same `Context` instance when called repeatedly for the same element, instead of constructing a fresh one every time. Since `alfa-cascade`/`alfa-style`'s caches are keyed by `Context` object identity, repeated single-element-context queries (e.g. a rule checking many candidate elements' style under one target's `:focus` context) previously missed those caches on every call. Measured on `sia-r65` (which does exactly this): ~131.4s → ~0.4s on a 10k-node real-world page, with identical pass/fail outcomes before and after. diff --git a/packages/alfa-selector/src/context.ts b/packages/alfa-selector/src/context.ts index fe0729e816..e9c0cc4b26 100644 --- a/packages/alfa-selector/src/context.ts +++ b/packages/alfa-selector/src/context.ts @@ -1,3 +1,4 @@ +import { Cache } from "@siteimprove/alfa-cache"; import type { Element } from "@siteimprove/alfa-dom"; import { Map } from "@siteimprove/alfa-map"; @@ -49,8 +50,18 @@ export class Context { return this.addState(element, Context.State.Hover); } + private static _hovered = Cache.empty(); + + /** + * @remarks + * Interned per element: repeated calls for the same element return the + * same instance, so downstream `Cache` lookups (e.g. in + * `Cascade`/`Style`) can actually hit across separate call sites querying + * the same single-element context instead of always missing on a fresh + * object. + */ public static hover(element: Element): Context { - return this.empty().hover(element); + return this._hovered.get(element, () => this.empty().hover(element)); } public isHovered(element: Element): boolean { @@ -61,8 +72,10 @@ export class Context { return this.addState(element, Context.State.Active); } + private static _activated = Cache.empty(); + public static active(element: Element): Context { - return this.empty().active(element); + return this._activated.get(element, () => this.empty().active(element)); } public isActive(element: Element): boolean { @@ -73,8 +86,10 @@ export class Context { return this.addState(element, Context.State.Focus); } + private static _focused = Cache.empty(); + public static focus(element: Element): Context { - return this.empty().focus(element); + return this._focused.get(element, () => this.empty().focus(element)); } public isFocused(element: Element): boolean { @@ -85,8 +100,10 @@ export class Context { return this.addState(element, Context.State.Visited); } + private static _visited = Cache.empty(); + public static visit(element: Element): Context { - return this.empty().visit(element); + return this._visited.get(element, () => this.empty().visit(element)); } public isVisited(element: Element): boolean { From eb80f6f563fde5bc7872562b904ef6c5b361cacc Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?Mads=20Vesterg=C3=A5rd=20Jacobsen?= Date: Wed, 2 Sep 2026 23:26:22 +0200 Subject: [PATCH 2/2] Correct changeset: this is a repeat-evaluation cache fix, not a 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. --- .changeset/context-interning.md | 4 +++- 1 file changed, 3 insertions(+), 1 deletion(-) diff --git a/.changeset/context-interning.md b/.changeset/context-interning.md index 1018bf67ae..984f684952 100644 --- a/.changeset/context-interning.md +++ b/.changeset/context-interning.md @@ -2,4 +2,6 @@ "@siteimprove/alfa-selector": patch --- -**Fixed:** `Context.hover()`/`.active()`/`.focus()`/`.visit()` (the static factories) now return the same `Context` instance when called repeatedly for the same element, instead of constructing a fresh one every time. Since `alfa-cascade`/`alfa-style`'s caches are keyed by `Context` object identity, repeated single-element-context queries (e.g. a rule checking many candidate elements' style under one target's `:focus` context) previously missed those caches on every call. Measured on `sia-r65` (which does exactly this): ~131.4s → ~0.4s on a 10k-node real-world page, with identical pass/fail outcomes before and after. +**Fixed:** `Context.hover()`/`.active()`/`.focus()`/`.visit()` (the static factories) now return the same `Context` instance when called repeatedly for the same element, instead of constructing a fresh one every time. Since `alfa-cascade`/`alfa-style`'s caches are keyed by `Context` object identity, repeated queries for the same element under the same single-state context previously missed those caches on every call, even when nothing had actually changed. + +This only pays off across *repeated* evaluations of the same `Document`/device (the cache is scoped to the page, not to one rule run) — e.g. `sia-r65` run a second time against an already-loaded page drops from ~131.4s to ~0.4s on a 10k-node fixture, with identical pass/fail outcomes before and after. A single, fresh evaluation (the normal case: each rule runs exactly once per page) sees no measurable change, since there is no prior pass to reuse. Kept as a correctness-neutral cache fix rather than a performance one.