javascriptsafariv8javascriptcorejavascript-engine

JavaScript - Object definition available before code execution on Safari


Objects and functions which I require only to execute once on a page load are wrapped inside an undefined check for the object. On Chrome on Windows/Linux which I usually use, the code works perfectly i.e. code only executes once. But on Safari on both iPad and MacBook, the undefined check doesn't work i.e. as per the browser, the object/function is already declared without even the code execution reaching there!

I've simplified my code to only include an if loop that checks if the nested function is already declared. Since it should not have been declared the first time, I've included someVariable inside the if that should never be undefined.

Run the same function on Chrome and Safari and see the difference.


if (typeof anObject == 'undefined') {

    function anObject(someParameter = 'someParameter') {
        var someProperty = 'someProperty';

        function someMethod(someParameter) {
            console.log(someParameter);
        }
    }

    console.log('Hi');
    var someVariable = 404;
}

On Chrome, you can see Console logging of 'Hi' as well someVariable as 404. But on Safari, there's no console logging and someVariable goes undefined.

If you put breakpoints to understand what's happening - the first undefined check never works actually. The anObject is defined even before the declaration.

I have tried searching about the differences regarding this between V8 (Chrome JS engine) and JavaScriptCore (Safari's engine) but haven't found anything solid. I assume this is something related to execution and function hoisting. It would be better if someone could explain to me the reason for this difference in execution. The behavior is the same on iPad that too even on Chrome!

Updates:

  1. I've found a similar problem regarding different execution. Seems like this is something related to function hoisting but couldn't find any solid source yet. Javascript Hoisting in Chrome And Firefox

  2. Looks like it is actually a hoisting behaviour now. This works by using function expression. In this case, simple replacing function anObject() with var anObject = function(). By doing this, I think, the variable doesn't get assigned the function reference even if the function is hoisted and evaluated before execution.

  3. As recommended by PhistucK, I have opened the issue on WebKit issue tracker (Bug #199823), Chromium Discuss and TC39 ECMA262 Github (Issue #1632).

  4. This is an existing Webkit bug reported in 2016 - Bug 163209 - [ES6]. Implement Annex B.3.3 function hoisting rules for global scope. I have now summarized the research in my answer.


Solution

  • This behaviour relates to using sloppy mode in Webkit engines, which has a bug. Let me wrap up the research:

    Specifically, there are three key aspects to the example: in non-strict mode code, a function is declared within a block and referenced prior to that block.

    As the introduction to Annex B.3.3 explains, function declarations inside block statements were not originally part of the language spec; this was an extension that browsers often implemented, each in their own unique way. ES2015 sought to specify as much of this behavior as possible, but as the differences between browsers were not fully reconcilable, some existing code remained inevitably unportable.

    "Here are things we were forced to specify because web browsers implemented this behavior and then pages start relying on it, but we aren't happy about it." - Annex B 3.3

    In sloppy mode, JavaScriptCore does behave differently than the normal behaviour:

    λ eshost -sx "if (typeof foo === 'undefined') { function foo() {} print('ok'); } else { print('hmm'); }"
    #### ch, sm, v8, xs
    ok
    
    #### jsc
    hmm
    

    One solution is to use 'strict' mode:

    λ eshost -sx "(function () { 'use strict'; if (typeof foo === 'undefined') { function foo() {} print('ok'); } else { print('hmm'); } })()"
    
    #### ch, jsc, sm, v8, xs
    ok
    

    Also, this apparently only happens in Safari at the top level of scripts. In functions, as in

    function g(){
      console.log(typeof f);
      {
        function f(){}
      }
    }
    
    g();
    

    Safari conforms to the spec. This might well be because the behavior at the top level of scripts was only specified in ES2016, in 8582e81, as opposed to the behavior in functions, which was specified in ES2015.

    Source: Comments posted by Ross Kirsling and Kevin Gibbons on GitHub issue #1632.

    There has been an existing bug reported in 2016 related to this hoisting behaviour, Webkit Issue #16309: [ES6]. Implement Annex B.3.3 function hoisting rules for global scope. Here's a Test262 case that covers this.

    To solve this, I have used Function Expressions:

    That is I replaced function anObject() with var anObject() = function(). Run this code to understand the flow now:

    if (typeof anObject == 'undefined') {
    
      if (typeof anObject == 'undefined') console.log('anObject not defined inside block')
      if (typeof someVariable == 'undefined') console.log('someVariable not defined as of now');
    
      var anObject = function(someParameter = 'someParameter') {
        var someProperty = 'someProperty';
      }
    
      console.log('anObject is now defined');
      var someVariable = 404;
    
      if (typeof someVariable == 'undefined') console.log('someVariable not defined as of now');
    
    }

    What's happening here?

    The functions and variables are hoisted to the top level. But engines like V8 (Chrome), semantically defines the function name during code execution. However, in sloppy mode on Webkit browsers, even after the ECMA2015/16 standardization, function name is defined before the execution. Please note that on both engines, the function is actually defined (hoisted) before anything - this is just about the semantics regarding the function name. The code above assigns the anonymous function's reference (because it has no name now) to anObject during the execution and this will run fine on Safari as well. A good explanation about block scopes and hoisting on What are the precise semantics of block-level functions in ES6?.