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: Traverse set correct scope for function declarations in sloppy mode #14203

Open
wants to merge 1 commit into
base: main
Choose a base branch
from

Conversation

overlookmotel
Copy link
Contributor

@overlookmotel overlookmotel commented Jan 26, 2022

Q                       A
Fixed Issues? Fixes #13549
Patch: Bug Fix? Yes
Major: Breaking Change? No
Minor: New Feature? No
Tests Added + Pass? Yes
Documentation PR Link n/a
Any Dependency Changes? No
License MIT

Fix for #13549.

I believe this captures all the edge cases.

  • A sloppy-mode function declaration is hoisted to the furthest ancestor block which does not include another const, let, param or module binding.
  • Only applies to plain functions, not async or generator functions.
  • Binding location depends on whether environment in which the function is declared is strict mode, not whether the function itself is strict mode.

A few notes about implementation:

  • Because it's necessary to know what other bindings exist in ancestor blocks before determining which scope the function declaration should be bound in, creating the binding has to be deferred until exiting the parent function / program. This deals with cases like this where at the time of visiting the function declaration, you don't have all the info you need to calculate the correct scope:
{
  function foo() {}
}
let foo;
  • The check for whether a function is strict mode here is fairly expensive. However, it's bypassed for functions which are declared at top level in program, or directly nested in another function - which covers the vast majority of cases. The check is only run if it really needs to be.

  • Because of the several conditions on correct input to _registerSloppyFunctionDeclarations(), I thought it best to prefix method name with _, so it's not part of public API.

  • The test I've modified in @babel/plugin-transform-block-scoping I think maintains the intent of the test - the later function f() {} is meant to be scoped to inside the switch block.

  • I am not entirely clear how traversal starting from a node which is not the program root works. I think this section covers such cases, but would appreciate input on whether I have this right.

@nicolo-ribaudo nicolo-ribaudo added the PR: Bug Fix 🐛 A type of pull request used for our changelog categories label Jan 26, 2022
let scope = path.scope;
if (scope.path === path) scope = scope.parent;

const parent = scope.getBlockParent();

if (
path.isFunctionDeclaration({ async: false, generator: false }) &&
Copy link
Contributor

Choose a reason for hiding this comment

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

nit: I prefer to move sloppyFunctionDeclarationStack manipulation to the Function visitor and then split BlockScoped visitor, as the current logic is implicitly determined by the execution order of Function and BlockScoped visitor.

Copy link
Contributor Author

Choose a reason for hiding this comment

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

Yes sure, I'll be happy to make these changes. Give me a day or two please.

// Must only be called on a scope attached to a program or function path.
// Must only be called with functions defined in sloppy mode.
// Must not be called with async or generator functions.
_registerSloppyFunctionDeclarations(
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 use pure function if it is not supposed to be a NodePath API.

}
}

_registerSloppyFunctionDeclaration(path: NodePath<t.FunctionDeclaration>) {
Copy link
Member

@nicolo-ribaudo nicolo-ribaudo Feb 1, 2022

Choose a reason for hiding this comment

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

Nit: It took me really long do see the difference between this function name and the name of the other function. Could you rename the other one to something like _registerMultipleSloppyFunctionDeclarations? 😅

@overlookmotel
Copy link
Contributor Author

I'm afraid I've discovered my assumptions were incorrect. I said above:

A sloppy-mode function declaration is hoisted to the furthest ancestor block which does not include another const, let, param or module binding.

This is not correct. It should be:

A sloppy-mode function declaration is hoisted to its parent function's scope if that scope and no intermediate scopes already have an existing binding.

There are some further complications if there are multiple function declarations.

Here the inner function declaration is hoisted as a redeclaration:

{
  function f() { return 2; }
}
function f() { return 1; }
console.log(f()); // Logs 2

Whereas here the more deeply nested function here does create a separate binding in its own block:

{
  function f() { return 1; }
  {
    function f() { return 2; }
    console.log(f()); // 2
  }
}
console.log(f()); // 1

So unfortunately the implementation in my PR would need a lot of changes to be correct. Going to take some time or (to be honest) maybe never happen!

@nicolo-ribaudo
Copy link
Member

(I hate annex b)

@overlookmotel
Copy link
Contributor Author

overlookmotel commented Feb 13, 2022

MDN notes that there are differences between browsers in how they handle this:

https://developer.mozilla.org/en-US/docs/Web/JavaScript/Reference/Statements/function#conditionally_created_functions

In NodeJS:

console.log(f); // undefined
const getOuterF = () => f;
{
  console.log(typeof f); // function
  console.log(typeof getOuterF()); // undefined <-- what???
  function f() {}
  console.log(typeof f); // function
  console.log(typeof getOuterF()); // function
  f = 123;
  console.log(typeof f); // number
  console.log(typeof getOuterF()); // function <-- what???
}
console.log(typeof f); // function

It appears the nested function declaration creates two bindings - one at top level, and one in the block statement.

I couldn't find a passage in ECMA spec which specifies the correct behavior. But I find the spec pretty impenetrable, so I may just have missed it. Anyone know if this exists?

@nicolo-ribaudo
Copy link
Member

It's specified here: https://tc39.es/ecma262/#sec-block-level-function-declarations-web-legacy-compatibility-semantics

I can try to analyze how that example should work according to the spec.

@overlookmotel
Copy link
Contributor Author

Thanks very much for swift reply. Some bedtime reading! It looks complicated, but more comprehensible than much of the spec.

@nicolo-ribaudo
Copy link
Member

Btw, with your example I get the same result in Firefox and Node.js

@nicolo-ribaudo
Copy link
Member

Ok, for that example you probably have to look at B.3.2.2 and 16.1.7.

Sign up for free to join this conversation on GitHub. Already have an account? Sign in to comment
Labels
pkg: traverse (scope) PR: Bug Fix 🐛 A type of pull request used for our changelog categories
Projects
None yet
Development

Successfully merging this pull request may close these issues.

[Bug]: Function declarations not hoisted to upper scope in sloppy mode code
4 participants