From 99d58af66b77029ac8245c4a722407aff2a34042 Mon Sep 17 00:00:00 2001 From: Marko Lahma Date: Wed, 29 Jul 2026 10:25:17 +0300 Subject: [PATCH 1/3] Build DOM prototypes from a member layout the process shares A DOM prototype was an ObjectInstance subclass of ours, and the engine refuses such an object as a prototype-method inline-cache holder by design: a host subclass keeps its own-property set outside the engine, so no engine-side version can witness that a name is still owned by it. Every warm member read on a node therefore re-walked to the prototype and re-resolved the member, which is the one cost three rounds of optimization never moved. Prototypes are now instantiated from a JsObjectShape: an immutable member layout keyed by (type, library set) and held for the whole process, over which each engine gets an object of its own. The layout is engine storage, so the carve-out admits it and the cache serves the warm read. Reflecting over a type tree also happens once per process rather than once per engine, and existence questions are answered off the layout without materialising the member they are about. Everything a prototype carried that belongs to one engine moved onto the object as host state - the engine, the constructor - and everything that belongs to the type moved into the shared description: the indexers, and the event members, whose identity is now the key a node files its handler under. Member implementations no longer close over an engine and derive it from the receiver instead. Each proxy remembers what its prototype knows and checks it against the prototype in force, so the hottest path costs a field load rather than two type tests. The window is the exception. Its members are copied onto the global object and a bare "alert('hi')" calls with no receiver at all, so its methods keep a function per engine. Its accessors stay shared: an accessor read always has a receiver, the global object itself for a bare "document", and that reaches the shaped window prototype. The registration quirk is reproduced deliberately: a member named after one of Object.prototype's is skipped, because that is what probing a half-built prototype used to do. A test pins that nothing in AngleSharp currently collides, so the choice is made rather than made by accident. The proxies' own-property hooks are untouched. Measured on an idle machine against Jint 4.15.2, per read with the loop baseline subtracted: el.tagName 150.7ns -> 104.7ns, el.hasChildNodes() 158.7ns -> 111.7ns, a script-assigned property 124.0ns -> 101.9ns, an accessor write at parity, allocation byte-identical on every row. One whole scripted document costs 1.291ms/578.68KB -> 244.8us/213.54KB. The Jint floor moves to 4.15.2 for a reason that is not an API one: JsObjectShape is complete in 4.15.0, but a name absent from a shaped prototype chain was slower to answer than one absent from a dictionary chain until jint#2858, and WebIDL makes this binding ask exactly that on every named lookup a collection serves. Co-Authored-By: Claude Opus 5 (1M context) --- CHANGELOG.md | 1 + .../PropertyAccessSemanticsTests.cs | 86 +++ .../PrototypeShapeTests.cs | 249 +++++++++ src/AngleSharp.Js.nuspec | 12 +- src/AngleSharp.Js/AngleSharp.Js.csproj | 2 +- src/AngleSharp.Js/Cache/DomShape.cs | 61 +++ src/AngleSharp.Js/Cache/DomShapeCache.cs | 493 +++++++++++++++++ src/AngleSharp.Js/Cache/LibrarySet.cs | 69 +++ src/AngleSharp.Js/Cache/PrototypeCache.cs | 8 +- src/AngleSharp.Js/EngineInstance.cs | 34 +- .../Extensions/EngineExtensions.cs | 48 ++ .../Proxies/DomCollectionInstance.cs | 41 +- .../Proxies/DomConstructorInstance.cs | 17 +- ...EventInstance.cs => DomEventDefinition.cs} | 39 +- src/AngleSharp.Js/Proxies/DomIndexers.cs | 197 +++++++ src/AngleSharp.Js/Proxies/DomNodeInstance.cs | 91 +++- .../Proxies/DomPrototypeInstance.cs | 498 ------------------ .../Proxies/DomPrototypeState.cs | 136 +++++ src/AngleSharp.Js/Proxies/IDomProxy.cs | 6 + 19 files changed, 1507 insertions(+), 581 deletions(-) create mode 100644 src/AngleSharp.Js.Tests/PropertyAccessSemanticsTests.cs create mode 100644 src/AngleSharp.Js.Tests/PrototypeShapeTests.cs create mode 100644 src/AngleSharp.Js/Cache/DomShape.cs create mode 100644 src/AngleSharp.Js/Cache/DomShapeCache.cs create mode 100644 src/AngleSharp.Js/Cache/LibrarySet.cs rename src/AngleSharp.Js/Proxies/{DomEventInstance.cs => DomEventDefinition.cs} (62%) create mode 100644 src/AngleSharp.Js/Proxies/DomIndexers.cs delete mode 100644 src/AngleSharp.Js/Proxies/DomPrototypeInstance.cs create mode 100644 src/AngleSharp.Js/Proxies/DomPrototypeState.cs diff --git a/CHANGELOG.md b/CHANGELOG.md index 526e32a..3318928 100644 --- a/CHANGELOG.md +++ b/CHANGELOG.md @@ -14,6 +14,7 @@ Released on Thursday, July 31 2026. - Updated to use AngleSharp v1 - Updated for Jint v4 (#89, #97) @tomvanenckevort @lahma - Updated CreatorCache to be thread-safe (#110) @badnickname +- Updated DOM prototypes to be built from a member layout shared by the whole process, which resolves a type once instead of once per document and lets the engine cache warm member reads @lahma - Added report for uncaught event-loop exceptions through the browsing context - Added `JsScriptingOptions` to tune the engine via `WithJs` (#75) @lahma - Added support for Web Workers (#78) diff --git a/src/AngleSharp.Js.Tests/PropertyAccessSemanticsTests.cs b/src/AngleSharp.Js.Tests/PropertyAccessSemanticsTests.cs new file mode 100644 index 0000000..f7225cc --- /dev/null +++ b/src/AngleSharp.Js.Tests/PropertyAccessSemanticsTests.cs @@ -0,0 +1,86 @@ +namespace AngleSharp.Js.Tests +{ + using AngleSharp.Dom; + using AngleSharp.Js.Proxies; + using AngleSharp.Scripting; + using Jint; + using Jint.Native.Object; + using NUnit.Framework; + using System; + using System.Threading.Tasks; + + /// + /// Pins the property access semantics Jint derives for the proxy types. + /// + /// + /// Every proxy here is ordinary, and the engine works that out by itself: a type gets the + /// short read path because it does not override Get, and loses it because it does. Nothing + /// declares that anywhere, so adding a Get override - the obvious way to intercept a read - + /// would silently move every read against that type onto the long path, with no test failing + /// and no behaviour changing. These assertions are the alarm for that. + /// + [TestFixture] + public class PropertyAccessSemanticsTests + { + private static Task AssertOrdinaryAsync(String expression, Type expected) => + AssertOrdinaryAsync(expression, (engine, instance) => + // Assert what it is as well as how it reads: if the expression stops producing + // the proxy under test the semantics assertion would still pass, and pass for + // the wrong object. + Assert.AreEqual(expected, instance.GetType(), expression + " produced an unexpected proxy type.")); + + private static async Task AssertOrdinaryAsync(String expression, Action identify) + { + var context = BrowsingContext.New(Configuration.Default.WithJs()); + var document = await context.OpenAsync(m => m.Content( + "x")).ConfigureAwait(false); + var engine = context.GetService().GetOrCreateJint(document); + var value = engine.Evaluate(expression); + + Assert.IsInstanceOf(value, expression + " did not evaluate to an object."); + + var instance = (ObjectInstance)value; + + identify.Invoke(engine, instance); + + Assert.AreEqual(PropertyAccessSemantics.Ordinary, engine.Advanced.GetPropertyAccessSemantics(instance), + instance.GetType().Name + " must keep ordinary read semantics; overriding Get forfeits them."); + + context.Dispose(); + } + + [Test] + public Task NodeProxyReadsAsOrdinary() => + AssertOrdinaryAsync("document.createElement('div')", typeof(DomNodeInstance)); + + [Test] + public Task CollectionProxyReadsAsOrdinary() => + AssertOrdinaryAsync("document.getElementsByTagName('span')", typeof(DomCollectionInstance)); + + /// + /// The prototype is identified by its representation rather than by its type, because it + /// no longer has one of ours: it is an object over a shared member layout, and that is + /// exactly the property carrying the win - a host subclass used as a prototype is refused + /// as an inline-cache holder by design, an object in the shared layout is not. + /// + /// + /// The member read is what settles the answer: the representation is installed on first + /// touch, and the diagnostic deliberately does not perturb an untouched object into it. + /// + [Test] + public Task PrototypeReadsAsOrdinary() => + AssertOrdinaryAsync( + "(function () { var d = document.createElement('div'); d.tagName; return Object.getPrototypeOf(d); })()", + (engine, instance) => + Assert.AreEqual(ObjectRepresentation.SharedBuiltinLayout, engine.Advanced.GetObjectRepresentation(instance), + "A DOM prototype must be an object over a shared member layout.")); + + [Test] + public Task ConstructorReadsAsOrdinary() => + AssertOrdinaryAsync("HTMLDivElement", typeof(DomConstructorInstance)); + + [Test] + public Task ConstructorFunctionReadsAsOrdinary() => + AssertOrdinaryAsync("Image", typeof(DomConstructorFunctionInstance)); + } +} diff --git a/src/AngleSharp.Js.Tests/PrototypeShapeTests.cs b/src/AngleSharp.Js.Tests/PrototypeShapeTests.cs new file mode 100644 index 0000000..4b80162 --- /dev/null +++ b/src/AngleSharp.Js.Tests/PrototypeShapeTests.cs @@ -0,0 +1,249 @@ +namespace AngleSharp.Js.Tests +{ + using AngleSharp.Attributes; + using AngleSharp.Dom; + using AngleSharp.Js; + using AngleSharp.Scripting; + using Jint; + using Jint.Native.Object; + using Jint.Runtime; + using NUnit.Framework; + using System; + using System.Collections.Generic; + using System.Reflection; + using System.Runtime.CompilerServices; + using System.Threading.Tasks; + + /// + /// What it means for a DOM prototype to be built from a member layout the whole process + /// shares: one description of a type, one object per engine over it, and no way for either + /// end to reach the other. + /// + [TestFixture] + public class PrototypeShapeTests + { + // Reads a member before handing the prototype back, because a member layout is + // installed on the object at the first touch and the diagnostics below deliberately + // do not perturb an untouched object into it. + private const String DivPrototype = + "(function () { var d = document.createElement('div'); d.tagName; return Object.getPrototypeOf(d); })()"; + + private static Engine Open(String content = "") + { + var context = BrowsingContext.New(Configuration.Default.WithJs()); + var document = context.OpenAsync(m => m.Content(content)).Result; + return context.GetService().GetOrCreateJint(document); + } + + /// + /// The description of a type is resolved once for the process: two engines reach the + /// very same one, which is what makes reflecting over a type tree a cost a second + /// document does not pay. + /// + [Test] + public void ShapeIsSharedAcrossEngines() + { + var first = Open(); + var second = Open(); + + var one = (ObjectInstance)first.Evaluate(DivPrototype); + var other = (ObjectInstance)second.Evaluate(DivPrototype); + + Assert.AreNotSame(one, other, "Each engine must have a prototype object of its own."); + Assert.AreSame(DomPrototypeState.Of(one).Shape, DomPrototypeState.Of(other).Shape, + "Both engines must build their prototype from the same shared description."); + Assert.AreEqual(ObjectRepresentation.SharedBuiltinLayout, first.Advanced.GetObjectRepresentation(one)); + Assert.AreEqual(ObjectRepresentation.SharedBuiltinLayout, second.Advanced.GetObjectRepresentation(other)); + } + + /// + /// Sharing the description does not share the objects: what a script does to one + /// engine's prototype is invisible to every other engine. + /// + [Test] + public void PatchingAPrototypeLeavesOtherEnginesAlone() + { + var first = Open(); + var second = Open(); + + first.Evaluate("HTMLDivElement.prototype.patched = function () { return 'yes'; };"); + + Assert.AreEqual("yes", first.Evaluate("document.createElement('div').patched()").AsString()); + Assert.IsTrue(second.Evaluate("typeof document.createElement('div').patched === 'undefined'").AsBoolean(), + "A patch applied to one engine's prototype must not be visible in another."); + } + + /// + /// A script may do to a DOM prototype what it does to any other object. The shared + /// layout gives way where it cannot express something - a deleted member drops the + /// object onto the ordinary property representation - and the members keep working. + /// + [Test] + public void PrototypePatchingAndDeletingStayCorrect() + { + var engine = Open("
text
"); + + engine.Evaluate("HTMLDivElement.prototype.shout = function () { return this.tagName + '!'; };"); + Assert.AreEqual("DIV!", engine.Evaluate("document.getElementById('d').shout()").AsString()); + + engine.Evaluate("delete HTMLDivElement.prototype.shout;"); + Assert.IsTrue(engine.Evaluate("typeof document.getElementById('d').shout === 'undefined'").AsBoolean(), + "A property a script added to a prototype must be deletable again."); + + // The delete is what the shared layout cannot express, so this is the read that + // proves the members survive the object falling back to the ordinary one. + Assert.AreEqual("DIV", engine.Evaluate("document.getElementById('d').tagName").AsString()); + Assert.IsTrue(engine.Evaluate("document.getElementById('d') instanceof HTMLDivElement").AsBoolean()); + Assert.IsTrue(engine.Evaluate("document.getElementById('d').constructor === HTMLDivElement").AsBoolean()); + } + + /// + /// A window member is the one kind a script reaches without naming a receiver, and both + /// halves of that have to keep working: the bare call, and the fact that the function it + /// finds is the very one hanging off the window. + /// + [Test] + public void WindowMembersAnswerWithoutAReceiver() + { + var engine = Open(); + + Assert.IsTrue(engine.Evaluate("btoa === window.btoa").AsBoolean(), + "A window method read bare and read off the window must be the same function."); + Assert.AreEqual(engine.Evaluate("window.btoa('shape')").AsString(), engine.Evaluate("btoa('shape')").AsString()); + Assert.IsTrue(engine.Evaluate("document === window.document").AsBoolean(), + "A window accessor read bare and read off the window must answer the same object."); + } + + /// + /// Registration used to write the members onto a half-built prototype and skip a name + /// its chain already carried, which silently dropped any DOM member named after one of + /// Object.prototype's. The rule is reproduced as it was; this pins both what it covers + /// and the fact that nothing in the DOM currently runs into it. + /// + /// + /// If AngleSharp ever names a member "toString" - the plausible one, since a browser + /// really does put it on HTMLAnchorElement - this fails, and the choice between keeping + /// the quirk and fixing it has to be made rather than made by accident. + /// + [Test] + public void NoDomMemberCollidesWithAnObjectPrototypeName() + { + var reserved = new HashSet(StringComparer.Ordinal); + + foreach (var key in new Engine().Intrinsics.Object.PrototypeObject.GetOwnPropertyKeys(Types.String)) + { + reserved.Add(key.ToString()); + } + + CollectionAssert.Contains(reserved, "toString", "The skipped set is Object.prototype's own names."); + + var assemblies = new[] + { + typeof(IDocument).GetTypeInfo().Assembly, + typeof(JsScriptingService).GetTypeInfo().Assembly, + }; + + var collisions = new List(); + + foreach (var assembly in assemblies) + { + foreach (var type in assembly.GetTypes()) + { + foreach (var member in type.GetTypeInfo().DeclaredMembers) + { + foreach (var name in member.GetCustomAttributes()) + { + if (reserved.Contains(name.OfficialName)) + { + collisions.Add($"{type.FullName}.{member.Name} is named '{name.OfficialName}'"); + } + } + } + } + } + + CollectionAssert.IsEmpty(collisions, + "A DOM member named after an Object.prototype member is silently dropped, as it was before shapes."); + } + + /// + /// The description of a type outlives every engine that used it, so it must hold nothing + /// of theirs - a delegate closing over an engine would pin every document ever opened. + /// + [Test] + public void SharedShapesDoNotRetainEngines() + { + var references = new List(); + + for (var i = 0; i < 3; i++) + { + references.Add(OpenAndDrop()); + } + + for (var attempt = 0; attempt < 5; attempt++) + { + GC.Collect(); + GC.WaitForPendingFinalizers(); + GC.Collect(); + } + + foreach (var reference in references) + { + Assert.IsFalse(reference.IsAlive, "A shape held on to the engine that instantiated it."); + } + } + + // Its own method so that no local of the caller's frame keeps the engine alive. + [MethodImpl(MethodImplOptions.NoInlining)] + private static WeakReference OpenAndDrop() + { + var context = BrowsingContext.New(Configuration.Default.WithJs()); + var document = context.OpenAsync(m => m.Content( + "
")).Result; + var reference = new WeakReference(context.GetService().GetOrCreateJint(document)); + context.Dispose(); + return reference; + } + + [Test] + public async Task PrototypeReportsTheDomNameAsItsTag() + { + var result = await "Object.prototype.toString.call(document.createElement('div'))".EvalScriptAsync(); + Assert.AreEqual("[object HTMLDivElement]", result); + } + + /// + /// "constructor" is the one member declared as a slot that produces its value on first + /// read, so that naming a type does not drag its prototype's members into existence. That + /// makes it the member whose mere existence has to be answerable before it has a value. + /// + /// + /// The engine answers an existence question off the member layout without materialising + /// the member, and a slot with nothing in it yet is exactly where that can go wrong in the + /// direction nothing else would catch: a wrong "missing" is silent, and drops the property + /// from "in", hasOwnProperty and every enumeration. Each assertion below runs before + /// anything has read the property, which is what makes it the interesting case. + /// + [Test] + public void UnreadConstructorStillExists() + { + Assert.IsTrue(Open().Evaluate( + "'constructor' in Object.getPrototypeOf(document.createElement('div'))").AsBoolean(), + "\"in\" must find the constructor slot before anything has read it."); + + // The strict one, and the one that actually catches a wrong "missing": "in" above + // walks the chain, and Object.prototype carries a "constructor" of its own to find. + Assert.IsTrue(Open().Evaluate( + "Object.getPrototypeOf(document.createElement('div')).hasOwnProperty('constructor')").AsBoolean(), + "hasOwnProperty must find the constructor slot before anything has read it."); + + Assert.IsFalse(Open().Evaluate( + "Object.keys(Object.getPrototypeOf(document.createElement('div'))).indexOf('constructor') >= 0").AsBoolean(), + "The constructor is not enumerable, whether or not it has been read."); + + // And the value is still the constructor object once something does read it. + Assert.IsTrue(Open().Evaluate( + "Object.getPrototypeOf(document.createElement('div')).constructor === HTMLDivElement").AsBoolean()); + } + } +} diff --git a/src/AngleSharp.Js.nuspec b/src/AngleSharp.Js.nuspec index 31706a6..814f90a 100644 --- a/src/AngleSharp.Js.nuspec +++ b/src/AngleSharp.Js.nuspec @@ -18,27 +18,27 @@ - + - + - + - + - + - + diff --git a/src/AngleSharp.Js/AngleSharp.Js.csproj b/src/AngleSharp.Js/AngleSharp.Js.csproj index 9274feb..35e0e69 100644 --- a/src/AngleSharp.Js/AngleSharp.Js.csproj +++ b/src/AngleSharp.Js/AngleSharp.Js.csproj @@ -19,7 +19,7 @@ - + diff --git a/src/AngleSharp.Js/Cache/DomShape.cs b/src/AngleSharp.Js/Cache/DomShape.cs new file mode 100644 index 0000000..bb65f12 --- /dev/null +++ b/src/AngleSharp.Js/Cache/DomShape.cs @@ -0,0 +1,61 @@ +namespace AngleSharp.Js.Cache +{ + using Jint.Native; + using System; + + /// + /// Everything the prototype of one DOM type is built from: the member layout the engine + /// instantiates, the indexers the type offers, and what its constructor object is made of. + /// + /// + /// None of it belongs to an engine. A is an immutable member + /// layout that any number of engines may instantiate, the indexers are reflected accessors, + /// and the constructor definition is reflection over a type - so the whole thing is resolved + /// once per process and every document opened afterwards only allocates the object. + /// + sealed class DomShape + { + public DomShape(Type type, Type baseType, String name, JsObjectShape shape, DomIndexers indexers, ConstructorDefinition constructor) + { + Type = type; + BaseType = baseType; + Name = name; + Shape = shape; + Indexers = indexers; + Constructor = constructor; + } + + /// + /// Gets the type whose prototype this is - the class defining the DOM name, which many + /// classes may share. + /// + public Type Type { get; } + + /// + /// Gets the type the prototype above this one is built from. + /// + public Type BaseType { get; } + + /// + /// Gets the DOM name of the type, or null if it carries none. + /// + public String Name { get; } + + /// + /// Gets the member layout, shared by every engine's prototype for this type. + /// + public JsObjectShape Shape { get; } + + /// + /// Gets the indexers the type offers, or null if it has none - which is the case for + /// nearly every DOM type. + /// + public DomIndexers Indexers { get; } + + /// + /// Gets what the constructor object of the type is built from, or null if the type is + /// not exposed as one under any name the engine's libraries define. + /// + public ConstructorDefinition Constructor { get; } + } +} diff --git a/src/AngleSharp.Js/Cache/DomShapeCache.cs b/src/AngleSharp.Js/Cache/DomShapeCache.cs new file mode 100644 index 0000000..fa47cab --- /dev/null +++ b/src/AngleSharp.Js/Cache/DomShapeCache.cs @@ -0,0 +1,493 @@ +namespace AngleSharp.Js.Cache +{ + using AngleSharp.Attributes; + using AngleSharp.Dom; + using AngleSharp.Text; + using Jint; + using Jint.Native; + using Jint.Native.Object; + using Jint.Runtime; + using Jint.Runtime.Interop; + using System; + using System.Collections.Concurrent; + using System.Collections.Generic; + using System.Linq; + using System.Reflection; + + /// + /// Holds the member layout of every DOM type the process has met, so that reflecting over a + /// type tree happens once rather than once per engine. + /// + /// + /// The key is the type together with the engine's library set, because the member set is not + /// a property of the type alone - loading AngleSharp.Css gives an element members it does not + /// otherwise have. Shapes are cheap to have several of, so a second configuration simply gets + /// a second one. + /// + static class DomShapeCache + { + private static readonly ConcurrentDictionary _shapes = new(); + private static HashSet _reservedNames; + + public static DomShape GetOrCreate(Type type, LibrarySet libs, Engine engine) + { + var key = new ShapeKey(type, libs); + + if (_shapes.TryGetValue(key, out var shape)) + { + return shape; + } + + // Built outside GetOrAdd: the factory overload taking state is not available on + // netstandard2.0, and a closure per call would be paid by every lookup, hit or not. + return _shapes.GetOrAdd(key, new DomShapeBuilder(type, libs, GetReservedNames(engine)).Build()); + } + + /// + /// The names a member of a DOM type used to be skipped for. + /// + /// + /// Registration used to probe the half-built prototype with HasProperty to decide whether + /// to skip a member, and at that moment the object's chain was still Object.prototype - so + /// a DOM member named "toString" was silently dropped. A builder has no object to probe, so + /// the names are read off the engine instead of copied into this file, which keeps the rule + /// tied to what Jint's Object.prototype actually carries. They are the same in every + /// engine, so two threads racing here compute the same set and either answer is right. + /// + private static HashSet GetReservedNames(Engine engine) + { + var reserved = _reservedNames; + + if (reserved == null) + { + var keys = engine.Intrinsics.Object.PrototypeObject.GetOwnPropertyKeys(Types.String); + reserved = new HashSet(StringComparer.Ordinal); + + for (var i = 0; i < keys.Count; i++) + { + reserved.Add(keys[i].ToString()); + } + + _reservedNames = reserved; + } + + return reserved; + } + + private readonly struct ShapeKey : IEquatable + { + private readonly Type _type; + private readonly LibrarySet _libs; + + public ShapeKey(Type type, LibrarySet libs) + { + _type = type; + _libs = libs; + } + + public Boolean Equals(ShapeKey other) => _type == other._type && _libs.Equals(other._libs); + + public override Boolean Equals(Object obj) => obj is ShapeKey other && Equals(other); + + public override Int32 GetHashCode() => (_type.GetHashCode() * 397) ^ _libs.GetHashCode(); + } + } + + /// + /// Turns the DOM attributes of one type into the member layout its prototype is built from. + /// + /// + /// The walk is the one the per-engine registration used to do, member for member and in the + /// same order, because the order decides which of two members of the same name survives. + /// What changed is where the answer goes: into a declaration that any engine can instantiate, + /// which forces every member implementation to derive its engine from the receiver rather + /// than close over one. + /// + sealed class DomShapeBuilder + { + private const String ConstructorName = "constructor"; + + private readonly Type _type; + private readonly Type _baseType; + private readonly String _name; + private readonly LibrarySet _libs; + private readonly HashSet _reserved; + private readonly Boolean _perRealmMethods; + private readonly List _order; + private readonly Dictionary _members; + + private Indexer _numericIndexer; + private Indexer _stringIndexerGetter; + private Indexer _stringIndexerSetter; + + public DomShapeBuilder(Type type, LibrarySet libs, HashSet reserved) + { + _type = type; + _baseType = type.GetTypeInfo().BaseType ?? typeof(Object); + _name = type.GetOfficialName(_baseType); + _libs = libs; + _reserved = reserved; + _order = new List(); + _members = new Dictionary(StringComparer.Ordinal); + + // The window's members are the one set a script reaches without naming a receiver: + // they are copied onto the global object, and a bare "alert('hi')" calls with no + // "this" at all. A shared implementation has nothing else to derive its engine from, + // so the window's methods keep an engine of their own - see Member.Declare. + _perRealmMethods = typeof(IWindow).GetTypeInfo().IsAssignableFrom(type.GetTypeInfo()); + } + + public DomShape Build() + { + SetAllMembers(_type); + SetExtensionMembers(); + + var constructor = _type.GetConstructorDefinition(_libs.Assemblies); + var builder = new JsObjectShape.Builder(); + + if (_name != null) + { + builder.ToStringTag(_name); + } + + for (var i = 0; i < _order.Count; i++) + { + var name = _order[i]; + + // "constructor" was written after the members and overrode one of the same name; + // the slot below has taken that role over, so a member claiming it gives way. + if (constructor != null && String.Equals(name, ConstructorName, StringComparison.Ordinal)) + { + continue; + } + + _members[name].Declare(builder, name, _perRealmMethods); + } + + if (constructor != null) + { + // The attributes an eagerly written constructor had, and still resolved on first + // read: a document names a handful of the types an assembly exposes. + builder.PerRealmSlot(ConstructorName, CreateConstructor, enumerable: false, writable: true, configurable: true); + } + + var indexers = _numericIndexer != null || _stringIndexerGetter != null || _stringIndexerSetter != null + ? new DomIndexers(_numericIndexer, _stringIndexerGetter, _stringIndexerSetter) + : null; + + return new DomShape(_type, _baseType, _name, builder.Build(), indexers, constructor); + } + + /// + /// Produces the "constructor" value of one engine's prototype. It is the very object + /// script reads off the window under the type's name, which is what keeps naming a type + /// and asking one of its instances for its constructor the same answer. + /// + private static JsValue CreateConstructor(ObjectInstance prototype) + { + var state = DomPrototypeState.Of(prototype); + return state != null ? state.GetConstructor(state.Shape.Constructor) : JsValue.Undefined; + } + + private void SetExtensionMembers() + { + if (_name == null) + { + return; + } + + foreach (var type in _libs.Assemblies.GetExtensionTypes(_name)) + { + var typeInfo = type.GetTypeInfo(); + SetExtensionMethods(typeInfo.DeclaredMethods); + } + } + + private void SetAllMembers(Type parentType) + { + foreach (var type in parentType.GetTypeTree()) + { + var typeInfo = type.GetTypeInfo(); + SetNormalProperties(typeInfo.DeclaredProperties); + SetNormalMethods(typeInfo.DeclaredMethods); + SetNormalEvents(typeInfo.DeclaredEvents); + } + } + + private void SetNormalEvents(IEnumerable eventInfos) + { + foreach (var eventInfo in eventInfos) + { + foreach (var m in eventInfo.GetCustomAttributes()) + { + SetEvent(m.OfficialName, eventInfo.AddMethod, eventInfo.RemoveMethod); + } + } + } + + private void SetExtensionMethods(IEnumerable methods) + { + foreach (var entry in methods.GetExtensions()) + { + var name = entry.Key; + var value = entry.Value; + + if (IsDeclared(name)) + { + // skip + } + else if (value.Adder != null && value.Remover != null) + { + SetEvent(name, value.Adder, value.Remover); + } + else if (value.Getter != null || value.Setter != null) + { + SetProperty(name, value.Getter, value.Setter, value.Forward); + } + else if (value.Other != null) + { + SetMethod(name, value.Other); + } + } + } + + private void SetNormalProperties(IEnumerable properties) + { + foreach (var property in properties) + { + var indexParameters = property.GetIndexParameters(); + var accessor = property.GetCustomAttribute()?.Type; + var putsForward = property.GetCustomAttribute(); + var names = property + .GetCustomAttributes() + .Select(m => m.OfficialName) + .ToArray(); + + if (accessor == Accessors.Method) + { + // property decorated with Method accessor, so we need to treat it as a method, not a property + + if (property.GetMethod == null) + { + throw new InvalidOperationException("Getter not found."); + } + + foreach (var name in names) + { + SetMethod(name, property.GetMethod); + } + + // methods were set, so continue with the next property + continue; + } + + var isIndexedAccessor = accessor.HasValue && (accessor.Value & (Accessors.Getter | Accessors.Setter)) != 0; + + if (isIndexedAccessor || Array.Exists(names, m => m.Is("item"))) + { + SetIndexer(property, indexParameters); + } + + foreach (var name in names) + { + SetProperty(name, property.GetMethod, property.SetMethod, putsForward); + } + } + } + + private void SetNormalMethods(IEnumerable methods) + { + foreach (var method in methods) + { + foreach (var m in method.GetCustomAttributes()) + { + SetMethod(m.OfficialName, method); + } + } + } + + private void SetEvent(String name, MethodInfo adder, MethodInfo remover) => + Define(name, new EventMember(new DomEventDefinition(adder, remover))); + + private void SetProperty(String name, MethodInfo getter, MethodInfo setter, DomPutForwardsAttribute putsForward) => + Define(name, new PropertyMember(getter, setter, putsForward)); + + private void SetIndexer(PropertyInfo property, ParameterInfo[] indexParameters) + { + if (indexParameters.Length != 1) + { + return; + } + + var getter = property.GetMethod; + var setter = property.SetMethod; + + if (indexParameters[0].ParameterType == typeof(Int32)) + { + if (getter != null) + { + _numericIndexer = new Indexer(getter); + } + } + else if (indexParameters[0].ParameterType == typeof(String)) + { + if (getter != null) + { + _stringIndexerGetter = new Indexer(getter); + } + + if (setter != null) + { + _stringIndexerSetter = new Indexer(setter); + } + } + } + + private void SetMethod(String name, MethodInfo method) + { + //TODO Jint + // If it already has a property with the given name (usually another method), + // then convert that method to a two-layer method, which decides which one + // to pick depending on the number (and probably types) of arguments. + if (!IsDeclared(name)) + { + Define(name, new MethodMember(method)); + } + } + + // What HasProperty answered while the members were being written onto a half-built + // prototype: the names declared so far, plus the ones its chain already carried. + private Boolean IsDeclared(String name) => _members.ContainsKey(name) || _reserved.Contains(name); + + // A member replacing one of the same name keeps its position, the way rewriting a + // dictionary entry did - the order is what a key enumeration reports. + private void Define(String name, Member member) + { + if (!_members.ContainsKey(name)) + { + _order.Add(name); + } + + _members[name] = member; + } + + /// + /// One member of a DOM type, as much of it as is the same for every engine. + /// + private abstract class Member + { + public abstract void Declare(JsObjectShape.Builder builder, String name, Boolean perRealm); + } + + private sealed class MethodMember : Member + { + private readonly MethodInfo _method; + + public MethodMember(MethodInfo method) + { + _method = method; + } + + public override void Declare(JsObjectShape.Builder builder, String name, Boolean perRealm) + { + var method = _method; + + if (perRealm) + { + // A window method may be called with no receiver at all, so it is the one + // kind of member that cannot find its engine and has to be handed one. + builder.PerRealmSlot( + name, + prototype => Bind(prototype, name, method), + enumerable: false, + writable: false, + configurable: false); + } + else + { + builder.Method( + name, + (thisObject, arguments) => EngineExtensions.CallShared(method, thisObject, arguments), + length: 0, + enumerable: false, + writable: false, + configurable: false); + } + } + + private static JsValue Bind(ObjectInstance prototype, String name, MethodInfo method) + { + var instance = DomPrototypeState.Of(prototype).Instance; + return new ClrFunction(instance.Jint, name, (thisObject, arguments) => + instance.Call(method, thisObject, arguments)); + } + } + + private sealed class PropertyMember : Member + { + private readonly MethodInfo _getter; + private readonly MethodInfo _setter; + private readonly DomPutForwardsAttribute _putsForward; + + public PropertyMember(MethodInfo getter, MethodInfo setter, DomPutForwardsAttribute putsForward) + { + _getter = getter; + _setter = setter; + _putsForward = putsForward; + } + + // Both halves are declared even when only one accessor exists, because that is what + // a pair of ClrFunctions over a null MethodInfo used to be: present, and answering + // undefined. An accessor is reached through the object it is read from, so unlike a + // method it always has a receiver to derive its engine from - the global object + // itself when a script names a window member bare. + public override void Declare(JsObjectShape.Builder builder, String name, Boolean perRealm) + { + var getter = _getter; + var setter = _setter; + var putsForward = _putsForward; + + // The forwarding case is decided here rather than per write: it is rare, and the + // ordinary setter is on the hot path for every attribute a script assigns. + var write = putsForward == null + ? (Func)((thisObject, arguments) => EngineExtensions.CallShared(setter, thisObject, arguments)) + : ((thisObject, arguments) => Forward(getter, putsForward, thisObject, arguments)); + + builder.Accessor( + name, + (thisObject, arguments) => EngineExtensions.CallShared(getter, thisObject, arguments), + write, + enumerable: false, + configurable: false); + } + + private static JsValue Forward(MethodInfo getter, DomPutForwardsAttribute putsForward, JsValue thisObject, JsValue[] arguments) + { + var instance = thisObject.GetEngineInstance() ?? throw new JavaScriptException("Illegal invocation."); + var ep = Array.Empty(); + var that = thisObject as IDomProxy ?? instance.Window; + var target = getter.Invoke(that.Value, ep); + var propName = putsForward.PropertyName; + var prop = getter.ReturnType + .GetInheritedProperties() + .FirstOrDefault(m => m.GetCustomAttributes().Any(n => n.OfficialName.Is(propName))); + var args = instance.BuildArgs(prop.SetMethod, arguments); + prop.SetMethod.Invoke(target, args); + return prop.GetMethod.Invoke(target, ep).ToJsValue(instance); + } + } + + private sealed class EventMember : Member + { + private readonly DomEventDefinition _definition; + + public EventMember(DomEventDefinition definition) + { + _definition = definition; + } + + public override void Declare(JsObjectShape.Builder builder, String name, Boolean perRealm) => + builder.Accessor(name, _definition.GetHandler, _definition.SetHandler, enumerable: false, configurable: false); + } + } +} diff --git a/src/AngleSharp.Js/Cache/LibrarySet.cs b/src/AngleSharp.Js/Cache/LibrarySet.cs new file mode 100644 index 0000000..22bd1c3 --- /dev/null +++ b/src/AngleSharp.Js/Cache/LibrarySet.cs @@ -0,0 +1,69 @@ +namespace AngleSharp.Js.Cache +{ + using System; + using System.Collections.Generic; + using System.Reflection; + + /// + /// The AngleSharp libraries an engine exposes to script, as a value. + /// + /// + /// What a DOM type looks like from script is not decided by the type alone: which extension + /// members it gains and which name its constructor is found under both depend on the set of + /// libraries the browsing context registered services from, and on the order they are + /// consulted in. Anything cached for the whole process therefore has to be keyed by this as + /// well as by the type - hence the value semantics. + /// + sealed class LibrarySet : IEquatable + { + private readonly Assembly[] _assemblies; + private readonly Int32 _hash; + + public LibrarySet(IEnumerable assemblies) + { + var list = new List(); + + foreach (var assembly in assemblies) + { + list.Add(assembly); + } + + _assemblies = list.ToArray(); + _hash = _assemblies.Length; + + for (var i = 0; i < _assemblies.Length; i++) + { + _hash = (_hash * 397) ^ _assemblies[i].GetHashCode(); + } + } + + public IReadOnlyList Assemblies => _assemblies; + + public Boolean Equals(LibrarySet other) + { + if (ReferenceEquals(this, other)) + { + return true; + } + + if (other == null || other._assemblies.Length != _assemblies.Length) + { + return false; + } + + for (var i = 0; i < _assemblies.Length; i++) + { + if (!ReferenceEquals(_assemblies[i], other._assemblies[i])) + { + return false; + } + } + + return true; + } + + public override Boolean Equals(Object obj) => Equals(obj as LibrarySet); + + public override Int32 GetHashCode() => _hash; + } +} diff --git a/src/AngleSharp.Js/Cache/PrototypeCache.cs b/src/AngleSharp.Js/Cache/PrototypeCache.cs index a65eeb7..29dae36 100644 --- a/src/AngleSharp.Js/Cache/PrototypeCache.cs +++ b/src/AngleSharp.Js/Cache/PrototypeCache.cs @@ -12,9 +12,9 @@ sealed class PrototypeCache { private readonly ConcurrentDictionary _prototypes; private readonly ConcurrentDictionary _canonicalTypes; - private readonly IEnumerable _libs; + private readonly LibrarySet _libs; - public PrototypeCache(Engine engine, IEnumerable libs) + public PrototypeCache(Engine engine, LibrarySet libs) { _prototypes = new ConcurrentDictionary { @@ -29,7 +29,7 @@ public ObjectInstance GetOrCreate(Type type, Func creator) // 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)); + public Type Canonicalize(Type type) => + _canonicalTypes.GetOrAdd(type, m => m.GetDomPrototypeType(_libs.Assemblies)); } } diff --git a/src/AngleSharp.Js/EngineInstance.cs b/src/AngleSharp.Js/EngineInstance.cs index be57b95..1506e23 100644 --- a/src/AngleSharp.Js/EngineInstance.cs +++ b/src/AngleSharp.Js/EngineInstance.cs @@ -23,7 +23,7 @@ sealed class EngineInstance private readonly Engine _engine; private readonly PrototypeCache _prototypes; private readonly ReferenceCache _references; - private readonly IEnumerable _libs; + private readonly LibrarySet _libs; private readonly DomNodeInstance _window; private readonly JsImportMap _importMap; @@ -49,8 +49,8 @@ public EngineInstance(IWindow window, IDictionary assignments, I // reports an ordinary "Maximum call stack size exceeded" error instead. o.Constraints.MaxExecutionStackCount = options.MaxCallStackDepth > 0 ? options.MaxCallStackDepth : StackGuardDisabled; }); - _libs = libs; - _prototypes = new PrototypeCache(_engine, libs); + _libs = new LibrarySet(libs); + _prototypes = new PrototypeCache(_engine, _libs); _references = new ReferenceCache(); foreach (var assignment in assignments) @@ -84,7 +84,7 @@ public EngineInstance(IWindow window, IDictionary assignments, I #region Properties - public IEnumerable Libs => _libs; + public IEnumerable Libs => _libs.Assemblies; public DomNodeInstance Window => _window; @@ -109,8 +109,8 @@ public DomConstructorInstance GetDomConstructor(ConstructorDefinition definition { // Only the prototype of System.Object is not one of ours, and that type is not // exposed as a constructor, so it never reaches this point. - var prototype = (DomPrototypeInstance)GetDomPrototype(definition.Type); - return prototype.GetConstructor(definition); + var prototype = GetDomPrototype(definition.Type); + return DomPrototypeState.Of(prototype).GetConstructor(definition); } public JsValue RunScript(String source, String type, String sourceUrl) @@ -230,7 +230,27 @@ private ObjectInstance CreateInstance(Object obj) return new DomNodeInstance(this, obj); } - private ObjectInstance CreatePrototype(Type type) => new DomPrototypeInstance(this, type); + /// + /// Builds this engine's prototype for a DOM type: an object over the member layout the + /// whole process shares for that type, chained to the prototype of its base type and + /// carrying what only this engine knows about it. + /// + private ObjectInstance CreatePrototype(Type type) + { + var shape = DomShapeCache.GetOrCreate(type, _libs, _engine); + var prototype = shape.Shape.Instantiate(_engine, GetParentPrototype(type, shape.BaseType)); + JsObjectShape.SetHostState(prototype, new DomPrototypeState(this, prototype, shape)); + return prototype; + } + + // 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. Asking for it would then re-enter + // the cache entry currently being built, so the fold is ruled out before the ask and the + // chain stops at Object.prototype, which is where it used to stop as well. + private ObjectInstance GetParentPrototype(Type type, Type baseType) => + ReferenceEquals(_prototypes.Canonicalize(baseType), type) + ? _engine.Intrinsics.Object.PrototypeObject + : GetDomPrototype(baseType); /// /// Converts a value handed over from C#, which reaches Jint through JsValue.FromObject diff --git a/src/AngleSharp.Js/Extensions/EngineExtensions.cs b/src/AngleSharp.Js/Extensions/EngineExtensions.cs index 9a27fc6..cb0c482 100644 --- a/src/AngleSharp.Js/Extensions/EngineExtensions.cs +++ b/src/AngleSharp.Js/Extensions/EngineExtensions.cs @@ -242,6 +242,54 @@ public static void AddInstance(this EngineInstance engine, ObjectInstance obj, T apply.Invoke(engine, obj); } + /// + /// Gets the engine a value belongs to, or null when nothing about it says. + /// + /// + /// A member declared on a shared prototype layout runs on behalf of whichever engine + /// instantiated it, so it has to work that out from the receiver. A DOM object answers + /// directly; anything else - the global object, or a plain object given a DOM prototype + /// by Object.create - answers through the first prototype in its chain that is one of + /// ours. Only a call made with no receiver at all leaves the question unanswerable. + /// + public static EngineInstance GetEngineInstance(this JsValue value) + { + if (value is IDomProxy proxy) + { + return proxy.Instance; + } + + for (var obj = value as ObjectInstance; obj != null; obj = obj.Prototype) + { + var state = DomPrototypeState.Of(obj); + + if (state != null) + { + return state.Instance; + } + } + + return null; + } + + /// + /// Invokes a DOM member on behalf of the engine the receiver belongs to. + /// + public static JsValue CallShared(MethodInfo method, JsValue thisObject, JsValue[] arguments) + { + var instance = thisObject.GetEngineInstance(); + + if (instance == null) + { + // A DOM member torn off its object and called with no receiver - what a browser + // answers with a TypeError, and what the engine-bound member this replaces used + // to answer by invoking against the window and failing further in. + throw new JavaScriptException("Illegal invocation."); + } + + return instance.Call(method, thisObject, arguments); + } + public static JsValue Call(this EngineInstance instance, MethodInfo method, JsValue thisObject, JsValue[] arguments) { if (method != null) diff --git a/src/AngleSharp.Js/Proxies/DomCollectionInstance.cs b/src/AngleSharp.Js/Proxies/DomCollectionInstance.cs index 2bfe5d2..d30a2b1 100644 --- a/src/AngleSharp.Js/Proxies/DomCollectionInstance.cs +++ b/src/AngleSharp.Js/Proxies/DomCollectionInstance.cs @@ -26,6 +26,8 @@ sealed class DomCollectionInstance : ArrayLikeObject, IDomProxy private readonly Object _value; private readonly IndexedCollection _collection; + private DomPrototypeState _state; + public DomCollectionInstance(EngineInstance engine, Object value, IndexedCollection collection) : base(engine.Jint) { @@ -33,7 +35,9 @@ public DomCollectionInstance(EngineInstance engine, Object value, IndexedCollect _value = value; _collection = collection; - Prototype = engine.GetDomPrototype(value.GetType()); + var prototype = engine.GetDomPrototype(value.GetType()); + Prototype = prototype; + _state = DomPrototypeState.Of(prototype); // 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 @@ -45,6 +49,27 @@ public DomCollectionInstance(EngineInstance engine, Object value, IndexedCollect public Object Value => _value; + public EngineInstance Instance => _instance; + + // Remembered rather than reached through two type tests per lookup, and checked against + // the prototype in force because a script may hand the collection another one. + private DomPrototypeState State + { + get + { + var state = _state; + var prototype = Prototype; + + if (state == null || !ReferenceEquals(state.Prototype, prototype)) + { + state = DomPrototypeState.Of(prototype); + _state = state; + } + + return state; + } + } + public override Object ToObject() => _value; public override UInt32 Length @@ -120,10 +145,12 @@ public override PropertyDescriptor GetOwnProperty(JsValue property) return PropertyDescriptor.Undefined; } - if (Prototype is DomPrototypeInstance prototype && - prototype.TryGetFromNamedIndex(_value, property, out _)) + var state = State; + var indexers = state?.Indexers; + + if (indexers != null && indexers.TryGetFromNamedIndex(state.Prototype, _value, property, out _)) { - return prototype.CreateNamedDescriptor(_value, property.ToString()); + return state.CreateNamedDescriptor(_value, property.ToString()); } return PropertyDescriptor.Undefined; @@ -158,8 +185,10 @@ private static Boolean IsArrayIndexName(JsValue property) protected override void SetOwnProperty(JsValue property, PropertyDescriptor desc) { - if (Prototype is DomPrototypeInstance prototype && - prototype.TrySetToIndex(_value, property, desc.Value)) + var state = State; + var indexers = state?.Indexers; + + if (indexers != null && indexers.TrySetToIndex(state.Prototype, _instance, _value, property, desc.Value)) { return; } diff --git a/src/AngleSharp.Js/Proxies/DomConstructorInstance.cs b/src/AngleSharp.Js/Proxies/DomConstructorInstance.cs index 0759b8d..b11a1cd 100644 --- a/src/AngleSharp.Js/Proxies/DomConstructorInstance.cs +++ b/src/AngleSharp.Js/Proxies/DomConstructorInstance.cs @@ -31,20 +31,11 @@ public DomConstructorInstance(EngineInstance engine, ConstructorDefinition defin Prototype = (ObjectInstance)engine.Jint.Intrinsics.Function.Get("prototype"); FastSetProperty("toString", new PropertyDescriptor(toString, true, false, true)); - SetOwnProperty("prototype", new PropertyDescriptor(_objectPrototype, false, false, false)); - - 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); - } + // Nothing is written back onto the prototype. Its member layout declares + // "constructor" as a slot resolved on first read, and that read arrives here, so the + // two directions still meet at one object without this end having to reach over. + SetOwnProperty("prototype", new PropertyDescriptor(_objectPrototype, false, false, false)); } /// diff --git a/src/AngleSharp.Js/Proxies/DomEventInstance.cs b/src/AngleSharp.Js/Proxies/DomEventDefinition.cs similarity index 62% rename from src/AngleSharp.Js/Proxies/DomEventInstance.cs rename to src/AngleSharp.Js/Proxies/DomEventDefinition.cs index 503caca..e62cef3 100644 --- a/src/AngleSharp.Js/Proxies/DomEventInstance.cs +++ b/src/AngleSharp.Js/Proxies/DomEventDefinition.cs @@ -4,39 +4,42 @@ namespace AngleSharp.Js using Jint; using Jint.Native; using Jint.Native.Function; - using Jint.Runtime.Interop; using System; using System.Reflection; - sealed class DomEventInstance + /// + /// One event-handler member of a DOM type - "onclick" and its kin - as the accessor pair + /// a prototype declares for it. + /// + /// + /// The pair is process-shared along with the rest of the type's members, so this object + /// must stay free of anything belonging to an engine: it holds the two reflected accessors + /// and derives everything else from the receiver. It doubles as the key a node files its + /// handler under, which is what keeps the handler itself - the one thing here that is per + /// node and per engine - on the node. + /// + sealed class DomEventDefinition { - private readonly EngineInstance _engine; private readonly MethodInfo _addHandler; private readonly MethodInfo _removeHandler; - public DomEventInstance(EngineInstance engine, MethodInfo addHandler, MethodInfo removeHandler) + public DomEventDefinition(MethodInfo addHandler, MethodInfo removeHandler) { - _engine = engine; _addHandler = addHandler; _removeHandler = removeHandler; - Getter = new ClrFunction(engine.Jint, "get", GetEventHandler); - Setter = new ClrFunction(engine.Jint, "set", SetEventHandler); } - public ClrFunction Getter { get; } - - public ClrFunction Setter { get; } - - private JsValue GetEventHandler(JsValue thisObject, JsValue[] arguments) + public JsValue GetHandler(JsValue thisObject, JsValue[] arguments) { var node = thisObject.As(); var registration = node?.GetEventHandler(this); return registration?.Function ?? JsValue.Null; } - private JsValue SetEventHandler(JsValue thisObject, JsValue[] arguments) + public JsValue SetHandler(JsValue thisObject, JsValue[] arguments) { var node = thisObject.As(); + var value = arguments.Length > 0 ? arguments[0] : JsValue.Undefined; if (node != null) { @@ -47,12 +50,14 @@ private JsValue SetEventHandler(JsValue thisObject, JsValue[] arguments) _removeHandler?.Invoke(node.Value, new Object[] { previous.Handler }); } - if (arguments[0] is Function function) + if (value is Function function) { + var engine = node.Instance; + DomEventHandler handler = (s, ev) => { - var sender = s.ToJsValue(_engine); - var args = ev.ToJsValue(_engine); + var sender = s.ToJsValue(engine); + var args = ev.ToJsValue(engine); function.Call(sender, new[] { args }); }; @@ -61,7 +66,7 @@ private JsValue SetEventHandler(JsValue thisObject, JsValue[] arguments) } } - return arguments[0]; + return value; } /// diff --git a/src/AngleSharp.Js/Proxies/DomIndexers.cs b/src/AngleSharp.Js/Proxies/DomIndexers.cs new file mode 100644 index 0000000..d60b184 --- /dev/null +++ b/src/AngleSharp.Js/Proxies/DomIndexers.cs @@ -0,0 +1,197 @@ +namespace AngleSharp.Js +{ + using Jint.Native; + using Jint.Native.Object; + using Jint.Native.Symbol; + using Jint.Runtime.Descriptors; + using Jint.Runtime.Interop; + using System; + using System.Reflection; + + /// + /// The indexers a DOM type offers - the numeric one a collection is read through, the + /// string one a named entry is found by - resolved once for the type. + /// + /// + /// Everything here is derived from the type's accessors and from nothing else, so an + /// instance of this class describes the type rather than any one engine, and is shared by + /// every prototype built from that type. Only the objects it produces - a value, a + /// descriptor - belong to the engine that asked for them, which is why the engine is a + /// parameter of the methods that produce one and of no others. + /// + sealed class DomIndexers + { + private readonly Indexer _numeric; + private readonly Indexer _namedGetter; + private readonly Indexer _namedSetter; + + public DomIndexers(Indexer numeric, Indexer namedGetter, Indexer namedSetter) + { + _numeric = numeric; + _namedGetter = namedGetter; + _namedSetter = namedSetter; + } + + /// + /// Asks the indexers about one property name, against the object being indexed rather + /// than against the prototype declaring them. + /// + /// + /// 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(ObjectInstance prototype, Object value, JsValue property, out Object result) + { + result = null; + + // Reached for every property lookup on every node of a type that has any indexer at + // all, and a write-only string indexer answers no read. Nothing below may run before + // that is ruled out - not even turning the key into a String. + if (_numeric == null && _namedGetter == 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 (_numeric != null && Int32.TryParse(index, out var numericIndex)) + { + try + { + var args = new Object[] { numericIndex }; + result = _numeric.Invoke(value, args); + return IndexerResult.Value; + } + catch (TargetInvocationException ex) + { + if (ex.InnerException is ArgumentOutOfRangeException) + { + return IndexerResult.Absent; + } + + throw; + } + } + + // Else a string property + return TryGetFromNamedIndex(prototype, 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(ObjectInstance prototype, Object value, JsValue property, out Object result) + { + result = null; + + if (_namedGetter == null || property is JsSymbol) + { + return false; + } + + var index = property.ToString(); + + // If we have a string indexer and no property exists for this name then use the string indexer + // Jint possibly has a limitation here - if an object has a string indexer. How do we know whether to use the defined indexer or a property? + // Eg. object.callMethod1() vs object['callMethod1'] is not necessarily the same if the object has a string indexer?? (I'm not an ECMA expert!) + // node.attributes is one such object - has both a string and numeric indexer + // This GetOwnProperty override might need an additional parameter to let us know this was called via an indexer + // + // 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. + // A shaped prototype answers it off the member layout, without materialising the + // member the answer is about. + if (prototype.HasProperty(property as JsString ?? (JsValue)index)) + { + return false; + } + + var valueAtIndex = _namedGetter.Invoke(value, new Object[] { index }); + + if (valueAtIndex == null && _namedSetter == null) + { + return false; + } + + // 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; + } + + /// + /// Whether a write to a name the string indexer claims has anywhere to go. + /// + public Boolean HasNamedSetter => _namedSetter != null; + + /// + /// Reads one name through the string indexer. Called by the descriptor + /// hands out, which keeps its + /// getter live rather than freezing the value it read. + /// + public Object ReadNamed(Object value, Object[] args) => _namedGetter.Invoke(value, args); + + /// + /// Writes one name through the string indexer. + /// + public void WriteNamed(Object value, Object[] args, EngineInstance engine) => + _namedSetter.Invoke(value, args, engine); + + public Boolean TrySetToIndex(ObjectInstance prototype, EngineInstance engine, Object value, JsValue property, JsValue newValue) + { + if (_namedSetter == null) + { + return false; + } + + var index = property.ToString(); + + if (prototype.HasProperty(property as JsString ?? (JsValue)index)) + { + return false; + } + + _namedSetter.Invoke(value, new Object[] { index, newValue }, engine); + return true; + } + + /// + /// What the indexers of a prototype have to say about a property name. + /// + internal 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 + } + } +} diff --git a/src/AngleSharp.Js/Proxies/DomNodeInstance.cs b/src/AngleSharp.Js/Proxies/DomNodeInstance.cs index dd8f66a..f0628fd 100644 --- a/src/AngleSharp.Js/Proxies/DomNodeInstance.cs +++ b/src/AngleSharp.Js/Proxies/DomNodeInstance.cs @@ -12,7 +12,8 @@ sealed class DomNodeInstance : ObjectInstance, IDomProxy private readonly EngineInstance _instance; private readonly Object _value; - private Dictionary _eventHandlers; + private Dictionary _eventHandlers; + private DomPrototypeState _state; public DomNodeInstance(EngineInstance engine, Object value) : base(engine.Jint) @@ -20,19 +21,50 @@ public DomNodeInstance(EngineInstance engine, Object value) _instance = engine; _value = value; - Prototype = engine.GetDomPrototype(value.GetType()); + var prototype = engine.GetDomPrototype(value.GetType()); + Prototype = prototype; + _state = DomPrototypeState.Of(prototype); + } + + /// + /// Gets what this node's prototype knows, or null if a script has given it a prototype + /// that is not one of ours. + /// + /// + /// Every property lookup on every node asks this - it is what decides whether an indexer + /// claims the name - so the answer is remembered rather than reached through two type + /// tests per lookup. The prototype of a node is not fixed, though: a script may hand it + /// another one, so what is remembered is checked against the prototype in force. + /// + private DomPrototypeState State + { + get + { + var state = _state; + var prototype = Prototype; + + if (state == null || !ReferenceEquals(state.Prototype, prototype)) + { + state = DomPrototypeState.Of(prototype); + _state = state; + } + + return state; + } } public Object Value => _value; + public EngineInstance Instance => _instance; + public override object ToObject() => _value; /// /// Gets the handler assigned to this node for the given event, if any. - /// The handler is per node - the itself is - /// shared by every node using the same prototype. + /// The handler is per node - the itself is + /// shared by every node of the type, in every engine. /// - public DomEventInstance.Registration GetEventHandler(DomEventInstance ev) + public DomEventDefinition.Registration GetEventHandler(DomEventDefinition ev) { if (_eventHandlers != null && _eventHandlers.TryGetValue(ev, out var registration)) { @@ -45,16 +77,16 @@ public DomEventInstance.Registration GetEventHandler(DomEventInstance ev) /// /// Assigns the handler for the given event to this node. /// - public void SetEventHandler(DomEventInstance ev, DomEventInstance.Registration registration) + public void SetEventHandler(DomEventDefinition ev, DomEventDefinition.Registration registration) { - _eventHandlers = _eventHandlers ?? new Dictionary(); + _eventHandlers = _eventHandlers ?? new Dictionary(); _eventHandlers[ev] = registration; } /// /// Removes and returns the handler assigned to this node for the given event, if any. /// - public DomEventInstance.Registration RemoveEventHandler(DomEventInstance ev) + public DomEventDefinition.Registration RemoveEventHandler(DomEventDefinition ev) { if (_eventHandlers != null && _eventHandlers.TryGetValue(ev, out var registration)) { @@ -73,12 +105,12 @@ public override PropertyDescriptor GetOwnProperty(JsValue property) // every inherited member as its own. switch (LookupIndex(property, out var indexed)) { - case DomPrototypeInstance.IndexerResult.Value: + case DomIndexers.IndexerResult.Value: return new PropertyDescriptor(indexed.ToJsValue(_instance), false, false, false); - case DomPrototypeInstance.IndexerResult.Absent: + case DomIndexers.IndexerResult.Absent: return PropertyDescriptor.Undefined; - case DomPrototypeInstance.IndexerResult.Named: - return ((DomPrototypeInstance)Prototype).CreateNamedDescriptor(_value, property.ToString()); + case DomIndexers.IndexerResult.Named: + return State.CreateNamedDescriptor(_value, property.ToString()); } return base.GetOwnProperty(property); @@ -101,13 +133,13 @@ protected override Boolean TryGetOwnPropertyValue(JsValue property, JsValue rece { switch (LookupIndex(property, out var indexed)) { - case DomPrototypeInstance.IndexerResult.Value: + case DomIndexers.IndexerResult.Value: value = indexed.ToJsValue(_instance); return true; - case DomPrototypeInstance.IndexerResult.Absent: + case DomIndexers.IndexerResult.Absent: value = JsValue.Undefined; return false; - case DomPrototypeInstance.IndexerResult.Named: + case DomIndexers.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); @@ -152,12 +184,12 @@ protected override OwnPropertyProbe ProbeOwnProperty(JsValue property) { switch (LookupIndex(property, out _)) { - case DomPrototypeInstance.IndexerResult.Value: + case DomIndexers.IndexerResult.Value: // The attributes GetOwnProperty gives an indexed entry. return OwnPropertyProbe.NonEnumerable; - case DomPrototypeInstance.IndexerResult.Absent: + case DomIndexers.IndexerResult.Absent: return OwnPropertyProbe.Missing; - case DomPrototypeInstance.IndexerResult.Named: + case DomIndexers.IndexerResult.Named: return OwnPropertyProbe.NonEnumerable; } @@ -171,27 +203,28 @@ protected override OwnPropertyProbe ProbeOwnProperty(JsValue property) return descriptor.Enumerable ? OwnPropertyProbe.Enumerable : OwnPropertyProbe.NonEnumerable; } - private DomPrototypeInstance.IndexerResult LookupIndex(JsValue property, out Object indexed) + private DomIndexers.IndexerResult LookupIndex(JsValue property, out Object indexed) { - if (Prototype is DomPrototypeInstance prototype) + var state = State; + var indexers = state?.Indexers; + + if (indexers != null) { - return prototype.TryGetFromIndex(_value, property, out indexed); + return indexers.TryGetFromIndex(state.Prototype, _value, property, out indexed); } indexed = null; - return DomPrototypeInstance.IndexerResult.None; + return DomIndexers.IndexerResult.None; } protected override void SetOwnProperty(JsValue property, PropertyDescriptor desc) { - if (Prototype is DomPrototypeInstance prototype) - { - var value = desc.Value; + var state = State; + var indexers = state?.Indexers; - if (prototype.TrySetToIndex(_value, property, value)) - { - return; - } + if (indexers != null && indexers.TrySetToIndex(state.Prototype, _instance, _value, property, desc.Value)) + { + return; } base.SetOwnProperty(property, desc); diff --git a/src/AngleSharp.Js/Proxies/DomPrototypeInstance.cs b/src/AngleSharp.Js/Proxies/DomPrototypeInstance.cs deleted file mode 100644 index d12769b..0000000 --- a/src/AngleSharp.Js/Proxies/DomPrototypeInstance.cs +++ /dev/null @@ -1,498 +0,0 @@ -namespace AngleSharp.Js -{ - using AngleSharp.Attributes; - using AngleSharp.Js.Cache; - using AngleSharp.Text; - using Jint.Native; - using Jint.Native.Object; - using Jint.Native.Symbol; - using Jint.Runtime.Descriptors; - using Jint.Runtime.Interop; - using System; - using System.Collections.Concurrent; - using System.Collections.Generic; - using System.Linq; - using System.Reflection; - - 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 DomConstructorInstance _constructor; - private Indexer _numericIndexer; - private Indexer _stringIndexerGetter; - private Indexer _stringIndexerSetter; - - public DomPrototypeInstance(EngineInstance engine, Type type) - : base(engine.Jint) - { - _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); - SetExtensionMembers(); - - // 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) - { - foreach (var property in _deferred) - { - FastSetProperty(property.Key, property.Value); - } - - _deferred = null; - } - - // 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(_instance.Libs); - - if (definition != null) - { - GetConstructor(definition); - } - } - - /// - /// Gets the constructor object of the type this prototype belongs to, building it - /// on first ask. Holding it here is what keeps the one script reads off the window - /// and the one an instance reports as its "constructor" the same object. - /// - public DomConstructorInstance GetConstructor(ConstructorDefinition definition) => - _constructor ?? (_constructor = new DomConstructorInstance(_instance, definition)); - - // 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)); - } - } - - /// - /// 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) - { - 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 }; - result = _numericIndexer.Invoke(value, args); - return IndexerResult.Value; - } - catch (TargetInvocationException ex) - { - if (ex.InnerException is ArgumentOutOfRangeException) - { - return IndexerResult.Absent; - } - - throw; - } - } - - // Else a string property - return TryGetFromNamedIndex(value, property, out result) ? IndexerResult.Named : IndexerResult.None; - } - - /// - /// The string-indexer half on its own, for a collection whose numeric half the engine - /// owns and must not be asked for twice. - /// - public Boolean TryGetFromNamedIndex(Object value, JsValue property, out Object result) - { - result = null; - - EnsureInitialized(); - - if (_stringIndexerGetter == null || property is JsSymbol) - { - return false; - } - - var index = property.ToString(); - - // If we have a string indexer and no property exists for this name then use the string indexer - // Jint possibly has a limitation here - if an object has a string indexer. How do we know whether to use the defined indexer or a property? - // Eg. object.callMethod1() vs object['callMethod1'] is not necessarily the same if the object has a string indexer?? (I'm not an ECMA expert!) - // node.attributes is one such object - has both a string and numeric indexer - // This GetOwnProperty override might need an additional parameter to let us know this was called via an indexer - // - // HasProperty takes a JsValue, so handing it the String would build one per - // lookup - and every read of an ordinary member of an indexed collection, say - // the length a loop tests, comes through here. The engine already passed one in. - if (HasProperty(property as JsString ?? (JsValue)index)) - { - return false; - } - - var valueAtIndex = _stringIndexerGetter.Invoke(value, new Object[] { index }); - - if (valueAtIndex == null && _stringIndexerSetter == null) - { - return false; - } - - // 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; - } - - /// - /// 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 }; - - // With nothing to forward a write to there is nothing an accessor pair would add: - // the value is read at the same moment either way, and the pair costs two function - // objects on a path that runs per read. - if (_stringIndexerSetter == null) - { - var current = _stringIndexerGetter.Invoke(value, args); - return new PropertyDescriptor( - current == null ? JsValue.Undefined : current.ToJsValue(_instance), false, false, false); - } - - var getter = new ClrFunction(_instance.Jint, index, (obj, values) => - { - var current = _stringIndexerGetter.Invoke(value, args); - return current == null ? JsValue.Undefined : current.ToJsValue(_instance); - }); - - ClrFunction setter = null; - - if (_stringIndexerSetter != null) - { - setter = new ClrFunction(_instance.Jint, index, (obj, values) => - { - var valueToSet = values.Length > 0 ? values[0] : JsValue.Undefined; - _stringIndexerSetter.Invoke(value, new Object[] { index, valueToSet }, _instance); - return valueToSet; - }); - } - - return new GetSetPropertyDescriptor(getter, setter, false, false); - } - - public Boolean TrySetToIndex(Object value, JsValue property, JsValue newValue) - { - EnsureInitialized(); - - if (_stringIndexerSetter == null) - { - 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() - { - foreach (var type in _instance.Libs.GetExtensionTypes(_name)) - { - var typeInfo = type.GetTypeInfo(); - SetExtensionMethods(typeInfo.DeclaredMethods); - } - } - - private void SetAllMembers(Type parentType) - { - foreach (var type in parentType.GetTypeTree()) - { - var typeInfo = type.GetTypeInfo(); - SetNormalProperties(typeInfo.DeclaredProperties); - SetNormalMethods(typeInfo.DeclaredMethods); - SetNormalEvents(typeInfo.DeclaredEvents); - } - } - - private void SetNormalEvents(IEnumerable eventInfos) - { - foreach (var eventInfo in eventInfos) - { - foreach (var m in eventInfo.GetCustomAttributes()) - { - SetEvent(m.OfficialName, eventInfo.AddMethod, eventInfo.RemoveMethod); - } - } - } - - private void SetExtensionMethods(IEnumerable methods) - { - foreach (var entry in methods.GetExtensions()) - { - var name = entry.Key; - var value = entry.Value; - - if (HasProperty(name)) - { - // skip - } - else if (value.Adder != null && value.Remover != null) - { - SetEvent(name, value.Adder, value.Remover); - } - else if (value.Getter != null || value.Setter != null) - { - SetProperty(name, value.Getter, value.Setter, value.Forward); - } - else if (value.Other != null) - { - SetMethod(name, value.Other); - } - } - } - - private void SetNormalProperties(IEnumerable properties) - { - foreach (var property in properties) - { - var indexParameters = property.GetIndexParameters(); - var accessor = property.GetCustomAttribute()?.Type; - var putsForward = property.GetCustomAttribute(); - var names = property - .GetCustomAttributes() - .Select(m => m.OfficialName) - .ToArray(); - - if (accessor == Accessors.Method) - { - // property decorated with Method accessor, so we need to treat it as a method, not a property - - if (property.GetMethod == null) - { - throw new InvalidOperationException("Getter not found."); - } - - foreach (var name in names) - { - SetMethod(name, property.GetMethod); - } - - // methods were set, so continue with the next property - continue; - } - - var isIndexedAccessor = accessor.HasValue && (accessor.Value & (Accessors.Getter | Accessors.Setter)) != 0; - - if (isIndexedAccessor || Array.Exists(names, m => m.Is("item"))) - { - SetIndexer(property, indexParameters); - } - - foreach (var name in names) - { - SetProperty(name, property.GetMethod, property.SetMethod, putsForward); - } - } - } - - private void SetNormalMethods(IEnumerable methods) - { - foreach (var method in methods) - { - foreach (var m in method.GetCustomAttributes()) - { - SetMethod(m.OfficialName, method); - } - } - } - - private void SetEvent(String name, MethodInfo adder, MethodInfo remover) - { - var eventInstance = new DomEventInstance(_instance, adder, remover); - FastSetProperty(name, new GetSetPropertyDescriptor(eventInstance.Getter, eventInstance.Setter, false, false)); - } - - private void SetProperty(String name, MethodInfo getter, MethodInfo setter, DomPutForwardsAttribute putsForward) - { - FastSetProperty(name, new GetSetPropertyDescriptor( - new ClrFunction(_instance.Jint, name, (obj, values) => - _instance.Call(getter, obj, values)), - new ClrFunction(_instance.Jint, name, (obj, values) => - { - if (putsForward != null) - { - var ep = Array.Empty(); - var that = obj as IDomProxy ?? _instance.Window; - var target = getter.Invoke(that.Value, ep); - var propName = putsForward.PropertyName; - var prop = getter.ReturnType - .GetInheritedProperties() - .FirstOrDefault(m => m.GetCustomAttributes().Any(n => n.OfficialName.Is(propName))); - var args = _instance.BuildArgs(prop.SetMethod, values); - prop.SetMethod.Invoke(target, args); - return prop.GetMethod.Invoke(target, ep).ToJsValue(_instance); - } - - return _instance.Call(setter, obj, values); - }), false, false)); - } - - private void SetIndexer(PropertyInfo property, ParameterInfo[] indexParameters) - { - if (indexParameters.Length != 1) - { - return; - } - - var getter = property.GetMethod; - var setter = property.SetMethod; - - if (indexParameters[0].ParameterType == typeof(Int32)) - { - if (getter != null) - { - _numericIndexer = new Indexer(getter); - } - } - else if (indexParameters[0].ParameterType == typeof(String)) - { - if (getter != null) - { - _stringIndexerGetter = new Indexer(getter); - } - - if (setter != null) - { - _stringIndexerSetter = new Indexer(setter); - } - } - } - - private void SetMethod(String name, MethodInfo method) - { - //TODO Jint - // If it already has a property with the given name (usually another method), - // then convert that method to a two-layer method, which decides which one - // to pick depending on the number (and probably types) of arguments. - if (!HasProperty(name)) - { - FastSetProperty(name, new PropertyDescriptor( - new ClrFunction(_instance.Jint, name, (obj, values) => - _instance.Call(method, obj, values) - ), false, false, false)); - } - } - - /// - /// What the indexers of a prototype have to say about a property name. - /// - internal 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 - } - } -} diff --git a/src/AngleSharp.Js/Proxies/DomPrototypeState.cs b/src/AngleSharp.Js/Proxies/DomPrototypeState.cs new file mode 100644 index 0000000..06ed35d --- /dev/null +++ b/src/AngleSharp.Js/Proxies/DomPrototypeState.cs @@ -0,0 +1,136 @@ +namespace AngleSharp.Js +{ + using AngleSharp.Js.Cache; + using Jint.Native; + using Jint.Native.Object; + using Jint.Runtime.Descriptors; + using Jint.Runtime.Interop; + using System; + + /// + /// What one engine's prototype for a DOM type carries beyond its members: which engine it + /// belongs to, which type it stands for, and the constructor object published under the + /// type's name. + /// + /// + /// The member layout is shared by every engine, so none of this can live on it - all three + /// are either engine-affine or, in the constructor's case, an object of exactly one engine. + /// It hangs off the prototype instead, which is what + /// exists for, so it is + /// reachable from any receiver by walking up to the prototype that declares the member being + /// served - the way a shared member implementation finds its engine. + /// + sealed class DomPrototypeState + { + private readonly EngineInstance _instance; + private readonly ObjectInstance _prototype; + private readonly DomShape _shape; + private readonly DomIndexers _indexers; + + private DomConstructorInstance _constructor; + + public DomPrototypeState(EngineInstance instance, ObjectInstance prototype, DomShape shape) + { + _instance = instance; + _prototype = prototype; + _shape = shape; + + // Copied out rather than reached through the shape: every property lookup on every + // node of this type asks whether an indexer claims the name, and for almost every + // type the answer is the null below. + _indexers = shape.Indexers; + } + + /// + /// Gets the state of a prototype, or null for an object that is not one - which is the + /// answer a walk up a prototype chain needs when it runs off the end. + /// + public static DomPrototypeState Of(ObjectInstance prototype) => + JsObjectShape.GetHostState(prototype) as DomPrototypeState; + + /// + /// Gets the engine the prototype belongs to. + /// + public EngineInstance Instance => _instance; + + /// + /// Gets the prototype this state belongs to, which is how a proxy holding the state + /// checks that it is still the state of the prototype it currently inherits from. + /// + public ObjectInstance Prototype => _prototype; + + /// + /// Gets what the prototype is built from, shared with every other engine. + /// + public DomShape Shape => _shape; + + /// + /// Gets the indexers of the type, or null if it has none. + /// + public DomIndexers Indexers => _indexers; + + /// + /// 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. + /// + /// + /// It lives here rather than on the indexers because the pair needs an engine as well as + /// the indexers, and this object is exactly the two of them together - so the closure + /// behind the pair carries one reference for both, on a path that builds it per read. + /// + 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 (!_indexers.HasNamedSetter) + { + var current = _indexers.ReadNamed(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 = _indexers.ReadNamed(value, args); + return current == null ? JsValue.Undefined : current.ToJsValue(_instance); + }); + + var setter = new ClrFunction(_instance.Jint, index, (obj, values) => + { + var valueToSet = values.Length > 0 ? values[0] : JsValue.Undefined; + _indexers.WriteNamed(value, new Object[] { index, valueToSet }, _instance); + return valueToSet; + }); + + return new GetSetPropertyDescriptor(getter, setter, false, false); + } + + /// + /// Gets the constructor object of the type this prototype belongs to, building it on + /// first ask. Holding it here is what keeps the one script reads off the window and the + /// one an instance reports as its "constructor" the same object. + /// + public DomConstructorInstance GetConstructor(ConstructorDefinition definition) + { + if (_constructor == null) + { + _constructor = new DomConstructorInstance(_instance, definition); + + // A type the engine's libraries expose under no name of their own gets no + // "constructor" slot in the shape, so naming its constructor is the moment the + // property has to appear - which is what the constructor object used to do for + // every type. A shaped object takes an undeclared name without losing its shape. + if (_shape.Constructor == null) + { + _prototype.FastSetProperty("constructor", new PropertyDescriptor(_constructor, true, false, true)); + } + } + + return _constructor; + } + } +} diff --git a/src/AngleSharp.Js/Proxies/IDomProxy.cs b/src/AngleSharp.Js/Proxies/IDomProxy.cs index a303a99..e6b07f2 100644 --- a/src/AngleSharp.Js/Proxies/IDomProxy.cs +++ b/src/AngleSharp.Js/Proxies/IDomProxy.cs @@ -17,5 +17,11 @@ interface IDomProxy /// Gets the DOM object this proxy stands for. /// Object Value { get; } + + /// + /// Gets the engine the proxy belongs to. A prototype member is shared by every engine + /// and reaches its own through whichever object it was invoked on. + /// + EngineInstance Instance { get; } } } From 4e4c5c34d879601d198639f43d8879394cda4f59 Mon Sep 17 00:00:00 2001 From: Marko Lahma Date: Wed, 29 Jul 2026 14:14:59 +0300 Subject: [PATCH 2/3] Let the type-name globals rejoin the engine's identifier cache Every exported type is published as a property of the window, and every one of those properties is copied onto the global object, so `HTMLDivElement` and `Image` and the other names a script may read bare are all backed by the same descriptor. It was a hand-written one carrying `CustomJsValue`, because the constructor object behind a name is only built once script reads it - a document names a handful of the types an assembly exposes. That flag is permanent on such a descriptor, and Jint's global-identifier cache declines a descriptor that could still compute a different value on the next read. It has no way to learn that a custom value became a constant, so the name stayed uncached for the rest of the process even after the one read that fixed its value forever. `PropertyDescriptor.CreateLazy` (Jint 4.15.3) is the same deferral written so the engine can see it end: the descriptor drops the flag the moment it holds a value and rejoins the caches that decline it. The attributes are unchanged - enumerable, neither writable nor configurable - and so is everything a script can observe, which `DeferredConstructorTests` covers in seventeen tests. The floor moves to 4.15.3 for that: it is an API the shipped assembly now uses, which is a stronger reason than the measured one that moved it to 4.15.2. Co-Authored-By: Claude Fable 5 --- src/AngleSharp.Js.nuspec | 12 +++--- src/AngleSharp.Js/AngleSharp.Js.csproj | 2 +- .../Extensions/EngineExtensions.cs | 37 ++++++++++++++++++- .../Proxies/DomConstructorDescriptor.cs | 37 ------------------- 4 files changed, 43 insertions(+), 45 deletions(-) delete mode 100644 src/AngleSharp.Js/Proxies/DomConstructorDescriptor.cs diff --git a/src/AngleSharp.Js.nuspec b/src/AngleSharp.Js.nuspec index 814f90a..180ae03 100644 --- a/src/AngleSharp.Js.nuspec +++ b/src/AngleSharp.Js.nuspec @@ -18,27 +18,27 @@ - + - + - + - + - + - + diff --git a/src/AngleSharp.Js/AngleSharp.Js.csproj b/src/AngleSharp.Js/AngleSharp.Js.csproj index 35e0e69..b5d6194 100644 --- a/src/AngleSharp.Js/AngleSharp.Js.csproj +++ b/src/AngleSharp.Js/AngleSharp.Js.csproj @@ -19,7 +19,7 @@ - + diff --git a/src/AngleSharp.Js/Extensions/EngineExtensions.cs b/src/AngleSharp.Js/Extensions/EngineExtensions.cs index cb0c482..f5f1465 100644 --- a/src/AngleSharp.Js/Extensions/EngineExtensions.cs +++ b/src/AngleSharp.Js/Extensions/EngineExtensions.cs @@ -226,10 +226,45 @@ public static void AddConstructor(this EngineInstance engine, ObjectInstance obj if (definition != null) { - obj.FastSetProperty(definition.Name, new DomConstructorDescriptor(engine, definition)); + obj.FastSetProperty(definition.Name, CreateConstructorProperty(engine, definition)); } } + /// + /// The property an exposed type is published under, on the window and on the global object. + /// A document names a handful of the types an assembly exposes, but a property is registered + /// for every one of them, so the constructor object behind it is only built once script reads + /// the property. + /// + /// + /// The attributes are the ones an eagerly written constructor had: enumerable, but neither + /// writable nor configurable. A lazy descriptor rather than a hand-written custom-valued one + /// because it stops being lazy once it holds its value, and the engine's global-identifier + /// cache declines a descriptor that could still compute - permanently, since it has no way to + /// learn that a custom value became a constant. A type name is exactly the sort of global a + /// script reads over and over. + /// + private static PropertyDescriptor CreateConstructorProperty(EngineInstance engine, ConstructorDefinition definition) => + PropertyDescriptor.CreateLazy( + new ConstructorRequest(engine, definition), + static request => request.Instance.GetDomConstructor(request.Definition), + PropertyFlag.OnlyEnumerable); + + // Handed to the factory instead of captured by it, so that the delegate above is the same + // one for every type rather than a closure allocated per registered name. + private readonly struct ConstructorRequest + { + public ConstructorRequest(EngineInstance instance, ConstructorDefinition definition) + { + Instance = instance; + Definition = definition; + } + + public EngineInstance Instance { get; } + + public ConstructorDefinition Definition { get; } + } + public static void AddConstructorFunction(this EngineInstance engine, ObjectInstance obj, Type type) { var apply = type.GetConstructorFunctionAction(); diff --git a/src/AngleSharp.Js/Proxies/DomConstructorDescriptor.cs b/src/AngleSharp.Js/Proxies/DomConstructorDescriptor.cs deleted file mode 100644 index 00590d7..0000000 --- a/src/AngleSharp.Js/Proxies/DomConstructorDescriptor.cs +++ /dev/null @@ -1,37 +0,0 @@ -namespace AngleSharp.Js -{ - using AngleSharp.Js.Cache; - using Jint.Native; - using Jint.Runtime.Descriptors; - - /// - /// The property an exposed type is published under on the window and on the global - /// object. A document names a handful of the types an assembly exposes, but a property - /// is registered for every one of them, so the constructor object behind it is only - /// built once script reads the property. - /// - sealed class DomConstructorDescriptor : PropertyDescriptor - { - private readonly EngineInstance _instance; - private readonly ConstructorDefinition _definition; - private JsValue _resolved; - - // The attributes an eagerly written constructor had: enumerable, but neither - // writable nor configurable. CustomJsValue is what routes a read through - // CustomValue below; Jint reads that flag on every access instead of taking a - // copy of the value, so the descriptor keeps working once one of the engine's - // property caches has taken hold of it. - public DomConstructorDescriptor(EngineInstance instance, ConstructorDefinition definition) - : base(PropertyFlag.OnlyEnumerable | PropertyFlag.CustomJsValue) - { - _instance = instance; - _definition = definition; - } - - protected override JsValue CustomValue - { - get => _resolved ?? (_resolved = _instance.GetDomConstructor(_definition)); - set => _resolved = value; - } - } -} From 47dff308c002549ddb68a3de7d67271ba2fb7de8 Mon Sep 17 00:00:00 2001 From: Marko Lahma Date: Wed, 29 Jul 2026 14:15:14 +0300 Subject: [PATCH 3/3] Assert the shaping, and verify the proxies' hooks on every run Two things this binding depends on were, until Jint 4.15.3, only observable through a diagnostic or not at all. `Engine.Advanced.HasSharedShape` is a supported predicate for "did the shaping actually happen", where `GetObjectRepresentation` is explicitly not part of Jint's compatibility contract. Instantiating a prototype from a member layout falls back to the ordinary per-object dictionary silently and correctly when it cannot shape an object, so nothing about a document would look wrong if a DOM prototype stopped being shaped - only every warm member read on every node would get slower again, which is the entire point of the previous commit. The three prototype kinds now assert it, and the two proxies assert the opposite: a host object of ours shares its layout with nothing, which is the refusal the prototypes had to be moved out of. The host-contract verifiers are the checks that catch a host object answering one of the engine's extension points in a way that contradicts another. This binding answers four of them - `TryGetOwnPropertyValue`, `ProbeOwnProperty`, `HasIndex`, and the semantics derived from not overriding `Get` - and the engine trusts every one without re-checking, so a wrong answer is silent: a member disappears from every enumeration, or a read that should have found an attribute resolves on the prototype. They used to be compiled out of Release, so reaching them meant building Jint from source in Debug, which is a thing a suite does once and then stops doing. Since 4.15.3 the shipped package reads an AppContext switch, so this run - against the very package the library ships against - is a verified one. The switch has to be set before the first use of any Jint type, because the gate behind it is read once at type initialization. A module initializer is the only hook early enough by construction, and it needs a declared attribute on net462 and net472, whose BCL carries none. `TheVerifiersAreRunningForThisSuite` proves the ordering from behaviour rather than from Jint's internal gate: a host whose probe contradicts its own descriptors throws exactly when something is verifying. Co-Authored-By: Claude Fable 5 --- .../HostContractVerificationTests.cs | 115 ++++++++++++++++++ .../PropertyAccessSemanticsTests.cs | 2 +- .../PrototypeShapeTests.cs | 42 ++++++- 3 files changed, 156 insertions(+), 3 deletions(-) create mode 100644 src/AngleSharp.Js.Tests/HostContractVerificationTests.cs diff --git a/src/AngleSharp.Js.Tests/HostContractVerificationTests.cs b/src/AngleSharp.Js.Tests/HostContractVerificationTests.cs new file mode 100644 index 0000000..df4bc2a --- /dev/null +++ b/src/AngleSharp.Js.Tests/HostContractVerificationTests.cs @@ -0,0 +1,115 @@ +namespace AngleSharp.Js.Tests +{ + using Jint; + using Jint.Native; + using Jint.Native.Object; + using Jint.Runtime; + using Jint.Runtime.Descriptors; + using NUnit.Framework; + using System; + using System.Collections.Generic; + using System.Runtime.CompilerServices; + + /// + /// Turns Jint's host-contract verifiers on for this suite, and proves they are on. + /// + /// + /// + /// The verifiers are the checks that catch a host object answering one of the engine's + /// extension points in a way that contradicts another - the exact hazard this binding lives + /// with, because its proxies answer four of them: TryGetOwnPropertyValue, ProbeOwnProperty, + /// HasIndex, and the semantics derived from not overriding Get. Every one of those is trusted + /// by the engine and never re-checked on the hot path, so a wrong answer is silent: a member + /// disappears from every enumeration, or a read that should have found an attribute resolves + /// on the prototype instead. A verified run is the only thing that turns any of that into a + /// failure. + /// + /// + /// They used to be compiled out of Release, so reaching them meant building Jint from source + /// in Debug. Since 4.15.3 the shipped package reads an AppContext switch instead, which is + /// what makes this a thing every CI run can do against the very package the library ships + /// against. + /// + /// + /// The switch is read once, at the type initialization of the gate behind it, so it has to be + /// set before the first use of any Jint type - a fixture, a one-time setup or a static + /// constructor all run far too late. A module initializer is the only hook early enough by + /// construction: the runtime runs it before any code of this assembly does, and Jint is only + /// ever reached from this assembly's code. + /// + /// + [TestFixture] + public class HostContractVerificationTests + { + internal const String SwitchName = "Jint.EnableHostContractVerification"; + + [ModuleInitializer] + internal static void EnableBeforeAnyJintTypeIsTouched() => AppContext.SetSwitch(SwitchName, true); + + /// + /// That the switch was set early enough, proven from behaviour rather than from the flag: + /// a host whose probe contradicts its own descriptors throws exactly when something is + /// verifying, and is quietly believed when nothing is. + /// + /// + /// It is deliberately not asserted through the gate itself, which is internal to Jint. If + /// this fails, the whole suite has been running unverified and the proxies' hooks are + /// covered by nothing. + /// + [Test] + public void TheVerifiersAreRunningForThisSuite() + { + var engine = new Engine(); + engine.SetValue("host", new SelfContradictingHost(engine)); + + var error = Assert.Throws(() => engine.Evaluate("Object.keys(host).join(',')"), + "The host-contract verifiers must be on, or nothing checks this binding's own hooks."); + + StringAssert.Contains("lied", error.Message); + } + + /// + /// Denies through its probe one name its own GetOwnProperty plainly serves. The engine + /// trusts the probe and never re-asks, so unverified the key simply vanishes from every + /// enumeration with nothing to see. + /// + private sealed class SelfContradictingHost : ObjectInstance + { + public SelfContradictingHost(Engine engine) + : base(engine) + { + } + + public override PropertyDescriptor GetOwnProperty(JsValue property) + { + var name = property.ToString(); + return name == "honest" || name == "lied" + ? new PropertyDescriptor(name, true, true, true) + : PropertyDescriptor.Undefined; + } + + // Seen as "protected" from outside the Jint assembly, which is what a host writes. + protected override OwnPropertyProbe ProbeOwnProperty(JsValue property) => + property.ToString() == "lied" ? OwnPropertyProbe.Missing : base.ProbeOwnProperty(property); + + public override List GetOwnPropertyKeys(Types types = Types.String | Types.Symbol) => + new List { new JsString("honest"), new JsString("lied") }; + } + } +} + +#if !NET5_0_OR_GREATER +namespace System.Runtime.CompilerServices +{ + using System; + + /// + /// The attribute the compiler recognizes by name rather than by identity, so declaring it + /// here is what gives the initializer above a target framework where the BCL carries none. + /// + [AttributeUsage(AttributeTargets.Method, Inherited = false)] + sealed class ModuleInitializerAttribute : Attribute + { + } +} +#endif diff --git a/src/AngleSharp.Js.Tests/PropertyAccessSemanticsTests.cs b/src/AngleSharp.Js.Tests/PropertyAccessSemanticsTests.cs index f7225cc..48a8580 100644 --- a/src/AngleSharp.Js.Tests/PropertyAccessSemanticsTests.cs +++ b/src/AngleSharp.Js.Tests/PropertyAccessSemanticsTests.cs @@ -72,7 +72,7 @@ public Task PrototypeReadsAsOrdinary() => AssertOrdinaryAsync( "(function () { var d = document.createElement('div'); d.tagName; return Object.getPrototypeOf(d); })()", (engine, instance) => - Assert.AreEqual(ObjectRepresentation.SharedBuiltinLayout, engine.Advanced.GetObjectRepresentation(instance), + Assert.IsTrue(engine.Advanced.HasSharedShape(instance), "A DOM prototype must be an object over a shared member layout.")); [Test] diff --git a/src/AngleSharp.Js.Tests/PrototypeShapeTests.cs b/src/AngleSharp.Js.Tests/PrototypeShapeTests.cs index 4b80162..f3df65a 100644 --- a/src/AngleSharp.Js.Tests/PrototypeShapeTests.cs +++ b/src/AngleSharp.Js.Tests/PrototypeShapeTests.cs @@ -52,8 +52,46 @@ public void ShapeIsSharedAcrossEngines() Assert.AreNotSame(one, other, "Each engine must have a prototype object of its own."); Assert.AreSame(DomPrototypeState.Of(one).Shape, DomPrototypeState.Of(other).Shape, "Both engines must build their prototype from the same shared description."); - Assert.AreEqual(ObjectRepresentation.SharedBuiltinLayout, first.Advanced.GetObjectRepresentation(one)); - Assert.AreEqual(ObjectRepresentation.SharedBuiltinLayout, second.Advanced.GetObjectRepresentation(other)); + Assert.IsTrue(first.Advanced.HasSharedShape(one)); + Assert.IsTrue(second.Advanced.HasSharedShape(other)); + } + + /// + /// The property this whole change exists to obtain, asserted rather than observed once: + /// a DOM prototype's own members are described by a layout it shares with the same + /// prototype in every other engine, which is what admits it as a holder of the engine's + /// prototype-member cache and lets a warm read off a node be served from it. + /// + /// + /// Instantiating from a member layout falls back to the ordinary per-object property + /// dictionary silently and correctly when it cannot shape an object, so nothing about a + /// document would look wrong if this stopped happening - only every warm member read on + /// every node would get slower. The control below is the object that can never be shaped + /// and is not meant to be. + /// + [Test] + public void PrototypesAreShapedAndTheProxiesAreNot() + { + var engine = Open("x"); + + Assert.IsTrue(engine.Advanced.HasSharedShape((ObjectInstance)engine.Evaluate(DivPrototype)), + "An element prototype must be built from the shared member layout."); + // Reading "length" off the collection would not do: an array-like proxy answers that + // one itself, so it never reaches the prototype whose layout is the point here. + Assert.IsTrue(engine.Advanced.HasSharedShape((ObjectInstance)engine.Evaluate( + "(function () { var p = Object.getPrototypeOf(document.getElementsByTagName('span')); p.constructor; return p; })()")), + "A collection prototype must be built from the shared member layout."); + Assert.IsTrue(engine.Advanced.HasSharedShape((ObjectInstance)engine.Evaluate( + "(function () { window.document; return Object.getPrototypeOf(window); })()")), + "The window prototype must be built from the shared member layout."); + + // A node is a host object of ours, whose own properties are its own business and + // describe no layout anything else could share - which is exactly the refusal the + // prototypes had to be moved out of, and the reason they are no longer of our type. + Assert.IsFalse(engine.Advanced.HasSharedShape((ObjectInstance)engine.Evaluate("document.createElement('div')")), + "A DOM node is a host object; it shares its layout with nothing."); + Assert.IsFalse(engine.Advanced.HasSharedShape((ObjectInstance)engine.Evaluate("document.getElementsByTagName('span')")), + "A DOM collection is a host object; it shares its layout with nothing."); } ///