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 };
}
}