Logo Questions Linux Laravel Mysql Ubuntu Git Menu
 

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

I really like how Eric Barnard's knockout validation lib integrates with observables, allows grouping, & offers custom validator pluggability (including on-the-fly validators). There are a couple of places where it could be more UX flexible/friendly, but overall it's reasonably well-documented... except, imo, when it comes to async validators.

I wrestled with this for a few hours today before doing a search and landing on this. I think I have the same issues/questions as the original author, but agree it wasn't clear exactly what duxa was asking for. I want to bring the question more attention so I am also asking here.

function MyViewModel() {
    var self = this;
    self.nestedModel1.prop1 = ko.observable().extend({
        required: { message: 'Model1 Prop1 is required.' },
        maxLength: {
            params: 140,
            message: '{0} characters max please.'
        }
    });
    self.nestedModel2.prop2 = ko.observable().extend({
        required: { message: 'Model2 Prop2 is required' },
        validation: {
            async: true,
            validator: function(val, opts, callback) {
                $.ajax({                                  // BREAKPOINT #1
                    url: '/validate-remote',
                    type: 'POST',
                    data: { ...some data... }
                })
                .success(function(response) {
                    if (response == true) callback(true); // BREAKPOINT #2
                    else callback(false);
                });
            },
            message: 'Sorry, server says no :('
        }
    });
}

ko.validation.group(self.nestedModel1);
ko.validation.group(self.nestedModel2);

A couple of notes about the code above: There are 2 separate validation groups, one for each nested model. Nested model #1 has no async validators, and nested model #2 has both a sync (required) and an async. The async invokes a server call to validate the inputs. When the server responds, the callback argument is used to tell ko.validation whether the user input is good or bad. If you put breakpoints on the lines indicated and trigger validation using a known invalid value, you end up with an infinite loop where the ajax success function causes the validator function to be called again. I cracked open the ko.validation source to see what was going on.

ko.validation.validateObservable = function(observable) {
    // set up variables & check for conditions (omitted for brevity)

    // loop over validators attached to the observable
    for (; i < len; i++) {
        if (rule['async'] || ctx['async']) {
            //run async validation
            validateAsync();
        } else {
            //run normal sync validation
            if (!validateSync(observable, rule, ctx)) {
                return false; //break out of the loop
            }
        }
    }

    //finally if we got this far, make the observable valid again!
    observable.error = null;
    observable.__valid__(true);
    return true;
}

This function is in a subscription chain attached to the user input observable so that when its value changes, the new value will be validated. The algorithm loops over each validator attached to the input and executes separate functions depending on whether or not the validator is async or not. If sync validation fails, the loop is broken and the whole validateObservable function exits. If all sync validators pass, the last 3 lines are executed, essentially telling ko.validation that this input is valid. The __valid__ function in the library looks like this:

//the true holder of whether the observable is valid or not
observable.__valid__ = ko.observable(true);

Two things to take away from this: __valid__ is an observable, and it is set to true after the validateAsync function exits. Now let's take a look at validateAsync:

function validateAsync(observable, rule, ctx) {
    observable.isValidating(true);

    var callBack = function (valObj) {
        var isValid = false,
            msg = '';

        if (!observable.__valid__()) {
            // omitted for brevity, __valid__ is true in this scneario
        }

        //we were handed back a complex object
        if (valObj['message']) {
            isValid = valObj.isValid;
            msg = valObj.message;
        } else {
            isValid = valObj;
        }

        if (!isValid) {
            //not valid, so format the error message...
            observable.error = ko.validation.formatMessage(...);
            observable.__valid__(isValid);
        }

        // tell it that we're done
        observable.isValidating(false);
    };

    //fire the validator and hand it the callback
    rule.validator(observable(), ctx.params || true, callBack);
}

It's important to note that only the first and last lines of this function are executed before ko.validation.validateObservable sets the __valid__ observable to true and exits. The callBack function is what gets passed as the 3rd parameter to the async validator function declared in MyViewModel. However before this happens, an isValidating observable's subscribers are invoked to notify that async validation has begun. When the server call is complete, the callback is invoked (in this case just passing either true or false).

Now here's why the breakpoints in MyViewModel are causing an infinite ping pong loop when server-side validation fails: In the callBack function above, notice how the __valid__ observable is set to false when validation fails. Here's what happens:

  1. The invalid user input changes the nestedModel2.prop2 observable.
  2. The ko.validation.validateObservable is notified via subscription of this change.
  3. The validateAsync function is invoked.
  4. The custom async validator is invoked, which submits an async $.ajax call to the server and exits.
  5. The ko.validation.validateObservable sets the __valid__ observable to true and exits.
  6. The server returns an invalid response, and callBack(false) is executed.
  7. The callBack function sets __valid__ to false.
  8. The ko.validation.validateObservable is notified of the change to the __valid__ observable (callBack changed it from true to false) This essentially repeats step 2 above.
  9. Steps 3, 4, and 5 above are repeated.
  10. Since the observable's value has not changed, the server returns another invalid response, triggering steps 6, 7, 8, & 9 above.
  11. We have ourselves a ping pong match.

So it seems like the problem is that the ko.validation.validateObservable subscription handler is listening to changes not just to the user input value, but also changes to its nested __valid__ observable. Is this a bug, or am I doing something wrong?

A secondary question

You can see from the ko.validation sources above that a user input value with an async validator is treated as valid while the server is validating it. Because of this, calling nestedModel2.isValid() cannot be relied on for "the truth". Instead, it looks like we have to use the isValidating hooks to create subscriptions to the async validators, and only make these decisions after they notify a value of false. Is this by design? Compared to the rest of the library this seems the most counter intuitive because non async validators don't have an isValidating to subscribe to, and can rely on .isValid() to tell the truth. Is this also by design, or am I doing something wrong here as well?

like image 647
danludwig Avatar asked Oct 04 '12 07:10

danludwig


People also ask

What are async validators?

The async validator's validate method returns a Promise that resolves if validation passes and value is updated or rejects with an Error if validation does not pass and value is not updated. The async validator also has a hint field that returns a Promise that when resolved will return a hint.


1 Answers

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.

like image 108
danludwig Avatar answered Oct 08 '22 10:10

danludwig