diff --git a/src/AngleSharp.Js.Tests/DomTests.cs b/src/AngleSharp.Js.Tests/DomTests.cs
index 489ee32..dcd220a 100644
--- a/src/AngleSharp.Js.Tests/DomTests.cs
+++ b/src/AngleSharp.Js.Tests/DomTests.cs
@@ -128,5 +128,148 @@ public async Task AssignedPropertyIsReportedConsistently()
var result = await "(function () { var d = document.createElement('div'); d.custom = 1; return d.hasOwnProperty('custom') + ',' + Object.getOwnPropertyNames(d).join(); })()".EvalScriptAsync();
Assert.AreEqual("true,custom", result);
}
+
+ // Reading an index, testing it for existence and enumerating it are three different
+ // questions the engine asks, and the node answers each of them from a different
+ // method. They have to agree.
+ [Test]
+ public async Task IndexedEntryIsReportedAsAnOwnPropertyAndReads()
+ {
+ var result = await "(function () { var c = document.getElementsByTagName('script'); return c.hasOwnProperty(0) + ',' + (0 in c) + ',' + c[0].nodeName; })()".EvalScriptAsync();
+ Assert.AreEqual("true,true,SCRIPT", result);
+ }
+
+ [Test]
+ public async Task IndexBeyondTheEndIsNoOwnPropertyAndReadsUndefined()
+ {
+ var result = await "(function () { var c = document.getElementsByTagName('script'); return c.hasOwnProperty(5) + ',' + (5 in c) + ',' + (typeof c[5]); })()".EvalScriptAsync();
+ Assert.AreEqual("false,false,undefined", result);
+ }
+
+ // A member of an indexed collection must not be mistaken for an index. "length" is
+ // the collection's own property rather than an inherited one, which is what the
+ // array-like projection reports for it - a browser has it on the prototype instead.
+ [Test]
+ public async Task MemberOfAnIndexedCollectionIsNotMistakenForAnIndex()
+ {
+ var result = await "(function () { var c = document.getElementsByTagName('script'); return ('length' in c) + ',' + c.length + ',' + c.propertyIsEnumerable('length'); })()".EvalScriptAsync();
+ Assert.AreEqual("true,1,false", result);
+ }
+
+ // A DOM collection is array-like, not an array - which is exactly what a browser
+ // reports for one.
+ [Test]
+ public async Task CollectionIsNotAnArray()
+ {
+ var result = await "Array.isArray(document.getElementsByTagName('script'))".EvalScriptAsync();
+ Assert.AreEqual("False", result);
+ }
+
+ [Test]
+ public async Task CollectionKeepsItsDomIdentity()
+ {
+ var result = await "(function () { var c = document.getElementsByTagName('script'); return Object.prototype.toString.call(c) + ',' + (c instanceof HTMLCollection); })()".EvalScriptAsync();
+ Assert.AreEqual("[object HTMLCollection],true", result);
+ }
+
+ [Test]
+ public async Task CollectionCanBeIterated()
+ {
+ var result = await "(function () { var n = 0; for (var s of document.getElementsByTagName('script')) { n += s.nodeName.length; } return n; })()".EvalScriptAsync();
+ Assert.AreEqual("6", result);
+ }
+
+ [Test]
+ public async Task CollectionCanBeSpread()
+ {
+ var result = await "[...document.getElementsByTagName('script')].length".EvalScriptAsync();
+ Assert.AreEqual("1", result);
+ }
+
+ [Test]
+ public async Task ArrayGenericsRunOverACollection()
+ {
+ var result = await "Array.prototype.map.call(document.getElementsByTagName('script'), function (e) { return e.nodeName; }).join()".EvalScriptAsync();
+ Assert.AreEqual("SCRIPT", result);
+ }
+
+ [Test]
+ public async Task IndicesOfACollectionAreEnumerated()
+ {
+ var result = await "JSON.stringify(Object.keys(document.getElementsByTagName('script')))".EvalScriptAsync();
+ Assert.AreEqual("[\"0\"]", result);
+ }
+
+ // The platform-object shape: the projection owns its indices, so script cannot delete
+ // one or define over it.
+ [Test]
+ public async Task IndexOfACollectionCannotBeDeleted()
+ {
+ var result = await "(function () { var c = document.getElementsByTagName('script'); return delete c[0]; })()".EvalScriptAsync();
+ Assert.AreEqual("False", result);
+ }
+
+ [Test]
+ public async Task ExpandoOnACollectionIsStillPossible()
+ {
+ var result = await "(function () { var c = document.getElementsByTagName('script'); c.marker = 'kept'; return c.marker + ',' + c.hasOwnProperty('marker'); })()".EvalScriptAsync();
+ Assert.AreEqual("kept,true", result);
+ }
+
+ // Existence is answered from the collection's length alone, without producing the
+ // element - so it has to keep agreeing with what reading it would say.
+ [Test]
+ public async Task ExistenceOfAnIndexAgreesWithReadingIt()
+ {
+ var result = await "(function () { var c = document.getElementsByTagName('script'); var r = []; for (var i = 0; i < 3; i++) { r.push((i in c) + ':' + (c[i] !== undefined)); } return r.join(); })()".EvalScriptAsync();
+ Assert.AreEqual("true:true,false:false,false:false", result);
+ }
+
+ [Test]
+ public async Task NumericIndexerOfNamedNodeMapStillReadsBothWays()
+ {
+ var result = await "(function () { var d = document.createElement('div'); d.setAttribute('title', 't'); var a = d.attributes; return a.length + ',' + a[0].name + ',' + a.title.value; })()".EvalScriptAsync();
+ Assert.AreEqual("1,title,t", result);
+ }
+
+ // A symbol cannot be an index, and it must not be turned into one either - the
+ // well-known symbols are probed on every kind of object by library code.
+ [Test]
+ public async Task SymbolKeyOnAnIndexedCollectionIsNotTreatedAsAnIndex()
+ {
+ var result = await "(function () { var c = document.getElementsByTagName('script'); return c.hasOwnProperty(Symbol.toStringTag) + ',' + (typeof c[Symbol.toStringTag]); })()".EvalScriptAsync();
+ Assert.AreEqual("false,string", result);
+ }
+
+ // The node's own property set lives in the DOM, so nothing in the engine changes
+ // when an entry appears there. A name that was absent has to start resolving on the
+ // node from the very next read, rather than staying on whatever the prototype said.
+ [Test]
+ public async Task NamedEntryStartsResolvingOnTheNodeAsSoonAsItExists()
+ {
+ var result = await "(function () { var d = document.createElement('div'), a = d.attributes, before = typeof a.title; d.setAttribute('title', 't'); return before + ',' + a.title.value + ',' + a.hasOwnProperty('title'); })()".EvalScriptAsync();
+ Assert.AreEqual("undefined,t,true", result);
+ }
+
+ [Test]
+ public async Task AccessorDefinedOnANodeByScriptIsInvokedOnRead()
+ {
+ var result = await "(function () { var d = document.createElement('div'); Object.defineProperty(d, 'marker', { get: function () { return 'from getter'; } }); return d.marker + ',' + d.hasOwnProperty('marker'); })()".EvalScriptAsync();
+ Assert.AreEqual("from getter,true", result);
+ }
+
+ [Test]
+ public async Task NonEnumerablePropertyOfANodeIsSeenButNotEnumerated()
+ {
+ var result = await "(function () { var d = document.createElement('div'); Object.defineProperty(d, 'hidden2', { value: 1 }); return d.hasOwnProperty('hidden2') + ',' + d.propertyIsEnumerable('hidden2') + ',' + Object.keys(d).length; })()".EvalScriptAsync();
+ Assert.AreEqual("true,false,0", result);
+ }
+
+ [Test]
+ public async Task PropertyAssignedToANodeIsCopiedAndSerialized()
+ {
+ var result = await "(function () { var d = document.createElement('div'); d.custom = 'v'; return JSON.stringify(d) + ',' + JSON.stringify(Object.assign({}, d)); })()".EvalScriptAsync();
+ Assert.AreEqual("{\"custom\":\"v\"},{\"custom\":\"v\"}", result);
+ }
}
}
diff --git a/src/AngleSharp.Js/AngleSharp.Js.csproj b/src/AngleSharp.Js/AngleSharp.Js.csproj
index 1c13d78..9274feb 100644
--- a/src/AngleSharp.Js/AngleSharp.Js.csproj
+++ b/src/AngleSharp.Js/AngleSharp.Js.csproj
@@ -19,7 +19,7 @@
-
+
diff --git a/src/AngleSharp.Js/Cache/CollectionTypeCache.cs b/src/AngleSharp.Js/Cache/CollectionTypeCache.cs
new file mode 100644
index 0000000..c925780
--- /dev/null
+++ b/src/AngleSharp.Js/Cache/CollectionTypeCache.cs
@@ -0,0 +1,177 @@
+namespace AngleSharp.Js.Cache
+{
+ using AngleSharp.Attributes;
+ using AngleSharp.Text;
+ using System;
+ using System.Collections.Concurrent;
+ using System.Linq;
+ using System.Linq.Expressions;
+ using System.Reflection;
+
+ ///
+ /// Decides whether a DOM type is an indexed collection - a node list, an HTML collection, a
+ /// token list, a named node map - and resolves the two members that make it one.
+ ///
+ ///
+ /// This is the WebIDL shape: an indexed property getter plus a "length". Both are required,
+ /// because the engine derives the whole array-like property model from them and an object
+ /// offering only one of the two would be a collection whose end nothing can find. The answer
+ /// depends on the type alone, so the cache is process-wide - the reflection runs once per
+ /// type however many documents are opened.
+ ///
+ static class CollectionTypeCache
+ {
+ private static readonly ConcurrentDictionary _collections = new();
+
+ public static IndexedCollection GetIndexedCollection(this Type type) =>
+ _collections.GetOrAdd(type, static current => Resolve(current));
+
+ private static IndexedCollection Resolve(Type type)
+ {
+ MethodInfo indexer = null;
+ MethodInfo length = null;
+
+ foreach (var current in type.GetTypeTree())
+ {
+ foreach (var property in current.GetTypeInfo().DeclaredProperties)
+ {
+ var getter = property.GetMethod;
+
+ if (getter == null)
+ {
+ continue;
+ }
+
+ var indexParameters = property.GetIndexParameters();
+
+ if (indexParameters.Length == 1)
+ {
+ // The same test SetIndexer applies, so a type is a collection here
+ // exactly when its prototype would have offered a numeric indexer.
+ if (indexer == null &&
+ indexParameters[0].ParameterType == typeof(Int32) &&
+ IsIndexedAccessor(property))
+ {
+ indexer = getter;
+ }
+ }
+ else if (length == null && indexParameters.Length == 0 && IsLength(property))
+ {
+ length = getter;
+ }
+ }
+ }
+
+ return indexer != null && length != null ? IndexedCollection.TryCreate(type, indexer, length) : null;
+ }
+
+ private static Boolean IsIndexedAccessor(PropertyInfo property)
+ {
+ var accessor = property.GetCustomAttribute()?.Type;
+
+ if (accessor.HasValue && (accessor.Value & (Accessors.Getter | Accessors.Setter)) != 0)
+ {
+ return true;
+ }
+
+ return property.GetCustomAttributes().Any(m => m.OfficialName.Is("item"));
+ }
+
+ private static Boolean IsLength(PropertyInfo property) =>
+ property.PropertyType == typeof(Int32) &&
+ property.GetCustomAttributes().Any(m => m.OfficialName.Is("length"));
+ }
+
+ ///
+ /// The indexed getter and the length of a collection type, compiled once.
+ ///
+ ///
+ /// These two run on every single indexed read, which is the one place in this binding where
+ /// reflection is genuinely too slow: the engine reads an element without materialising a
+ /// descriptor or a key, so a MethodInfo.Invoke and the object[] it needs would be all that is
+ /// left of the cost. They are compiled instead, against the interface that declares them, so
+ /// the call is a virtual dispatch that lands on whatever concrete class the target happens to
+ /// be - the case a reflective invoke of an explicitly re-implemented member gets wrong.
+ ///
+ sealed class IndexedCollection
+ {
+ private readonly Func