Skip to content

2.2.26+: matches(':modal') recurses to stack overflow under jsdom - ~1.3s per call, always returns false #172

Closed

Description

@EGMW2

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:

  1. Performance: every matches(':modal') costs one full stack exhaustion (~1.3s at default stack size).
  2. Correctness: the swallowed RangeError means :modal unconditionally reports false in 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

  1. MirKml commented on Aug 31, 2026

    @MirKml

    I think duplicate of #171, solved by PR mentioned inside #171

  2. dperini commented on Sep 1, 2026

    @dperini
    Owner

    @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,

  3. dperini commented on Sep 1, 2026

    @dperini
    Owner

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

  4. MirKml commented on Sep 1, 2026

    @MirKml

    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.

  5. MirKml commented on Sep 1, 2026

    @MirKml

    I will try to swap node_modules/nwsapi/src/nwsapi.js with your nwsapi-demo.js
    result: seems doesn't work
    it throws error

    ReferenceError: isOpen is not defined
          at Factory (../node_modules/nwsapi/src/nwsapi.js:2060:13)
          at initNwsapi (../node_modules/jsdom/lib/jsdom/living/helpers/selectors.js:10:10)
    

    let me know how can I test it - nwsapi is used as installed npm package with npm install

  6. dperini commented on Sep 1, 2026

    @dperini
    Owner

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

  7. MirKml commented on Sep 2, 2026

    @MirKml

    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

  8. dperini commented on Sep 2, 2026

    @dperini
    Owner

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

  9. MirKml commented on Sep 3, 2026

    @MirKml

    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.

  10. jdalton commented on Sep 5, 2026

    @jdalton
    Collaborator

    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.

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