How to use JavaScript Timers in Drupal 9+

By lostcarpark_admin, 26 March, 2023
Drupal Drop logo and hand holding stopwatch over JS letters

Often we want timed events on our website. As a general rule, we should try to avoid having these take place in the front end. There are many ways this can be achieved, typically involving cron tasks.

However, occasionally we do genuinely need a task to be controlled from the front end, usually when we need to give user feedback.

In my case, I needed bulk emails, which is normally something I would control from the back end. However, I wanted to ensure the emails were sent in a steady stream so the mail server didn’t get hit with too many at once. I also wanted to give the user some visual feedback, so they could see when all the mails had been sent.

So I had a look at some old JavaScript code I had written, that looked like it ought to do the job:

var bulkTimer;

Drupal.behaviors.bulkemail = {
  attach: function (context) {
    jQuery("#edit-do-sending").click(function() {
      delay = jQuery('#edit-options-delay').val();
      // Start a timer running every selected interval.
      bulkTimer = setInterval(sendTick, delay);
    });
  }
};

/*
 * Timer handler.
 */
function sendTick() {
  // Get the list of IDs.
  var ids = jQuery('#edit-sending-ids').val();
  // If no more IDs, stop timer.
  if (ids.length <= 1) {
    clearInterval(bulkTimer);
    return;
  }
  // Get the first ID from the list.
  var index = ids.indexOf("\n");
  var first = ids.substring(0,index);
  var rest = ids.substring(index+1);
  // If no newlines, only one entry left.
  if (index === -1) {
    first = ids;
    rest = "";
  }
  // Upload the next badge.
  sendMessage(first);
  // Update the list of IDs in the textarea.
  jQuery('#edit-sending-ids').val(rest);
}

/*
 * Function to upload badge image to server.
 */
function sendMessage(mid) {
  // Make Ajax call to send email.
  jQuery.get('/admin/members/bulksend/1/'+mid, function(data, status){});
}

On the Drupal side, I load IDs of messages to be sent into a control. In the final site, this will be a hidden element, but for testing I used a textarea.

A button on the site triggers the JavaScript setInterval function to initiate a timer that triggers a function at a specified interval. Each time the interval triggers the function, it gets the next ID from the list, and calls a Drupal endpoint in my controller that does the actual email sending. When there are no more IDs in the list, the clearInterval function is called to stop the timer. Simple, right?

Here’s an illustration of how it should work:

Unfortunately, in reality this was a disaster. For some reason, multiple timers were getting triggered, and the timer kept triggering after it should have finished. Timers would run simultaneously, reading and writing the IDs at the same time, causing some emails to get duplicated and others to be missed.

Why was this?

Well, it all comes down to Drupal.behaviors. While it will ensure your JavaScript runs when it should, it does nothing to prevent it from getting run multiple times. In my testing, it would always run at least twice, but that was a best case scenario, and quite often other events on the page could trigger additional runs. As the behavior was adding an event listener to the “Start sending” button, it got called multiple times, meaning that each added listener would be called, and each one would start another timer. To make matters worse, we are storing the timer reference in the bulkTimer variable. Unfortunately this can only hold one value, so when we go to clear the timer, we have lost the references for all but the most recent timer, so all of the others are left running, which means that the problem will get even worse if we try to start it again.

Searching this problem seems to find a lot of people have had similar problems, and not many answers. Some people have got around it by moving their code outside of Drupal.behaviors, which may work, but may also cause other issues since you are working outside the Drupal ecosystem.

To solve this, all we need to do is ensure that setInterval only gets called once, and if we can be sure that the event listener has only been added to the button a single time, that should ensure the timer only gets created once.

Fortunately, Drupal has a library that can ensure this, and it’s called once.

This used to be part of jQuery, but since Drupal 9.2, it has been a separate part of the Drupal core.

To avail of it, you need to specify it in the dependencies section of your module’s libraries.yml file:

conreg_bulkemail:
  version: 1.x
  js:
    js/conreg_bulkemail.js: {}
  dependencies:
    - core/drupal
    - core/once

We can then include in our JavaScript file:

(function (Drupal, once) {
 Drupal.behaviors.bulkemail = {
   attach (context) {
     once('init', '#edit-do-sending', context).forEach((button) => {
       button.addEventListener('click', () => {
         delay = document.querySelector('#edit-options-delay').value;
         // Start a timer running every 1s to upload the badges.
         bulkTimer = setInterval(() => {
           // Get the list of IDs.
           const idString = document.querySelector('#edit-sending-ids').value;
           // If no more IDs, stop timer.
           if (idString.length <= 0) {
             clearInterval(bulkTimer);
             return;
           }
           const ids = idString.split(' ');
           // Get the first ID from the list.
           const first = ids.shift();
           // Upload the next email.
           fetch('/admin/members/bulksend/'+first);
           // Update the list of IDs in the textarea.
           document.querySelector('#edit-sending-ids').value = ids.join(' ');
         }, delay);
       });
     });
   }
 }
})(Drupal, once);

The once function ensures that a block of code can only be run one time for a page element. It accepts three parameters. The first is a unique identifier, so if you have multiple once statements, each one should have a different identifier. The second is the element selector, in this case the ID of our button. The final parameter is the context, such as the document or window, but in this case the context passed in by Drupal.

once returns an array of elements matching the selector. In our case, there should only be one button with the ID. The forEach method will iterate through the elements of that array. Even though we know it will only have a single element, it’s still a handy way to run our code against it.

The original code used jQuery to reference the button element and add the listener to the “click” event. However, almost all jQuery functionality is now available in vanilla JavaScript, and in general, module developers are encouraged to avoid it when possible, so I have replaced the jQuery functionality in the original version, mostly with the querySelector function.

I’ve also replaced the original use of a separate function call with an embedded function, which shortens the code a little.

I’ve also reworked my old string handling code to avoid substrings, instead, splitting into an array, using the shift method to remove the first element, and joining the remaining elements back together. I don’t know which is quicker, but part of the aim is to slow down email sending, so I’ll take cleaner and more readable code over speed.

The result works cleanly. The IDs in the textarea give a visual indication of the emails being sent.

In the production version, I’ll clean up the user interface. I will hide the list of IDs, and display something more helpful like a progress bar. I’ll probably also make it disable the button while sending is taking place to prevent accidental multiple clicks.

This pattern is particularly useful for cases like this, where event listeners getting triggered multiple times can have undesired consequences, but it is probably a good practice whenever adding event listeners, as we almost always intend the listener to be added only once.

As this is something that seems to have caused problems for others, I thought it would be worth writing a post about it. I hope it’s something that some people find helpful.

Topic

Comments

Restricted HTML

  • Allowed HTML tags: <a href hreflang> <em> <strong> <cite> <blockquote cite> <code> <ul type> <ol start type> <li> <dl> <dt> <dd> <h2 id> <h3 id> <h4 id> <h5 id> <h6 id>
  • Lines and paragraphs break automatically.
  • Web page addresses and email addresses turn into links automatically.