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