diff --git a/cli/engine/engines/static-html/css-cascade.mjs b/cli/engine/engines/static-html/css-cascade.mjs index caf4aff44..e5f53b69a 100644 --- a/cli/engine/engines/static-html/css-cascade.mjs +++ b/cli/engine/engines/static-html/css-cascade.mjs @@ -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)); diff --git a/cli/engine/engines/static-html/detect-html.mjs b/cli/engine/engines/static-html/detect-html.mjs index 9e7a429a2..b4efa84ac 100644 --- a/cli/engine/engines/static-html/detect-html.mjs +++ b/cli/engine/engines/static-html/detect-html.mjs @@ -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, }; diff --git a/tests/detect-antipatterns.test.js b/tests/detect-antipatterns.test.js index a61fa6e6d..2c7141365 100644 --- a/tests/detect-antipatterns.test.js +++ b/tests/detect-antipatterns.test.js @@ -10,6 +10,10 @@ import { buildImportGraph, resolveImport, detectFrameworkConfig, isPortListening, FRAMEWORK_CONFIGS, } from '../cli/engine/detect-antipatterns.mjs'; +import * as htmlparser2 from 'htmlparser2'; +import * as cssSelect from 'css-select'; +import * as domutils from 'domutils'; +import { StaticDocument } from '../cli/engine/engines/static-html/css-cascade.mjs'; import { filterByScopes } from '../cli/engine/registry/antipatterns.mjs'; import { checkColors, @@ -1236,6 +1240,46 @@ describe('detectHtml — static HTML/CSS engine', () => { }); }); +describe('StaticDocument.closest — compiled selector cache', () => { + test('compiles each selector once per document', () => { + let compileCount = 0; + const compile = (sel) => { + compileCount++; + return cssSelect.compile(sel); + }; + const root = htmlparser2.parseDocument( + '
x
'); + const doc = new StaticDocument(root, { + selectAll: cssSelect.selectAll, + selectOne: cssSelect.selectOne, + compile: cssSelect.compile, + domutils, + }); + const p = doc.querySelector('p'); + expect(p.closest('p:has-invalid(')).toBeNull(); + expect(p.closest('p:has-invalid(')).toBeNull(); + }); +}); + // --------------------------------------------------------------------------- // Side-tab as absolutely-positioned pseudo-element stripe // ---------------------------------------------------------------------------