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 _length; + private readonly Func _item; + + private IndexedCollection(Func length, Func item) + { + _length = length; + _item = item; + } + + public static IndexedCollection TryCreate(Type type, MethodInfo indexer, MethodInfo length) + { + // What carries the DOM attributes and what can actually be called are not always the + // same member: an interface may re-implement one of its own bases explicitly, as + // IHtmlCollection does with "T IReadOnlyList.this[Int32]", and that member is + // private and abstract. The declaration is what identifies the collection, the + // public method of the same name on the concrete class is what runs. + var resolvedIndexer = ResolvePublic(type, indexer); + var resolvedLength = ResolvePublic(type, length); + + if (resolvedIndexer == null || resolvedLength == null) + { + return null; + } + + try + { + return new IndexedCollection( + CompileLength(type, resolvedLength), + CompileItem(type, resolvedIndexer)); + } + catch (Exception) + { + // A member that cannot be expressed as an expression tree leaves the type on the + // ordinary node proxy, which reaches the same accessors reflectively. + return null; + } + } + + private static MethodInfo ResolvePublic(Type type, MethodInfo declared) + { + if (declared.IsPublic && declared.DeclaringType.GetTypeInfo().IsAssignableFrom(type.GetTypeInfo())) + { + return declared; + } + + var name = declared.Name; + var simpleName = name.Substring(name.LastIndexOf('.') + 1); + var parameters = declared.GetParameters(); + var parameterTypes = new Type[parameters.Length]; + + for (var i = 0; i < parameters.Length; i++) + { + parameterTypes[i] = parameters[i].ParameterType; + } + + var resolved = type.GetRuntimeMethod(simpleName, parameterTypes); + return resolved != null && resolved.IsPublic ? resolved : null; + } + + public Int32 GetLength(Object target) => _length.Invoke(target); + + public Object GetItem(Object target, Int32 index) => _item.Invoke(target, index); + + private static Func CompileLength(Type type, MethodInfo length) + { + var target = Expression.Parameter(typeof(Object), "target"); + var call = Expression.Call(Expression.Convert(target, type), length); + return Expression.Lambda>(call, target).Compile(); + } + + private static Func CompileItem(Type type, MethodInfo indexer) + { + var target = Expression.Parameter(typeof(Object), "target"); + var index = Expression.Parameter(typeof(Int32), "index"); + var call = Expression.Call(Expression.Convert(target, type), indexer, index); + return Expression.Lambda>( + Expression.Convert(call, typeof(Object)), target, index).Compile(); + } + } +} diff --git a/src/AngleSharp.Js/Cache/ReferenceCache.cs b/src/AngleSharp.Js/Cache/ReferenceCache.cs index e081f84..c46542b 100644 --- a/src/AngleSharp.Js/Cache/ReferenceCache.cs +++ b/src/AngleSharp.Js/Cache/ReferenceCache.cs @@ -1,18 +1,19 @@ namespace AngleSharp.Js { + using Jint.Native.Object; using System; using System.Runtime.CompilerServices; sealed class ReferenceCache { - private readonly ConditionalWeakTable _references; + private readonly ConditionalWeakTable _references; public ReferenceCache() { - _references = new ConditionalWeakTable(); + _references = new ConditionalWeakTable(); } - public DomNodeInstance GetOrCreate(Object obj, Func creator) => + public ObjectInstance GetOrCreate(Object obj, Func creator) => _references.GetValue(obj, creator.Invoke); } } diff --git a/src/AngleSharp.Js/EngineInstance.cs b/src/AngleSharp.Js/EngineInstance.cs index de09f1f..be57b95 100644 --- a/src/AngleSharp.Js/EngineInstance.cs +++ b/src/AngleSharp.Js/EngineInstance.cs @@ -58,7 +58,7 @@ public EngineInstance(IWindow window, IDictionary assignments, I _engine.SetValue(assignment.Key, assignment.Value); } - _window = GetDomNode(window); + _window = (DomNodeInstance)GetDomNode(window); foreach (var lib in libs) { @@ -96,7 +96,7 @@ public EngineInstance(IWindow window, IDictionary assignments, I #region Methods - public DomNodeInstance GetDomNode(Object obj) => _references.GetOrCreate(obj, CreateInstance); + public ObjectInstance GetDomNode(Object obj) => _references.GetOrCreate(obj, CreateInstance); public ObjectInstance GetDomPrototype(Type type) => _prototypes.GetOrCreate(type, CreatePrototype); @@ -215,7 +215,20 @@ private JsValue ImportModule(String specifier, String source) #region Helpers - private DomNodeInstance CreateInstance(Object obj) => new DomNodeInstance(this, obj); + // A collection is projected by the array-like proxy, which lets the engine read its + // indices and length directly; everything else by the ordinary one. The decision is a + // property of the type, so it is resolved once per type for the whole process. + private ObjectInstance CreateInstance(Object obj) + { + var collection = obj.GetType().GetIndexedCollection(); + + if (collection != null) + { + return new DomCollectionInstance(this, obj, collection); + } + + return new DomNodeInstance(this, obj); + } private ObjectInstance CreatePrototype(Type type) => new DomPrototypeInstance(this, type); diff --git a/src/AngleSharp.Js/Extensions/EngineExtensions.cs b/src/AngleSharp.Js/Extensions/EngineExtensions.cs index 8162d1b..9a27fc6 100644 --- a/src/AngleSharp.Js/Extensions/EngineExtensions.cs +++ b/src/AngleSharp.Js/Extensions/EngineExtensions.cs @@ -246,9 +246,9 @@ public static JsValue Call(this EngineInstance instance, MethodInfo method, JsVa { if (method != null) { - DomNodeInstance nodeInstance; + IDomProxy nodeInstance; - if (thisObject.Type == Types.Object && thisObject.AsObject() is DomNodeInstance node) + if (thisObject.Type == Types.Object && thisObject.AsObject() is IDomProxy node) { nodeInstance = node; } @@ -262,7 +262,7 @@ public static JsValue Call(this EngineInstance instance, MethodInfo method, JsVa if (method.IsStatic) { var newArgs = new JsValue[arguments.Length + 1]; - newArgs[0] = nodeInstance; + newArgs[0] = (JsValue)nodeInstance; Array.Copy(arguments, 0, newArgs, 1, arguments.Length); var parameters = instance.BuildArgs(method, newArgs); return method.Invoke(null, parameters).ToJsValue(instance); diff --git a/src/AngleSharp.Js/Extensions/JsValueExtensions.cs b/src/AngleSharp.Js/Extensions/JsValueExtensions.cs index d56a556..c6152a3 100644 --- a/src/AngleSharp.Js/Extensions/JsValueExtensions.cs +++ b/src/AngleSharp.Js/Extensions/JsValueExtensions.cs @@ -56,7 +56,7 @@ public static Object FromJsValue(this JsValue val) return val.AsString(); case Types.Object: var obj = val.AsObject(); - var node = obj as DomNodeInstance; + var node = obj as IDomProxy; return node != null ? node.Value : obj; case Types.Undefined: return JsValue.Undefined.ToString(); diff --git a/src/AngleSharp.Js/Proxies/DomCollectionInstance.cs b/src/AngleSharp.Js/Proxies/DomCollectionInstance.cs new file mode 100644 index 0000000..8f75431 --- /dev/null +++ b/src/AngleSharp.Js/Proxies/DomCollectionInstance.cs @@ -0,0 +1,132 @@ +namespace AngleSharp.Js +{ + using AngleSharp.Js.Cache; + using Jint.Native; + using Jint.Native.Object; + using Jint.Native.Symbol; + using Jint.Runtime.Descriptors; + using System; + using System.Reflection; + + /// + /// The JS object standing for a DOM collection - a node list, an HTML collection, a token + /// list, a named node map. Jint derives the whole array-like property model from the two + /// members below, so indices and "length" are answered out of the collection itself with no + /// descriptor, no reflection over the prototype and no exception at the end. + /// + /// + /// It is deliberately not an Array, which is what a browser reports too: "Array.isArray" is + /// false and the prototype chain is still the DOM one. What it does gain is the iterator, so + /// "for...of", spread and "Array.from" work on a collection the way they do in a browser. + /// + sealed class DomCollectionInstance : ArrayLikeObject, IDomProxy + { + private readonly EngineInstance _instance; + private readonly Object _value; + private readonly IndexedCollection _collection; + + public DomCollectionInstance(EngineInstance engine, Object value, IndexedCollection collection) + : base(engine.Jint) + { + _instance = engine; + _value = value; + _collection = collection; + + Prototype = engine.GetDomPrototype(value.GetType()); + + // A DOM collection is iterable in a browser, and wiring the array iterator is what + // the specifications themselves prescribe for it. Jint recognises that exact + // function and takes its array-like path through TryGetIndex for it. + FastSetProperty(GlobalSymbolRegistry.Iterator, new PropertyDescriptor( + engine.Jint.Intrinsics.Array.PrototypeObject.Get(GlobalSymbolRegistry.Iterator), + true, false, true)); + } + + public Object Value => _value; + + public override Object ToObject() => _value; + + public override UInt32 Length + { + get + { + var length = _collection.GetLength(_value); + return length > 0 ? (UInt32)length : 0; + } + } + + public override Boolean TryGetIndex(UInt32 index, out JsValue value) + { + // Length is read live, so an index can go out of range between the two calls, and + // a collection whose accessor is stricter than its length still has to answer + // rather than throw. + if (index < Length) + { + try + { + value = _collection.GetItem(_value, (Int32)index).ToJsValue(_instance); + return true; + } + catch (ArgumentOutOfRangeException) + { + } + } + + value = JsValue.Undefined; + return false; + } + + /// + /// Whether the collection currently has an element at the index. Containment in an + /// indexed collection is the length comparison and nothing else, so the questions that + /// only want a yes or a no - "in", hasOwnProperty, the key enumerations, the hole test + /// the Array.prototype generics run per element - stop projecting an element, wrapping + /// it in a JS value and looking it up in the identity cache just to drop it again. + /// + /// + /// The one case where this could disagree with is a collection + /// whose accessor refuses an index its own length admits. No DOM collection does that, + /// and a Debug build of Jint checks both directions of the agreement on every probe, so + /// the whole test suite is the proof. + /// + protected override Boolean HasIndex(UInt32 index) => index < Length; + + /// + /// Named entries - "document.forms.login", "el.attributes.title" - are the one thing a + /// collection answers that the array-like model knows nothing about. + /// + /// + /// Indices and "length" have to reach the base implementation untouched, which is why + /// the base is asked first: it owns them, and a Debug build of Jint verifies that it was + /// allowed to answer them. + /// + public override PropertyDescriptor GetOwnProperty(JsValue property) + { + var descriptor = base.GetOwnProperty(property); + + if (!ReferenceEquals(descriptor, PropertyDescriptor.Undefined)) + { + return descriptor; + } + + if (Prototype is DomPrototypeInstance prototype && + prototype.TryGetFromNamedIndex(_value, property, out _)) + { + return prototype.CreateNamedDescriptor(_value, property.ToString()); + } + + return PropertyDescriptor.Undefined; + } + + protected override void SetOwnProperty(JsValue property, PropertyDescriptor desc) + { + if (Prototype is DomPrototypeInstance prototype && + prototype.TrySetToIndex(_value, property, desc.Value)) + { + return; + } + + base.SetOwnProperty(property, desc); + } + } +} diff --git a/src/AngleSharp.Js/Proxies/DomConstructorInstance.cs b/src/AngleSharp.Js/Proxies/DomConstructorInstance.cs index a85e493..0759b8d 100644 --- a/src/AngleSharp.Js/Proxies/DomConstructorInstance.cs +++ b/src/AngleSharp.Js/Proxies/DomConstructorInstance.cs @@ -76,7 +76,7 @@ private JsValue HasInstance(JsValue thisObject, JsValue[] arguments) { var value = arguments.Length > 0 ? arguments[0] : JsValue.Undefined; - if (value is DomNodeInstance node && IsInstance(node.Value)) + if (value is IDomProxy node && IsInstance(node.Value)) { return JsBoolean.True; } diff --git a/src/AngleSharp.Js/Proxies/DomNodeInstance.cs b/src/AngleSharp.Js/Proxies/DomNodeInstance.cs index 4812a1b..dd8f66a 100644 --- a/src/AngleSharp.Js/Proxies/DomNodeInstance.cs +++ b/src/AngleSharp.Js/Proxies/DomNodeInstance.cs @@ -7,7 +7,7 @@ namespace AngleSharp.Js using System; using System.Collections.Generic; - sealed class DomNodeInstance : ObjectInstance + sealed class DomNodeInstance : ObjectInstance, IDomProxy { private readonly EngineInstance _instance; private readonly Object _value; @@ -71,22 +71,124 @@ public override PropertyDescriptor GetOwnProperty(JsValue property) // itself. The members of the DOM interface live on the prototype, so finding // them is the engine's job - answering them here would make the node claim // every inherited member as its own. - if (Prototype is DomPrototypeInstance prototype && - prototype.TryGetFromIndex(_value, property.ToString(), out var descriptor)) + switch (LookupIndex(property, out var indexed)) { - return descriptor; + case DomPrototypeInstance.IndexerResult.Value: + return new PropertyDescriptor(indexed.ToJsValue(_instance), false, false, false); + case DomPrototypeInstance.IndexerResult.Absent: + return PropertyDescriptor.Undefined; + case DomPrototypeInstance.IndexerResult.Named: + return ((DomPrototypeInstance)Prototype).CreateNamedDescriptor(_value, property.ToString()); } return base.GetOwnProperty(property); } + /// + /// Hands the engine the value of an own property directly, with no descriptor in + /// between. Jint asks this wherever it only wants the value, which is every read a + /// script performs, so an indexed read no longer allocates a descriptor for the + /// engine to unwrap and drop. + /// + /// + /// Returning false is a statement that the node has no own property of that name, + /// not that the value was awkward to produce: the engine trusts it, does not ask + /// again, and continues the read on the prototype. It is exactly what the discarded + /// descriptor used to prove, which is what lets the engine keep its prototype cache + /// while a node's own property set lives outside the engine entirely. + /// + protected override Boolean TryGetOwnPropertyValue(JsValue property, JsValue receiver, out JsValue value) + { + switch (LookupIndex(property, out var indexed)) + { + case DomPrototypeInstance.IndexerResult.Value: + value = indexed.ToJsValue(_instance); + return true; + case DomPrototypeInstance.IndexerResult.Absent: + value = JsValue.Undefined; + return false; + case DomPrototypeInstance.IndexerResult.Named: + // What the accessor pair's getter would have returned, without building + // the pair. Null means the name is only writable, and reads as undefined. + value = indexed == null ? JsValue.Undefined : indexed.ToJsValue(_instance); + return true; + } + + // What is left is what script or the window mirror wrote onto the node. Jint's + // own fallback would route back through GetOwnProperty and ask the indexers a + // second time, so the property bag is read here instead. + var descriptor = base.GetOwnProperty(property); + + if (ReferenceEquals(descriptor, PropertyDescriptor.Undefined)) + { + value = JsValue.Undefined; + return false; + } + + if (descriptor.Get == null) + { + // Value also covers a descriptor that produces its value on read - the + // deferred constructors on the window are of that kind. + value = descriptor.Value ?? JsValue.Undefined; + return true; + } + + // An accessor has to be invoked against the receiver, and that is the engine's + // job. Script installs those on nodes - React tracks input values that way. + return base.TryGetOwnPropertyValue(property, receiver, out value); + } + + /// + /// Answers whether an own property exists, and whether it enumerates, without + /// building a descriptor for it. Backs "in", hasOwnProperty, Object.assign, object + /// spread and JSON.stringify. + /// + /// + /// The answer has to be the one would give at the same + /// moment - the engine does not verify it, and a wrong "missing" silently drops the + /// property from every one of those. Both go through the same lookup for that reason. + /// + protected override OwnPropertyProbe ProbeOwnProperty(JsValue property) + { + switch (LookupIndex(property, out _)) + { + case DomPrototypeInstance.IndexerResult.Value: + // The attributes GetOwnProperty gives an indexed entry. + return OwnPropertyProbe.NonEnumerable; + case DomPrototypeInstance.IndexerResult.Absent: + return OwnPropertyProbe.Missing; + case DomPrototypeInstance.IndexerResult.Named: + return OwnPropertyProbe.NonEnumerable; + } + + var descriptor = base.GetOwnProperty(property); + + if (ReferenceEquals(descriptor, PropertyDescriptor.Undefined)) + { + return OwnPropertyProbe.Missing; + } + + return descriptor.Enumerable ? OwnPropertyProbe.Enumerable : OwnPropertyProbe.NonEnumerable; + } + + private DomPrototypeInstance.IndexerResult LookupIndex(JsValue property, out Object indexed) + { + if (Prototype is DomPrototypeInstance prototype) + { + return prototype.TryGetFromIndex(_value, property, out indexed); + } + + indexed = null; + return DomPrototypeInstance.IndexerResult.None; + } + protected override void SetOwnProperty(JsValue property, PropertyDescriptor desc) { if (Prototype is DomPrototypeInstance prototype) { var value = desc.Value; - if (prototype.TrySetToIndex(_value, property.ToString(), value)) + if (prototype.TrySetToIndex(_value, property, value)) { return; } diff --git a/src/AngleSharp.Js/Proxies/DomPrototypeInstance.cs b/src/AngleSharp.Js/Proxies/DomPrototypeInstance.cs index 95bc5f3..d12769b 100644 --- a/src/AngleSharp.Js/Proxies/DomPrototypeInstance.cs +++ b/src/AngleSharp.Js/Proxies/DomPrototypeInstance.cs @@ -118,28 +118,54 @@ public void DefineDeferredProperty(String name, PropertyDescriptor descriptor) } } - public Boolean TryGetFromIndex(Object value, String index, out PropertyDescriptor result) + /// + /// Asks the indexers this prototype offers about one property name, against the object + /// being indexed rather than against the prototype itself. + /// + /// + /// The raw CLR value comes back instead of a because + /// only one of the three callers wants a descriptor: the engine asks a host object for + /// the value of an own property and for its mere existence separately, and building a + /// descriptor for either is pure waste - and for a named entry it builds two + /// s with it. + /// + public IndexerResult TryGetFromIndex(Object value, JsValue property, out Object result) { - // If we have a numeric indexer and the property is numeric - result = default; + result = null; EnsureInitialized(); + // Reached for every property lookup on every node, and most DOM types carry no + // indexer at all. Nothing below may run before that is ruled out - not even + // turning the key into a String. + if (_numericIndexer == null && _stringIndexerGetter == null) + { + return IndexerResult.None; + } + + // A symbol is never an index, and JsSymbol.ToString() composes "Symbol(...)" + // every time it is asked. Library code probes Symbol.toStringTag constantly. + if (property is JsSymbol) + { + return IndexerResult.None; + } + + var index = property.ToString(); + + // If we have a numeric indexer and the property is numeric if (_numericIndexer != null && Int32.TryParse(index, out var numericIndex)) { try { var args = new Object[] { numericIndex }; - var orig = _numericIndexer.Invoke(value, args); - result = new PropertyDescriptor(orig.ToJsValue(_instance), false, false, false); - return true; + result = _numericIndexer.Invoke(value, args); + return IndexerResult.Value; } catch (TargetInvocationException ex) { if (ex.InnerException is ArgumentOutOfRangeException) { - result = PropertyDescriptor.Undefined; - return true; + return IndexerResult.Absent; } throw; @@ -147,58 +173,111 @@ public Boolean TryGetFromIndex(Object value, String index, out PropertyDescripto } // Else a string property + return TryGetFromNamedIndex(value, property, out result) ? IndexerResult.Named : IndexerResult.None; + } + + /// + /// The string-indexer half on its own, for a collection whose numeric half the engine + /// owns and must not be asked for twice. + /// + public Boolean TryGetFromNamedIndex(Object value, JsValue property, out Object result) + { + result = null; + + EnsureInitialized(); + + if (_stringIndexerGetter == null || property is JsSymbol) + { + return false; + } + + var index = property.ToString(); + // If we have a string indexer and no property exists for this name then use the string indexer // Jint possibly has a limitation here - if an object has a string indexer. How do we know whether to use the defined indexer or a property? // Eg. object.callMethod1() vs object['callMethod1'] is not necessarily the same if the object has a string indexer?? (I'm not an ECMA expert!) // node.attributes is one such object - has both a string and numeric indexer // This GetOwnProperty override might need an additional parameter to let us know this was called via an indexer - if (_stringIndexerGetter != null && !HasProperty(index)) + // + // HasProperty takes a JsValue, so handing it the String would build one per + // lookup - and every read of an ordinary member of an indexed collection, say + // the length a loop tests, comes through here. The engine already passed one in. + if (HasProperty(property as JsString ?? (JsValue)index)) { - var args = new Object[] { index }; - var valueAtIndex = _stringIndexerGetter.Invoke(value, args); + return false; + } - if (valueAtIndex == null && _stringIndexerSetter == null) - { - result = PropertyDescriptor.Undefined; - return false; - } + var valueAtIndex = _stringIndexerGetter.Invoke(value, new Object[] { index }); - var getter = new ClrFunction(_instance.Jint, index, (obj, values) => - { - var current = _stringIndexerGetter.Invoke(value, args); - return current == null ? JsValue.Undefined : current.ToJsValue(_instance); - }); + if (valueAtIndex == null && _stringIndexerSetter == null) + { + return false; + } - ClrFunction setter = null; + // Null with a setter present is still an own property - one that reads as + // undefined - because a write to it still has to reach the setter. + result = valueAtIndex; + return true; + } - if (_stringIndexerSetter != null) - { - setter = new ClrFunction(_instance.Jint, index, (obj, values) => - { - var valueToSet = values.Length > 0 ? values[0] : JsValue.Undefined; - _stringIndexerSetter.Invoke(value, new Object[] { index, valueToSet }, _instance); - return valueToSet; - }); - } + /// + /// Builds the descriptor for a name the string indexer claims. It is an accessor pair + /// rather than a value so that both directions stay live: the getter re-reads the + /// indexer, and the setter is what forwards a write to it. + /// + public PropertyDescriptor CreateNamedDescriptor(Object value, String index) + { + var args = new Object[] { index }; - result = new GetSetPropertyDescriptor(getter, setter, false, false); - return true; + // With nothing to forward a write to there is nothing an accessor pair would add: + // the value is read at the same moment either way, and the pair costs two function + // objects on a path that runs per read. + if (_stringIndexerSetter == null) + { + var current = _stringIndexerGetter.Invoke(value, args); + return new PropertyDescriptor( + current == null ? JsValue.Undefined : current.ToJsValue(_instance), false, false, false); + } + + var getter = new ClrFunction(_instance.Jint, index, (obj, values) => + { + var current = _stringIndexerGetter.Invoke(value, args); + return current == null ? JsValue.Undefined : current.ToJsValue(_instance); + }); + + ClrFunction setter = null; + + if (_stringIndexerSetter != null) + { + setter = new ClrFunction(_instance.Jint, index, (obj, values) => + { + var valueToSet = values.Length > 0 ? values[0] : JsValue.Undefined; + _stringIndexerSetter.Invoke(value, new Object[] { index, valueToSet }, _instance); + return valueToSet; + }); } - return false; + return new GetSetPropertyDescriptor(getter, setter, false, false); } - public Boolean TrySetToIndex(Object value, String index, JsValue newValue) + public Boolean TrySetToIndex(Object value, JsValue property, JsValue newValue) { EnsureInitialized(); - if (_stringIndexerSetter != null && !HasProperty(index)) + if (_stringIndexerSetter == null) { - _stringIndexerSetter.Invoke(value, new Object[] { index, newValue }, _instance); - return true; + return false; } - return false; + var index = property.ToString(); + + if (HasProperty(property as JsString ?? (JsValue)index)) + { + return false; + } + + _stringIndexerSetter.Invoke(value, new Object[] { index, newValue }, _instance); + return true; } private void SetExtensionMembers() @@ -329,7 +408,7 @@ private void SetProperty(String name, MethodInfo getter, MethodInfo setter, DomP if (putsForward != null) { var ep = Array.Empty(); - var that = obj as DomNodeInstance ?? _instance.Window; + var that = obj as IDomProxy ?? _instance.Window; var target = getter.Invoke(that.Value, ep); var propName = putsForward.PropertyName; var prop = getter.ReturnType @@ -391,79 +470,29 @@ private void SetMethod(String name, MethodInfo method) } /// - /// One of the indexers a prototype offers, invoked against whichever object is being - /// indexed rather than against the type the prototype was created for. + /// What the indexers of a prototype have to say about a property name. /// - /// - /// A prototype stands for a DOM type, not for a single class - col and colgroup are - /// both an HTMLTableColElement, and every element class carrying no name of its own - /// shares the prototype of its nearest named ancestor. An accessor bound to one of - /// those classes cannot be invoked on the others, so the target decides. - /// - private sealed class Indexer + internal enum IndexerResult { - private readonly MethodInfo _declared; - private readonly ConcurrentDictionary _resolved; - - public Indexer(MethodInfo declared) - { - _declared = declared; - - // A public accessor is dispatched virtually and works on any implementation; - // only an explicitly re-implemented one has to be looked up per target. - _resolved = declared.IsPublic ? null : new ConcurrentDictionary(); - } - - public Object Invoke(Object target, Object[] arguments, EngineInstance engine = null) - { - var method = Resolve(target.GetType()); - - if (engine != null) - { - var parameters = method.GetParameters(); - var converted = new Object[arguments.Length]; - - for (var i = 0; i < arguments.Length; i++) - { - if (arguments[i] is JsValue value) - { - converted[i] = value.As(parameters[i].ParameterType, engine); - } - else - { - converted[i] = arguments[i]; - } - } - - arguments = converted; - } - - return method.Invoke(target, arguments); - } - - private MethodInfo Resolve(Type targetType) => - _resolved == null ? _declared : _resolved.GetOrAdd(targetType, ResolveCore); - - private MethodInfo ResolveCore(Type targetType) - { - // An interface may re-implement a member of one of its own base interfaces - // explicitly, e.g. "T IReadOnlyList.this[Int32 index]" declared on an - // IHtmlCollection. Such a member is private and abstract - invoking it - // reflectively throws an EntryPointNotFoundException because the actual - // implementation lives in a different slot. - var name = _declared.Name; - var simpleName = name.Substring(name.LastIndexOf('.') + 1); - var parameters = _declared.GetParameters(); - var parameterTypes = new Type[parameters.Length]; - - for (var i = 0; i < parameters.Length; i++) - { - parameterTypes[i] = parameters[i].ParameterType; - } - - return targetType.GetRuntimeMethod(simpleName, parameterTypes) ?? _declared; - } + /// + /// No indexer claims the name, so the ordinary own-property lookup decides. + /// + None, + /// + /// The numeric indexer answered, and the answer is the value handed back. + /// + Value, + /// + /// The numeric indexer claims the name but has nothing at it, so the object has no + /// own property of that name and the ordinary lookup is not consulted either. + /// + Absent, + /// + /// The string indexer claims the name. The value handed back is what it reads as + /// right now, and may be null - a name the indexer only accepts writes for is + /// still an own property, described by an accessor pair. + /// + Named } } } - diff --git a/src/AngleSharp.Js/Proxies/IDomProxy.cs b/src/AngleSharp.Js/Proxies/IDomProxy.cs new file mode 100644 index 0000000..a303a99 --- /dev/null +++ b/src/AngleSharp.Js/Proxies/IDomProxy.cs @@ -0,0 +1,21 @@ +namespace AngleSharp.Js +{ + using System; + + /// + /// A JS object standing for one CLR DOM object. + /// + /// + /// There are two of them and they cannot share a base class: an indexed collection derives + /// from Jint's array-like base so that the engine knows how to read it, everything else from + /// the ordinary object base. What every caller outside the proxies needs is the same either + /// way - the CLR object behind the proxy - so that is what they agree on. + /// + interface IDomProxy + { + /// + /// Gets the DOM object this proxy stands for. + /// + Object Value { get; } + } +} diff --git a/src/AngleSharp.Js/Proxies/Indexer.cs b/src/AngleSharp.Js/Proxies/Indexer.cs new file mode 100644 index 0000000..12f135f --- /dev/null +++ b/src/AngleSharp.Js/Proxies/Indexer.cs @@ -0,0 +1,82 @@ +namespace AngleSharp.Js +{ + using Jint.Native; + using System; + using System.Collections.Concurrent; + using System.Reflection; + + /// + /// One of the indexers a prototype offers, invoked against whichever object is being + /// indexed rather than against the type the prototype was created for. + /// + /// + /// A prototype stands for a DOM type, not for a single class - col and colgroup are + /// both an HTMLTableColElement, and every element class carrying no name of its own + /// shares the prototype of its nearest named ancestor. An accessor bound to one of + /// those classes cannot be invoked on the others, so the target decides. + /// + sealed class Indexer + { + private readonly MethodInfo _declared; + private readonly ConcurrentDictionary _resolved; + + public Indexer(MethodInfo declared) + { + _declared = declared; + + // A public accessor is dispatched virtually and works on any implementation; + // only an explicitly re-implemented one has to be looked up per target. + _resolved = declared.IsPublic ? null : new ConcurrentDictionary(); + } + + public Object Invoke(Object target, Object[] arguments, EngineInstance engine = null) + { + var method = Resolve(target.GetType()); + + if (engine != null) + { + var parameters = method.GetParameters(); + var converted = new Object[arguments.Length]; + + for (var i = 0; i < arguments.Length; i++) + { + if (arguments[i] is JsValue value) + { + converted[i] = value.As(parameters[i].ParameterType, engine); + } + else + { + converted[i] = arguments[i]; + } + } + + arguments = converted; + } + + return method.Invoke(target, arguments); + } + + private MethodInfo Resolve(Type targetType) => + _resolved == null ? _declared : _resolved.GetOrAdd(targetType, ResolveCore); + + private MethodInfo ResolveCore(Type targetType) + { + // An interface may re-implement a member of one of its own base interfaces + // explicitly, e.g. "T IReadOnlyList.this[Int32 index]" declared on an + // IHtmlCollection. Such a member is private and abstract - invoking it + // reflectively throws an EntryPointNotFoundException because the actual + // implementation lives in a different slot. + var name = _declared.Name; + var simpleName = name.Substring(name.LastIndexOf('.') + 1); + var parameters = _declared.GetParameters(); + var parameterTypes = new Type[parameters.Length]; + + for (var i = 0; i < parameters.Length; i++) + { + parameterTypes[i] = parameters[i].ParameterType; + } + + return targetType.GetRuntimeMethod(simpleName, parameterTypes) ?? _declared; + } + } +}