Repository navigation
Description
Element#matches(selectors) parses and compiles the selector again on every call. Code that tests many elements against the same few selectors (resolving a <style> sheet over an SVG chart, for example) spends most of its time in css-what's parser and css-select's compiler rather than in matching.
// npm i linkedom && node matches.mjs
import { parseHTML } from 'linkedom';
let svg = '<svg id="chart">';
for (let i = 0; i < 400; i++)
svg += `<g class="node n${i % 7}"><rect class="box"></rect><g class="label"><text class="title"><tspan>${i}</tspan></text></g></g>`;
const { document } = parseHTML(`<!doctype html><html><body>${svg}</svg></body></html>`);
const selectors = [];
for (const a of ['#chart', '#chart .node', '#chart g.label', 'svg'])
for (const b of ['rect', 'text', 'tspan', '.label', '.title', '.box', '.n3', 'g', '.cluster rect', '.edgeLabel', 'g.node', '.missing'])
selectors.push(`${a} ${b}`);
const elements = [...document.querySelectorAll('svg, svg *')];
const t = performance.now();
for (const el of elements)
for (const sel of selectors)
el.matches(sel);
console.log(`${elements.length * selectors.length} calls: ${(performance.now() - t).toFixed(0)} ms`);Node 22.17.0, macOS, median of runs:
96,048 matches() calls |
|
|---|---|
| linkedom 0.18.13 | ~112 ms |
linkedom main (3fcf145) |
~108 ms |
| same, with the compiled selector kept per selector string | ~20 ms |
A larger run (2,001 elements × 50 selectors, 300k calls), in ms per 100k calls: linkedom 0.18.13 158, main 111, with the compiled selector kept 17; happy-dom 21.0.3 10, jsdom 30.1.3 209.
A CPU profile of main shows css-what parseSelector/unescapeCSS, css-select compileToken/escapeRegex and GC on top; the matching itself is a small share.
Where: esm/shared/matches.js#L109 passes the selector string to CSSselect.is(), which calls compile(query, options) each time (css-select 7.0.0, dist/index.js, is). querySelector* and closest() compile once per call through prepareMatch, so it is matches() that pays per element.
Possible fix: keep the compiled function per selector string and document kind (xmlMode), in a small bounded Map; selectors with :scope keep compiling per call since their context is the element. I have this with a test (coverage unchanged) and can open a PR.
Activity
visualkhh commentedon Oct 31, 2026
Ran into this — I hit the same shape on my stack (400 svg groups, 2001 elements x 36 selectors = 72k
matches()calls, median of 3 runs, Node 24):I'm on @dooboostore/dom-parser — my parser's matcher is hand-rolled with no compile step and no per-selector cache — matching runs right-to-left over compounds directly. I mention it because I hit the staleness cost in the cache direction (PR #349, and happy-dom's approach): see happy-dom#2489, where a cached
matches()result survives ancestor mutations. Skipping the cache entirely sidesteps that bug class for me while staying within ~1.5x of the cached implementations — and this part just works now.