From ae4485ae0bb3f646b462325f6af044e1d094b65d Mon Sep 17 00:00:00 2001 From: Marko Lahma Date: Sun, 26 Jul 2026 20:51:18 +0300 Subject: [PATCH 1/2] Fix indexed access into DOM collections Indexing a DOM collection from script - `document.getElementsByTagName('p')[0]`, `element.classList[0]`, and everything jQuery builds on top of them - throws a `TargetInvocationException` wrapping `EntryPointNotFoundException`. `IHtmlCollection`, `ITokenList` and `IStringList` declare their numeric indexer as an explicit re-implementation of `IReadOnlyList`'s indexer. A member declared that way on an interface is private and abstract, so the `MethodInfo` obtained from the interface's `PropertyInfo` cannot be invoked: the implementation lives in a different slot and the runtime has no entry point for the one we hand it. Resolve the indexer accessor once, when the prototype is built, against the type the prototype belongs to, and invoke that. Public accessors - the common case, including every string indexer and the numeric indexers of `INodeList`, `INamedNodeMap`, `IStyleSheetList` and friends - are used unchanged. This is what makes the six currently failing tests in `ScriptingTests` and `JqueryTests` fail; they pass again with this change. Co-Authored-By: Claude Opus 5 (1M context) Claude-Session: https://claude.ai/code/session_0179sA2T7HuRfRfSc2JirFik --- src/AngleSharp.Js.Tests/DomTests.cs | 28 +++++++++ .../Proxies/DomPrototypeInstance.cs | 63 +++++++++++++++---- 2 files changed, 78 insertions(+), 13 deletions(-) diff --git a/src/AngleSharp.Js.Tests/DomTests.cs b/src/AngleSharp.Js.Tests/DomTests.cs index 5d1b975..ffabd0b 100644 --- a/src/AngleSharp.Js.Tests/DomTests.cs +++ b/src/AngleSharp.Js.Tests/DomTests.cs @@ -26,5 +26,33 @@ public async Task NodeHasChildNodesWithChildren() var result = await "new DOMParser().parseFromString(`
`, 'text/html').body.firstChild.hasChildNodes()".EvalScriptAsync(); Assert.AreEqual("True", result); } + + [Test] + public async Task NumericIndexerOfHtmlCollectionYieldsTheElement() + { + var result = await "document.getElementsByTagName('script')[0].nodeName".EvalScriptAsync(); + Assert.AreEqual("SCRIPT", result); + } + + [Test] + public async Task NumericIndexerOfHtmlCollectionOutOfRangeIsUndefined() + { + var result = await "typeof document.getElementsByTagName('script')[5]".EvalScriptAsync(); + Assert.AreEqual("undefined", result); + } + + [Test] + public async Task NumericIndexerOfNodeListYieldsTheNode() + { + var result = await "new DOMParser().parseFromString(`
`, 'text/html').body.childNodes[0].nodeName".EvalScriptAsync(); + Assert.AreEqual("DIV", result); + } + + [Test] + public async Task NumericIndexerOfTokenListYieldsTheToken() + { + var result = await "new DOMParser().parseFromString(`
`, 'text/html').body.firstChild.classList[1]".EvalScriptAsync(); + Assert.AreEqual("b", result); + } } } diff --git a/src/AngleSharp.Js/Proxies/DomPrototypeInstance.cs b/src/AngleSharp.Js/Proxies/DomPrototypeInstance.cs index df226b9..540688a 100644 --- a/src/AngleSharp.Js/Proxies/DomPrototypeInstance.cs +++ b/src/AngleSharp.Js/Proxies/DomPrototypeInstance.cs @@ -15,9 +15,10 @@ sealed class DomPrototypeInstance : ObjectInstance { private readonly String _name; private readonly EngineInstance _instance; + private readonly Type _type; - private PropertyInfo _numericIndexer; - private PropertyInfo _stringIndexer; + private MethodInfo _numericIndexer; + private MethodInfo _stringIndexer; public DomPrototypeInstance(EngineInstance engine, Type type) : base(engine.Jint) @@ -25,6 +26,7 @@ public DomPrototypeInstance(EngineInstance engine, Type type) var baseType = type.GetTypeInfo().BaseType ?? typeof(Object); _name = type.GetOfficialName(baseType); _instance = engine; + _type = type; Set(GlobalSymbolRegistry.ToStringTag, _name); @@ -45,7 +47,7 @@ public Boolean TryGetFromIndex(Object value, String index, out PropertyDescripto try { var args = new Object[] { numericIndex }; - var orig = _numericIndexer.GetMethod.Invoke(value, args); + var orig = _numericIndexer.Invoke(value, args); result = new PropertyDescriptor(orig.ToJsValue(_instance), false, false, false); return true; } @@ -70,7 +72,7 @@ public Boolean TryGetFromIndex(Object value, String index, out PropertyDescripto if (_stringIndexer != null && !HasProperty(index)) { var args = new Object[] { index }; - var valueAtIndex = _stringIndexer.GetMethod.Invoke(value, args); + var valueAtIndex = _stringIndexer.Invoke(value, args); if (valueAtIndex == null) { @@ -229,17 +231,52 @@ private void SetProperty(String name, MethodInfo getter, MethodInfo setter, DomP private void SetIndexer(PropertyInfo property, ParameterInfo[] indexParameters) { - if (indexParameters.Length == 1) + if (indexParameters.Length != 1) { - if (indexParameters[0].ParameterType == typeof(Int32)) - { - _numericIndexer = property; - } - else if (indexParameters[0].ParameterType == typeof(String)) - { - _stringIndexer = property; - } + return; + } + + var getter = ResolveAccessor(property.GetMethod); + + if (getter == null) + { + return; + } + + if (indexParameters[0].ParameterType == typeof(Int32)) + { + _numericIndexer = getter; } + else if (indexParameters[0].ParameterType == typeof(String)) + { + _stringIndexer = getter; + } + } + + private MethodInfo ResolveAccessor(MethodInfo accessor) + { + // 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. Resolve it against the type the + // prototype was created for, which is where the implementation can be found. + if (accessor == null || accessor.IsPublic) + { + return accessor; + } + + var name = accessor.Name; + var simpleName = name.Substring(name.LastIndexOf('.') + 1); + var parameters = accessor.GetParameters(); + var parameterTypes = new Type[parameters.Length]; + + for (var i = 0; i < parameters.Length; i++) + { + parameterTypes[i] = parameters[i].ParameterType; + } + + return _type.GetRuntimeMethod(simpleName, parameterTypes) ?? accessor; } private void SetMethod(String name, MethodInfo method) From 082f7bbfa9d469e2aa3174cd622f209da2ad1dbb Mon Sep 17 00:00:00 2001 From: Marko Lahma Date: Sun, 26 Jul 2026 21:11:55 +0300 Subject: [PATCH 2/2] Register DOM prototype members only when the prototype is used A prototype is created for every DOM type an assembly exposes, and its constructor reflects over the whole type tree of that type right away - properties, methods, events, extension methods, on every interface and base class. A document that touches a dozen DOM types still pays for all of them before the first statement runs. `ObjectInstance` already has the hook for this: `Initialize` is called the first time an object is asked for a property, and Jint marks the instance initialized before calling it, so the registration can use the object normally. Move the constructor body there verbatim - same order, same prototype link established last, so member registration still sees exactly the properties it saw before. Two places needed adjusting so that laziness actually holds: * the prototype link is now set during initialization, so reading it has to initialize too - `Object.getPrototypeOf` and `instanceof` reach it without going through a property lookup; * every exposed type gets a constructor, and the constructor writes `constructor` onto its prototype, which would initialize all of them. That write is deferred and applied at the end of initialization, where the old code performed it. The test suite drops from ~20s to ~3s, which is the same work disappearing: every test builds at least one document. Co-Authored-By: Claude Opus 5 (1M context) Claude-Session: https://claude.ai/code/session_0179sA2T7HuRfRfSc2JirFik --- src/AngleSharp.Js.Tests/DomTests.cs | 14 +++++ .../Proxies/DomConstructorInstance.cs | 14 ++++- .../Proxies/DomPrototypeInstance.cs | 59 +++++++++++++++++-- 3 files changed, 82 insertions(+), 5 deletions(-) diff --git a/src/AngleSharp.Js.Tests/DomTests.cs b/src/AngleSharp.Js.Tests/DomTests.cs index ffabd0b..88b41b7 100644 --- a/src/AngleSharp.Js.Tests/DomTests.cs +++ b/src/AngleSharp.Js.Tests/DomTests.cs @@ -27,6 +27,20 @@ public async Task NodeHasChildNodesWithChildren() Assert.AreEqual("True", result); } + [Test] + public async Task PrototypeChainOfElementIsBuiltCompletely() + { + var result = await "(function () { var p = Object.getPrototypeOf(document.createElement('div')), t = []; while (p) { t.push(p[Symbol.toStringTag]); p = Object.getPrototypeOf(p); } return t.join(); })()".EvalScriptAsync(); + Assert.AreEqual("HTMLDivElement,HTMLElement,Element,Node,EventTarget,", result); + } + + [Test] + public async Task ConstructorPropertyOfPrototypeRefersBackToTheConstructor() + { + var result = await "HTMLDivElement.prototype.constructor === HTMLDivElement".EvalScriptAsync(); + Assert.AreEqual("True", result); + } + [Test] public async Task NumericIndexerOfHtmlCollectionYieldsTheElement() { diff --git a/src/AngleSharp.Js/Proxies/DomConstructorInstance.cs b/src/AngleSharp.Js/Proxies/DomConstructorInstance.cs index 64bd0d3..4c36f1f 100644 --- a/src/AngleSharp.Js/Proxies/DomConstructorInstance.cs +++ b/src/AngleSharp.Js/Proxies/DomConstructorInstance.cs @@ -22,7 +22,19 @@ public DomConstructorInstance(EngineInstance engine, Type type) _instance = engine; FastSetProperty("toString", new PropertyDescriptor(toString, true, false, true)); SetOwnProperty("prototype", new PropertyDescriptor(_objectPrototype, false, false, false)); - _objectPrototype.FastSetProperty("constructor", new PropertyDescriptor(this, true, false, true)); + + var constructor = new PropertyDescriptor(this, true, false, true); + + // Every exposed type gets a constructor, so writing this directly would make + // each of their prototypes register its members right away. + if (_objectPrototype is DomPrototypeInstance domPrototype) + { + domPrototype.DefineDeferredProperty("constructor", constructor); + } + else + { + _objectPrototype.FastSetProperty("constructor", constructor); + } } public DomConstructorInstance(EngineInstance engine, ConstructorInfo constructor) diff --git a/src/AngleSharp.Js/Proxies/DomPrototypeInstance.cs b/src/AngleSharp.Js/Proxies/DomPrototypeInstance.cs index 540688a..a3a393c 100644 --- a/src/AngleSharp.Js/Proxies/DomPrototypeInstance.cs +++ b/src/AngleSharp.Js/Proxies/DomPrototypeInstance.cs @@ -16,25 +16,74 @@ sealed class DomPrototypeInstance : ObjectInstance private readonly String _name; private readonly EngineInstance _instance; private readonly Type _type; + private readonly Type _baseType; + private List> _deferred; + private Boolean _membersSet; private MethodInfo _numericIndexer; private MethodInfo _stringIndexer; public DomPrototypeInstance(EngineInstance engine, Type type) : base(engine.Jint) { - var baseType = type.GetTypeInfo().BaseType ?? typeof(Object); - _name = type.GetOfficialName(baseType); + _baseType = type.GetTypeInfo().BaseType ?? typeof(Object); + _name = type.GetOfficialName(_baseType); _instance = engine; _type = type; + } + + // A document uses a handful of the DOM types an assembly exposes, but a prototype + // is created for every one of them. Reflecting over the whole type tree is by far + // the most expensive part of that, so it waits until the prototype is looked at. + // Jint calls this before serving any property, and marks the instance initialized + // beforehand, so the registration below can use the object as usual. + protected override void Initialize() + { + _membersSet = true; Set(GlobalSymbolRegistry.ToStringTag, _name); - SetAllMembers(type); + SetAllMembers(_type); SetExtensionMembers(); // DOM objects can have properties added dynamically - Prototype = engine.GetDomPrototype(baseType); + Prototype = _instance.GetDomPrototype(_baseType); + + if (_deferred != null) + { + foreach (var property in _deferred) + { + FastSetProperty(property.Key, property.Value); + } + + _deferred = null; + } + } + + // The prototype link is only established once the members are known, so reading it + // has to initialize as well - not every reader goes through a property lookup. + protected override ObjectInstance GetPrototypeOf() + { + EnsureInitialized(); + return base.GetPrototypeOf(); + } + + /// + /// Defines a property on the prototype without forcing its members to be + /// registered. The property is applied after the members, so it keeps + /// overriding a member of the same name. + /// + public void DefineDeferredProperty(String name, PropertyDescriptor descriptor) + { + if (_membersSet) + { + FastSetProperty(name, descriptor); + } + else + { + _deferred = _deferred ?? new List>(); + _deferred.Add(new KeyValuePair(name, descriptor)); + } } public Boolean TryGetFromIndex(Object value, String index, out PropertyDescriptor result) @@ -42,6 +91,8 @@ public Boolean TryGetFromIndex(Object value, String index, out PropertyDescripto // If we have a numeric indexer and the property is numeric result = default; + EnsureInitialized(); + if (_numericIndexer != null && Int32.TryParse(index, out var numericIndex)) { try