diff --git a/Jint.Tests/Runtime/ForInEnumerationTests.cs b/Jint.Tests/Runtime/ForInEnumerationTests.cs index bf99bb56a..ccc579dda 100644 --- a/Jint.Tests/Runtime/ForInEnumerationTests.cs +++ b/Jint.Tests/Runtime/ForInEnumerationTests.cs @@ -319,6 +319,27 @@ public void ForInOverArrayEnumeratesIndicesThenExtraProperties() Assert.Equal("0,1,2,foo", result); } + [Fact] + public void ForInOverArrayIncludesInheritedEnumerableArrayPrototypeIndex() + { + // Regression: Array.prototype is itself array-backed, so script-placed elements on it live in + // its dense store — the builtin-shape shared name list does not include them. The for-in key + // shortcut must not hand out that list once Array.prototype carries own index keys: the + // inherited enumerable '5' must be enumerated after the own indices, and the own '1' must + // shadow the inherited '1'. + var engine = new Engine(); + var result = engine.Evaluate(""" + Array.prototype[5] = 'inherited'; + Array.prototype[1] = 'shadowed'; + var arr = [10, 20]; + var out = []; + for (var k in arr) { out.push(k); } + out.join(','); + """).AsString(); + + Assert.Equal("0,1,5", result); + } + [Fact] public void ForInKeysAreStrings() { diff --git a/Jint/Native/Array/ArrayInstance.cs b/Jint/Native/Array/ArrayInstance.cs index f95c86ac3..88760baca 100644 --- a/Jint/Native/Array/ArrayInstance.cs +++ b/Jint/Native/Array/ArrayInstance.cs @@ -506,6 +506,33 @@ internal override OwnPropertyProbe ProbeOwnProperty(JsValue property) return base.ProbeOwnProperty(property); } + /// + /// True when any own index-keyed element exists: a present dense element or a materialized + /// per-index descriptor. O(1) for the pristine case that matters (Array.prototype's empty + /// backing store) — the dense scan only runs over slots the object actually allocated. + /// + internal bool HasAnyOwnIndex() + { + if (_sparse is { Count: > 0 }) + { + return true; + } + + var dense = _dense; + if (dense is not null) + { + foreach (var element in dense) + { + if (element is not null) + { + return true; + } + } + } + + return false; + } + internal JsValue Get(uint index) { if (!TryGetValue(index, out var value)) diff --git a/Jint/Native/Object/ObjectInstance.cs b/Jint/Native/Object/ObjectInstance.cs index bb20152ee..ba27bccf4 100644 --- a/Jint/Native/Object/ObjectInstance.cs +++ b/Jint/Native/Object/ObjectInstance.cs @@ -455,11 +455,13 @@ internal IReadOnlyList GetForInStringKeys() } else if ((_type & InternalTypes.BuiltinShapeMode) != InternalTypes.Empty && _properties is null + && (this is not Array.ArrayInstance arrayInstance || !arrayInstance.HasAnyOwnIndex()) && ReferenceEquals(GetInitialOwnStringPropertyKeys(), System.Linq.Enumerable.Empty())) { - // No function-derived length/name/prototype prefix and no hybrid dictionary additions, so the - // built-in's shared, ordered name list is exactly its own string keys — e.g. Object.prototype - // (the common deeper for-in level). + // No function-derived length/name/prototype prefix, no hybrid dictionary additions, and no + // exotic index storage (Array.prototype is array-backed: script CAN place elements on it, + // which the shared name list would miss), so the built-in's shared, ordered name list is + // exactly its own string keys — e.g. Object.prototype (the common deeper for-in level). return Unsafe.As(this).BuiltinShape.NamesAsJsStrings; }