Skip to content

Psuedo-class selector :text producing errors and incorrect results - #50

Draft
jakejackson1 wants to merge 6 commits into
mainfrom
issue-49
Draft

Psuedo-class selector :text producing errors and incorrect results#50
jakejackson1 wants to merge 6 commits into
mainfrom
issue-49

Conversation

@jakejackson1

@jakejackson1 jakejackson1 commented Mar 21, 2024

Copy link
Copy Markdown
Member

Pull Request type

Please check the type of change your PR introduces:

  • Bugfix
  • Feature
  • Code style update (formatting, renaming)
  • Refactoring (no functional changes, no API changes)
  • Build-related changes
  • Documentation content changes
  • Other (please describe):

What is the current behavior?

  1. If doing ->is(':text') on a text node an error is produced.
  2. If doing ->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 DOMElement and called element-only APIs
(getElementsByTagName(), tagName, getAttribute(), hasAttribute()) on it. A match set can legitimately hold
text, comment, CDATA and processing instruction nodes — contents() produces exactly that — so
$singleTextNode->is(':text') blew up with Call 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() returns false for any node that is not a DOMElement. This is the
    single funnel every selector match passes through. matchesSelector(), matchesSimpleSelector() and combine()
    now accept a DOMNode rather than a DOMElement so they can make that decision instead of raising a TypeError.
    (This also fixes combineDirectDescendant(), which could hand a DOMDocument to a DOMElement parameter.)
  • initialMatchOnElement(), initialMatchOnID() and initialMatchOnClasses() skip nodes that cannot hold elements.
  • CSS\DOMTraverser\PseudoClass::elementMatches() and CSS\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 the
node itself when the element selector is the wildcard (*, which is what a bare :text / :foo selector expands
to). initialMatchOnID() and initialMatchOnClasses() already test the node itself for their own selectors, and
initialMatchOnElement() already did so for a named element and for the document element — the wildcard was the odd
one out. Without this, $input->is(':text') could never be true for the input itself.

The legacy engine (CSS\QueryPathEventHandler, still used by remove() and replaceAll()) already filtered
non-element nodes out in its constructor, so it did not need the crash fix.

2. :text now means what it means in jQuery

:text was implemented as [type="text"], which matched any element carrying that attribute and missed a bare
<input />. It now matches jQuery: an input whose type attribute is absent (text is an input's default
type) or is text, compared case-insensitively. It does not, and never did, indicate whether a node is a text
node.

Fixed in both engines — CSS\DOMTraverser\PseudoClass::isTextInput() and
CSS\QueryPathEventHandler::textInput() — so find(':text') and remove(':text') / replaceAll(':text') agree.

Tests

The committed spec in tests/Issues/Issue49Test.php passes unchanged. Added alongside it:

  • testTextSelectorOnlyMatchesTextInputs:text matches <input type="text">, bare <input> and
    <input type="TEXT">, and not password / checkbox / submit / <textarea> / <button>.
  • testTextSelectorMatchesTheInputItself — the same variants asserted through is() on the input itself.
  • testTextSelectorInTheLegacyEngineremove(':text') selects the same set as find(':text').
  • testSelectorsAgainstATextNodeDoNotThrow — element, wildcard, class, ID, attribute, attribute-value,
    pseudo-class and descendant selectors against a text-node collection, through both is() and find(), plus
    filter().
  • testSelectorsAgainstACommentNodeDoNotThrow — the same for a DOMComment.
  • testSelectorsAgainstCdataAndProcessingInstructionNodesDoNotThrow — the same for DOMCdataSection and
    DOMProcessingInstruction (via qp() on XML).
  • testMixedNodeMatchSetStillMatchesItsElements — a match set mixing a text node with an element still matches the
    element 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_function on PHP 8). composer run lint and composer run lint:min-php are clean.

Does this introduce a breaking change?

  • Yes
  • No

:text changes meaning, deliberately: it no longer matches non-input elements that happen to carry
type="text", and it now matches <input> with no type. This is the point of the issue and brings the selector
in 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 class
initial-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 their
descendants (matching jQuery). The committed spec on this branch contains
$this->assertTrue($q->is(':text')); where $q holds the <div> and only its children are :text — that
assertion relies on the current descendant semantics and will become false once #51 lands. I deliberately did not
touch is()'s descendant-vs-set semantics here; that assertion needs to be reconciled (flipped to
assertFalse, 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 :text is a pseudo-class evaluation. Note that under #51 the sibling
assertion $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 the
old [type=x] implementation and have the same divergence from jQuery in a milder form (e.g. jQuery's :button
also matches <button>). Left alone deliberately — out of scope for #49.

🤖 Generated with Claude Code

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>
jakejackson1 and others added 2 commits August 22, 2026 04:21
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

codecov Bot commented Aug 21, 2026

Copy link
Copy Markdown

Codecov Report

❌ Patch coverage is 88.88889% with 4 lines in your changes missing coverage. Please review.
✅ Project coverage is 89.45%. Comparing base (296d828) to head (e23694e).
⚠️ Report is 1 commits behind head on main.

Files with missing lines Patch % Lines
src/CSS/DOMTraverser/Util.php 75.00% 2 Missing ⚠️
src/CSS/DOMTraverser.php 92.30% 1 Missing ⚠️
src/CSS/DOMTraverser/PseudoClass.php 80.00% 1 Missing ⚠️
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.
📢 Have feedback on the report? Share it here.

🚀 New features to boost your workflow:
  • ❄️ Test Analytics: Detect flaky tests, report on failures, and find test suite problems.

jakejackson1 and others added 2 commits August 22, 2026 04:37
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>
Sign up for free to join this conversation on GitHub. Already have an account? Sign in to comment

Labels

None yet

Projects

None yet

Development

Successfully merging this pull request may close these issues.

$singleTextNode->is(':text') throws

1 participant