Debugging JS minification bug

An example of a weird browser behavior traced back to the minification step.

Recently I have found a very weird issue that took some debugging to locate and fix. I finally traced the issue to an overly aggressive JavaScript minification tool. I want to describe the experience to help others locate similar issues quicker.

Problem

On a particular website of mine, when the user clicks on an item inside a Bootstrap collapse widget the item expands, closes quickly and then opens again.

Initial steps

I suspected an issue with the content and the element's style, thinking the collapse widget could not compute the content's dimensions correctly. This was wrong, the bug was visible even as every child node had its width and height hardcoded. Finally, I noticed that there was a difference between running the website without minification vs running uglified and concatenated code.

Semicolons?

I started to suspect that problem was caused by the concatenation step. Often different pieces of code are jammed together and if they are not property separated, they might affect each other.

The most obvious suspect were the missing semicolons (boostrap javascipt does not use any). Combine lack of semicolons with logical shortcuts like condition && expression and condition || else expression and code like this might be minified too much:

1
2
3
4
5
if (actives && actives.length) {
hasData = actives.data('collapse')
actives.collapse('hide')
hasData || actives.data('collapse', null)
}

I added semi-colons everywhere inside the bootstrap collapse js file, but this has not fixed the problem.

Removed code

After proactively adding semi-colons without success, it was time to start looking directly at the minified code. When looking at a large minified file, with all variable and methods renamed to short random strings, I use quoted strings to locate the code fragment of interest. In this case, the unique string is [data-toggle=collapse] that allowed to locate the collapse widget fragment inside the large bootstrap js (and inside our application).

The original source code before minification of interest looks like this:

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
Collapse.prototype.hide = function () {
if (this.transitioning || !this.$element.hasClass('in')) return

var startEvent = $.Event('hide.bs.collapse')
this.$element.trigger(startEvent)
if (startEvent.isDefaultPrevented()) return

var dimension = this.dimension()

this.$element
[dimension](this.$element[dimension]())
[0].offsetHeight

this.$element
.addClass('collapsing')
.removeClass('collapse')
.removeClass('in');
// more code follows
};

I compared the minified code created by our minifier vs the minified code provided by the Bootstrap project dist/bootstrap.min.js

var b=a.Event("hide.bs.collapse");if(this.$element.trigger(b),
!b.isDefaultPrevented()){var c=this.dimension();
this.$element[c](this.$element[c]())[0].offsetHeight,
this.$element.addClass("collapsing").removeClass("collapse").removeClass("in")

Our minified code

var b=a.Event("hide.bs.collapse");if(this.$element.trigger(b),
!b.isDefaultPrevented()){var c=this.dimension();
this.$element[c](this.$element[c]()),
this.$element.addClass("collapsing").removeClass("collapse").removeClass("in") ...

Notice missing .offsetHeight access. When adding the call manually to the minified code, the collapse behaved correctly. What is going on?

The browser in this case tries to queue several operations (transition, setting dimension, resetting collapse class). The browser will not redraw or recompute the actual element's dimensions if there are more changes coming. Accessing .offsetWidth property forces the browser to actually stop, recompute the layout and return the computed property. It is a common shortcut trick.

The problem was happening since no one else was using the fetched value, thus the minifier assumed the read access could be safely removed without affecting the state of the execution. This is too aggressive, since the browser was doing something to compute the value (that we actually wanted to do). Also this could be a problem even in the JavaScript world, if we used a getter function that changed global state for example.

Preserving the read property access

Once the problem was identified, I need a solution. I did not want to modify the minification parameters, because it would affect a lot of code. Instead I picked a dummy assignment that the minifier was not smart enough to remove

1
2
3
4
var tmp = this.$element
[dimension](this.$element[dimension]())
[0];
tmp.offsetHeight = tmp.offsetHeight;

If the optimizer were smart to realize I was assigning variable to itself, and removed it, I would add zero to the property

1
2
3
4
var tmp = this.$element
[dimension](this.$element[dimension]())
[0];
tmp.offsetHeight += 0;

This would be worse solution, because without knowing the run time type of offsetHeight we could change it, for example from 120px to 120px0 and that would be bad.

Conclusion

  • Unit test the unminified code to catch logical problems
  • Smoke test both unminified code and then minified production code too. Minification is a complex operation and you want to make sure it works as expected. Minification and assembling the final production code chunk might change the loading order, and as this example shows might change the semantics.
  • If possible, use the original 3rd party minified code. Just concatenate the libraries with your code avoiding possible issues like these. A good example of 3rd party library that broke our minification build was AngularJs with its dependency injection everywhere.