Skip to content
This repository has been archived by the owner on Dec 5, 2019. It is now read-only.

Webpack's removes classnames when minifying/uglifying ES6 code with inheritance #269

Closed
anatoly314 opened this issue Apr 2, 2018 · 21 comments

Comments

@anatoly314
Copy link

anatoly314 commented Apr 2, 2018

This is a copy of my answer on StackOverflow, I suspect that it's a bug so I decided to open an issue here too.
Project to demonstrate the issue: https://github.com/anatoly314/webpack-uglify-inheritence


Webpack's removes classnames when minifying/uglifying ES6 code with inheritance:

There's MVCE code which we try to minify/uglify:

Class Child:

const ParentClass = require('parent');

class Child extends ParentClass{
    constructor(){
        super();
    }
}

module.exports = Child;

index.js which invokes Child class:

const Child = require('./classes_so/child');

let child = new Child();

console.log(child.constructor.name);

Module Parent inside node_modules:

class Parent {
    constructor() {
        if (this.constructor.name === 'Parent'){
            throw new TypeError("Parent class is abstract - cant be instance");
        }
    }

}

module.exports = Parent;

The whole output I'll post to the end of the question, here I want to post only relevant lines which I think cause a wrong behavior (lines 33-37 from original output):

n.exports = class extends r {
        constructor() {
            super();
        }
    };

Why a classname is missing here: class extends r? I expect that value will be mangled but will exist, can I consider it as a bug? I tried to use keep_classnames flag but it keeps original class names which unacceptable.

We're using:

  • Webpack: 3.11.0 (tried with 4, same behavior)
  • uglifyjs-webpack-plugin: 1.2.4 (tried with different plugins)
  • NodeJS: v6.9.1 and v8.9.1 (same output)

Our webpack.config.js:

const webpack = require('webpack');
const UglifyJsPlugin = require('uglifyjs-webpack-plugin');
const path = require('path');
const fs = require('fs');

const nodeModules = {};
const localDependencies = ['.bin'];
fs.readdirSync('node_modules')
    .filter(function (x) {
        return localDependencies.indexOf(x) === -1;
    })
    .forEach(function (mod) {
        nodeModules[mod] = 'commonjs ' + mod;
    });

try {


    module.exports = {
        target: 'node',
        node: {
            console: false,
            global: false,
            process: false,
            Buffer: false,
            __filename: true,
            __dirname: true
        },

        entry: './index_so.js',

        output: {
            path: path.join(__dirname, 'build'),
            filename: 'index.js'
        },

        externals: nodeModules,
        plugins: [
            new webpack.IgnorePlugin(/\.(css|less)$/),
            new webpack.BannerPlugin({
                banner: 'require("source-map-support").install();',
                raw: true,
                entryOnly: false
            })
        ],
        devtool: 'sourcemap',

        module: {
            loaders: [
                {test: /\.json$/, loader: "json-loader"}
            ]
        },

        plugins: [
            new UglifyJsPlugin({
                uglifyOptions: {
                    compress: {
                        warnings: false
                    },
                    keep_classnames: false,
                    mangle: true,
                    output: {
                        beautify: true
                    }
                }
            })
        ]
    };
}
catch (e) {
    console.error(e);
}

The whole minified/uglified code from the example above:

!function(n) {
    var t = {};
    function e(r) {
        if (t[r]) return t[r].exports;
        var o = t[r] = {
            i: r,
            l: !1,
            exports: {}
        };
        return n[r].call(o.exports, o, o.exports, e), o.l = !0, o.exports;
    }
    e.m = n, e.c = t, e.d = function(n, t, r) {
        e.o(n, t) || Object.defineProperty(n, t, {
            configurable: !1,
            enumerable: !0,
            get: r
        });
    }, e.n = function(n) {
        var t = n && n.__esModule ? function() {
            return n.default;
        } : function() {
            return n;
        };
        return e.d(t, "a", t), t;
    }, e.o = function(n, t) {
        return Object.prototype.hasOwnProperty.call(n, t);
    }, e.p = "", e(e.s = 0);
}([ function(n, t, e) {
    let r = new (e(1))();
    console.log(r.constructor.name);
}, function(n, t, e) {
    const r = e(2);
    n.exports = class extends r {
        constructor() {
            super();
        }
    };
}, function(n, t) {
    n.exports = require("parent");
} ]);
@alexander-akait
Copy link
Member

@anatoly314 Thanks for issue, Yes looks like a bug. Need investigate who output this in not right way.

@tndev
Copy link

tndev commented Apr 2, 2018

I do not think that this is an error in webpack or uglify.

The syntax itself is so far correct because on the one hand you can have anonymous classes and these can also extend from other classes. And because the class name is not referenced anywhere in the file it can be optimized away.

So I do not see any problem in that part of the code:

n.exports = class extends r {

The error itself happens in this part of the code, which is afaik not part of webpack, but a module provided by @anatoly314:

class Parent {
    constructor() {
        if (this.constructor.name === 'Parent'){
            throw new TypeError("Parent class is abstract - cant be instance");
        }
    }
}

A test like this.constructor.name === 'Parent' that relays on function/class name should not be used in an environment that can have minified code. And it would for sure also fail to work if the code is not minified, and the class that extends from Parent would also be named Parent.

Because of that, the code of @anatoly314 should look like this:

class Parent {
    constructor() {
        if (this.constructor === Parent){
            throw new TypeError("Parent class is abstract - cant be instance");
        }
    }
}

And then the code would not have any problem with this kind of minification.

@alexander-akait
Copy link
Member

@tndev we don't change any code, just run uglify on code which go from webpack

@tndev
Copy link

tndev commented Apr 2, 2018

@evilebottnawi Yes and the resulting code generated is perfectly fine, there is no syntax error or invalid minification. The error happens in the code of node_modules/webpack-uglify-inheritence which is not part of webpack. And it only happens because of using this.constructor.name === 'Parent' instead of this.constructor === Parent. So imho there is no bug in uglify or webpack. But anyway I'm not contributor or maintainer, so it is not my decision anyway, I just wanted to share my observation. :)

@anatoly314
Copy link
Author

@tndev, your solution doesn't work. When you run minified code under NodeJS v6.9.1, console.log(this.constructor) returns:

[Function]                 //object instantiated from inherited class
[Function: Parent]   //object instantiated from abstract class

But when we run it from NodeJS v8.9.1 it returns:

[Function: Parent]    //object instantiated from inherited class
[Function: Parent]   //object instantiated from abstract class

In both case I can't know from which class an object was instantiated.

@alexander-akait
Copy link
Member

@anatoly314 looks as won't fix in uglify/webpack/uglify-plugin, maybe you should use polyfills or other complex check for this.constructor

@tndev
Copy link

tndev commented Apr 2, 2018

@evilebottnawi The displayed names are wrong that is true, but comparison is still correct (at leat here with 6, 8 and 9).

The output of:

console.log(this.constructor)
console.log(this.constructor.name, (this.constructor === Parent))

Is for new Child:

Parent false
[Function: Parent]

And for new Parent it is:

Parent true
[Function: Parent]

Or do you get true for this.constructor === Parent in both cases?

And console.log(this.constructor.toString())

Is for new Child:

class extends r {
        constructor() {
            super();
        }
    }

And for new Parent it is:

class Parent {
    constructor() {
        console.log(this.constructor.toString())
        console.log(this.constructor.name, (this.constructor == Parent))
        if (this.constructor === Parent){
            throw new TypeError("Parent class is abstract - cant be instance");
        }
    }

}

So imho is it just a display bug of V8.

@anatoly314
Copy link
Author

@tndev, it's mind-blowing but it works. But why, or it's just one of evil parts of JavaScript?

@tndev
Copy link

tndev commented Apr 2, 2018

@anatoly314 writing this.constructor.name === 'Parent' is evil, because you don't test if the constructor of this the same as the of of Parent, you only test if the names are equal. The this.constructor === Parent does not test for the name but for the actual function, and thats the only correct way to do a reliable test.

@anatoly314
Copy link
Author

Thank you very much, you helped us a lot and today I've learned a bit more about JavaScript.

@ericmorand
Copy link

ericmorand commented Feb 13, 2019

Why is this issue closed?

Using constructor.name is perfectly valid in JavaScript. Why would webpack breaking a code that works and is perfectly valid be considered as normal?

A test like this.constructor.name === 'Parent' that relays on function/class name should not be used in an environment that can have minified code.

God this doesn't make any sense. How would I know if my module will be minified or not? Why would I have to write code that assume how it will be consumed? This is nonsense.

If this is a perfectly valid ES6 syntax then webpack should not break it. This is a bug.

@tndev
Copy link

tndev commented Feb 14, 2019

The point of minification is that is it changes the names of variables, functions and classes. Reflection features are considered to be not reliable in all languages if it comes to minification and obfuscation as of that.

They already take care for parts where it is not avoidable or checkable:

shorten variable names (usually to single characters). Our mangler will analyze the code and generate proper variable names, depending on scope and usage, and is smart enough to deal with globals defined elsewhere, or with eval() calls or with{} statements. In short, if eval() or with{} are used in some scope, then all variables in that scope and any variables in the parent scopes will remain unmangled, and any references to such variables remain unmangled as well.

But should this be extended to a part where a reliable alternative exists?

So the question is more why can't (or don't want to) use this.constructor === Parent which does the check if the constructor is equal to Parent. And instead want to test if the name of the constructor is Parent which would also be true if the deriving class is calling itself also Parent (which would also be valid JavaScript):

class Parent {
  constructor() {
    console.log('parentA')
    console.log(this.constructor.name, this.constructor === Parent)
  }
}

const Test = Parent;

(function() {
  class Parent extends Test {
    constructor() {
      super();
      console.log('parentB')
      console.log(this.constructor.name, this.constructor === Parent)
    }
  }
  new Parent()
}())

As you can see this.constructor === Parent is reliable, but this.constructor.name is not unambiguous (even if the code is not minified). I am aware that this example is very artificial and not necessarily meaningful. It's just to illustrate that the name is never an unambiguous and reliable test in contrast to the direct comparison of the Objects itself.

@alexander-akait
Copy link
Member

uglif-js doesn't support es6 syntax, you should use terser plugin https://github.com/webpack-contrib/terser-webpack-plugin

@ericmorand
Copy link

ericmorand commented Feb 14, 2019

@tndev But what if you need to actually get the constructor name? Not for comparison. Just for the sake of using it for output.

@evilebottnawi Actually, I don't explicitely used uglifyjs. I just launch webpack command and the generated bundle is uglyfied by uglifyjs.

@ericmorand
Copy link

@evilebottnawi I assume this is a webpack 4 issue. Why they are minifying by default and thus not supporting ES6 is beyond me.

@tndev
Copy link

tndev commented Feb 14, 2019

@ericmorand It is actually not webpack related. That's how uglify/terser do the minification.

The minifiyer will convert this:

class Foo {
    getConstructorName() {
        return this.constructor.name;
    }
}

let foo = new Foo();

console.warn(foo.getConstructorName());

to:

let t=new class{
   getConstructorName(){return this.constructor.name}
};
console.warn(t.getConstructorName());

But that's not webpacks fault, nor something webpack needs to care about.

Those are related uglifyjs2 issues:

But those reflection features should really only be used for debugging/logging or code completion purpose.

@ericmorand
Copy link

ericmorand commented Feb 14, 2019

But those reflection features should really only be used for debugging/logging or code completion purpose.

But an uglifier should not assume anything on the way the code is written as long as its respects the language specifications. Which is the case there. A source code is only validated against the language specifications. The source code should not make any asumption on how it will be consumed; the consumers should not make any asumption on how the code is written.

This...

class Foo {
    getConstructorName() {
        return this.constructor.name;
    }
}

let foo = new Foo();

console.warn(foo.getConstructorName());

...and this...

let t=new class{
   getConstructorName(){return this.constructor.name}
};
console.warn(t.getConstructorName());

...are different algorithms. The first one is perfectly valid and pass the unit tests. The second one doesn't. So uglifyjs2 is actually making lossy changes to the code, and thus it's a bug.

Except if someone can point me to the uglifyjs2 specifications that say that uglifyjs2 is lossy and will break the original code expected result.

Imagine a compiler or a transpiler that would make the source code not giving the expected result even though it is perfectly valid and pass the unit tests.

This would clearly be considered as a bug. And this is exactly what is happening here.

@corneliusweiss
Copy link

it's not just an uglifyjs2 issue the same problem (with Twing) applies when babel-minify is used instead of uglifyjs2.

you need to switch of mangleing to have Twing working:

new MinifyPlugin({
    keepClassName: true,
})

@ericmorand how much work would it be to solve it in Twing?

@ericmorand
Copy link

@corneliusweiss, not much, fortunately, just in one place. I will have it fixed in no time. My rant is more conceptual - a transpiler should not break a perfectly valid code.

@noppa
Copy link

noppa commented Apr 28, 2019

Then again, since we have Function.prototype.toString in the spec, can you really ever do any kind of minification without potentially breaking valid code 🤔

It might make sense to add an option that disables this feature, but name mangling is extremely useful as a minification tool for those who don't use constructor names for anything.

@ericmorand
Copy link

But it doesn't only cause issues with constructor names. Named arguments also suffer from this. Twing can't be used with mangling because named arguments are part of Twig specifications.

Once again, a transpiler should not break a valid code. The bundle size argument is not meaningful.

tafm added a commit to tafm/prosemirror-model that referenced this issue May 26, 2019
If you need to check the marks at replace steps in all TextNodes, you can use recursive functions and check if its a Node or a TextNode with constructor.name, but using uglify-js (case of vue.js) it breaks the code, according to this thread (webpack-contrib/uglifyjs-webpack-plugin#269) a better idea should be use elem.constructor === Parent instead of element.construcot.name === 'Parent'. For this purpose you can import Node and TextNode and check.
ashklianko added a commit to enonic/app-applications that referenced this issue Jun 19, 2019
…104

-Updating js minifier to terser because uglify started to remove class name, probably due to some dependant module change, and that broke our event handling logic: we used to use full class names to bind to events but now those classes names are minified to 'e' or 'r' etc.
Probably issue: webpack-contrib/uglifyjs-webpack-plugin#269
ashklianko added a commit to enonic/app-users that referenced this issue Jun 19, 2019
…237

-Updating js minifier to terser because uglify started to remove class name, probably due to some dependant module change, and that broke our event handling logic: we used to use full class names to bind to events but now those classes names are minified to 'e' or 'r' etc.
Probably issue: webpack-contrib/uglifyjs-webpack-plugin#269
bainternet added a commit to bainternet/elementor that referenced this issue Sep 4, 2019
Updating js minifier to terser because uglify removes class name, probably due to some dependant module change, and that broke our event handling logic: 
we used to use full class names to bind to events but now those classes names are minified to 'e' or 'r' etc.
Probably issue: webpack-contrib/uglifyjs-webpack-plugin#269
Sign up for free to subscribe to this conversation on GitHub. Already have an account? Sign in.
Projects
None yet
Development

No branches or pull requests

6 participants