Repository navigation
Make :nth-* pseudo-classes linear on wide sibling lists - #119
Merged
Merged
Conversation
Codecov Report✅ All modified and coverable lines are covered by tests. Additional details and impacted files@@ Coverage Diff @@
## main #119 +/- ##
============================================
+ Coverage 90.69% 91.05% +0.35%
- Complexity 1424 1431 +7
============================================
Files 25 26 +1
Lines 3170 3174 +4
============================================
+ Hits 2875 2890 +15
+ Misses 295 284 -11 ☔ View full report in Codecov by Harness. 🚀 New features to boost your workflow:
|
isNthChild() walked the preceding (or following) siblings of every candidate, so matching n siblings cost n^2/2 hops. Positions are now recorded for a whole sibling list on first use and remembered until the outermost DOMTraverser::find() ends, so a DOM mutated between queries is never answered from a stale index. Nested :not()/:has() queries, and the per-element queries run by filter() and children(), share the outer handler so they reuse the same index instead of rebuilding it per candidate. Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
NodeMatcher::matches() runs a traverser per node for the walk-and-stop
methods, so each got its own handler and indexed the whole sibling list
to answer one node: nextUntil(':nth-last-child(1)') over 2000 siblings
went from 290 ms on main to 950 ms. The matcher now holds one handler
open for its lifetime, which is already a single call (17 ms).
Outside a query the index is built into a throwaway store, replacing the
separate sibling walkers, and entries are flat arrays.
Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
gravitypdf-bot
force-pushed
the
fix/issue-84
branch
from
October 26, 2026 02:03
b3e78e8 to
21f088b
Compare
gravitypdf-bot
added a commit
that referenced
this pull request
Oct 26, 2026
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>
jakejackson1
pushed a commit
that referenced
this pull request
Oct 26, 2026
* Make :has() match related elements in DOMTraverser 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> * Test a later sibling itself against a sibling-relative :has() 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> * Return whether a :has() group starts at a sibling, not its combinator 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> * Share the sibling index with the queries :has() runs 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> --------- Co-authored-by: Claude Opus 5.5 <noreply@anthropic.com>
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.
:nth-child(),:nth-last-child(),:nth-of-type()and:nth-last-of-type()worked out each candidate's position by walking its siblings, so matching n siblings cost about n²/2 hops.What changed
PseudoClassindexes a parent's element children once. For each child it records the position from the start and from the end, both overall and among same-tag siblings, so all four families read from one table.DOMTraverser::find()brackets traversal withbeginQuery()/endQuery()in atry/finally. Calls nest, and positions are dropped when the outermost query ends, so a DOM mutated between queries is never matched against stale positions and no node references outlive the query. With no query running (callingelementMatches()directly), the sibling list is indexed into a throwaway store, so nothing is remembered and there is one position algorithm, not two.:not()/:has()traversers share their caller'sPseudoClassthrough a new optional fourthDOMTraverserconstructor argument. Without that, each candidate would build a fresh index, which is slower than the old walk.NodeMatcher(from Match selectors against the nodes in hand, not their descendants #72) holds onePseudoClassopen for its lifetime, which is a single call. Its per-node queries forclosest(),nextUntil()and the other walk-and-stop methods therefore share one index.filter()andchildren($selector)already run one set-based query onmain, so they need nothing extra.an+brule is memoised perPseudoClassinstead of re-parsed for every node.The match logic is unchanged.
Benchmarks
PHP 8.5, best of 3. The first table uses the issue's
<table>with n ×<tr><td>x</td></tr>.trtr:nth-child(2n)beforetr:nth-child(2n)tr:nth-last-child(2n)tr:nth-of-type(2n)tr:nth-last-of-type(2n)tr:not(:nth-child(2n))qp($xml, 'tr')->filter(':nth-child(2n)')qp($xml, 'table')->children(':nth-child(2n)')qp($xml, 'tr:first-child')->nextUntil(':nth-last-child(1)')qp($xml, 'td')->closest('tr:nth-child(2n)')The last four rows were re-measured after rebasing onto
mainwith #72 and #73.Tests
DOMTraverserTest::testNthPseudoClassescovers all four families on a mixedli/plist with interleaved text and comments, across two parents. Cases includeodd,even,n,0n+0,-n+3,3n-5,-2n-1, out-of-range indexes, several:nth-*in one selector, comma groups, and:not(:nth-*).testNthPseudoClassesInFilterAndChildrencoversfilter()andchildren().testNthPseudoClassesAfterMutationruns queries, thenprepend(),append()andremove(), then queries again on the sameDOMQuery.PseudoClassTest::not().vendor/bin/phpunit: 598 tests, 1736 assertions, 0 failures (1 skipped).composer run lintandcomposer run lint:min-phpare clean.Rebased onto #72 and #73
#119 used to thread the shared handler through per-element loops in
filter()andchildren(). Onmainthose loops are gone, so that part was dropped in favour ofmain's version.There is no behaviour change.
DOMTraverser::__construct()gains an optional fourth argument, andPseudoClassgainsbeginQuery()/endQuery().Fixes #84.
🤖 Generated with Claude Code