Skip to content

deferUpdates conflict with 'if' binding#2226

Merged
mbest merged 1 commit intomasterfrom
2226-deferUpdates-if-binding
Apr 15, 2017
Merged

deferUpdates conflict with 'if' binding#2226
mbest merged 1 commit intomasterfrom
2226-deferUpdates-if-binding

Conversation

@mbest
Copy link
Copy Markdown
Member

@mbest mbest commented Apr 15, 2017

As described at http://stackoverflow.com/q/43341484/1287183 by Simon_Weaver:

I very often use the if binding in knockout to hide something, with the added bonus that I don't need to worry about null reference errors inside the if. In this example if address() is null then the whole block is removed so you avoid having to deal with null checking for every property. This would not be the case had I used the visible binding.

<div data-bind="if: address()">
	You live at:
	<p data-bind="text: address().street.toUpperCase()"></p>			
</div>

This is the simplest case above - and yes I would generally use this pattern with the <!-- ko --> comment syntax.

What is actually causing me problems is when I use a more complex computed value and enable the ko.options.deferUpdates option :

<div data-bind="if: hasAddress()">
	You live at:
	<p data-bind="text: address().street.toUpperCase()"></p>			
</div>

The simplest implementation of this computed observable might be something like this :

this.hasAddress = ko.computed(function () { return _this.address() != null; }); 

This all works great until I do the following:

  1. set ko.options.deferUpdates = true before creating the observables.
  2. address() will start off as null and everything is fine
  3. set address() to { street: '123 My Street' }. Again everything works fine.
  4. reset address() to null. I get a null error because address().street is null :-(

Here is a fiddle to illustrate the problem : https://jsfiddle.net/g5gvfb7x/2/

It seems that unfortunately due to the order in which the micro-tasks runs it tries to recalculate the text binding before the if binding and so you still get a null error that normally wouldn't occur.

@mbest mbest force-pushed the 2226-deferUpdates-if-binding branch from 5c9e22b to 992bc71 Compare April 15, 2017 06:11
@mbest mbest merged commit 1592bdc into master Apr 15, 2017
@mbest mbest deleted the 2226-deferUpdates-if-binding branch April 15, 2017 06:16
@fastfasterfastest
Copy link
Copy Markdown

fastfasterfastest commented Apr 20, 2017

Interestingly, I ran into this very issue last week as well while turning on ko.options.deferUpdates in my app.

I had distilled down a reproduction sample and was going to post but then saw the stackoverflow post and this pull request. I made a custom knockout build (3.4.2 modified with this pull request) and my reproduction sample no longer shows the problem - great! But, I am still seeing the problem in my app....

Before tracking down why my app still shows the problem, I want to ask.
Assume

  • <!-- ko if: CONDITION --><span data-bind="text: THEN"></span><!-- /ko -->
  • CONDITION is a computed observable that has a boolean value and has a dependency on an observable (obsv1)
  • THEN is a computed observable (or function call) that has a dependency on the same observable (obsv1)

Initially the value of CONDITION is true and, thus, the view has been rendered with the value THEN has.

My question is: With this pull request in place, if obsv1 is changed in such a way that CONDITION will become false, is it possible for THEN be re-evaluated?

Added: In other words, is it possible that the computed observable that knockout uses to wrap THEN in the text binding is "scheduled" to be updated before the computed observable that knockout uses to wrap CONDITION in the if binding?

@mbest
Copy link
Copy Markdown
Member Author

mbest commented Apr 20, 2017

Is it possible that the computed observable that knockout uses to wrap THEN in the text binding is "scheduled" to be updated before the computed observable that knockout uses to wrap CONDITION in the if binding?

Yes. That's possible. But I would think with this change, that it would match the behavior of not setting deferUpdates.

@fastfasterfastest
Copy link
Copy Markdown

fastfasterfastest commented Apr 20, 2017

In my case/app, I didn't encounter the problem when not setting deferUpdates. With deferUpdates (and your change/pull request), I am still running into the problem in my "full" app. I have to track it down further, but it is as if THEN is re-evaluated before CONDITION is re-evaluated.

Yes. That's possible.

Can you quickly/briefly elaborate how/when that could happen - maybe it will help me track my problem down.

@mbest
Copy link
Copy Markdown
Member Author

mbest commented Apr 21, 2017

Can you quickly/briefly elaborate how/when that could happen - maybe it will help me track my problem down.

https://jsfiddle.net/mbest/g5gvfb7x/12/

Is there a solution? Yes, use pureComputed instead:

https://jsfiddle.net/mbest/g5gvfb7x/13/

@fastfasterfastest
Copy link
Copy Markdown

Thanks - but your examples don't set ko.options.deferUpdates, right? My question, and I think the OP as well, is when ko.options.deferUpdates is set to true. (And even with this pull request applied.)

I.e., using ko.options.deferUpdates=true and with this pull request applied, is it possible that the computed observable that knockout uses to wrap THEN in the text binding is "scheduled" to be updated before the computed observable that knockout uses to wrap CONDITION in the if binding?

This is what's happening in my app currently. I am still trying to distill it down to a simpler/simple sample.

Btw, the CONDITION and THEN (computed) observables are all pure in my case.

@mbest
Copy link
Copy Markdown
Member Author

mbest commented Apr 21, 2017

I've modified the example to use the latest build and deferUpdates=true to show that we can get the same problem. But this example also has that problem without deferUpdates=true. I'm not sure how to create an example that will fail in only one case after the fix here.

http://jsfiddle.net/mbest/g5gvfb7x/15/

@chrisknoll
Copy link
Copy Markdown

@mbest, it looks like from your commit comment that you're directly looking for the if binding to evaluate before descendant bindings, but wouldn't it be more generalized to refresh bindings from a top-down approach (instead of what I think we're seeing here is a bottom-up approach)?

I can imagine a very complex view model that has a very high top-level property which a whole network of child-level properties are bound to deep in a cascading call hierarchy (consider a top level with, with then a for: followed by a nested for, which might have a nested if..etc). if the top level change in the observable causes all the bottom-level bindings to re-evaluate going bottom up, it could be that the top level binding is changed in such a way that none of the child bindings even need to evaluate anymore (which I think is the case in the simple fiddle you supplied above). And i think this behavior doesn't depend on defered updates at all, it is just how the nested bindings are resolving themselves going from the inner-to-outer bindings.

So is this just a matter of the order of binding evaluations, and also canceling the binding evaluations when the parent bidning causes a removal of the dom-binding of a child (such as the case in the if: binding)?

@mbest
Copy link
Copy Markdown
Member Author

mbest commented Apr 21, 2017

The commit message isn't entirely accurate. It makes sure that when using deferred updates, the order of binding updates will match what you'd get without deferred updates. Actually there's currently no mechanism in Knockout to ensure that bindings on descendant nodes get updated after parent nodes.

@fastfasterfastest
Copy link
Copy Markdown

Thanks for the additional fiddle.

Is there a solution? Yes, use pureComputed instead

The important difference doesn't seem to be (just) computed vs. pureComputed. Or perhaps it is only an important aspect of the observable being referenced inside the guarded section? E.g., using your first fiddle, https://jsfiddle.net/mbest/g5gvfb7x/13/ , if you change hasAddress from pureComputed to computed, things still work.

Another factor coming into play seems to be the ordering of the DOM nodes in the html document. E.g., your last fiddle, http://jsfiddle.net/mbest/g5gvfb7x/15/ , slightly modified using ifnot and not dereferencing the observables but which in essence is:

<div data-bind="ifnot: computedAddress">
    Sorry! I don't know where you live
</div>
<div data-bind="if: hasAddress">
    You live at: <p data-bind="text: computedAddress().street.toUpperCase()"></p>
</div>

generates an error. But simply re-ordering the two divs then things will work:

<div data-bind="if: hasAddress">
    You live at: <p data-bind="text: computedAddress().street.toUpperCase()"></p>
</div>
<div data-bind="ifnot: computedAddress">
    Sorry! I don't know where you live
</div>

Another instance of the ordering of DOM nodes is perhaps the following. Using your first fiddle, https://jsfiddle.net/mbest/g5gvfb7x/13/ , slightly modified using ifnot and not dereferencing the observables but which in essence is:

<div data-bind="if: hasAddress">
    You live at: <p data-bind="text: computedAddress().street.toUpperCase()"></p>
</div>
<div data-bind="ifnot: hasAddress">
    Sorry! I don't know where you live
</div>

works. But changing to computedAddress in the 2nd div causes an error (in a binding in the 1st div!). This is perhaps due to the reference to computedAddress in the guarded section occurs before the reference in the ifnot binding:

<div data-bind="if: hasAddress">
    You live at: <p data-bind="text: computedAddress().street.toUpperCase()"></p>
</div>
<div data-bind="ifnot: computedAddress">
    Sorry! I don't know where you live
</div>

I am still trying to distill the issue down in my app. Tracing into the knockout sources I did notice that (in my scenario) the observable(s) in THEN is re-evaluated before the observable(s) in CONDITION. When CONDITION finally gets re-evaluated it of course properly removes its child nodes and disposes of the observables in the bindings in the child nodes, etc. Just wished/hoped that CONDITION was ahead of the THEN in the task queue of dirty observables... (or that the computed observable knockout creates for the THEN binding had a dependency on CONDITION, then perhaps CONDITION would be re-evaluated first, so when it comes time for the THEN observable it is either disposed or marked disposed so no re-evaluation is necessary.)

@chrisknoll
Copy link
Copy Markdown

chrisknoll commented Apr 22, 2017

Maybe the bottom line here is that the computeds should be assumed to be re-evaluated in any order, ignoring how they are being bound to in a hierarchy of the DOM. IE: you can't depend on an observable in a THEN part of an if: binding to not evaluate based on the condition of the IF. If the observable is set up in a dependency track, then all the dependent observables will be notified and evaluated. It just means that you will need to guard against nulls like so:

<div data-bind="if: hasAddress">
    You live at: <p data-bind="text: computedAddress() && compuatedAddress().street && computedAddress().street.toUpperCase()"></p>
</div>

I guess I'd like it to be more robust, but I think maybe the fundamental issue here is that the dependency tracking of observables is separate from the arrangement of the DOM. And with this separation, we can't do things like 'ignore these observables when a parent in the DOM is re-evaluated'. I like knockout for what it does, and so if part of the beauty of it comes from this separation from DOM arrangement and dependency tracking of observables, then I guess the cost of this separation is to santiy-check all evaluations and not depend on ordering in the dom.

-Chris

@fastfasterfastest
Copy link
Copy Markdown

I think it would get cumbersome and error prone having to repeat the condition in the descendant bindings. And if you have nested if bindings you may need to repeat multiple conditions...

Actually there's currently no mechanism in Knockout to ensure that bindings on descendant nodes get updated after parent nodes.

Intuitively, it feels like the bindings inside an if binding should not get re-evaluated if the condition in the if binding becomes false - it is quite surprising if they do (as currently can happen.) I understand that this is due to the THEN observables in some circumstances happen to get re-evaluated before the CONDITION observables.

But, rather than ensuring bindings on descendant nodes get updated after parent nodes, what if there was a mechanism to allow for a binding to indicate whether a child (or descendant) binding is soon-to-be-disposed. I don't know the intricate details of the knockout internals and consequences and implications of modifications, but something like this:

  1. bindingContext.disposalIsImminent
    Extend bindingContext with a disposalIsImminent property. The value, if specified, is an observable indicating whether the binding will soon be disposed.
    Bindings that do their updates in an update method can ignore this - knockout will handle it automatically and not call the update method if disposal of the binding is imminent.
    Bindings that do their updates through other means may want to/should look at bindingContext.disposalIsImminent before unwrapping valueAccessor, see below.
  2. applyBindingsToNodeInternal
    In applyBindingsToNodeInternal, change the computed observable that is created for the update method for a binding to use bindingContext.disposalIsImminent
    // Run update in its own computed wrapper
    if (typeof handlerUpdateFn == "function") {
        ko.dependentObservable(function() {
            if(bindingContext.disposalIsImminent && bindingContext.disposalIsImminent()){
                ko.virtualElements.emptyNode(node);
            }else{
                handlerUpdateFn(node, getValueAccessor(bindingKey), allBindings, bindingContext['$data'], bindingContext);
            }
        }, null, { disposeWhenNodeIsRemoved: node });
    }

This change means that bindings that do their updates in an update method will be handled automatically - their update method will not get called (and thus the observable in their binding will not get re-evaluated) if an ancestor binding indicates it will soon dispose them, instead knockout would immediately remove such nodes.

  1. makeWithIfBinding
    In makeWithIfBinding, change the computed observable that is used to perform the update: use bindingContext.disposalIsImminent and only unwrap valueAccessor if disposal is not imminent; return a boolean indicating whether the descendant bindings will soon be disposed; pass this computed observable as disposalIsImminent in the binding context to descendant bindings.
    var descendantBindingDisposalIsImminent = ko.computed(function () {
        var disposalIsImminent = bindingContext.disposalIsImminent && bindingContext.disposalIsImminent(),
            rawValue = disposalIsImminent ? undefined : valueAccessor(),
            dataValue = disposalIsImminent ? undefined : ko.utils.unwrapObservable(rawValue),
            shouldDisplay = disposalIsImminent ? false : !isNot !== !dataValue, // equivalent to isNot ? !dataValue : !!dataValue
            isFirstRender = !savedNodes,
            needsRefresh = isFirstRender || isWith || (shouldDisplay !== didDisplayOnLastUpdate);

            if (needsRefresh) {
                // Save a copy of the inner nodes on the initial update, but only if we have dependencies.
                if (isFirstRender && ko.computedContext.getDependenciesCount()) {
                    savedNodes = ko.utils.cloneNodes(ko.virtualElements.childNodes(element), true /* shouldCleanNodes */);
                }

                if (shouldDisplay) {
                    if (!isFirstRender) {
                        ko.virtualElements.setDomNodeChildren(element, ko.utils.cloneNodes(savedNodes));
                    }
                    //todo: create new binding context w/ disposalIsImminent set to descendantBindingDisposalIsImminent and pass this binding context to descendants
                    ko.applyBindingsToDescendants(makeContextCallback ? makeContextCallback(bindingContext, rawValue) : bindingContext, element);
                } else {
                    ko.virtualElements.emptyNode(element);
                }

                didDisplayOnLastUpdate = shouldDisplay;
            }
            //return true if disposal of descendant bindings are imminent
            return disposalIsImminent || !shouldDisplay;
        }

  1. other bindings without an update method
    In other bindings without an update method that do their updates by using a computed observable, change the computed observable to use bindingContext.disposalIsImminent and only unwrap valueAccessor if disposal is not imminent.

The (intended) net effect would be that descendant bindings of if, ifnot and with, although they may be re-evaluated before their ancestor, they would detect if the ancestor is about to dispose them and, if so, not re-evaluate their value proper.

@fastfasterfastest
Copy link
Copy Markdown

Instead of disposalIsImminent, a better name is perhaps ancestorBindingCondition. Similar to how a binding can indicate whether it controls descendant bindings by { controlsDescendantBindings: true }, ancestorBindingCondition would be passed to descendant bindings by the controlling ancestor binding to indicate to the descendants whether they should bind/evaluate or not.

    // Run update in its own computed wrapper
    if (typeof handlerUpdateFn == "function") {
        ko.dependentObservable(function() {

            if(!bindingContext.ancestorBindingCondition || bindingContext.ancestorBindingCondition()){
                handlerUpdateFn(node, getValueAccessor(bindingKey), allBindings, bindingContext['$data'], bindingContext);
            }else{
                ko.virtualElements.emptyNode(node);
            }
        }, null, { disposeWhenNodeIsRemoved: node });
    }
    var descendantBindingAncestorBindingCondition = ko.computed(function () {
        var ancestorBindingCondition = !bindingContext.ancestorBindingCondition || bindingContext.ancestorBindingCondition(),
            rawValue = ancestorBindingCondition ? valueAccessor() : undefined,
            dataValue = ancestorBindingCondition ? ko.utils.unwrapObservable(rawValue) : undefined,
            shouldDisplay = ancestorBindingCondition ? !isNot !== !dataValue : false, // equivalent to isNot ? !dataValue : !!dataValue
            isFirstRender = !savedNodes,
            needsRefresh = isFirstRender || isWith || (shouldDisplay !== didDisplayOnLastUpdate);

            if (needsRefresh) {
                // Save a copy of the inner nodes on the initial update, but only if we have dependencies.
                if (isFirstRender && ko.computedContext.getDependenciesCount()) {
                    savedNodes = ko.utils.cloneNodes(ko.virtualElements.childNodes(element), true /* shouldCleanNodes */);
                }

                if (shouldDisplay) {
                    if (!isFirstRender) {
                        ko.virtualElements.setDomNodeChildren(element, ko.utils.cloneNodes(savedNodes));
                    }
                    //todo: create new binding context w/ ancestorBindingCondition set to descendantBindingAncestorBindingCondition and pass this binding context to descendants
                    ko.applyBindingsToDescendants(makeContextCallback ? makeContextCallback(bindingContext, rawValue) : bindingContext, element);
                } else {
                    ko.virtualElements.emptyNode(element);
                }

                didDisplayOnLastUpdate = shouldDisplay;
            }
            //return true if descendant bindings should bind/evaluate
            return ancestorBindingCondition && shouldDisplay;
        }
  1. createChildContext and/or bindingContext.extend and/or ko.bindingContext need to copy ancestorBindingCondition into new bindingContext

@fastfasterfastest
Copy link
Copy Markdown

While trying to distill down this problem in my app, I came across some additional weirdness/surprises. My samples use a similar address scenario as OP, but slightly more complex and I am using @mbest's latest, pre-3.50 build.

  1. Sample 1: http://jsfiddle.net/fastfasterfastest/z0jhfrwa/
    <div data-bind="if: hasValidAddress">
        <p data-bind="text: '' + address.streetNumber() + ' ' + address.street().toUpperCase()"></p>

Set Address followed by Clear Address => no error. I didn't even need to add CONDITION to THEN for things to work.

  1. Sample 2: http://jsfiddle.net/fastfasterfastest/0u1qx24p/
    The only difference between sample 1 and sample 2 is the text binding.
    <div data-bind="if: hasValidAddress">
        <p data-bind="text: address.street().toUpperCase()"></p>

Set Address followed by Clear Address => error (!). This was quite surprising - since the only change was removing stuff from the text binding and that broke things!

Interestingly, in sample 1 there was no need to have the hasValidAddress condition to avoid the error. What apparently makes sample 1 not error is the access of another observable (an observable that is not even involved in the error). You can see that if you change sample 2 to text: (address.streetNumber(), address.street().toUpperCase()) - then sample 2 no longer errors.

I think it is quite weird and confusing, that even with this pull request applied:
<p data-bind="text: address.street().toUpperCase() + ' ' + address.streetNumber()"></p> => ok
<p data-bind="text: address.street().toUpperCase()"></p> => error

@mbest
Copy link
Copy Markdown
Member Author

mbest commented Apr 24, 2017

@fastfasterfastest Thanks for the additional information. I'll take a look and see what's going on.

@fastfasterfastest
Copy link
Copy Markdown

Thanks. I also noticed that sample 2 does not error if address.streetNumber is not updated ("not updated" meaning set to same value as it currently has - equalityComparer takes care of that) in the Clear Address callback... Remember, in sample 2 address.streetNumber is not even rendered by the text binding so I think it is quite confusing that whether address.streetNumber is updated or not has an effect on the outcome.

  1. Sample 3: http://jsfiddle.net/fastfasterfastest/0u1qx24p/2/
    The only difference between sample 2 and sample 3 is an additional Clear Address button. The added button clears address.street only, does not touch address.streetNumber.
    Set Address followed by Clear Address (street only) => no error
<button data-bind="click: function() { address({street: null, streetNumber: 123 }); }">Clear Address (street only)</button>

@mbest
Copy link
Copy Markdown
Member Author

mbest commented Apr 25, 2017

It's not necessary to actually do anything with the descendantBindingDisposalIsImminent (or whatever we call it) in the descendant bindings. It's only necessary that the descendant bindings have a dependency on the if binding so they are scheduled after it. Here's a quick hack that makes the if binding do just that:

http://jsfiddle.net/mbest/0u1qx24p/3/

The important change to the if binding is below. I won't be able to use this specific change in Knockout, but it's progress, at least.

makeWithIfBinding('if', false /* isWith */, false /* isNot */,
    function(bindingContext, dataValue) {
        return bindingContext.createChildContext(function() {
        	return dataValue() && bindingContext.$data;
        });
    }
);

@fastfasterfastest
Copy link
Copy Markdown

Thanks! A couple of comments and questions...

  1. In http://jsfiddle.net/mbest/0u1qx24p/3/, changing if: hasValidAddress to if: hasValidAddress() generates an error when clicking Clear Address

  2. return dataValue() && bindingContext.$data;

Should that instead be return ko.utils.unwrapObservable(dataValue) && bindingContext.$data; to handle non-observable values (e.g. <!-- ko if: true --> currently generates an error)

  1. I assume the ifnot binding needs to be similarly created. Is return !ko.utils.unwrapObservable(dataValue) && bindingContext.$data; the correct way to create ifnot's child context?

  2. I assume the with binding does not need to be similarly created since it already creates a child context for its descendant bindings and thus they presumably already have a dependency on it, correct?

  3. With these changes a child context is now created for the if binding, while the original if binding doesn't do that - shouldn't we actually use the same context? If so, what is the proper way to do that? Is it using new ko.bindingContext as follows or should bindingContext.extend be used?

makeWithIfBinding('if', false /* isWith */, false /* isNot */,
    function (bindingContext, dataValue) {
        return new ko.bindingContext(function () {
            return ko.utils.unwrapObservable(dataValue) && bindingContext.$data;
        }, bindingContext);
    }
);
makeWithIfBinding('ifnot', false /* isWith */, true /* isNot */,
    function (bindingContext, dataValue) {
        return new ko.bindingContext(function () {
            return !ko.utils.unwrapObservable(dataValue) && bindingContext.$data;
        }, bindingContext);
    }
);
  1. I also tried a couple of other variations, e.g. ifnot: !hasValidAddress(), but ran into errors, probably due to 1 above, and/or 5.

  2. I won't be able to use this specific change in Knockout

Do you mean you will not be able to make the modifications we are discussing here to the next or a future release of knockout? If so, why not?

@mbest
Copy link
Copy Markdown
Member Author

mbest commented Apr 25, 2017

Do you mean you will not be able to make the modifications we are discussing here to the next or a future release of knockout? If so, why not?

As you've noted, there are a number of compatibility problem with the simplified approach I took. The important thing to note is that if all descendant bindings have a dependency on the if condition, it will solve the problem. Now the question is how to do this a backwards-compatible way.

@fastfasterfastest
Copy link
Copy Markdown

OK, good.

I did some further modifications to your makeWithIfBinding, see http://jsfiddle.net/fastfasterfastest/0u1qx24p/5/

Instead of passing rawValue to makeContextCallback, it passes valueAccessor so it gets dereferenced a little later. This takes care of the problem I had in item 1 (changing if: hasValidAddress to if: hasValidAddress() generates an error ) and item 6. It also has the changes I mentioned in item 2-5. However, whether any of these changes are "correct" or not, I don't know :) But at least I don't see any errors yet...

@fastfasterfastest
Copy link
Copy Markdown

fastfasterfastest commented Apr 25, 2017

I quickly ran through the fiddles we have referred to above, and none of them generate any errors using the changes to makeWithIfBinding in http://jsfiddle.net/fastfasterfastest/0u1qx24p/5/
Well, at least none of the samples that uses ko.options.deferUpdates = true. https://jsfiddle.net/mbest/g5gvfb7x/12/ still errors w/out ko.options.deferUpdates but it did so before as well; switching to ko.options.deferUpdates then that sample is ok too. So, this could be an incentive to switch to ko.options.deferUpdates as opposed to a disincentive...

@mbest
Copy link
Copy Markdown
Member Author

mbest commented Apr 25, 2017

@fastfasterfastest That might be exactly what we need. I'll do some more testing and let you know.

@fastfasterfastest
Copy link
Copy Markdown

A quick, initial test of my app also has no errors anymore, I need to do some more testing though.

I don't know if you can simplify makeWithIfBinding and the calls of it. Before only one call passed a makeContextCallback, now all three calls do. Also, with the above changes, a new binding context is always created for if and ifnot - intuitively it feels like one should be able to re-use the "current" one. I think (but maybe I am wrong) a new binding context is created only as a "hook" to create the dependency on the binding's value. Maybe there is another trickery that can be used to create that dependency without creating a new/"duplicate" binding context. But maybe the cycles of creating a binding context is not that great in the grand scheme of things.

@fastfasterfastest
Copy link
Copy Markdown

By the way, if I understand things correctly, there is no need to actually use the value of the if and ifnot binding when creating the binding context for the descendants as was done above - I think it is sufficient to establish the dependency on the value. It is probably more proper (and clear) to always set the data of the cloned context to bindingContext.$data. The makeContextCallback for both the if and ifnot binding will be the same. Updated fiddle http://jsfiddle.net/fastfasterfastest/0u1qx24p/6/:

makeWithIfBinding('if', false /* isWith */, false /* isNot */,
    function (bindingContext, valueAccessor) {
        return new ko.bindingContext(function () {
            ko.utils.unwrapObservable(valueAccessor());
            return bindingContext.$data;
        }, bindingContext);
    }
);
makeWithIfBinding('ifnot', false /* isWith */, true /* isNot */,
    function (bindingContext, valueAccessor) {
        return new ko.bindingContext(function () {
            ko.utils.unwrapObservable(valueAccessor());
            return bindingContext.$data;
        }, bindingContext);
    }
);

@mbest
Copy link
Copy Markdown
Member Author

mbest commented Apr 26, 2017

@fastfasterfastest Good points.

@fastfasterfastest
Copy link
Copy Markdown

By the way, the same problem applies to the with binding, see http://jsfiddle.net/fastfasterfastest/0u1qx24p/10/

this.addressIfValidAddress = ko.pureComputed(function(){
    return this.hasValidAddress() ? this.address() : null;
}, this);
...
<div data-bind="with: addressIfValidAddress">
  With - You live at:
  <p data-bind="text: $parent.address.street().toUpperCase()"></p>
</div>

Set Address followed by Clear Address => error

Perhaps this is little trickier to fix this since the with binding uses createStaticChildContext to create its descendant context and createStaticChildContext does not cause a dependency on the value to be created (ko.bindingContext does not create a computed when exportDependencies is specified which createStaticChildContext does.)

One way that appears to work is to create an intermediary context after the with binding context proper. The intermediary context would handle the dependency, see http://jsfiddle.net/fastfasterfastest/0u1qx24p/11/

makeWithIfBinding('with', true /* isWith */, false /* isNot */,
    function (bindingContext, valueAccessor) {
        var childContext = bindingContext.createStaticChildContext(valueAccessor());

        return new ko.bindingContext(function () {
            ko.utils.unwrapObservable(valueAccessor());
            return childContext.$data;
        }, childContext);
    }
);

Another way that appears to work, and avoid an intermediary context, is for the with binding context to not use exportDependencies. If it uses createChildContext instead of createStaticChildContext to create its bindingContext then there is no error in my sample. However, I don't know the implications of the with binding not using exportDependencies - perhaps it should/must?
Anyway, here is a sample with that approach, http://jsfiddle.net/fastfasterfastest/0u1qx24p/12/

makeWithIfBinding('with', true /* isWith */, false /* isNot */,
    function (bindingContext, valueAccessor) {
        var dataValue = valueAccessor();

	return bindingContext.createChildContext(dataValue);
    }
);

@mbest
Copy link
Copy Markdown
Member Author

mbest commented Apr 27, 2017

My main concern with the above changes is that it will add a dependency to every single descendant binding within an if, etc. binding, probably adding a performance penalty to applications. Although I agree that making this change is more correct, I wonder what the performance cost will be.

@fastfasterfastest
Copy link
Copy Markdown

  1. Current state of affairs (3.4.2. plus the above pull request) with ko.deferUpdates shows, if not incorrect behavior, at the very least "weird" and surprising behavior - a good example is that <p data-bind="text: address.street().toUpperCase() + ' ' + address.streetNumber()"></p> does not generate an error but <p data-bind="text: address.street().toUpperCase()"></p> does. I think, perhaps, that makes using ko.deferUpdates so unpredictable that it is not useable. I think it needs to be fixed.

  2. What is the performance penalty? Do you mean in terms of time or in terms of memory? In terms of memory, yes the internal bookkeeping of dependencies. In terms of time, I am not sure - although the descendant bindings may have a dependency on their ancestor bindings, the above changes mostly/only(?) cause the ancestor binding to be scheduled to be evaluated before the descendant bindings, right? And when they (the ancestor bindings) do change, they will actually delete the descendants - so there may even be an improvement from a time performance point of view, since now the change would be propagated top-down and all the descendants would be vaporized before being notified.
    I assume with the above changes the descendant bindings only "get notified" if the ancestor binding's value actually change, e.g. when the if binding value changes from true to false, and not when some other aspects of the ancestor binding changes. I used "get notified", with quotation marks, because even though the descendant bindings have a dependency and would get notified, the fact that the ancestor binding gets evaluated first, and while doings so it deletes the descendants, the descendant bindings would actually not get notified.
    You are of course the expert here and I don't know enough of the intricacies, and therefore may not fully understand the implications...

Regardless, I think 1 trumps over 2.

@fastfasterfastest
Copy link
Copy Markdown

I assume with the above changes the descendant bindings only "get notified" if the ancestor binding's value actually change, e.g. when the if binding value changes from true to false, and not when some other aspects of the ancestor binding changes.

I think this assumption is not correct.
The approach I initialized outlined (using bindingContext.ancestorBindingCondition) would only create a dependency on the condition itself, I think.

@fastfasterfastest
Copy link
Copy Markdown

@mbest - here is another attempt. This approach uses the notion that the binding value, not the binding context, can have an "additional" dependency specified ("additional" in addition to whatever dependencies the binding "itself" creates). The additional dependency is stored in the binding context as $dataDependency - the value is a function that when called establishes the dependencies.

The additional dependencies a binding value may have is automatically established by the internals by calling bindingContext.$dataDependency when handling out the valueAccessor.

The if, ifnot and with bindings sets the $dataDependency for their descendant bindings. The dependency they specify is their own value - i.e., an if binding specifies to its descendant bindings that their values are dependent on the if binding's value.

The result is/should be that the descendant bindings only get re-evaluated when the actual value of the ancestor if binding is modified. And/But - the if binding gets re-evaluated first and in the process deletes its descendant bindings, thus the descendants will actually not be re-evaluated.

Here is a sample http://jsfiddle.net/fastfasterfastest/0u1qx24p/13/ I added display of timestamps and
a 2nd Set Address button that sets a different valid address to show that the address can be updated without the timestamps being re-evaluated.

Note, this sample uses a modified version of knockout 3.4.2 (3.4.2 + 2226 pull request + my modifications), not your 3.5.0-pre-20170420/knockout-latest.debug.js - I had to create a "custom knockout build" since modifications were needed to the internals; if you do a diff against 3.4.2 you'll see that the modifications are rather minor:

ko.bindingContext
When creating a binding context, $dataDependency is either set to the parent context's $dataDependency, or an empty function if context has no parent context.

getValueAccessor
The function returned first calls bindingContext['$dataDependency'] to establish the dependencies before returning the valueAccessor.

if, ifnot and with bindings
The makeWithIfBinding calls to create the if, ifnot and with bindings creates the descendant binding contexts with a $dataDependency that dereferences their respective values.

@mbest
Copy link
Copy Markdown
Member Author

mbest commented May 1, 2017

I've been working on a fix. I'll post it in the next few days.

@mbest
Copy link
Copy Markdown
Member Author

mbest commented May 2, 2017

See #2234

Sign up for free to join this conversation on GitHub. Already have an account? Sign in to comment

Projects

None yet

Development

Successfully merging this pull request may close these issues.

3 participants