#229 Polling for Changes (revised)
- Download:
- source codeProject Files in Zip (67.6 KB)
- mp4Full Size H.264 Video (20.2 MB)
- m4vSmaller H.264 Video (11.4 MB)
- webmFull Size VP8 Video (14.2 MB)
- ogvFull Size Theora Video (25.4 MB)
Below is a screenshot from a blogging application showing an article that has many comments. While the article is short, it might take a user several minutes to read a longer one and its comments before deciding to add their own. Other users may have added their own comments in the meantime that the user may want to respond to, or which duplicate what they were about to say so it would be good if we could show these in some way without requiring them to reload the page.
One way to do this is to use polling and that’s how we’ll do it in this episode. Some consider this a dated technique and would use something like WebSockets instead. That way we could keep a socket connection open so that we can push the changes to the client instead of having to poll for them. One way to do this is to use Faye which was covered in episode 260. Polling still has it’s uses, though, especially if we don’t need instant feedback from the server. A 30-second poll delay won’t take too much away from this feature, even though it means that the user won’t see new comments displayed immediately. Using polling also means that we don’t need to worry about keeping a socket open all the time.
Getting Started With Polling
Polling is frequently done with the JavaScript setTimeout
function. This accepts two arguments: the function that we want to trigger and the delay before the function should be triggered in milliseconds. If we wanted to show an alert after, say, three seconds we’d write this:
setTimeout(alert, 3000)
If we reload the page now we’ll see an alert around three seconds after the page loads. This will only happen once though as setTimeout
doesn’t fire recurrently, unlike the setInterval
function which does. We’ll stick with setTimeout
so that if our application takes a while to respond we won’t be continuously hammering the server. Using setTimeout
also gives us more control over the timeout interval, too. We’ll use it now to poll for new comments.
CommentPoller = poll: -> setTimeout @request, 5000 request: -> $.get($('#comments').data('url')) jQuery -> if $('#comments').length > 0 CommentPoller.poll()
This code runs when the page’s DOM has loaded an checks so see if there’s an element with an id
of comments on the page. Note that this code isn’t compatible with Turbolinks so if you’re using that you’ll need to check for the page:change
event as well. We keep all of the polling behaviour inside an object namespace called CommentPoller
. This has a poll
function which is called if the comments div
is found. This function calls setTimeout
and is passed a request
function which we trigger this every five seconds for now while we’re testing. The request
function triggers an AJAX request to get the new comments and so it needs to know the URL to make the request to. It’s generally best to avoid hard-coding URLs in JavaScript so we fetch it from a data
attribute on the comments div
instead.
Now we just have to modify the comments div
and give it a data-url
attribute. To avoid putting erb into the attribute we’ll use content_tag
. The data-url
attribute needs to hold the path to the comments for the current article so we use article_comments_url
and pass in the current article.
<%= content_tag :div, id: "comments", data: {url: article_comments_url(@article) } do %> <%= render @article.comments %> <% end %>
Our JavaScript timeout will now make a GET request to the index
action in the CommentsController
’s so we’ll need to define this.
def index @comments = @article.comments end
We have a before filter in this controller that fetches the current article and assigns it to an instance variable so we can use this to fetch the comments. We’ll fetch all the comments for now. We’ll need a template to go along with this to respond to JavaScript requests. Any JavaScript we put in this template will be executed when the timeout expires. We could instead use JSON but this would mean that we’d have to render out the comments on the client. Whatever we do here we’ll need to call CommentPoller.poll()
again so that the polling timeout is triggered again.
alert("it works!"); CommentPoller.poll();
We just have an alert
in the template for now and when we reload the page we’ll see it pop up after five seconds. There’s a problem, however. If we dismiss the alert then wait another one won’t show after another five seconds. This is a common problem and it can be tricky to track down. The issue is that the code in the template doesn’t have access to our CommentPoller
object and this is because the CoffeeScript scopes each of the files separately. We need to make this object and we do that by using an @
where we define it.
@CommentPoller = poll: -> setTimeout @request, 5000 request: -> $.get($('#comments').data('url')) jQuery -> if $('#comments').length > 0 CommentPoller.poll()
Now the CommentPoller
will be accessible wherever we call it from.
Rendering New Comments
Now that we have this working we’ll replace the alert
with code to load in the new comments. To do this we’ll replace the comments div
’s content entirely with the article’s current comments. We already have a partial that renders out a single comment so this will render out each one. Note that use of the j
function to escape the output.
$('#comments').html('<%= j render(@comments) %>');
CommentPoller.poll();
We can try this out now. If we open two browser windows on the same article then add a comment in one window the comment should appear in the other after a few seconds and when we do that it does.
A More Efficient Approach
This works but it isn’t very efficient as all the comments for the article are reloaded every time the timer elapses. Instead of replacing all the comments it would be better if we appended any new comments that have been added since the page was loaded. We’ll need to modify the controller’s index
action so that it only fetches the comments with an id
greater than the biggest id
of the comments that are currently displayed. If we don’t want to rely on sequential id
s we could use a created_at
timestamp field but this can get messy when we try to shuffle this value to the client and back.
def index @comments = @article.comments.where('id > ?', params[:after].to_i) end
Now we have to pass in an after
parameter through our AJAX request so we’ll modify our GET request so that it does. We’ll need to get the id
of the last comment that shows so we’ll modify the comment partial so that it adds a data-
attribute to each comment that holds this.
<%= div_for comment, data: {id: comment.id} do %> <p><strong><%= comment.name %> says</strong></p> <%= simple_format comment.content %> <% end %>
We can now modify our comments CoffeeScript file to read this id
and send it as the after
parameter. Our comments will be displayed in order of their id
so we can simply read the data-id
attribute from the last one.
request: -> $.get($('#comments').data('url'), after:$('.comment').last().data('id'))
Finally we’ll change our comments JavaScript template so that it appends the comments that are returned to the existing list instead of replacing them all.
$('#comments').append('<%= j render(@comments) %>');
CommentPoller.poll();
Now when our application polls for comments it will only append any new comments that have been added which is much more efficient.
Showing a Link For New Comments
Our article page works well now but what if, instead of automatically adding new comments as they appear, we want to show a link that tells the user that new comments have been added so they can click it to view them? This will make it more obvious to them that new comments have been added. We’ll start by adding a link below the existing comments.
<p id="show_comments" style="display:none;"> More comments have recently been added. <a href="#">Show Comments</a> </p>
This is invisible by default but and we’ll show it when new comments are added. We could do this in the index
JavaScript template, but to keep this file small we’ll instead modify this to call a new addComments
function and do all the heavy work in our CommentPoller
object.
CommentPoller.addComments('<%= j render(@comments) %>');
We can now define this function in our CoffeeScript file.
addComments: (comments) -> if comments.length > 0 $('#comments').append($(comments).hide()) $('#show_comments').show() @poll()
Here we check that there are new comments to add and, if so, we add and hide them so that they’re not shown straight away. We then show the link so that the user knows that there are new comments to view. Next we’ll need to add a click
event to the link so that the comments are shown when the link is clicked.
showComments: (e) -> e.preventDefault() $('.comment').show() $('#show_comments').hide() jQuery -> if $('#comments').length > 0 CommentPoller.poll() $('#show_comments a').click CommentPoller.showComments
When the link is clicked now it will trigger a showComments
function. This function first calls preventDefault
to stop the link’s default behaviour from triggering then shows all the comments and hides the link itself.
We can try this out now by opening two browser windows again and entering a comment in one of them. When we do the link appears in the other window a few seconds later.
Clicking this shows the new comments just like we expect so our polling functionality is now complete. This means that we can increase the time interval so that the polling isn’t happening so much. One option would be to make this dynamic depending on the rate that the article is gaining new comments. If we decide to replace the polling with WebSockets at a later date our solution makes this easy to do. We’d just need to trigger the addComments function whenever a comment is added instead of polling at a set time.