WebdriverIO Chained Selector Quirkiness

I ran into a bit of niche issue the other day that was driving me mad trying to figure out what was wrong.

I'm not sure how it should, or even if it should, be documented, but I figured it's worth mentioning here for anyone interested.

Basically, I have this HTML structure:

<div id="imageInput" class="imageComponent">
    <input type="file" />
    <div class="imageComponent">
        <input type="file" />
    </div>
</div>

I want to target that second file input, but I'm using a "component" so i have a "parent" element to go off of:

class ImagePicker () {
    constructor () {
        this.selector = '#imageInput'
    }

    get $origin () { return $(this.selector); }
    get $imageInput () { return this.$origin.$('.imageComponent input[type="file"]'); }
}

Strangely, that $imageInput element reference would fail as it selected the first input.
However, if I didn't use the $this.origin reference, and included the parent selector in my reference it works just fine:

get $imageInput () { return $('#imageInput .imageComponent input[type="file"]'); }

So that doesn't make any sense, right? this.$origin is equal to $('#imageInput'), so this.$origin.$('.imageComponent input[type="file"]') should be the same as $('#imageInput .imageComponent input[type="file"]')...

But it's not.

The reason is that the chained $ starts from the 'outside' of the origin element, so the .imageComponent selector matches the $origin element, not the child one, meaning the selector actually looks like $('#imageInput.imageComponent input[type="file"]') (i.e. no space between #imageInput and .imageComponent).

This functionality is normally unseen, as most HTML structures don't have a parent-child relationship that shares the same class name.

In the WebdriverIO docs, it says that when you chain selectors (e.g. $('.parent').$('.child'), "WebdriverIO starts the query from that element" (i.e. $('.parent')).

I read that as "it starts looking from inside the parent element", not, "it looks at the parent element first". I don't know how I'd word it though to clear that ambiguity, otherwise I'd submit a PR to update the docs 😄 I think it's just based on an internal assumption I've already made.

I was curious how the DOMs querySelector functionality behaves, and it turns out it's the same. document.querySelector('#imageInput').querySelector('.imageComponent input[type="file"]') has the same "issue". In fact, it has its own quirkiness: https://developer.mozilla.org/en-US/docs/Web/API/Element/querySelector#The_entire_hierarchy_counts

So what are my options?

An easy workaround is to change our element reference to include a second .imageComponent reference:

get $imageInput () { return this.$origin.$('.imageComponent .imageComponent input[type="file"]'); }

I really don't like that because honestly it's an ugly selector and oddly specific.

A better fix would be to edit the HTML, but that's not always possible.

One really interesting option is the :scope pseudo-class selector. It's not really useful in stylesheets, but when called on an element via querySelector, it matches that element. So $('#imageInput').$(':scope .imageComponent input[type="file"]') is the same as '$('#imageInput').$('#imageInput .imageComponent input[type="file"]')'.

That looks funny, so let's see how we'd use it back in our $imageInput element reference:

get $imageInput () { return this.$origin.$(':scope .imageComponent input[type="file"]'); }

One drawback of this approach is that :scope is relatively unknown, so anyone reading the code would have to look it up to understand why it's being used, so you'll probably want to leave a comment about it anytime you use this.

Overall, you likely won't run into this issue, but I could see it causing trouble on things like nested menus where classes are re-used in the nested elements. One of those things that you'd never know you need to know it, until you need to know it 😆

Header Photo by Shaojie on Unsplash