Prevent recursive state pseudo-class matching in jsdom - #170
Open
grablair wants to merge 1 commit into
Open
Conversation
grablair
marked this pull request as ready for review
August 30, 2026 17:34
jdalton
added a commit
to jdalton/nwsapi
that referenced
this pull request
Sep 4, 2026
Four defects, all present in plain 2.2.27 and all reproduced here before being fixed. Three arrived with the 2.2.25 compiler rework (upstream 7a22775); the fourth arrived with the 2.2.26 state pseudo-classes. Forgiving :is()/:where(). The fallback tested /(:(?:is|where)\\x28)/, where the doubled escape matches a literal backslash rather than an opening parenthesis, so ':not(:is(svg|div))' raised "unknown pseudo-class selector" instead of matching nothing. EOF-terminated arguments. The linguistic, logicalsel and treestruct groups lost their '(?:\x29|$)' terminator, so ':not([class]' and 'meta[charset="utf-8"' were parse errors rather than being closed by EOF the way the CSS Syntax parser closes any open construct (/css/selectors/missing-right-token.html). Restoring the terminator alone reintroduces the bug it papered over: with '[^()]*|.*' the greedy alternative swallows the closing parenthesis of a nested argument, so ':not(:is(div))' compiles ':is(div))'. A regular expression cannot track nesting, so the argument of :is, :where, :matches, :not and :has is now delimited by matchLogical(), which scans for the balanced closing parenthesis, honoring quotes and escapes, and falls back to EOF. :has() anchoring. The relative argument was compiled by prefixing ':scope ', then collect() was called directly. ':scope' compiles to a comparison against Snapshot.from, which only select() keeps up to date, so inside :has() it still pointed at the outer query context: the ancestor walk was not bounded by the element under test and '.x:has(.d .e)' matched an .x whose only .d was itself, while ':has(child)' on an element outside the document matched nothing. ':scope' cannot stand in for the anchor in any case, since an explicit ':scope' inside the argument keeps referring to the scoping root of the outer query, which /css/selectors/has-argument-with-explicit-scope.html asserts. The implied anchor is now the private ':-nwsapi-anchor' pseudo-class, compiled against Snapshot.anchor, which has() sets around the argument and restores in a finally block. The sibling arguments take the same path with the parent as the collection context, which retires the open-coded '+' branch and the '~' branch that ignored its argument entirely. Re-entry under jsdom. matchesNative() reached for node.matches at match time. jsdom wires Element.prototype.matches back into nwsapi, so resolving ':modal' called jsdom, which called nwsapi, which resolved ':modal' again until the stack was exhausted, and the RangeError was then swallowed and reported as a plain false. Measured against 2.2.27, one NW.match(':modal', element) makes 5,428,790 re-entrant calls; it is 0 after this change. Provenance is established once, when the factory runs, from global.Element.prototype, and node.matches is never consulted; a re-entrancy guard remains for hosts that pass their window as the global, where the captured matcher can itself be a delegating wrapper. That is upstream dperini#172, dperini#171 and dperini#177, combining the approaches of their PRs dperini#176 and dperini#170. Attribute selector after a pseudo-class. The combinator alternative inside the validator's pseudo-class pattern was '[>+~][^>+~]', which consumes the character after the combinator; when that is the '[' of an attribute selector the attribute can no longer be parsed and the whole selector is rejected. The top-level combinator pattern already uses a lookahead, so the two now agree. "[class*='a' i]:not(:empty) + [class*='b']" is upstream dperini#175, which reaches jsdom users through @testing-library/user-event. The error it raised named a selector with commas where its quotes should be, because emit() was passed the array of fragments the validator did match rather than the selector; it now names the selector. /css/selectors/has-relative-argument.html now passes in full. 16 entries leave the WPT baseline, 336 remain, none added. A 'node' Playwright project covers the regressions that only appear when nwsapi is the engine behind a host's matches().
jdalton
added a commit
to jdalton/nwsapi
that referenced
this pull request
Sep 4, 2026
matchesNative() reaches for node.matches at match time, on the assumption that it is the host's own implementation. jsdom wires Element.prototype.matches back into nwsapi, so resolving ':modal' calls jsdom, which calls nwsapi, which resolves ':modal' again, until the stack is exhausted — and the RangeError is then swallowed and reported as a plain false.
Measured against 2.2.27, one NW.match(':modal', element) makes 5,428,790 re-entrant calls to Element.prototype.matches. It is 0 with this change.
Provenance is established once, when the factory runs, from global.Element.prototype, and node.matches is never consulted. A host that passes only a document, as jsdom does, has no native matcher and therefore no native state to read, which is the correct outcome rather than a workaround. A re-entrancy guard stays in place for hosts that pass their window as the global, where the captured matcher can itself be a delegating wrapper.
This combines the two approaches already proposed in dperini#176 and dperini#170.
References:
- Spec: https://drafts.csswg.org/selectors-4/#modal-state — ':modal' and the state pseudo-classes
- Spec: https://html.spec.whatwg.org/#attr-dialog-open — the dialog open attribute, and the 'is modal' flag that has no reflection
- Chromium: https://github.com/chromium/chromium/blob/155.0.8041.1/third_party/blink/renderer/core/css/selector_checker.cc#L3199 — ':modal' asks the element, not another selector engine
- MDN: https://developer.mozilla.org/en-US/docs/Web/CSS/:modal
Closes dperini#172
Closes dperini#171
Closes dperini#177
jdalton
added a commit
to jdalton/nwsapi
that referenced
this pull request
Sep 5, 2026
matchesNative() reaches for node.matches at match time, on the assumption that it is the host's own implementation. jsdom wires Element.prototype.matches back into nwsapi, so resolving ':modal' calls jsdom, which calls nwsapi, which resolves ':modal' again, until the stack is exhausted — and the RangeError is then swallowed and reported as a plain false.
Measured against 2.2.27, one NW.match(':modal', element) makes 5,428,790 re-entrant calls to Element.prototype.matches. With this change the first query makes one call and every query after it makes none, so fifty of them run in under a millisecond.
The matcher is taken from the node's own realm, through ownerDocument.defaultView, and memoized per document. A matcher belonging to another realm answers a foreign node wrong, or throws a brand check that the existing catch turns into a silent false, and the realm this module loaded in is not reliably the node's: the documented Node shape is nwsapi({ document, DOMException }), which carries no Element at all.
A re-entrancy guard makes that safe. When the host matcher routes back into this engine, the nested call returns the outer answer instead of recursing, and the trip is recorded, so a delegating host is asked at most once per document and never again. Under jsdom that settles to zero calls after the first; in a browser the host keeps answering, which is where ':modal' and ':popover-open' have to come from.
This combines the two approaches already proposed in dperini#176 and dperini#170.
References:
- Spec: https://drafts.csswg.org/selectors-4/#modal-state — ':modal' and the state pseudo-classes
- Spec: https://html.spec.whatwg.org/#attr-dialog-open — the dialog open attribute, and the 'is modal' flag that has no reflection
- Chromium: https://github.com/chromium/chromium/blob/155.0.8041.1/third_party/blink/renderer/core/css/selector_checker.cc#L3199 — ':modal' asks the element, not another selector engine
- MDN: https://developer.mozilla.org/en-US/docs/Web/CSS/:modal
Closes dperini#172
Closes dperini#171
Closes dperini#177
This file contains hidden or bidirectional Unicode text that may be interpreted or compiled differently than what appears below. To review, open the file in an editor that reveals hidden Unicode characters.
Learn more about bidirectional Unicode characters
Sign up for free
to join this conversation on GitHub.
Already have an account?
Sign in to comment
Add this suggestion to a batch that can be applied as a single commit.This suggestion is invalid because no changes were made to the code.Suggestions cannot be applied while the pull request is closed.Suggestions cannot be applied while viewing a subset of changes.Only one suggestion per line can be applied in a batch.Add this suggestion to a batch that can be applied as a single commit.Applying suggestions on deleted lines is not supported.You must change the existing code in this line in order to create a valid suggestion.Outdated suggestions cannot be applied.This suggestion has been applied or marked resolved.Suggestions cannot be applied from pending reviews.Suggestions cannot be applied on multi-line comments.Suggestions cannot be applied while the pull request is queued to merge.Suggestion cannot be applied right now. Please check back later.
Summary
Element.matches()implementationsProblem
The native state detection added in 2.2.26 assumes that
node.matchesis a browser-native implementation when_matchesis unavailable. In jsdom,Element.matches()delegates back to NWSAPI:The resulting
RangeErroris caught bymatchesNative(), so callers receive the expectedfalseresult, but only after the JavaScript call stack has been exhausted.:modalalso falls through toisFullscreen(), which exercises the same recursive path.This becomes especially expensive for consumers such as Floating UI that call
element.matches(":modal")repeatedly while positioning elements. In a jsdom reproduction that performs 100:modalchecks:The guard in this change only suppresses re-entrant native matching. A genuine native matcher remains the first source of truth.
Testing
npm testmastergit diff --check