24

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();
Community
  • 1
  • 1
Oz Solomon
  • 2,716
  • 19
  • 20
  • 1
    Can you show how you build your object ? Can you reproduce the problem in a fiddle ? – Denys Séguret Jan 26 '15 at 17:57
  • Many possible causes of your observation come to mind. We really need more code to give you a diagnostic. – Denys Séguret Jan 26 '15 at 18:03
  • That particular object was simply built with the inline code `{ 1: 'Just me', 2: '2', 3: '3', 4: '4', 5: '5', 6: 'More than 5' } and passed as an argument to another function which called $.each(). – Oz Solomon Jan 26 '15 at 18:06
  • Can you reproduce the problem in a [fiddle](http://jsbin.com) ? – Denys Séguret Jan 26 '15 at 18:07
  • @dystroy I'll see if I can fiddle it. – Oz Solomon Jan 26 '15 at 18:07
  • @dystroy Please see my note above regarding a fiddle. – Oz Solomon Jan 26 '15 at 19:59
  • What exactly happens for the user which shouldn't happen on your iPhone ? Are you aware that you have no guarantee regarding the order of iteration on your object (which means you can have about any result at the end in #res) ? – Denys Séguret Jan 27 '15 at 07:55
  • This is not an issue of iteration order. It's that jQuery/Underscore for whatever reason believe that there is a valid length so they try to iterate numerically and when they try to pass obj[0] it fails since there is no key 0. – Oz Solomon Jan 28 '15 at 03:05
  • Hi there, we are getting the same issue as you are. Using ipad's iOS 8.12 and 8.13. Have you found anything regarding this very odd issue? Thanks – Thibs Feb 06 '15 at 16:45
  • @Thibs I'm pretty certain at this point that it's a bug in the JavaScript VM on iOS. My workaround right now is adding an implementation of 'each' that works only on objects, and calling that version when I know I'm iterating on an object. – Oz Solomon Feb 08 '15 at 14:38
  • I've done some testing, and I can only reproduce this on certain iOS devices. iPhone 6 Plus and iPad Mini 3 do see the bug. iPhone 5C and iPad Mini 1 do not. Maybe it's specific to 64-bit CPUs? – vasi Mar 13 '15 at 15:54
  • I was able to reproduce it on the iPhone 6 (not plus) and iPad Mini 3. This damn bug wrecked my entire afternoon! – Guilherme Rodrigues Apr 01 '15 at 00:29
  • Summary from jQuery: caused by incorrect JIT optimization and jQuery will release a workaround. Summary from underscore: fix is to check obj.hasOwnProperty('length') as well as obj.length. WebKit bug patched 27 March: https://bugs.webkit.org/show_bug.cgi?id=142792 – robocat Apr 01 '15 at 08:56
  • I confirmed the JIT bug on iOS 7.1.2 (11D257) on an iPad Mini 2 using underscore.js stress test (http://jsfiddle.net/rcsz27v1/2/). So this is not specific to iOS 8. – Aymeric Barthe Apr 01 '15 at 18:19
  • Whow, @OzSolomon great thanks for writing this extensive question. This really saves me hitting my head on the table a million times. – Dirk Boer Jul 30 '15 at 18:53
  • Upgrading to the latest version of lodash (3.10.0) fixed this problem for me. Take note that there is a breaking change in this lodash version with `_.first` vs `_.take`. For anyone who isn't familiar with lodash - it's a fork of underscore that is nowadays (imo) a better solution. – Dirk Boer Jul 31 '15 at 08:15

5 Answers5

8

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

Nik
  • 6,378
  • 5
  • 32
  • 46
1

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/

Vlad Birt
  • 11
  • 1
1

Upgrading to the latest version of lodash (3.10.0) fixed this problem for me.

Take note that there is a breaking change in this lodash version with _.first vs _.take.

For anyone who isn't familiar with lodash - it's a fork of underscore that is nowadays (imo) a better solution.

Really a big thanks to @OzSolomon for explaning, describing and figuring out this problem.

If I would be able to give a bounty to a question I would've done it.

Dirk Boer
  • 7,007
  • 9
  • 49
  • 89
0

I have worked a while with something that could be similar or the same problem. The company I work for have a game that uses KineticJS 4.3.2. Kinetic makes intensive use of Arrays when grouping graphic objects on a html5 canvas. The problems that occurred was that push was missing on the array sometimes or that properties on the object that was stored in the array was missing. The problem occurred in Safari and when run from homescreen in iOS8. For some reason the problem did not occur when run in Chrome on iOS. The problem also did not occur with a debugger connected. After a lot of testing and searching on the net we think this is a JIT optimisation bug in webkit on iOS. A colleague found the following links.

TypeError: Attempted to assign to readonly property. in Angularjs application on iOS8 Safari

https://github.com/angular/angular.js/issues/9128

http://tracker.zkoss.org/browse/ZK-2487

Currently we have made a workaround by removing dot-notation when accessing the array that did not work (.children was replaced with [ "children" ]). I have not been able to create a jsFiddle.

Community
  • 1
  • 1
-4

The problem is the user code, not iOS8 webkit.

var obj = {
  23: 'a',
  24: 'b',
  25: 'c'
}

The map keys above are integers, when strings should be used. It's not a valid map by the javascript standard so the behaviour is undefined, and Apple is choosing to interpret `obj' as an array of 26 elements (indexes 0 to 25 inclusive).

Use string keys and the length problem will go away:

var obj = {
  '23': 'a',
  '24': 'b',
  '25': 'c'
}
  • That's not true. integer keys are perfectly valid (though IIRC the js engine should convert them to strings (or at least pretend to). Though your fix would work from what i've read. The behaviour is simply a bug. – Jauco Apr 01 '15 at 11:52
  • Point to the section in the ECMAscript specification. You're describing common practice, not a standard. – javasciptmaps Apr 01 '15 at 13:43
  • It's how i interpret the grammer spec for object literals (notice numericliteral under propertyname) ``` ObjectLiteral : { } { PropertyNameAndValueList } { PropertyNameAndValueList , } PropertyNameAndValueList : PropertyAssignment PropertyNameAndValueList , PropertyAssignment PropertyAssignment : PropertyName : AssignmentExpression PropertyName : IdentifierName StringLiteral NumericLiteral ``` http://ecma-international.org/ecma-262/5.1/#sec-11.1.5 – Jauco Apr 03 '15 at 09:25