From 0c885b3200ce1e5104f43714fa149f504295d2c9 Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?Nicol=C3=B2=20Ribaudo?= Date: Wed, 20 Dec 2017 20:46:00 +0100 Subject: [PATCH] Add support for extending builtins (#7020) --- packages/babel-helpers/src/helpers.js | 45 +++++++++++++++++++ .../babel-plugin-transform-classes/README.md | 12 +++-- .../package.json | 3 +- .../src/index.js | 13 +++++- .../src/vanilla.js | 18 +++++++- .../fixtures/exec/class-prototype-chain.js | 23 ++++++++++ .../exec.js | 4 ++ .../options.json | 3 ++ .../exec.js | 34 ++++++++++++++ .../options.json | 3 ++ .../fixtures/extend-builtins/loose/actual.js | 1 + .../fixtures/extend-builtins/loose/exec.js | 4 ++ .../extend-builtins/loose/expected.js | 23 ++++++++++ .../extend-builtins/loose/options.json | 6 +++ .../extend-builtins/shadowed/actual.js | 3 ++ .../extend-builtins/shadowed/expected.js | 16 +++++++ .../extend-builtins/shadowed/options.json | 3 ++ .../fixtures/extend-builtins/spec/actual.js | 1 + .../fixtures/extend-builtins/spec/exec.js | 4 ++ .../fixtures/extend-builtins/spec/expected.js | 29 ++++++++++++ .../extend-builtins/spec/options.json | 3 ++ ...eritance.js => .ClassMethodInheritance.js} | 0 22 files changed, 244 insertions(+), 7 deletions(-) create mode 100644 packages/babel-plugin-transform-classes/test/fixtures/exec/class-prototype-chain.js create mode 100644 packages/babel-plugin-transform-classes/test/fixtures/extend-builtins/builtin-objects-throw-when-wrapped/exec.js create mode 100644 packages/babel-plugin-transform-classes/test/fixtures/extend-builtins/builtin-objects-throw-when-wrapped/options.json create mode 100644 packages/babel-plugin-transform-classes/test/fixtures/extend-builtins/imported_babel-plugin-transform-builtin-classes/exec.js create mode 100644 packages/babel-plugin-transform-classes/test/fixtures/extend-builtins/imported_babel-plugin-transform-builtin-classes/options.json create mode 100644 packages/babel-plugin-transform-classes/test/fixtures/extend-builtins/loose/actual.js create mode 100644 packages/babel-plugin-transform-classes/test/fixtures/extend-builtins/loose/exec.js create mode 100644 packages/babel-plugin-transform-classes/test/fixtures/extend-builtins/loose/expected.js create mode 100644 packages/babel-plugin-transform-classes/test/fixtures/extend-builtins/loose/options.json create mode 100644 packages/babel-plugin-transform-classes/test/fixtures/extend-builtins/shadowed/actual.js create mode 100644 packages/babel-plugin-transform-classes/test/fixtures/extend-builtins/shadowed/expected.js create mode 100644 packages/babel-plugin-transform-classes/test/fixtures/extend-builtins/shadowed/options.json create mode 100644 packages/babel-plugin-transform-classes/test/fixtures/extend-builtins/spec/actual.js create mode 100644 packages/babel-plugin-transform-classes/test/fixtures/extend-builtins/spec/exec.js create mode 100644 packages/babel-plugin-transform-classes/test/fixtures/extend-builtins/spec/expected.js create mode 100644 packages/babel-plugin-transform-classes/test/fixtures/extend-builtins/spec/options.json rename packages/babel-preset-es2015/test/fixtures/traceur/Classes/{ClassMethodInheritance.js => .ClassMethodInheritance.js} (100%) diff --git a/packages/babel-helpers/src/helpers.js b/packages/babel-helpers/src/helpers.js index 566aa199b0..99f4ec77e4 100644 --- a/packages/babel-helpers/src/helpers.js +++ b/packages/babel-helpers/src/helpers.js @@ -425,6 +425,51 @@ helpers.inheritsLoose = defineHelper(` } `); +// Based on https://github.com/WebReflection/babel-plugin-transform-builtin-classes +helpers.wrapNativeSuper = defineHelper(` + var _gPO = Object.getPrototypeOf || function _gPO(o) { return o.__proto__ }; + var _sPO = Object.setPrototypeOf || function _sPO(o, p) { o.__proto__ = p }; + var _construct = (typeof Reflect === "object" && Reflect.construct) || + function _construct(Parent, args, Class) { + var Constructor, a = [null]; + a.push.apply(a, args); + Constructor = Parent.bind.apply(Parent, a); + return _sPO(new Constructor, Class.prototype); + }; + + var _cache = typeof Map === "function" && new Map(); + + export default function _wrapNativeSuper(Class) { + if (typeof Class !== "function") { + throw new TypeError("Super expression must either be null or a function"); + } + + if (typeof _cache !== "undefined") { + if (_cache.has(Class)) return _cache.get(Class); + _cache.set(Class, Wrapper); + } + + function Wrapper() {} + Wrapper.prototype = Object.create(Class.prototype, { + constructor: { + value: Wrapper, + enumerable: false, + writeable: true, + configurable: true, + } + }); + return _sPO( + Wrapper, + _sPO( + function Super() { + return _construct(Class, arguments, _gPO(this).constructor); + }, + Class + ) + ); + } +`); + helpers.instanceof = defineHelper(` export default function _instanceof(left, right) { if (right != null && typeof Symbol !== "undefined" && right[Symbol.hasInstance]) { diff --git a/packages/babel-plugin-transform-classes/README.md b/packages/babel-plugin-transform-classes/README.md index 2da2fb76a9..b0f5130791 100644 --- a/packages/babel-plugin-transform-classes/README.md +++ b/packages/babel-plugin-transform-classes/README.md @@ -4,9 +4,15 @@ ## Caveats -Built-in classes such as `Date`, `Array`, `DOM` etc cannot be properly subclassed -due to limitations in ES5 (for the [classes](http://babeljs.io/docs/plugins/transform-classes) plugin). -You can try to use [@babel/plugin-transform-builtin-extend](https://github.com/loganfsmyth/babel-plugin-transform-builtin-extend) based on `Object.setPrototypeOf` and `Reflect.construct`, but it also has some limitations. +When extending a native class (e.g., `class extends Array {}`), the super class +needs to be wrapped. This is needed to workaround two problems: +- Babel transpiles classes using `SuperClass.apply(/* ... */)`, but native + classes aren't callable and thus throw in this case. +- Some built-in functions (like `Array`) always return a new object. Instead of + returning it, Babel should treat it as the new `this`. + +The wrapper works on IE11 and every other browser with `Object.setPrototypeOf` or `__proto__` as fallback. +There is **NO IE <= 10 support**. If you need IE <= 10 it's recommended that you don't extend natives. ## Examples diff --git a/packages/babel-plugin-transform-classes/package.json b/packages/babel-plugin-transform-classes/package.json index 8a431b7b5b..e400c090aa 100644 --- a/packages/babel-plugin-transform-classes/package.json +++ b/packages/babel-plugin-transform-classes/package.json @@ -10,7 +10,8 @@ "@babel/helper-define-map": "7.0.0-beta.35", "@babel/helper-function-name": "7.0.0-beta.35", "@babel/helper-optimise-call-expression": "7.0.0-beta.35", - "@babel/helper-replace-supers": "7.0.0-beta.35" + "@babel/helper-replace-supers": "7.0.0-beta.35", + "globals": "^11.1.0" }, "keywords": [ "babel-plugin" diff --git a/packages/babel-plugin-transform-classes/src/index.js b/packages/babel-plugin-transform-classes/src/index.js index 8c9d1f4420..93904772e4 100644 --- a/packages/babel-plugin-transform-classes/src/index.js +++ b/packages/babel-plugin-transform-classes/src/index.js @@ -3,6 +3,15 @@ import VanillaTransformer from "./vanilla"; import annotateAsPure from "@babel/helper-annotate-as-pure"; import nameFunction from "@babel/helper-function-name"; import { types as t } from "@babel/core"; +import globals from "globals"; + +const getBuiltinClasses = category => + Object.keys(globals[category]).filter(name => /^[A-Z]/.test(name)); + +const builtinClasses = new Set([ + ...getBuiltinClasses("builtin"), + ...getBuiltinClasses("browser"), +]); export default function(api, options) { const { loose } = options; @@ -54,7 +63,9 @@ export default function(api, options) { node[VISITED] = true; - path.replaceWith(new Constructor(path, state.file).run()); + path.replaceWith( + new Constructor(path, state.file, builtinClasses).run(), + ); if (path.isCallExpression()) { annotateAsPure(path); diff --git a/packages/babel-plugin-transform-classes/src/vanilla.js b/packages/babel-plugin-transform-classes/src/vanilla.js index 8064a37511..083eb90e25 100644 --- a/packages/babel-plugin-transform-classes/src/vanilla.js +++ b/packages/babel-plugin-transform-classes/src/vanilla.js @@ -4,6 +4,8 @@ import optimiseCall from "@babel/helper-optimise-call-expression"; import * as defineMap from "@babel/helper-define-map"; import { traverse, template, types as t } from "@babel/core"; +type ReadonlySet = Set | { has(val: T): boolean }; + const noMethodVisitor = { "FunctionExpression|FunctionDeclaration"(path) { path.skip(); @@ -61,7 +63,7 @@ const findThisesVisitor = traverse.visitors.merge([ ]); export default class ClassTransformer { - constructor(path: NodePath, file) { + constructor(path: NodePath, file, builtinClasses: ReadonlySet) { this.parent = path.parent; this.scope = path.scope; this.node = path.node; @@ -93,6 +95,12 @@ export default class ClassTransformer { this.superName = this.node.superClass || t.identifier("Function"); this.isDerived = !!this.node.superClass; + + const { name } = this.superName; + this.extendsNative = + this.isDerived && + builtinClasses.has(name) && + !this.scope.hasBinding(name, /* noGlobals */ true); } run() { @@ -112,7 +120,13 @@ export default class ClassTransformer { // if (this.isDerived) { - closureArgs.push(superName); + if (this.extendsNative) { + closureArgs.push( + t.callExpression(this.file.addHelper("wrapNativeSuper"), [superName]), + ); + } else { + closureArgs.push(superName); + } superName = this.scope.generateUidIdentifierBasedOnNode(superName); closureParams.push(superName); diff --git a/packages/babel-plugin-transform-classes/test/fixtures/exec/class-prototype-chain.js b/packages/babel-plugin-transform-classes/test/fixtures/exec/class-prototype-chain.js new file mode 100644 index 0000000000..c8e5d97d62 --- /dev/null +++ b/packages/babel-plugin-transform-classes/test/fixtures/exec/class-prototype-chain.js @@ -0,0 +1,23 @@ +function B() {} +B.b = function() { + return 'B.b'; +}; + +class C extends B {} + +assert.equal(Object.getPrototypeOf(C), B); +assert.equal(Object.getPrototypeOf(C.prototype), B.prototype); + +assert.equal(C.b(), 'B.b'); + +class D extends Object {} + +assert.ok(D instanceof Object) +assert.ok(D.prototype instanceof Object); +assert.equal(D.keys, Object.keys); + +class E {} + +assert.equal(Object.getPrototypeOf(E), Function.prototype); +assert.equal(Object.getPrototypeOf(E.prototype), Object.prototype); +assert.isFalse('keys' in E); diff --git a/packages/babel-plugin-transform-classes/test/fixtures/extend-builtins/builtin-objects-throw-when-wrapped/exec.js b/packages/babel-plugin-transform-classes/test/fixtures/extend-builtins/builtin-objects-throw-when-wrapped/exec.js new file mode 100644 index 0000000000..eeb747a6fd --- /dev/null +++ b/packages/babel-plugin-transform-classes/test/fixtures/extend-builtins/builtin-objects-throw-when-wrapped/exec.js @@ -0,0 +1,4 @@ +// JSON is wrapped because it starts with an uppercase letter, but it +// should not be possible to extend it anyway. + +assert.throws(() => class BetterJSON extends JSON {}); \ No newline at end of file diff --git a/packages/babel-plugin-transform-classes/test/fixtures/extend-builtins/builtin-objects-throw-when-wrapped/options.json b/packages/babel-plugin-transform-classes/test/fixtures/extend-builtins/builtin-objects-throw-when-wrapped/options.json new file mode 100644 index 0000000000..5d03380e6f --- /dev/null +++ b/packages/babel-plugin-transform-classes/test/fixtures/extend-builtins/builtin-objects-throw-when-wrapped/options.json @@ -0,0 +1,3 @@ +{ + "plugins": ["transform-classes", "transform-block-scoping"] +} diff --git a/packages/babel-plugin-transform-classes/test/fixtures/extend-builtins/imported_babel-plugin-transform-builtin-classes/exec.js b/packages/babel-plugin-transform-classes/test/fixtures/extend-builtins/imported_babel-plugin-transform-builtin-classes/exec.js new file mode 100644 index 0000000000..2871cd4560 --- /dev/null +++ b/packages/babel-plugin-transform-classes/test/fixtures/extend-builtins/imported_babel-plugin-transform-builtin-classes/exec.js @@ -0,0 +1,34 @@ +// Imported from +// https://github.com/WebReflection/babel-plugin-transform-builtin-classes/blob/85efe1374e1c59a8323c7eddd4326f6c93d9f64f/test/test.js + +class List extends Array { + constructor(value) { + super().push(value); + } + push(value) { + super.push(value); + return this; + } +} + +assert.ok(new List(1) instanceof List, 'new List is an instanceof List'); +assert.ok(new List(2) instanceof Array, 'new List is an instanceof Array'); + +var l = new List(3); +assert.ok(l.length === 1 && l[0] === 3, 'constructor pushes an entry'); +assert.ok(l.push(4) === l && l.length === 2 && l.join() === '3,4', 'method override works'); + +class SecondLevel extends List { + method() { + return this; + } +} + +assert.ok(new SecondLevel(1) instanceof SecondLevel, 'new SecondLevel is an instanceof SecondLevel'); +assert.ok(new SecondLevel(2) instanceof List, 'new SecondLevel is an instanceof List'); +assert.ok(new SecondLevel(3) instanceof Array, 'new SecondLevel is an instanceof Array'); + +var s = new SecondLevel(4); +assert.ok(s.length === 1 && s[0] === 4, 'constructor pushes an entry'); +assert.ok(s.push(5) === s && s.length === 2 && s.join() === '4,5', 'inherited override works'); +assert.ok(s.method() === s, 'new method works'); diff --git a/packages/babel-plugin-transform-classes/test/fixtures/extend-builtins/imported_babel-plugin-transform-builtin-classes/options.json b/packages/babel-plugin-transform-classes/test/fixtures/extend-builtins/imported_babel-plugin-transform-builtin-classes/options.json new file mode 100644 index 0000000000..0dfc820234 --- /dev/null +++ b/packages/babel-plugin-transform-classes/test/fixtures/extend-builtins/imported_babel-plugin-transform-builtin-classes/options.json @@ -0,0 +1,3 @@ +{ + "plugins": ["transform-classes","transform-block-scoping"] +} diff --git a/packages/babel-plugin-transform-classes/test/fixtures/extend-builtins/loose/actual.js b/packages/babel-plugin-transform-classes/test/fixtures/extend-builtins/loose/actual.js new file mode 100644 index 0000000000..59e3aab723 --- /dev/null +++ b/packages/babel-plugin-transform-classes/test/fixtures/extend-builtins/loose/actual.js @@ -0,0 +1 @@ +class List extends Array {} \ No newline at end of file diff --git a/packages/babel-plugin-transform-classes/test/fixtures/extend-builtins/loose/exec.js b/packages/babel-plugin-transform-classes/test/fixtures/extend-builtins/loose/exec.js new file mode 100644 index 0000000000..a193f870c1 --- /dev/null +++ b/packages/babel-plugin-transform-classes/test/fixtures/extend-builtins/loose/exec.js @@ -0,0 +1,4 @@ +class List extends Array {} + +assert.ok(new List instanceof List); +assert.ok(new List instanceof Array); diff --git a/packages/babel-plugin-transform-classes/test/fixtures/extend-builtins/loose/expected.js b/packages/babel-plugin-transform-classes/test/fixtures/extend-builtins/loose/expected.js new file mode 100644 index 0000000000..ad11812833 --- /dev/null +++ b/packages/babel-plugin-transform-classes/test/fixtures/extend-builtins/loose/expected.js @@ -0,0 +1,23 @@ +function _inheritsLoose(subClass, superClass) { subClass.prototype = Object.create(superClass.prototype); subClass.prototype.constructor = subClass; subClass.__proto__ = superClass; } + +var _gPO = Object.getPrototypeOf || function _gPO(o) { return o.__proto__; }; + +var _sPO = Object.setPrototypeOf || function _sPO(o, p) { o.__proto__ = p; }; + +var _construct = typeof Reflect === "object" && Reflect.construct || function _construct(Parent, args, Class) { var Constructor, a = [null]; a.push.apply(a, args); Constructor = Parent.bind.apply(Parent, a); return _sPO(new Constructor(), Class.prototype); }; + +var _cache = typeof Map === "function" && new Map(); + +function _wrapNativeSuper(Class) { if (typeof Class !== "function") { throw new TypeError("Super expression must either be null or a function"); } if (typeof _cache !== "undefined") { if (_cache.has(Class)) return _cache.get(Class); _cache.set(Class, Wrapper); } function Wrapper() {} Wrapper.prototype = Object.create(Class.prototype, { constructor: { value: Wrapper, enumerable: false, writeable: true, configurable: true } }); return _sPO(Wrapper, _sPO(function Super() { return _construct(Class, arguments, _gPO(this).constructor); }, Class)); } + +var List = +/*#__PURE__*/ +function (_Array) { + _inheritsLoose(List, _Array); + + function List() { + return _Array.apply(this, arguments) || this; + } + + return List; +}(_wrapNativeSuper(Array)); diff --git a/packages/babel-plugin-transform-classes/test/fixtures/extend-builtins/loose/options.json b/packages/babel-plugin-transform-classes/test/fixtures/extend-builtins/loose/options.json new file mode 100644 index 0000000000..4bd503a0fa --- /dev/null +++ b/packages/babel-plugin-transform-classes/test/fixtures/extend-builtins/loose/options.json @@ -0,0 +1,6 @@ +{ + "plugins": [ + ["transform-classes", { "loose": true }], + "transform-block-scoping" + ] +} diff --git a/packages/babel-plugin-transform-classes/test/fixtures/extend-builtins/shadowed/actual.js b/packages/babel-plugin-transform-classes/test/fixtures/extend-builtins/shadowed/actual.js new file mode 100644 index 0000000000..0b0778a7d8 --- /dev/null +++ b/packages/babel-plugin-transform-classes/test/fixtures/extend-builtins/shadowed/actual.js @@ -0,0 +1,3 @@ +class Array {} + +class List extends Array {} \ No newline at end of file diff --git a/packages/babel-plugin-transform-classes/test/fixtures/extend-builtins/shadowed/expected.js b/packages/babel-plugin-transform-classes/test/fixtures/extend-builtins/shadowed/expected.js new file mode 100644 index 0000000000..f0fd087354 --- /dev/null +++ b/packages/babel-plugin-transform-classes/test/fixtures/extend-builtins/shadowed/expected.js @@ -0,0 +1,16 @@ +let Array = function Array() { + babelHelpers.classCallCheck(this, Array); +}; + +let List = +/*#__PURE__*/ +function (_Array) { + babelHelpers.inherits(List, _Array); + + function List() { + babelHelpers.classCallCheck(this, List); + return babelHelpers.possibleConstructorReturn(this, (List.__proto__ || Object.getPrototypeOf(List)).apply(this, arguments)); + } + + return List; +}(Array); diff --git a/packages/babel-plugin-transform-classes/test/fixtures/extend-builtins/shadowed/options.json b/packages/babel-plugin-transform-classes/test/fixtures/extend-builtins/shadowed/options.json new file mode 100644 index 0000000000..aec48c2b7d --- /dev/null +++ b/packages/babel-plugin-transform-classes/test/fixtures/extend-builtins/shadowed/options.json @@ -0,0 +1,3 @@ +{ + "plugins": ["transform-classes", "external-helpers"] +} diff --git a/packages/babel-plugin-transform-classes/test/fixtures/extend-builtins/spec/actual.js b/packages/babel-plugin-transform-classes/test/fixtures/extend-builtins/spec/actual.js new file mode 100644 index 0000000000..59e3aab723 --- /dev/null +++ b/packages/babel-plugin-transform-classes/test/fixtures/extend-builtins/spec/actual.js @@ -0,0 +1 @@ +class List extends Array {} \ No newline at end of file diff --git a/packages/babel-plugin-transform-classes/test/fixtures/extend-builtins/spec/exec.js b/packages/babel-plugin-transform-classes/test/fixtures/extend-builtins/spec/exec.js new file mode 100644 index 0000000000..a193f870c1 --- /dev/null +++ b/packages/babel-plugin-transform-classes/test/fixtures/extend-builtins/spec/exec.js @@ -0,0 +1,4 @@ +class List extends Array {} + +assert.ok(new List instanceof List); +assert.ok(new List instanceof Array); diff --git a/packages/babel-plugin-transform-classes/test/fixtures/extend-builtins/spec/expected.js b/packages/babel-plugin-transform-classes/test/fixtures/extend-builtins/spec/expected.js new file mode 100644 index 0000000000..ae78bb95f7 --- /dev/null +++ b/packages/babel-plugin-transform-classes/test/fixtures/extend-builtins/spec/expected.js @@ -0,0 +1,29 @@ +function _classCallCheck(instance, Constructor) { if (!(instance instanceof Constructor)) { throw new TypeError("Cannot call a class as a function"); } } + +function _possibleConstructorReturn(self, call) { if (call && (typeof call === "object" || typeof call === "function")) { return call; } if (self === void 0) { throw new ReferenceError("this hasn't been initialised - super() hasn't been called"); } return self; } + +function _inherits(subClass, superClass) { if (typeof superClass !== "function" && superClass !== null) { throw new TypeError("Super expression must either be null or a function"); } subClass.prototype = Object.create(superClass && superClass.prototype, { constructor: { value: subClass, enumerable: false, writable: true, configurable: true } }); if (superClass) Object.setPrototypeOf ? Object.setPrototypeOf(subClass, superClass) : subClass.__proto__ = superClass; } + +var _gPO = Object.getPrototypeOf || function _gPO(o) { return o.__proto__; }; + +var _sPO = Object.setPrototypeOf || function _sPO(o, p) { o.__proto__ = p; }; + +var _construct = typeof Reflect === "object" && Reflect.construct || function _construct(Parent, args, Class) { var Constructor, a = [null]; a.push.apply(a, args); Constructor = Parent.bind.apply(Parent, a); return _sPO(new Constructor(), Class.prototype); }; + +var _cache = typeof Map === "function" && new Map(); + +function _wrapNativeSuper(Class) { if (typeof Class !== "function") { throw new TypeError("Super expression must either be null or a function"); } if (typeof _cache !== "undefined") { if (_cache.has(Class)) return _cache.get(Class); _cache.set(Class, Wrapper); } function Wrapper() {} Wrapper.prototype = Object.create(Class.prototype, { constructor: { value: Wrapper, enumerable: false, writeable: true, configurable: true } }); return _sPO(Wrapper, _sPO(function Super() { return _construct(Class, arguments, _gPO(this).constructor); }, Class)); } + +var List = +/*#__PURE__*/ +function (_Array) { + _inherits(List, _Array); + + function List() { + _classCallCheck(this, List); + + return _possibleConstructorReturn(this, (List.__proto__ || Object.getPrototypeOf(List)).apply(this, arguments)); + } + + return List; +}(_wrapNativeSuper(Array)); diff --git a/packages/babel-plugin-transform-classes/test/fixtures/extend-builtins/spec/options.json b/packages/babel-plugin-transform-classes/test/fixtures/extend-builtins/spec/options.json new file mode 100644 index 0000000000..5d03380e6f --- /dev/null +++ b/packages/babel-plugin-transform-classes/test/fixtures/extend-builtins/spec/options.json @@ -0,0 +1,3 @@ +{ + "plugins": ["transform-classes", "transform-block-scoping"] +} diff --git a/packages/babel-preset-es2015/test/fixtures/traceur/Classes/ClassMethodInheritance.js b/packages/babel-preset-es2015/test/fixtures/traceur/Classes/.ClassMethodInheritance.js similarity index 100% rename from packages/babel-preset-es2015/test/fixtures/traceur/Classes/ClassMethodInheritance.js rename to packages/babel-preset-es2015/test/fixtures/traceur/Classes/.ClassMethodInheritance.js