Knockout Validation async validators: Is this a bug or am I doing something wrong?

So the question I asked really had to do with how to use async validators in ko.validation. There are 2 big takeaways that I have learned from my experience:

  1. Do not create async Anonymous or Single-Use Custom Rule validators. Instead, create them as Custom Rules. Otherwise you will end up with the infinite loop / ping ping match described in my question.

  2. If you use async validators, don't trust isValid() until all async validators' isValidating subscriptions change to false.

If you have multiple async validators, you can use a pattern like the following:

var viewModel = {
    var self = this;
    self.prop1 = ko.observable().extend({validateProp1Async: self});
    self.prop2 = ko.observable().extend({validateProp2Async: self});
    self.propN = ko.observable();
    self.isValidating = ko.computed(function() {
        return self.prop1.isValidating() || self.prop2.isValidating();
    });
    self.saveData = function(arg1, arg2, argN) {

        if (self.isValidating()) {
            setTimeout(function() {
                self.saveData(arg1, arg2, argN);
            }, 50);
            return false;
        }

        if (!self.isValid()) {
            self.errors.showAllMessages();
            return false;
        }

        // data is now trusted to be valid
        $.post('/something', 'data', function() { doWhatever() });
    }
};

You can also see this for another reference with similar alternate solutions.

Here is an example of an async "custom rule":

var validateProp1Async = {
    async: true,
    message: 'you suck because your input was wrong fix it or else',
    validator: function(val, otherVal, callback) {
        // val will be the value of the viewmodel's prop1() observable
        // otherVal will be the viewmodel itself, since that was passed in
        //     via the .extend call
        // callback is what you need to tell ko.validation about the result
        $.ajax({
            url: '/path/to/validation/endpoint/on/server',
            type: 'POST', // or whatever http method the server endpoint needs
            data: { prop1: val, otherProp: otherVal.propN() } // args to send server
        })
        .done(function(response, statusText, xhr) {
            callback(true); // tell ko.validation that this value is valid
        })
        .fail(function(xhr, statusText, errorThrown) {
            callback(false); // tell ko.validation that his value is NOT valid
            // the above will use the default message. You can pass in a custom
            // validation message like so:
            // callback({ isValid: false, message: xhr.responseText });
        });
    }
};

Basically, you use the callback arg to the validator function to tell ko.validation whether or not validation succeeded. That call is what will trigger the isValidating observables on the validated property observables to change back to false (meaning, async validation has completed and it is now known whether the input was valid or not).

The above will work if your server-side validation endpoints return an HTTP 200 (OK) status when validation succeeds. That will cause the .done function to execute, since it is the equivalent of the $.ajax success. If your server returns an HTTP 400 (Bad Request) status when validation fails, it will trigger the .fail function to execute. If your server returns a custom validation message back with the 400, you can get that from xhr.responseText to effectively override the default you suck because your input was wrong fix it or else message.