skip to Main Content

I have an "upload" button whose function uses fetch() to parse a user-selected CSV file and computes for 1000 – 2000 ms to generate a series of d3 charts. My users have reported that bugs occur if they click any other buttons during this time. So I tried disabling buttons using any/all of the normal suggested methods:

$("#my-button").off("click");
$("#my-button").prop("disabled", true);
$("#my-button").css("pointer-events", "none");

All of them work if they’re left as-is. But when I add lines of code to turn the click back on at the end upload function execution, things go wrong.

What I see from debugging is the following chronology of "log messages" and <user actions>:

0000ms: <user clicks upload button>
0001ms: "buttons disabled!"
0500ms: <user clicks any button>
1000ms: "buttons enabled!"
2000ms: "button clicked!"

In other words, the user clicks while the upload function is running and buttons are disabled so no click event is apparently happening from my code. But as soon as my upload function finishes and turns back on the buttons, the user’s previous click event suddenly triggers as if it was waiting in a queue.

The only way I’ve found to handle this situation is to add a $(document).ready(<enable buttons>) call at the end of my upload function. This is OK, but not ideal. I want to make sure that click event isn’t triggered at all during the upload function.

Any ideas on how to accomplish this?

2

Answers


  1. One solution would be to use a boolean flag to keep track of whether or not the upload function is currently running. When the flag is set to true, you can disable all buttons except for the upload button. When the flag is set to false, you can enable all buttons again.

    Here’s an example implementation:

    let uploadRunning = false;
    
    function handleUploadClick() {
      if (uploadRunning) {
        return;
      }
    
      disableButtons();
    
      // Set uploadRunning to true before starting long-running operation
      uploadRunning = true;
    
      // Perform long-running operation
      doSomeWork().then(() => {
        // Set uploadRunning to false when operation completes
        uploadRunning = false;
    
        // Enable buttons when operation completes
        enableButtons();
      });
    }
    
    function disableButtons() {
      $("button").prop("disabled", true);
      $("#upload-button").prop("disabled", false);
    }
    
    function enableButtons() {
      $("button").prop("disabled", false);
    }
    
    function doSomeWork() {
      // Perform long-running operation here
      return new Promise(resolve => setTimeout(resolve, 2000));
    }
    

    In this implementation, the uploadRunning flag is used to prevent any buttons from being clickable while the upload function is running, except for the upload button itself. When uploadRunning is set to false again, all buttons are re-enabled.

    This should prevent the issue of queued up click events triggering after the upload function finishes.

    Login or Signup to reply.
  2. The thing that must be happening here is that your code blocks the event loop during this whole time.
    Browsers do use multiple processes, and while the rendering one will wait for the event-loop to be free, the one responsible for queuing the UI events won’t. So after the event-loop is freed, all the events queued by the UI process will be fired one after the other in a small burst. This can be demonstrated quite easily:

    let i = 0;
    let blocked = false;
    document.querySelector("button").onclick = (evt) => {
      if (blocked) {
        return; // will not work 
      }
      blocked = true; // useless
      i = 0; // reset the counter
      console.log("blocking the event loop for 5s.");
      console.log("click anywhere during this period");
      // let the console message print
      requestAnimationFrame(() => setTimeout(() => {
        // block the event-loop for 5s
        const t1 = performance.now();
        while(performance.now() - t1 < 5000) {}
        console.log("releasing the event loop.");
        blocked = false;
      }));
    };
    onclick = (evt) => {
      if (blocked) {
        return; // will not work 
      }
      console.log("clicked %s times", ++i);
    };
    <button>click here</button>

    As you can see, the synchronous log "releasing the event loop." appears before the next "clicked 1 times". This is because while the UI process kept working and queued the events, the main process, also responsible for the JS execution was blocked and resumed where it was. So all your attempts to prevent the click from happening were moot because the event callbacks would only be executed after you unlocked the button. For these, it’s as if the button was never blocked.


    To avoid that, the best is to not block the event-loop. You don’t really show what you are doing, so it’s hard to tell what you should do exactly, but some common strategies involve:

    • Purely arithmetic computation / data transformation (heavy ones), should be moved to a second thread, using a web worker. This allows to keep your main thread and its rendering process working seamlessly. This way you can await nicely that the asynchronous job is done before unlocking your buttons.

    • If your visualizations are done on a <canvas>and that the drawing is the culprit, try to improve it, and you can consider using an OffscreenCanvas that you’d also transfer to a web worker.

    • If the culprit is due to DOM manipulations, you won’t be able to transfer that to a worker, they don’t have access to the DOM. So instead, make sure that your code doesn’t trigger unnecessary "reflows". This operation forces the CSSOM to recalculate all of the elements’ boxes. Misused it can become a perf’ killer. If you must get computed styles during your DOM manipulations, be sure to batch them all in the same place and to do the changes that would dirty the layout after you’ve had all the getters.

    • If you have several visualizations being rendered at the same time, try to render only the ones that would be visible in the screen first, and then wait that a first batch is done before continuing with the other ones. Space each of these in setTimeout() calls, this will give the browser time to render what it has to render, and to process the UI events. Tasks coming from the UI task source usually have the highest priority in the event-loop.

    • If you’ve got only one visualization that still takes more than a few hundred ms, you can try to split it’s rendering algorithm and space it in setTimeout() calls.


    If none of these solution is applicable to you, the worst solution possible would be to wrap the code that does reenable the button in a setTimeout() callback. To be safe you may pass something like 10ms as the second argument. But really, this is the worst solution.

    let i = 0;
    let blocked = false;
    document.querySelector("button").onclick = (evt) => {
      if (blocked) {
        return; // will now work 
      }
      blocked = true;
      i = 0; // reset the counter
      console.log("blocking the event loop for 5s.");
      console.log("click anywhere during this period");
      // let the console message print
      requestAnimationFrame(() => setTimeout(() => {
        // block the event-loop for 5s
        const t1 = performance.now();
        while(performance.now() - t1 < 5000) {}
        console.log("releasing the event loop.");
        setTimeout(() => blocked = false, 10);
      }));
    };
    onclick = (evt) => {
      if (blocked) {
        return; // will now work 
      }
      console.log("clicked %s times", ++i);
    };
    <button>click here</button>
    Login or Signup to reply.
Please signup or login to give your own answer.
Back To Top
Search