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

Handle private access chained on an optional chain #11248

Merged
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Jump to
Jump to file
Failed to load files.
Diff view
Diff view
67 changes: 42 additions & 25 deletions packages/babel-helper-create-class-features-plugin/src/fields.js
Expand Up @@ -128,8 +128,12 @@ const privateNameVisitor = privateNameVisitorFactory({
const { privateNamesMap, redeclared } = this;
const { node, parentPath } = path;

if (!parentPath.isMemberExpression({ property: node })) return;

if (
!parentPath.isMemberExpression({ property: node }) &&
!parentPath.isOptionalMemberExpression({ property: node })
) {
return;
}
const { name } = node.id;
if (!privateNamesMap.has(name)) return;
if (redeclared && redeclared.includes(name)) return;
Expand Down Expand Up @@ -296,23 +300,43 @@ const privateNameHandlerSpec = {
// The first access (the get) should do the memo assignment.
this.memoise(member, 1);

return optimiseCall(this.get(member), this.receiver(member), args);
return optimiseCall(this.get(member), this.receiver(member), args, false);
},

optionalCall(member, args) {
this.memoise(member, 1);

return optimiseCall(this.get(member), this.receiver(member), args, true);
},
};

const privateNameHandlerLoose = {
handle(member) {
get(member) {
jridgewell marked this conversation as resolved.
Show resolved Hide resolved
const { privateNamesMap, file } = this;
const { object } = member.node;
const { name } = member.node.property.id;

member.replaceWith(
template.expression`BASE(REF, PROP)[PROP]`({
BASE: file.addHelper("classPrivateFieldLooseBase"),
REF: object,
PROP: privateNamesMap.get(name).id,
}),
);
return template.expression`BASE(REF, PROP)[PROP]`({
BASE: file.addHelper("classPrivateFieldLooseBase"),
REF: object,
PROP: privateNamesMap.get(name).id,
});
},

simpleSet(member) {
return this.get(member);
},

destructureSet(member) {
return this.get(member);
},

call(member, args) {
return t.callExpression(this.get(member), args);
},

optionalCall(member, args) {
return t.optionalCallExpression(this.get(member), args, true);
},
};

Expand All @@ -326,21 +350,14 @@ export function transformPrivateNamesUsage(
if (!privateNamesMap.size) return;

const body = path.get("body");
const handler = loose ? privateNameHandlerLoose : privateNameHandlerSpec;

if (loose) {
body.traverse(privateNameVisitor, {
privateNamesMap,
file: state,
...privateNameHandlerLoose,
});
} else {
memberExpressionToFunctions(body, privateNameVisitor, {
privateNamesMap,
classRef: ref,
file: state,
...privateNameHandlerSpec,
});
}
memberExpressionToFunctions(body, privateNameVisitor, {
privateNamesMap,
classRef: ref,
file: state,
...handler,
});
body.traverse(privateInVisitor, {
privateNamesMap,
classRef: ref,
Expand Down
230 changes: 227 additions & 3 deletions packages/babel-helper-member-expression-to-functions/src/index.js
Expand Up @@ -29,6 +29,55 @@ class AssignmentMemoiser {
}
}

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;
}

// Determines if the current path is in a detached tree. This can happen when
// we are iterating on a path, and replace an ancestor with a new node. Babel
// doesn't always stop traversing the old node tree, and that can cause
// inconsistencies.
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() {
// noop.
Expand All @@ -37,9 +86,175 @@ const handle = {
handle(member) {
const { node, parent, parentPath } = member;

if (member.isOptionalMemberExpression()) {
// Transforming optional chaining requires we replace ancestors.
if (isInDetachedTree(member)) return;

// We're looking for the end of _this_ optional chain, which is actually
// the "rightmost" property access of the chain. This is because
// everything up to that property access is "optional".
//
// Let's take the case of `FOO?.BAR.baz?.qux`, with `FOO?.BAR` being our
// member. The "end" to most users would be `qux` property access.
// Everything up to it could be skipped if it `FOO` were nullish. But
// actually, we can consider the `baz` access to be the end. So we're
// looking for the nearest optional chain that is `optional: true`.
const endPath = member.find(({ node, parent, parentPath }) => {
if (parentPath.isOptionalMemberExpression()) {
// We need to check `parent.object` since we could be inside the
// computed expression of a `bad?.[FOO?.BAR]`. In this case, the
// endPath is the `FOO?.BAR` member itself.
return parent.optional || parent.object !== node;
jridgewell marked this conversation as resolved.
Show resolved Hide resolved
}
if (parentPath.isOptionalCallExpression()) {
// Checking `parent.callee` since we could be in the arguments, eg
// `bad?.(FOO?.BAR)`.
// Also skip `FOO?.BAR` in `FOO?.BAR?.()` since we need to transform the optional call to ensure proper this
return (
// In FOO?.#BAR?.(), endPath points the optional call expression so we skip FOO?.#BAR
(node !== member.node && parent.optional) || parent.callee !== node
);
}
return true;
});

const rootParentPath = endPath.parentPath;
if (
rootParentPath.isUpdateExpression({ argument: node }) ||
rootParentPath.isAssignmentExpression({ left: node })
) {
throw member.buildCodeFrameError(`can't handle assignment`);
}
if (rootParentPath.isUnaryExpression({ operator: "delete" })) {
throw member.buildCodeFrameError(`can't handle delete`);
jridgewell marked this conversation as resolved.
Show resolved Hide resolved
}

// Now, we're looking for the start of this optional chain, which is
// optional to the left of this member.
//
// Let's take the case of `foo?.bar?.baz.QUX?.BAM`, with `QUX?.BAM` being
// our member. The "start" to most users would be `foo` object access.
// But actually, we can consider the `bar` access to be the start. So
// we're looking for the nearest optional chain that is `optional: true`,
// which is guaranteed to be somewhere in the object/callee tree.
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;
}
// prevent infinite loop: unreachable if the AST is well-formed
throw new Error(
`Internal error: unexpected ${startingOptional.node.type}`,
);
}
JLHwung marked this conversation as resolved.
Show resolved Hide resolved

const { scope } = member;
const startingProp = startingOptional.isOptionalMemberExpression()
? "object"
: "callee";
const startingNode = startingOptional.node[startingProp];
const baseNeedsMemoised = scope.maybeGenerateMemoised(startingNode);
const baseRef = baseNeedsMemoised ?? startingNode;

// Compute parentIsOptionalCall before `startingOptional` is replaced
// as `node` may refer to `startingOptional.node` before replaced.
const parentIsOptionalCall = parentPath.isOptionalCallExpression({
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 {
member.replaceWith(this.get(member));
}

let regular = member.node;
for (let current = member; current !== endPath; ) {
const { parentPath } = current;
// skip transforming `Foo.#BAR?.call(FOO)`
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);
}
}

endPath.replaceWith(
t.conditionalExpression(
t.logicalExpression(
"||",
t.binaryExpression(
"===",
baseNeedsMemoised
? t.assignmentExpression("=", baseRef, startingNode)
: baseRef,
t.nullLiteral(),
),
t.binaryExpression(
"===",
t.cloneNode(baseRef),
scope.buildUndefinedNode(),
),
),
scope.buildUndefinedNode(),
regular,
),
);

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

return;
}

// MEMBER++ -> _set(MEMBER, (_ref = (+_get(MEMBER))) + 1), _ref
// ++MEMBER -> _set(MEMBER, (+_get(MEMBER)) + 1)
if (parentPath.isUpdateExpression({ argument: node })) {
if (this.simpleSet) {
member.replaceWith(this.simpleSet(member));
return;
}

const { operator, prefix } = parent;

// Give the state handler a chance to memoise the member, since we'll
Expand Down Expand Up @@ -72,6 +287,11 @@ const handle = {
// MEMBER = VALUE -> _set(MEMBER, VALUE)
// MEMBER += VALUE -> _set(MEMBER, _get(MEMBER) + VALUE)
if (parentPath.isAssignmentExpression({ left: node })) {
if (this.simpleSet) {
member.replaceWith(this.simpleSet(member));
return;
}

const { operator, right } = parent;
let value = right;

Expand All @@ -92,11 +312,15 @@ const handle = {
return;
}

// MEMBER(ARGS) -> _call(MEMBER, ARGS)
// MEMBER(ARGS) -> _call(MEMBER, ARGS)
if (parentPath.isCallExpression({ callee: node })) {
const { arguments: args } = parent;
parentPath.replaceWith(this.call(member, parent.arguments));
return;
}

parentPath.replaceWith(this.call(member, args));
// MEMBER?.(ARGS) -> _optionalCall(MEMBER, ARGS)
if (parentPath.isOptionalCallExpression({ callee: node })) {
parentPath.replaceWith(this.optionalCall(member, parent.arguments));
return;
}

Expand Down
9 changes: 8 additions & 1 deletion packages/babel-helper-optimise-call-expression/src/index.js
@@ -1,6 +1,6 @@
import * as t from "@babel/types";

export default function(callee, thisNode, args) {
export default function(callee, thisNode, args, optional) {
if (
args.length === 1 &&
t.isSpreadElement(args[0]) &&
Expand All @@ -12,6 +12,13 @@ export default function(callee, thisNode, args) {
args[0].argument,
]);
} else {
if (optional) {
return t.optionalCallExpression(
t.optionalMemberExpression(callee, t.identifier("call"), false, true),
[thisNode, ...args],
false,
);
}
return t.callExpression(t.memberExpression(callee, t.identifier("call")), [
thisNode,
...args,
Expand Down
3 changes: 2 additions & 1 deletion packages/babel-helper-replace-supers/src/index.js
Expand Up @@ -158,6 +158,7 @@ const specHandlers = {
this._get(superMember, thisRefs),
t.cloneNode(thisRefs.this),
args,
false,
);
},
};
Expand Down Expand Up @@ -215,7 +216,7 @@ const looseHandlers = {
},

call(superMember, args) {
return optimiseCall(this.get(superMember), t.thisExpression(), args);
return optimiseCall(this.get(superMember), t.thisExpression(), args, false);
},
};

Expand Down