microsoft / ClearScript

A library for adding scripting to .NET applications. Supports V8 (Windows, Linux, macOS) and JScript/VBScript (Windows).
https://microsoft.github.io/ClearScript/
MIT License
1.76k stars 148 forks source link

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

Open herki18 opened 1 day ago

herki18 commented 1 day ago

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

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

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.

ClearScriptLib commented 22 hours ago

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?

herki18 commented 21 hours ago

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.