Search code examples
javascriptknockout.jsknockout-mapping-plugin

Trouble with Knockout Mapping Create/Update


I am trying to map data so that elements only get re-rendered when values have actually changed.

{
    Apps : [
        {
            "Categories" : [{
                    "Name" : "#Some,#More,#Tags,#For,#Measure"
                }
            ],
            "Concentrator" : "",
            "Health" : 1,
            "Id" : 2648,
            "Ip" : "1.1.1.1",
            "IsDisabled" : true,
            "IsObsolete" : false,
            "Name" : "",
            "Path" : "...",
            "SvcUrl" : "http://1.1.1.1",
            "TimeStamp" : "\/Date(1463015444163)\/",
            "Type" : "...",
            "Version" : "1.0.0.0"
        }
        ...
    ]
    ...
}

var ViewModel = function() {
    self.Apps = ko.observableArray([]);
}

var myModel = new ViewModel();

var map = {
    'Apps': {
        create: function (options) {
            return new AppModel(options.data);
        },

        key: function(data) { return ko.utils.unwrapObservable(data.Id); }
    }
}

var AppModel = function(data){
    data.Categories = data.Categories[0].Name.split(',');
    ko.mapping.fromJS(data, { }, this);
    return this;
}

function UpdateViewModel() {
    return api.getDashboard().done(function (data) {
        ko.mapping.fromJS(data, map, myModel);
    });
}

loopMe(UpdateViewModel, 5000);

function loopMe(func, time) {
    //Immediate run, once finished we set a timeout and run loopMe again
    func().always(function () {
        setTimeout(function () { loopMe(func, time); }, time);
    });
}

<script type="tmpl" id="App-template">
    <div>
        <!-- ko foreach: Categories -->
        <span class="btn btn-default btn-xs" data-bind="text:$data"></span>
        <!-- /ko -->
    </div>
</script>

On the first run of UpdateViewModel I will see 5 spans as expected. On the second call, receiving the same data, it gets updated to a single span that says [Object object] which is because it still thinks Categories is an array of objects instead of an array of strings.

Everything seems fixed if I change 'create' to 'update' in my map, however it seems that the spans are then re-rendered every time regardless if data changed or not.

Can anyone lend me a hand in the direction I need to go so that I can

  1. adjust the Categories array from objects to strings
  2. Only re-render/render changed/new items

Here is a Fiddle showing the behavior


Solution

  • The problem is with these lines:

    var AppModel = function(data){
        data.Categories = data.Categories[0].Name.split(','); // <-- mainly this one
        ko.mapping.fromJS(data, { }, this);
        return this;
    }
    

    There's two problems:

    1. You mutate the data object which (at least in our repro) mutates the original object that data references to. So first time one of the fakeData objects is passed in, that one is mutated in place, and will forever be "fixed".

    2. You mutate it in the AppModel constructor function, which is only called the first time. According to your key function, the second time the constructor should not be called, but instead ko-mapping should leave the original object and mutate it in place. But it will do so with a "wrongly" formatted data.Categories property.

    The correct fix seems to me to be in your data layer, which we have mocked in the repro, so it makes little sense for my answer to show you how.

    Another more hacky way to do this would be to have an update method in your mapping like so:

    update: function(options) {
      if (!!options.data.Categories[0].Name) {
        options.data.Categories = options.data.Categories[0].Name.split(',');
      }
      return options.data;
    },
    

    When it encounters an "unmodified" data object it'll do the same mutation. See this jsfiddle for that solution in action.