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 likeimport { isToday } from 'date-fns'
withimport 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)