From da4c064e307bd4deecb9b2531a8f73696c7c3173 Mon Sep 17 00:00:00 2001 From: Marko Lahma Date: Mon, 27 Jul 2026 00:07:16 +0300 Subject: [PATCH] Build the constructor of an exposed type when script first names it Every exposed type got a constructor object as the engine was set up: 178 of them per engine, each with its own prototype object, toString function and property descriptors. A document names a handful of them. The property is still registered for every type, with the attributes it had, so nothing about enumerating the window changes - only the object behind it waits for the first read. The prototype keeps the constructor, so reading the name off the window and reading "constructor" off an instance still arrive at the same object; a prototype reached through an instance asks for its constructor itself, since that path never names the type. --- .../DeferredConstructorTests.cs | 141 ++++++++++++++++++ src/AngleSharp.Js/Cache/CreatorCache.cs | 58 +++++-- src/AngleSharp.Js/EngineInstance.cs | 13 ++ .../Extensions/EngineExtensions.cs | 8 +- .../Proxies/DomConstructorDescriptor.cs | 37 +++++ .../Proxies/DomConstructorInstance.cs | 15 +- .../Proxies/DomPrototypeInstance.cs | 20 +++ 7 files changed, 265 insertions(+), 27 deletions(-) create mode 100644 src/AngleSharp.Js.Tests/DeferredConstructorTests.cs create mode 100644 src/AngleSharp.Js/Proxies/DomConstructorDescriptor.cs 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()