Repository navigation
Description
Summary
Since 2.2.26, Element.matches(':modal') under jsdom takes ~1.3 seconds per call (0.05ms on 2.2.25) and burns a full JS call stack each time. The cause is unbounded self-recursion in the new matchesNative fallback: under jsdom, node.matches IS nwsapi, so isModal -> matchesNative(node, ':modal') -> nwsapi :modal -> isModal -> ... until the stack overflows; the try/catch in matchesNative swallows the RangeError and returns false.
This makes 2.2.26 and 2.2.27 effectively unusable for a large class of jsdom test suites: @floating-ui/dom (the positioning engine behind Radix UI, Headless UI, etc.) calls element.matches(':modal') in isTopLayer() on every position computation, so every popover/select/tooltip interaction in a jsdom test pays multiple ~1.3s stack exhaustions. In our suite, tests that run in 16s on 2.2.25 take ~525s with 7 timeouts on 2.2.26/2.2.27. Since jsdom pins nwsapi: ^2.2.12, every fresh install gets the regression.
Minimal reproduction
// npm i jsdom (jsdom 25.0.1, resolves nwsapi ^2.2.12 -> 2.2.27)
const { JSDOM } = require('jsdom');
const dom = new JSDOM('<!doctype html><body><button id="b">x</button></body>');
const el = dom.window.document.getElementById('b');
el.matches(':modal'); // warm-up
const N = 20, t0 = Date.now();
for (let i = 0; i < N; i++) el.matches(':modal');
console.log(`${(Date.now() - t0) / N}ms per matches(':modal') call`);Measured (node 22.23.1, same machine, same trivial one-button document):
| nwsapi | per matches(':modal') call |
control matches(':enabled') |
|---|---|---|
| 2.2.25 | 0.05 ms | ~0.05 ms |
| 2.2.26 | 1,277 ms | ~0.05 ms |
| 2.2.27 | 1,329 ms | ~0.05 ms |
Evidence it is recursion-to-stack-overflow
Per-call time scales with the engine stack size, which only fits the stack-exhaustion mechanism:
node repro.js -> 1,275 ms per call
node --stack-size=200 repro.js -> 47 ms per call
Root cause (2.2.26/2.2.27 src/nwsapi.js)
matchesNative =
function(node, selector) {
var matcher = _matches || node.matches || node.webkitMatchesSelector || ...
try { return matcher.call(node, selector); }
catch (e) { return false; }
},
...
isModal =
function(node) {
return matchesNative(node, ':modal') || isFullscreen(node);
},In a browser, node.matches is the native engine and this terminates. Under jsdom (and any host that wires Element.prototype.matches to nwsapi itself), matcher.call(node, ':modal') re-enters nwsapi, whose compiled :modal lambda calls s.isModal(e) again -> infinite recursion -> RangeError: Maximum call stack size exceeded -> swallowed by the catch -> false.
Two consequences:
- Performance: every
matches(':modal')costs one full stack exhaustion (~1.3s at default stack size). - Correctness: the swallowed RangeError means
:modalunconditionally reportsfalsein such environments, even where the host has real dialog state to answer from.
The same matchesNative-fallback shape is used by the other display-state pseudo-classes added in 2.2.26 (:open, :closed, :fullscreen, :picture-in-picture, :popover-open); most short-circuit on a portable DOM check first, so they recurse only when that check is false (e.g. :open on a non-open element). I measured :modal only, which has no portable check before the native call.
Suggested direction
Guard matchesNative against re-entry: skip the native fallback when the resolved matcher is nwsapi's own installed function (or use a simple re-entrancy flag keyed by selector), and fall back to the portable state checks (dialog.open + jsdom's modal flag where exposed) instead. A recursion guard would fix both the runtime blow-up and the always-false result.
Happy to test a fix against our Radix/floating-ui/jsdom suite that surfaced this.
Activity
MirKml commentedon Aug 31, 2026
I think duplicate of #171, solved by PR mentioned inside #171
dperini commentedon Sep 1, 2026
@EGMW2 @MirKml
I will release 2.2.28 now to let you try without the need of resorting to the pseudo rewrite stuff of 2.2.27.
I believe this is what is needed to get speed, uncluttered code and at the same time avoid using nativeMatches.
Please recall we have a pretty flexible configuration flags system that will allow for very specific usage of the engine.
I get 0.1ms per matches(':modal') call from the above test.
Comments are welcome,
dperini commentedon Sep 1, 2026
@MirKml
here is a link to download the mentioned version with nativeMatches removed:
nwsapi-demo.js
to try out and see if performance are kept and if other issue will float.
MirKml commentedon Sep 1, 2026
hm, do you have any npm package?
The problem is with the jest test through jsdom on node.js. I can try backup copy some .js from node_modules back and fort, but it's so hacky and error prone.
MirKml commentedon Sep 1, 2026
I will try to swap
node_modules/nwsapi/src/nwsapi.jswith your nwsapi-demo.jsresult: seems doesn't work
it throws error
let me know how can I test it - nwsapi is used as installed npm package with
npm installdperini commentedon Sep 1, 2026
@MirKml
sorry I forgot to remove remaining of the nativeMatches" and related references.
Now it should work. The way you did it is correct, replace the installed nwsapi.js wth the one you download.
I am very interest to know if the speed is enough for the floating-ui component and other similar,
please if you can include a comparison. This will tell us if it can be done the simplest way.
By using it this way we avoid several problem from the beginning, we leave a completely empty lane for other possible usage and in particular we don't create races points with other components that might need to use "nativeMatches" the way we have seen.
So please download it again and retry, ensure you clear the browser cache before you attempt the download.
The signal that the ile is correct is that it will not contain any reference to isOpen.
MirKml commentedon Sep 2, 2026
so I tested 2.2.25, 2.2.27, your nwsapi-demo.js
one simple test which opens one popup with floating-ui
2.2.25
PDDateTimeIntervalPicker component
√ opens a popup with two calendars after clicking the input (198 ms)
Test Suites: 1 passed, 1 total
Tests: 1 passed, 1 total
Snapshots: 0 total
Time: 2.204 s
Ran all test suites matching PDDateTimeInterval.
2.2.27 (regression)
PDDateTimeIntervalPicker component
√ opens a popup with two calendars after clicking the input (25128 ms)
Test Suites: 1 passed, 1 total
Tests: 1 passed, 1 total
Snapshots: 0 total
Time: 64.566 s
Ran all test suites matching PDDateTimeInterval.
your demo with the fix
PDDateTimeIntervalPicker component
√ opens a popup with two calendars after clicking the input (202 ms)
Test Suites: 1 passed, 1 total
Tests: 1 passed, 1 total
Snapshots: 0 total
Time: 2.224 s
Ran all test suites matching PDDateTimeInterval.
complete test suite with many calls - total time is even better with your fix 3.325s vs 3.378s on 2.2.25
So I think it's fixed 👍, difference between 2.2.25 and your fix is negligible, especially when test suite contains many floating ui calls which hit affected selectors
dperini commentedon Sep 2, 2026
@MirKml
Great help thank you.
This extended test confirms I have not forgotten pieces of code as I did with the demo.
The fact made me happy and I wish I had more test to work on because I have not tried areas similar to floating-ui.
Talking with some developers at floating-ui may reveal the need to collaborate with them to better fit their needs.
When working with :modal having ID it is possible to configure nwsapi in a way that almost double speed of the queries by id, and the fact that the principal API in nwsapi accept a callback function as parameter can switch extra options they ght be interested in.
My time is going almost completely to these projects in the future, giving support and spread the news functionalities for both headless and client browsers.
Thank you again for the time you put on this.
MirKml commentedon Sep 3, 2026
Thanks.
I think this problem is mostly related to jsdom, particularly jest jsdom wrapper.
Floating-ui uses nwsapi only as third party deps same way - through jest/jsdom testing. They don't care about selector engine for browser related code, maybe they could :-).
But if there are some nwsapi globals environment configs, which can work with jsdom/node too, it can be helpful also for 3rd party usage of nswapi with cli/node for better test performance etc.
Just note, as I see - current jsdom - 30.0.1 - doesn't depend on nwsapi. Problem is with the jest wrapper - it uses way more older jsdom implementation 26.1.0 which uses nwsapi and calls it also for element.matches.
jdalton commentedon Sep 5, 2026
The recursion comes from state pseudo-classes asking the host matcher, while jsdom routes that matcher back into nwsapi. #178 addresses this without disabling real browser state matching.
Correction to my earlier description: the current implementation does not capture every document's matcher at factory startup. It resolves a document's matcher on first use, caches the record, and remembers if that matcher delegates back into the engine. A last-document fast path avoids repeated map lookups. Re-entry returns false for the nested call; the outer helper can still use its DOM-state fallback. With WeakMap, switching documents retains each document's delegation result. Without it, legacy mode keeps the guarded single-document fallback.
The PR includes 27 Node tests, including jsdom 26.1.0's actual Element.matches route, and five Chromium scenarios covering changing modal/popover state and existing factory call shapes. jsdom 30 alone would not exercise the reported nwsapi-backed path.
Current code and reproduction instructions: #178 at
7a8a32e. This remains an open PR, not a released fix. #171 and #177 track the same recursion/performance problem.Stop state pseudo-classes from re-entering the engine