Skip to content

Create static class properties in a class IIFE#6963

Open
Andarist wants to merge 1 commit intobabel:masterfrom
Andarist:static-class-properties-iife
Open

Create static class properties in a class IIFE#6963
Andarist wants to merge 1 commit intobabel:masterfrom
Andarist:static-class-properties-iife

Conversation

@Andarist
Copy link
Copy Markdown
Member

@Andarist Andarist commented Dec 3, 2017

Q                       A
Fixed Issues? Fixes #5902, Fixes #6916
Patch: Bug Fix? no
Major: Breaking Change? no
Minor: New Feature? yes
Tests Added + Pass? Yes
Documentation PR n/a
Any Dependency Changes? internal
License MIT

I must say I'm not completely satisfied with the implementation. I've found that handling ClassExpressions was troublesome and I've left them at the moment unpolished - in combination with static properties they can produce 2 nested IIFEs now - I'd like to revisit this, but possibly as a followup PR as I don't have much time lately to work on OSS and I wouldn't like to postpone merging this IMHO important PR.

@Andarist Andarist added area: perf PR: Polish 💅 A type of pull request used for our changelog categories labels Dec 3, 2017
inherits: syntaxClassProperties,

visitor: {
ExportDefaultDeclaration(path) {
Copy link
Copy Markdown
Member Author

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

this is literally copied from here any suggestions what's the best way to dedupe this?

Copy link
Copy Markdown
Member

@nicolo-ribaudo nicolo-ribaudo Dec 4, 2017

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

You can create a path.splitExportDeclaration() function (or a @babel/helper-...)

Copy link
Copy Markdown
Member Author

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

was thinking about both, weren't sure which one to pick :D I'd probably go with latter, thanks

Copy link
Copy Markdown
Member

@nicolo-ribaudo nicolo-ribaudo Dec 7, 2017

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

maybeConvertFromExportDeclaration(parentDeclar) {
const exportDeclar =
parentDeclar.parentPath.isExportDeclaration() && parentDeclar.parentPath;
if (!exportDeclar) return;
// build specifiers that point back to this export declaration
const isDefault = exportDeclar.isExportDefaultDeclaration();
if (
isDefault &&
(parentDeclar.isFunctionDeclaration() ||
parentDeclar.isClassDeclaration()) &&
!parentDeclar.node.id
) {
// Ensure that default class and function exports have a name so they have a identifier to
// reference from the export specifier list.
parentDeclar.node.id = parentDeclar.scope.generateUidIdentifier(
"default",
);
}
const bindingIdentifiers = parentDeclar.getOuterBindingIdentifiers();
const specifiers = [];
for (const name in bindingIdentifiers) {
const localName = name === this.oldName ? this.newName : name;
const exportedName = isDefault ? "default" : name;
specifiers.push(
t.exportSpecifier(t.identifier(localName), t.identifier(exportedName)),
);
}
if (specifiers.length) {
const aliasDeclar = t.exportNamedDeclaration(null, specifiers);
// hoist to the top if it's a function
if (parentDeclar.isFunctionDeclaration()) {
aliasDeclar._blockHoist = 3;
}
exportDeclar.insertAfter(aliasDeclar);
exportDeclar.replaceWith(parentDeclar.node);
}
}
looks similar

return iife;
}

if (this.path.isClassExpression()) {
Copy link
Copy Markdown
Member Author

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

hating this IfStatement here, kinda a hack to insert a correct return for mentioned double IIFEs case - need to figure out how to flatten those IIFEs (in case of ClassExpresion with static properties) and keep all surrounding references correct

@babel-bot
Copy link
Copy Markdown
Collaborator

babel-bot commented Dec 3, 2017

Build successful! You can test your changes in the REPL here: https://babeljs.io/repl/build/6877/

var _class;

return _temp = _class =
return _class =
Copy link
Copy Markdown
Member

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Is it possible to remove this unused _class = assignment?

Copy link
Copy Markdown
Member Author

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

it probably is, added _class is caused by ClassExpression being replaced by multiple statements. The path#replaceWithMultiple creates an IIFE when the conversion is made so the single expression can be maintained in place.

This is certainly something I'd like to revisit, but for another month (till Jan 16th) I have very little time and I couldn't manage to do this properly at the moment because of the complexity and probably my insufficient experience with AST manipulation.

Ideally I would like to "inline" the ClassExpression here if possible to skip second IIFE creation, but I'm not sure how safe it is because scope references have to be synced and stuff.

Copy link
Copy Markdown
Member Author

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

This case (and other similar) are caused by simple insertion here. To remove this unused variable the logic here would have to be expanded. I might revisit this in a followup PR, but would like to keep this one as simple as possible.

@@ -6,7 +6,7 @@ var four = 4;

var MyClass =
/*#__PURE__*/
Copy link
Copy Markdown
Member

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Isn't this iife pure only if computed properties are pure?

Copy link
Copy Markdown
Member Author

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Yes, you are right. They can also be impure in other cases, such as impure property initializers, or even super class being an impure expression.

I've wanted to keep marking those class IIFEs as pure, because it is what transform-classes do at the moment in all cases (and classes without properties can be impure too). That's quite my fault, adding those annotations was one of my 1st PRs to babel.

I'd like to revisit (as another PR) annotating classes as pure as a whole and not just in case of proposal-class-properties plugin. While checking if class is pure should be quite easy, I'm not sure what we should do about subclasses - we won't be able to tell if a super class is pure or not.

});

export default class MyClass3 {
class MyClass3 {
Copy link
Copy Markdown
Member

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Can we split the class and the export statement only if it is actually needed? (Only if it doesn't add too much complexity to the plugin)

Copy link
Copy Markdown
Member Author

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

It was easier to split those for now in all cases, need to check how much complexity it would add to keep them together (and the same should be done in transform-classes as it is splitting those every time)

@Andarist
Copy link
Copy Markdown
Member Author

Andarist commented Dec 4, 2017

@nicolo-ribaudo thanks for the initial review!

declaration.isFunctionDeclaration() || declaration.isClassDeclaration();

let id = declaration.node.id;
const liveBinding = !!id;
Copy link
Copy Markdown
Member

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

export default function() {}

is transformed into

function _default() {}
export default _default;

The first export statement is hoisted, but is it also the second?

Copy link
Copy Markdown
Member Author

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

could u elaborate? I've thought about hoisting briefly in this context, but came to conclusion that no extra work needs to be done for export statements, they are evaluated in place, right? and as function declaration will get hoisted by javascript this identifier will be available by the time it gets exported

Copy link
Copy Markdown
Member

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

After playing a bit with it, I understood that the way you implemented it is correct 👍

@Andarist Andarist force-pushed the static-class-properties-iife branch 2 times, most recently from a314c95 to 7fec1b7 Compare January 1, 2018 18:09
@Andarist
Copy link
Copy Markdown
Member Author

Andarist commented Jan 1, 2018

@nicolo-ribaudo
I've finished working on this PR, seems feature complete for me. Would love your code review on this :)

Copy link
Copy Markdown
Member

@nicolo-ribaudo nicolo-ribaudo left a comment

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Are the changes to the module transformer required by the split-export-declaration helper? If not, I'd prefer them to be moved to another PR, to keep the changes simpler.

PS. This review is not complete yet (I don't have time to look at the tests now).


const classIIFEs = new WeakMap();

export function getClassIIFE(path, assumePure) {
Copy link
Copy Markdown
Member

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Nit: can you use a default export? It seems that most helpers do that.

localData.delete(localName);

reexport.names.forEach(name => {
reexport.bindings.map(({ name }) => name).forEach(name => {
Copy link
Copy Markdown
Member

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Can you use reexport.bindings.forEach({ name } => { and avoid an unnecessary loop? Also on lines 186 and 200

for (const data of metadata.local.values()) {
for (const name of data.names) {
const names = data.bindings.map(({ name }) => name);
for (const name of names) {
Copy link
Copy Markdown
Member

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Here you can avoid looping twice:

for (const { name } of data.bindings) {

const declaration = child.get("declaration");
if (

if (declaration.isIdentifier()) {
Copy link
Copy Markdown
Member

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Aren't identifiers normal expressions? e.g., what is the difference between

var a = 2;
export default a;

and

export default 2;

?

Copy link
Copy Markdown
Member Author

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

There is no difference when it comes to the functionality.

The containing function here (getLocalExportMetadata) gathers export names for local bindings so every future assignment to those local bindings can be transformed into a live reference.

You can note that this function here expects only a FunctionDeclaration, ClassDeclarations and now Identifiers in position of default export. Everything else in this position gets splitted before by nameAnonymousExports into

var *local* = expression
export { *local* as default }

I omit this splitting for local bindings here to produce a nicer output, if I'd keep splitting them we would end up with things like:

var _default = function () { /* some class */ }
var _default2 = _default
module.exports = _default2

export default function(api, options) {
const { loose } = options;
const { loose, assumePure: _assumePure } = options;
const assumePure = typeof _assumePure === "boolean" ? _assumePure : !!loose;
Copy link
Copy Markdown
Member

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

I'd prefer this to throw if assumePure is specified and it is neither a boolean nor null/undefined.

Copy link
Copy Markdown
Member Author

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Gonna go with throwing on nulls too, it doesn't give any extra meaning for the option, and one can just skip providing it (thus making it undefined). If you feel we should allow null here and treat is as undefined, please raise your concern.

path.parentPath.insertAfter(nodes);
} else {
path.insertAfter(nodes);
}
Copy link
Copy Markdown
Member

@nicolo-ribaudo nicolo-ribaudo Jan 1, 2018

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

You can just use path.insertAfter(nodes), thanks to #7040

if (isClassExpression || !path.node.id) {
nameFunction(path);
ref = path.scope.generateUidIdentifier("class");
ref = path.node.id || path.scope.generateUidIdentifier("class");
Copy link
Copy Markdown
Member

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

You should always generate a new identifier, otherwise it won't work in cases like https://babeljs.io/repl/build/6407/#?babili=false&browsers=&build=&builtIns=false&code_lz=DYUwLgBAghC8ECYDcAoFAzAdgCgMbAEMBnI6CAbxQgiLALAEtcIAHAJwHsW5FUBfAJRIgA&debug=false&circleciRepo=&evaluate=false&lineWrap=false&presets=stage-0&prettier=false&targets=&version=7.0.0-beta.36%2Bpr.6963 (A gets declared twice).

You can do something like

ref = path.scope.generateUidIdentifier(path.node.id ? path.node.id.name : "class");

Copy link
Copy Markdown
Member Author

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Good catch. Need to dig into this after work, as I have a feeling that it will break inlining ClassExpressions into a class IIFE here, so I will have to check this and probably fix.

Copy link
Copy Markdown
Member Author

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Made a quick test and as suspected - it breaks some cases. Maybe you would have a possible solution/approach in mind to fix this.

The problem is that this:

() => class Bar {
  method(){}
  static fn = () => 'static';
};

gets transformed into with class-properties transform

() => {
  var _Bar;
  return _Bar =
  /*#__PURE__*/
  (() => {
    let _Bar = class Bar {
      method() {}
    };
    _Bar.fn = () => 'static';
    return _Bar;
  })();
}

which gets later transformed with transform-classes into:

() => {
  var _Bar;
  return _Bar =
  /*#__PURE__*/
  (() => {
    function Bar() {}
    var _proto = Bar.prototype;
    _proto.method = function method() {};
    _Bar.fn = () => 'static';
    return _Bar;
  })();
};

The problem lies in the fact that the mentioned inlining doesn't know that the local class ID is other than that class ID itself.

What comes to my mind is to:

  • retrieve this ID - we know for sure in this place that the parentStatement (the one that is going to be replaced) is a VariableDeclaration containing the correct ID
  • rename references to the old ID (constructor's ID) with the retrieved ID (Im not sure how to do this right now, as I'd like to update identifiers in array of nodes and not a path)
  • actually do not remove parentStatement, but just replace it's init with a constructor (from the nodes that we want to insert here), to keep a local ID and a class proper name

Copy link
Copy Markdown
Member

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Probably the right fix is avoiding adding var [name] for class expressions.

e.g.
https://babeljs.io/repl/build/6407/#?babili=false&browsers=&build=&builtIns=false&code_lz=BQYwNghgzlAECCsDeAoWsoBcKYJYlgDMB7Y2AXlgCYBuFAXwEog&debug=false&circleciRepo=&evaluate=false&lineWrap=false&presets=stage-0&prettier=false&targets=&version=7.0.0-beta.36%2Bpr.6963

Input:

(class A {
  static foo = 2;
})

Expected output

/*#__PURE__*/
(() => {
  let A = class A {};
  Object.defineProperty(A, "foo", {
    configurable: true,
    enumerable: true,
    writable: true,
    value: 2
  });
  return A;
})();

// Which then becomes

/*#__PURE__*/
(function () {
  var A = function A() {
    _classCallCheck(this, A);
  };

  Object.defineProperty(A, "foo", {
    configurable: true,
    enumerable: true,
    writable: true,
    value: 2
  });
  return A;
})();

Actual output

var A;

A =
/*#__PURE__*/
(() => {
  let A = class A {};
  Object.defineProperty(A, "foo", {
    configurable: true,
    enumerable: true,
    writable: true,
    value: 2
  });
  return A;
})();

// Which then becomes

var A;

A =
/*#__PURE__*/
function () {
  var A = function A() {
    _classCallCheck(this, A);
  };

  Object.defineProperty(A, "foo", {
    configurable: true,
    enumerable: true,
    writable: true,
    value: 2
  });
  return A;
}();

@Andarist
Copy link
Copy Markdown
Member Author

Andarist commented Jan 2, 2018

Are the changes to the module transformer required by the split-export-declaration helper? If not, I'd prefer them to be moved to another PR, to keep the changes simpler.

Somewhat, with the changes it produces nicer output without extra temporary variables.

Nit: can you use a default export? It seems that most helpers do that.

Sure, at first I thought this module might contain more than a single export, but it didnt come to that. Gonna change in a minute.

@Andarist Andarist force-pushed the static-class-properties-iife branch 2 times, most recently from cb4efac to c17aa0c Compare January 3, 2018 16:08
@Andarist
Copy link
Copy Markdown
Member Author

Andarist commented Jan 3, 2018

@nicolo-ribaudo Fixed discussed issue by omitting inner var for ClassExpressions and converting them into ClassDeclarations (can be certain about this conversion because we are always inside a class IIFE by this time) + Im generating outer uid for outer ref (as suggested).

Probably gonna add some test or 2 to be sure that I won't shadow existing bindings with freshly inserted one, this is somewhat tested already, but want to add a more focused test for this + maybe an exec test.

Anyway - I think this is ready for another round of code review.

Copy link
Copy Markdown
Member

@nicolo-ribaudo nicolo-ribaudo left a comment

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

A part from a thing I don't understand, LGTM 👍

if (isClassExpression) {
const outerRef = path.scope.generateUidIdentifier(ref.name);
path.scope.push({ id: outerRef });
path.replaceWith(t.assignmentExpression("=", outerRef, path.node));
Copy link
Copy Markdown
Member

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Q: What is outer ref needed for? It doesn't conflict now so it works, but I still don't understand what it is for.

Copy link
Copy Markdown
Member Author

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Im pretty confident it's not needed in 100% cases, it was here before the PR - didnt want to optimize the output more than needed. Gonna revisit this in followup PR.

Is this the only part that you do not understand? Or is something else needing a clarification too?

Copy link
Copy Markdown
Member

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

I think it was needed because static properties where defined after the iife: https://babeljs.io/repl/build/master#?babili=false&browsers=&build=&builtIns=false&code_lz=BQYwNghgzlAECCsDeAoWsoBcKYJYlgDMB7Y2AXlgCYBuNWAWwFNMALYgE2AEpkBfFH240gA&debug=false&circleciRepo=&evaluate=false&fileSize=false&lineWrap=false&presets=es2015%2Cstage-0%2Ctypescript&prettier=false&targets=&version=7.0.0-beta.36 (_class is used in Object.defineProperty)

Is this the only part that you do not understand? Or is something else needing a clarification too?

Thank you, the other parts are clear enough 😊

@Andarist
Copy link
Copy Markdown
Member Author

Andarist commented Jan 6, 2018

@nicolo-ribaudo I think you were right, removed creating outerRef altogether. Tests seem to be ok - you can check out the latest commit alone. I guess I'm still a little bit too scared about changing existing codebase too much 😄

@Andarist
Copy link
Copy Markdown
Member Author

Andarist commented Jan 7, 2018

@loganfsmyth @existentialism @xtuc would love any of yours feedback on this before merging :)

@Andarist Andarist force-pushed the static-class-properties-iife branch from 8922dd0 to 2b959c0 Compare January 27, 2018 18:06
@kzc
Copy link
Copy Markdown

kzc commented Jan 29, 2018

@Andarist Can you confirm with this PR to have unreferenced classes with static class properties with side effects dropped the following must be specified?

  "plugins": [
    ["transform-classes", { "assumePure": true }],
  ]

By default such otherwise unreferenced classes with static properties with side effects will be retained, correct?

@Andarist
Copy link
Copy Markdown
Member Author

@kzc it's a little bit more complicated that this. I try to detect if the class is pure and if I it is I annotate it no matter what the value of assumePure is, this is done here. isPure check would generally be ok to use alone (not considering convoluted cases) only if not inheritance - having a super class means that it cannot be treated as pure automatically (this could be optimized a little bit - but most commonly super declarations will be in different file, so whatever). That's why I had to introduce an additional option of assumePure.

When left unspecified it uses an "auto" value - false in default mode and true in loose mode. It can be specified explicitly to enable/disable annotating all classes (in default/loose modes respectively).

This means that by default when using loose mode every class will get PURE annotated, which is potentially unsafe - but I consider this a safe default for loose mode.

WDYT about used approach?

@kzc
Copy link
Copy Markdown

kzc commented Jan 29, 2018

I personally think all down-leveled classes should be pure by default regardless of loose or strict mode. But I'm biased. Should get others' opinions on this. Regardless of the Babel default, as long as there is a way to override it to always emit pure classes regardless of static initializer side effects, that would be fine.

Rationale for pure classes by default:

microsoft/TypeScript#13721 (comment)

@Andarist
Copy link
Copy Markdown
Member Author

This linked rationale lines up perfectly with my reasoning (I've also extended the same reasoning to things other than just classes in babel-plugin-annotate-pure-calls). I just ain't sure what is others opinion and I've went with the safer option by default for now. Happy to change it, I'd like to reach an agreement on this though with the rest of the team.

@kzc
Copy link
Copy Markdown

kzc commented Jan 30, 2018

@Andarist If not pure annotated by default, what would a .babelrc have to look like to enable pure annotations for classes with static initializers with side effects? I am not well versed in Babel.

@nicolo-ribaudo
Copy link
Copy Markdown
Member

IIUC, it would look like this:

{
  "plugins": [
    ["@babel/plugin-proposal-class-properties", { "assumePure": true }]
  ]
}

@Andarist
Copy link
Copy Markdown
Member Author

Yeah, exactly - although I feel that we need to introduce option passing through presets to underlaying plugins.

@Andarist Andarist force-pushed the static-class-properties-iife branch from 2b959c0 to 5dd49ed Compare January 30, 2018 17:09
? t.exportNamedDeclaration(null, [
t.exportSpecifier(t.cloneNode(id), t.identifier("default")),
])
: t.exportDefaultDeclaration(t.cloneNode(id));
Copy link
Copy Markdown
Member

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Even if it doesn't have an ID, cyclic dependencies can still rely on it being hoisted. It's never safe to do export default foo; in this case.

Copy link
Copy Markdown
Member Author

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

could you back it up with a sample code? not sure if I understand how the problem can be created

import { types as t } from "@babel/core";
import annotateAsPure from "@babel/helper-annotate-as-pure";

const classIIFEs = new WeakMap();
Copy link
Copy Markdown
Member

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

I generally haven't been super comfortable with things that rely on being singletons like this because it relies on them being the same version and npm flattening things. Usually we've relied on flattening for better performance, but making flattening affect the actual transform output worries me a lot.

Copy link
Copy Markdown
Member Author

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

good point, but without merging both class transforms (which I think @hzoo proposed numerous times) i have no idea how to achieve the desired goal of "pulling" stuff into a single IIFE here

Copy link
Copy Markdown
Member Author

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

@loganfsmyth do you have an idea how I could tackle this one differently?

The only thing that comes to my mind is to wrap a class with IIFE in class-properties transform and hardcode some detection logic in transform-classes to check if it should create IIFE or if it should just inline output nodes in place (when it would know that it's already in the IIFE).

It would create indirect dependency on what class-properties does, but even if the detection logic fails it would just create an extra IIFE (so the output still would be valid, just not "optimized").

@quantizor
Copy link
Copy Markdown

Hey @Andarist do you need help getting this PR updated? Would love to see it land.

@nicolo-ribaudo
Copy link
Copy Markdown
Member

This is probably blocked until we merge the base class transform with the other classes plugins. I'll probably do that after we have finished adding all the new class features.

@quantizor
Copy link
Copy Markdown

@nicolo-ribaudo is there a tracking ticket / PR I can subscribe to? Thanks!

@nicolo-ribaudo
Copy link
Copy Markdown
Member

I suggest just watching this PR

Sign up for free to join this conversation on GitHub. Already have an account? Sign in to comment

Labels

area: perf PR: Polish 💅 A type of pull request used for our changelog categories

Projects

None yet

Development

Successfully merging this pull request may close these issues.

[static class properties] pull them into compiled closure Unnecessary variable injected by class-properties plugin

7 participants