Logo Questions Linux Laravel Mysql Ubuntu Git Menu
 

Mysterious failure of jQuery.each() and Underscore.each() on iOS

A brief summary for anyone landing here from Google: There is a bug in iOS8 (on 64-bit devices only) that intermittently causes a phantom "length" property to appear on objects that only have numeric properties. This causes functions such as $.each() and _.each() to incorrectly try to iterate your object as an array.

I have filed an issue report (really a workaround request) with jQuery (https://github.com/jquery/jquery/issues/2145), and there is a similar issue on the Underscore tracker (https://github.com/jashkenas/underscore/issues/2081).

Update: This is a confirmed webkit bug. A fix was comitted on 2015-03-27, but there is no indication as to which version of iOS will have the fix. See https://bugs.webkit.org/show_bug.cgi?id=142792. Currently iOS 8.0 - 8.3 are known to be affected.

Update 2: A workaround for the iOS bug can be found in jQuery 2.1.4+ and 1.11.3+ as well as Underscore 1.8.3+. If you're using any of these versions, then the library itself will behave properly. However, it's still up to you to ensure that your own code isn't affected.

This question can also be called: "How can an object without a length have a length?"

I'm having a twilight zone kind of issue with mobile Safari (seen on both iPhones and iPads running iOS 8). My code has a lot of intermittent failures using the "each" implementation of both jQuery ($.each()) and Underscore (_.each()).

After some investigation, I discovered that in all cases of failure, the each function was treating my object as an array. It would then try to iterate it like an array (obj[0], obj[1], etc.) and would fail.

Both jQuery and Underscore use the length property to determine if an argument is an object or an array/array-like collection. For example, Underscore uses this test:

if (length === +length) { ... this is an array

My objects had no length parameter, yet they were triggering the above if statements. I double validated that there was no length by:

  1. Sending the value of obj.length to the server for logging prior to calling each() (confirming that length was undefined)
  2. Calling delete obj.length prior to calling each() (this didn't change anything.)

I have finally been able to capture this behavior in the debugger with an iPhone attached to Safari on a Mac.

The following picture shows that $.isArrayLike thinks that length is 7.

Debugger stopped in $.isArrayLike

However, a console trace shows that length is undefined, as expected: Console trace

At this point I believe this is a bug in iOS Safari, especially since it's intermittent. I'd love to hear from others who's seen this problem and perhaps found a way to counter it.

Update

I was asked to create a fiddle of this, but unfortunately I can't. There seems to be a timing issue (which may even differ between devices) and I can't reproduce it in a fiddle. This is the minimum set of code I was able to repro the problem with, and it requires an external .js file. With this code happens 100% of the time on my iPhone 6 running 8.1.2. If I change anything (e.g. making the JS inline, removing any of the unrelated JS code, etc), the problem goes away.

Here is the code:

index.html

<html>
<head>
    <script src="//ajax.googleapis.com/ajax/libs/jquery/2.1.3/jquery.min.js"></script>
    <script src="script.js"></script>
</head>
<body>
Should say 3: 
<div id="res"></div>
<script>
    function trigger_failure() {
        var obj = { 1: '1', 2: '2', 3: '3' };
        print_last(obj);
    }
    $(window).load(trigger_failure);
</script>
</body>
</html>

script.js

function init_menu()
{
    var elemMenu = $('#menu');
    elemMenu
        .on('mouseenter', function() {})
        .on('mouseleave', function() {});
    elemMenu.find('.menu-btn').on('touchstart', function(ev) {});
    $(document).on('touchstart', function(ev) { });         
    return;    
}

function main_init()
{
    $(document).ready(function() {
        init_menu();
    });
}


function print_last(obj)
{
    var a = $($.parseHTML('<div></div>'));
    var b = $($.parseHTML('<div></div>'));
    b.append($.parseHTML('foo'));
    $.each(obj, function(key, btnText) {
        document.getElementById('res').innerHTML = ("adding " + btnText);       
    });
}

main_init();
like image 753
Oz Solomon Avatar asked Jan 26 '15 17:01

Oz Solomon


2 Answers

This isn't an answer but rather an analysis of what's going on under the covers after much testing. I hope that, after reading this, someone on either safari mobile side or the JavaScript VM on iOS side can take a look and correct the issue.

We can confirm that the _.each() function is treating js objects {} as arrays [] because the safari browser returns the 'length' property of an object as an integer. BUT ONLY IN CERTAIN CASES.

If we use an object map where the keys are integers:

var obj = {
  23:'some value',
  24:'some value',
  25:'some value'
}
obj.hasOwnProperty('length'); //...this comes out as 'false'
var length = obj.length; //...this returns 26!!!

Inspecting with the debugger on mobile Safari browser we clearly see that obj.length is "undefined". However stepping to next line:

var length = obj.length;

the length variable is clearly being assigned the value 26 which is an integer. The integer part is important because the bug in underscore.js occurs at these two lines in the underscore.js code:

var i, length = obj.length;
if (length === +length) { //... it treats an object as an array because 
                          //... it has assigned the obj (which has no own
                          //... 'length' property) an actual length (integer)

However if we were to change the object in question just slightly and add a key-value pair where the key is a string (and is the last item in object) such as:

var obj = {
  23:'some value',
  24:'some value',
  25:'some value',
  'foo':'bar'
}
obj.hasOwnProperty('length'); //...this comes out as 'false'
var length = obj.length; //...this now returns 'undefined'

More interestingly, if we change the object again and a key-value pair such as:

var obj = {
  23:'some value',
  24:'some value',
  25:'some value',
  75:'bar'
}
obj.hasOwnProperty('length'); //...this comes out as 'false'
var length = obj.length; //...this now returns 76!

It appears that the bug (wherever it is happening: Safari/JavaScript VM) looks at the key of last item in the object and if it is an integer adds one (+1) to it and reports that as a length of the object...even though obj.hasOwnProperty('length') comes back as false.

This occurs on:

  • some iPads (but NOT ALL that we have) with iOS version 8.1.1, 8.1.2, 8.1.3
  • the iPads that it does occur on, it happens consistently...every time
  • only Safari browser on iOS

This does not occur on:

  • any iPhones we tried with iOS 8.1.3 (using both Safari and Chrome)
  • any iPads with iOS 7.x.x (using both Safari and Chrome)
  • chrome browser on iOS
  • any js fiddles we attempted to create using the above mentioned iPads that consistently created the error

Because we can't really prove it with a jsFiddle we did the next best thing and got a screen capture of it stepping through the debugger. We posted the video on youTube and it can be seen at this location:

https://www.youtube.com/watch?v=IR3ZzSK0zKU&feature=youtu.be

As stated above this is just an analysis of the problem in more detail. We are hoping someone with more understanding under the hood can comment on the situation.

One simple solution is to NOT USE _.each function (or the jQuery equivalent). We can confirm that using angular.js forEach function remedies this issue. However, we use underscore.js pretty extensively and _.each is used nearly everywhere we iterate through arrays/collections.

Update

This was confirmed as a bug and there is now a fix for this bug on WebKit as of 2015-03-27:

fix: http://trac.webkit.org/changeset/182058

original bug report: https://bugs.webkit.org/show_bug.cgi?id=142792

like image 74
Nik Avatar answered Oct 18 '22 10:10

Nik


For anyone looking at this and using jQuery, just a heads' up that this has now been fixed in versions 2.1.4 and 1.11.3, which specifically only contain a hot-fix to the above issue:

http://blog.jquery.com/2015/04/28/jquery-1-11-3-and-2-1-4-released-ios-fail-safe-edition/

like image 25
Vlad Birt Avatar answered Oct 18 '22 09:10

Vlad Birt