You signed in with another tab or window. Reload to refresh your session.You signed out in another tab or window. Reload to refresh your session.You switched accounts on another tab or window. Reload to refresh your session.Dismiss alert
:nth-child() and its siblings are O(n²) on the width of the sibling list. Each candidate node
recomputes its own position by walking previousSibling to the start of the list, so a selector
tested against n siblings performs n²/2 pointer hops.
On a table of 4000 rows, tr:nth-child(2n) takes 75× longer than tr, and the gap grows with
every row added.
Measurements
<table> containing n × <tr><td>x</td></tr>, PHP 8.3.16, main @ e23fbb0:
rows
tr
tr:nth-child(2n)
ratio
500
0.8 ms
12.2 ms
15×
1000
1.6 ms
32.6 ms
20×
2000
3.0 ms
120.4 ms
40×
4000
6.4 ms
476.8 ms
75×
tr is linear; :nth-child quadruples for each doubling of the row count. At 20 000 rows the same
selector takes ~12 seconds.
Which selectors are affected
Only the four :nth-* families. Measured at 2000 rows:
selector
time
tr
5.6 ms
tr:first-child
3.9 ms
tr:last-child
3.6 ms
tr:only-child
3.5 ms
tr:empty
3.9 ms
tr:nth-child(2n)
122.2 ms
tr:nth-last-child(2n)
122.4 ms
tr:nth-of-type(2n)
169.8 ms
tr:nth-last-of-type(2n)
171.4 ms
:first-child, :last-child, :only-child and :empty take a different path and are fine.
Root cause
src/CSS/DOMTraverser/PseudoClass.php:377 — nodePositionFromStart() walks the whole preceding
sibling chain for one node:
nodePositionFromEnd() (:399) is the mirror image. isNthChild() (:438) calls one of them per
node, so the cost for a full sibling list is 1 + 2 + … + n.
The :nth-of-type variants are slower still because $byType adds a tagName comparison at every
step of every walk.
Secondary: isNthChild() re-parses the an+b expression on every node — Util::parseAnB($value) at :440 — for a value that is fixed for the whole query.
Suggested fix
Index the parent's child list once and reuse it, rather than re-deriving each node's position.
The same technique is already used elsewhere in the codebase for document-order sorting
(Util::siblingOffset()), where a SplObjectStorage memo scoped to a single call replaced exactly
this quadratic walk and made a 4000-wide sort go from 253 ms to 33 ms.
Sketch:
On the first position request for a given parent, walk childNodes once and record each element
child's 1-based index (and its index among same-tag siblings, for $byType).
Memoize in an SplObjectStoragescoped to the query, not stored on the handler — this library
mutates DOMs constantly (append(), remove(), wrap()), so a cache that outlives a single
traversal would go stale.
nodePositionFromEnd() is then count - index + 1 off the same table, with no second walk.
Hoist Util::parseAnB() out of the per-node path.
That makes the whole sibling list cost one pass instead of one pass per node.
Notes
Not a regression — main and every currently open branch behave identically here.
Fix jQuery positional pseudo-classes to index the matched set #70 converts the jQuery positional pseudo-classes (:eq, :first, :lt, :gt, :odd, :even) into set-level filters and deliberately leaves the CSS structural ones alone, so it
neither causes nor fixes this.
Correctness is not in question; this is purely about cost.
Description of the bug
:nth-child()and its siblings are O(n²) on the width of the sibling list. Each candidate noderecomputes its own position by walking
previousSiblingto the start of the list, so a selectortested against n siblings performs n²/2 pointer hops.
On a table of 4000 rows,
tr:nth-child(2n)takes 75× longer thantr, and the gap grows withevery row added.
Measurements
<table>containing n ×<tr><td>x</td></tr>, PHP 8.3.16,main@ e23fbb0:trtr:nth-child(2n)tris linear;:nth-childquadruples for each doubling of the row count. At 20 000 rows the sameselector takes ~12 seconds.
Which selectors are affected
Only the four
:nth-*families. Measured at 2000 rows:trtr:first-childtr:last-childtr:only-childtr:emptytr:nth-child(2n)tr:nth-last-child(2n)tr:nth-of-type(2n)tr:nth-last-of-type(2n):first-child,:last-child,:only-childand:emptytake a different path and are fine.Root cause
src/CSS/DOMTraverser/PseudoClass.php:377—nodePositionFromStart()walks the whole precedingsibling chain for one node:
nodePositionFromEnd()(:399) is the mirror image.isNthChild()(:438) calls one of them pernode, so the cost for a full sibling list is 1 + 2 + … + n.
The
:nth-of-typevariants are slower still because$byTypeadds atagNamecomparison at everystep of every walk.
Secondary:
isNthChild()re-parses thean+bexpression on every node —Util::parseAnB($value)at:440— for a value that is fixed for the whole query.Suggested fix
Index the parent's child list once and reuse it, rather than re-deriving each node's position.
The same technique is already used elsewhere in the codebase for document-order sorting
(
Util::siblingOffset()), where aSplObjectStoragememo scoped to a single call replaced exactlythis quadratic walk and made a 4000-wide sort go from 253 ms to 33 ms.
Sketch:
childNodesonce and record each elementchild's 1-based index (and its index among same-tag siblings, for
$byType).SplObjectStoragescoped to the query, not stored on the handler — this librarymutates DOMs constantly (
append(),remove(),wrap()), so a cache that outlives a singletraversal would go stale.
nodePositionFromEnd()is thencount - index + 1off the same table, with no second walk.Util::parseAnB()out of the per-node path.That makes the whole sibling list cost one pass instead of one pass per node.
Notes
mainand every currently open branch behave identically here.:eq,:first,:lt,:gt,:odd,:even) into set-level filters and deliberately leaves the CSS structural ones alone, so itneither causes nor fixes this.
Repro
QueryPath version
main@ e23fbb0PHP Version and environment
PHP 8.3.16 (cli), macOS. Not version-specific.