babel: Optional Chaining does not propagate receiver when parenthesized

Bug Report

Current Behavior

Optional Calls allows you to parenthesize the expression to break the optional-ness of the chain. For instance, (foo?.bar).baz requires that an object be returned to access .baz. This also applies to call expressions: (foo?.bar)().

The problem is, that (foo?.bar)() must propagate foo as the this receiver to the call expression. We currently do not do this.

Input Code

repl

const foo = { bar() { return this } };

console.assert(foo === (foo?.bar)());

Expected behavior/code The foo object should be used as the receiver during the foo.bar method call.

Babel Configuration (babel.config.js, .babelrc, package.json#babel, cli command, .eslintrc)

  • Filename: babel.config.js
{
  "plugins": [ "@babel/plugin-proposal-optional-chaining" ]
}

Possible Solution We need to detect parenthesized call expressions, and use .call to properly propagate the receiver.

Additional context/Screenshots

This is likely more difficult good-first-issue. I’m still prototyping a fix to see whether we should recommend it to a first time contributor.

About this issue

  • Original URL
  • State: closed
  • Created 5 years ago
  • Comments: 19 (9 by maintainers)

Most upvoted comments

However the semantic of (foo?.bar)() is changed: if foo or foo?.bar is nullish, (foo?.bar)() must throw undefined is not a function.

I think it’s easiest if we transform to:

foo == null ? (void 0)() : foo.bar()

Doing a bind is super expensive, and using .call is moderately expensive.

@iansu

(foo?.bar)();
// →
(foo == null ? void 0 : foo.bar.bind(foo))();

is equivalent to

foo == null ? (void 0)() : foo.bar()

if you apply the call expression on the conditional branches.

You can construct (void 0)() from void 0 (scope.buildUndefinedNode()) via t.callExpression.

@iansu I come up with a possible solution inspired by @jridgewell 's tips. Let’s see if we can sort it out.

Brief summary of optional-chaining plugin The optional-chaining transform plugin can be roughly divided into two parts: i) The first part is to collect an array of to-be-processed AST nodes (optionals) and determine the AST node that we want to transform (replacementPath). ii) The second part is to apply transforms to the optionals and replace replacementPath by the de-sugared member expressions or call expressions.

Justin’s approach @jridgewell 's approach is to reuse the logic that we choose parentPath to be the replacementPath, that said,

foo?.bar;
// →
foo == null ? void 0 : foo.bar;
// replacementPath is `foo?.bar`, an optional member expression.

delete foo?.bar;
// →
foo == null ? true : delete foo.bar;
// replacementPath is `delete foo?.bar`, the **parentPath** of 
// the optional member expression`foo?.bar`

so we can imagine that

(foo?.bar)();
// →
foo == null ? void 0 : foo.bar();
// and the replacementPath is also the **parentPath** of
// the optional member expression`foo?.bar`

In this case foo is correctly propagated to be the this value of foo.bar() member expression. However the semantic of (foo?.bar)() is changed: if foo or foo?.bar is nullish, (foo?.bar)() must throw undefined is not a function. However here we have replaced the un-optional call expression (foo?.bar)() by optional chains which returns undefined when foo or foo?.bar is nullish.

My approach Here I propose we use .bind to pass the this value and preserve the non-optional call expression. In the example above

(foo?.bar)();
// →
(foo == null ? void 0 : foo.bar.bind(foo))();

So we still 1) preserve the call expression out of the optional member foo?.bar 2) pass this value foo to the replacement node foo.bar via function#bind.

As I said before the plugin can be divided into two parts. In this approach we only need focus on how replacement is generated.

https://github.com/babel/babel/blob/3960f4de647e3c81b3c0d3020de8ef156c188944/packages/babel-plugin-proposal-optional-chaining/src/index.js#L121

we pass replacement.node (which is foo.bar in the above example) to be the non-nullish conditional alternate. We can check if the replacementPath is parenthesized and its parent path is a call expression

let replacement = replacementPath.node;
if (
  replacementPath.isMemberExpression() &&
  replacementPath.node.extra?.parenthesized &&
  replacementPath.parentPath.isCallExpression()
) {
  // we have detected the pattern `(optional?.member)(any, arguments)`
}

And it is detected we can append .bind(foo) to foo.bar:

replacement = t.callExpression(
  t.memberExpression(replacement, t.identifier("bind")),
  [t.cloneNode(replacementPath.get("object").node)],
);

where .get("object") extracts foo from foo.bar and foo.bar.bind is constructed from t.memberExpression.

@jridgewell Please let me know if I miss some semantic differences between function#bind and function#call, thank you!

@sidntrivedi012 I am still working on this. I’ve written some failing tests to cover this case. I’m just working on actually fixing it. I don’t have a ton of experience working with Babel’s AST so it’s taking me a little longer than I hoped but I am still trying to work through it.

@jakewilson I am still working on this. I just haven’t had much time over the holidays. I’ll pick it up again this week.

Hi. I will try to get a PR up for this today. Apologies for the delays.

I’m interested in working on this issue if no one else is.