Javascript Events Doubled by HMR

I’m finding that all of my Javascript events are being compounded by HMR when it performs a reload. For example, with this script:

import {domReady} from '@roots/sage/client';

const main = async (err) => {
  if (err) {
    // handle hmr errors
    console.error(err);
  }

  // application code
  const button = document.querySelector('button');
  button.addEventListener('click', event => {
    console.log('click');
  });
};

domReady(main);
import.meta.webpackHot?.accept(main);

On the first load, buttons will correctly log a “click” when pressed:

click              app.js:32:13

But after changing the JS file, and having a HMR performed, it seems the event is now being called from multiple JS files:

click               app.js:32:13
click               app.e717bb1c3e94b3888f14.hot-update.js:31:13

And another change in the JS file causes yet another log, from another file on click:

click                app.js:32:13
click                app.e717bb1c3e94b3888f14.hot-update.js:31:13
click                app.9bed096b8aae3ed59f54.hot-update.js:31:13

I’ve attempted using the “dispose” and “invalidate” methods provided by the HMR module, but must be doing something wrong as they consistently throw errors or fail to prevent this behavior.

Anyone have some insight on the correct setup to prevent these compounding events?

1 Like

This is happening because you have a const button that is persisting between updates.

every update has a new anonymous event handler bound to the button. when you click all of the event handlers are still bound and still fire.

Three thoughts:

  1. rather than self-accepting the module, i find it’s easier to create a component file and use the entrypoint to deal with HMR.

  2. give the click handler a name so that you can reference it later during the accept callback.

  3. If you use React you have access to react-refresh which handles a lot of this logic for you.

That said, here’s an example HMR implementation. I didn’t do a click handler, but the problem and solution are very similar (in this case I wind up with a ton of buttons rather than just updating the button I want to update unless I handle the old button ref):

import {makeButton} from './button'

const header = document.querySelector("header");

// Note this is `let`. 
// we want to reassign during the accept callback 
let button = makeButton();

header.appendChild(button);

/* When './button.js' is updated... */
import.meta.webpackHot?.accept('./button.js', (err) => {
  /* ...remove the old ref */
  header.removeChild(button)

  /* ...update the button ref */
  button = makeButton()

  /* ...append the result */
  header.appendChild(button);

  /* If there was an error don't accept the update */
  err && module.hot.invalidate()
});

../button.js is really simple and kind of unimportant to the task, but for clarity:

export const makeButton = () => {
  const el = document.createElement(`button`);

  el.innerHTML = "click me!!";

  el.classList.add(
    "button",
    "text-white",
    "bg-blue-700",
    "hover:bg-blue-800",
    "font-medium",
    "rounded-lg",
    "text-sm",
    "px-5",
    "py-3",
    "mr-2",
    "mb-2",
  );

  return el;
};

I hope that’s helpful. Thank you for using bud.js and good luck with HMR!

3 Likes