Skip to content

Element#matches() compiles the selector on every call #348

Open

Description

@jonasporto

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

  1. visualkhh commented on Oct 31, 2026

    @visualkhh

    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):

    72k calls
    linkedom 0.18.13 ~91ms
    dom-parser 1.0.15 (@dooboostore/dom-parser) ~62ms

    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.

Sign up for free to join this conversation on GitHub. Already have an account? Sign in to comment

Metadata

Metadata

Assignees

No one assigned

    Labels

    No labels
    No labels

    Projects

    No projects

      Milestone

      No milestone

      Relationships

      None yet

      Development

      No branches or pull requests

      Issue actions