Getting the DOM to debug for you

The other day, as part of a week long effort to squash a few bugs our team had in our backlog, I came across something that promised to be a real pain in the ass.

It was one of those ones where sometimes, something has had some side effect where something has made the screen un-scrollable when a modal was opened. Normally what makes our modal scrollable is a modal-open class getting appended to the body, but in some cases, it just wasn't there. Typically, as it wasn't easy to reproduce, it found its way to the very back of our backlog.

But this week, I decided I would fix it. It wasn't until late on Friday that I found time to even start looking into the issue.

Now, normally my modus operandi when debugging something like this is to find the code where the modal is opened and work my way backward from there; looking for anything obvious that could be triggering this.

This time, I didn't have time for that shit. It was 5:30 on a Friday, and I wanted to start my weekend. So I decided to let the browser do the work for me.

We can't scroll the modal because a class is getting removed from the body? Let's work our way back from there. So I threw the following snippet as early as I could in my application and pressed "continue" a few times until I had reproduced the bug.

const observer = new MutationObserver((mutations) => {
  mutations.forEach((mutation) => {
    if (mutation.attributeName === 'class') {
      console.trace(document.body.className);
    }      
  });    
});
observer.observe(document.body, { attributes: true });

And what greeted me? A stack trace that told me exactly what was changing the body class, including everything leading up to it (thank you console.trace).

// first log where the class was added as expected
overrideMethod @ react_devtools_backend.js
eval @ bootstrapAngularApp.ts
attributes (async)		
addClass @ jquery.js

// second log where the class was removed unexpectedly 
overrideMethod @ react_devtools_backend.js
eval @ bootstrapAngularApp.ts
attributes (async)		
removeModalOpenBodyClass @ DOMOperations.js
i @ Dimmer.js
eval @ Dimmer.js
requestAnimationFrame (async)		
requestFrame @ renderprops.cjs.js
frameLoop @ renderprops.cjs.js

I was immediately able to see that the class was getting added, but it was also getting removed. I could also see what was removing it and some of the other calls that were in the chain, painting an incredibly clear picture:

  • An unrelated component in a different library happened to be mounted - but not visible

  • This component had an unmount handler which removed the modal-open class

  • The component wasn't getting unmounted until an animation finished running, which happened to finish after the class had been added by the open modal

What would have normally been a pretty arduous process of tracking down what was changing the class turned into a ten minute task thanks to a MutationObserver and a stack trace.

Obviously debugging an issue like this won't work every time. But the next time I'm stuck trying to figure out why something is altering the DOM, I'm definitely going to be reaching for this trick sooner than later.