diff --git a/src/AngleSharp.Js.Tests/DeferredConstructorTests.cs b/src/AngleSharp.Js.Tests/DeferredConstructorTests.cs
new file mode 100644
index 0000000..4cb9155
--- /dev/null
+++ b/src/AngleSharp.Js.Tests/DeferredConstructorTests.cs
@@ -0,0 +1,141 @@
+namespace AngleSharp.Js.Tests
+{
+ using NUnit.Framework;
+ using System.Threading.Tasks;
+
+ ///
+ /// The constructor of an exposed type is only built once script reads the property it
+ /// is published under, so these cover what a reader is entitled to see either way.
+ ///
+ [TestFixture]
+ public class DeferredConstructorTests
+ {
+ [Test]
+ public async Task ConstructorIsSameObjectOnWindowAndGlobal()
+ {
+ var result = await "String(window.HTMLDivElement === HTMLDivElement)".EvalScriptAsync();
+ Assert.AreEqual("true", result);
+ }
+
+ [Test]
+ public async Task ConstructorIsSameObjectOnEveryRead()
+ {
+ var result = await "String((function () { var a = HTMLDivElement; var b = window.HTMLDivElement; return a === b && a === HTMLDivElement; })())".EvalScriptAsync();
+ Assert.AreEqual("true", result);
+ }
+
+ [Test]
+ public async Task ConstructorIsFunction()
+ {
+ var result = await "typeof HTMLDivElement".EvalScriptAsync();
+ Assert.AreEqual("function", result);
+ }
+
+ [Test]
+ public async Task UnreadConstructorIsStillOwnPropertyOfWindow()
+ {
+ var result = await "String(window.hasOwnProperty('HTMLTableColElement'))".EvalScriptAsync();
+ Assert.AreEqual("true", result);
+ }
+
+ [Test]
+ public async Task UnreadConstructorIsStillEnumerable()
+ {
+ var result = await "String(Object.keys(window).indexOf('HTMLTableColElement') !== -1)".EvalScriptAsync();
+ Assert.AreEqual("true", result);
+ }
+
+ [Test]
+ public async Task UnreadConstructorIsStillFoundByForIn()
+ {
+ var result = await "String((function () { for (var k in window) { if (k === 'HTMLTableColElement') { return true; } } return false; })())".EvalScriptAsync();
+ Assert.AreEqual("true", result);
+ }
+
+ [Test]
+ public async Task ConstructorKeepsItsAttributes()
+ {
+ var result = await "(function () { var d = Object.getOwnPropertyDescriptor(window, 'HTMLDivElement'); return d.writable + ',' + d.enumerable + ',' + d.configurable; })()".EvalScriptAsync();
+ Assert.AreEqual("false,true,false", result);
+ }
+
+ [Test]
+ public async Task DescriptorValueIsTheConstructor()
+ {
+ var result = await "String(Object.getOwnPropertyDescriptor(window, 'HTMLDivElement').value === HTMLDivElement)".EvalScriptAsync();
+ Assert.AreEqual("true", result);
+ }
+
+ [Test]
+ public async Task ReadingAConstructorDoesNotChangeTheKeysOfWindow()
+ {
+ var result = await "String((function () { var before = Object.keys(window).length; var c = HTMLDivElement; return before === Object.keys(window).length; })())".EvalScriptAsync();
+ Assert.AreEqual("true", result);
+ }
+
+ [Test]
+ public async Task ConstructedInstanceIsInstanceOfItsConstructor()
+ {
+ var result = await "String(new CustomEvent('foo') instanceof CustomEvent)".EvalScriptAsync();
+ Assert.AreEqual("true", result);
+ }
+
+ [Test]
+ public async Task ConstructorBuildsInstances()
+ {
+ var result = await "new CustomEvent('foo').type".EvalScriptAsync();
+ Assert.AreEqual("foo", result);
+ }
+
+ [Test]
+ public async Task PrototypePointsBackAtItsConstructor()
+ {
+ var result = await "String(HTMLDivElement.prototype.constructor === HTMLDivElement)".EvalScriptAsync();
+ Assert.AreEqual("true", result);
+ }
+
+ // Reaching a prototype through an instance is the one path that never names the
+ // type, so it is the one that has to pull the constructor in by itself.
+ [Test]
+ public async Task InstanceReportsItsConstructorWhenTheNameWasNeverRead()
+ {
+ var result = await "screen.constructor.name".EvalScriptAsync();
+ Assert.AreEqual("Screen", result);
+ }
+
+ [Test]
+ public async Task InstanceReportsTheSameConstructorTheWindowPublishes()
+ {
+ var result = await "String(screen.constructor === Screen)".EvalScriptAsync();
+ Assert.AreEqual("true", result);
+ }
+
+ [Test]
+ public async Task ConstructorIsNotWritable()
+ {
+ var result = await "String((function () { var before = HTMLDivElement; window.HTMLDivElement = 5; return window.HTMLDivElement === before; })())".EvalScriptAsync();
+ Assert.AreEqual("true", result);
+ }
+
+ [Test]
+ public async Task ConstructorIsNotConfigurable()
+ {
+ var result = await "String((delete window.HTMLDivElement) === false && typeof HTMLDivElement === 'function')".EvalScriptAsync();
+ Assert.AreEqual("true", result);
+ }
+
+ [Test]
+ public async Task ConstructorStringifiesAsNativeCode()
+ {
+ var result = await "String(HTMLDivElement)".EvalScriptAsync();
+ Assert.AreEqual("function HTMLDivElement() { [native code] }", result);
+ }
+
+ [Test]
+ public async Task NonConstructableTypeStillRejectsNew()
+ {
+ var result = await "(function () { try { new Node(); return 'no throw'; } catch (e) { return 'threw'; } })()".EvalScriptAsync();
+ Assert.AreEqual("threw", result);
+ }
+ }
+}
diff --git a/src/AngleSharp.Js/Cache/CreatorCache.cs b/src/AngleSharp.Js/Cache/CreatorCache.cs
index 3c1188e..8ecbf23 100644
--- a/src/AngleSharp.Js/Cache/CreatorCache.cs
+++ b/src/AngleSharp.Js/Cache/CreatorCache.cs
@@ -12,11 +12,16 @@ namespace AngleSharp.Js.Cache
{
static class CreatorCache
{
- private static readonly ConcurrentDictionary> _constructorActions = new();
-
- public static Action GetConstructorAction(this Type type)
+ private static readonly ConcurrentDictionary _constructorDefinitions = new();
+
+ ///
+ /// Gets what is needed to build the constructor object for a type, or null if the
+ /// type is not exposed as one. The answer depends on the type alone, so the null
+ /// is cached as well - most exported types do not get a constructor.
+ ///
+ public static ConstructorDefinition GetConstructorDefinition(this Type type)
{
- if (!_constructorActions.TryGetValue(type, out var action))
+ if (!_constructorDefinitions.TryGetValue(type, out var definition))
{
var ti = type.GetTypeInfo();
var names = ti.GetCustomAttributes();
@@ -25,21 +30,13 @@ public static Action GetConstructorAction(this T
if (name != null && !ti.IsEnum)
{
var info = ti.DeclaredConstructors.FirstOrDefault(m => m.GetCustomAttributes().Any());
- action = (engine, obj) =>
- {
- var constructor = info != null ? new DomConstructorInstance(engine, info) : new DomConstructorInstance(engine, type);
- obj.FastSetProperty(name.OfficialName, new PropertyDescriptor(constructor, false, true, false));
- };
- }
- else
- {
- action = (e, o) => { };
+ definition = new ConstructorDefinition(type, name.OfficialName, info);
}
- _constructorActions.TryAdd(type, action);
+ _constructorDefinitions.TryAdd(type, definition);
}
- return action;
+ return definition;
}
private static readonly ConcurrentDictionary> _constructorFunctionActions = new();
@@ -111,4 +108,35 @@ public static Action GetInstanceAction(this Type
return action;
}
}
+
+ ///
+ /// Everything the constructor object of a type is built from. The reflection behind it
+ /// is the same for every engine, so it is resolved once and kept by
+ /// - only the object built from it belongs to an engine.
+ ///
+ sealed class ConstructorDefinition
+ {
+ public ConstructorDefinition(Type type, String name, ConstructorInfo info)
+ {
+ Type = type;
+ Name = name;
+ Info = info;
+ }
+
+ ///
+ /// Gets the type the constructor creates instances of.
+ ///
+ public Type Type { get; }
+
+ ///
+ /// Gets the name the constructor is exposed under.
+ ///
+ public String Name { get; }
+
+ ///
+ /// Gets the constructor to invoke, or null if the type cannot be constructed from
+ /// script - naming it is still legal, calling it is not.
+ ///
+ public ConstructorInfo Info { get; }
+ }
}
diff --git a/src/AngleSharp.Js/EngineInstance.cs b/src/AngleSharp.Js/EngineInstance.cs
index 0253141..55b30a6 100644
--- a/src/AngleSharp.Js/EngineInstance.cs
+++ b/src/AngleSharp.Js/EngineInstance.cs
@@ -86,6 +86,19 @@ public EngineInstance(IWindow window, IDictionary assignments, I
public ObjectInstance GetDomPrototype(Type type) => _prototypes.GetOrCreate(type, CreatePrototype);
+ ///
+ /// Gets the constructor object of the given type, building it on first ask. The
+ /// prototype keeps it, so that naming the type and reading "constructor" off one of
+ /// its instances arrive at the same object.
+ ///
+ 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);
+ }
+
public JsValue RunScript(String source, String type, String sourceUrl)
{
if (string.IsNullOrEmpty(type))
diff --git a/src/AngleSharp.Js/Extensions/EngineExtensions.cs b/src/AngleSharp.Js/Extensions/EngineExtensions.cs
index ec7ed3f..553cbaa 100644
--- a/src/AngleSharp.Js/Extensions/EngineExtensions.cs
+++ b/src/AngleSharp.Js/Extensions/EngineExtensions.cs
@@ -193,8 +193,12 @@ public static void AddInstances(this EngineInstance engine, ObjectInstance obj,
public static void AddConstructor(this EngineInstance engine, ObjectInstance obj, Type type)
{
- var apply = type.GetConstructorAction();
- apply.Invoke(engine, obj);
+ var definition = type.GetConstructorDefinition();
+
+ if (definition != null)
+ {
+ obj.FastSetProperty(definition.Name, new DomConstructorDescriptor(engine, definition));
+ }
}
public static void AddConstructorFunction(this EngineInstance engine, ObjectInstance obj, Type type)
diff --git a/src/AngleSharp.Js/Proxies/DomConstructorDescriptor.cs b/src/AngleSharp.Js/Proxies/DomConstructorDescriptor.cs
new file mode 100644
index 0000000..00590d7
--- /dev/null
+++ b/src/AngleSharp.Js/Proxies/DomConstructorDescriptor.cs
@@ -0,0 +1,37 @@
+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 4c36f1f..00783ee 100644
--- a/src/AngleSharp.Js/Proxies/DomConstructorInstance.cs
+++ b/src/AngleSharp.Js/Proxies/DomConstructorInstance.cs
@@ -1,11 +1,11 @@
namespace AngleSharp.Js
{
+ using AngleSharp.Js.Cache;
using Jint.Native;
using Jint.Native.Object;
using Jint.Runtime;
using Jint.Runtime.Descriptors;
using Jint.Runtime.Interop;
- using System;
using System.Reflection;
sealed class DomConstructorInstance : Constructor
@@ -14,12 +14,13 @@ sealed class DomConstructorInstance : Constructor
private readonly EngineInstance _instance;
private readonly ObjectInstance _objectPrototype;
- public DomConstructorInstance(EngineInstance engine, Type type)
- : base(engine.Jint, type.GetOfficialName())
+ public DomConstructorInstance(EngineInstance engine, ConstructorDefinition definition)
+ : base(engine.Jint, definition.Name)
{
var toString = new ClrFunction(Engine, "toString", ToString);
- _objectPrototype = engine.GetDomPrototype(type);
+ _objectPrototype = engine.GetDomPrototype(definition.Type);
_instance = engine;
+ _constructor = definition.Info;
FastSetProperty("toString", new PropertyDescriptor(toString, true, false, true));
SetOwnProperty("prototype", new PropertyDescriptor(_objectPrototype, false, false, false));
@@ -37,12 +38,6 @@ public DomConstructorInstance(EngineInstance engine, Type type)
}
}
- public DomConstructorInstance(EngineInstance engine, ConstructorInfo constructor)
- : this(engine, constructor.DeclaringType)
- {
- _constructor = constructor;
- }
-
public override ObjectInstance Construct(JsValue[] arguments, JsValue newTarget)
{
if (_constructor == null)
diff --git a/src/AngleSharp.Js/Proxies/DomPrototypeInstance.cs b/src/AngleSharp.Js/Proxies/DomPrototypeInstance.cs
index 16c86bc..43ab720 100644
--- a/src/AngleSharp.Js/Proxies/DomPrototypeInstance.cs
+++ b/src/AngleSharp.Js/Proxies/DomPrototypeInstance.cs
@@ -1,6 +1,7 @@
namespace AngleSharp.Js
{
using AngleSharp.Attributes;
+ using AngleSharp.Js.Cache;
using AngleSharp.Text;
using Jint.Native.Object;
using Jint.Native.Symbol;
@@ -20,6 +21,7 @@ sealed class DomPrototypeInstance : ObjectInstance
private List> _deferred;
private Boolean _membersSet;
+ private DomConstructorInstance _constructor;
private MethodInfo _numericIndexer;
private MethodInfo _stringIndexer;
@@ -58,8 +60,26 @@ protected override void Initialize()
_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();
+
+ 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()