skip to Main Content

I’m puzzled by the behavior of the following code snippet. I have a button that becomes disabled while running a long computation after it’s clicked.

        function delay() {
            console.log('start');
            for (let x = 0; x < 4000000000; x++) {
                Math.sqrt(20);
            }
            console.log('done');
        }

        function click() {
            button.disabled = true;
            setTimeout(() => {
                delay();
                button.disabled = false;
            }, 0);
        }
        const button = document.getElementById('button');
        button.addEventListener('click', click);

The button successfully disables and the long computation runs, but something strange happens: if I click the button multiple times while it is in its disabled state, it queues up a single click event and runs the click() function again after it’s done with the current computation.

My understanding was that disabled elements should not trigger or queue any events. Could anyone explain this behavior?

I’ve read up on JavaScript’s microtasks and macrotasks as well as how the UI updates in between them. Based on this, I was expecting the button to be fully unresponsive to clicks while it’s disabled.

However, what I observed is that despite the button being disabled, if I click it multiple times while it’s disabled, a single click event still gets queued up.

2

Answers


  1. it’s related to how browsers handle rapid sequences of events and the disabled state of an element.

    When you click on the button rapidly, even if it’s disabled, the browser might still register the click event due to the way it processes events in rapid succession. This is especially true if the button becomes disabled and then re-enabled in a short time frame, as is the case with your code.

    Here’s a breakdown of what’s happening:

    You click the button.
    The click function is called.
    The button is immediately disabled.
    The setTimeout function schedules the delay function to run after the current call stack is cleared.

    While the button is disabled, you click on it again. The browser might still register this click event due to the rapid sequence of events.
    The delay function runs, and after it’s done, the button is re-enabled.
    The queued click event is processed, even though it was registered while the button was disabled.
    To avoid this behavior, you can use a flag to check if the computation is already running and prevent the function from being called again:

        let isRunning = false;
    
    function delay() {
        console.log('start');
        for (let x = 0; x < 4000000000; x++) {
            Math.sqrt(20);
        }
        console.log('done');
    }
    
    function click() {
        if (isRunning) return;
    
        isRunning = true;
        button.disabled = true;
        setTimeout(() => {
            delay();
            button.disabled = false;
            isRunning = false;
        }, 0);
    }
    
    const button = document.getElementById('button');
    button.addEventListener('click', click);
    

    With this modification, even if the browser registers a click event while the button is disabled, the click function will immediately return without doing anything because the isRunning flag is set to true.

    Login or Signup to reply.
    1. What happens: while you block the thread with the loop the browser still collects the events. After the loop you enable the button immediately and then return control to the browser. The browser now is able to process the collected events and has your button enabled, so it triggers the events thus running the loop again. The solution: enable the button in a separate task too, so the browser would still have the button disabled when prosessing the events.
    2. You could make your code more readable with using async/await.
    3. To make UI updated in sync with your code add requestAnimationFrame to your timeout logic. For example if you try to display the start message with setTimeout(..., 0) the UI won’t be updated. That’s because the next rendering frame is in the future, but you block the rendering with the loop, so the frame waits you to return the control to the browser. Using requestAnimationFrame makes you waiting the frame rendered and then starting the loop.
    const flushUI = () => new Promise((resolve => setTimeout(() => requestAnimationFrame(resolve))));
    
    async function delay() {
        const id = (Math.random()*100|0).toString().padStart(2, '0');
        console.log(id, ': start');
        await flushUI();
        const start = performance.now();
        for (let x = 0; x < 4000000000; x++) {
            Math.sqrt(20);
        }
        console.log(id, ': done in', performance.now() - start + 'ms');
    }
    
    async function click() {
        button.disabled = true;
        await flushUI(); // could be omitted since delay() flushes too but doesn't hurt because delay() could be changed in the future and lose the flushing inside it
        await delay();
        await flushUI();
        button.disabled = false;
    }
    const button = document.getElementById('button');
    button.addEventListener('click', click);
    button{
      padding: 20px 30px;
      border-radius:4px;
      user-select:none;
      cursor:pointer;
    }
    <button id="button">Click</button>
    Login or Signup to reply.
Please signup or login to give your own answer.
Back To Top
Search