Multiple descendant selectors, bug or misunderstanding?

Viewed 77

Shouldn't the following two approaches to selecting a node produce the same result?

let tmp = fruits.querySelector("ul:first-of-type li:first-of-type");
tmp = tmp.querySelector("span")    

vs.

let tmp = fruits.querySelector("ul:first-of-type li:first-of-type span");

(See it in action here)

I've tested this in both firefox and chrome. Different results in both cases. Can anybody please explain why?

Example in stack snippet:

let fruits = document.querySelector("[data-segment='fruits']");
console.log(fruits);
let tmp = fruits.querySelector("ul:first-of-type li:first-of-type")
tmp = tmp.querySelector("span")
console.log("Works:")
console.log(tmp)
console.log("Does not work:")
console.log(fruits.querySelector("ul:first-of-type li:first-of-type span"))
<main id="app" data-v-app="">
  <section>
    <h2>Tree</h2>
    <ul role="tree">
      <li role="treeitem" data-segment="fruits" aria-level="1" aria-setsize="3" aria-posinset="1" aria-expanded="true"><span tabindex="0">Fruits</span>
        <ul role="group">
          <li role="none" data-segment="oranges" aria-level="2" aria-setsize="5" aria-posinset="1"><span tabindex="-1">Oranges</span>
            <!--v-if-->
          </li>
          <li role="none" data-segment="pineapple" aria-level="2" aria-setsize="5" aria-posinset="2"><span tabindex="-1">Pineapple</span>
            <!--v-if-->
          </li>
          <li role="treeitem" data-segment="apples" aria-level="2" aria-setsize="5" aria-posinset="3" aria-expanded="false"><span tabindex="-1">Apples</span>
            <ul role="group">
              <li role="none" data-segment="macintosh" aria-level="3" aria-setsize="3" aria-posinset="1"><span tabindex="-1">Macintosh</span>
                <!--v-if-->
              </li>
              <li role="none" data-segment="granny_smith" aria-level="3" aria-setsize="3" aria-posinset="2"><span tabindex="-1">Granny Smith</span>
                <!--v-if-->
              </li>
              <li role="none" data-segment="fuji" aria-level="3" aria-setsize="3" aria-posinset="3"><span tabindex="-1">Fuji</span>
                <!--v-if-->
              </li>
            </ul>
          </li>
          <li role="none" data-segment="bananas" aria-level="2" aria-setsize="5" aria-posinset="4"><span tabindex="-1">Bananas</span>
            <!--v-if-->
          </li>
          <li role="none" data-segment="pears" aria-level="2" aria-setsize="5" aria-posinset="5"><span tabindex="-1">Pears</span>
            <!--v-if-->
          </li>
        </ul>
      </li>
      <li role="none" data-segment="vegetables" aria-level="1" aria-setsize="3" aria-posinset="2"><span tabindex="-1">Vegetables</span>
        <!--v-if-->
      </li>
      <li role="none" data-segment="grains" aria-level="1" aria-setsize="3" aria-posinset="3"><span tabindex="-1">Grains</span>
        <!--v-if-->
      </li>
    </ul>
  </section>
</main>

2 Answers

The documentation explains it:

element = baseElement.querySelector(selectors);

Return value

The first descendant element of baseElement which matches the specified group of selectors. The entire hierarchy of elements is considered when matching, including those outside the set of elements including baseElement and its descendants; in other words, selectors is first applied to the whole document, not the baseElement, to generate an initial list of potential elements. The resulting elements are then examined to see if they are descendants of baseElement. The first match of those remaining elements is returned by the querySelector method.

(Emphasis mine.)

Let’s consider a simplified example:

console.log(document.getElementById("a").querySelector("ul li span"));
<ul>
  <li id="a"><span>A</span>
    <ul>
      <li><span>B</span></li>
    </ul>
  </li>
</ul>

Here, baseElement is document.getElementById("a"); selectors is "ul li span".

document.querySelector("ul li span") indeed includes both <span>s, and both of them are inside baseElement. <span>A</span> happens to be the first one that is in this set.

There’s a rather new pseudo-class called :scope that may help here:

console.log(document.getElementById("a").querySelector(":scope ul li span"));
<ul>
  <li id="a"><span>A</span>
    <ul>
      <li><span>B</span></li>
    </ul>
  </li>
</ul>

el.querySelector(selector) can return surprising results if you're not aware of how it works in the background.

 el.querySelector(selector)

What really is going on is like this:

 (el, selector) => [...document.querySelectorAll(selector)].filter(node => el !== node && el.contains(node))[0]

See this example:

const el = document.getElementById('outer');
const selector = 'span > span';
console.log(el.querySelector(selector).id); // logs "inner", not "innermost"
// same as if you did
console.log([...document.querySelectorAll(selector)].filter(node => el !== node && el.contains(node))[0].id);
<span id="outermost">
  <span id="outer">
    <span id="inner">
      <span id="innermost"></span>
    </span>
  </span>
</span>

So el.querySelector(selector) returns the first match from if you did document.querySelectorAll(selector) that satisfies the condition that the node found must be a descendant of el.

Even more surprising, el.querySelector even finds a match if you pass a selector that you'd think has no matches because parts of the selector aren't even inside the el:

const el = document.getElementById('outer');
const selector = '#outermost > span > span';
console.log(el.querySelector(selector).id); // logs "inner"!
// same as if you did
console.log([...document.querySelectorAll(selector)].filter(node => el !== node && el.contains(node))[0].id);
<span id="outermost">
  <span id="outer">
    <span id="inner">
      <span id="innermost"></span>
    </span>
  </span>
</span>

Related