querySelectorAll from an element probably doesn't do what you think it does

Modern browsers have APIs called querySelector and querySelectorAll. They find one or more elements matching a CSS selector. I'm assuming basic familiarity with CSS selectors: how you select elements, classes and ids. If you haven't used them, the Mozilla Developer Network has an excellent introduction.

Imagine the following HTML page:

<!DOCTYPE html>
<html>
<body>
    <img id="outside">
    <div id="my-id">
        <img id="inside">
        <div class="lonely"></div>
        <div class="outer">
            <div class="inner"></div>
        </div>
    </div>
</body>
</html>

document.querySelectorAll("div") returns a NodeList of all of the <div> elements on the page. document.querySelector("div.lonely") returns that single lonely div.

document supports both querySelector and querySelectorAll, letting you find elements in the entire document. Elements themselves also support both querySelector and querySelectorAll, letting you query for elements that are descendants of that element. For example, the following expression will find images that are descendants of #my-id:

document.querySelector("#my-id").querySelectorAll("img")

In the sample HTML page above, it will find <img id="inside"> but not <img id="outside">.

With that in mind, what do these two expressions do?

document.querySelectorAll("#my-id div div");
document.querySelector("#my-id").querySelectorAll("div div");

You might reasonably expect them to be equivalent. After all, one asks for div elements inside div elements inside #my-id, and the other asks for div elements inside div elements that are descendants of #my-id. However, when you look at this JSbin, you'll see that they produce very different results:

document.querySelectorAll("#my-id div div").length === 1;
document.querySelector("#my-id").querySelectorAll("div div").length === 3;

What is going on here?

It turns out that element.querySelectorAll doesn't match elements starting from element. Instead, it matches elements matching the query that are also descendants of element. Therefore, we're seeing three div elements: div.lonely, div.outer, div.inner. We're seeing them because they both match the div div selector and are all descendants of #my-id.

The trick to remembering this is that CSS selectors are absolute. They are not relative to any particular element, not even the element you're calling querySelectorAll on.

This even works with elements outside the element you're calling querySelectorAll on. For example, this selector:

document.querySelector("#my-id").querySelector("div div div")

... matches div.inner in this snippet (JSbin):

<!DOCTYPE html>
<html>
  <body>
    <div>
      <div id="my-id">
        <div class="inner"></div>
      </div>
    </div>
  </body>
</html>

I think this API is surprising, and the front-end engineers I've asked seem to agree with me. This is, however, not a bug. It's how the spec defines it to work, and browsers consistently implement it that way. Safari. John Resig commented how he and others felt this behavior was quite confusing back when the spec came out.

If you can't easily rewrite the selector to be absolute like we did above, there are two alternatives: the :scope CSS pseudo-selector, and query/queryAll.

The :scope pseudo-selector matches against the current scope. The name comes from the CSS scoping, which limits the scope of styles to part of the document. The element we're calling querySelectorAll on also counts as a scope, so this expression only matches div.inner:

document.querySelector("#my-id").querySelectorAll(":scope div div");

Unfortunately, browser support for scoped CSS and the :scope pseudo-selector is extremely limited. Only recent versions of Firefox support it by default. Blink-based browsers like Chrome and Opera require the well-hidden experimental features flag to be turned on. Safari has a buggy implementation. Internet Explorer doesn't support it at all.

The other alternative is element.query/queryAll. These are alternative methods to querySelector and querySelectorAll that exist on DOM parent nodes. They also take selectors, except these selectors are interpreted relative to the element being queried from. Unfortunately, these methods are even more obscure: they are not referenced on MDN or caniuse.com, and are missing from the current DOM4 working draft, dated 18 June 2015. They were still present in an older version, dated 4 February 2014, as well as in the WHATWG Living Document version of the spec. They have also been implemented by at least two polyfills:

In conclusion, the DOM spec doesn't always necessarily do the most obvious thing. It's important to know pitfalls like these, because they're difficult to discover from just the behavior. Fortunately, you can often rewrite your selector so that it isn't a problem. If you can't, there's always a polyfill to give you the modern API you want. Alternatively, libraries like jQuery can also help you get a consistent, friendly interface for querying the DOM.