2

I have a block of code that executes when a button is clicked. The code uses a loop that sometimes takes a while to complete. When the user clicks the button, I want the cursor to change a "wait" cursor before the loop begins. Once the loop is finished, the cursor should return to normal.

What is actually happening (in Chrome for Windows at least) is that the style doesn't get updated until after the loop. It seems to be a quirk of how buttons work. I really don't know. I'm out of guesses!

A sample fiddle: http://jsfiddle.net/ra51npjr/1/ (it just uses console.log to execute "something"... you might need to change how many times the loop runs depending on how zippy or slow your machine is).

Sample HTML:

<div class="fakebody">
    <button id="foo">Foo</button>
</div>

Sample CSS:

.fakeBody {
    height: 1000px;
    width: 100%;
}
.wait {
    cursor: wait !important;
}

Sample JavaScript:

$('#foo').on('click', function (e) {
    $('.fakebody').addClass('wait');
    for (i = 0; i < 10000; i++) {
        console.log(i);
    }
    $('.fakebody').removeClass('wait');
});

--

Here are my ASSUMPTIONS on how the script should work:

  • The click happens, which fires up the code. Indeed, if I log "started!" inside the code block, it will correctly log that it has started
  • The cursor should be a wait cursor so long as it is hovering anywhere over "fakebody".
  • The for loop is just a simple way to kill a few seconds to see the effect. Feel free to substitute any other loop that takes a while to complete
  • At the end of the loop, the cursor is no longer a wait cursor

What is actually happening:

  • The loop executes
  • At the end of the loop, the cursor turns to a "wait" cursor and then instantly back to a regular cursor. The change doesn't happen until the loop is complete

Does anybody know a technique or workaround to get the cursor to change before the loop starts instead of only after it is finished? Is this known behaviour that I need to educate myself about (and if so, do you know where I should start looking?)

Greg Pettit
  • 10,749
  • 5
  • 53
  • 72

3 Answers3

3

This is a common issue in JavaScript. This question may provide some deeper insight, but essentially the point is that synchronous JavaScript execution must finish before the browser can perform other actions (like updating the view).

Because .addClass, the for loop, and .removeClass all occur synchronously, the browser doesn't get a chance to redraw anything. A technique that is often used in these cases is to setTimeout with a timeout of 0, which essentially just "yields" control back to the browser.

$('.fakebody').addClass('wait');

setTimeout(function() {
    for (i = 0; i < 10000; i++) {
        console.log(i);
    }
    $('.fakebody').removeClass('wait');
}, 0);

If this is a common pattern, you could potentially extract it out to a function (which would also help improve readability) that wraps the async setTimeout. Here's a simple example:

/**
 * Wraps a long-running JavaScript process in a setTimeout
 * which yields to allow the browser to process events, e.g. redraw
 */
function yieldLongRunning(preFn, fn, postFn, ctx) {
    if (arguments.length <= 2) {
        ctx = fn; fn = preFn;
        preFn = postFn = function() {};
    }
    preFn.call(ctx);
    setTimeout(function() {
        fn.call(ctx);
        postFn.call(ctx);
    }, 0);
}

And use it like so:

yieldLongRunning(function() {
    $('.fakebody').addClass('wait');
},
function() {
    for (i = 0; i < 10000; i++) {
        console.log(i);
    }
},
function() {
    $('.fakebody').removeClass('wait');
});

As a side point, note that setTimeout(..., 0) simply queues the function in the browser's event loop, alongside other queued JavaScript functions, as well as other types of events (like redraws). Thus, no setTimeout call is guaranteed to run precisely at the given time - the timeout argument is simply a lower-bound (and, in fact, there is a minimum timeout of 4ms specified by HTML5 spec, which browsers use to prevent infinite timeout loops; you can still use 0, though, and the browser will add it to the event queue after the minimum delay).

Community
  • 1
  • 1
voithos
  • 68,482
  • 12
  • 101
  • 116
  • It works, and I 'get' how it yields control. The semantics are 'iffy' and I hope future developers see why it's needed (might throw a comment in there) but there's no denying that it does the job! – Greg Pettit Aug 19 '14 at 19:47
  • @GregPettit: Indeed, some of the asynchronous aspects of JavaScript are not exactly intuitive until you learn a bit about the inner workings. I added a bit more to the answer, discussing how you may wish to make this pattern a bit more maintainable. – voithos Aug 19 '14 at 20:52
  • Great! Thanks for the answer and also the beefed-up pattern. I could expand it further to allow the function parameters to accept either single functions or an array of functions. – Greg Pettit Aug 20 '14 at 14:02
1

Demo - Use queue & dequeue to construct an order of what should happen when in jQuery.

$('#foo').on('click', function (e) {

    $('.fakebody').addClass('wait').queue(function(n) {  

        for (i = 0; i < 10000; i++) { console.log(i); }

    }).removeClass('wait').dequeue();

});    
Brian Dillingham
  • 9,118
  • 3
  • 28
  • 47
  • 1
    Brian, I super-appreciate the work you put into this answer, and ultimately I like the semantics better than the setTimeout method. But my production code is a tiny bit more involved than my sample and I can't actually chain the queue and dequeue events. Since in production I'm using the setTimeout method, I went ahead and accepted that answer. – Greg Pettit Aug 19 '14 at 19:45
  • Readability is a luxury sometimes =) No worries, was happy to offer regardless of usage. Plus I learned @voithos's approach, pretty interesting, which is currently leading me down a rabbit hole of information. Cheers. – Brian Dillingham Aug 19 '14 at 19:50
  • 1
    Good read -> http://geekabyte.blogspot.com/2014/01/javascript-effect-of-setting-settimeout.html – Brian Dillingham Aug 19 '14 at 19:51
1

I think you should try to force a redraw by hiding + showing the parent element. Try this:

document.getElementById('fakebody').style.display = 'none';
document.getElementById('fakebody').style.display = 'block';

Before and after the loop (i.e. when you want the child element "foo" to refresh.

EDIT: Since you're using jquery you could do this:

$('#fakebody').hide().show(0);
Ajk_P
  • 1,874
  • 18
  • 23