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

fix: Duplicate function call in variable destructuring #13711

Merged

Conversation

dan-kez
Copy link
Contributor

@dan-kez dan-kez commented Aug 28, 2021

Q                       A
Fixed Issues? Fixes #13710
Patch: Bug Fix? Yes
Tests Added + Pass? Yes
Any Dependency Changes? No
License MIT

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)

@babel-bot
Copy link
Collaborator

babel-bot commented Aug 28, 2021

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

@codesandbox-ci
Copy link

codesandbox-ci bot commented Aug 28, 2021

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:

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

@dan-kez dan-kez changed the title Duplicate function call in variable destructuring fix: Duplicate function call in variable destructuring Aug 28, 2021
@nicolo-ribaudo nicolo-ribaudo added PR: Bug Fix 🐛 A type of pull request used for our changelog categories Spec: Object Rest/Spread labels Aug 30, 2021
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();
Copy link
Member

Choose a reason for hiding this comment

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

Thanks!

Can you add a test case with mixed arrays?

Suggested change
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();

Copy link
Contributor Author

Choose a reason for hiding this comment

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

Just pushed up a commit. Thanks for the recommendation, it caught an edge case with the implementation.

Copy link
Contributor

@JLHwung JLHwung Aug 30, 2021

Choose a reason for hiding this comment

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

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.

Copy link
Contributor Author

Choose a reason for hiding this comment

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

Great point! I added the test case and confirmed the output looks as I'd expect.

Copy link
Contributor

Choose a reason for hiding this comment

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

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.

Copy link
Contributor Author

@dan-kez dan-kez Aug 30, 2021

Choose a reason for hiding this comment

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

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!

@dan-kez dan-kez force-pushed the duplicate_function_call_in_variable_destructuring branch 2 times, most recently from 9a62ee3 to 4336cdb Compare August 30, 2021 13:48
@@ -335,7 +368,7 @@ export default declare((api, opts) => {
// skip single-property case, e.g.
// const { ...x } = foo();
// since the RHS will not be duplicated
originalPath.node.id.properties.length > 1 &&
doesAnyChildNodeHaveMoreThanOneProperty(originalPath.node.id) &&
!t.isIdentifier(originalPath.node.init)
Copy link
Contributor

@JLHwung JLHwung Aug 30, 2021

Choose a reason for hiding this comment

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

Unrelated to this PR: I think we should use originalPath.scope.isStatic(originalPath.node.init) here, because

  1. A ThisExpression is safe to reuse, e.g.
const { ...x } = this;
  1. Even if node.init is 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.

Comment on lines 269 to 272
// If this is an AssignmentPattern, always return true else the RHS will be duplicated
if (innerNode.type === "AssignmentPattern" && innerNode.left) {
return true;
}
Copy link
Contributor

Choose a reason for hiding this comment

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

Similar to RestElement, we need to recurse into node.left.

innerNode ??= node.left

Copy link
Contributor Author

Choose a reason for hiding this comment

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

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

Copy link
Contributor

@JLHwung JLHwung Aug 31, 2021

Choose a reason for hiding this comment

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

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

Copy link
Contributor Author

Choose a reason for hiding this comment

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

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.

@dan-kez dan-kez requested a review from JLHwung September 1, 2021 20:42
// ObjectPattern
} else if (node.properties) {
if (node.properties.length > 1) return true;
// Case of `const {} = {}`. This shouldn't be hit as
Copy link
Contributor

Choose a reason for hiding this comment

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

This branch is reachable because visitRestElements applies to rest elements in array patterns, too. E.g.

const { x: [ ...{} ] } = { x: [] }

Copy link
Contributor

@JLHwung JLHwung left a comment

Choose a reason for hiding this comment

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

Great work!

@dan-kez
Copy link
Contributor Author

dan-kez commented Sep 2, 2021

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

$ npm run build:prod -w react-error-overlay
npm ERR! missing script: build:prod
npm ERR! 
npm ERR! Did you mean this?
npm ERR!     build

I'm not sure if this error is blocking to have this fixed merged in or how I can help fixing this error.

@JLHwung
Copy link
Contributor

JLHwung commented Sep 2, 2021

The react e2e CI error has been fixed in #13724, you can rebase this PR on latest main.

@dan-kez dan-kez force-pushed the duplicate_function_call_in_variable_destructuring branch from 700c692 to ab3b603 Compare September 2, 2021 14:32
@dan-kez
Copy link
Contributor Author

dan-kez commented Sep 2, 2021

The react e2e CI error has been fixed in #13724, you can rebase this PR on latest main.

Perfect - thank you! Just rebased

return hasMoreThanOneBinding(node.argument);
} else {
// node is Identifier or MemberExpression
return true;
Copy link
Member

Choose a reason for hiding this comment

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

Q: Since this is a single identifier, shouldn't it return false? (it's one binding)

Copy link
Contributor Author

Choose a reason for hiding this comment

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

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

Copy link
Contributor Author

Choose a reason for hiding this comment

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

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.

Copy link
Member

Choose a reason for hiding this comment

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

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.

Copy link
Contributor Author

Choose a reason for hiding this comment

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

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.

Copy link
Member

Choose a reason for hiding this comment

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

Yes, that's ok!

describe("hasMoreThanOneBinding", function () {
it.each([
["const { x: { ...y } } = z();", true],
["let { x4: { ...y4 } } = z();", true],
Copy link
Contributor

Choose a reason for hiding this comment

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

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.

Copy link
Contributor Author

@dan-kez dan-kez Sep 3, 2021

Choose a reason for hiding this comment

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

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.

Comment on lines +22 to +23
["const [...[ ...y17 ]] = z();", true],
["const [...{ ...y18 }] = z();", true],
Copy link
Contributor

Choose a reason for hiding this comment

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

These two cases are almost identical, we can remove one of them.

}
describe("hasMoreThanOneBinding", function () {
it.each([
["const { x: { ...y } } = z();", true],
Copy link
Contributor

Choose a reason for hiding this comment

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

Should return false.

["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],
Copy link
Contributor

Choose a reason for hiding this comment

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

Should return false.

["const [...[ ...y17 ]] = z();", true],
["const [...{ ...y18 }] = z();", true],
["const [...{ a19, ...y19 }] = z();", true],
["const { x20: { ...y20 } = { } } = z();", true],
Copy link
Contributor

Choose a reason for hiding this comment

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

Should return false.

Comment on lines +27 to +28
["const [[ ...y23 ] = []] = z();", true],
["const [{ ...y24 } = []] = z();", true],
Copy link
Contributor

Choose a reason for hiding this comment

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

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],
Copy link
Contributor

Choose a reason for hiding this comment

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

Should return false.

["const [{ ...y24 } = []] = z();", true],
["const { x25: [ ...y25 ] = []} = z();", true],
["const { x26: [ q26, ...y26 ] = []} = z();", true],
["const {x28: [,,{...y28}]} = z();", true],
Copy link
Contributor

Choose a reason for hiding this comment

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

Should return false

Comment on lines +34 to +37
["const [,,{...x31}] = z();", true],
["const { x32: { }, w32: { ...y32 } } = z();", true],
["const [,,{}, {...q32}] = z();", true],
["const { ...y33 } = z();", true],
Copy link
Contributor

Choose a reason for hiding this comment

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

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],
Copy link
Contributor

Choose a reason for hiding this comment

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

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

@dan-kez
Copy link
Contributor Author

dan-kez commented Sep 7, 2021

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:

  1. There are no instances of the RHS of an assignment being duplicated due to variable destructuring.
  2. The code is readable and tested.
  3. Optimizations where we intelligently determine if we would need a temporary variable can be done in a separate PR with a distinct issue to track it. This is to minimize scope of this ticket.

To accomplish the above I want to do the following

  1. Create a new issue to track the optimization listed in point #3 above.
  2. Rename hasMoreThanOneBinding to shouldStoreRHSInTemporaryVariable with a comment linking to this discussion and the issue made above.

Would making both those changes be sufficient to see this PR merged?

@nicolo-ribaudo
Copy link
Member

Since this PR fixes the bug in the generated code, I'm ok with just renaming hasMoreThanOneBinding and merging it.

@nicolo-ribaudo
Copy link
Member

Could you rebase and run yarn && yarn dedupe to fix the CI failure?

@dan-kez
Copy link
Contributor Author

dan-kez commented Sep 7, 2021

Thanks for the prompt response @nicolo-ribaudo !

I updated the function name, added a comment, and also ran yarn dedupe.

Copy link
Member

@nicolo-ribaudo nicolo-ribaudo left a comment

Choose a reason for hiding this comment

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

Let's also wait for @JLHwung's opinion.

Copy link
Contributor

@JLHwung JLHwung left a comment

Choose a reason for hiding this comment

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

This PR already fixed several cases. We can iterate later on further issues.

@dan-kez
Copy link
Contributor Author

dan-kez commented Sep 7, 2021

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.

@JLHwung JLHwung merged commit 40e43f5 into babel:main Sep 7, 2021
@JLHwung
Copy link
Contributor

JLHwung commented Sep 7, 2021

@dan-kez All good! Merged.

@dan-kez
Copy link
Contributor Author

dan-kez commented Sep 7, 2021

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!

@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 Dec 8, 2021
@github-actions github-actions bot locked as resolved and limited conversation to collaborators Dec 8, 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: Bug Fix 🐛 A type of pull request used for our changelog categories Spec: Object Rest/Spread
Projects
None yet
Development

Successfully merging this pull request may close these issues.

[Bug]: @babel/plugin-proposal-object-rest-spread duplicates assignment calls for _objectWithoutProperties
4 participants