Skip to content
New issue

Have a question about this project? Sign up for a free GitHub account to open an issue and contact its maintainers and the community.

By clicking “Sign up for GitHub”, you agree to our terms of service and privacy statement. We’ll occasionally send you account related emails.

Already on GitHub? Sign in to your account

Lazily initialize and cache constant JSX elements #12967

Merged
merged 12 commits into from Mar 6, 2021
@@ -1,16 +1,16 @@
var _Contact;

const title = "Neem contact op";

function action() {
return _action.apply(this, arguments);
}

var _ref = /*#__PURE__*/React.createElement(Contact, {
title: title
});

function _action() {
_action = babelHelpers.asyncToGenerator(function* () {
return _ref;
return _Contact || (_Contact = /*#__PURE__*/React.createElement(Contact, {
Copy link
Member Author

Choose a reason for hiding this comment

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

In Babel 8 we could just use ||= here, and if someone targets an older browser it will be transformed by another different plugin.

title: title
}));
});
return _action.apply(this, arguments);
}
179 changes: 164 additions & 15 deletions packages/babel-plugin-transform-react-constant-elements/src/index.js
@@ -1,5 +1,5 @@
import { declare } from "@babel/helper-plugin-utils";
import { types as t } from "@babel/core";
import { types as t, template } from "@babel/core";

export default declare((api, options) => {
api.assertVersion(7);
Expand All @@ -15,9 +15,33 @@ export default declare((api, options) => {
);
}

const HOISTED = new WeakSet();
// Element -> Target scope
const HOISTED = new WeakMap();

const immutabilityVisitor = {
function declares(node: t.Identifier | t.JSXIdentifier, scope) {
if (
t.isJSXIdentifier(node, { name: "this" }) ||
t.isJSXIdentifier(node, { name: "arguments" }) ||
t.isJSXIdentifier(node, { name: "super" }) ||
t.isJSXIdentifier(node, { name: "new" })
) {
const { path } = scope;
return path.isFunctionParent() && !path.isArrowFunctionExpression();
}

return scope.hasOwnBinding(node.name);
}

function isHoistingScope({ path }) {
return path.isFunctionParent() || path.isLoop() || path.isProgram();
}

function getHoistingScope(scope) {
while (!isHoistingScope(scope)) scope = scope.parent;
return scope;
}

const analyzer = {
enter(path, state) {
const stop = () => {
state.isImmutable = false;
Expand All @@ -41,7 +65,8 @@ export default declare((api, options) => {
if (
path.isJSXIdentifier() ||
path.isIdentifier() ||
path.isJSXMemberExpression()
path.isJSXMemberExpression() ||
path.isJSXNamespacedName()
) {
return;
}
Expand Down Expand Up @@ -75,6 +100,90 @@ export default declare((api, options) => {
stop();
}
},

ReferencedIdentifier(path, state) {
const { node } = path;
let { scope } = path;

while (scope) {
// We cannot hoist outside of the previous hoisting target
// scope, so we return early and we don't update it.
if (scope === state.targetScope) return;

// If the scope declares this identifier (or we're at the function
// providing the lexical env binding), we can't hoist the var any
// higher.
if (declares(node, scope)) break;

scope = scope.parent;
}

state.targetScope = getHoistingScope(scope);
},

/*
See the discussion at https://github.com/babel/babel/pull/12967#discussion_r587948958
to uncomment this code.

ReferencedIdentifier(path, state) {
const { node } = path;
let { scope } = path;
let targetScope;

let isNestedScope = true;
let needsHoisting = true;
Copy link
Member

@jridgewell jridgewell Mar 5, 2021

Choose a reason for hiding this comment

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

I think we can actually reduce this code a bit:

diff --git a/packages/babel-plugin-transform-react-constant-elements/src/index.js b/packages/babel-plugin-transform-react-constant-elements/src/index.js
index 40d929e992..393ea016c6 100644
--- a/packages/babel-plugin-transform-react-constant-elements/src/index.js
+++ b/packages/babel-plugin-transform-react-constant-elements/src/index.js
@@ -31,6 +31,17 @@ export default declare((api, options) => {
     return scope.hasOwnBinding(node.name);
   }
 
+  function getHoistingScope(scope) {
+    for (;;) {
+      const { path } = scope;
+      if (path.isFunctionParent() || path.isLoop()) {
+        break;
+      }
+      scope = scope.parent;
+    }
+    return scope;
+  }
+
   const analyzer = {
     enter(path, state) {
       const stop = () => {
@@ -89,67 +100,25 @@ export default declare((api, options) => {
         stop();
       }
     },
-    "ReferencedIdentifier|ThisExpression"(path, state) {
+
+    ReferencedIdentifier(path, state) {
       const { node } = path;
       let { scope } = path;
-      let targetScope;
-
-      let isNestedScope = true;
-      let needsHoisting = true;
 
       while (scope) {
         // We cannot hoist outside of the previous hoisting target
         // scope, so we return early and we don't update it.
         if (scope === state.targetScope) return;
 
-        // When we hit the scope of our JSX element, we must start
-        // checking if they declare the binding of the current
-        // ReferencedIdentifier.
-        // We don't case about bindings declared in nested scopes,
-        // because the whole nested scope is hoisted alongside the
-        // JSX element so it doesn't impose any extra constraint.
-        if (scope === state.jsxScope) {
-          isNestedScope = false;
-        }
-
-        // If we are in an upper scope and hoisting to this scope has
-        // any benefit, we update the possible targetScope to the
-        // current one.
-        if (!isNestedScope && needsHoisting) {
-          targetScope = scope;
-        }
-
-        // When we start walking in upper scopes, avoid hoisting JSX
-        // elements until we hit a scope introduced by a function or
-        // loop.
-        // This is because hoisting from the inside to the outside
-        // of block or if statements doesn't give any performance
-        // benefit, and it just unnecessarily increases the code size.
-        if (scope === state.jsxScope) {
-          needsHoisting = false;
-        }
-        if (
-          !needsHoisting &&
-          (scope.path.isFunctionParent() || scope.path.isLoop())
-        ) {
-          needsHoisting = true;
-        }
-
-        // If the current scope declares the ReferencedIdentifier we
-        // are checking, we break out of this loop. There are two
-        // possible scenarios:
-        //  1. We are in a nested scope, this this declaration means
-        //     that this reference doesn't affect the target scope.
-        //     The targetScope variable is still undefined.
-        //  2. We are in an upper scope, so this declaration defines
-        //     a new hoisting constraint. The targetScope variable
-        //     refers to the current scope.
+        // If the scope declares this identifier (or we're at the function
+        // providing the lexical env binding), we can't hoist the var any
+        // higher.
         if (declares(node, scope)) break;
 
         scope = scope.parent;
       }
 
-      if (targetScope) state.targetScope = targetScope;
+      state.targetScope = getHoistingScope(scope);
     },
   };
 
@@ -179,23 +148,10 @@ export default declare((api, options) => {
           mutablePropsAllowed = allowMutablePropsOnTags.includes(elementName);
         }
 
-        // In order to avoid hoisting unnecessarily, we need to know which is
-        // the scope containing the current JSX element. If a parent of the
-        // current element has already been hoisted, we can consider its target
-        // scope as the base scope for the current element.
-        let jsxScope;
-        let current = path;
-        while (!jsxScope && current.parentPath.isJSXElement()) {
-          current = current.parentPath;
-          jsxScope = HOISTED.get(current.node);
-        }
-        jsxScope ??= path.scope;
-
         const state = {
           isImmutable: true,
           mutablePropsAllowed,
-          jsxScope,
-          targetScope: null,
+          targetScope: path.scope.getProgramParent(),
         };
 
         // Traverse all props passed to this element for immutability,
@@ -206,9 +162,21 @@ export default declare((api, options) => {
 
         // If we didn't find any hoisting constraint, we can hoist the current
         // helement to the program scope.
-        const targetScope = state.targetScope ?? path.scope.getProgramParent();
+        const { targetScope } = state;
         HOISTED.set(path.node, targetScope);
 
+        // In order to avoid hoisting unnecessarily, we need to know which is
+        // the scope containing the current JSX element. If a parent of the
+        // current element has already been hoisted, we can consider its target
+        // scope as the base scope for the current element.
+        let jsxScope;
+        let current = path;
+        while (!jsxScope && current.parentPath.isJSXElement()) {
+          current = current.parentPath;
+          jsxScope = HOISTED.get(current.node);
+        }
+        jsxScope ??= getHoistingScope(path.scope);
+
         // Only hoist if it would give us an advantage.
         if (targetScope === jsxScope) return;
 

Copy link
Member Author

Choose a reason for hiding this comment

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

This seems to unnecessarily cache some components:

function Component({ isLoading, text }) {
  if (isLoading) {
    return <p>Loading {text}</p>;
  }
  return <p>Loaded {text}</p>;
}
function Component({
  isLoading,
  text
}) {
  var _p;

  if (isLoading) {
    return _p || (_p = <p>Loading {text}</p>);
  }

  return <p>Loaded {text}</p>;
}

which is the reason I introduced the needsHoisting variable.

Copy link
Member

Choose a reason for hiding this comment

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

I edited the diff a few hours after initially comment, are you sure you're using the latest? You can see it running at https://astexplorer.net/#/gist/15ad6fa36177a1ba8f06a94c7dd28f8a/bedfd7d67d72dbb98b33be1e66f98785c3b18aac. This test case (and all the others) are passing for me.

Copy link
Member Author

Choose a reason for hiding this comment

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

I probably made some mistake while applying your diff.

I'm just commenting out my code instead of deleting it, since it will be needed if/when we do #12967 (comment) for cases like

function Component({ isLoading, text }) {
  return () => <p>Loading {text => text}</p>;
}

which with the simplified logic would become

function Component({
  isLoading,
  text
}) {
  return () => _p || (_p = <p>Loading {text => {
      var _p;

      return text;
    }}</p>);
}

Copy link
Member

Choose a reason for hiding this comment

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

I think we'll be able to handle that case with the simplified code, but let's wait for the followup PR.


while (scope) {
// We cannot hoist outside of the previous hoisting target
// scope, so we return early and we don't update it.
if (scope === state.targetScope) return;

// When we hit the scope of our JSX element, we must start
// checking if they declare the binding of the current
// ReferencedIdentifier.
// We don't case about bindings declared in nested scopes,
// because the whole nested scope is hoisted alongside the
// JSX element so it doesn't impose any extra constraint.
if (scope === state.jsxScope) {
isNestedScope = false;
}

// If we are in an upper scope and hoisting to this scope has
// any benefit, we update the possible targetScope to the
// current one.
if (!isNestedScope && needsHoisting) {
targetScope = scope;
}

// When we start walking in upper scopes, avoid hoisting JSX
// elements until we hit a scope introduced by a function or
// loop.
// This is because hoisting from the inside to the outside
// of block or if statements doesn't give any performance
// benefit, and it just unnecessarily increases the code size.
if (scope === state.jsxScope) {
needsHoisting = false;
}
if (!needsHoisting && isHoistingScope(scope)) {
needsHoisting = true;
}

// If the current scope declares the ReferencedIdentifier we
// are checking, we break out of this loop. There are two
// possible scenarios:
// 1. We are in a nested scope, this this declaration means
// that this reference doesn't affect the target scope.
// The targetScope variable is still undefined.
// 2. We are in an upper scope, so this declaration defines
// a new hoisting constraint. The targetScope variable
// refers to the current scope.
if (declares(node, scope)) break;

scope = scope.parent;
}

if (targetScope) state.targetScope = targetScope;
},*/
};

return {
Expand All @@ -83,30 +192,70 @@ export default declare((api, options) => {
visitor: {
JSXElement(path) {
if (HOISTED.has(path.node)) return;
HOISTED.add(path.node);
HOISTED.set(path.node, path.scope);

const state = { isImmutable: true };
const name = path.node.openingElement.name;

// This transform takes the option `allowMutablePropsOnTags`, which is an array
// of JSX tags to allow mutable props (such as objects, functions) on. Use sparingly
// and only on tags you know will never modify their own props.
let mutablePropsAllowed = false;
if (allowMutablePropsOnTags != null) {
// Get the element's name. If it's a member expression, we use the last part of the path.
// So the option ["FormattedMessage"] would match "Intl.FormattedMessage".
let namePath = path.get("openingElement.name");
while (namePath.isJSXMemberExpression()) {
namePath = namePath.get("property");
let lastSegment = name;
Copy link
Contributor

Choose a reason for hiding this comment

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

name can also be a JSXNamespacedName, which is currently not handled.

Can you add a test case like

function AComponent () {
  return <BComponent/>

  function BComponent () {
    return <n:CComponent />
  }
}

n:CComponent should be cached.

while (t.isJSXMemberExpression(lastSegment)) {
lastSegment = lastSegment.property;
}

const elementName = namePath.node.name;
state.mutablePropsAllowed =
allowMutablePropsOnTags.indexOf(elementName) > -1;
const elementName = lastSegment.name;
mutablePropsAllowed = allowMutablePropsOnTags.includes(elementName);
}

// Traverse all props passed to this element for immutability.
path.traverse(immutabilityVisitor, state);
const state = {
isImmutable: true,
mutablePropsAllowed,
targetScope: path.scope.getProgramParent(),
};

// Traverse all props passed to this element for immutability,
// and compute the target hoisting scope
path.traverse(analyzer, state);

if (!state.isImmutable) return;

const { targetScope } = state;
HOISTED.set(path.node, targetScope);

// In order to avoid hoisting unnecessarily, we need to know which is
// the scope containing the current JSX element. If a parent of the
// current element has already been hoisted, we can consider its target
// scope as the base scope for the current element.
let jsxScope;
let current = path;
while (!jsxScope && current.parentPath.isJSX()) {
current = current.parentPath;
jsxScope = HOISTED.get(current.node);
}
jsxScope ??= getHoistingScope(path.scope);

// Only hoist if it would give us an advantage.
if (targetScope === jsxScope) return;

const id = path.scope.generateUidBasedOnNode(name);
targetScope.push({ id: t.identifier(id) });

let replacement = template.expression.ast`
${t.identifier(id)} || (${t.identifier(id)} = ${path.node})
`;
if (
path.parentPath.isJSXElement() ||
path.parentPath.isJSXAttribute()
) {
replacement = t.jsxExpressionContainer(replacement);
}

if (state.isImmutable) path.hoist();
path.replaceWith(replacement);
},
},
};
Expand Down
@@ -0,0 +1,8 @@
function AComponent () {
const CComponent = () => <div/>
return <BComponent/>

function BComponent () {
return <CComponent/>
}
}
@@ -0,0 +1,13 @@
var _div;

function AComponent() {
var _CComponent;

const CComponent = () => _div || (_div = <div />);

return <BComponent />;

function BComponent() {
return _CComponent || (_CComponent = <CComponent />);
}
}
@@ -1,17 +1,15 @@
var _ref = <div>child</div>;
var _div, _div2;

const AppItem = () => {
return _ref;
return _div || (_div = <div>child</div>);
};

var _ref2 = <div>
<p>Parent</p>
<AppItem />
</div>;

export default class App extends React.Component {
render() {
return _ref2;
return _div2 || (_div2 = <div>
<p>Parent</p>
<AppItem />
</div>);
}

}
@@ -1,20 +1,19 @@
var _ref2 = <div>child</div>;

var _ref3 = <p>Parent</p>;
var _p, _div2;

(function () {
var _div;

class App extends React.Component {
render() {
return _ref;
return _div || (_div = <div>
{_p || (_p = <p>Parent</p>)}
<AppItem />
</div>);
}

}

const AppItem = () => {
return _ref2;
},
_ref = <div>
{_ref3}
<AppItem />
</div>;
return _div2 || (_div2 = <div>child</div>);
};
});
@@ -1,20 +1,18 @@
var _ref = <div>child</div>;

var _ref3 = <p>Parent</p>;
var _div, _p;

(function () {
var _div2;

const AppItem = () => {
return _ref;
return _div || (_div = <div>child</div>);
};

var _ref2 = <div>
{_ref3}
<AppItem />
</div>;

class App extends React.Component {
render() {
return _ref2;
return _div2 || (_div2 = <div>
{_p || (_p = <p>Parent</p>)}
<AppItem />
</div>);
}

}
Expand Down
@@ -1,16 +1,15 @@
var _div, _div2;

export default class App extends React.Component {
render() {
return _ref;
return _div || (_div = <div>
<p>Parent</p>
<AppItem />
</div>);
}

}

var _ref2 = <div>child</div>;

const AppItem = () => {
return _ref2;
},
_ref = <div>
<p>Parent</p>
<AppItem />
</div>;
return _div2 || (_div2 = <div>child</div>);
};
@@ -1,9 +1,9 @@
var _ref = <span />;
var _span;

var Foo = React.createClass({
render: function () {
return <div className={this.props.className}>
{_ref}
{_span || (_span = <span />)}
</div>;
}
});