How can I register a callback to be invoked when a template is re-rendered?


#1

I need to update Isotope every time a template instance gets rendered. I’ve used the onRendered callback to achieve this, but it turns out it’ll only run the first time the template instance is rendered, not when it’s re-rendered. How can I ensure my callback gets invoked also when the template instance is re-rendered?


#2

Define “re-rendered.” What would cause a template to be re-rendered?


#3

The answer to these questions always involves using a reactive variable in reactive context. But not sure exactly what you want to do here.


#4

A template instance typically gets re-rendered by Blaze due to one of its dependencies having changed (for example a database collection being inserted into).

The point though, is that you can register an onRendered callback, but it will only get invoked the first time the template instance is rendered. I would like to have a callback that gets invoked also for successive renders (i.e., re-renders) of a template instance.


#5

Like I said initially, I would like a callback that works the same as onRendered except that it also gets invoked for successive renders of a template instance (i.e. re-renders), not just the first time the instance gets rendered.


#6

Well, technically speaking, the template does not get re-rendererd if a reactive helper it contains is changed. Otherwise, indeed, onRendered would be called again.

So what exactly are you trying to achieve? What’s Isotope? I’m willing to bet there’s another approach to what you’re after, but I’d need more background. Maybe some code would be helpful too.


#7

@aknudsen: In Meteor, there is no such thing as template re-rendering. A template is first rendered, then changing sub-parts are updated (or not), then the template is destroyed. What do you want to achieve exactly?


#8

Maybe this is the thing, that the template instance as a whole doesn’t get re-rendered, I don’t know Blaze that well. Thanks.

I’ve changed how I render the Isotope grid though, and let Isotope render its children instead. I think this is the better approach, after looking some more at it. To update the grid, I will try to react to the database collection being updated instead, and add/remove Isotope elements correspondingly.


#9

I got this to work with masonry. Not at my house right now though. I’ll post a sample when i get home.


#10

So, you have two templates: gallery and image:

<template name="gallery">
  <div id="gallery">  
    {{#each this}}
      {{> image}}
    {{/each}}
  </div>
</template>
<template name="image">
  <div class="image">        
        <img src="{{imgUrl}}" />
  </div>
</template>

Basically, a container template for all the pictures, and then a template for each picture. In your JS file, you would have something like:

var $gallery;

var initMasonry = function($imgs) {
  if ($gallery) {
    $gallery.masonry({
      itemSelector : '.image'
    });
  }
};

var debouncedRelayout = _.debounce(function() {
  if ($gallery) {
    $gallery.masonry();  
  }
}, 200);

Template.gallery.onRendered(function() {
  $gallery = $(this.find('#gallery'));
  var $imgs = this.$('.image');

  $gallery.imagesLoaded(function() {
    initMasonry($imgs);
  });
});

Template.image.onRendered(function() {
  var $imgs = this.$('.image');
  if ($gallery && $imgs) {
    $imgs.imagesLoaded(function() {
      $gallery.masonry('appended', $imgs[0]);
      debouncedRelayout();
    });
  }
});

Here you have rendered callback for the gallery that will first initialize masonry. But this will only work for the images that are already there. So what you need to do is have a rendered callback on the image template that adds the image to the masonry object, and then does a re-layout on it. To prevent the re-layout from being called a bunch of times (which will happen when it first gets populated) you debounce the method. This example only uses 200 milliseconds… you could probably increase the time here.

The tricky part is when an image gets removed. Here you have to keep track of each image as it gets added, then when the template gets destroyed, you have to add it back to the DOM, tell masonry to remove it, then do a debounced re-layout again.

I don’t know if the code above works as is (I had to extract it from a project that contains other stuff that wouldn’t make sense to show), but it should at least give you an idea of how to go about doing it.


Amazon S3 photo uploads (cfs:gridfs cfs:s3) in a grid layout solution? Can't get Isotope/Masonry to work
#11

Often it seems that storing which elements are present in the session is the only way to handle isotope well, which for some reason feels wrong to me, but it is effective. At least it was for me. I actually removed isotope as I found that using a responsive bootstrap layout and nested templates (template holds the container that renders a template per item) gave me acceptable ux on insertion and deletion of objects. Adding the nifty velocity.js helper package let me add a little fade-in delay to each object


#12

Thanks for sharing your code @moberegger. I’ve currently gone with the approach to let Isotope render its nodes itself, and only let Meteor render the container element. This entails to insert and remove items into the Isotope grid dynamically as I detect that the database collection mutates. It looks as if it works well enough - do you see any problems with this approach?

I’ve included the parts of my code pertaining to updating the Isotope grid below (you can see the whole source code on GitHub):

Template.explore.onRendered(->
  logger.debug("Template explore has been rendered")

  projects = Projects.find({}, {sort: [["created", "asc"]]})
  @autorun(->
    logger.debug("Installing change observer")
    ignore = true
    projects.observe({
      added: (project) ->
        if ignore
          return
        logger.debug("A project (#{getQualifiedId(project)}) was added, updating Isotope grid")
        $projElem = createProjectElement(project)
        getIsotopeContainer().isotope("insert", $projElem)
      ,
      changed: (project) ->
        qualifiedId = getQualifiedId(project)
        logger.debug("A project (#{qualifiedId}) was changed, updating Isotope grid")
        projectElem = document.querySelector("[data-id=#{qualifiedId}]")
        if !projectElem?
          logger.debug("Couldn't find element corresponding to project")
        else
          logger.debug("Found element corresponding to project, updating it")
      ,
      removed: (project) ->
        qualifiedId = getQualifiedId(project)
        logger.debug("A project (#{qualifiedId}) was removed, updating Isotope grid")
        projectElem = document.querySelector("[data-id='#{qualifiedId}']")
        if !projectElem?
          logger.debug("Couldn't find element corresponding to project")
        else
          logger.debug("Found element corresponding to project, removing it")
          getIsotopeContainer().isotope("remove", projectElem).isotope("layout")
      ,
    })
    ignore = false
    logger.debug("Installed change observer")
  )

  $projectElems = R.map(createProjectElement, projects)
  logger.debug("Configuring Isotope")
  getIsotopeContainer().isotope({
    itemSelector: ".project-item",
    layoutMode: 'fitRows',
  })
    .isotope("insert", $projectElems)
)
Template.explore.onDestroyed(->
  projElems = document.querySelectorAll(".project-item")
  logger.debug(
    "Template explore being destroyed, clearing Isotope container of #{projElems.length} item(s)")
  getIsotopeContainer().isotope("remove", projElems).isotope("destroy")
)

#13

Haha, your javascript is most likely much better than mine, so I wouldn’t put too much stock into what I think! Flattered that you asked, though!

I think your approach is much better, performance-wise. My way has both blaze and the plugin doing a bunch of work that probably doesn’t even need to be done. I might use your way and do some refactoring for my stuff. I have never used observers before, so this is as good a way as any to learn it. Thank you.


#14

Thanks @moberegger :slight_smile: My experience with Isotope has been that it’s best to let it render to the DOM itself, instead of trying to shoehorn it into some templating engine (cough Blaze cough). I’m not a super expert on frontend things though, nor Meteor, so I could be missing something.


#15

hi @aknudsen i like your method and i followed it. but just a thought, can we replace your createProjectElement() function with the more “native” method Blaze.toHTMLWithData(). seems cleaner that way.

btw, i’m wondering if anyone manage to combine meteor-isotope with a pagination/infinite loading? i’ve added a pagination based on this tutorial on Discover Meteor.


#16

@rzky Thanks, I will see if I can indeed use Blaze.toHTMLWithData instead. I haven’t got around to infinite loading yet, although I mean to.

Update: @rzky I don’t see that Blaze.toHTMLWithData will help us much, we will still need to turn the HTML into a DOM element anyway. I can’t see any way to make Blaze render a raw DOM element either (only a Blaze view).


#17

Blaze.toHTMLWithData returns an HTML string. So we simply wrap it into a jQuery object like this:

posts.observe({
    addedAt: function(post) {
        var $item = $(Blaze.toHTMLWithData(Template.PostItem, post));
        $grid.isotope('insert', $item);
    },
    changed: function(post) {
        ...
    },
    removed: function(post) {
        ...
    }
});

It works for me so far. Now the code is cleaner since we don’t need to mix markup with code. But the downside is, the grid items is no longer reactive.


#18

If I still have to use jQuery to render to DOM, I don’t see that calling the Blaze API as well will help much. Rather, I prefer the current solution of a raw string, as it’s less abstract than calling Blaze. It’s not very complex either. Besides, the createProjectElement function is useful as without it, there would be code repetition (for me at least).


#19

true. our code is a bit different. what works for me, may not be suited for you. anyhow, pls do let me know if you’ve figure out the pagination thing :smile:

Update: @aknudsen i think i found an alternate solution. i figure since using raw string or Blaze.toHTMLWithData() removes any reactivity to the grid items (not preferable in my case), i update the code to use Blaze.renderWithData() instead:

Template.MasterLayout.rendered = function() {
    var $grid = $('.post__container');
    var items = Posts.find({}, {sort: {date: -1}});

    $grid.isotope({
        itemSelector: '.post__wrap',
        layoutMode: 'masonry'
    });

    items.observe({
        addedAt: function(items) {
            console.log('Item ' + items._id + ' added');

            // add grid item using jquery (non-reactive)
            // var $item_added = $(Blaze.toHTMLWithData(Template.PostItem, items));
            // $grid.isotope('insert', $item_added);

            // add grid item using blaze (reactive)
            Blaze.renderWithData(Template.PostItem, items, $grid[0]);
            $grid.isotope('appended', $('[data-id=post-' + items._id + ']'));
        },
        changed: function(items) {
            console.log('Item ' + items._id + ' changed');
        },
        removed: function(items) {
            console.log('Item ' + items._id + ' removed');

            // remove grid item using jquery
            // var $item_removed = $('[data-id=post-' + items._id + ']');
            // $grid.isotope('remove', $item_removed).isotope('layout');

            // remove grid item using blaze
            Blaze.remove(Blaze.getView($('[data-id=post-' + items._id + ']')[0]));
            $grid.isotope('layout');
        }
    });
};

The minus side is during onRemove you won’t get Isotope’s item remove effect, since they’re already removed by Blaze. But i think i can live with that :smile:

I left the jquery way commented just in case someone need it.