DEV Community

Benjamin Seber
Benjamin Seber

Posted on

Pitfalls of transpiling JavaScript code with babel

Pitfalls of transpiling JavaScript code with babel

Recently I had one of these WTF?!? moments. I updated webpack dependency from 4.28.4 to 4.39.3. Unit tests did still pass. I started the application and "nothing" worked anymore. Browser console displayed an error log like Uncaught RangeError: Maximum call stack size exceeded. So what happened?

In our project we're using

  • babel to transpile modern JavaScript code into JavaScript that can be run by legacy browsers.
  • date-fns library for date handling.
  • babel-plugin-date-fns to optimize the final JavaScript bundle file size. This plugins replaces imports like import { isToday } from 'date-fns' with import isToday from 'date-fns/is-today'. This results in the final bundle not containing the whole library but only the required stuff from date-fns. (obsolete since date-fns version 2 which supports tree shaking)

Let me show you some code snippets first.

Original code:

import { isToday, isPast } from "date-fns"

const assert = {
  isPast: function(date) {
    return !isToday(date) && isPast(date);
  },
};

Transpiled code until recently (webpack 4.28.4, mode=development):

var date_fns_is_today__WEBPACK_IMPORTED_MODULE_9__ = __webpack_require__(/*! date-fns/is_today */ "MNHD");
var date_fns_is_today__WEBPACK_IMPORTED_MODULE_9___default = /*#__PURE__*/__webpack_require__.n(date_fns_is_today__WEBPACK_IMPORTED_MODULE_9__);
var date_fns_is_past__WEBPACK_IMPORTED_MODULE_10__ = __webpack_require__(/*! date-fns/is_past */ "qTUo");
var date_fns_is_past__WEBPACK_IMPORTED_MODULE_10___default = /*#__PURE__*/__webpack_require__.n(date_fns_is_past__WEBPACK_IMPORTED_MODULE_10__);

var assert = {
  isPast: function isPast(date) {
    return !date_fns_is_today__WEBPACK_IMPORTED_MODULE_9___default()(date) && date_fns_is_past__WEBPACK_IMPORTED_MODULE_10___default()(date);
  },
}

Transpiled code after updating webpack (to 4.39.3, mode=development):

var date_fns_is_today__WEBPACK_IMPORTED_MODULE_9__ = __webpack_require__(/*! date-fns/is_today */ "MNHD");
var date_fns_is_today__WEBPACK_IMPORTED_MODULE_9___default = /*#__PURE__*/__webpack_require__.n(date_fns_is_today__WEBPACK_IMPORTED_MODULE_9__);

var assert = {
  isPast: function isPast(date) {
    /* NOTE: Today is not in the past! */
    return !date_fns_is_today__WEBPACK_IMPORTED_MODULE_9___default()(date) && isPast(date);
  },
}

Babel configuration:

{
  "babel": {
    "presets": [
      [
        "@babel/preset-env", {
          "modules": false
        }
      ]
    ],
    "plugins": [
      "babel-plugin-date-fns"
    ],
    "env": {
      "test": {
        "presets": [
          [
            "@babel/preset-env", {
              "targets": {
                "node": "current"
              }
            }
          ]
        ]
      }
  }
}

Did you see the fail? It's not quite obvious when you know the original code. If you didn't find the issue, even now looking at the code again, I will solve the puzzle soon for you. But first I want to recap the WTF session.

I've updated multiple other dependencies besides webpack. Every dependency (block) in a separate commit. Every commit passed the unit tests.

  • webpack
  • babel
  • ...

Then I started the application and a wonderful Uncaught RangeError: Maximum call stack size exceeded error welcomed me in the browser.

Due to the fact that a similar issue happened at this day actually, I immediately knew that it could only be the babel update or the webpack update. (Note that it was a runtime issue in the browser. The unit tests still worked! Therefore it had to be a transpilation issue.) Thanks to the atomic commits it was an easy one to find out that the webpack update commit was the evil doer. Check out the two commits and start the application. Easy peasy.

But what should be the next step? Googling the issue? Which query to use? Searching "maximum call stack size exceeded after webpack update" did not raise helpful articles.

So I went to github and cloned the webpack project. I knew that it worked with webpack v4.28.4. but not with v4.39.3 I've linked the local webpack into the project and used it to build the application. With git bisect I had to build it roughly 6 times to find the evil commit

  > git checkout v4.39.3
  # start a binary search for the commit that breaks something
  > git bisect start
  # we know that it's broken here
  > git bisect bad
  # we know that it worked recently
  > git checkout v4.28.4
  > git bisect good
  # git now starts checking out commits between the previously marked commits
  # you can go through theses commits and mark it with 'good' or 'bad'

I looked at the diff of the webpack commit and thought "ok, I have no clue what you're doing here. but the comment 'Add function name in scope for recursive calls' sounds like the runtime error. recursion == maximum call stack"

I looked at the linked bug report and thought "Well... I still have no clue but it seems right, doesn't it. But why the fuck is this breaking my application? I don't reuse variable names from outer scopes. Even verified by eslint/no-shadow rule"

  import { isToday, isPast } from "date-fns"

  const assert = {
    isPast: function(date) {
      // no shadowing here
      // `assert.isPast` is something else and not called here
      // `isPast` is the imported function
      return !isToday(date) && isPast(date);
    },
  };

But the transpiled code contained the shadowing... And with the webpack bugfix commit the isPast function has not been replaced anymore ðŸĪŠ

  var date_fns_is_today__WEBPACK_IMPORTED_MODULE__ = __webpack_require__("date-fns/is_today");
  var date_fns_is_isPast__WEBPACK_IMPORTED_MODULE__ = __webpack_require__("date-fns/is_past");

  var assert = {
    // function is not anonymous anymore
    // it is declared as 'isPast'
    isPast: function isPast(date) {
      return !date_fns_is_today__WEBPACK_IMPORTED_MODULE_9__(date) && isPast(date);
    },
  };

Next question I asked myself was "who adds the name to the anonymous function?"

Well, I myself configured babel with @babel/preset-env which transpiles isPast: function() {} into isPast: function isPast() {}. Why you may ask? Well, I think this is due to better stack traces. While the original code declares an anonymous function and assigns it to the key isPast the latter one declares a named function which is then assigned. The difference is that older browsers are logging <anonymous>:1337:42" instead of <isPast>:1337:42" in error cases.

If we configure @babel/preset-env to transpile the original code for modern browsers only like firefox@68 it will just take the original code. Modern browser devtools are still showing the anonymous part in the stack traces flavored with the assigned key name.

So why didn't the unit tests backfire? Why did they still pass? Did you notice the env part in the babel config? We're using jest as a test runner. jest sets the NODE_ENV environment variable to test. Babel is then configured to target the current nodejs version for code transpilation. Guess which option of the two snippets above will be generated for the unit tests running in the nodejs process? Yep, you're right.

But why do we have two various configurations for code transpilation? Glad you asked! Because we're writing code for the browser, not for nodejs. And still we're testing it in a nodejs environment with simulated DOM abstraction implemented by jsdom... Good ol' times when we used jasmine in the browser to test all the things 😅 Anyway, there are valid reasons to use node and jsdom. At least for unit tests. Execution time is much faster and we do not need a browser setup (html file, browser binary, ...). However, back to the @babel/preset-env configuration. Using "target": { "node": "current" } is the most convenient way. And in most use cases it just works, right ÂŊ\(ツ)/ÂŊ

Fixing it

Honestly I don't know how to fix this. Maybe it is a bug of the babel-plugin-date-fns. Maybe it is a bug of the babel-preset-env. I had no intention going deeper down the rabbit hole.

In my use case this issue was obsolete with upgrading date-fns to version 2. This major update supports tree shaking. So I could delete the babel-plugin-date-fns from my build config and the transpiled code accesses the date-fns module instead of separate function names.

var date_fns__WEBPACK_IMPORTED_MODULE__ = __webpack_require__("date-fns");

var assert = {
  isPast: function isPast(date) {
    return !Object(date_fns__WEBPACK_IMPORTED_MODULE_2__["isToday"])(date) && Object(date_fns__WEBPACK_IMPORTED_MODULE_2__["isPast"])(date);
  },
};

Unfortunately, this scope shadowing issue could also appear with babel plugins like babel-plugin-lodash or other util libraries I don't know yet. If tree shaking is not supported by the lib and you have to use the babel plugin you could just use named functions, of course:

import { isToday, isPast } from 'date-fns';

const assert = {
  isPast: function assertIsPast(date) {
    return !isToday(date) && isPast(date);
  }
}

Using shorthand method names could be another solution. It creates a bit more code however, since this function is bound to the object context.

isPast: function (_isPast) {
  function isPast(_x) {
    return _isPast.apply(this, arguments);
  }

  isPast.toString = function () {
    return _isPast.toString();
  };

  return isPast;
}(function (date) {
  return !date_fns_is_today__WEBPACK_IMPORTED_MODULE_()(date) && date_fns_is_past__WEBPACK_IMPORTED_MODULE_()(date);
}),

vs.

isPast: function isPast(date) {
  return !date_fns_is_today__WEBPACK_IMPORTED_MODULE_()(date) && date_fns_is_past__WEBPACK_IMPORTED_MODULE_()(date);
},

Lessons Learned

Modern JavaScript development is awesome. We have all these great tools to write code with syntax sugar and new features not yet implemented by all browser platforms.

However, as a good Software Engineer... Know your tools, know what happens with your code base. Know your build setup. Do atomic commits. Reference issue tickets in merge request.

  • git bisect helped me to quickly find the bad commit in webpack
  • the reference to the bug ticket explained the "why" of the bugfix
  • running tests NOT in the production environment and NOT with the actual bundled sourcecode is... hm... better than nothing I guess ðŸĪ” however, I've got to investigate running my unit tests in a real browser runtime again

Top comments (0)