Issues with Using JavaScript Proxies to Wrap ClearScript Host Objects for DOM-like Functionality
Main Problem:
I want to enable the use of the standard JavaScript event handler assignment, such as onclick = function() { ... }
Description
I'm integrating ClearScript into my project to expose C# backend objects as a DOM-like environment for JavaScript frameworks to interact with. To facilitate event handling and maintain standard DOM method access, I'm attempting to wrap these host objects using JavaScript proxies. However, this approach is causing inherited methods like appendChild to become inaccessible, resulting in runtime errors.
Objective
- Provide a DOM-like environment in JavaScript where elements can be created, manipulated, and have event handlers assigned using standard DOM methods and properties.
- Ensure compatibility with JavaScript frameworks that expect a standard browser-like DOM API.
- Handle event assignments using property setters (e.g.,
onclick = ...) instead of exclusively relying on methods likeconnect.
Approach Taken
-
Proxy Implementation:
- Wrapped the
documentobject and created elements using JavaScript proxies to intercept event property assignments (e.g.,onclick).
- Wrapped the
-
Proxy Handlers:
-
Document Proxy Handler:
const documentHandler = { get(target, property, receiver) { if (property === 'createElement') { return function(tagName) { let element = documentBackend.createElement(tagName); return new Proxy(element, elementHandler); }; } return Reflect.get(documentBackend, property, receiver); }, set(target, property, value, receiver) { if (property.startsWith('on')) { // Event assignment logic const eventType = property.substring(2).toLowerCase(); const eventNameMap = { 'click': 'Clicked', 'load': 'Loaded' /* Add more as needed */ }; const eventName = eventNameMap[eventType]; if (!eventName) { console.error(`[JS] No event mapping found for event type '${eventType}'`); return true; } if (typeof value === 'function') { // Disconnect previous handler if it exists if (target[property] && target[property].disconnect) { target[property].disconnect(); } // Connect new handler target[property] = documentBackend[eventName].connect(value); target[property] = value; } else if (value == null) { // Remove handler if (target[property] && target[property].disconnect) { target[property].disconnect(); delete target[property]; } } return true; } else { // Delegate property assignment to documentBackend return Reflect.set(documentBackend, property, value, receiver); } } }; -
Element Proxy Handler:
const elementHandler = { get(target, property, receiver) { // Always delegate property access to the target object let value = Reflect.get(target, property, receiver); // If the property is a function, bind it to the target to preserve 'this' context if (typeof value === 'function') { return value.bind(target); } // Return the property value return value; }, set(target, property, value, receiver) { if (property.startsWith('on')) { // Handle event assignment const eventType = property.substring(2).toLowerCase(); const eventName = eventType.charAt(0).toUpperCase() + eventType.slice(1); if (typeof target[eventName] === 'object' && typeof target[eventName].connect === 'function') { if (typeof value === 'function') { // Disconnect previous handler if it exists if (target[property] && target[property].disconnect) { target[property].disconnect(); } // Connect new handler and store the connection object target[property] = target[eventName].connect(value); } else if (value == null) { // Disconnect handler if (target[property] && target[property].disconnect) { target[property].disconnect(); delete target[property]; } } } else { console.error(`[JS] Event '${eventName}' is not available on target.`); } return true; } else { // Delegate property assignment to the target object return Reflect.set(target, property, value, receiver); } } };
-
-
Usage Example:
// Create a proxy object for 'document' const document = new Proxy({}, documentHandler); let div = document.createElement('div'); div.innerHTML = 'Hello from TypeScript'; document.appendChild(div); div.onclick = function() { console.log('Div clicked'); }; document.onclick = function() { console.log('Document clicked'); }; // Optional: Define a method to dispatch events from JavaScript div.dispatchEvent = function(eventName) { if (eventName.toLowerCase() === 'click' && this.onclick) { this.onclick(); } }; // Simulate a click event on the 'div' div.dispatchEvent('click'); // Simulate a click event on the document document.dispatchEvent('click');
Problem Encountered
When wrapping elements in proxies, methods like appendChild, leading to runtime errors such as:
Error executing script: Error: 'AngleSharp.Html.Dom.HtmlDocument' does not contain a definition for 'appendChild' - at Microsoft.ClearScript.V8.SplitProxy.V8SplitProxyNative.ThrowScheduledException () [0x00007] in <bff66af6ed014f828d44655bb768ca7c>:0
at Microsoft.ClearScript.V8.SplitProxy.V8SplitProxyNative.Invoke (System.Action`1[T] action) [0x00027] in <bff66af6ed014f828d44655bb768ca7c>:0
at Microsoft.ClearScript.V8.SplitProxy.V8ContextProxyImpl.InvokeWithLock (System.Action action) [0x00020] in <bff66af6ed014f828d44655bb768ca7c>:0
at Microsoft.ClearScript.V8.V8ScriptEngine.ScriptInvoke[T] (System.Func`1[TResult] func) [0x0002d] in <bff66af6ed014f828d44655bb768ca7c>:0
at Microsoft.ClearScript.V8.V8ScriptEngine.Execute (Microsoft.ClearScript.UniqueDocumentInfo documentInfo, System.String code, System.Boolean evaluate) [0x00028] in <bff66af6ed014f828d44655bb768ca7c>:0
at Microsoft.ClearScript.ScriptEngine.Execute (Microsoft.ClearScript.DocumentInfo documentInfo, System.String code) [0x00009] in <2086470d86374678b98d7a6edf931177>:0
at Microsoft.ClearScript.ScriptEngine.Execute (System.String documentName, System.Boolean discard, System.String code) [0x0001c] in <2086470d86374678b98d7a6edf931177>:0
at Microsoft.ClearScript.ScriptEngine.Execute (System.String documentName, System.String code) [0x00000] in <2086470d86374678b98d7a6edf931177>:0
at Microsoft.ClearScript.ScriptEngine.Execute (System.String code) [0x00000] in <2086470d86374678b98d7a6edf931177>:0
at EngineInstance.LoadAndExecuteScript () [0x0002a] in D:\Development\Azure Devops\JSXEngine\src\JSXEngineV2\Assets\EngineInstance.cs:134
This issue does not occur when interacting with documentBackend directly without proxies. It seems that the proxy interferes with ClearScript's method binding for inherited methods.
What I've Tried
-
Removing Conditional Property Checks:
- Initially, I had checks like
if (property in target)in thegettrap, which I removed to ensure all properties are accessible.
- Initially, I had checks like
-
Binding Methods:
- In the
gettrap, if the property is a function, I bind it to the target object to preserve the correctthiscontext.
get(target, property, receiver) { let value = Reflect.get(target, property, receiver); if (typeof value === 'function') { return value.bind(target); } return value; } - In the
-
Direct Property Access:
- Adjusted the proxy to delegate all property accesses directly using
Reflect.getandReflect.setwithout additional logic, except for event properties.
- Adjusted the proxy to delegate all property accesses directly using
-
Event Handling Adjustments:
- Implemented event handling in the
settrap to connect and disconnect event handlers appropriately based on the presence ofconnectanddisconnectmethods on C# events.
- Implemented event handling in the
-
Testing Without Proxies:
- Verified that methods like
appendChildwork correctly without proxies, confirming that the issue is proxy-related.
- Verified that methods like
Additional Information
-
C# Backend:
- Using AngleSharp to represent and manipulate DOM elements in C#.
-
JavaScript Environment:
- The goal is to provide a standard DOM API in JavaScript, allowing frameworks like React or Angular to interact seamlessly.
Request for Assistance
-
Best Practices:
- What is the recommended approach for using JavaScript proxies with ClearScript host objects to maintain full access to inherited methods like
appendChild?
- What is the recommended approach for using JavaScript proxies with ClearScript host objects to maintain full access to inherited methods like
-
Proxy Configuration:
- Are there specific configurations or patterns for the
getandsettraps that prevent interference with ClearScript's method binding?
- Are there specific configurations or patterns for the
-
Event Handling Alternatives:
-
Is there an alternative to using
connectmethods for event handling? -
Can we override how events are handled to use property assignments (e.g.,
onclick = ...) instead of relying onconnect? - What is the best way to intercept event property assignments without disrupting access to other properties and methods?
-
Is there an alternative to using
-
Known Limitations:
- Are there any known limitations or workarounds when using proxies with ClearScript host objects that I should be aware of?
-
Alternative Approaches:
- If proxies are inherently problematic with ClearScript, what alternative strategies can I employ to achieve similar functionality without losing method access?
Update / Additional Information
Inheritance Issue:
It appears that the use of JavaScript proxies is breaking the inheritance chain for host-bound objects (such as those created in C# using AngleSharp). Specifically, methods like appendChild are inherited from parent classes (e.g., Node in the DOM hierarchy), but the proxy seems to prevent JavaScript from correctly resolving and accessing these inherited methods. When interacting with the documentBackend directly (without proxies), the inherited methods are accessible as expected, but once a proxy is applied, those methods are no longer available, causing runtime errors.
It seems that ClearScript's method binding is affected by the proxy, particularly when resolving methods along the prototype chain. Any suggestions or workarounds to ensure that inherited methods remain accessible when using proxies would be greatly appreciated.
Hello @herki18,
It appears that the use of JavaScript proxies is breaking the inheritance chain for host-bound objects
Not exactly. In the expression document.appendChild(div), the problem is div. As a JavaScript proxy, it's a script object and therefore an invalid argument for AppendChild.
On the JavaScript side, ClearScript host objects are exotic in the sense that they have internal properties that are inaccessible via the script language. Those properties allow host objects to be transformed into their .NET counterparts when passed back to the host, but proxies can only expose normal JavaScript properties.
We've toyed with the idea of switching to normal properties, perhaps keyed by symbols, but we abandoned the project. Full support for proxied host objects is likely infeasible due to the radical differences between the .NET and JavaScript type systems.
Unfortunately, the error message is misleading here, and that's due to the use of case-insensitive member resolution. You're using V8ScriptEngineFlags.UseCaseInsensitiveMemberBinding, right?
In any case, let's take a step back. What issues are motivating the wrapping of host objects within JavaScript proxies? It would appear that event handler syntax is one such issue. Is there something else?
Hello @ClearScriptLib ,
Yes, the event handler syntax is the main issue motivating our use of JavaScript proxies. We're trying to maintain the standard event handler syntax (e.g., element.onclick = function() {...}) without requiring modifications to third-party frameworks like React, which would otherwise require .connect for event binding.
Aside from that, there are no other major issues driving the use of proxies at the moment. We’re using a CustomAttributeLoader to simplify integration between JavaScript and our backend (customized AngleSharp in Unity), but the main workaround we're trying to achieve is for event handling without modifying external frameworks.
Hi @herki18,
Yes, the event handler syntax is the main issue motivating our use of JavaScript proxies.
Ah, right. If it were a method instead – say, setOnClick – you could define a .NET extension method that does the job, but we aren't aware of a way to hook property assignment without using proxies.
Here's a partial example that you may find useful. It sets up automatic proxy usage for all AngleSharp event sources. It also uses a custom attribute loader to leverage AngleSharp's DomNameAttribute. Finally, it uses custom cast functions to get around type restriction, something that can be disabled if necessary (see Issue #607).
First, let's set up some preliminaries:
class DomNameLoader : CustomAttributeLoader {
public override T[] LoadCustomAttributes<T>(ICustomAttributeProvider resource, bool inherit) {
var declaredAttributes = base.LoadCustomAttributes<T>(resource, inherit);
if (!declaredAttributes.Any() && typeof(T) == typeof(ScriptMemberAttribute) && resource is MemberInfo) {
var attrs = resource.GetCustomAttributes(typeof(DomNameAttribute), false);
if (attrs.Length > 0) {
return new[] { new ScriptMemberAttribute(((DomNameAttribute)attrs[0]).OfficialName) } as T[];
}
}
return declaredAttributes;
}
}
public static class HostUtil {
public static bool isEventSource(object obj) => obj is EventSource;
public static IGlobalEventHandlers asEventHandlers(object obj) => obj as IGlobalEventHandlers;
public static IHtmlElement asHtmlElement(object obj) => obj as IHtmlElement;
}
And then:
engine.CustomAttributeLoader = new DomNameLoader();
engine.AddHostType(typeof(Console));
engine.AddHostType(typeof(HostUtil));
Now, let's create a script function that sets up the proxy machinery and exposes a document proxy:
var setupFunction = (ScriptObject)engine.Evaluate(@"
(function (document) {
Object.prototype.unwrap = function () { return this; };
function wrap(target) {
const handlers = HostUtil.asEventHandlers(target);
if (!handlers) return target;
const connections = {};
return new Proxy(target, {
get(target, key, receiver) {
if (key === 'unwrap') return () => target;
const value = Reflect.get(target, key, receiver);
if (typeof value === 'function') {
return new Proxy(value, {
apply(target, thisArg, args) {
const unwrappedArgs = Array.from(args).map(arg => arg.unwrap());
return wrap(Reflect.apply(target, thisArg.unwrap(), unwrappedArgs));
}
});
}
return wrap(value);
},
set(target, key, value, receiver) {
const source = handlers[key];
if (HostUtil.isEventSource(source)) {
connections[key]?.disconnect();
if (value)
connections[key] = source.connect((sender, event) => value(event));
else
delete connections[key];
return true;
}
return Reflect.set(target, key, value, receiver);
}
});
}
globalThis.document = wrap(document);
})
");
Finally, let's create a document and invoke our setup function:
var config = Configuration.Default.WithDefaultLoader();
var context = BrowsingContext.New(config);
var document = await context.OpenAsync(req => req.Content("<!DOCTYPE html><html><body/></html>"));
setupFunction.InvokeAsFunction(document.ToRestrictedHostObject(engine));
We're now ready to run some test code:
engine.Execute(@"
// create a simple console object
const console = { log: Console.WriteLine };
// add a DIV to the document
const div = document.createElement('div');
div.innerHTML = 'Hello from JavaScript';
document.body.appendChild(div);
console.log(document.body.outerHTML);
// add click handlers and simulate a click
const divHtml = HostUtil.asHtmlElement(div.unwrap());
div.onclick = function(event) {
console.log('Div clicked, propagating');
};
document.onclick = function(event) {
console.log('Document clicked');
};
divHtml.click();
// use a click handler that stops propagation
div.onclick = function(event) {
event.stopPropagation();
console.log('Div clicked, not propagating');
}
divHtml.click();
// remove click handlers
div.onclick = document.onclick = undefined;
divHtml.click();
");
Output:
<body><div>Hello from JavaScript</div></body>
Div clicked, propagating
Document clicked
Div clicked, not propagating
Note that this example is incomplete – e.g., it doesn't support event handler retrieval via onclick et al – but hopefully it gives you an idea of how one might use ClearScript to bridge AngleSharp with standard JavaScript frameworks.
Please don't hesitate to send additional questions, findings, and thoughts our way!
Cheers!
Thank you for the help so far. I have a related issue regarding host.type() and would appreciate some clarification.
I've added my custom type UnityHtmlDivElement to the script engine using the following code:
_scriptEngine.AddHostObject("host", new ExtendedHostFunctions());
_scriptEngine.AddHostType(typeof(HostUtil));
_scriptEngine.AddHostType(typeof(UnityHtml.UnityHtmlDivElement));
In JavaScript, the following works:
var targetTypeOne = host.type("System.String"); // This works
var targetType = host.type(target.GetType()); // This works
But this does not work:
var targetUnityType = host.type("UnityHtml.UnityHtmlDivElement"); // Fails
Here is the class definition for UnityHtmlDivElement:
namespace UnityHtml
{
public class UnityHtmlDivElement : HtmlElement, IHtmlDivElement
{
public UnityHtmlDivElement([NotNull] Document owner, [NotNull] string localName, [CanBeNull] string prefix = null, NodeFlags flags = NodeFlags.None, [CanBeNull] IViewSynchronizer view = null)
: base(owner, TagNames.Div, prefix, NodeFlags.Special)
{
ViewSync = new UnityViewSynchronizer(this, new VisualElement());
}
}
}
Main Question:
-
Do I need to provide the exact constructor when calling
host.type()for this to work?- This is so I can use
asTypeor casting in JavaScript with this type.
- This is so I can use
I am trying to understand how the code works underneath, particularly with respect to how ClearScript handles type recognition and casting for custom classes.
Thank you for your help, and I will provide examples once I have a proper solution worked out.
Also, would it be better to create a new issue for this question, or should I continue the discussion here?
Hello @herki18,
But this does not work:
var targetUnityType = host.type("UnityHtml.UnityHtmlDivElement"); // Fails
The type function is a bit of a wrecking ball.
For one thing, it allows script code to import arbitrary .NET types, giving it the power to derail the host or mess with the system on its behalf. That alone is reason enough for most hosts to avoid exposing ExtendedHostFunctions.
Also, if you don't specify an assembly name, type isn't likely to succeed unless the type in question resides in one of several "system" assemblies. That's why it's failing for you.
More importantly, since you're exposing that type via AddHostType, it's already available to script code. Why use type to import the same type? If you'd like to expose a set of related types – say, all the public types in a given namespace – consider using HostTypeCollection.
Also, would it be better to create a new issue for this question, or should I continue the discussion here?
Let's continue here. Yours is an interesting use case – one that it makes sense to discuss in one place.
Thanks!
Hi @ClearScriptLib,
Thank you for the example—it was extremely helpful! I’ve expanded on your code to support events that aren’t part of the interface and added casting to expose full method access on derived types. Below is the modified JavaScript code, building on your setup.
(function (document) {
/**
* Checks if the provided target is a non-null object.
* @param {any} target - The target to check.
* @returns {boolean} - True if target is an object and not null, false otherwise.
*/
function isObject(target) {
return target !== null && typeof target === 'object';
}
/**
* Casts the backend object to its actual type to expose derived type methods.
* This ensures JavaScript can access all methods from derived types (such as HTMLDivElement).
* @param {any} value - The backend object that might need casting.
* @returns {any} - The casted object if necessary, or the original value if no casting is needed.
*/
function getCastedBackendObject(value) {
if (isObject(value) && typeof value.GetType === 'function') {
const targetType = host.type(value.GetType()); // Get the type of the backend object
return host.cast(targetType, value); // Cast the object to expose its full API
}
return value;
}
// Adds an unwrap function to all objects, allowing easy access to the original target object.
Object.prototype.unwrap = function () { return this; };
/**
* Wraps the target object in a proxy to handle property access and method calls.
* This ensures proper event handling and casts return values to their actual type when needed.
* @param {any} target - The object to wrap.
* @returns {any} - The wrapped object or the original object if it's not a DOM node.
*/
function wrap(target) {
// If the target is not a DOM node, return it directly without wrapping.
if (!HostUtil.isDomNode(target)) {
return target;
}
const connections = {}; // Store active event connections for later disconnection.
return new Proxy(target, {
/**
* Intercepts property access on the object.
* - If the property is a method, it wraps the method to handle arguments and results.
* - If the property is 'unwrap', it returns the original object.
* @param {any} target - The original object.
* @param {string} key - The property key being accessed.
* @param {any} receiver - The proxy or object that made the call.
* @returns {any} - The result of the property access, potentially wrapped.
*/
get(target, key, receiver) {
if (key === 'unwrap') return () => target; // Special case: return the unwrapped original object.
const value = Reflect.get(target, key, receiver); // Get the property value.
if (typeof value === 'function') {
// If the property is a function, wrap the function to handle unwrapping arguments
// and casting return values.
return new Proxy(value, {
apply(targetFn, thisArg, args) {
const unwrappedArgs = Array.from(args).map(arg => arg.unwrap()); // Unwrap arguments.
const result = Reflect.apply(targetFn, thisArg.unwrap(), unwrappedArgs); // Call the original function.
return wrap(getCastedBackendObject(result)); // Cast and wrap the return value.
}
});
}
return wrap(value); // Wrap and return the property value.
},
/**
* Intercepts property assignment on the object.
* - Handles special cases like event handling, ensuring proper connection and disconnection of events.
* @param {any} target - The original object.
* @param {string} key - The property key being set.
* @param {any} value - The new value being assigned.
* @param {any} receiver - The proxy or object that made the call.
* @returns {boolean} - True if the operation was successful.
*/
set(target, key, value, receiver) {
// Check if the property is an event source (like an event handler) and handle connection/disconnection.
if (HostUtil.isEventSource(target[key])) {
connections[key]?.disconnect(); // Disconnect any existing event connection.
if (value) {
// Connect the new event handler.
connections[key] = target[key].connect((sender, event) => value(event));
} else {
// Remove the event handler if the value is null or undefined.
delete connections[key];
}
return true;
}
return Reflect.set(target, key, value, receiver); // Set the property value normally.
}
});
}
// Wrap the global document object to handle event binding and return type casting.
globalThis.document = wrap(document);
});
And here’s the supporting C# in HostUtil:
public static class HostUtil {
public static bool isEventSource(object obj) => obj is EventSource;
public static IGlobalEventHandlers asEventHandlers(object obj) => obj as IGlobalEventHandlers;
public static IHtmlElement asHtmlElement(object obj) => obj as IHtmlElement;
public static bool isDomNode(object obj) => obj is IEventTarget;
}
This is still a work in progress and needs further testing. I’m sharing it here in case others have ideas for improvement or run into similar issues. Thanks again for your example—it made a huge difference in shaping this approach!
Hi @ClearScriptLib,
I’m working to solve a problem with DOM integration in ClearScript: I need to expose the style property so JavaScript can interact with it directly (e.g., element.style.color = "red";). I’m exploring whether reflection or alternative binding techniques could achieve this. Have you seen effective approaches for integrating properties like this in ClearScript?
Context:
I’m using HtmlElement from AngleSharp.Core, with AngleSharp.Css providing style handling through extension methods. The current implementation, designed for Jint, uses reflection for style bindings:
/// <summary>
/// Gets the style declaration of an element.
/// </summary>
[DomName("style")]
[DomAccessor(Accessors.Getter)]
public static ICssStyleDeclaration GetStyle(this IElement element) => _styles.GetValue(element, CreateStyle);
/// <summary>
/// Sets the style declaration of an element.
/// </summary>
[DomName("style")]
[DomAccessor(Accessors.Setter)]
public static void SetStyle(this IElement element, string value) => element.SetAttribute(AttributeNames.Style, value);
Approaches I’m Considering:
- Using a JavaScript Proxy to intercept
styleproperty access - Modifying AngleSharp to add a direct
styleproperty onHtmlElement - Using ClearScript binding to map
GetStyle/SetStyleas a single JavaScript property
Questions:
- Is it feasible to bind
GetStyleandSetStyleas astyleproperty in JavaScript using reflection or a similar approach? And if so, would this be recommended with ClearScript? - Are there specific ClearScript patterns or techniques that could simplify setting up JavaScript access to
element.style? - Based on your experience, what approach would you recommend for this integration?
Hi @herki18,
Great to hear that you found the example useful!
A recommendation: Instead of getCastedBackendObject, consider using a host method:
public static class HostUtil {
...
public static object asRuntimeType(object obj) => obj;
}
Host methods whose return type is object are exempt from type restriction, so the effect is a cast to the object's runtime type. The advantages are that (a) the cast requires one host call instead of three, and (b) exposing ExtendedHostFunctions and enabling reflection is no longer necessary.
Regarding style, how does Jint handle it? It doesn't directly support AngleSharp's DomAccessorAttribute, does it?
In any case, it probably goes without saying that ClearScript does not support AngleSharp's attributes. The custom attribute loader in our earlier example effectively translates DomNameAttribute into ClearScript's ScriptMemberAttribute, but there's no such equivalent for DomAccessorAttribute. That is, ClearScript can't "synthesize" script properties in that manner.
And because style is a property rather than a method, we can't use an extension class to provide it – the same issue we had with onclick. Instead, it seems most straightforward to add style support to the proxies you're already creating.
First, let's expose the relevant AngleSharp.css extension class:
engine.AddHostType(typeof(ElementCssInlineStyleExtensions));
Next, as those extensions apply to IElement, we'll need a way to check for that type:
public static class HostUtil {
...
public static IElement asElement(object obj) => obj as IElement;
}
Finally, let's extend the proxy trap:
return new Proxy(target, {
get(target, key, receiver) {
if (key === 'style') {
const element = HostUtil.asElement(target);
if (element) {
return element.GetStyle();
}
}
...
},
...
});
Will that work for you? Please send us your thoughts.
Thanks!
Please reopen this issue if you have additional questions or comments. Thanks!
Hey @ClearScriptLib
I cannot reopen this issue myself. I do not have the necessary permissions.
Thank you for your help with my previous problems—it has been incredibly valuable! With your support, I've made great progress and am now trying to get Preact working with ClearScript. At the moment, I am dealing with the following error:
Error executing script: Error: The object has no suitable property or field named '__k'
at Microsoft.ClearScript.V8.SplitProxy.V8SplitProxyNative.ThrowScheduledException () [0x00007] in <bff66af6ed014f828d44655bb768ca7c>:0
at Microsoft.ClearScript.V8.SplitProxy.V8SplitProxyNative.Invoke (System.Action`1[T] action) [0x00027] in <bff66af6ed014f828d44655bb768ca7c>:0
at Microsoft.ClearScript.V8.SplitProxy.V8ContextProxyImpl.InvokeWithLock (System.Action action) [0x00020] in <bff66af6ed014f828d44655bb768ca7c>:0
at Microsoft.ClearScript.V8.V8ScriptEngine.ScriptInvoke[T] (System.Func`1[TResult] func) [0x0002d] in <bff66af6ed014f828d44655bb768ca7c>:0
at Microsoft.ClearScript.V8.V8ScriptEngine.Execute (Microsoft.ClearScript.UniqueDocumentInfo documentInfo, System.String code, System.Boolean evaluate) [0x00028] in <bff66af6ed014f828d44655bb768ca7c>:0
at Microsoft.ClearScript.ScriptEngine.Execute (Microsoft.ClearScript.DocumentInfo documentInfo, System.String code) [0x00009] in <2086470d86374678b98d7a6edf931177>:0
at Microsoft.ClearScript.ScriptEngine.Execute (System.String documentName, System.Boolean discard, System.String code) [0x0001c] in <2086470d86374678b98d7a6edf931177>:0
at Microsoft.ClearScript.ScriptEngine.Execute (System.String documentName, System.String code) [0x00000] in <2086470d86374678b98d7a6edf931177>:0
at Microsoft.ClearScript.ScriptEngine.Execute (System.String code) [0x00000] in <2086470d86374678b98d7a6edf931177>:0
at EngineInstance.LoadAndExecuteScript () [0x0000e] in C:\dev\Git\Training\JSXEngine\src\JSXEngineV2\Assets\EngineInstance.cs:109
The issue is that the error message does not specify the line and column numbers in the JavaScript code where the error occurred. This makes it extremely difficult to pinpoint the root cause, especially when debugging complex scripts or consuming external libraries.
Is there an option or configuration in ClearScript to provide more detailed error messages, including the line and column numbers of the JavaScript code where the error occurred? If not, do you have suggestions on how I might approach this scenario?
If it would help, I can put together a minimal reproducible example as a console application that produces this issue. Let me know if that would make the investigation easier.
After Investigation: Additional Findings and Questions
The issue was caused by the __k field, which is part of Preact's internal structure (_children), and ClearScript cannot directly handle dynamic fields like this because it lacks the expanded JavaScript type representation available in the browser environment.
To work around this, I expanded the proxy logic to handle dynamic fields, allowing ClearScript to interact with objects that contain such properties. Below is the proxy implementation I am currently working on. This is still a work in progress, and I am looking for feedback on whether there might be a better way to handle this scenario.
Current Proxy Implementation
(function (document) {
function printProperties(target) {
const properties = [];
for (const key in target) {
if (target.hasOwnProperty(key)) {
properties.push(`${key}: ${target[key]} \n`);
}
}
const propertiesString = properties.join(', ');
return propertiesString;
}
function getLogContext() {
const stack = new Error().stack.split('\n');
// Extract the second stack frame, which points to the caller
const callerFrame = stack[3]?.trim();
return callerFrame || '[Unknown Context]';
}
function logWithContext(message, ...args) {
const context = getLogContext();
console.log(`[${context}] ${message}`, ...args);
}
/**
* Checks if the provided target is a non-null object.
* @param {any} target - The target to check.
* @returns {boolean} - True if target is an object and not null, false otherwise.
*/
function isObject(target) {
return target !== null && typeof target === 'object';
}
/**
* Casts the backend object to its actual type to expose derived type methods.
* This ensures JavaScript can access all methods from derived types (such as HTMLDivElement).
* @param {any} value - The backend object that might need casting.
* @returns {any} - The casted object if necessary, or the original value if no casting is needed.
*/
function getCastedBackendObject(value) {
if (isObject(value) && typeof value.GetType === 'function') {
const targetType = host.type(value.GetType()); // Get the type of the backend object
return host.cast(targetType, value); // Cast the object to expose its full API
}
return value;
}
// Adds an unwrap function to all objects, allowing easy access to the original target object.
Object.prototype.unwrap = function () {
if (this === undefined || this === null) {
console.error(`[unwrap] Attempted to unwrap an undefined or null value.`);
return this; // Return undefined/null safely
}
return this;
};
/**
* Wraps the target object in a proxy to handle property access and method calls.
* This ensures proper event handling and casts return values to their actual type when needed.
* @param {any} target - The object to wrap.
* @returns {any} - The wrapped object or the original object if it's not a DOM node.
*/
function wrap(target) {
// If the target is not a DOM node, return it directly without wrapping.
if (!HostUtil.isDomNode(target) && !HostUtil.isCssStyleDeclaration(target)) {
return target;
}
const connections = {}; // Store active event connections for later disconnection.
const dynamicProperties = {}; // Store dynamic properties for missing properties.
return new Proxy(target, {
/**
* Intercepts property access on the object.
* - If the property is a method, it wraps the method to handle arguments and results.
* - If the property is 'unwrap', it returns the original object.
* @param {any} target - The original object.
* @param {string} key - The property key being accessed.
* @param {any} receiver - The proxy or object that made the call.
* @returns {any} - The result of the property access, potentially wrapped.
*/
get(target, key, receiver) {
logWithContext(`Accessing property '${key}' on`, target);
if (key === 'unwrap') return () => {
logWithContext(`Returning unwrapped '${target} for '${key}'`);
return target; // Special case: return the unwrapped original object.
}
if(Reflect.has(target, key)) {
const value = Reflect.get(target, key, receiver); // Get the property value.
if (key === 'style' && HostUtil.isCssStyleDeclaration(value)) {
const targetType = host.type(value.GetType()); // Get the type of the backend object
const castedStyle = host.cast(targetType, value);
logWithContext(`Accessing 'style' property. Casted value:`, castedStyle);
return castedStyle;
}
if (typeof value === 'function') {
// If the property is a function, wrap the function to handle unwrapping arguments
// and casting return values.
return new Proxy(value, {
apply(targetFn, thisArg, args) {
logWithContext(`Accessing method '${key}'`);
const unwrappedArgs = Array.from(args).map((arg, index) => {
let unwrapped;
if(arg === undefined) {
unwrapped = null;
logWithContext(`Argument[${index}] is undefined:`, arg);
}
else if (arg && typeof arg.unwrap === 'function') {
unwrapped = arg.unwrap();
logWithContext(`Unwrapped argument[${index}]:`, arg, '=>', unwrapped);
} else {
unwrapped = arg;
logWithContext(`Argument[${index}] has no unwrap method:`, arg);
}
return unwrapped;
});
// Call the original function using the unwrapped arguments
const result = Reflect.apply(targetFn, thisArg.unwrap(), unwrappedArgs);
logWithContext(`Function '${key}' returned:`, result);
// Cast and wrap the return value
return wrap(getCastedBackendObject(result));
}
});
}
return wrap(value); // Wrap and return the property value.
}
logWithContext(`Property '${key}' does not exist on target.`);
// Check for dynamic properties
if (key in dynamicProperties) {
console.log(`[Proxy:get] Accessing dynamic property '${key}'`);
return dynamicProperties[key];
}
console.warn(`[Proxy:get] Property '${key}' does not exist on target.`);
return undefined; // Default behavior for missing properties
},
/**
* Intercepts property assignment on the object.
* - Handles special cases like event handling, ensuring proper connection and disconnection of events.
* @param {any} target - The original object.
* @param {string} key - The property key being set.
* @param {any} value - The new value being assigned.
* @param {any} receiver - The proxy or object that made the call.
* @returns {boolean} - True if the operation was successful.
*/
set(target, key, value, receiver) {
// Check if the property is an event source (like an event handler) and handle connection/disconnection.
if (HostUtil.isEventSource(target[key])) {
connections[key]?.disconnect(); // Disconnect any existing event connection.
if (value) {
// Connect the new event handler.
connections[key] = target[key].connect((sender, event) => value(event));
} else {
// Remove the event handler if the value is null or undefined.
delete connections[key];
}
return true;
}
// Handle dynamic properties
if (!Reflect.has(target, key)) {
console.log(`[Proxy:set] Adding dynamic property '${key}'`);
dynamicProperties[key] = value;
return true;
}
return Reflect.set(target, key, value, receiver); // Set the property value normally.
},
has(target, key) {
console.log(`[Proxy:has] Checking existence of property '${key}'`);
return Reflect.has(target, key) || key in dynamicProperties;
}
});
}
// Wrap the global document object to handle event binding and return type casting.
globalThis.document = wrap(document);
});
Questions
-
Is there a better approach to handling dynamic fields like
__k?- This solution works by dynamically adding properties to the proxy, but I'm unsure if it's the most efficient or maintainable way to handle this issue.
-
Can ClearScript be configured to handle dynamic properties natively?
- Are there options in ClearScript to better handle JavaScript objects that expand dynamically, such as Preact's
_children?
- Are there options in ClearScript to better handle JavaScript objects that expand dynamically, such as Preact's
-
Improved Logging and Source Map Support
- Currently, debugging errors is challenging due to lack of detailed error information, especially for line numbers and stack traces in JavaScript code.
- Is there a way to integrate source maps or more detailed logging into ClearScript for better debugging?
I'd like to clarify something regarding handling undefined in ClearScript. Currently, I check if a value is undefined and convert it to null before passing it to C#.
Example:
const options = arg === undefined ? null : arg;
document.createElement("div", options);
On the C# side, the method signature is:
[DomName("createElement")] IElement CreateElement(String name, ElementCreationOptions? options);
If I pass undefined directly, ClearScript cannot find the correct method to consume because the method signatures do not match. Is converting undefined to null the correct approach in this scenario, or should undefined be handled differently in ClearScript?"
Here’s the formatted question with the additional information included as requested:
After Investigation: Additional Findings and Questions:
I’ve noticed the following behaviors in different scenarios and would like further clarification on handling similar cases:
-
What Works:
- Calling
document.createElement("div", null)works as expected. - Handling
undefinedexplicitly and setting it tonullbefore passing it to C# works as well. - Passing an instance of
ElementCreationOptions(e.g.,new ElementCreationOptions()) also works correctly.
- Calling
-
What Fails:
- Passing
undefineddirectly to the second parameter results in an error:Error executing script: Error: 'AngleSharp.Dom.IDocument' does not contain a definition for 'createElement' - Passing an object like
{ is: "div" }also causes the same error, indicating it doesn’t map to theElementCreationOptionsparameter correctly.
- Passing
-
C# Method Signature:
[DomName("createElement")] IElement CreateElement(String name, ElementCreationOptions? options);
Questions:
- Should I always explicitly handle
undefinedin JavaScript to map it tonull, or is there a way to configure ClearScript to automatically treatundefinedasnullfor nullable types? - In cases where JavaScript objects are passed (e.g.,
{ is: "div" }), how can I ensure they are correctly mapped toElementCreationOptions? - Is there a way to define additional overloads or use custom bindings in C# to improve compatibility for these scenarios?
Thank you for your insights!
Hi @herki18,
Is there a better approach to handling dynamic fields like __k?
Having the proxy route property assignment to a private JavaScript object if the target doesn't support it seems like a great solution. Just be aware that the Reflect.has check involves a hop into managed code, so performance could be an issue. Also, for completeness and consistency, consider rounding out your proxy with additional traps – defineProperty, deleteProperty, ownKeys, etc.
Can ClearScript be configured to handle dynamic properties natively?
It isn't a configuration option, but you can expose objects with dynamic property semantics. Any object that implements IPropertyBag works that way, as does any implementation of IDynamicMetaObjectProvider such as DynamicObject and ExpandoObject.
Currently, debugging errors is challenging due to lack of detailed error information, especially for line numbers and stack traces in JavaScript code.
Exceptions thrown by the script engine provide script error information (when available) in the ErrorDetails property. Consider:
engine.Script.foo = new { bar = 123 };
try {
engine.Execute("foo.baz = 'qux'");
}
catch (ScriptEngineException exception){
Console.WriteLine(exception.ErrorDetails);
}
This code produces the following output:
Error: The object has no suitable property or field named 'baz'
at Script:1:9 -> foo.baz = 'qux'
Script error information is also included in the text returned by the exception's ToString override.
Cheers!
PS. We'll respond to your most recent questions soon!
Hello @herki18,
is there a way to configure ClearScript to automatically treat
undefinedasnullfor nullable types?
Yes; UndefinedImportValue allows you to do just that – e.g., engine.UndefinedImportValue = null. However, there are a couple of caveats:
- Your host will not be able to distinguish script calls that pass
undefinedfrom those that passnull. - Unless the
optionsparameter specifies a default argument, script calls will still have to specify both arguments; that is,createElement('div')won't work. However, please read on...
In cases where JavaScript objects are passed (e.g.,
{ is: "div" }), how can I ensure they are correctly mapped toElementCreationOptions?
Unfortunately, ClearScript can't do that kind of conversion. As in C#, the argument types select the method, not the other way around. However, please read on...
Is there a way to define additional overloads or use custom bindings in C# to improve compatibility for these scenarios?
Yes! You can define script-friendly extension methods. For example, assuming the following simplified definitions:
public struct ElementCreationOptions {
public string @is;
}
public class Document {
public IElement createElement(string name, ElementCreationOptions? options) {
...
}
}
Here's an extension method that enables the required script invocation syntax:
public static class DocumentExtensions {
public static IElement createElement(this Document document, string name, ScriptObject options = null) {
ElementCreationOptions? creationOptions = null;
if (options != null && options["is"] is string @is) {
creationOptions = new ElementCreationOptions { @is = @is };
}
return document.createElement(name, creationOptions);
}
}
And now you can do this:
engine.AddHostType(typeof(DocumentExtensions));
engine.Execute("document.createElement('foo')");
engine.Execute("document.createElement('foo', { is: 'bar' })");
Please let us know if that works for you.
Thanks!
Please reopen this issue if you have additional questions or comments. Thanks!