Skip to content

Commit 175ef77

Browse files
committed
perf(template-require-mandatory-role-attributes): pre-index elementAXObjects by tag (Copilot review)
Benchmarked ~12.5x speedup on isSemanticRoleElement. The naive impl walked the full elementAXObjects map per call (O(concepts × axObjects × roles)); pre-indexing resolves each concept's exposed-role set once at module load and buckets concepts by tag, reducing the per-call hot path to a handful of entries per tag. Benchmark: 200k calls on a realistic tag/role mix — current 154 ms, indexed 12 ms. Behavior-preserving (140/140 parity combos verified before landing; 84/84 rule-test suite passes).
1 parent 4cd0bfc commit 175ef77

2 files changed

Lines changed: 231 additions & 24 deletions

File tree

bench-local.js

Lines changed: 194 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,194 @@
1+
'use strict';
2+
3+
const { AXObjectRoles, elementAXObjects } = require('axobject-query');
4+
5+
// Stand-in for the rule's getStaticAttrValue — just reads the attr map directly.
6+
function getStaticAttrValue(node, name) {
7+
return node.attrs[name];
8+
}
9+
10+
function getTagName(node) {
11+
return node.tag;
12+
}
13+
14+
// ─────────────────────────────────────────────────────────────────
15+
// Impl A: CURRENT (walk elementAXObjects every call).
16+
// ─────────────────────────────────────────────────────────────────
17+
function isSemanticRoleElement_current(node, role) {
18+
const tag = getTagName(node);
19+
if (!tag || typeof role !== 'string') return false;
20+
const targetRole = role.toLowerCase();
21+
22+
for (const [concept, axObjectNames] of elementAXObjects) {
23+
if (concept.name !== tag) continue;
24+
const conceptAttrs = concept.attributes || [];
25+
const allMatch = conceptAttrs.every((cAttr) => {
26+
const nodeVal = getStaticAttrValue(node, cAttr.name);
27+
if (nodeVal === undefined) return false;
28+
if (cAttr.value === undefined) return true;
29+
return nodeVal === String(cAttr.value).toLowerCase();
30+
});
31+
if (!allMatch) continue;
32+
33+
for (const axName of axObjectNames) {
34+
const axRoles = AXObjectRoles.get(axName);
35+
if (!axRoles) continue;
36+
for (const axRole of axRoles) {
37+
if (axRole.name === targetRole) return true;
38+
}
39+
}
40+
}
41+
return false;
42+
}
43+
44+
// ─────────────────────────────────────────────────────────────────
45+
// Impl B: PRE-INDEXED (build tag → [{attrs, roles}] map once).
46+
// ─────────────────────────────────────────────────────────────────
47+
const TAG_INDEX = buildTagIndex();
48+
49+
function buildTagIndex() {
50+
const index = new Map();
51+
for (const [concept, axObjectNames] of elementAXObjects) {
52+
// Collect the set of roles the concept can expose.
53+
const roles = new Set();
54+
for (const axName of axObjectNames) {
55+
const axRoles = AXObjectRoles.get(axName);
56+
if (!axRoles) continue;
57+
for (const axRole of axRoles) {
58+
roles.add(axRole.name);
59+
}
60+
}
61+
const entry = {
62+
attributes: concept.attributes || [],
63+
roles,
64+
};
65+
if (!index.has(concept.name)) {
66+
index.set(concept.name, []);
67+
}
68+
index.get(concept.name).push(entry);
69+
}
70+
return index;
71+
}
72+
73+
function isSemanticRoleElement_indexed(node, role) {
74+
const tag = getTagName(node);
75+
if (!tag || typeof role !== 'string') return false;
76+
const entries = TAG_INDEX.get(tag);
77+
if (!entries) return false;
78+
const targetRole = role.toLowerCase();
79+
80+
for (const { attributes, roles } of entries) {
81+
if (!roles.has(targetRole)) continue;
82+
const allMatch = attributes.every((cAttr) => {
83+
const nodeVal = getStaticAttrValue(node, cAttr.name);
84+
if (nodeVal === undefined) return false;
85+
if (cAttr.value === undefined) return true;
86+
return nodeVal === String(cAttr.value).toLowerCase();
87+
});
88+
if (allMatch) return true;
89+
}
90+
return false;
91+
}
92+
93+
// ─────────────────────────────────────────────────────────────────
94+
// Sanity-check: both impls produce identical results across a
95+
// realistic set of (tag, attrs, role) triples.
96+
// ─────────────────────────────────────────────────────────────────
97+
const TEST_NODES = [
98+
{ tag: 'input', attrs: { type: 'checkbox' } },
99+
{ tag: 'input', attrs: { type: 'radio' } },
100+
{ tag: 'input', attrs: { type: 'text' } },
101+
{ tag: 'input', attrs: { type: 'submit' } },
102+
{ tag: 'input', attrs: { type: 'search' } },
103+
{ tag: 'button', attrs: {} },
104+
{ tag: 'a', attrs: { href: '/x' } },
105+
{ tag: 'a', attrs: {} },
106+
{ tag: 'select', attrs: {} },
107+
{ tag: 'textarea', attrs: {} },
108+
{ tag: 'div', attrs: {} },
109+
{ tag: 'h1', attrs: {} },
110+
{ tag: 'img', attrs: { alt: 'x' } },
111+
{ tag: 'nav', attrs: {} },
112+
];
113+
const TEST_ROLES = [
114+
'button',
115+
'checkbox',
116+
'switch',
117+
'radio',
118+
'link',
119+
'heading',
120+
'textbox',
121+
'searchbox',
122+
'img',
123+
'navigation',
124+
];
125+
126+
let mismatches = 0;
127+
for (const node of TEST_NODES) {
128+
for (const role of TEST_ROLES) {
129+
const a = isSemanticRoleElement_current(node, role);
130+
const b = isSemanticRoleElement_indexed(node, role);
131+
if (a !== b) {
132+
mismatches++;
133+
console.log(`MISMATCH: tag=${node.tag} attrs=${JSON.stringify(node.attrs)} role=${role} current=${a} indexed=${b}`);
134+
}
135+
}
136+
}
137+
console.log(`Parity check: ${mismatches === 0 ? 'OK' : 'FAIL'} (${TEST_NODES.length * TEST_ROLES.length} combos)`);
138+
139+
// ─────────────────────────────────────────────────────────────────
140+
// Benchmark: realistic call volume for a large template.
141+
// Scenario: a template with ~200 elements carrying roles (generous
142+
// upper bound — most real files have far fewer), rule runs the full
143+
// file. Each visit calls isSemanticRoleElement once.
144+
// ─────────────────────────────────────────────────────────────────
145+
const CALL_VOLUME = 200; // elements with roles per lint run
146+
const ITERATIONS = 1000; // how many times we re-run the full-file scan
147+
// (simulating `eslint .` over a medium project
148+
// where this rule fires hundreds of times per
149+
// file × many files — 200k calls total).
150+
151+
function buildCallList() {
152+
const calls = [];
153+
for (let i = 0; i < CALL_VOLUME; i++) {
154+
const node = TEST_NODES[i % TEST_NODES.length];
155+
const role = TEST_ROLES[i % TEST_ROLES.length];
156+
calls.push({ node, role });
157+
}
158+
return calls;
159+
}
160+
161+
const calls = buildCallList();
162+
163+
// Warmup
164+
for (const { node, role } of calls) {
165+
isSemanticRoleElement_current(node, role);
166+
isSemanticRoleElement_indexed(node, role);
167+
}
168+
169+
// Time current impl
170+
const t1 = process.hrtime.bigint();
171+
for (let i = 0; i < ITERATIONS; i++) {
172+
for (const { node, role } of calls) {
173+
isSemanticRoleElement_current(node, role);
174+
}
175+
}
176+
const t2 = process.hrtime.bigint();
177+
const currentMs = Number(t2 - t1) / 1e6;
178+
179+
// Time indexed impl
180+
const t3 = process.hrtime.bigint();
181+
for (let i = 0; i < ITERATIONS; i++) {
182+
for (const { node, role } of calls) {
183+
isSemanticRoleElement_indexed(node, role);
184+
}
185+
}
186+
const t4 = process.hrtime.bigint();
187+
const indexedMs = Number(t4 - t3) / 1e6;
188+
189+
const totalCalls = ITERATIONS * CALL_VOLUME;
190+
console.log(`Total calls: ${totalCalls.toLocaleString()}`);
191+
console.log(`Current: ${currentMs.toFixed(1)} ms (${(currentMs * 1000 / totalCalls).toFixed(2)} µs/call)`);
192+
console.log(`Indexed: ${indexedMs.toFixed(1)} ms (${(indexedMs * 1000 / totalCalls).toFixed(2)} µs/call)`);
193+
console.log(`Speedup: ${(currentMs / indexedMs).toFixed(1)}x`);
194+
console.log(`Diff: ${(currentMs - indexedMs).toFixed(1)} ms saved over ${totalCalls.toLocaleString()} calls`);

lib/rules/template-require-mandatory-role-attributes.js

Lines changed: 37 additions & 24 deletions
Original file line numberDiff line numberDiff line change
@@ -68,24 +68,49 @@ function getTagName(node) {
6868
// Mirrors jsx-a11y's `isSemanticRoleElement` util
6969
// (https://github.com/jsx-eslint/eslint-plugin-jsx-a11y/blob/main/src/util/isSemanticRoleElement.js).
7070
//
71-
// Perf note: this walks the full `elementAXObjects` map for every call, giving
72-
// an O(n·m) scan per node (n = concepts, m = axObject→roles). In practice the
73-
// map is small (~dozens of entries) and callers only invoke this after a role
74-
// attribute has already been matched, so it hasn't shown up as a hotspot.
75-
// A future optimization could precompute a `{tag,role} → boolean` lookup.
71+
// Pre-indexed at module load: elementAXObjects is static data, so we resolve
72+
// each concept's exposed-role set once (walking axObjectNames → AXObjectRoles
73+
// → role names) and bucket concepts by tag. That turns the per-call hot path
74+
// into O(concepts-for-this-tag × attrs-on-that-concept), which in practice
75+
// is a handful of entries. Benchmarked at ~12.5× faster than the naive full-
76+
// map walk on a realistic 200k-call workload.
77+
const AX_CONCEPTS_BY_TAG = buildAxConceptsByTag();
78+
79+
function buildAxConceptsByTag() {
80+
const index = new Map();
81+
for (const [concept, axObjectNames] of elementAXObjects) {
82+
const roles = new Set();
83+
for (const axName of axObjectNames) {
84+
const axRoles = AXObjectRoles.get(axName);
85+
if (!axRoles) continue;
86+
for (const axRole of axRoles) {
87+
roles.add(axRole.name);
88+
}
89+
}
90+
const entry = { attributes: concept.attributes || [], roles };
91+
if (!index.has(concept.name)) {
92+
index.set(concept.name, []);
93+
}
94+
index.get(concept.name).push(entry);
95+
}
96+
return index;
97+
}
98+
7699
function isSemanticRoleElement(node, role) {
77100
const tag = getTagName(node);
78101
if (!tag || typeof role !== 'string') {
79102
return false;
80103
}
104+
const entries = AX_CONCEPTS_BY_TAG.get(tag);
105+
if (!entries) {
106+
return false;
107+
}
81108
const targetRole = role.toLowerCase();
82-
83-
for (const [concept, axObjectNames] of elementAXObjects) {
84-
if (concept.name !== tag) {
109+
for (const { attributes, roles } of entries) {
110+
if (!roles.has(targetRole)) {
85111
continue;
86112
}
87-
const conceptAttrs = concept.attributes || [];
88-
const allMatch = conceptAttrs.every((cAttr) => {
113+
const allMatch = attributes.every((cAttr) => {
89114
const nodeVal = getStaticAttrValue(node, cAttr.name);
90115
if (nodeVal === undefined) {
91116
return false;
@@ -95,20 +120,8 @@ function isSemanticRoleElement(node, role) {
95120
}
96121
return nodeVal === String(cAttr.value).toLowerCase();
97122
});
98-
if (!allMatch) {
99-
continue;
100-
}
101-
102-
for (const axName of axObjectNames) {
103-
const axRoles = AXObjectRoles.get(axName);
104-
if (!axRoles) {
105-
continue;
106-
}
107-
for (const axRole of axRoles) {
108-
if (axRole.name === targetRole) {
109-
return true;
110-
}
111-
}
123+
if (allMatch) {
124+
return true;
112125
}
113126
}
114127
return false;

0 commit comments

Comments
 (0)