How is jQuery.val able to bypass the get/set functions on the value property?

Viewed 26
const { get, set } = Object.getOwnPropertyDescriptor(HTMLSelectElement.prototype, 'value');
let countryElement = $('select[name="country_id"]').get(0);
Object.defineProperty(countryElement, 'value', {
    get() {
        console.log('calling get');
        return get.call(this);
    },
    set(newVal) {
        console.log('New value assigned to selected[name="country_id"]: ' + newVal);
        return set.call(this, newVal);
    }
});

If I run document.querySelector('select[name="country_id"]').value = 'CA' then the set function is called. If I run $('select[name="country_id"]').val('CA') the set function is not called.

My goal is to observe changes to a form field which are executed by a third party JS tool which I cannot change. However, I can't get it to work. As I dove into the issue, I found that I can even get jQuery to change the value without going through my set function. But I don't see how that's possible.

1 Answers

Look into jQuery's source code. val does, eventually:

        hooks = jQuery.valHooks[ this.type ] || jQuery.valHooks[ this.nodeName.toLowerCase() ];

        // If set returns undefined, fall back to normal setting
        if ( !hooks || !( "set" in hooks ) || hooks.set( this, val, "value" ) === undefined ) {
            this.value = val;
        }

So it accesses and calls jQuery.valHooks.select.set, which does, elsewhere in the source:

        set: function( elem, value ) {
            var optionSet, option,
                options = elem.options,
                values = jQuery.makeArray( value ),
                i = options.length;

            while ( i-- ) {
                option = options[ i ];

                /* eslint-disable no-cond-assign */

                if ( option.selected =
                    jQuery.inArray( jQuery.valHooks.option.get( option ), values ) > -1
                ) {
                    optionSet = true;
                }

                /* eslint-enable no-cond-assign */
            }

            // Force browsers to behave consistently when non-matching value is set
            if ( !optionSet ) {
                elem.selectedIndex = -1;
            }
            return values;
        }

It iterates through the options. The only lines that actually change the DOM in the above function is:

option.selected = jQuery.inArray( jQuery.valHooks.option.get( option ), values ) > -1

or

elem.selectedIndex = -1;

So, jQuery isn't calling the value setter at all. You could emulate what it's doing and set the value of a <select> by assigning to the selected property of the option you want to make selected.

const { get, set } = Object.getOwnPropertyDescriptor(HTMLSelectElement.prototype, 'value');
let countryElement = $('select[name="country_id"]').get(0);
Object.defineProperty(countryElement, 'value', {
    get() {
        console.log('calling get');
        return get.call(this);
    },
    set(newVal) {
        console.log('New value assigned to selected[name="country_id"]: ' + newVal);
        return set.call(this, newVal);
    }
});

countryElement.children[1].selected = true;
<script src="https://cdnjs.cloudflare.com/ajax/libs/jquery/3.3.1/jquery.min.js"></script>
<select name="country_id">
  <option>foo</option>
  <option value="CA">CA</option>
</select>

My goal is to observe changes to a form field which are executed by a third party JS tool which I cannot change.

There are a number of ways to change a <select> value, which include .value, setting the .selected property of an option, and setting the .selectedIndex of the select. You'll need to implement all of them to do what you want reliably.

Related