diff --git a/.changeset/context-interning.md b/.changeset/context-interning.md new file mode 100644 index 0000000000..984f684952 --- /dev/null +++ b/.changeset/context-interning.md @@ -0,0 +1,7 @@ +--- +"@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 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. 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 {