Fix: compile closest() selectors once per document in the static scanner (#575) (#595)

StaticElement.closest() handed the raw selector string to css-select's
is() on every ancestor step, recompiling the same selector N times for
an element N levels deep. StaticDocument now caches one compiled
matcher per selector (failed compiles cached as rethrowers so bad
selectors still return null). Findings are byte-identical across the
fixture corpus; scan time drops to ~62% on the fixtures and ~7x faster
on deep-DOM pages.

Prepared with AI assistance (Cursor agent), directed by @abdulwahabone.

Co-authored-by: Cursor <cursoragent@cursor.com>
This commit is contained in:
Abdul Wahab
2026-08-14 16:15:01 -07:00
committed by GitHub
co-authored by Cursor
parent c88d815e05
commit 886cd669ef
3 changed files with 63 additions and 3 deletions
+18 -2
View File
@@ -835,10 +835,11 @@ class StaticElement {
}
}
closest(selector) {
const matcher = this._doc.matcherFor(selector);
let cur = this.node;
while (cur && cur.type === 'tag') {
try {
if (this._doc.is(cur, selector)) return this._doc.wrap(cur);
if (matcher(cur)) return this._doc.wrap(cur);
} catch {
return null;
}
@@ -862,9 +863,10 @@ class StaticDocument {
this.root = root;
this.selectAll = modules.selectAll;
this.selectOne = modules.selectOne;
this.is = modules.is;
this.compile = modules.compile;
this.domutils = modules.domutils;
this._wrappers = new WeakMap();
this._compiledSelectors = new Map();
this._styleMap = new WeakMap();
this._hoverStyleMap = new WeakMap();
this._accentDashPseudo = new WeakSet();
@@ -882,6 +884,20 @@ class StaticDocument {
}
return wrapped;
}
matcherFor(selector) {
let matcher = this._compiledSelectors.get(selector);
if (!matcher) {
try {
matcher = this.compile(selector);
} catch (err) {
// Cache the failure as a rethrower so a bad selector still reaches
// closest()'s catch on every call, first and repeat alike.
matcher = () => { throw err; };
}
this._compiledSelectors.set(selector, matcher);
}
return matcher;
}
querySelectorAll(selector) {
try {
return this.selectAll(selector, this.root.children || []).map(node => this.wrap(node));
@@ -134,7 +134,7 @@ async function detectHtml(filePath, options = {}) {
parseDocument: htmlparser2.parseDocument,
selectAll: cssSelect.selectAll,
selectOne: cssSelect.selectOne,
is: cssSelect.is,
compile: cssSelect.compile,
csstree,
domutils,
};