javascriptjqueryrace-condition

How can one handle racing between two callbacks on the same event that can be triggered multiple times?


I have two callbacks that need to be put on the same change event on the same item. For reasons that are not worth going into, I need to have these callbacks in separate on, I cannot unify them under a single on call. So I need these on calls to stay separate, for example:

$('body').on('change', '#my-div', function1);
$('body').on('change', '#my-div', function2); 

Now, I have an AJAX call inside function1. I would like for function2 to always execute after the execution of function1 is done, including after an AJAX response has been received inside function1. I asked ChatGPT and it advised me to use $.Deferred():

let function1Deferred = $.Deferred();
function function1() {
    function1Deferred = $.Deferred();
    $.ajax({
        url: 'your-url', // Replace with your URL
        method: 'GET',
        success: function(data) {
            console.log("Function1 AJAX request completed");
            function1Deferred.resolve(); 
        },
        error: function() {
            function1Deferred.reject();
        }
    });
}

function function2() {
    function1Deferred.done(function() {
        console.log("Function2 is now executing after function1 has completed.");
    });
}

To my mind, though, this only solves the problem the first time the change event gets triggered, because there's no guarantee that function1 will run first, before function2 - and therefore there's no guarantee that, on the second and third etc. trigger of the change event, the line function1Deferred = $.Deferred(); inside function1 will run before function2 runs, so function2 might well be the first to run, and run to the end, on the subsequent triggers of the change event.

Am I missing something, is the code actually achieving what I want and I'm just missing something about how Deferred works under the hood? If yes, what am I missing? If not, how can I solve my problem and ensure function2 always runs after function1 on subsequent triggers of the event, not just on the first one?

I just want to emphasize again that I need the calls of the on function to stay separate, I cannot use a single on('change', '#my-div', newFunction) call, this is not a solution for my problem in its larger context.


Solution

  • As presented, the code will not necessarily do what you want. If function2 triggers first:

    Here's a demo which explicitly sets the deferred object to null to make it more obvious. If you click the "run F2+F1" button, you'll get a "function1Deferred is null" error.

    const output = document.getElementById("output");
    
    let function1Deferred = null;
    
    function function1() {
        output.append("Running function 1\n");
        function1Deferred = $.Deferred();
        setTimeout(() => {
            output.append("Function 1 complete\n");
            function1Deferred.resolve();
        }, 1000);
    }
    
    function function2() {
        function1Deferred.done(() => {
            output.append("Running function 2\n");
        });
    }
    
    document.getElementById("run1").addEventListener("click", () => {
        try {
            function1Deferred = null;
            function1();
            function2();
        } catch (e) {
            output.append(`Error: ${e}\n`);
        }
    });
    
    document.getElementById("run2").addEventListener("click", () => {
        try {
            function1Deferred = null;
            function2();
            function1();
        } catch (e) {
            output.append(`Error: ${e}\n`);
        }
    });
    <script src="https://cdnjs.cloudflare.com/ajax/libs/jquery/3.7.1/jquery.min.js"></script>
    <pre id="output"></pre>
    <p>
      <button id="run1" type="button">Run F1+F2</button>
      <button id="run2" type="button">Run F2+F1</button>
    </p>

    Given the constraints, the simplest option is probably to wrap the function2 body in a setTimeout call:

    setTimeout(() => function1Deferred.done(() => {
        output.append("Running function 2\n");
    }), 0);
    

    This will return control to the calling function, allowing the other handlers to execute, before registering the done callback on the deferred object.

    Updated demo

    const output = document.getElementById("output");
    
    let function1Deferred = null;
    
    function function1() {
        output.append("Running function 1\n");
        function1Deferred = $.Deferred();
        setTimeout(() => {
            output.append("Function 1 complete\n");
            function1Deferred.resolve();
        }, 1000);
    }
    
    function function2() {
        setTimeout(() => function1Deferred.done(() => {
            output.append("Running function 2\n");
        }), 0);
    }
    
    document.getElementById("run1").addEventListener("click", () => {
        try {
            function1Deferred = null;
            function1();
            function2();
        } catch (e) {
            output.append(`Error: ${e}\n`);
        }
    });
    
    document.getElementById("run2").addEventListener("click", () => {
        try {
            function1Deferred = null;
            function2();
            function1();
        } catch (e) {
            output.append(`Error: ${e}\n`);
        }
    });
    <script src="https://cdnjs.cloudflare.com/ajax/libs/jquery/3.7.1/jquery.min.js"></script>
    <pre id="output"></pre>
    <p>
      <button id="run1" type="button">Run F1+F2</button>
      <button id="run2" type="button">Run F2+F1</button>
    </p>

    Now, whichever button you click, you will not see the "Running function 2" message until after the "Function 1 complete" message.


    Also, as @jabaa mentioned in the comments, $.Deferred is a precursor to the JavaScript Promise. Combined with async functions, you could simplify this code even more:

    const output = document.getElementById("output");
    
    /* https://stackoverflow.com/a/39538518/124386 */
    const delay = (timeout, result = null) =>
      new Promise((resolve) => setTimeout(resolve, timeout, result));
    
    let function1Deferred = null;
    
    async function function1() {
      output.append("Running function 1\n");
      const { promise, resolve, reject } = Promise.withResolvers();
      function1Deferred = promise;
    
      await delay(1000);
    
      output.append("Function 1 complete\n");
      resolve();
    }
    
    async function function2() {
      await delay(0);
      await function1Deferred;
      output.append("Running function 2\n");
    }
    
    document.getElementById("run1").addEventListener("click", () => {
      try {
        function1Deferred = null;
        function1();
        function2();
      } catch (e) {
        output.append(`Error: ${e}\n`);
      }
    });
    
    document.getElementById("run2").addEventListener("click", () => {
      try {
        function1Deferred = null;
        function2();
        function1();
      } catch (e) {
        output.append(`Error: ${e}\n`);
      }
    });
    <pre id="output"></pre>
    <p>
      <button id="run1" type="button">Run F1+F2</button>
      <button id="run2" type="button">Run F2+F1</button>
    </p>