From 57a3e78d1e86cdab6b7462137b4502f184a3281b Mon Sep 17 00:00:00 2001 From: Marko Lahma Date: Mon, 27 Jul 2026 15:28:42 +0300 Subject: [PATCH 1/3] Update Jint to 4.15.0 The release brings the host-object hooks the DOM proxies use in the next commit, and one change that applies without any code here: an object that overrides GetOwnProperty but not Get is now recognised as having ordinary read semantics, so a dotted DOM read probes the node once instead of twice. Co-Authored-By: Claude Opus 5 (1M context) --- src/AngleSharp.Js/AngleSharp.Js.csproj | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/src/AngleSharp.Js/AngleSharp.Js.csproj b/src/AngleSharp.Js/AngleSharp.Js.csproj index a910151..912ea3b 100644 --- a/src/AngleSharp.Js/AngleSharp.Js.csproj +++ b/src/AngleSharp.Js/AngleSharp.Js.csproj @@ -18,7 +18,7 @@ - + From acf5f790781cba87088b60df4a32cb143edf7a42 Mon Sep 17 00:00:00 2001 From: Marko Lahma Date: Mon, 27 Jul 2026 15:28:57 +0300 Subject: [PATCH 2/3] Answer the engine's own-property questions without building descriptors A node is asked three different things about a property name, and until now all three arrived at GetOwnProperty and got a PropertyDescriptor built for them - including the two that only ever throw it away. Jint 4.15 splits them up, and the node answers each directly: * TryGetOwnPropertyValue hands the value over, so an indexed read no longer allocates a descriptor for the engine to unwrap and drop. Returning false is an authoritative "no own property of that name", which is exactly what the discarded descriptor used to prove. * ProbeOwnProperty answers existence and enumerability, which is what "in", hasOwnProperty, Object.assign, spread and JSON.stringify actually want. An indexed entry no longer has to be converted into a JsValue to be counted. The three share one indexer lookup so that they cannot disagree - the answer one gives has to be the answer the others would give at the same instant, and a Debug build of Jint checks that on every read. The lookup itself now takes the property key rather than its string form, and two things fall out of that: * a key is no longer turned into a String before the type is known to have an indexer at all, and most DOM types have none. A symbol used to compose "Symbol(...)" on every probe, and library code probes Symbol.toStringTag constantly - 83.7 to 2.9 bytes per read; * the string indexer's HasProperty check is handed the key it was given instead of building a JsString for it. That is on the path of every read of an ordinary member of an indexed collection - the length a loop tests - and it is 64 bytes a read, which 4.15 exposes because a host prototype is no longer eligible for the engine's prototype cache. Behaviour is unchanged. The eight tests added to DomTests pass on devel with 4.14.0 as well; they pin the agreement between the three answers, which is the thing this commit could get wrong. Co-Authored-By: Claude Opus 5 (1M context) --- src/AngleSharp.Js.Tests/DomTests.cs | 65 ++++++++ src/AngleSharp.Js/Proxies/DomNodeInstance.cs | 110 ++++++++++++- .../Proxies/DomPrototypeInstance.cs | 144 +++++++++++++----- 3 files changed, 281 insertions(+), 38 deletions(-) diff --git a/src/AngleSharp.Js.Tests/DomTests.cs b/src/AngleSharp.Js.Tests/DomTests.cs index 489ee32..2c4bbf3 100644 --- a/src/AngleSharp.Js.Tests/DomTests.cs +++ b/src/AngleSharp.Js.Tests/DomTests.cs @@ -128,5 +128,70 @@ 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 is still an inherited one. + [Test] + public async Task MemberOfAnIndexedCollectionIsNotMistakenForAnIndex() + { + var result = await "(function () { var c = document.getElementsByTagName('script'); return c.hasOwnProperty('length') + ',' + ('length' in c) + ',' + c.length; })()".EvalScriptAsync(); + Assert.AreEqual("false,true,1", 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/Proxies/DomNodeInstance.cs b/src/AngleSharp.Js/Proxies/DomNodeInstance.cs index 4812a1b..e1c4234 100644 --- a/src/AngleSharp.Js/Proxies/DomNodeInstance.cs +++ b/src/AngleSharp.Js/Proxies/DomNodeInstance.cs @@ -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..3a86c51 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; @@ -152,53 +178,77 @@ public Boolean TryGetFromIndex(Object value, String index, out PropertyDescripto // 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 (_stringIndexerGetter != null && !HasProperty(property as JsString ?? (JsValue)index)) { var args = new Object[] { index }; var valueAtIndex = _stringIndexerGetter.Invoke(value, args); if (valueAtIndex == null && _stringIndexerSetter == null) { - result = PropertyDescriptor.Undefined; - return false; + return IndexerResult.None; } - var getter = new ClrFunction(_instance.Jint, index, (obj, values) => - { - var current = _stringIndexerGetter.Invoke(value, args); - return current == null ? JsValue.Undefined : current.ToJsValue(_instance); - }); + // 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 IndexerResult.Named; + } - ClrFunction setter = null; + return IndexerResult.None; + } - 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 }; + + 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; - result = new GetSetPropertyDescriptor(getter, setter, false, false); - 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; + }); } - 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() @@ -390,6 +440,32 @@ private void SetMethod(String name, MethodInfo method) } } + /// + /// What the indexers of a prototype have to say about a property name. + /// + public enum IndexerResult + { + /// + /// 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 + } + /// /// One of the indexers a prototype offers, invoked against whichever object is being /// indexed rather than against the type the prototype was created for. From 088d95e28b2cee770691292d33ae8ad4077a1afa Mon Sep 17 00:00:00 2001 From: Marko Lahma Date: Mon, 27 Jul 2026 17:13:54 +0300 Subject: [PATCH 3/3] Project DOM collections as array-likes the engine can read directly A node list, an HTML collection, a token list and a named node map are all the same thing to script - a live, read-only, indexed view - and until now every read of one went through the general node proxy: parse the key, reflect the numeric indexer off the prototype, invoke it, wrap the result in a descriptor. An index past the end was signalled by a CLR exception, so a bounds test cost about a kilobyte. Jint 4.15 has a base class for exactly this shape. A collection supplies the members WebIDL says such a thing has - Length, TryGetIndex, and a containment test - and the engine derives the whole property model from them. Nothing is cached, so the view stays live. Containment is the length comparison and nothing else, so every question that only wants a yes or a no - "in", hasOwnProperty, the key enumerations, the hole test the Array.prototype generics run per element - stops projecting an element, wrapping it in a JS value and looking it up in the identity cache just to drop it again. Length and the indexed getter are the one place in this binding where reflection was too slow to leave alone: with the descriptor gone, a MethodInfo.Invoke and its object[] would have been all that was left of an indexed read. They are compiled once per type instead. What carries the DOM attributes and what can be called are not always the same member - IHtmlCollection re-implements "T IReadOnlyList.this[Int32]" explicitly, and that one is private and abstract - so the attributed declaration identifies the collection while the public method of the same name on the concrete class is what runs. Deciding which proxy to build is a property of the type - an indexed getter plus a length, the same pair SetIndexer looks for - so it is resolved once per type for the whole process and never repeats. There was one proxy class for every DOM object and there are now two, which cannot share a base: they agree on IDomProxy instead, which is what every caller outside the proxies actually wanted. Named entries - document.forms.login, el.attributes.title - are not part of the array-like model and stay on the string indexer, with indices and length delegated to the base as that base requires. A named entry whose indexer has no setter is described by a plain value now rather than by an accessor pair: with nothing to forward a write to, the pair only cost two function objects per read. Measured, allocation per operation, net8.0, loop baseline subtracted: | operation | devel + 4.14.0 | this branch | | -------------------------------- | -------------- | ----------- | | list[0] | 326.6 B | 132.1 B | | list[999] out of range | 1086.1 B | 0.5 B | | attrs['id'] | 1021.6 B | 278.4 B | | list.length | 51.9 B | 0.0 B | | 0 in list | 326.6 B | 0.0 B | | list.hasOwnProperty(0) | 472.0 B | 0.5 B | | Array.prototype.forEach.call(..) | 1724.5 B | 568.2 B | What script sees changes in four ways, all toward what a browser does: for...of, spread, Array.from and destructuring work on a collection; Object.keys and for...in report the indices; delete c[0] and defineProperty on an index are refused; and Array.isArray stays false with the prototype chain untouched, so instanceof and Symbol.toStringTag are exactly as before. One deviation is worth stating plainly: "length" becomes an own property of the collection rather than an inherited one, so hasOwnProperty('length') answers true where a browser answers false. It is the model the base class defines, it is what an Array reports too, and reads, "in", enumerability and the value are unchanged. Co-Authored-By: Claude Opus 5 (1M context) --- src/AngleSharp.Js.Tests/DomTests.cs | 84 ++++++++- .../Cache/CollectionTypeCache.cs | 177 ++++++++++++++++++ src/AngleSharp.Js/Cache/ReferenceCache.cs | 7 +- src/AngleSharp.Js/EngineInstance.cs | 19 +- .../Extensions/EngineExtensions.cs | 6 +- .../Extensions/JsValueExtensions.cs | 2 +- .../Proxies/DomCollectionInstance.cs | 132 +++++++++++++ .../Proxies/DomConstructorInstance.cs | 2 +- src/AngleSharp.Js/Proxies/DomNodeInstance.cs | 2 +- .../Proxies/DomPrototypeInstance.cs | 133 +++++-------- src/AngleSharp.Js/Proxies/IDomProxy.cs | 21 +++ src/AngleSharp.Js/Proxies/Indexer.cs | 82 ++++++++ 12 files changed, 562 insertions(+), 105 deletions(-) create mode 100644 src/AngleSharp.Js/Cache/CollectionTypeCache.cs create mode 100644 src/AngleSharp.Js/Proxies/DomCollectionInstance.cs create mode 100644 src/AngleSharp.Js/Proxies/IDomProxy.cs create mode 100644 src/AngleSharp.Js/Proxies/Indexer.cs diff --git a/src/AngleSharp.Js.Tests/DomTests.cs b/src/AngleSharp.Js.Tests/DomTests.cs index 2c4bbf3..dcd220a 100644 --- a/src/AngleSharp.Js.Tests/DomTests.cs +++ b/src/AngleSharp.Js.Tests/DomTests.cs @@ -146,12 +146,90 @@ public async Task IndexBeyondTheEndIsNoOwnPropertyAndReadsUndefined() Assert.AreEqual("false,false,undefined", result); } - // A member of an indexed collection is still an inherited one. + // 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 c.hasOwnProperty('length') + ',' + ('length' in c) + ',' + c.length; })()".EvalScriptAsync(); - Assert.AreEqual("false,true,1", result); + 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 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 e1c4234..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; diff --git a/src/AngleSharp.Js/Proxies/DomPrototypeInstance.cs b/src/AngleSharp.Js/Proxies/DomPrototypeInstance.cs index 3a86c51..d12769b 100644 --- a/src/AngleSharp.Js/Proxies/DomPrototypeInstance.cs +++ b/src/AngleSharp.Js/Proxies/DomPrototypeInstance.cs @@ -173,6 +173,26 @@ public IndexerResult TryGetFromIndex(Object value, JsValue property, out Object } // 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!) @@ -182,23 +202,22 @@ public IndexerResult TryGetFromIndex(Object value, JsValue property, out Object // 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 (_stringIndexerGetter != null && !HasProperty(property as JsString ?? (JsValue)index)) + 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) - { - return IndexerResult.None; - } + var valueAtIndex = _stringIndexerGetter.Invoke(value, new Object[] { index }); - // 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 IndexerResult.Named; + if (valueAtIndex == null && _stringIndexerSetter == null) + { + return false; } - return IndexerResult.None; + // 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; } /// @@ -210,6 +229,16 @@ public PropertyDescriptor CreateNamedDescriptor(Object value, String index) { var args = new Object[] { index }; + // 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); @@ -379,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 @@ -443,7 +472,7 @@ private void SetMethod(String name, MethodInfo method) /// /// What the indexers of a prototype have to say about a property name. /// - public enum IndexerResult + internal enum IndexerResult { /// /// No indexer claims the name, so the ordinary own-property lookup decides. @@ -465,81 +494,5 @@ public enum IndexerResult /// Named } - - /// - /// 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. - /// - private 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; - } - } } } - 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; + } + } +}