Skip to content

:modal matching recursively calls Element.matches()` under jsdom since 2.2.26 #177

Closed

Description

@karol-f

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:

  • nwsapi 2.2.27, the latest npm release
  • the current master branch at commit 2e9498f

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.js

Results

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

  1. jdalton commented on Sep 5, 2026

    @jdalton
    Collaborator

    This has the same cause as #172, and your bisect to 24cdab6 matches: 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 7a8a32e includes 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.

  2. added a commit that references this issue on Sep 17, 2026
    a6e79c4
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

    Projects

    No projects

      Milestone

      No milestone

      Relationships

      None yet

      Development

      No branches or pull requests

      Issue actions