Search code examples
javascriptknockout.jsknockout-3.0knockout-binding-handlers

Why did changing this formatting function in this way, cause the Knockout binding to start working?


I have an element on a page, like this:

<span data-bind="text: FormattedCountOfPeople"></span>

And the function that calculates it I first wrote like this:

self.FormattedCountOfPeople = ko.computed(function () {
    if(!self.PeopleCountFormatString)                 //a string like "{0} people in the conference", but set by a service call so starts out undefined
      return "not set yet";

    let sizes = self.Params().PermittedCounts();      //an array of discrete permitted sizes e.g. [2, 10, 30]
    let size = self.Params().ChosenCount();           //value set by a jquery slider, could be any double, even 5.7435 - to achieve smooth dragging of the slider it has a small step but snaps to a permitted count
    let n = nearest(size, sizes);                     //a "round to nearest" helper function that given args of e.g. (5.7345, [2, 10, 30]) will pick 2 because 5.7345 is nearer to 2 than 10
    let s = self.PeopleCountFormatString.csFormat(n); //csformat is a helper function that performs C# style string Formatting e.g. "{0} people".csFormat(2) -> "2 people"
    return s;
});

And I went round in circles for hours wondering why the text on the page just jammed at "not set yet" no matter what the slider setting was, but another element that was <span data-bind="text: Params().ChosenCount"></span> added as a test, was updating perfectly and elsewhere a different slider was setting an hours and minutes duration just fine, using a similar logic:

//if the user picks 61.2345, it rounds to 60, then returns "1 hour". Picking 74.11 rounds to 75 then returns "1 hour, 15 min"
self.FormattedDurationText = ko.computed(function () {
    var duration = self.Params().ChosenDuration();  
    duration = Math.round(duration / 15) * 15;

    if (Math.floor(duration / 60) > 0)
        var hours = self.DurationTextHours.csFormat(Math.floor(duration / 60));
    if (duration % 60 > 0)
        var minutes = self.DurationTextMinutes.csFormat(duration % 60);
    if (minutes && hours)
        return self.DurationTextLayout.csFormat(hours, minutes)
    if (hours && !minutes)
        return hours;
    if (!hours && minutes)
        return minutes;
    return "";
});

Adding some console logging in various places it became apparent that after the first invocation of FormattedCountOfPeople that returned "not set yet", the FormattedCountOfPeople was never being called again when dragging the people count slider. The other span, bound straight to the raw Params().ChosenCount would update continually, so the value was changing ok. Similarly the slider bound to Params().ChosenDuration was updating the value, and FormattedDuration() was being called every time the value changed and providing a new formatted string to go in the span

In the end, the code change that made things work seems incredibly inconsequential. I dropped the initial if and swapped it for an inline conditional:

self.FormattedCountOfPeople = ko.computed(function () {
    let sizes = self.Params().PermittedCounts();
    let size = self.Params().ChosenCount();
    let n = nearest(size, sizes); 
    let s = (self.PeopleCountFormatString ? self.PeopleCountFormatString: "not set yet").csFormat(n); 
    return s;

Why did making this change suddenly mean that knockout started calling FormattedCountOfPeople every time the value changed? The only thing I can see that really differs is that FormattedDurationText is structured such that it always calls e.g. Params().ChosenDuration() before deciding the values are no good and returns "" whereas the first version of FormattedCountOfPeople had a behavior that occasionally decided not to call anything and the second version always calls. Is there a side effect to calling Params().Xxx() such as "resetting a flag", and if the flag is never reset, knockout never calls the function again even if the value changes?


Solution

  • When you reference an observable in a computed, KO will internally subscribe to that observable, so the computed is re-evaluated every time the observable changes.

    When you do this right at the start of the computed:

    if(!self.PeopleCountFormatString)
      return "not set yet";
    

    And self.PeopleCountFormatString isn't an observable (which it doesn't appear to be), your computed isn't evaluated any further because of the return statement, the subscription to ChosenCount never happens and because PeopleCountFormatString itself isn't an observable either, the computed is not re-evaluated later on when PeopleCountFormatString does have a value. Hence, its value will forever stay "not set yet".

    The updated computed works because you reference the other observables immediately, so internally KO will subscribe to those and re-evaluate the computed whenever the observables change.