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()