fix: Duplicate function call in variable destructuring#13711
Conversation
|
Build successful! You can test your changes in the REPL here: https://babeljs.io/repl/build/48579/ |
|
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 4be2624:
|
| let { x6: { w6: { a6, ...y6 } } } = z(); | ||
| let { x7: { e7, r7 }, q7: { w7: { a7, ...y7 } } } = z(); | ||
| let { x8, ...y8 } = z(); | ||
| let { x9: { w9: { a9, ...y9 } }, x10: { a10, ...y10 }, } = z(); |
There was a problem hiding this comment.
Thanks!
Can you add a test case with mixed arrays?
| let { x9: { w9: { a9, ...y9 } }, x10: { a10, ...y10 }, } = z(); | |
| let { x9: { w9: { a9, ...y9 } }, x10: { a10, ...y10 }, } = z(); | |
| let { x10: [{ w10, ...z10 }] } = z(); | |
| let { x11: [{ a11, b11 }, { c11, ...d11 }] } = z(); | |
| let { x12: [, { c12, ...d12 }] } = z(); |
There was a problem hiding this comment.
Just pushed up a commit. Thanks for the recommendation, it caught an edge case with the implementation.
There was a problem hiding this comment.
Can you add a new test case?
const { x: [...{ x, ...y }] } = z();A RestElement's argument can be a pattern so we have to recurse into it.
There was a problem hiding this comment.
Great point! I added the test case and confirmed the output looks as I'd expect.
There was a problem hiding this comment.
We can not return early when properties is falsy, instead we should invoke the recursive function on .argument. For example,
const { x: [...{ ...y }] } = z();
has only one binding identifier but doesAnyChildNodeHaveMoreThanOneProperty returns true.
There was a problem hiding this comment.
Hey @JLHwung - thanks for the the detailed case. I looked through https://astexplorer.net/ and believe I was able to diagnose the issue you called out with more robust test cases for array destructuring.
Happy to make any other adjustments. Thank you for the thorough review and prompt responses!
9a62ee3 to
4336cdb
Compare
| // since the RHS will not be duplicated | ||
| originalPath.node.id.properties.length > 1 && | ||
| doesAnyChildNodeHaveMoreThanOneProperty(originalPath.node.id) && | ||
| !t.isIdentifier(originalPath.node.init) |
There was a problem hiding this comment.
Unrelated to this PR: I think we should use originalPath.scope.isStatic(originalPath.node.init) here, because
- A
ThisExpressionis safe to reuse, e.g.
const { ...x } = this;- Even if
node.initis an identifier, it can not be reused if not a constant binding. e.g.
let z = { v: 1 };
function sideEffect() {
z = { v: 2 };
}
const { x = sideEffect(), ...y } = z;
y // { v: 1 }y becomes { v: 2 } after transformed because z can be modified in sideEffect().
These two issues will be fixed by scope.isStatic, they can be addressed in a subsequent PR.
| // If this is an AssignmentPattern, always return true else the RHS will be duplicated | ||
| if (innerNode.type === "AssignmentPattern" && innerNode.left) { | ||
| return true; | ||
| } |
There was a problem hiding this comment.
Similar to RestElement, we need to recurse into node.left.
innerNode ??= node.left
There was a problem hiding this comment.
So I originally added that but the output did not end up as I expected. Specifically the RHS was duplicated. I believe the way that we handle assignment patterns also duplicates the RHS.
See this diff that I branched off this branch for: https://github.com/dan-kez/babel/pull/1/files
There was a problem hiding this comment.
Oh I was meant to replace the whole "if" branch with innerNode ??= node.left, which is similar to how we handle the array / object patterns (note that in your posted branch it was changed to innerNode = innerNode.left). The innerNode is essentially the arguments of next recursive call, so the algorithm can be summarized as
function hasMoreThanOneBinding(node) {
if (node is ArrayPattern ) {
return true if node has more than one elements
else return hasMoreThanOneBinding(node's first element)
} else if (node is ObjectPattern) {
return true if node has more than one properties
else return hasMoreThanOneBinding(node's first property value)
} else if (node is AssignmentPattern) {
return hasMoreThanOneBinding(node's left)
} else if (node is RestElement) {
return hasMoreThanOneBinding(node's argument)
} else { // node is Identifier or MemberExpression
return true
}
}
There was a problem hiding this comment.
Ah I understand now. Apologies for all the back and forth here - I really appreciate you taking the time on this PR!
I've refactored the function to be more in line with you example. I did add one additional case ObjectProperty. This was because an ObjectPattern could have the first element a RestElement (for example const { x15: { ...y15 } } = z();). In this case node.properties[0].value would be undefined which would fall through the final else clause.
To be more specific I added an ObjectProperty case where I test on the presence of node?.value and then recurse on the given value.
If this is wrong please let me know - I'm happy to change it.
I also used optional chaining just in case I missed a case were node could be falsey (this was how I found the above issue when stepping through the test case). I am also happy to add an early return if node is undefined to return true or other cases that could be present in this LHS assignment.
| // ObjectPattern | ||
| } else if (node.properties) { | ||
| if (node.properties.length > 1) return true; | ||
| // Case of `const {} = {}`. This shouldn't be hit as |
There was a problem hiding this comment.
This branch is reachable because visitRestElements applies to rest elements in array patterns, too. E.g.
const { x: [ ...{} ] } = { x: [] }|
Thanks for the review @JLHwung !! I was following along with the CI steps and noticed [CI / E2E (create-react-app) (pull_request) was failing]https://github.com/babel/babel/runs/3495970358?check_suite_focus=true It looks like there is in issue in how we're invoking the build command incorrectly I'm not sure if this error is blocking to have this fixed merged in or how I can help fixing this error. |
|
The react e2e CI error has been fixed in #13724, you can rebase this PR on latest |
700c692 to
ab3b603
Compare
Perfect - thank you! Just rebased |
| return hasMoreThanOneBinding(node.argument); | ||
| } else { | ||
| // node is Identifier or MemberExpression | ||
| return true; |
There was a problem hiding this comment.
Q: Since this is a single identifier, shouldn't it return false? (it's one binding)
There was a problem hiding this comment.
It looks like this test case fails if I make this false
input:
const { x15: [...{ ...y15 }] } = z();
output:
const {} = z(),
y15 = babelHelpers.extends({}, z().x15);
There was a problem hiding this comment.
Ah I believe I have a fix. In the event node.argument is an Identifier then we can early return true and this these test cases work out.
There was a problem hiding this comment.
Actually, I think we don't need to return true for rest but the fix needs to live somewhere else.
In the const { x15: [...{ ...y15 }] } = z(); case, we don't need to cache z(): we can just skip the {} = z() one.
There was a problem hiding this comment.
Would it be ok to consider making this change in a separate PR as an optimization? My main goal for this is to mitigate duplicating the right hand assignment. I'd prefer avoiding doing a larger refactor of the greater plugin if possible.
| describe("hasMoreThanOneBinding", function () { | ||
| it.each([ | ||
| ["const { x: { ...y } } = z();", true], | ||
| ["let { x4: { ...y4 } } = z();", true], |
There was a problem hiding this comment.
It should return false, the source contains only one binding y4. Also we don't have to add number suffix since every snippet is parsed independently.
There was a problem hiding this comment.
So I think hasMoreThanOneBinding is an invalid name for the function. The end goal of this is to strictly stop the right hand side being duplicated
I am happy to rename this function or do something else but in the end the main goal is to mitigate the active bug.
| ["const [...[ ...y17 ]] = z();", true], | ||
| ["const [...{ ...y18 }] = z();", true], |
There was a problem hiding this comment.
These two cases are almost identical, we can remove one of them.
| } | ||
| describe("hasMoreThanOneBinding", function () { | ||
| it.each([ | ||
| ["const { x: { ...y } } = z();", true], |
| ["let { x12: [{ a12, b12 }, { c12, ...d12 }] } = z();", true], | ||
| ["let { x13: [, { c13, ...d13 }] } = z();", true], | ||
| ["const { x14: [...{ q14, ...y14 }] } = z();", true], | ||
| ["const { x15: [...{ ...y16 }] } = z();", true], |
| ["const [...[ ...y17 ]] = z();", true], | ||
| ["const [...{ ...y18 }] = z();", true], | ||
| ["const [...{ a19, ...y19 }] = z();", true], | ||
| ["const { x20: { ...y20 } = { } } = z();", true], |
| ["const [[ ...y23 ] = []] = z();", true], | ||
| ["const [{ ...y24 } = []] = z();", true], |
There was a problem hiding this comment.
Should return false and almost identical examples.
| ["const { x22: { q22, ...y22 } = {} } = z();", true], | ||
| ["const [[ ...y23 ] = []] = z();", true], | ||
| ["const [{ ...y24 } = []] = z();", true], | ||
| ["const { x25: [ ...y25 ] = []} = z();", true], |
| ["const [{ ...y24 } = []] = z();", true], | ||
| ["const { x25: [ ...y25 ] = []} = z();", true], | ||
| ["const { x26: [ q26, ...y26 ] = []} = z();", true], | ||
| ["const {x28: [,,{...y28}]} = z();", true], |
| ["const [,,{...x31}] = z();", true], | ||
| ["const { x32: { }, w32: { ...y32 } } = z();", true], | ||
| ["const [,,{}, {...q32}] = z();", true], | ||
| ["const { ...y33 } = z();", true], |
There was a problem hiding this comment.
These four examples also contains only one binding, and thus should return false.
| ["const {x29: [,,{q29, ...y29}]} = z();", true], | ||
| ["const [,,{y30, ...x30}] = z();", true], | ||
| ["const [,,{...x31}] = z();", true], | ||
| ["const { x32: { }, w32: { ...y32 } } = z();", true], |
There was a problem hiding this comment.
This is a very interesting case. It shows that we can't simply return true if node.properties > 1, since the property value may be an empty object pattern, or nested pattern.
We may need a new algorithm similar to
Essentially the hasMoreThanOneBinding should be equivalent to
Object.keys(getBindingIdentifiers(node)).length > 1
|
Hey @JLHwung & @nicolo-ribaudo I'd love to align on the implementation that you both would like to see to have this bug fixed. Right now I would say the acceptance criteria should likely be the following:
To accomplish the above I want to do the following
Would making both those changes be sufficient to see this PR merged? |
|
Since this PR fixes the bug in the generated code, I'm ok with just renaming |
|
Could you rebase and run |
|
Thanks for the prompt response @nicolo-ribaudo ! I updated the function name, added a comment, and also ran yarn dedupe. |
JLHwung
left a comment
There was a problem hiding this comment.
This PR already fixed several cases. We can iterate later on further issues.
|
Excellent! Thank you both!I took a look at CI and I'm not sure if there is something a maintainer / member needs to do to enable the tests to continue. |
|
@dan-kez All good! Merged. |
|
Awesome! Thank you @JLHwung - looking forward to incorporating this at work once a new version is cut. Really appreciate the thorough review from both of you! |
I am new to babel's code base but I believe I have a fix here for an issue where the RHS of an assignment will be called duplicate times.
I've added a few test cases to validate against this potential issue and have confirmed the output looks as I would expect.
If there are any suggestions please let me know! I'm happy to make updates.
Thanks for reviewing!
Short summary
This PR adds a simple recursive check so that any multi property case with a spread operator on assignment will not duplicate the RHS of the assignment.
Prior to this PR there was a guard for this case but it only looked one level deep. Example cases of this are shown in #13710 (comment)