From 186be83d2391b4d794bf9df5f5995ee05d301e8c Mon Sep 17 00:00:00 2001 From: Marko Lahma Date: Mon, 27 Jul 2026 10:07:10 +0300 Subject: [PATCH] Make instanceof work for DOM objects `window instanceof Window` and `document.createElement('div') instanceof Element` are both false, and so is every other check against one of the 151 DOM types AngleSharp exposes as an interface (#103). The two sides of the relation never meet. An instance keys its prototype on the internal concrete class - `DomNodeInstance` passes `value.GetType()`, so `HtmlDivElement` - while the constructor keys its own on the exported interface, because `AddConstructors` walks the types carrying `[DomName]` and for the DOM those are interfaces. `PrototypeCache` is keyed by `Type`, so the two end up with a prototype each and the chain walk behind `instanceof` never reaches the constructor's. The 20 types exposed as a class - `Event`, `URL`, `MutationObserver` and the other event types - already worked, because `Construct` hands back a node keyed on that same class. Fold both onto the class that defines the DOM name, i.e. the topmost class carrying it, which is also the class the prototype chain is built from. That makes `HTMLDivElement.prototype` the very object a div already inherits from, so `instanceof` follows from the chain rather than from a special case, and `Object.getPrototypeOf(div) === HTMLDivElement.prototype` holds as well. Two kinds of type cannot be folded and keep answering through an own `Symbol.hasInstance` on the constructor, the way Jint's own `TypeReference` does: a WebIDL mixin such as `ParentNode` has no class to define it, and the closed forms of `IHtmlCollection` are separate types that one prototype cannot stand for. Also give a DOM constructor `Function.prototype` as its prototype - Jint's `Constructor` leaves it at `Object.prototype`, which left them the only functions in the engine without `call`, `apply` or `bind` - and give `DomConstructorFunctionInstance` the `prototype` property it never had, so `new Image() instanceof Image` is an answer rather than a `TypeError`. Since a prototype now stands for a DOM type rather than for a single class, an indexer can no longer be resolved once against the type the prototype was built for: col and colgroup share the `HTMLTableColElement` prototype, and every element class carrying no name of its own shares the one of its nearest named ancestor. Resolve it against whichever object is being indexed instead, cached per target type. Two visible consequences, both matching what a browser does: `b`, `nav`, `noscript` and the other elements AngleSharp has no named class for lose their own untagged prototype level and become `HTMLElement`, so `Object.prototype.toString.call(el)` reports `[object HTMLElement]` rather than `[object Object]`; and `HTMLDivElement.length` is 0 rather than undefined, inherited from `Function.prototype`. Co-Authored-By: Claude Opus 5 (1M context) --- src/AngleSharp.Js.Tests/DomTests.cs | 18 ++ src/AngleSharp.Js.Tests/InstanceOfTests.cs | 196 +++++++++++++++++ src/AngleSharp.Js/Cache/PrototypeCache.cs | 18 +- src/AngleSharp.Js/Cache/PrototypeTypeCache.cs | 207 ++++++++++++++++++ src/AngleSharp.Js/EngineInstance.cs | 4 +- .../Proxies/DomConstructorFunctionInstance.cs | 11 + .../Proxies/DomConstructorInstance.cs | 88 ++++++++ .../Proxies/DomPrototypeInstance.cs | 103 ++++++--- 8 files changed, 605 insertions(+), 40 deletions(-) create mode 100644 src/AngleSharp.Js.Tests/InstanceOfTests.cs create mode 100644 src/AngleSharp.Js/Cache/PrototypeTypeCache.cs diff --git a/src/AngleSharp.Js.Tests/DomTests.cs b/src/AngleSharp.Js.Tests/DomTests.cs index bfd1e4c..489ee32 100644 --- a/src/AngleSharp.Js.Tests/DomTests.cs +++ b/src/AngleSharp.Js.Tests/DomTests.cs @@ -55,6 +55,24 @@ public async Task NumericIndexerOfHtmlCollectionOutOfRangeIsUndefined() Assert.AreEqual("undefined", result); } + // Two closed forms of IHtmlCollection in one document - each resolves its own + // indexer, and the explicit IReadOnlyList re-implementation behind them is the + // one accessor that cannot be invoked as declared. + [Test] + public async Task NumericIndexerWorksForHtmlCollectionsOfDifferentItemTypes() + { + var result = await "(function () { var d = new DOMParser().parseFromString(``, 'text/html'); return d.getElementsByTagName('img')[0].id + ',' + d.images[0].id; })()".EvalScriptAsync(); + Assert.AreEqual("x,x", result); + } + + // col and colgroup are separate classes sharing the HTMLTableColElement prototype. + [Test] + public async Task NumericIndexerWorksForElementsSharingAPrototype() + { + var result = await "(function () { var d = new DOMParser().parseFromString(`
`, 'text/html'); var g = d.getElementsByTagName('colgroup')[0], c = d.getElementsByTagName('col')[0]; return g.classList[1] + ',' + c.classList[1]; })()".EvalScriptAsync(); + Assert.AreEqual("b,d", result); + } + [Test] public async Task NumericIndexerOfNodeListYieldsTheNode() { diff --git a/src/AngleSharp.Js.Tests/InstanceOfTests.cs b/src/AngleSharp.Js.Tests/InstanceOfTests.cs new file mode 100644 index 0000000..633db5b --- /dev/null +++ b/src/AngleSharp.Js.Tests/InstanceOfTests.cs @@ -0,0 +1,196 @@ +namespace AngleSharp.Js.Tests +{ + using NUnit.Framework; + using System.Threading.Tasks; + + [TestFixture] + public class InstanceOfTests + { + // The two cases reported in https://github.com/AngleSharp/AngleSharp.Js/issues/103 + + [Test] + public async Task WindowIsAnInstanceOfWindow() + { + var result = await "window instanceof Window".EvalScriptAsync(); + Assert.AreEqual("True", result); + } + + [Test] + public async Task CreatedElementIsAnInstanceOfElement() + { + var result = await "document.createElement('div') instanceof Element".EvalScriptAsync(); + Assert.AreEqual("True", result); + } + + // Every level of the chain on its own - the leaf passing says nothing about the rest. + + [Test] + public async Task CreatedElementIsAnInstanceOfItsOwnType() + { + var result = await "document.createElement('div') instanceof HTMLDivElement".EvalScriptAsync(); + Assert.AreEqual("True", result); + } + + [Test] + public async Task CreatedElementIsAnInstanceOfHtmlElement() + { + var result = await "document.createElement('div') instanceof HTMLElement".EvalScriptAsync(); + Assert.AreEqual("True", result); + } + + [Test] + public async Task CreatedElementIsAnInstanceOfNode() + { + var result = await "document.createElement('div') instanceof Node".EvalScriptAsync(); + Assert.AreEqual("True", result); + } + + [Test] + public async Task CreatedElementIsAnInstanceOfEventTarget() + { + var result = await "document.createElement('div') instanceof EventTarget".EvalScriptAsync(); + Assert.AreEqual("True", result); + } + + [Test] + public async Task CreatedElementIsNotAnInstanceOfAnUnrelatedType() + { + var result = await "document.createElement('div') instanceof HTMLAnchorElement".EvalScriptAsync(); + Assert.AreEqual("False", result); + } + + [Test] + public async Task DocumentIsAnInstanceOfHtmlDocument() + { + var result = await "document instanceof HTMLDocument".EvalScriptAsync(); + Assert.AreEqual("True", result); + } + + [Test] + public async Task DocumentIsAnInstanceOfDocument() + { + var result = await "document instanceof Document".EvalScriptAsync(); + Assert.AreEqual("True", result); + } + + // An anchor reaches HTMLElement through the URLUtils mixin, which sits in the chain + // because AngleSharp implements it as an abstract class of its own. + + [Test] + public async Task AnchorIsAnInstanceOfHtmlElement() + { + var result = await "document.createElement('a') instanceof HTMLElement".EvalScriptAsync(); + Assert.AreEqual("True", result); + } + + // An element class carrying no DOM name of its own - a "b" is an HTMLElement in a + // browser, and now here as well. + + [Test] + public async Task UnnamedElementIsAnInstanceOfHtmlElement() + { + var result = await "document.createElement('b') instanceof HTMLElement".EvalScriptAsync(); + Assert.AreEqual("True", result); + } + + [Test] + public async Task UnnamedElementUsesThePrototypeOfHtmlElement() + { + var result = await "Object.getPrototypeOf(document.createElement('b')) === HTMLElement.prototype".EvalScriptAsync(); + Assert.AreEqual("True", result); + } + + // Asserted without "instanceof" on purpose: a Symbol.hasInstance answer would hide a + // broken chain from every test above, but not from these. + + [Test] + public async Task PrototypeOfElementIsThePrototypeOfItsConstructor() + { + var result = await "Object.getPrototypeOf(document.createElement('div')) === HTMLDivElement.prototype".EvalScriptAsync(); + Assert.AreEqual("True", result); + } + + [Test] + public async Task PrototypeOfConstructorIsChainedToItsBase() + { + var result = await "Object.getPrototypeOf(HTMLDivElement.prototype) === HTMLElement.prototype".EvalScriptAsync(); + Assert.AreEqual("True", result); + } + + [Test] + public async Task PrototypeOfWindowIsThePrototypeOfItsConstructor() + { + var result = await "Object.getPrototypeOf(window) === Window.prototype".EvalScriptAsync(); + Assert.AreEqual("True", result); + } + + [Test] + public async Task ConstructorOfAnElementIsTheExposedConstructor() + { + var result = await "document.createElement('div').constructor === HTMLDivElement".EvalScriptAsync(); + Assert.AreEqual("True", result); + } + + // Types reached only through a property, never constructed from script. + + [Test] + public async Task TokenListIsAnInstanceOfDomTokenList() + { + var result = await "document.createElement('div').classList instanceof DOMTokenList".EvalScriptAsync(); + Assert.AreEqual("True", result); + } + + [Test] + public async Task CollectionIsAnInstanceOfHtmlCollection() + { + var result = await "document.getElementsByTagName('div') instanceof HTMLCollection".EvalScriptAsync(); + Assert.AreEqual("True", result); + } + + // A type constructed from script keeps working - these already did. + + [Test] + public async Task ConstructedEventIsAnInstanceOfEvent() + { + var result = await "new Event('foo') instanceof Event".EvalScriptAsync(); + Assert.AreEqual("True", result); + } + + [Test] + public async Task ConstructedCustomEventIsAnInstanceOfEvent() + { + var result = await "new CustomEvent('foo') instanceof Event".EvalScriptAsync(); + Assert.AreEqual("True", result); + } + + [Test] + public async Task ImageIsAnInstanceOfHtmlImageElement() + { + var result = await "new Image() instanceof HTMLImageElement".EvalScriptAsync(); + Assert.AreEqual("True", result); + } + + // A DOM constructor is a function, so it inherits from Function.prototype. + + [Test] + public async Task DomConstructorIsAFunction() + { + var result = await "typeof HTMLDivElement".EvalScriptAsync(); + Assert.AreEqual("function", result); + } + + [Test] + public async Task DomConstructorIsAnInstanceOfFunction() + { + var result = await "Window instanceof Function".EvalScriptAsync(); + Assert.AreEqual("True", result); + } + + [Test] + public async Task DomConstructorInheritsCallFromFunctionPrototype() + { + var result = await "typeof HTMLDivElement.call".EvalScriptAsync(); + Assert.AreEqual("function", result); + } + } +} diff --git a/src/AngleSharp.Js/Cache/PrototypeCache.cs b/src/AngleSharp.Js/Cache/PrototypeCache.cs index ffcc2fd..a65eeb7 100644 --- a/src/AngleSharp.Js/Cache/PrototypeCache.cs +++ b/src/AngleSharp.Js/Cache/PrototypeCache.cs @@ -1,25 +1,35 @@ namespace AngleSharp.Js { + using AngleSharp.Js.Cache; using Jint; using Jint.Native.Object; using System; using System.Collections.Concurrent; + using System.Collections.Generic; + using System.Reflection; sealed class PrototypeCache { private readonly ConcurrentDictionary _prototypes; - private readonly Engine _engine; + private readonly ConcurrentDictionary _canonicalTypes; + private readonly IEnumerable _libs; - public PrototypeCache(Engine engine) + public PrototypeCache(Engine engine, IEnumerable libs) { _prototypes = new ConcurrentDictionary { [typeof(Object)] = engine.Intrinsics.Object.PrototypeObject, }; - _engine = engine; + _canonicalTypes = new ConcurrentDictionary(); + _libs = libs; } public ObjectInstance GetOrCreate(Type type, Func creator) => - _prototypes.GetOrAdd(type, creator.Invoke); + _prototypes.GetOrAdd(Canonicalize(type), creator.Invoke); + + // Memoized per engine rather than globally: the set of libraries a document uses is what + // decides the outcome, and that is fixed for an engine but not for the process. + private Type Canonicalize(Type type) => + _canonicalTypes.GetOrAdd(type, m => m.GetDomPrototypeType(_libs)); } } diff --git a/src/AngleSharp.Js/Cache/PrototypeTypeCache.cs b/src/AngleSharp.Js/Cache/PrototypeTypeCache.cs new file mode 100644 index 0000000..29e9f16 --- /dev/null +++ b/src/AngleSharp.Js/Cache/PrototypeTypeCache.cs @@ -0,0 +1,207 @@ +namespace AngleSharp.Js.Cache +{ + using AngleSharp.Attributes; + using System; + using System.Collections.Concurrent; + using System.Collections.Generic; + using System.Linq; + using System.Reflection; + + /// + /// Maps a CLR type onto the single type whose prototype represents it in JS. + /// + /// + /// An instance is wrapped from an internal concrete class (HtmlDivElement), while the + /// constructor exposed to scripts is built from the exported interface (IHtmlDivElement). + /// Left alone the two end up with a prototype each, so neither "instanceof" nor + /// "Object.getPrototypeOf(div) === HTMLDivElement.prototype" can ever hold. Both sides are + /// therefore folded onto the class that defines the DOM name - the topmost class carrying + /// it - which is also the class the prototype chain is built from. + /// + static class PrototypeTypeCache + { + private static readonly ConcurrentDictionary> _definingTypes = new(); + private static readonly ConcurrentDictionary> _exposedTypes = new(); + + /// + /// Gets what the constructor object of the type a prototype belongs to is built from, + /// or null if the type is not exposed as one. + /// + /// + /// A prototype is keyed by the class defining the DOM name, but it is the exported + /// interface next to it that carries the [DomName] - HtmlDivElement has none of its + /// own, IHtmlDivElement is what names HTMLDivElement - so the class has to be traded + /// back for the interface first. + /// + public static ConstructorDefinition GetConstructorDefinition(this Type type, IEnumerable libs) + { + var definition = type.GetConstructorDefinition(); + + if (definition == null) + { + var name = GetCanonicalName(type); + + if (name != null) + { + foreach (var lib in libs) + { + if (GetExposedTypes(lib).TryGetValue(name, out var exposedType)) + { + return exposedType.GetConstructorDefinition(); + } + } + } + } + + return definition; + } + + public static Type GetDomPrototypeType(this Type type, IEnumerable libs) + { + var typeInfo = type.GetTypeInfo(); + + // An enum carries the [DomName] of its owner (NodeType is named "Document"), and the + // closed instantiations of a generic are mutually non-assignable, so a member resolved + // against one of them cannot be invoked on another (IHtmlCollection). Neither may + // be folded onto somebody else's prototype. + if (typeInfo.IsEnum || typeInfo.IsGenericType) + { + return type; + } + + var name = GetCanonicalName(type); + + if (name != null) + { + foreach (var lib in libs) + { + if (GetDefiningTypes(lib).TryGetValue(name, out var definingType)) + { + return definingType; + } + } + } + + return type; + } + + /// + /// Gets the DOM name a type is represented by, which for the many element classes + /// carrying no name of their own (HtmlBoldElement, HtmlSemanticElement, ...) is the name + /// of their nearest named ancestor - just like in a browser, where a "b" element is an + /// HTMLElement. + /// + private static String GetCanonicalName(Type type) + { + var current = type; + + while (current != null) + { + var baseType = current.GetTypeInfo().BaseType; + var name = current.GetOfficialName(baseType); + + if (name != null) + { + return name; + } + + current = baseType; + } + + return null; + } + + private static IReadOnlyDictionary GetDefiningTypes(Assembly assembly) => + _definingTypes.GetOrAdd(assembly, CreateDefiningTypes); + + private static IReadOnlyDictionary CreateDefiningTypes(Assembly assembly) + { + // Ordinal on purpose: XmlHttpRequest is named "XMLHttpRequest" while the + // RequesterState enum next to it is named "XmlHttpRequest". + var result = new Dictionary(StringComparer.Ordinal); + + foreach (var type in GetLoadableTypes(assembly)) + { + var typeInfo = type.GetTypeInfo(); + + // Only a class can define a prototype: the interface is what the DOM exposes, + // but the class is what an instance is built from and what its base type - and + // hence the prototype chain - is taken from. + if (!typeInfo.IsClass || typeInfo.IsGenericType) + { + continue; + } + + var baseType = typeInfo.BaseType; + var name = type.GetOfficialName(baseType); + + if (name == null || String.Equals(name, GetNameOf(baseType), StringComparison.Ordinal)) + { + // Either nothing to define, or the base type already defines the very same + // name - so this class is not the topmost one carrying it. + continue; + } + + // A name may legitimately be defined twice (col and colgroup are both an + // HTMLTableColElement); share a prototype, but pick the same one every time. + if (!result.TryGetValue(name, out var existing) || + String.CompareOrdinal(type.FullName, existing.FullName) < 0) + { + result[name] = type; + } + } + + return result; + } + + private static String GetNameOf(Type type) => + type?.GetOfficialName(type.GetTypeInfo().BaseType); + + private static IReadOnlyDictionary GetExposedTypes(Assembly assembly) => + _exposedTypes.GetOrAdd(assembly, CreateExposedTypes); + + /// + /// Collects the types a DOM name is exposed by, which is what + /// walks - hence exported types only. + /// + private static IReadOnlyDictionary CreateExposedTypes(Assembly assembly) + { + var result = new Dictionary(StringComparer.Ordinal); + + foreach (var type in assembly.ExportedTypes) + { + var typeInfo = type.GetTypeInfo(); + + if (typeInfo.IsEnum) + { + // An enum carries the [DomName] of the type owning it, not one of its own. + continue; + } + + var name = typeInfo.GetCustomAttributes().FirstOrDefault()?.OfficialName; + + // An interface wins over a class: it is the DOM type, and the class is only + // one way of implementing it. + if (name != null && (!result.TryGetValue(name, out var existing) || + (typeInfo.IsInterface && !existing.GetTypeInfo().IsInterface))) + { + result[name] = type; + } + } + + return result; + } + + private static IEnumerable GetLoadableTypes(Assembly assembly) + { + try + { + return assembly.GetTypes(); + } + catch (ReflectionTypeLoadException ex) + { + return ex.Types.Where(m => m != null); + } + } + } +} diff --git a/src/AngleSharp.Js/EngineInstance.cs b/src/AngleSharp.Js/EngineInstance.cs index 55b30a6..5f2033f 100644 --- a/src/AngleSharp.Js/EngineInstance.cs +++ b/src/AngleSharp.Js/EngineInstance.cs @@ -35,9 +35,9 @@ public EngineInstance(IWindow window, IDictionary assignments, I { options.EnableModules(new JsModuleLoader(this, window.Document, false)); }); - _prototypes = new PrototypeCache(_engine); - _references = new ReferenceCache(); _libs = libs; + _prototypes = new PrototypeCache(_engine, libs); + _references = new ReferenceCache(); foreach (var assignment in assignments) { diff --git a/src/AngleSharp.Js/Proxies/DomConstructorFunctionInstance.cs b/src/AngleSharp.Js/Proxies/DomConstructorFunctionInstance.cs index 348ab1a..6a95c26 100644 --- a/src/AngleSharp.Js/Proxies/DomConstructorFunctionInstance.cs +++ b/src/AngleSharp.Js/Proxies/DomConstructorFunctionInstance.cs @@ -3,6 +3,7 @@ namespace AngleSharp.Js.Proxies using Jint.Native; using Jint.Native.Object; using Jint.Runtime; + using Jint.Runtime.Descriptors; using System.Reflection; sealed class DomConstructorFunctionInstance : Constructor @@ -14,6 +15,16 @@ public DomConstructorFunctionInstance(EngineInstance instance, MethodInfo constr { _instance = instance; _constructorFunction = constructorFunction; + + // Jint's Constructor leaves the prototype at Object.prototype, which would make a + // DOM constructor the one function in the engine without call, apply or bind. + Prototype = (ObjectInstance)instance.Jint.Intrinsics.Function.Get("prototype"); + + // Image is a second way of naming HTMLImageElement rather than a type of its own, + // so it publishes the prototype of what it returns. Without one, "instanceof" + // against it is a TypeError rather than an answer. + SetOwnProperty("prototype", new PropertyDescriptor( + instance.GetDomPrototype(constructorFunction.ReturnType), false, false, false)); } public override ObjectInstance Construct(JsValue[] arguments, JsValue newTarget) diff --git a/src/AngleSharp.Js/Proxies/DomConstructorInstance.cs b/src/AngleSharp.Js/Proxies/DomConstructorInstance.cs index 00783ee..a85e493 100644 --- a/src/AngleSharp.Js/Proxies/DomConstructorInstance.cs +++ b/src/AngleSharp.Js/Proxies/DomConstructorInstance.cs @@ -3,9 +3,11 @@ namespace AngleSharp.Js using AngleSharp.Js.Cache; using Jint.Native; using Jint.Native.Object; + using Jint.Native.Symbol; using Jint.Runtime; using Jint.Runtime.Descriptors; using Jint.Runtime.Interop; + using System; using System.Reflection; sealed class DomConstructorInstance : Constructor @@ -13,6 +15,7 @@ sealed class DomConstructorInstance : Constructor private readonly ConstructorInfo _constructor; private readonly EngineInstance _instance; private readonly ObjectInstance _objectPrototype; + private readonly Type _type; public DomConstructorInstance(EngineInstance engine, ConstructorDefinition definition) : base(engine.Jint, definition.Name) @@ -21,6 +24,12 @@ public DomConstructorInstance(EngineInstance engine, ConstructorDefinition defin _objectPrototype = engine.GetDomPrototype(definition.Type); _instance = engine; _constructor = definition.Info; + _type = definition.Type; + + // Jint's Constructor leaves the prototype at Object.prototype, which would make a + // DOM constructor the one function in the engine without call, apply or bind. + Prototype = (ObjectInstance)engine.Jint.Intrinsics.Function.Get("prototype"); + FastSetProperty("toString", new PropertyDescriptor(toString, true, false, true)); SetOwnProperty("prototype", new PropertyDescriptor(_objectPrototype, false, false, false)); @@ -38,6 +47,85 @@ public DomConstructorInstance(EngineInstance engine, ConstructorDefinition defin } } + /// + /// Answers "instanceof" itself, because the prototype chain cannot always carry the + /// answer: a mixin such as ParentNode has no class of its own to hang a prototype off, + /// and the closed instantiations of IHtmlCollection<T> are separate types that a + /// single prototype cannot stand for. Built on first ask - most types are never asked. + /// + public override PropertyDescriptor GetOwnProperty(JsValue property) + { + if (property == GlobalSymbolRegistry.HasInstance) + { + var descriptor = base.GetOwnProperty(property); + + if (descriptor == PropertyDescriptor.Undefined) + { + var hasInstance = new ClrFunction(Engine, "[Symbol.hasInstance]", HasInstance, 1, PropertyFlag.Configurable); + descriptor = new PropertyDescriptor(hasInstance, false, false, false); + SetOwnProperty(property, descriptor); + } + + return descriptor; + } + + return base.GetOwnProperty(property); + } + + private JsValue HasInstance(JsValue thisObject, JsValue[] arguments) + { + var value = arguments.Length > 0 ? arguments[0] : JsValue.Undefined; + + if (value is DomNodeInstance node && IsInstance(node.Value)) + { + return JsBoolean.True; + } + + // Not a DOM object of this type, but something may still have been given this + // prototype - Object.create(HTMLDivElement.prototype) is an instance in a browser. + return InheritsFromPrototype(value as ObjectInstance) ? JsBoolean.True : JsBoolean.False; + } + + private Boolean IsInstance(Object value) + { + var type = value.GetType(); + + if (_type.IsAssignableFrom(type)) + { + return true; + } + + // IHtmlCollection is exposed as HTMLCollection, so an instance of any of its + // closed forms answers to it. + if (_type.GetTypeInfo().IsGenericTypeDefinition) + { + foreach (var contract in type.GetTypeInfo().ImplementedInterfaces) + { + if (contract.GetTypeInfo().IsGenericType && contract.GetGenericTypeDefinition() == _type) + { + return true; + } + } + } + + return false; + } + + private Boolean InheritsFromPrototype(ObjectInstance obj) + { + while (obj != null) + { + obj = obj.Prototype; + + if (ReferenceEquals(obj, _objectPrototype)) + { + return true; + } + } + + return false; + } + public override ObjectInstance Construct(JsValue[] arguments, JsValue newTarget) { if (_constructor == null) diff --git a/src/AngleSharp.Js/Proxies/DomPrototypeInstance.cs b/src/AngleSharp.Js/Proxies/DomPrototypeInstance.cs index 43ab720..5a0c3d0 100644 --- a/src/AngleSharp.Js/Proxies/DomPrototypeInstance.cs +++ b/src/AngleSharp.Js/Proxies/DomPrototypeInstance.cs @@ -8,6 +8,7 @@ namespace AngleSharp.Js using Jint.Runtime.Descriptors; using Jint.Runtime.Interop; using System; + using System.Collections.Concurrent; using System.Collections.Generic; using System.Linq; using System.Reflection; @@ -22,8 +23,8 @@ sealed class DomPrototypeInstance : ObjectInstance private List> _deferred; private Boolean _membersSet; private DomConstructorInstance _constructor; - private MethodInfo _numericIndexer; - private MethodInfo _stringIndexer; + private Indexer _numericIndexer; + private Indexer _stringIndexer; public DomPrototypeInstance(EngineInstance engine, Type type) : base(engine.Jint) @@ -48,8 +49,17 @@ protected override void Initialize() SetAllMembers(_type); SetExtensionMembers(); - // DOM objects can have properties added dynamically - Prototype = _instance.GetDomPrototype(_baseType); + // The base type may fold onto this very prototype - a class carrying no DOM name of + // its own shares the one of its nearest named ancestor. Jint's setter answers a + // prototype cycle by quietly doing nothing, which would leave Object.prototype in + // place and silently cut the chain short, so rule it out here. + var parent = _instance.GetDomPrototype(_baseType); + + if (!ReferenceEquals(parent, this)) + { + // DOM objects can have properties added dynamically + Prototype = parent; + } if (_deferred != null) { @@ -64,7 +74,7 @@ protected override void Initialize() // It is the constructor object that registers "constructor" here, and it is // only built once script names the type. A prototype reached through an // instance instead - the usual way - would otherwise lack the property. - var definition = _type.GetConstructorDefinition(); + var definition = _type.GetConstructorDefinition(_instance.Libs); if (definition != null) { @@ -307,7 +317,7 @@ private void SetIndexer(PropertyInfo property, ParameterInfo[] indexParameters) return; } - var getter = ResolveAccessor(property.GetMethod); + var getter = property.GetMethod; if (getter == null) { @@ -316,40 +326,14 @@ private void SetIndexer(PropertyInfo property, ParameterInfo[] indexParameters) if (indexParameters[0].ParameterType == typeof(Int32)) { - _numericIndexer = getter; + _numericIndexer = new Indexer(getter); } else if (indexParameters[0].ParameterType == typeof(String)) { - _stringIndexer = getter; + _stringIndexer = new Indexer(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) { //TODO Jint @@ -364,6 +348,57 @@ private void SetMethod(String name, MethodInfo method) ), false, false, false)); } } + + /// + /// 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) => + Resolve(target.GetType()).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; + } + } } }