Race Conditions in Knockout

402 views Asked by At

I've recently run into a small issue with race conditions in my code. I am using Knockout.Js to gather some information to be displayed to the user.

The trouble occurs when a drop down needs to be generated before the value can be selected. Normally the drop down list is smaller than the other request and will win the race, and thus causing no problems. However I have seen some situations in my application, on slower internet connections, where the list loads secondly. Below is an example. If there is no option in the list for the value, it cannot be selected, and to the user appears as though there was not one selected.

Using setTimeout I've simulated this experience. You can swap the two values to see the "success" scenario.

function ViewModel() {
  var self = this;

  self.UserName = ko.observable();
  self.UserGroup = ko.observable();
  self.GroupList = ko.observableArray();

  self.LoadUserGroups = function() {
    //Ajax call to populate user groups 

    setTimeout(function() {
      response = "Red Team,Blue Team,Green Team".split(",");

      self.GroupList(response)
    }, 2000) /// SWAP ME 
  }

  self.LoadUserInformation = function() {
    setTimeout(function() {
      response = {
        UserName: "John Pavek",
        UserGroup: "Blue Team"
      };

      self.UserName(response.UserName);
      self.UserGroup(response.UserGroup);

    }, 1000) // SWAP ME
  }

  self.Load = function() {
    self.LoadUserGroups();
    self.LoadUserInformation();
  }

  self.Load();

}

ko.applyBindings(new ViewModel())
<script src="https://cdnjs.cloudflare.com/ajax/libs/knockout/3.4.2/knockout-min.js"></script>

User Name:
<input data-bind="value: UserName" /> User Group:
<select data-bind="options: GroupList, optionsCaption: '--Pick a Team--', value: UserGroup"></select>

What could I add to my code, Vanilla or knockout to prevent this issue from occurring without slowing down the entire experience?

2

There are 2 answers

1
Roy J On BEST ANSWER

What's happening is that the select is overriding UserGroup when the value LoadUserInformation tries to set isn't in the options list. But you can use valueAllowUnset to tell it not to worry about unknown values. Then it won't override.

function ViewModel() {
  var self = this;

  self.UserName = ko.observable();
  self.UserGroup = ko.observable();
  self.GroupList = ko.observableArray();

  self.LoadUserGroups = function() {
    //Ajax call to populate user groups 

    setTimeout(function() {
      response = "Red Team,Blue Team,Green Team".split(",");

      self.GroupList(response)
    }, 2000) /// SWAP ME 
  }

  self.LoadUserInformation = function() {
    setTimeout(function() {
      response = {
        UserName: "John Pavek",
        UserGroup: "Blue Team"
      };

      self.UserName(response.UserName);
      self.UserGroup(response.UserGroup);

    }, 1000) // SWAP ME
  }

  self.Load = function() {
    self.LoadUserGroups();
    self.LoadUserInformation();
  }

  self.Load();

}

ko.applyBindings(new ViewModel())
<script src="https://cdnjs.cloudflare.com/ajax/libs/knockout/3.4.2/knockout-min.js"></script>

User Name:
<input data-bind="value: UserName" /> User Group:
<select data-bind="options: GroupList, optionsCaption: '--Pick a Team--', value: UserGroup, valueAllowUnset: true"></select>

2
T.J. Crowder On

You have no option but to wait to apply the information from LoadUserInformation until after getting the information from LoadUserGroups. But that doesn't have to slow down the experience at all.

You'll need to separate loading from showing the information. In a non-promise environment I'd have each function accept a callback, and then only proceed (calling the "show" functions) once I had both results, see *** comments:

function ViewModel() {
  var self = this;

  self.UserName = ko.observable();
  self.UserGroup = ko.observable();
  self.GroupList = ko.observableArray();

  // *** Only loads, doesn't show
  self.LoadUserGroups = function(callback) {
    //Ajax call to populate user groups 

    setTimeout(function() {
      callback("Red Team,Blue Team,Green Team".split(","));
    }, 2000);
  }
  
  // *** Shows
  self.ShowUserGroups = function(groups) {
      self.GroupList(groups);
  };

  // *** Only loads, doesn't show
  self.LoadUserInformation = function(callback) {
    setTimeout(function() {
      callback({
        UserName: "John Pavek",
        UserGroup: "Blue Team"
      });
    }, 1000);
  };
  
  // *** Shows
  self.ShowUserInformation = function(info) {
      self.UserName(info.UserName);
      self.UserGroup(info.UserGroup);
  };

  self.Load = function() {
    var groups = null, userInfo = null;
    self.LoadUserGroups(function(g) {
      groups = g;
      checkDone();
    });
    self.LoadUserInformation(function(u) {
      userInfo = u;
      checkDone();
    });
    function checkDone() {
      if (groups && userInfo) {
        // *** We have both, show them
        self.ShowUserGroups(groups);
        self.ShowUserInformation(userInfo);
      }
    }
  }

  self.Load();

}

ko.applyBindings(new ViewModel())
<script src="https://cdnjs.cloudflare.com/ajax/libs/knockout/3.4.2/knockout-min.js"></script>

User Name:
<input data-bind="value: UserName" /> User Group:
<select data-bind="options: GroupList, optionsCaption: '--Pick a Team--', value: UserGroup"></select>

Technically, of course, there's nothing preventing you showing the groups if they arrive before the user info, you'd just use a state flag:

function ViewModel() {
  var self = this;

  self.UserName = ko.observable();
  self.UserGroup = ko.observable();
  self.GroupList = ko.observableArray();

  // *** Only loads, doesn't show
  self.LoadUserGroups = function(callback) {
    //Ajax call to populate user groups 

    setTimeout(function() {
      callback("Red Team,Blue Team,Green Team".split(","));
    }, 2000);
  }
  
  // *** Shows
  self.ShowUserGroups = function(groups) {
      self.GroupList(groups);
  };

  // *** Only loads, doesn't show
  self.LoadUserInformation = function(callback) {
    setTimeout(function() {
      callback({
        UserName: "John Pavek",
        UserGroup: "Blue Team"
      });
    }, 1000);
  };
  
  // *** Shows
  self.ShowUserInformation = function(info) {
      self.UserName(info.UserName);
      self.UserGroup(info.UserGroup);
  };

  self.Load = function() {
    var haveGroups = false, userInfo = null;
    self.LoadUserGroups(function(groups) {
      // *** No need to wait for the user info
      self.ShowUserGroups(groups);
      haveGroups = true;
      checkDone();
    });
    self.LoadUserInformation(function(u) {
      userInfo = u;
      checkDone();
    });
    function checkDone() {
      if (haveGroups && userInfo) {
        // *** Show the user info
        self.ShowUserInformation(userInfo);
      }
    }
  }

  self.Load();

}

ko.applyBindings(new ViewModel())
<script src="https://cdnjs.cloudflare.com/ajax/libs/knockout/3.4.2/knockout-min.js"></script>

User Name:
<input data-bind="value: UserName" /> User Group:
<select data-bind="options: GroupList, optionsCaption: '--Pick a Team--', value: UserGroup"></select>

In a promise environment I'd have the "load" functions return promises instead, and then using them would look something like this instead of requiring the checkDone call:

Promise.all([
    self.LoadUserGroups().then(self.ShowUserGroups),
    self.LoadUserInfo()
]).then(function(results) {
    self.ShowUserInfo(results[1]);
});

...which becomes clearer with ES2015+ parameter destructuring syntax:

Promise.all([
    self.LoadUserGroups().then(self.ShowUserGroups),
    self.LoadUserInfo()
]).then(function([_, userInfo]) {
    self.ShowUserInfo(userInfo);
});