diff --git a/Jint.Tests/Runtime/Interop/ClrCollectionConcatTests.cs b/Jint.Tests/Runtime/Interop/ClrCollectionConcatTests.cs new file mode 100644 index 0000000000..72622dcccc --- /dev/null +++ b/Jint.Tests/Runtime/Interop/ClrCollectionConcatTests.cs @@ -0,0 +1,124 @@ +using System.Dynamic; + +namespace Jint.Tests.Runtime.Interop; + +/// +/// Array.prototype.concat spreads a host collection that script sees as an array, as it did in 4.13 +/// (#4201). +/// +/// +/// IsConcatSpreadable (https://tc39.es/ecma262/#sec-isconcatspreadable) asks IsArray when the object has no +/// Symbol.isConcatSpreadable, and a wrapper for a CLR array or list is not an array exotic object, so +/// the answer was false and the wrapper was appended as a single element. +/// +public class ClrCollectionConcatTests +{ + private static Engine NewEngine() => new(options => { options.AllowClr(); options.Strict = false; }); + + private static Engine NewLiveViewEngine() => new(options => + { + options.AllowClr(); + options.Strict = false; + options.Interop.ArrayConversion = ArrayConversionMode.LiveView; + }); + + [Test] + public void ConcatSpreadsTwoClrArraysOfExpandoObjects() + { + var engine = NewEngine(); + + var a = new ExpandoObject() as IDictionary; + a.Add("id", Guid.NewGuid()); + var b = new ExpandoObject() as IDictionary; + b.Add("id", Guid.NewGuid()); + + engine.SetValue("a", new[] { a }); + engine.SetValue("b", new[] { b }); + engine.Execute("var c = a.concat(b);"); + + engine.Evaluate("c.length").AsNumber().Should().Be(2); + + var c = engine.GetValue("c").ToObject() as object[]; + c.Should().NotBeNull(); + c!.Length.Should().Be(2); + c[0].Should().BeSameAs(a); + c[1].Should().BeSameAs(b); + } + + [Test] + public void ConcatSpreadsTwoLiveViewClrArraysOfExpandoObjects() + { + var engine = NewLiveViewEngine(); + + var a = new ExpandoObject() as IDictionary; + a.Add("id", Guid.NewGuid()); + var b = new ExpandoObject() as IDictionary; + b.Add("id", Guid.NewGuid()); + + engine.SetValue("a", new[] { a }); + engine.SetValue("b", new[] { b }); + engine.Execute("var c = a.concat(b);"); + + engine.Evaluate("c.length").AsNumber().Should().Be(2); + + var c = engine.GetValue("c").ToObject() as object[]; + c.Should().NotBeNull(); + c!.Length.Should().Be(2); + c[0].Should().BeSameAs(a); + c[1].Should().BeSameAs(b); + } + + [Test] + public void ConcatSpreadsLiveViewClrArraysOnBothSides() + { + var engine = NewLiveViewEngine(); + engine.SetValue("a", new object[] { 1, 2 }); + engine.SetValue("b", new object[] { 3, "x" }); + + engine.Evaluate("a.concat(b).join(',')").AsString().Should().Be("1,2,3,x"); + engine.Evaluate("[0].concat(a, b).join(',')").AsString().Should().Be("0,1,2,3,x"); + engine.Evaluate("a.concat([9], 8).join(',')").AsString().Should().Be("1,2,9,8"); + } + + [Test] + public void ConcatSpreadsClrObjectArrays() + { + var engine = NewEngine(); + engine.SetValue("a", new object[] { 1, 2 }); + engine.SetValue("b", new object[] { 3, "x" }); + + engine.Evaluate("a.concat(b).length").AsNumber().Should().Be(4); + engine.Evaluate("a.concat(b).join(',')").AsString().Should().Be("1,2,3,x"); + } + + [Test] + public void ConcatSpreadsGenericLists() + { + var engine = NewEngine(); + engine.SetValue("a", new List { 1, 2 }); + engine.SetValue("b", new List { 3 }); + + engine.Evaluate("a.concat(b).length").AsNumber().Should().Be(3); + engine.Evaluate("a.concat(b).join(',')").AsString().Should().Be("1,2,3"); + } + + [Test] + public void ConcatSpreadsAClrArrayPassedToAnArray() + { + var engine = NewEngine(); + engine.SetValue("b", new object[] { 3, 4 }); + + engine.Evaluate("[1, 2].concat(b).join(',')").AsString().Should().Be("1,2,3,4"); + engine.Evaluate("[1, 2].concat(b, [5]).length").AsNumber().Should().Be(5); + } + + [Test] + public void ConcatDoesNotSpreadACollectionWithoutIndexedElements() + { + var engine = NewEngine(); + engine.SetValue("q", new Queue([1, 2])); + + // a Queue has a Count but no element at index 0, so it is not something script sees as an array + engine.Evaluate("[0].concat(q).length").AsNumber().Should().Be(2); + } +} diff --git a/Jint.Tests/SpecAnchors.txt b/Jint.Tests/SpecAnchors.txt index 87e5b19a10..0f471dbe69 100644 --- a/Jint.Tests/SpecAnchors.txt +++ b/Jint.Tests/SpecAnchors.txt @@ -14,8 +14,8 @@ # The 'verified' date is the only thing bounding anchor rot: a clause renamed upstream keeps # resolving here until somebody re-runs that. # -verified: 2026-09-19 -anchors: 1269 +verified: 2026-10-05 +anchors: 1270 absent: 2 !proposal-decorators sec-autoaccessordefinitionevaluation # tc39.es still renders the 2018 descriptor design, which has no such clause !proposal-decorators sec-createdecoratorcontextobject # tc39.es still renders the 2018 descriptor design, which has no such clause @@ -381,6 +381,7 @@ ecma262 sec-integerindexedelementget ecma262 sec-integerindexedelementset ecma262 sec-invoke ecma262 sec-isarray +ecma262 sec-isconcatspreadable ecma262 sec-isconstructor ecma262 sec-isintailposition ecma262 sec-isintegralnumber diff --git a/Jint/Native/Object/ObjectInstance.cs b/Jint/Native/Object/ObjectInstance.cs index cabcca4b91..118f83b7f7 100644 --- a/Jint/Native/Object/ObjectInstance.cs +++ b/Jint/Native/Object/ObjectInstance.cs @@ -2754,7 +2754,12 @@ internal bool IsConcatSpreadable { return TypeConverter.ToBoolean(spreadable); } - return IsSpecArray(); + + // Step 2 of IsConcatSpreadable (https://tc39.es/ecma262/#sec-isconcatspreadable) is IsArray, which a host + // wrapper is not. One that exposes the target's elements by index is what script sees as an array though + // (a live view over a CLR array, a List), and concat spread it before ArrayConversionMode.LiveView + // became a wrapper (#4201). The type test is only reached for a non-array, so a JsArray pays nothing. + return IsSpecArray() || this is ObjectWrapper { IsArrayLike: true, HasIndexedElements: true }; } }