ClearScript icon indicating copy to clipboard operation
ClearScript copied to clipboard

Issues with Using JavaScript Proxies to Wrap ClearScript Host Objects for DOM-like Functionality

Open herki18 opened this issue 1 year ago • 2 comments

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 like connect.

Approach Taken

  1. Proxy Implementation:

    • Wrapped the document object and created elements using JavaScript proxies to intercept event property assignments (e.g., onclick).
  2. 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);
              }
          }
      };
      
  3. 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

  1. Removing Conditional Property Checks:

    • Initially, I had checks like if (property in target) in the get trap, which I removed to ensure all properties are accessible.
  2. Binding Methods:

    • In the get trap, if the property is a function, I bind it to the target object to preserve the correct this context.
    get(target, property, receiver) {
        let value = Reflect.get(target, property, receiver);
        if (typeof value === 'function') {
            return value.bind(target);
        }
        return value;
    }
    
  3. Direct Property Access:

    • Adjusted the proxy to delegate all property accesses directly using Reflect.get and Reflect.set without additional logic, except for event properties.
  4. Event Handling Adjustments:

    • Implemented event handling in the set trap to connect and disconnect event handlers appropriately based on the presence of connect and disconnect methods on C# events.
  5. Testing Without Proxies:

    • Verified that methods like appendChild work correctly without proxies, confirming that the issue is proxy-related.

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

  1. Best Practices:

    • What is the recommended approach for using JavaScript proxies with ClearScript host objects to maintain full access to inherited methods like appendChild?
  2. Proxy Configuration:

    • Are there specific configurations or patterns for the get and set traps that prevent interference with ClearScript's method binding?
  3. Event Handling Alternatives:

    • Is there an alternative to using connect methods for event handling?
    • Can we override how events are handled to use property assignments (e.g., onclick = ...) instead of relying on connect?
    • What is the best way to intercept event property assignments without disrupting access to other properties and methods?
  4. Known Limitations:

    • Are there any known limitations or workarounds when using proxies with ClearScript host objects that I should be aware of?
  5. 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.

herki18 avatar Oct 21 '24 09:10 herki18

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?

ClearScriptLib avatar Oct 21 '24 15:10 ClearScriptLib

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.

herki18 avatar Oct 21 '24 16:10 herki18

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!

ClearScriptLib avatar Oct 22 '24 14:10 ClearScriptLib

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 asType or casting in JavaScript with this type.

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?

herki18 avatar Oct 23 '24 13:10 herki18

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!

ClearScriptLib avatar Oct 23 '24 15:10 ClearScriptLib

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!

herki18 avatar Oct 25 '24 21:10 herki18

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 style property access
  • Modifying AngleSharp to add a direct style property on HtmlElement
  • Using ClearScript binding to map GetStyle/SetStyle as a single JavaScript property

Questions:

  1. Is it feasible to bind GetStyle and SetStyle as a style property in JavaScript using reflection or a similar approach? And if so, would this be recommended with ClearScript?
  2. Are there specific ClearScript patterns or techniques that could simplify setting up JavaScript access to element.style?
  3. Based on your experience, what approach would you recommend for this integration?

herki18 avatar Oct 25 '24 21:10 herki18

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!

ClearScriptLib avatar Oct 27 '24 17:10 ClearScriptLib

Please reopen this issue if you have additional questions or comments. Thanks!

ClearScriptLib avatar Nov 05 '24 16:11 ClearScriptLib

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

  1. 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.
  2. 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?
  3. 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?

herki18 avatar Nov 10 '24 00:11 herki18

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:

  1. What Works:

    • Calling document.createElement("div", null) works as expected.
    • Handling undefined explicitly and setting it to null before passing it to C# works as well.
    • Passing an instance of ElementCreationOptions (e.g., new ElementCreationOptions()) also works correctly.
  2. What Fails:

    • Passing undefined directly 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 the ElementCreationOptions parameter correctly.
  3. C# Method Signature:

    [DomName("createElement")]
    IElement CreateElement(String name, ElementCreationOptions? options);
    

Questions:

  • Should I always explicitly handle undefined in JavaScript to map it to null, or is there a way to configure ClearScript to automatically treat undefined as null for nullable types?
  • In cases where JavaScript objects are passed (e.g., { is: "div" }), how can I ensure they are correctly mapped to ElementCreationOptions?
  • 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!

herki18 avatar Nov 11 '24 11:11 herki18

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!

ClearScriptLib avatar Nov 11 '24 15:11 ClearScriptLib

Hello @herki18,

is there a way to configure ClearScript to automatically treat undefined as null for nullable types?

Yes; UndefinedImportValue allows you to do just that – e.g., engine.UndefinedImportValue = null. However, there are a couple of caveats:

  1. Your host will not be able to distinguish script calls that pass undefined from those that pass null.
  2. Unless the options parameter 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 to ElementCreationOptions?

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!

ClearScriptLib avatar Nov 11 '24 21:11 ClearScriptLib

Please reopen this issue if you have additional questions or comments. Thanks!

ClearScriptLib avatar Nov 21 '24 12:11 ClearScriptLib