Search code examples
jqueryasynchronousjquery-deferred

JQuery Deferred: wait for result synchronously


I have a function that is called when the user navigates away from a page. This function ensures that pending changes on the page are always saved. If saving fails, the navigation can be cancelled by returning false from this method. This is existing code in the application and cannot be changed to work with callbacks (legacy).

This method is used an OnClick-handler for navigation elements (I know, legacy):

<a href="link" onclick="return saveOnNavigation()">link</a>

Now I have this new function that uses jQuery.Deferred to save changes on a page. I want to call this functionality from the method described above, but due to the synchronous nature of that method, I cannot use a callback.

How can I 'wait' for the result of a jQuery.Deferred in this context?

Here some pseudocode (and as a fiddle):

function saveAll() {
    var d = $.Deferred();

    //Do work asynchronously and cann d.resolve(true);
    setTimeout(function(){
        console.log("everything is saved.")
        d.resolve(true);
    }, 1000);

  return d;
}

function saveOnNavigation() {
    if(saveAll()){
        console.log("saved and navigate to next page.");
        return true;
    }
    console.log("failed to save. Staying on page");
    return false;
}

var x = saveOnNavigation();
if(x === true) {
    console.log("navigating to next page");
}

The output is now:

saved and navigate to next page.
navigating to next page
everything is saved.

While it should be:

everything is saved.
saved and navigate to next page.
navigating to next page

Solution

  • You haven't shown how you're calling those functions, but if it boils down to an onbeforeunload or onunload handler, I'm afraid you can't do what you've asked. Neither of those can be made to wait for an asynchronous process to complete, and neither can be cancelled (except by the user). See below, you may be able to do something with those links.

    Separately: There's no way in recent jQuery to make $.Deferred's callback to your then (done, etc.) handler synchronous (and even in old jQuery, there was no way if the Deferred hadn't already been settled before you called then etc.). jQuery's Deferred has been updated to be in line with the Promises/A+ specification, which requires that calls to the then and similar callbacks must be asynchronous. (Before it was updated to be compliant with that spec, Deferred was chaotic: If it had already been settled, it called the callback synchronously, but if it hadn't been then of course it was asynchronous. Promises/A+ doesn't allow that chaotic behavior.)


    You've now said that you're calling those functions like this:

    <a href="link" onclick="saveOnNavigation">link</a>
    

    I assume it must really be as follows, with ()

    <a href="link" onclick="saveOnNavigation()">link</a>
    

    If you can change that to:

    <a href="link" onclick="return saveOnNavigation(this, event)">link</a>
    

    Then you can go ahead and so something asynchronous in that handler, by cancelling the default navigation, and then navigating when done:

    function saveOnNavigation(link, event) {
        // Start the async operation
        startAsynchronousOperation().then(function(saved) {
            // Now it's finished (later)
            if (saved){
                // All good, do the navigation
                console.log("saved and navigate to next page.");
                location = link.href;
            } else {
                // Not good, don't navigate
                console.log("failed to save. Staying on page");
            }
        });
    
        // Cancel the click (belt-and-braces)
        if (event.preventDefault) {
            event.preventDefault();
        }
        return false;
    }
    

    Live exmaple:

    var clicks = 0;
    
    function startAsynchronousOperation() {
      var d = $.Deferred();
      setTimeout(function() { // Emulate an async op
        if (++clicks == 1) {
          d.resolve(false);   // disallow
        } else {
          d.resolve(true);    // allow
        }
      }, 500);
      return d.promise();
    }
    function saveOnNavigation(link, event) {
      // Start the async operation
      startAsynchronousOperation().then(function(saved) {
        // Now it's finished (later)
        if (saved) {
          // All good, do the navigation
          console.log("saved and navigate to next page.");
          location = link.href;
        } else {
          // Not good, don't navigate
          console.log("failed to save. Staying on page");
        }
      });
    
      // Cancel the click (belt-and-braces)
      if (event.preventDefault) {
        event.preventDefault();
      }
      return false;
    }
    <a href="http://example.com" onclick="return saveOnNavigation(this, event)">The first click on this link will be stopped; the second one will be allowed.</a>
    <script src="https://ajax.googleapis.com/ajax/libs/jquery/2.1.1/jquery.min.js"></script>

    Note: If the user right-clicks the link and chooses one of the options from the menu (such as "open in new tab"), your onclick handler won't be called and none of the code above will work. But I'm guessing that since that doesn't take the current window away from the page, that's okay.

    If you can't change the markup of the page, we can still do it. :-) We just need to run some code once the page is loaded, to convert those old-fashioned attribute-style event handlers into modern event handlers:

    $('a[onclick*="saveOnNavigation"]')
        .attr("onclick", "")
        .on("click", saveOnNavigation);
    

    ...where we change saveOnNavigation to:

    function saveOnNavigation(event) {
      var link = this;
    
      // Cancel the click
      event.preventDefault();
    
      // Start the async operation
      startAsynchronousOperation().then(function(saved) {
        // Now it's finished (later)
        if (saved) {
          // All good, do the navigation
          console.log("saved and navigate to next page.");
          location = link.href;
        } else {
          // Not good, don't navigate
          console.log("failed to save. Staying on page");
        }
      });
    }
    

    Example:

    var clicks = 0;
    
    function startAsynchronousOperation() {
      var d = $.Deferred();
      setTimeout(function() { // Emulate an async op
        if (++clicks == 1) {
          d.resolve(false);   // disallow
        } else {
          d.resolve(true);    // allow
        }
      }, 500);
      return d.promise();
    }
    function saveOnNavigation(event) {
      var link = this;
    
      // Cancel the click
      event.preventDefault();
    
      // Start the async operation
      startAsynchronousOperation().then(function(saved) {
        // Now it's finished (later)
        if (saved) {
          // All good, do the navigation
          console.log("saved and navigate to next page.");
          location = link.href;
        } else {
          // Not good, don't navigate
          console.log("failed to save. Staying on page");
        }
      });
    }
    $(document).ready(function() { // Don't need this if the script tag is at the end, just before </body>
        $('a[onclick*="saveOnNavigation"]')
            .attr("onclick", "")
            .on("click", saveOnNavigation);
    });
    <a href="http://example.com" onclick="saveOnNavigation()">The first click on this link will be stopped; the second one will be allowed.</a>
    <script src="https://ajax.googleapis.com/ajax/libs/jquery/2.1.1/jquery.min.js"></script>