Repository navigation
Make :has() match in find() - #125
Merged
Merged
Conversation
gravitypdf-bot
force-pushed
the
fix/issue-121
branch
from
October 26, 2026 02:03
21fec70 to
8bfec5c
Compare
Codecov Report❌ Patch coverage is
Additional details and impacted files@@ Coverage Diff @@
## main #125 +/- ##
============================================
+ Coverage 90.69% 91.17% +0.48%
- Complexity 1424 1459 +35
============================================
Files 25 26 +1
Lines 3170 3231 +61
============================================
+ Hits 2875 2946 +71
+ Misses 295 285 -10 ☔ View full report in Codecov by Harness. 🚀 New features to boost your workflow:
|
PseudoClass::has() ran its argument in initialized mode against the element itself, so ul:has(li) asked whether the ul is an li and never matched. It now reads the argument as a relative selector anchored to the element: a descendant by default, or a child, next sibling or later sibling for a leading >, + or ~. :not() and :matches() keep testing the element itself. :has() is no longer a set filter when its argument is positional: the argument is searched for around each element, as in jQuery. Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
find() now searches descendants only (#73), so the per-sibling search in :has(+ ...) and :has(~ ...) no longer saw the sibling it started from. Test the sibling itself first, and search below it only when the group goes on to a child or descendant. Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
has() only ever asked whether the anchor combinator was + or ~, so relativeSelector() now answers that, and the descendant and sibling searches become the two arms of one if/else. Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
findsAny() built each per-element and per-sibling traverser with its own PseudoClass, so with #119's index every one re-indexed its sibling list: li:has(~ li:nth-child(1)) over 400 siblings took about 9 s. It now passes its own handler, as is() does, which also lets a nested :has() reuse the parsed-argument cache (about 0.26 s). Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
gravitypdf-bot
force-pushed
the
fix/issue-121
branch
from
October 26, 2026 02:15
8bfec5c to
9fa8255
Compare
This was referenced Oct 26, 2026
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.
:has()never matched in the current engine (CSS\DOMTraverser), whichfind(),filter()and the factories use.PseudoClass::has()ran its argument in initialized mode against the element itself, soul:has(li)asked whether theulis anli.What changed
has()now reads its argument as a CSS 4 relative selector anchored to the element with:scope. It matches a descendant by default, a child for> li, the next sibling for+ li, and a later sibling for~ li. Each group is parsed and anchored once per argument and then cached.:not()and:matches()keep the old "is this element a match?" check, now inis().isNot()used to callhas().applySetFilter()had the same initialized-mode bug for:has()with a positional argument.hasis no longer inUtil::SELECTOR_ARGUMENT_PSEUDO_CLASSES, so:has()is never a set filter. Its argument is searched separately around each element, as in jQuery, soul:has(li:eq(2))matches everyulthat has a thirdli. This matchesremove().DOMTraverser::findGroups()runs a selector that is already parsed, andfind()now calls it.SimpleSelector::isBlank()is new.Supported relative forms
li,> li,+ li,~ li, chains such as+ ul ~ qand~ ol > li > span, and comma lists without a leading combinator (x, span). Parser limitations I did not fix:li, > span) throws "two combinators in sequence", or parses wrongly.li:nth-child(3), span) loses its comma when the argument is rebuilt. This is close to Doubly nested parentheses in a selector throw "Recursion error detected" (e.g.li:not(:not(:nth-child(1)))) #120.Behaviour change
Selectors using
:has()used to match nothing, and now they match.:not(:has(...))used to match everything. On self-matching, this engine andremove()now differ: the legacy engine counts the element itself, soremove('*:has(li.k)')includes theli.k.find()follows the spec and does not.Performance
A descendant search is one traversal per candidate.
+checks only the next sibling.~searches the later siblings one at a time and stops at the first match. When~has no match it costs O(siblings) for each candidate:li:has(~ b)over 2000 siblings takes about 4 s.Sharing #119's sibling index
#119 (now on
main) indexes sibling lists once per query and shares the index with nested traversers throughDOMTraverser's 4th constructor argument.is()andfindsAny()both pass$this, so every per-element and per-sibling search inside:has()reuses one index. Without that,li:has(~ li:nth-child(1))over 400 siblings took about 9.2 s. With it, it takes about 0.26 s.PseudoClassTest::testHasQueriesShareTheSiblingIndexpins this for+,~and>arguments. Each case indexes exactly one sibling list, and all three fail without$this.Rebased onto #72, #73 and #119
findsAny()searched each later sibling with an uninitializedfind()that used to count the sibling itself. #73 madefind()search descendants only, which broke 9 sibling-relative tests.findsAny()now takes a$selfflag. A later sibling is tested itself first, and searched below only when the group continues to a child or descendant (~ ol > li > span).relativeSelector()now records whether a group starts at a sibling, rather than returning the anchor combinator.Tests
DOMQueryTest: a data provider of 24find()cases, plushtml5qp(),filter(), agreement withremove()on the issue's selectors, and:not()regression checks.PseudoClassTest: unit checks ofhas,matchesandnot.Every new
:has()test fails onmain.vendor/bin/phpunit: 630 tests, 1797 assertions, 0 failures (1 skipped).lintandlint:min-phppass.Fixes #121.
🤖 Generated with Claude Code