Repository navigation
Description
Description
Starting with nwsapi 2.2.26, matching :modal under jsdom causes a large number of recursive Element.matches() calls and a severe performance regression.
The issue is still present in:
nwsapi2.2.27, the latest npm release- the current
masterbranch at commit2e9498f
The regression appears to come from commit 24cdab6, which introduced common native detection for display-state pseudo-classes.
Minimal reproduction
mkdir nwsapi-modal-repro
cd nwsapi-modal-repro
npm init -y
npm install [email protected] [email protected]Create reproduce.js:
const { performance } = require('node:perf_hooks');
const { JSDOM } = require('jsdom');
const dom = new JSDOM('<!doctype html><html><body><div></div></body></html>');
const element = dom.window.document.body.firstElementChild;
const start = performance.now();
for (let index = 0; index < 10; index += 1) {
element.matches(':modal');
}
console.log({
nwsapi: require('nwsapi/package.json').version,
durationMs: performance.now() - start,
});Run:
node reproduce.jsResults
Tested with Node.js 24.15.0 and jsdom 20.0.3:
| nwsapi version | 10 calls to element.matches(':modal') |
|---|---|
| 2.2.25 | 2.915 ms |
| 2.2.26 | 3148.128 ms |
| 2.2.27 | 3034.784 ms |
master at 2e9498f |
3063.912 ms |
The exact timings vary, but the version boundary is consistent. Pinning nwsapi to 2.2.25 removes the slowdown.
Recursive call profile
Instrumenting Element.prototype.matches around one top-level element.matches(':modal') call produced:
| nwsapi version | :modal calls |
:fullscreen calls |
Duration |
|---|---|---|---|
| 2.2.25 | 1 | 0 | 2.619 ms |
| 2.2.27 | 868 | 1,424,730 | 171.016 ms |
In a downstream six-test Jest file using Floating UI, this expanded to approximately 127 million :fullscreen checks and caused tests to exceed a 50-second timeout.
Suspected cause
The new implementation contains this fallback:
matchesNative =
function(node, selector) {
var matcher = _matches || node.matches || node.webkitMatchesSelector ||
node.mozMatchesSelector || node.msMatchesSelector;
if (!matcher) return false;
try {
return matcher.call(node, selector);
} catch (e) {
return false;
}
},
isFullscreen =
function(node) {
var doc = node.ownerDocument;
return matchesNative(node, ':fullscreen') || !!(doc && (
doc.fullscreenElement === node ||
doc.webkitFullscreenElement === node ||
doc.mozFullScreenElement === node ||
doc.msFullscreenElement === node));
},
isModal =
function(node) {
return matchesNative(node, ':modal') || isFullscreen(node);
},jsdom implements Element.matches() by delegating to nwsapi. When nwsapi is used as jsdom's selector engine, _matches is not set through Dom.install(), so matchesNative() falls back to node.matches. That routes back into nwsapi:
jsdom Element.matches(':modal')
-> nwsapi match(':modal')
-> isModal()
-> matchesNative(node, ':modal')
-> jsdom Element.matches(':modal')
-> nwsapi match(':modal')
...
isModal() also falls back to isFullscreen(), producing the much larger number of :fullscreen evaluations.
Expected behavior
Matching :modal or :fullscreen in an environment without native support should return the result derived from observable DOM state, or false, without recursively invoking the same selector engine.
Possible fix direction
matchesNative() should avoid falling back to node.matches when that method is backed by the current nwsapi instance. For example, the display-state helpers could use a genuinely preserved native matcher only, use observable DOM properties when available, and otherwise return false.
A re-entrancy guard around native detection may also prevent recursion, but avoiding the non-native fallback would address the underlying integration problem.
As a local check, I added a module-level re-entrancy flag around matcher.call(node, selector) and returned false from nested calls. On master at 2e9498f, this reduced the minimal reproduction from 3063.912 ms to 3.253 ms for ten :modal matches. This confirms the recursive path, although the preferred fix may depend on the browser compatibility expected from matchesNative().
Activity
jdalton commentedon Sep 5, 2026
This has the same cause as #172, and your bisect to
24cdab6matches: jsdom routes the display-state matcher back into nwsapi.Correction to my earlier explanation: #178 now caches matcher records per document on first use, with a last-document fast path and a remembered delegation result. It does not capture every matcher at startup or repeatedly inspect function source. A re-entry guard stops recursive calls; working browser matchers still run to read changing state. The persistent per-document cache uses WeakMap, with a guarded single-document fallback in legacy environments without it.
#178 at
7a8a32eincludes 27 Node tests, including the original jsdom 26.1.0 Element.matches reproduction, plus five Chromium scenarios. The PR contains the engine change, regression tests and reproduction instructions. It is still open, not a released fix.Stop state pseudo-classes from re-entering the engine