What I Learned at Work this Week: Let’s talk about hoisting

credit: Pexels

I had a not-so-great moment at work recently. Towards the end of the week, we started seeing some unexpected (and undesired) behavior from our script on certain client websites. In situations like this, the first question to ask is: “what changed? What happened between yesterday and today that could be causing the behavior?” As I learned more about the issue, I started to get an idea of what was causing it. And it turns out I was right, because it came from a PR that I had approved.

Part of what I like about engineering for a larger company is that you can’t really make any big change completely on your own. Yes, I was the one who approved the code, but this wasn’t “Mike’s fault” because the author also bears some responsibility (I’m also grateful that my team and my company don’t bother to assign blame, so no time was wasted wondering “who was responsible” anyway). I’m glad that nobody had to deal with blame, but I want to be sure that I understand why the code didn’t work as expected so that I can catch it before deployment in the future. The PR that fixed the problem mentioned hoisting, so I’m investigating that this week.

Here’s yet another term that many of us probably heard in boot camp but rarely use because it’s more academic than practical. I had to go back to trusty MDN to get a refresher on what it means and why it’s important. I think the key phrase in that article is that variable and function declarations are put into memory during the compile phase [of our JavaScript].

Keep in mind that JavaScript, like all code that we write, has to be compiled before it can be run by a machine. During this step, memory is allocated for variable and function declarations. The word declaration is important because ES6 has made it much less frequent than it used to be.

For variables, declaration doesn’t do much more than register a variable within a certain scope. It’s important to understand that this is not the same thing as allocating memory for the variable, which is instead part of the variable’s initialization. Earlier today, I thought both of these declarations worked in the same way, but were different because they had a different scope. As it turns out, these two variables have slightly different lifecycles:

var a
let b

If you read the MDN link I posted earlier, you might know that var is hoisted but let is not. This is because var variables are declared and initialized during the same step of their lifecycle. During this declaration step, the variable is hoisted, which is why this code won’t cause an error:

return myVar // => undefined
var myVar = 5 // myVar is hoisted before the previous line runs, but is not assigned a value until after

But let works differently. Declaration and initialization don’t occur at the same time, so during compilation, variables declared using let will not have any memory allocated to them. Rather than returning undefined, this example will throw an error:

return myLet // => ReferenceError: Cannot access ‘myLet’ before initialization
let myLet = 5

We’ll get the same error if we use const or class, which have the same lifespan as let. If you want to dive deeper, the images from this post by Dmitri Pavlutin really helped me understand this concept. If you’re feeling generally confused, the good news is that this isn’t terribly relevant for those of us who don’t use var. At best, it might be helpful if we’re trying to understand some older or some transpiled code, but if we’re writing, we probably aren’t going to declare variables after using them anyway.

The original MDN quote mentions both variables and functions. Functions have their own unique lifecycle where declaration, initialization, and assignment happen in the same step. This means that if we declare a function using classical JS syntax, its name and its definition will be hoisted and can be invoked at any point in the code:

myFunc('Mike') // => 'My name is Mike'
function myFunc (name) {
return 'My name is ' + name
}

This again becomes less relevant with ES6 syntax since in my experience it’s much more common to use an arrow function. This means that our function name is declared as a variable and if we use let or const, it will not be hoisted. Using var will hoist the variable name, but not the definition. If we attempt to invoke the function, we’ll get an error because the variable exists, but has not been declared as a function yet:

return myFunc('Mike') // => TypeError: myFunc is not a function
var myFunc = (name) => {
return 'My name is ' + name
}

I’ve mentioned several times that hoisting is a lot less relevant today thanks to ES6, so why did it come up at work? The code wasn’t written using var — here’s a simplified version of the logic we were dealing with. I’ve made sure to keep all variable declarations in the same style so that we can check for hoisting:

if (
window.__my_window_conditional &&
window.__third_party_window_conditional
) {
let intervalID = setInterval(() => {
if (window.__third_party_window_conditional) {
const cookieValue = !!verifyShouldRun();
if (cookieValue) {
clearInterval(intervalID);
setNewCookie(cookieValue)
}
runRemainingLogic();
}
}, 200);
}

The idea here is that we want to set a cookie before running the rest of our logic because that cookie is going to determine what is displayed once our code finishes running. There’s lots of conditionals here because we only want to invoke this custom logic in certain situations. Let’s break down what each of these variables represents:

  • __my_window_conditional: I’ve added an attribute to the window for clients that should be using this logic.
  • __third_party_window_conditional: This logic works in conjunction with a third party, so I want to make sure that their script is also loaded onto the page.
  • verifyShouldRun: This is a function that checks the window and returns a value that the third party has set. If the third party has not set the value, it will return undefined.
  • setNewCookie: This is a function that just creates a cookie with a certain value. In this case, it’s the boolean from verifyShouldRun.
  • runRemainingLogic: We run this now that the cookie has been set because it’s going to conditionally display some text that will change depending on that cookie value.

This isn’t the exact code I was working with in the office, but I can promise you I didn’t leave out any var or classical function declarations. My guess is that the issue here was something else completely.

This logic is setting up what we call a poller — logic that checks on something periodically. In this case, we’re checking for the value returned by verifyShouldRun. If that value is truthy, we clear the interval which stops our poller, sets the cookie, and moves on the rest of the logic. But in this case, if the third party never populates the field we’re looking for, or even populates it with false, we’ll never escape our poller. To fix the issue, we changed the final conditional from if(cookieValue) to if(window.__third_party_window_conditional). This makes the assumption that once window.__third_party_window_conditional is defined, the return from verifyShouldRun has also been populated, which may or may not be true depending on how the third party’s script runs. But what we do know is that it can’t create any more infinite loops, which was the problem we were solving for.

I learned two useful lessons from writing this post. The first was a refresher on what hoisting means and why it’s important. The second is that I have an opportunity to be more thoughtful and attentive in my PR reviews. This issue was caused not by my lack of technical understanding, but by a simple failure to realize that the proposed logic wouldn’t work in a common situation. Mistakes help us learn and grow. Now that we’re out of the woods, I’m glad it happened!

Sources

Solutions Engineer