Skip to content

querySelectorAll() returns duplicate elements for selector lists #24260

Description

@fivefilters

Description

I got some strange results selecting DOM elements recently, caused by how querySelectorAll handles comma separated selectors.

The following analysis is mostly via AI (Claude Opus 5.5), but I have checked it over and it looks like it has captured the issue well. I'm less sure of the PHP src fix at the end as I'm not a C developer and have never contributed to internals.

The following code:

<?php

$doc = Dom\HTMLDocument::createFromString(
    '<!DOCTYPE html><p class="a b">one</p><p class="b">two</p>'
);

foreach (['.a, .b', '.a, .b, .a.b', ':is(.a, .b)'] as $selector) {
    $texts = [];
    foreach ($doc->querySelectorAll($selector) as $element) {
        $texts[] = $element->textContent;
    }
    echo $selector, ' => ', implode(' ', $texts), "\n";
}

Resulted in this output (3v4l shows same output on 8.4.1 through 8.4.26 and 8.5.0 through 8.5.11)

.a, .b => one one two
.a, .b, .a.b => one one one two
:is(.a, .b) => one two

But I expected this output instead:

.a, .b => one two
.a, .b, .a.b => one two
:is(.a, .b) => one two

Each element should appear once: "A comma-separated list of selectors represents the union of all elements selected by each of the individual selectors in the selector list." (Selectors 4, §4.1)

The same code in JavaScript prints the expected output (click Run).

AI (Claude Opus 5.5) source analysis: cause and tested fix

querySelectorAll() passes LXB_SELECTORS_OPT_DEFAULT to Lexbor in ext/dom/parentnode/css_selectors.c, so a node is reported once per matching selector. querySelector(), matches() and closest() pass LXB_SELECTORS_OPT_MATCH_FIRST, which Lexbor documents as removing these duplicates. Using it in querySelectorAll() fixes this on an 8.5.8 build, and the ext/dom tests still pass.

--- a/ext/dom/parentnode/css_selectors.c
+++ b/ext/dom/parentnode/css_selectors.c
@@ -241,7 +241,7 @@
 		selectors_str,
 		dom_query_selector_find_array_callback,
 		&ctx,
-		LXB_SELECTORS_OPT_DEFAULT
+		LXB_SELECTORS_OPT_MATCH_FIRST
 	) != LXB_STATUS_OK) {
 		zend_array_destroy(list);
 		RETURN_THROWS();

PHP Version

PHP 8.5.8 (cli) (built: Jul  7 2026 05:21:52) (NTS gcc 14.2.0)
Copyright (c) The PHP Group
Built by static-php-cli 2.8.6
Zend Engine v4.5.8, Copyright (c) Zend Technologies
    with Zend OPcache v8.5.8, Copyright (c), by Zend Technologies

Operating System

Ubuntu 24.04 (x86_64)

Activity

  1. fivefilters commented on Oct 11, 2026

    @fivefilters
    Author

    Tagging @ndossche if it's of interest 🙂

  2. ndossche commented on Oct 11, 2026

    @ndossche
    Member

    Right, feel free to open a PR ;)

  3. fivefilters commented on Oct 11, 2026

    @fivefilters
    Author

    @ndossche Okay, will do later today 🙏 😄

Sign up for free to join this conversation on GitHub. Already have an account? Sign in to comment

Metadata

Metadata

Assignees

No one assigned

    Type

    No type

    Projects

    No projects

      Milestone

      No milestone

      Relationships

      None yet

      Development

      No branches or pull requests

      Issue actions