4

I have deferred updates enabled.

I have two components.

The first is a list, which is simply implemented as a div with a foreach data binding:

<div class="list-people" data-bind="foreach: { data: people, afterRender: afterRenderPeople }">
    <!-- ko component: { name: "listitem-person", params: { person: $data } } --><!-- /ko -->
</div>

The second is the list item:

<div class="listitem-person">
    <span data-bind="text: Name"></span>
</div>

afterRender is called for each item in the foreach.

My afterRenderPerson function is simple enough:

public afterRenderPerson = (elements: any[], data: Person) => {
    let top = $(element[0]).offset().top;

    scrollTo(top);
};

The problem is that when afterRenderPerson is called the sub-component listitem-person hasn't yet been rendered.

Which means the element array passed to afterRenderPerson has 4 nodes:

  1. A text node containing \n i.e. a new line.
  2. A comment node containing <!-- ko component: { name: "listitem-person", params: { person: $data } } -->.
  3. A comment node containing <!-- /ko -->.
  4. A text node containing \n i.e. a new line.

None of these are suitable for getting the top pixel, and even if they were, the sub-component being rendered could affect the layout at that location changing the value of the pixel I'm trying to get.

  • 1
    Only workaround I can think of is to define a custom `scrollTo` binding and include it in the component template... Quite easy to implement, but still feels hacky and makes your inner component harder to reuse. You might also want to track this [feature request](https://github.com/knockout/knockout/issues/1533) – user3297291 Aug 10 '17 at 11:41
  • The workaround I've been looking at is using a setInterval to detect when the element is available by ID. But having to resort to that makes me feel very dirty. –  Aug 10 '17 at 12:48

2 Answers2

3

Unfortunately it seems that the documentation for foreach doesn't take in to account the delayed nature of components.

If you need to run some further custom logic on the generated DOM elements, you can use any of the afterRender/afterAdd/beforeRemove/beforeMove/afterMove callbacks described below.

Note: These callbacks are only intended for triggering animations related to changes in a list.

There are two workarounds I've come across, neither of which are great, but that's why they're workarounds and not solutions!

user3297291 gave the suggestion in a comment of making a scrollTo binding that's placed on the child components.

Only workaround I can think of is to define a custom scrollTo binding and include it in the component template... Quite easy to implement, but still feels hacky and makes your inner component harder to reuse. You might also want to track this feature request – user3297291

This would simply be a custom binding that conditionally executes some code based on a value provided to it.

The bindings aren't called until the HTML has been inserted in to the DOM. That's not perfect, as later changes to the DOM could affect the position of the inserted HTML elements, but it should work for many situations.

I wasn't very keen on having to modify the child components though, I preferred a solution when remained encapsulated in the parent component.

The second workaround is to check to see if the child component HTML element exists in the DOM by it's ID. Since I don't know when they will come in to existence this has to be done in some sort of loop.

A while loop isn't suitable as it'll run the check far too often, in a "tight" loop, so instead setTimeout is used.

setTimeout is a horrid hack, and it makes me feel dirty to use it, but it does work for this situation.

private _scrollToOffset = -100;
private _detectScrollToDelayInMS = 200;
private _detectScrollToCountMax = 40;
private _detectScrollToCount = 0;

private _detectScrollTo = (scrollToContainerSelector: string, scrollToChildSelector: string) => {
    //AJ: If we've tried too many times then give up.
    if (this._detectScrollToCount >= this._detectScrollToCountMax)
        return;

    setTimeout(() => {
        let foundElements = $(scrollToChildSelector);

        if (foundElements.length > 0) {
            //AJ: Scroll to it
            $(scrollToContainerSelector).animate({ scrollTop: foundElements.offset().top + this._scrollToOffset });

            //AJ: Give it a highlight
            foundElements.addClass("highlight");
        } else {
            //AJ: Try again
            this._detectScrollTo(scrollToContainerSelector, scrollToChildSelector);
        }
    }, this._detectScrollToDelayInMS);

    this._detectScrollToCount++;
};

I made sure to put a limit on how long it can run for, so if something goes wrong it won't loop forever.

It should probably be noted that there is an "Ultimate" solution to this problem, and that's TKO, AKA Knockout 4.

But that's not "production ready" yet.

How to know when a component has finished updating DOM?

brianmhunt commented on Jun 20

knockout/tko (ko 4 candidate) latest master branch has this.

More specifically, the applyBindings family of functions now return a Promise that resolves when sub-children (including asynchronous ones) are bound.

The API isn't set or documented yet, but the bones have been set up.

Community
  • 1
  • 1
  • 1
    Good research, @AndyJ, thanks for digging deep and posting this. Just FYI, even though it's in alpha there are a few sites using TKO/KO4 in production now for extremely complex apps. The big caveat is that these sites are limited to modern browsers. TKO is not heartily tested with IE/Edge/Safari/IOS/Opera yet. – Brian M. Hunt Aug 11 '17 at 12:21
  • 1
    @BrianM.Hunt That's good to know thank you. We're still clinging on to IE11 while it's still officially supported, but we'll celibate the day it dies! I would argue that Edge is a modern browser, though it's certainly not very widely used. When we move on to our next project I'll have to evaluate TKO and see if it's suitable. –  Aug 11 '17 at 12:30
  • Does it ever take more than one iteration of the setTimeout? – Roy J Aug 11 '17 at 14:21
  • @RoyJ Good question, I haven't got any logging in there at the moment, so there's no data showing one way or the other for my specific situation. Performing the check multiple times with a limit is pretty brute-force, but it should be good for most situations where there might be differences in browsers, changes to Knockout, or changes to the component. If spent more time on this I'd look at duration of 0 for the first setTimeout, then a higher value on subsequent tries. I'd also investigate http://knockoutjs.com/documentation/microtasks.html which is used for the KO deferred rendering. –  Aug 11 '17 at 14:37
2

This appears to work. I made a binding handler that runs a callback in its init (it uses tasks.schedule to allow a rendering cycle). Attaching it at the parent level does not get the children rendered in time, but attaching it to the virtual element does.

I designed it to work with a function whose signature is like afterRender. Because it runs for each of the elements, the callback function has to test that the data is for the first one of them.

ko.options.deferUpdates = true;

ko.bindingHandlers.notify = {
  init: function (element, valueAccessor, allBindings, viewModel, bindingContext) {
    // Make it asynchronous, to allow Knockout to render the child component
    ko.tasks.schedule(() => {
      const onMounted = valueAccessor().onMounted;
      const data = valueAccessor().data;
      const elements = [];

      // Collect the real DOM nodes (ones with a tagName)
      for(let child=ko.virtualElements.firstChild(element);
          child;
          child=ko.virtualElements.nextSibling(child)) {
        if (child.tagName) { elements.push(child); }
      }
      onMounted(elements, data);
    });
  }
};

ko.virtualElements.allowedBindings.notify = true;

function ParentVM(params) {
  this.people = params.people;
  this.afterRenderPeople = (elements, data) => {
    console.log("Elements:", elements.map(e => e.tagName));
    if (data === this.people[0]) {
      console.log("Scroll to", elements[0].outerHTML);
      //let top = $(element[0]).offset().top;

      //scrollTo(top);
    }
  };
}

ko.components.register('parent-component', {
  viewModel: ParentVM,
  template: {
    element: 'parent-template'
  }
});

function ChildVM(params) {
  this.Name = params.person;
}

ko.components.register('listitem-person', {
  viewModel: ChildVM,
  template: {
    element: 'child-template'
  }
});

vm = {
  names: ['One', 'Two', 'Three']
};

ko.applyBindings(vm);
<script src="https://cdnjs.cloudflare.com/ajax/libs/knockout/3.4.2/knockout-min.js"></script>

<template id="parent-template">
  <div class="list-people" data-bind="foreach: people">
    <!-- ko component: { name: "listitem-person", params: { person: $data } }, notify: {onMounted: $parent.afterRenderPeople, data: $data} -->
    <!-- /ko -->
  </div>
</template>

<template id="child-template">
  <div class="listitem-person">
    <span data-bind="text: Name"></span>
  </div>
</template>

<parent-component params="{ people: names }">
</parent-component>
Roy J
  • 42,522
  • 10
  • 78
  • 102
  • Thank you for the suggestion, that would be a good option if afterRenderPerson was indeed called *after* the child components were rendered. The problem is that afterRenderPerson is called *before* the child component is finished rendering. The "afterRender" binding should probably be called "afterHtmlInserted". –  Aug 10 '17 at 15:35
  • Components don't have an afterRender binding: https://github.com/knockout/knockout/issues/1533 and the documentation for foreach explicitly says that the foreach afterRender should be the place to do animations http://knockoutjs.com/documentation/foreach-binding.html Unfortunately it seems that the foreach documentation didn't take components in to account with their delayed nature. You can make a kind of "fake" afterRender binding, which is what user3297291 suggested with his comment. –  Aug 11 '17 at 08:03
  • @AndyJ I've taken another shot at this, using some of the ideas you mentioned. – Roy J Aug 11 '17 at 14:50
  • Thanks! I'll check this out now, but it already looks very promising. –  Aug 11 '17 at 15:00
  • 1
    I've added in the binding handler and at the moment `init` isn't being called. I'm pretty sure this is a problem my end so I'll investigate and try to debug what's going on. It's now getting to the end of the Friday working day here so I'll be picking this back up Monday morning. But I wanted to express my appreciation for the effort you've put in on this question. Your persistence is admirable and I hope that I can get this code working so I can accept your answer. BTW, I love your profile pic! Was it inspired by Bugs? https://images-na.ssl-images-amazon.com/images/I/91ZmpGREGpL._SX385_.jpg –  Aug 11 '17 at 16:09
  • 1
    @AndyJ Yes, my profile picture was inspired by Bugs. A friend and I did an "air band" rendition of "What's Opera Doc" many years ago in college. That is the hat I wore. – Roy J Aug 14 '17 at 12:45