Currently reading: The Runaway Jury by John Grisham

querySelector() gotchas

There are two handy querySelector() methods in JS for digging elements out of the DOM for manipulation:

These basically obsoleted the selector engines in various third-party libraries (e.g. the one in jQuery) overnight upon arrival, but they're not without their quirks.

Gotcha 1: It Ain't jQuery

Element.querySelector() appears syntactically similar to how jQuery lets you dig down into an element's children to find something. If you started your JS journey with jQuery, and as such it's your mental model of DOM navigation, it's easy to make an incorrect assumption about how it works. Consider this document fragment and sets of selectors below:

<div id="primary">
  <main>
     <div class="container">
       <div>This is the one we want!</div>
     </div>
  </main>
</div>
// jQuery
const main  = $('main');
const child = main.find('div div');

// Vanilla
const main  = document.querySelector('main');
const child = main.querySelector('div div');

They appear to be doing the same thing, but this is a lie, and you'll find the incorrect div has been selected in the second case. With the jQuery version you will have selected one or more elements that match main div div, leaving you with the most-nested div as desired, but with the vanilla version you'll have grabbed .container instead. Quite a different result!

What gives? It turns out that Element.querySelector() doesn't work like you've assumed it does. As the documentation linked above says:

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.

In other words, your selector is still run against the whole document first, as though you called Document.querySelector() yourself, finding matches from the entire thing, and then those matches are pared down to the first match that's nested within the target element.

Thus, if your selector is overly broad, for which there are many matches throughout the entire document as well as within the target element, it can easily return an unexpected result. In this case, our div div selector has matched #primary .container first, and since .container is within the target element, that's the one that gets returned.

Gotcha 2: Syntax errors are Fun!

Having selected the wrong div, your next attempt to remedy the situation is likely this:

const main  = document.querySelector('main');
const child = main.querySelector('> div > div');

This results in a syntax error: > div > div is not considered a valid selector. Again, treating Element.querySelector() in the same way as jQuery has led you astray. Selectors can't just up and start with a >; the > has to have a parent element to check under, and due to the mechanics explained above, we're not magically starting our search under main.

While looking for div > div would work in this case, because there's a div nested directly under another within main, it would be nice if we had a this-style reference within CSS that automatically resolved to the element on which querySelector() is being called. Luckily, that exact thing exists!

When used within a DOM API call ⸺ such as querySelector(), querySelectorAll(), matches(), or Element.closest():scope matches the element on which the method was called.

Justified text alignment has done a number to this, eh?

:scope is your this! This handy pseudo-class works exactly as you intuitively assume, and your syntax issue is no more since your selector no longer starts with >!

const main  = document.querySelector('main');
const child = main.querySelector(':scope > div > div');

The selector is still applied across the entire document as per the documentation, but now you're effectively replacing :scope with a selector that maps to main. It's like you had done this:

const child = document.querySelector('main > div > div');

I doubt that's what the browser's doing, of course; it's certainly more complicated behind the scenes. My guess is there's some internal mapping of all the elements on a page, and :scope is replaced with the internal address through that map directly to the target element.

Not that that matters in the end. Browsers are extremely complicated voodoo many millions of lines of code long, and we're lucky there's any sort of baseline to keep developers somewhat sane. In summary:

Element.querySelector is not jQuery.find(), so don't think of it working that way, and :scope is sometimes needed to ensure your selector returns the element you expect.

Leave a comment

Your email address will not be published. Required fields are marked *