2
votes

I have nested custom knockout bindings.

The top level binding extends the binding context and and adds to it.

The child binding needs to be a child binding because it uses the data added to the binding context.

Psuedo-code -

ko.bindingHandlers.map = {
    init: function(element, valueAccessor, allBindings, viewModel, bindingContext) {

     let map = new map({element: element});
     let innerBindingContext = bindingContext.extend({"map": map});
     ko.applyBindingsToDescendants(innerBindingContext, element);

     // tell KO *not* to bind the descendants itself, otherwise they will be bound twice
     return { controlsDescendantBindings: true };
    }
};

ko.bindingHandlers.overlay = {
    init: function(element, valueAccessor, allBindings, viewModel, bindingContext) {

     let map = bindingContext.map;
     let overlay = new overlay({element: element});
     map.addOverlay(overlay);

     // have tried 1. code below, 
     // 2. createChildContext,
     // 3. not returning anything, 
     // 4. ko.cleanNode(element); prior to binding,
     // all with the same error

     let innerBindingContext = bindingContext.extend({});
     ko.applyBindingsToDescendants(innerBindingContext, element);
     return { controlsDescendantBindings: true };
    }
};

var vm = {
  someVariable: ko.observable("test");
}
ko.applyBindings(vm);

HTML -

<div data-bind="map:{}">
  <div data-bind="overlay:{}">
    <span data-bind="text:someVariable"></span>
  </div>
</div>

No matter what I do, I get the error -

"You cannot apply bindings multiple times to the same element."

The bindings are actually making use of a javascript library. This library moves the childbinding dom element elsewhere in the HTML. And I think this is the problem.

Why am I getting this error due to the dom element being moved, and how do I resolve this?

1
Try adding ko.cleanNode(element); to the very top of your map binding handler instead. That might remove the whole block from the binding hierarchy. - Jason Spake
@JasonSpake - I think this fixed it. It just needed to be added prior to the third party object instantiation, which must be when the element is moved in the dom. Thanks! What I don't get, is why if I return {controlsdescendantbindings: true} it tries to get re-bound. If you create an answer I'll mark it as the answer - user210757
I think the descendant bindings command applies to any child nodes, but the top level element itself is already marked as bound by the main ko.applyBindings call. I'll try to add a better answer tomorrow when I'm at a real computer. - Jason Spake

1 Answers

1
votes

Whenever knockout is initialized by calling applyBindings it recursively loops through every node in the target element or document to look for and apply bindings. I think what is probably happening is a timing issue with knockout trying to apply bindings to each unique element while they are being moved around at the same time. This is similar to modifying the length of an array while looping through it.

When a custom binding returns {controlsDescendantBindings: true} it removes any child nodes from the main binding process. Knockout will stop its recursion from going deeper and bindings will only be applied to those elements if done manually. The root element that your custom binding is attached to however, is still part of that process and has already been bound (by definition - triggering the custom binding in the first place). So while that helps it does not fix the main issue which is that the root element is being bound, and then moved somewhere else in the DOM such that knockout "finds" it again and tries to bind it a second time.

Cleaning the first pass bindings with ko.cleanNode before the second pass gets applied is a diagnostic hack. Ideally you should wait to apply bindings at all until the elements have finished being moved by your other plugin(s). The reverse might also work where knockout is allowed to finish binding everything before allowing the plugins to move elements, but that can cause other problems if the elements are truly removed and then re-added instead of just being moved. That depends on how the plugin works.

EDIT: Another possible solution if you can't separate the timing of the plugins from the bindings would be to have your map binding on a wrapper element. That way all of the elements targeted by the plugin(s) can be removed from the binding process via {controlsDescendantBindings: true}. When the plugin moves the target nodes it leaves the wrapper behind, and everyone's happy. (As long as the map binding doesn't need to react to the changes of an observable object)

ko.bindingHandlers.map = {
  init: function(element, valueAccessor, allBindings, viewModel, bindingContext) {
    let target = $(element).children();
    let map = new Map({element: target});
    let innerBindingContext = bindingContext.extend({"map": map });
    ko.applyBindingsToDescendants(innerBindingContext, target); 

    // tell KO *not* to bind the descendants itself, otherwise they will be bound twice
    return { controlsDescendantBindings: true };
  }
};