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

optimize optional chain when expression will be cast to boolean #12291

Conversation

JLHwung
Copy link
Contributor

@JLHwung JLHwung commented Oct 30, 2020

Q                       A
Fixed Issues? Fixes #10004, in a spec-compliant way
Tests Added + Pass? Yes
License MIT

This PR is inspired from #10004 but fixes the violation to spec.

You can see here the output difference

Input
function summary(a) {
  if (a?.b) {
    const { b } = a;
    if (b.c?.d && b.d?.e) {
      return b.c.d + b.d.e;
    }
  }
}
Output (before this PR)
function summary(a) {
  if (a === null || a === void 0 ? void 0 : a.b) {
    var _b$c, _b$d;

    const {
      b
    } = a;

    if (((_b$c = b.c) === null || _b$c === void 0 ? void 0 : _b$c.d) && ((_b$d = b.d) === null || _b$d === void 0 ? void 0 : _b$d.e)) {
      return b.c.d + b.d.e;
    }
  }
}
Terser output (before this PR): 168 Byte
function summary(d){if(null==d?void 0:d.b){var i,n;const{b:o}=d;if((null===(i=o.c)||void 0===i?void 0:i.d)&&(null===(n=o.d)||void 0===n?void 0:n.e))return o.c.d+o.d.e}}
Output (after this PR)
function summary(a) {
  if (a !== null && a !== void 0 && a.b) {
    var _b$c, _b$d;

    const {
      b
    } = a;

    if ((_b$c = b.c) !== null && _b$c !== void 0 && _b$c.d && (_b$d = b.d) !== null && _b$d !== void 0 && _b$d.e) {
      return b.c.d + b.d.e;
    }
  }
}
Terser output (after this PR): 146 Byte
function summary(n){if(null!=n&&n.b){var d,l;const{b:u}=n;if(null!==(d=u.c)&&void 0!==d&&d.d&&null!==(l=u.d)&&void 0!==l&&l.e)return u.c.d+u.d.e}}

Todo:

  • copy the code to packages/babel-helper-member-expression-to-functions/src/index.js
  • copy the tests to non-loose mode

@JLHwung JLHwung added PR: Polish 💅 A type of pull request used for our changelog categories Spec: Optional Chaining labels Oct 30, 2020
@babel-bot
Copy link
Collaborator

babel-bot commented Oct 30, 2020

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

@codesandbox-ci
Copy link

codesandbox-ci bot commented Oct 30, 2020

This pull request is automatically built and testable in CodeSandbox.

To see build info of the built libraries, click here or the icon next to each commit SHA.

Latest deployment of this branch, based on commit 9b38e9c:

Sandbox Source
babel-repl-custom-plugin Configuration
babel-plugin-multi-config Configuration

@JLHwung JLHwung force-pushed the optimize-optional-chaining-on-cast-to-boolean branch 2 times, most recently from 3f3a41c to 645910b Compare November 2, 2020 17:13
@JLHwung JLHwung marked this pull request as ready for review November 2, 2020 17:13
@JLHwung JLHwung force-pushed the optimize-optional-chaining-on-cast-to-boolean branch from 645910b to d51cf4b Compare November 2, 2020 17:23
}, {
key: "testConditional",
value: function testConditional(o) {
return (o === null || o === void 0 ? void 0 : babelHelpers.classPrivateFieldLooseBase(o, _a)[_a].b)?.c.d ? true : false;
Copy link
Contributor Author

Choose a reason for hiding this comment

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

The output here is not ideal because willPathCastToBoolean stops at the optional chain ?.c.d. I think such cases are rare and we could iterate later.

@nicolo-ribaudo nicolo-ribaudo added PR: Output optimization 🔬 A type of pull request used for our changelog categories and removed PR: Polish 💅 A type of pull request used for our changelog categories labels Nov 3, 2020
@JLHwung JLHwung force-pushed the optimize-optional-chaining-on-cast-to-boolean branch from d89d30e to 1dce17e Compare November 12, 2020 15:46
];

describe("default parser options", () => {
test.each(positiveCases.map(x => [x]))(
Copy link
Member

Choose a reason for hiding this comment

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

What is the .map(x => [x]) needed for?

Copy link
Contributor Author

Choose a reason for hiding this comment

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

Ahh Jest will internally map 1-D array to 2-D: https://jestjs.io/docs/en/api#1-testeachtablename-fn-timeout

will update later.

@JLHwung JLHwung force-pushed the optimize-optional-chaining-on-cast-to-boolean branch from ebbbfbf to 9b38e9c Compare November 13, 2020 20:52
@JLHwung
Copy link
Contributor Author

JLHwung commented Nov 13, 2020

In the last commit we use rollup on @babel/plugin-proposal-optional-chaining and @babel/helper-member-expression-to-functions so they are still shipped in a single lib/index.js entries. This is to prevent downstream imports of src/utils.js introduced in this commit.

The local build results looks good to me

Rollup build: packages/babel-helper-member-expression-to-functions/lib/index.js
'use strict';

Object.defineProperty(exports, '__esModule', { value: true });

var t = require('@babel/types');

function willPathCastToBoolean(path) {
  const maybeWrapped = path;
  const {
    node,
    parentPath
  } = maybeWrapped;

  if (parentPath.isLogicalExpression()) {
    const {
      operator,
      right
    } = parentPath.node;

    if (operator === "&&" || operator === "||" || operator === "??" && node === right) {
      return willPathCastToBoolean(parentPath);
    }
  }

  if (parentPath.isSequenceExpression()) {
    const {
      expressions
    } = parentPath.node;

    if (expressions[expressions.length - 1] === node) {
      return willPathCastToBoolean(parentPath);
    } else {
      return true;
    }
  }

  return parentPath.isConditional({
    test: node
  }) || parentPath.isUnaryExpression({
    operator: "!"
  }) || parentPath.isLoop({
    test: node
  });
}

class AssignmentMemoiser {
  constructor() {
    this._map = new WeakMap();
  }

  has(key) {
    return this._map.has(key);
  }

  get(key) {
    if (!this.has(key)) return;

    const record = this._map.get(key);

    const {
      value
    } = record;
    record.count--;

    if (record.count === 0) {
      return t.assignmentExpression("=", value, key);
    }

    return value;
  }

  set(key, value, count) {
    return this._map.set(key, {
      count,
      value
    });
  }

}

function toNonOptional(path, base) {
  const {
    node
  } = path;

  if (path.isOptionalMemberExpression()) {
    return t.memberExpression(base, node.property, node.computed);
  }

  if (path.isOptionalCallExpression()) {
    const callee = path.get("callee");

    if (path.node.optional && callee.isOptionalMemberExpression()) {
      const {
        object
      } = callee.node;
      const context = path.scope.maybeGenerateMemoised(object) || object;
      callee.get("object").replaceWith(t.assignmentExpression("=", context, object));
      return t.callExpression(t.memberExpression(base, t.identifier("call")), [context, ...node.arguments]);
    }

    return t.callExpression(base, node.arguments);
  }

  return path.node;
}

function isInDetachedTree(path) {
  while (path) {
    if (path.isProgram()) break;
    const {
      parentPath,
      container,
      listKey
    } = path;
    const parentNode = parentPath.node;

    if (listKey) {
      if (container !== parentNode[listKey]) return true;
    } else {
      if (container !== parentNode) return true;
    }

    path = parentPath;
  }

  return false;
}

const handle = {
  memoise() {},

  handle(member) {
    const {
      node,
      parent,
      parentPath,
      scope
    } = member;

    if (member.isOptionalMemberExpression()) {
      if (isInDetachedTree(member)) return;
      const endPath = member.find(({
        node,
        parent,
        parentPath
      }) => {
        if (parentPath.isOptionalMemberExpression()) {
          return parent.optional || parent.object !== node;
        }

        if (parentPath.isOptionalCallExpression()) {
          return node !== member.node && parent.optional || parent.callee !== node;
        }

        return true;
      });

      if (scope.path.isPattern()) {
        endPath.replaceWith(t.callExpression(t.arrowFunctionExpression([], endPath.node), []));
        return;
      }

      const willEndPathCastToBoolean = willPathCastToBoolean(endPath);
      const rootParentPath = endPath.parentPath;

      if (rootParentPath.isUpdateExpression({
        argument: node
      }) || rootParentPath.isAssignmentExpression({
        left: node
      })) {
        throw member.buildCodeFrameError(`can't handle assignment`);
      }

      const isDeleteOperation = rootParentPath.isUnaryExpression({
        operator: "delete"
      });

      if (isDeleteOperation && endPath.isOptionalMemberExpression() && endPath.get("property").isPrivateName()) {
        throw member.buildCodeFrameError(`can't delete a private class element`);
      }

      let startingOptional = member;

      for (;;) {
        if (startingOptional.isOptionalMemberExpression()) {
          if (startingOptional.node.optional) break;
          startingOptional = startingOptional.get("object");
          continue;
        } else if (startingOptional.isOptionalCallExpression()) {
          if (startingOptional.node.optional) break;
          startingOptional = startingOptional.get("callee");
          continue;
        }

        throw new Error(`Internal error: unexpected ${startingOptional.node.type}`);
      }

      const startingProp = startingOptional.isOptionalMemberExpression() ? "object" : "callee";
      const startingNode = startingOptional.node[startingProp];
      const baseNeedsMemoised = scope.maybeGenerateMemoised(startingNode);
      const baseRef = baseNeedsMemoised != null ? baseNeedsMemoised : startingNode;
      const parentIsOptionalCall = parentPath.isOptionalCallExpression({
        callee: node
      });
      const parentIsCall = parentPath.isCallExpression({
        callee: node
      });
      startingOptional.replaceWith(toNonOptional(startingOptional, baseRef));

      if (parentIsOptionalCall) {
        if (parent.optional) {
          parentPath.replaceWith(this.optionalCall(member, parent.arguments));
        } else {
          parentPath.replaceWith(this.call(member, parent.arguments));
        }
      } else if (parentIsCall) {
        member.replaceWith(this.boundGet(member));
      } else {
        member.replaceWith(this.get(member));
      }

      let regular = member.node;

      for (let current = member; current !== endPath;) {
        const {
          parentPath
        } = current;

        if (parentPath === endPath && parentIsOptionalCall && parent.optional) {
          regular = parentPath.node;
          break;
        }

        regular = toNonOptional(parentPath, regular);
        current = parentPath;
      }

      let context;
      const endParentPath = endPath.parentPath;

      if (t.isMemberExpression(regular) && endParentPath.isOptionalCallExpression({
        callee: endPath.node,
        optional: true
      })) {
        const {
          object
        } = regular;
        context = member.scope.maybeGenerateMemoised(object);

        if (context) {
          regular.object = t.assignmentExpression("=", context, object);
        }
      }

      let replacementPath = endPath;

      if (isDeleteOperation) {
        replacementPath = endParentPath;
        regular = endParentPath.node;
      }

      if (willEndPathCastToBoolean) {
        const nonNullishCheck = t.logicalExpression("&&", t.binaryExpression("!==", baseNeedsMemoised ? t.assignmentExpression("=", t.cloneNode(baseRef), t.cloneNode(startingNode)) : t.cloneNode(baseRef), t.nullLiteral()), t.binaryExpression("!==", t.cloneNode(baseRef), scope.buildUndefinedNode()));
        replacementPath.replaceWith(t.logicalExpression("&&", nonNullishCheck, regular));
      } else {
        const nullishCheck = t.logicalExpression("||", t.binaryExpression("===", baseNeedsMemoised ? t.assignmentExpression("=", t.cloneNode(baseRef), t.cloneNode(startingNode)) : t.cloneNode(baseRef), t.nullLiteral()), t.binaryExpression("===", t.cloneNode(baseRef), scope.buildUndefinedNode()));
        replacementPath.replaceWith(t.conditionalExpression(nullishCheck, isDeleteOperation ? t.booleanLiteral(true) : scope.buildUndefinedNode(), regular));
      }

      if (context) {
        const endParent = endParentPath.node;
        endParentPath.replaceWith(t.optionalCallExpression(t.optionalMemberExpression(endParent.callee, t.identifier("call"), false, true), [t.cloneNode(context), ...endParent.arguments], false));
      }

      return;
    }

    if (parentPath.isUpdateExpression({
      argument: node
    })) {
      if (this.simpleSet) {
        member.replaceWith(this.simpleSet(member));
        return;
      }

      const {
        operator,
        prefix
      } = parent;
      this.memoise(member, 2);
      const value = t.binaryExpression(operator[0], t.unaryExpression("+", this.get(member)), t.numericLiteral(1));

      if (prefix) {
        parentPath.replaceWith(this.set(member, value));
      } else {
        const {
          scope
        } = member;
        const ref = scope.generateUidIdentifierBasedOnNode(node);
        scope.push({
          id: ref
        });
        value.left = t.assignmentExpression("=", t.cloneNode(ref), value.left);
        parentPath.replaceWith(t.sequenceExpression([this.set(member, value), t.cloneNode(ref)]));
      }

      return;
    }

    if (parentPath.isAssignmentExpression({
      left: node
    })) {
      if (this.simpleSet) {
        member.replaceWith(this.simpleSet(member));
        return;
      }

      const {
        operator,
        right: value
      } = parent;

      if (operator === "=") {
        parentPath.replaceWith(this.set(member, value));
      } else {
        const operatorTrunc = operator.slice(0, -1);

        if (t.LOGICAL_OPERATORS.includes(operatorTrunc)) {
          this.memoise(member, 1);
          parentPath.replaceWith(t.logicalExpression(operatorTrunc, this.get(member), this.set(member, value)));
        } else {
          this.memoise(member, 2);
          parentPath.replaceWith(this.set(member, t.binaryExpression(operatorTrunc, this.get(member), value)));
        }
      }

      return;
    }

    if (parentPath.isCallExpression({
      callee: node
    })) {
      parentPath.replaceWith(this.call(member, parent.arguments));
      return;
    }

    if (parentPath.isOptionalCallExpression({
      callee: node
    })) {
      if (scope.path.isPattern()) {
        parentPath.replaceWith(t.callExpression(t.arrowFunctionExpression([], parentPath.node), []));
        return;
      }

      parentPath.replaceWith(this.optionalCall(member, parent.arguments));
      return;
    }

    if (parentPath.isForXStatement({
      left: node
    }) || parentPath.isObjectProperty({
      value: node
    }) && parentPath.parentPath.isObjectPattern() || parentPath.isAssignmentPattern({
      left: node
    }) && parentPath.parentPath.isObjectProperty({
      value: parent
    }) && parentPath.parentPath.parentPath.isObjectPattern() || parentPath.isArrayPattern() || parentPath.isAssignmentPattern({
      left: node
    }) && parentPath.parentPath.isArrayPattern() || parentPath.isRestElement()) {
      member.replaceWith(this.destructureSet(member));
      return;
    }

    member.replaceWith(this.get(member));
  }

};
function memberExpressionToFunctions(path, visitor, state) {
  path.traverse(visitor, Object.assign({}, handle, state, {
    memoiser: new AssignmentMemoiser()
  }));
}

exports.default = memberExpressionToFunctions;

UNPKG

Rollup build: packages/babel-plugin-proposal-optional-chaining/lib/index.js
'use strict';

Object.defineProperty(exports, '__esModule', { value: true });

var helperPluginUtils = require('@babel/helper-plugin-utils');
var helperSkipTransparentExpressionWrappers = require('@babel/helper-skip-transparent-expression-wrappers');
var syntaxOptionalChaining = require('@babel/plugin-syntax-optional-chaining');
var core = require('@babel/core');

function _interopDefaultLegacy (e) { return e && typeof e === 'object' && 'default' in e ? e : { 'default': e }; }

var syntaxOptionalChaining__default = /*#__PURE__*/_interopDefaultLegacy(syntaxOptionalChaining);

function willPathCastToBoolean(path) {
  const maybeWrapped = findOutermostTransparentParent(path);
  const {
    node,
    parentPath
  } = maybeWrapped;

  if (parentPath.isLogicalExpression()) {
    const {
      operator,
      right
    } = parentPath.node;

    if (operator === "&&" || operator === "||" || operator === "??" && node === right) {
      return willPathCastToBoolean(parentPath);
    }
  }

  if (parentPath.isSequenceExpression()) {
    const {
      expressions
    } = parentPath.node;

    if (expressions[expressions.length - 1] === node) {
      return willPathCastToBoolean(parentPath);
    } else {
      return true;
    }
  }

  return parentPath.isConditional({
    test: node
  }) || parentPath.isUnaryExpression({
    operator: "!"
  }) || parentPath.isLoop({
    test: node
  });
}
function findOutermostTransparentParent(path) {
  let maybeWrapped = path;
  path.findParent(p => {
    if (!helperSkipTransparentExpressionWrappers.isTransparentExprWrapper(p)) return true;
    maybeWrapped = p;
  });
  return maybeWrapped;
}

const {
  ast
} = core.template.expression;
var index = helperPluginUtils.declare((api, options) => {
  api.assertVersion(7);
  const {
    loose = false
  } = options;

  function isSimpleMemberExpression(expression) {
    expression = helperSkipTransparentExpressionWrappers.skipTransparentExprWrappers(expression);
    return core.types.isIdentifier(expression) || core.types.isSuper(expression) || core.types.isMemberExpression(expression) && !expression.computed && isSimpleMemberExpression(expression.object);
  }

  function needsMemoize(path) {
    let optionalPath = path;
    const {
      scope
    } = path;

    while (optionalPath.isOptionalMemberExpression() || optionalPath.isOptionalCallExpression()) {
      const {
        node
      } = optionalPath;
      const childKey = optionalPath.isOptionalMemberExpression() ? "object" : "callee";
      const childPath = helperSkipTransparentExpressionWrappers.skipTransparentExprWrappers(optionalPath.get(childKey));

      if (node.optional) {
        return !scope.isStatic(childPath.node);
      }

      optionalPath = childPath;
    }
  }

  return {
    name: "proposal-optional-chaining",
    inherits: syntaxOptionalChaining__default['default'],
    visitor: {
      "OptionalCallExpression|OptionalMemberExpression"(path) {
        const {
          scope
        } = path;
        const maybeWrapped = findOutermostTransparentParent(path);
        const {
          parentPath
        } = maybeWrapped;
        const willReplacementCastToBoolean = willPathCastToBoolean(maybeWrapped);
        let isDeleteOperation = false;
        const parentIsCall = parentPath.isCallExpression({
          callee: maybeWrapped.node
        }) && path.isOptionalMemberExpression();
        const optionals = [];
        let optionalPath = path;

        if (scope.path.isPattern() && needsMemoize(optionalPath)) {
          path.replaceWith(core.template.ast`(() => ${path.node})()`);
          return;
        }

        while (optionalPath.isOptionalMemberExpression() || optionalPath.isOptionalCallExpression()) {
          const {
            node
          } = optionalPath;

          if (node.optional) {
            optionals.push(node);
          }

          if (optionalPath.isOptionalMemberExpression()) {
            optionalPath.node.type = "MemberExpression";
            optionalPath = helperSkipTransparentExpressionWrappers.skipTransparentExprWrappers(optionalPath.get("object"));
          } else if (optionalPath.isOptionalCallExpression()) {
            optionalPath.node.type = "CallExpression";
            optionalPath = helperSkipTransparentExpressionWrappers.skipTransparentExprWrappers(optionalPath.get("callee"));
          }
        }

        let replacementPath = path;

        if (parentPath.isUnaryExpression({
          operator: "delete"
        })) {
          replacementPath = parentPath;
          isDeleteOperation = true;
        }

        for (let i = optionals.length - 1; i >= 0; i--) {
          const node = optionals[i];
          const isCall = core.types.isCallExpression(node);
          const replaceKey = isCall ? "callee" : "object";
          const chainWithTypes = node[replaceKey];
          let chain = chainWithTypes;

          while (helperSkipTransparentExpressionWrappers.isTransparentExprWrapper(chain)) {
            chain = chain.expression;
          }

          let ref;
          let check;

          if (isCall && core.types.isIdentifier(chain, {
            name: "eval"
          })) {
            check = ref = chain;
            node[replaceKey] = core.types.sequenceExpression([core.types.numericLiteral(0), ref]);
          } else if (loose && isCall && isSimpleMemberExpression(chain)) {
            check = ref = chainWithTypes;
          } else {
            ref = scope.maybeGenerateMemoised(chain);

            if (ref) {
              check = core.types.assignmentExpression("=", core.types.cloneNode(ref), chainWithTypes);
              node[replaceKey] = ref;
            } else {
              check = ref = chainWithTypes;
            }
          }

          if (isCall && core.types.isMemberExpression(chain)) {
            if (loose && isSimpleMemberExpression(chain)) {
              node.callee = chainWithTypes;
            } else {
              const {
                object
              } = chain;
              let context = scope.maybeGenerateMemoised(object);

              if (context) {
                chain.object = core.types.assignmentExpression("=", context, object);
              } else if (core.types.isSuper(object)) {
                context = core.types.thisExpression();
              } else {
                context = object;
              }

              node.arguments.unshift(core.types.cloneNode(context));
              node.callee = core.types.memberExpression(node.callee, core.types.identifier("call"));
            }
          }

          let replacement = replacementPath.node;

          if (i === 0 && parentIsCall) {
            var _baseRef;

            const object = helperSkipTransparentExpressionWrappers.skipTransparentExprWrappers(replacementPath.get("object")).node;
            let baseRef;

            if (!loose || !isSimpleMemberExpression(object)) {
              baseRef = scope.maybeGenerateMemoised(object);

              if (baseRef) {
                replacement.object = core.types.assignmentExpression("=", baseRef, object);
              }
            }

            replacement = core.types.callExpression(core.types.memberExpression(replacement, core.types.identifier("bind")), [core.types.cloneNode((_baseRef = baseRef) != null ? _baseRef : object)]);
          }

          if (willReplacementCastToBoolean) {
            const nonNullishCheck = loose ? ast`${core.types.cloneNode(check)} != null` : ast`
            ${core.types.cloneNode(check)} !== null && ${core.types.cloneNode(ref)} !== void 0`;
            replacementPath.replaceWith(core.types.logicalExpression("&&", nonNullishCheck, replacement));
            replacementPath = helperSkipTransparentExpressionWrappers.skipTransparentExprWrappers(replacementPath.get("right"));
          } else {
            const nullishCheck = loose ? ast`${core.types.cloneNode(check)} == null` : ast`
            ${core.types.cloneNode(check)} === null || ${core.types.cloneNode(ref)} === void 0`;
            const returnValue = isDeleteOperation ? ast`true` : ast`void 0`;
            replacementPath.replaceWith(core.types.conditionalExpression(nullishCheck, returnValue, replacement));
            replacementPath = helperSkipTransparentExpressionWrappers.skipTransparentExprWrappers(replacementPath.get("alternate"));
          }
        }
      }

    }
  };
});

exports.default = index;

UNPKG

@nicolo-ribaudo nicolo-ribaudo merged commit a44151a into babel:main Nov 20, 2020
@nicolo-ribaudo nicolo-ribaudo deleted the optimize-optional-chaining-on-cast-to-boolean branch November 20, 2020 19:55
nicolo-ribaudo pushed a commit to nicolo-ribaudo/babel that referenced this pull request Dec 2, 2020
@github-actions github-actions bot added the outdated A closed issue/PR that is archived due to age. Recommended to make a new issue label Feb 20, 2021
@github-actions github-actions bot locked as resolved and limited conversation to collaborators Feb 20, 2021
Sign up for free to subscribe to this conversation on GitHub. Already have an account? Sign in.
Labels
outdated A closed issue/PR that is archived due to age. Recommended to make a new issue PR: Output optimization 🔬 A type of pull request used for our changelog categories Spec: Optional Chaining
Projects
None yet
Development

Successfully merging this pull request may close these issues.

Optional chaining optimization
4 participants