Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
124 changes: 124 additions & 0 deletions Jint.Tests/Runtime/Interop/ClrCollectionConcatTests.cs
Original file line number Diff line number Diff line change
@@ -0,0 +1,124 @@
using System.Dynamic;

namespace Jint.Tests.Runtime.Interop;

/// <summary>
/// <c>Array.prototype.concat</c> spreads a host collection that script sees as an array, as it did in 4.13
/// (<see href="https://github.com/sebastienros/jint/issues/4201">#4201</see>).
/// </summary>
/// <remarks>
/// IsConcatSpreadable (https://tc39.es/ecma262/#sec-isconcatspreadable) asks IsArray when the object has no
/// <c>Symbol.isConcatSpreadable</c>, 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.
/// </remarks>
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<string, object>;
a.Add("id", Guid.NewGuid());
var b = new ExpandoObject() as IDictionary<string, object>;
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<string, object>;
a.Add("id", Guid.NewGuid());
var b = new ExpandoObject() as IDictionary<string, object>;
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<int> { 1, 2 });
engine.SetValue("b", new List<int> { 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<int>([1, 2]));

// a Queue<T> 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);
}
}
5 changes: 3 additions & 2 deletions Jint.Tests/SpecAnchors.txt
Original file line number Diff line number Diff line change
Expand Up @@ -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
Expand Down Expand Up @@ -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
Expand Down
7 changes: 6 additions & 1 deletion Jint/Native/Object/ObjectInstance.cs
Original file line number Diff line number Diff line change
Expand Up @@ -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<T>), 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 };
}
}

Expand Down
Loading