4

Fiddler here: http://jsfiddle.net/u9PLF/

I got a list of parents with nested children. I periodically update the whole object hierarchy and update the first parent's children array once. The problem I'm having is that it seems like the children array is always being updated, or at least it notifies the subscribers every time I call the fromJS function.

I expect the children observableArray to only notify the subscribers once (when it first change from [A,B] to [A,B,C], not on subsequent calls).

What am I doing wrong?

Thanks

Code:

var data = {
    parents: [{
        id: 1,
        name: 'Scot',
        children: ['A', 'B']
    }]
};

function ParentVM(data) {
    var self = this;

    ko.mapping.fromJS(data, {}, self);

    self.count = ko.observable(0);

    self.hello = ko.computed(function () {
        console.log("Update", self.children());
        self.count(self.count() + 1);
    });
}

var mapping = {
    parents: {
        create: function (options) {
            return new ParentVM(options.data);
        },
        key: function (data) {
            return ko.utils.unwrapObservable(data.id);
        }
    }
};

var viewModel = ko.mapping.fromJS(data, mapping);
ko.applyBindings(viewModel);

setInterval(function () {

    var data = {
        parents: [{
            id: 1,
            name: 'Scott',
            children: ['A', 'B', 'C']
        }]
    };

    ko.mapping.fromJS(data, mapping, viewModel);
}, 2000);

viewModel.parents.subscribe(function () {
    console.log("Parents notif");
});
slvnperron
  • 1,323
  • 10
  • 13
  • 1
    I'm not super familiar with the mapping plugin so it may be smart enough to deal with this, but I'd guess it has something to do with the fact that `['A','B','C'] != ['A','B','C']` in javascript. So a naive "has this value changed?" function would think that it has changed. – Charlie Apr 22 '14 at 22:00
  • I'm stumped, maybe overlooking something, but if you just comment out the children parts, you'll notice the exact same behaviour, while there's just only an `id` and `name` for a parent ("Parents notif") keeps popping up in the console. 1st time is of course correct, changing the name from Scot to Scott, but any following change should be prevented by the `key:´ part in `mapping`. – Major Byte Apr 23 '14 at 04:51
  • @MajorByte, I didn't read ko.mapping source, but my guess is ko.mapping didn't know the content of parents[0] never changes. It called an `update` on `name('Scott')`, even when `name` doesn't trigger a change event, ko.mapping has to broadcast a change event of `parents` blindly since it did some actions in underneath fields. – huocp Apr 23 '14 at 13:36
  • @huocp,that seems to be the case, but still, that strikes me as odd. Because the `parents` hasn't changed, nor has the `children` after the first time the setInterval callback executed, nor has any of the fields in the `children` array, so why is there a need to send out an event that the array has changed, when absolutely nothing has changed. – Major Byte Apr 23 '14 at 16:02
  • @MajorByte, from ko.mapping source code line 590-634, when mappedRootObject is an Array (like parents), it goes through all the items in the array, then calls `mappedRootObject(newContents)` with no check whether the content of newContents differs from the existing contents. I think this line 634 fires change event for parents. Here is a not-so-great proof: http://jsfiddle.net/zDa3K/4/ with a customised ko.mapping source code prints before and after line 634. – huocp Apr 23 '14 at 23:18

1 Answers1

3

@Charlie is right. This is not related to ko.mapping, it's how ko.observable tests equality of values.

http://jsfiddle.net/C7wUb/

In this demo, items are updated every time when calling items([1,2])

var vm = {
    items: ko.observable([1,2]),
    count: ko.observable(0)
};

vm.items.subscribe(function(new_v) {
    console.log("items are updated to " + new_v);
    vm.count(vm.count() + 1);
});

ko.applyBindings(vm);

setInterval(function() {
    vm.items([1,2]);
}, 2000);

The deeper cause is the default implementation of ko's equalityComparer, it only compares primitive types.

// src/subscribables/observable.js
ko.observable['fn'] = {
    "equalityComparer": valuesArePrimitiveAndEqual
};

// src/subscribables/extenders.js
var primitiveTypes = { 'undefined':1, 'boolean':1, 'number':1, 'string':1 };
function valuesArePrimitiveAndEqual(a, b) {
    var oldValueIsPrimitive = (a === null) || (typeof(a) in primitiveTypes);
    return oldValueIsPrimitive ? (a === b) : false;
}

The way to avoid unnecessary update event on observableArray is to overwrite equalityComparer.

http://jsfiddle.net/u9PLF/2/

var oldComparer = ko.observable.fn.equalityComparer;

ko.observable.fn.equalityComparer = function(a, b) {
    var i, toStr = Object.prototype.toString;
    if (toStr.call(a) === '[object Array]' && toStr.call(b) === '[object Array]') {
        if (a.length !== b.length) return false;
        for (i = 0; i < a.length; i++) {
            if (!oldComparer(a[i], b[i])) return false;
        }
        return true;
    } else {
        return oldComparer(a, b);
    }
};

(you can also overwrite equalityComparer only for children observableArray, see how ko.extenders notify is implemented in src/subscribables/extenders.js)

BTW, the way you do self.hello = ko.computed(...) is kind of risky.

self.hello = ko.computed(function () {
    console.log("Update", self.children()); // you want to notify self.hello when children change.
    self.count(self.count() + 1); // but self.hello also has dependency on self.count()
});

I think what you want is a simple subscribe, register a callback for children change event.

self.children.subscribe(function(new_v) {
    console.log("Update", new_v);
    self.count(self.count() + 1);
});
huocp
  • 3,898
  • 1
  • 17
  • 29
  • 1
    using the mapping plugin you should be able to have some control over comparing for equality, as described [here](http://knockoutjs.com/documentation/plugins-mapping.html), about halfway into the page, described under _Uniquely identifying objects using “keys”_ – Major Byte Apr 23 '14 at 13:56
  • The key in ko.mapping doesn't control equality, it tells ko.mapping whether to update existing object or blow away existing object then create new one. – huocp Apr 23 '14 at 22:01
  • 1
    @MajorByte, you missed the point, in your jsfiddle, the `name` never trigger another change event because `'Alicw' === 'Alicw'`, that is guaranteed by default equalityComparer. But if you don't use string, instead use `[1,2]` as the value of `name`, you will see the change event is triggered again and again, because `[1,2] !== [1,2]`. http://jsfiddle.net/sdygf/3/ – huocp Apr 23 '14 at 23:24