Psuedo-class selector :text producing errors and incorrect results - #50
Draft
jakejackson1 wants to merge 6 commits into
Draft
Psuedo-class selector :text producing errors and incorrect results#50jakejackson1 wants to merge 6 commits into
jakejackson1 wants to merge 6 commits into
Conversation
1 task
Two defects, per issue #49. 1. Running any selector against a match set that contained a non-element node (text, comment, CDATA, processing instruction) fataled, because the traverser assumed every node was a DOMElement and called element-only methods such as getElementsByTagName() and tagName on it. Non-element nodes now simply do not match an element selector: - DOMTraverser::matchesSimpleSelector() returns FALSE for any node that is not a DOMElement. matchesSelector(), matchesSimpleSelector() and combine() take a DOMNode rather than a DOMElement so they can make that decision instead of raising a TypeError. - initialMatchOnElement(), initialMatchOnID() and initialMatchOnClasses() skip nodes that cannot hold elements. - PseudoClass::elementMatches() and Util::matchesAttribute[NS]() guard against non-elements as well, since they are reachable directly. initialMatchOnElement() also now captures the node itself when the element selector is the wildcard, which is what initialMatchOnID() and initialMatchOnClasses() already do for their own selectors. 2. The :text pseudo-class matched anything with type="text". It now follows jQuery, matching an input whose type attribute is absent (text is an input's default type) or is text, compared case-insensitively. It has never indicated whether a node is a text node. Fixed in both the current engine (CSS\DOMTraverser) and the legacy engine (CSS\QueryPathEventHandler) that remove() and replaceAll() still use, so the two agree. Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>
The assertion held the <div> and expected is(':text') to be true, which only
worked because is() ran a descendant search. #72 makes is() test the elements in
the match set, as jQuery does, so that assertion would flip to false.
Rewritten so it does not depend on which semantics are in force: the containment
question is asked with has(), which is what it always meant, and is() is asked of
the inputs themselves. It passes both with and without #72.
Also renamed $textNode to $firstInput in this test. contents()->eq(0) here is the
first <input> element, not a text node — the name is accurate in the sibling test
below, where the fixture really does hold text.
Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>
# Conflicts: # CHANGELOG.md # src/CSS/DOMTraverser.php
Codecov Report❌ Patch coverage is
Additional details and impacted files@@ Coverage Diff @@
## main #50 +/- ##
=========================================
Coverage 89.44% 89.45%
- Complexity 1342 1358 +16
=========================================
Files 26 26
Lines 3023 3054 +31
=========================================
+ Hits 2704 2732 +28
- Misses 319 322 +3 ☔ View full report in Codecov by Harness. 🚀 New features to boost your workflow:
|
Two assertions reached find() for a node that was already in their own match set, which only worked because find() self-matched. #73 makes find() search descendants only, as jQuery does. Rewritten to ask each question of the method that answers it: find() of a real descendant, filter()/is() of the elements in the set. The mixed-node fixture gains a nested <em> so find() still has something to reach, which keeps the point of the test — that a set holding a text node does not cause a fatal — intact on both sides of the selector. Passes with and without #72/#73. Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>
The rule — an input whose type is absent or case-insensitively "text" — was
spelled out once per engine. The two are meant to agree, which is why this PR
has a test asserting they do; sharing the definition is what actually keeps them
agreeing. Util is the established home for this: 4.1.0 moved parseAnB() there
for the same reason.
Also in this commit, none of it behavioural:
- Drop .phpunit.result.cache, which was committed despite being in .gitignore.
- Restore the six blank lines the diff had stripped from released CHANGELOG
sections. All five open PRs edit that file, so unrelated whitespace churn in it
buys four conflicts for nothing.
- Record the find('*') self-match change in the CHANGELOG. It was needed to make
is(':text') work on an element under the current is(), but it is a behaviour
change that was going in unmentioned, and #73 supersedes it.
Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>
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.
Pull Request type
Please check the type of change your PR introduces:
What is the current behavior?
->is(':text')on a text node an error is produced.->find(':text')it won't match<input />tags without a type (which are considered text inputs). See https://api.jquery.com/text-selector/Issue Number: #49
Fixes #49
What is the new behavior?
1. Selectors no longer fatal on non-element nodes
The traverser assumed every node in a match set was a
DOMElementand called element-only APIs(
getElementsByTagName(),tagName,getAttribute(),hasAttribute()) on it. A match set can legitimately holdtext, comment, CDATA and processing instruction nodes —
contents()produces exactly that — so$singleTextNode->is(':text')blew up withCall to undefined method DOMText::getElementsByTagName().This is fixed as a class of bug, not just at the one line in the stack trace. A non-element node now simply does not
match an element selector:
CSS\DOMTraverser::matchesSimpleSelector()returnsfalsefor any node that is not aDOMElement. This is thesingle funnel every selector match passes through.
matchesSelector(),matchesSimpleSelector()andcombine()now accept a
DOMNoderather than aDOMElementso they can make that decision instead of raising aTypeError.(This also fixes
combineDirectDescendant(), which could hand aDOMDocumentto aDOMElementparameter.)initialMatchOnElement(),initialMatchOnID()andinitialMatchOnClasses()skip nodes that cannot hold elements.CSS\DOMTraverser\PseudoClass::elementMatches()andCSS\DOMTraverser\Util::matchesAttribute()/matchesAttributeNS()guard against non-elements too, since they are reachable as public API in their own right.One related consistency fix was needed to make the committed spec pass:
initialMatchOnElement()now captures thenode itself when the element selector is the wildcard (
*, which is what a bare:text/:fooselector expandsto).
initialMatchOnID()andinitialMatchOnClasses()already test the node itself for their own selectors, andinitialMatchOnElement()already did so for a named element and for the document element — the wildcard was the oddone out. Without this,
$input->is(':text')could never be true for the input itself.The legacy engine (
CSS\QueryPathEventHandler, still used byremove()andreplaceAll()) already filterednon-element nodes out in its constructor, so it did not need the crash fix.
2.
:textnow means what it means in jQuery:textwas implemented as[type="text"], which matched any element carrying that attribute and missed a bare<input />. It now matches jQuery: aninputwhosetypeattribute is absent (textis an input's defaulttype) or is
text, compared case-insensitively. It does not, and never did, indicate whether a node is a textnode.
Fixed in both engines —
CSS\DOMTraverser\PseudoClass::isTextInput()andCSS\QueryPathEventHandler::textInput()— sofind(':text')andremove(':text')/replaceAll(':text')agree.Tests
The committed spec in
tests/Issues/Issue49Test.phppasses unchanged. Added alongside it:testTextSelectorOnlyMatchesTextInputs—:textmatches<input type="text">, bare<input>and<input type="TEXT">, and notpassword/checkbox/submit/<textarea>/<button>.testTextSelectorMatchesTheInputItself— the same variants asserted throughis()on the input itself.testTextSelectorInTheLegacyEngine—remove(':text')selects the same set asfind(':text').testSelectorsAgainstATextNodeDoNotThrow— element, wildcard, class, ID, attribute, attribute-value,pseudo-class and descendant selectors against a text-node collection, through both
is()andfind(), plusfilter().testSelectorsAgainstACommentNodeDoNotThrow— the same for aDOMComment.testSelectorsAgainstCdataAndProcessingInstructionNodesDoNotThrow— the same forDOMCdataSectionandDOMProcessingInstruction(viaqp()on XML).testMixedNodeMatchSetStillMatchesItsElements— a match set mixing a text node with an element still matches theelement it holds.
All 9 tests in the file fail without the
src/changes. Full suite: 286 tests, 1084 assertions, 0 failures(2 pre-existing skips for
create_functionon PHP 8).composer run lintandcomposer run lint:min-phpare clean.Does this introduce a breaking change?
:textchanges meaning, deliberately: it no longer matches non-inputelements that happen to carrytype="text", and it now matches<input>with notype. This is the point of the issue and brings the selectorin line with jQuery.
Additionally, a wildcard element selector now also considers the context node itself, not only its descendants
(see above), so e.g.
$el->find('*')includes$el. This makes the wildcard consistent with the ID and classinitial-match paths, and no existing test changed behaviour because of it.
Other information
Conflict with #51. PR #51 changes
is()so that it only tests the elements in the match set, not theirdescendants (matching jQuery). The committed spec on this branch contains
$this->assertTrue($q->is(':text'));where$qholds the<div>and only its children are:text— thatassertion relies on the current descendant semantics and will become
falseonce #51 lands. I deliberately did nottouch
is()'s descendant-vs-set semantics here; that assertion needs to be reconciled (flipped toassertFalse, or the fixture changed) when the two branches meet. Everything else in this PR is orthogonal to #51:the crash fix lives in the traverser, and
:textis a pseudo-class evaluation. Note that under #51 the siblingassertion
$textNode->is(':text')(which actually holds the first<input>, not a text node) continues to pass,because this PR makes the wildcard initial match consider the node itself.
The other jQuery input pseudo-classes (
:radio,:checkbox,:password,:submit,:button, …) still use theold
[type=x]implementation and have the same divergence from jQuery in a milder form (e.g. jQuery's:buttonalso matches
<button>). Left alone deliberately — out of scope for #49.🤖 Generated with Claude Code