Leaky Semantics in ES2015 Transpilation

What happens when the compiler lies to you?

In one of our recent internal builds, we discovered a curious regression in the attachment button. It just simply stopped working. When I inspected the code, I found this and had a good head scratch.

function getFileMobile() {
  let options = {
    ...options,
    allowEdit: false,
    destinationType: Camera.DestinationType.FILE_URI,
  };
 
  // ... do cordova camera access
}

The options variable that gets spread into the object appears to be a missing argument. It's an undefined reference, and there's no way this code should have ever worked. So what happened?

Well, earlier this release cycle we had determined that all of our targets supported ES2015 sufficiently that we could stop telling Babel to transpile to ES5 and instead target ES2015 directly (our targets are iOS 10, Android Chromium, and Electron). So something about the transformation to ES5 changed this in a way that hid the reference error. Let's slim this down into a minimal case.

function test() {
    let obj = {
      ...obj,
      a: 1,
    }
}

Here's how that looks transpiled to ES5.

var _extends = // an ES5 implementation of Object.assign
 
function test() {
     var obj = _extends({}, obj, {
          a: 1
     });
}

And here's how it looks transpiled to ES2015.

var _extends = // Same as before
 
function test() {
     let obj = _extends({}, obj, {
          a: 1
     });
}

Can you spot the difference? That's right, our let became var, changing the semantics of the variable scoping. The differences are subtle, but important. let is lexically scoped, which means the variable doesn't exist until after the statement that defines it. var is function scoped, which means the variable exists at the beginning of function execution. So the original ES5 transpiled version worked because the obj variable was referencing itself. Targetting ES2015 allowed the browser to use the correct semantics and expose the bug.

I had initially suspected that object spread had something to do with it, but object spread is not part of ES5 or ES2015 so it has no bearing on the bug. I left it in as a red herring. The actual minimal case is:

function test() {
    let obj = obj;
}

So the punchline is, if you can target ES2015, do so. Transpilation can subtly change the semantics of your code to mask errors. And you should also use no-use-before-define in ESLint, which will catch this kind of error but is not in the "recommended" configuration. If you'd like to play with the above example, here's a link to a Babel repl.

Leaky Semantics in ES2015 Transpilation
Share this
All content is licensed with: