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/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 new file mode 100644 index 0000000..48a8580 --- /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.IsTrue(engine.Advanced.HasSharedShape(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..f3df65a --- /dev/null +++ b/src/AngleSharp.Js.Tests/PrototypeShapeTests.cs @@ -0,0 +1,287 @@ +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.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."); + } + + /// + /// 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..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 9274feb..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/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..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(); @@ -242,6 +277,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/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; - } - } -} 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; } } }