Skip to content
New issue

Have a question about this project? Sign up for a free GitHub account to open an issue and contact its maintainers and the community.

By clicking “Sign up for GitHub”, you agree to our terms of service and privacy statement. We’ll occasionally send you account related emails.

Already on GitHub? Sign in to your account

JSInterop support for invoking constructors, properties, and supplying callbacks #31151

Open
3 tasks
javiercn opened this issue Mar 23, 2021 · 24 comments
Open
3 tasks
Assignees
Labels
area-blazor Includes: Blazor, Razor Components enhancement This issue represents an ask for new feature or an enhancement to an existing one feature-blazor-jsinterop This issue is related to JSInterop in Blazor Pillar: Complete Blazor Web Priority:2 Work that is important, but not critical for the release

Comments

@javiercn
Copy link
Member

  • Support invoking constructor functions either detecting them automatically or with a new method.
  • Support invoking callback objects (maybe this works already), either with a new JS/Dotnet callback.
  • Support accessing object properties directly (automatically or via a new method).
@javiercn javiercn added area-blazor Includes: Blazor, Razor Components feature-blazor-jsinterop This issue is related to JSInterop in Blazor labels Mar 23, 2021
@javiercn javiercn added this to the Next sprint planning milestone Mar 23, 2021
@ghost
Copy link

ghost commented Mar 23, 2021

Thanks for contacting us.
We're moving this issue to the Next sprint planning milestone for future evaluation / consideration. We will evaluate the request when we are planning the work for the next milestone. To learn more about what to expect next and how this issue will be handled you can read more about our triage process here.

@javiercn
Copy link
Member Author

We had several reports of these things in this area and some of them block using basic JS functionality without having to write a wrapper.

@campersau
Copy link
Contributor

For wasm only apps there is https://github.com/dotnet/runtime/tree/main/src/libraries/System.Private.Runtime.InteropServices.JavaScript if you aren't afraid of the Private part.

@javiercn
Copy link
Member Author

We don't recommend using those directly, since they are wasm specific and I believe might change in the future

@mkArtakMSFT mkArtakMSFT added the enhancement This issue represents an ask for new feature or an enhancement to an existing one label Jul 27, 2021
@ghost
Copy link

ghost commented Aug 10, 2021

We've moved this issue to the Backlog milestone. This means that it is not going to be worked on for the coming release. We will reassess the backlog following the current release and consider this item at that time. To learn more about our issue management process and to have better expectation regarding different types of issues you can read our Triage Process.

@TanayParikh TanayParikh modified the milestones: Backlog, .NET 7 Planning Oct 19, 2021
@TanayParikh TanayParikh added the Priority:2 Work that is important, but not critical for the release label Oct 21, 2021
@mkArtakMSFT mkArtakMSFT modified the milestones: .NET 7 Planning, Backlog Nov 11, 2021
@ghost
Copy link

ghost commented Nov 11, 2021

We've moved this issue to the Backlog milestone. This means that it is not going to be worked on for the coming release. We will reassess the backlog following the current release and consider this item at that time. To learn more about our issue management process and to have better expectation regarding different types of issues you can read our Triage Process.

@ghost
Copy link

ghost commented Oct 12, 2022

Thanks for contacting us.

We're moving this issue to the .NET 8 Planning milestone for future evaluation / consideration. We would like to keep this around to collect more feedback, which can help us with prioritizing this work. We will re-evaluate this issue, during our next planning meeting(s).
If we later determine, that the issue has no community involvement, or it's very rare and low-impact issue, we will close it - so that the team can focus on more important and high impact issues.
To learn more about what to expect next and how this issue will be handled you can read more about our triage process here.

@ghost
Copy link

ghost commented Dec 19, 2023

Thanks for contacting us.

We're moving this issue to the .NET 9 Planning milestone for future evaluation / consideration. We would like to keep this around to collect more feedback, which can help us with prioritizing this work. We will re-evaluate this issue, during our next planning meeting(s).
If we later determine, that the issue has no community involvement, or it's very rare and low-impact issue, we will close it - so that the team can focus on more important and high impact issues.
To learn more about what to expect next and how this issue will be handled you can read more about our triage process here.

@SteveSandersonMS SteveSandersonMS changed the title JSInterop little improvements JSInterop support for invoking constructors, properties, and supplying callbacks Jan 24, 2024
@trungnt2910
Copy link

Raising my support for this issue. With the current IJSRuntime mechanism, even for simple properties like document.title, if eval is blocked, there's no way other than a wrapper function (a certain bug in .NET's implementation prevents the usage of valueOf for primitive types).

Something like giving IJSObjectReference functions like GetProperty[...]Async and SetProperty[...]Async similar to what JSObject already has would be highly desirable.

@mkArtakMSFT mkArtakMSFT modified the milestones: .NET 10 Planning, Backlog Nov 1, 2024
@danroth27 danroth27 modified the milestones: Backlog, .NET 10 Planning Dec 11, 2024
@danroth27 danroth27 added Priority:2 Work that is important, but not critical for the release and removed Priority:1 Work that is critical for the release, but we could probably ship without labels Jan 13, 2025
@lskyum
Copy link

lskyum commented Jan 15, 2025

With the new partial properties in C# 13, this seems like a good case for source generators?

For a private project I generate "type safe" wrappers for JSObject (using source generators) and it works really well.
This makes it possible to use libraries like three.js directly from C#, which is way nicer than manually writing interop or moving too much logic to JS.

@javiercn
Copy link
Member Author

javiercn commented Feb 5, 2025

Design notes

  • We should support this directly through JsInvoke as follows:
  • Constructors can be detected by the presence of a prototype property in the object.
  • Properties / Invocations can be differentiated based on whether the object is an instance of Function
  • Creating a reference to a JS function can be done by using the return type from the call to JSInvoke in the same way is done for JSObjectReference

With that in mind, here are a few examples:

// Get a reference to a JS function
var fn = JSRuntime.InvokeAsync<IJsFunctionReference>("atob");

// Get the href property of the window.location object
var url = JSRuntime.InvokeAsync<string>("window.location.href");

// Set the href property of the window.location object
JSRuntime.InvokeAsync("window.location.href", "http://www.google.com");

// Invoke a constructor
var urlObj = JSRuntime.InvokeAsync<IJSObjectReference>("URL", "https://www.example.com");

@trungnt2910
Copy link

Some of these could come for free if this restriction is somehow removed: #55259

@KristofferStrube
Copy link
Contributor

I like it a lot. The one thing I'm missing to make this feature complete is the option to make it possible to also set attributes.

The current API change is cool as it uses the existing method, but I worry that it might not fit that well if we expand the API in the future. So even if we don't add an option for setting an attribute as a part of solving this issue I would expect that others would request it in the future. And then when we need to add that and use the existing pattern of using the same method, we would simply use the second argument as the new value for the attribute which would read as:

JSRuntime.InvokeVoidAsync("window.myVariable", newValue);

But that would seem rather unintuitive as you would not be able to read from that method whether myVariable is an attribute that is set or a function that takes a single parameter.

So I would suggest to add a method specifically for getting an attribute called GetAttributeAsync (and GetAttribute for IJSInprocessRuntime). So that we can also add a method for setting an attribute as a counterpart called SetAttributeAsync (and SetAttribute for IJSInProcessRuntime) either in the PR that solves this issue or in a separate PR.

@KristofferStrube
Copy link
Contributor

I additionally want to point out that I called the suggested methods GetAttributeAsync and SetAttributeAsync instead of GetPropertyAsync and SetPropertyAsync as properties are completely separate things in JS which attributes should not be confused with.

@javiercn
Copy link
Member Author

I like it a lot. The one thing I'm missing to make this feature complete is the option to make it possible to also set attributes.

The current API change is cool as it uses the existing method, but I worry that it might not fit that well if we expand the API in the future. So even if we don't add an option for setting an attribute as a part of solving this issue I would expect that others would request it in the future. And then when we need to add that and use the existing pattern of using the same method, we would simply use the second argument as the new value for the attribute which would read as:

JSRuntime.InvokeVoidAsync("window.myVariable", newValue);
But that would seem rather unintuitive as you would not be able to read from that method whether myVariable is an attribute that is set or a function that takes a single parameter.

So I would suggest to add a method specifically for getting an attribute called GetAttributeAsync (and GetAttribute for IJSInprocessRuntime). So that we can also add a method for setting an attribute as a counterpart called SetAttributeAsync (and SetAttribute for IJSInProcessRuntime) either in the PR that solves this issue or in a separate PR.

I didn't explicitly mention it, but setting a property would also work

var url = JSRuntime.InvokeAsync<string>("window.location.href", "http://www.google.com");

If window.location.href is undefined or not an instance of Function we set the property. Otherwise, we invoke it and pass the argument

@pavelsavara
Copy link
Member

I think we should rather has API with which can explicitly express the intent.
Is it calling a function or setting a property ?

Because JSRuntime.InvokeAsync("globalThis.foo", "abc") would behave differently if there already was function foo.

Regarding callbacks, so the JS function is also JS object. In Blazor it could get assigned a interop ID.

So we could abuse IJSObjectReference.InvokeAsync with null name. Is that what we want ?

@javiercn
Copy link
Member Author

Regarding callbacks, so the JS function is also JS object. In Blazor it could get assigned a interop ID.

So we could abuse IJSObjectReference.InvokeAsync with null name. Is that what we want ?

JSFunction will be an object under the hood, they need to be tracked with an object id.

@javiercn
Copy link
Member Author

javiercn commented Feb 20, 2025

Because JSRuntime.InvokeAsync("globalThis.foo", "abc") would behave differently if there already was function foo

It would behave the same way it has behaved since the beginning; if foo is a function, it will invoke it, if not, it'll set the value. This currently fails if foo is not a function, we would only be making it work.

Today you have to write wrappers around all these scenarios, with the proposed changes, 99% of those scenarios don't require a wrapper, for the potential 1% that remains, you can still write the wrapper.

@oroztocil
Copy link
Member

oroztocil commented Mar 5, 2025

Extending the JS interop solution

I wrote the following notes while working on extending the current interop capabilities (from .NET to JS) in order to document issues I ran into, and to prepare for discussion about how to deal with them.

1. Goals

We want to add support for these features:

  • Creating an instance of a JS object using a constructor function and getting the IJSObjectReference .NET handle for referencing the instance.
  • Reading and modifying value of a JS object property (both data and accessor properties).
  • Getting and using references to JS functions as IJSObjectReference or new dedicated type (e.g. IJSFunctionReference).

Note: In this version, we only deal with the asynchronous API and ignore the synchronous API available via IJSInProcessRuntime and IJSInProcessObjectReference.

2. Current state

At the moment, we support calling JS functions from .NET. This is covered in the existing API by InvokeAsync that can be called on an IJSObjectReference instance which is resolved on the JS side to the actual object whose members are accessed based on the identifier argument. InvokeAsync can be also called on an IJSRuntime instance, in which case the window object is implicitly targeted.

The invoked function can:

  • be void (called with InvokeVoidAsync),
  • return a simple serializable value (called with InvokeAsync<T> where T is JSON serializable),
  • return a reference to a JS object (called with InvokeAsync<IJSObjectReference>),
  • return a reference to a JS stream (called with IJSStreamReference).

When adding support for other operations over interop, we need to know if we can (and want) to cover them with the existing InvokeAsync API, or if we need (or want) to introduce more methods.

3. Invoking constructors

Currently, it is not possible to invoke a JS function with new, i.e. to instantiate a new JS object from .NET without writing a wrapper JS function.

The goal is to support invocation such as:

// Using the existing API
var urlObj = JSRuntime.InvokeAsync<IJSObjectReference>("URL", "https://www.example.com");

The problem with overloading the existing API is that, there seems to be no dependable way of differentiating a "regular" function from a constructor function.

More precisely, as summarized in this post:

ECMAScript 6+ distinguishes between callable (can be called without new) and constructible (can be called with new) functions:

  • Functions created via the arrow functions syntax or via a method definition in classes or object literals are not constructible.
  • Functions created via the class syntax are not callable.
  • Functions created in any other way (function expression/declaration, Function constructor) are callable and constructible.
  • Built-in functions are not constructible unless explicitly stated otherwise.

The first two cases are simple. However, the third case means we can't determine, in general, just by examining the function object if we should invoke a resolved function directly or with new.

This can be easily demonstrated using the common approaches for determining if a function is a constructor (is constructible). One such approach is based on checking the function's prototype property (or prototype.constructor). This method gives "false positives" for functions like this:

window.f = function(num) { return num * 2; }
console.log(!!(f.prototype && f.prototype.constructor === f)) // true
console.log(new f(1)) // window.f { prototype: ... }
console.log(f(1)) // 2

If we would determine f in this example as a constructor and returned the result of new f() to the .NET caller, they would get something quite different than what they likely expected.
(Or the InvokeAsync call would crash unexpectedly.)

At the same time we most likely don't want to call the following function Cat without new, even thought it is not discernible from f based on its properties alone:

window.cat = function(name) { this.name = name; }
console.log(!!(cat.prototype && cat.prototype.constructor === cat)) // true
console.log(new cat("Tom")) // window.cat { name: "Tom" }
console.log(cat("Tom")) // undefined

Other approaches, namely using Proxy objects or invoking the function with new in a try/catch block, have the same behavior. (The try/catch approach is also not feasible due to possible side effects.)

Therefore, it seems necessary to extend the interop API. If we don't rely on just InvokeAsync to cover both cases, we can avoid having to make the decision whether to use the function as a constructor or not.

We can let users express their intent by using e.g. InvokeNewAsync or InvokeConstructorAsync. When handling such call, we can check that the resolved object is a function and try to invoke it with new while catching and translating the possible type error that would occur if the resolved function is not constructible.

4. Getting and setting property values

Unlike with constructors, we could cover reading and writing property values using InvokeAsync:

var url = await JSRuntime.InvokeAsync<string>("window.location.href");
await JSRuntime.InvokeAsync("window.location.href", "http://www.google.com");

The benefit is the ease of implementation: We can both reuse the existing API methods and not need to modify the interop infrastructure (e.g. functions like ICallDispatcher.beginInvokeJSFromDotNet).

An alternative would be to introduce new dedicated API methods such as:

var currentTitle = await JSRuntime.GetValueAsync<string>("document.title");
var name = await catReference.GetValueAsync<string>("name");

await JSRuntime.SetValueAsync("document.title", "Brave new title");
await catReference.SetValueAsync("name", "Tom");

Arguments for such extension include:

  • It is more obvious and unambiguous for the user how to do things
  • The behavior does not change depending on run-time values
  • We already need to deal with constructors separately, therefore the API & the infrastructure will need to be modified anyway
  • We avoid issues with functions as values

We can illustrate the last point. First, it is not uncommon that JS libraries have APIs which support a direct value or a callback function that provides the value. For example:

interface SomeType {
    x: number | () => number;
}

function computeX() { ... }

const obj: SomeType = {
    x: computeX
}

Now, when obj.x contains a function during run-time, there would be no way to change its value if we only have InvokeAsync:

// This just invokes the callback and ignores the result
await JSRuntime.InvokeAsync("obj.x", 100);

// This would be unambiguous and would change the value of obj.x
await JSRuntime.SetValueAsync("obj.x", 100);

Second, with separate InvokeAsync and GetValueAsync we can unambiguously work with functions that return a function and get references to both the function itself, or the result of its invocation:

// Lets say someFunc returns a function
var refToSomeFunc = await JSRuntime.GetValueAsync<IJSFunctionReference>("someFunc");
var refToSomeFuncResult = await JSRuntime.InvokeAsync<IJSFunctionReference>("someFunc");

We could also enable cleanly getting value of the entire object from its IJSObjectReference:

var catModel = await catReference.GetValueAsync<CatModel>();

This could be implemented with objRef.InvokeAsync("") or objRef.InvokeAsync(null), but I'd argue that is quite non-obvious and would increase chance that users miss bugs in their code (when they didn't intend to pass such value to the call and it doesn't crash).

5. Function references

This area needs to be specified more. There are multiple open questions:

  1. Do we want to add a dedicated .NET type such as IJSFunctionReference, or do we want to use the existing IJSObjectReference? A new type could help type safety and could be used in more specialized APIs.
  2. What do we want the user to be able to do with a function reference in .NET? Invoke the referenced function? Pass it as an argument to another function? Set it as a value of a property (using e.g. the proposed SetValueAsync)?
  3. What types of JS methods can be referenced? In particular, how do we handle capturing/binding this for the different types of functions? Consider the ways and contexts in which functions can be defined in JS:
    1. Function declaration: function f() { ... }
    2. Function expression: const f = function() { ... }
    3. Arrow function expression: const f = () => { ... }
    4. Object literal method: const obj = { f() { ... } }
    5. Class method: class C { f() { ... } }
    6. Static class method: class C { static f() { ... } }
  4. Do we support generator functions, and if yes, how do we treat them?

6. Summary

  • Constructors: A common category of JS functions is both callable and constructible, therefore we can't determine if we should call them with or without new without knowing user intent. Adding new interop API method seems necessary.
  • Getting/setting properties: Can be done as InvokeAsync "overload". However this has downsides, particularly due to ambiguity. Adding new interop API methods seems preferable.
  • Function references: Needs further specification.

@KristofferStrube
Copy link
Contributor

@oroztocil really good exploration of the issue! The things you point out align well with what others have also pointed out regarding separate methods/overloads for the new features.

Related to the function references topic I also think we should consider what an IJSFunctionReference would be and whether that would be extending an IJSObjectReference. For what functions are available for an IJSFunctionReference I think it might make sense to either align with or draw inspiration from the methods that are available for functions in JS like apply, bind, and call.

@javiercn
Copy link
Member Author

javiercn commented Mar 6, 2025

@oroztocil great write-up.

I'm convinced by your arguments, so let's go with the more explicit approach that includes new APIs. I was hoping that we could make this work without having to use different APIs, but I don't think the complexity it adds is worth it. So as a summary:

  • Let's have new APIs for constructing objects IJSObjectReference NewAsync("<<path>>", <<args>>)
  • Let's have Task<TValue> GetValueAsync<TValue>("<<path>>") and Task SetValueAsync<TValue>("<<path>>", TValue value) on IJSObjectReference and IJSRuntime (for additional APIs)
  • Let's have IJSFunctionReference with Task<TResult> InvokeAsync<TResult>(params object [] args) and Task InvokeVoidAsync(params object [] args)
    • We shouldn't make IJSFunctionReference extend IJSObjectReference

Other considerations:

  • We will need synchronous versions of these APIs for WASM.
  • We don't need to do anything special to support generator functions I believe. It's a regular function that you invoke and returns a sequence as an IJSObjectReference that then you can call additional methods on.
  • The main purpose of supporting functions is to be able to call them from .NET code, but the other aspects are also valuable and shouldn't be any different than any other value.
  • For defining the bind behavior, we would look at what we do today:
    • What happens when you call a.b.c(), is c bound to b? (I think it is, because I believe we do b["c"]())
    • We would capture b and c in our underlying IJSFunctionReference and have it use b["c"](args) within its invokeAsync
  • For function references, we'll likely also want to have a way to wrap them as Func<...Task>/Func<...Task<TResult>> instances

Overall, this looks great. I think we can break this down into at least three separate PRs:

  1. Support calling constructors.
  2. Support getting/setting properties
  3. Support getting function references.

@pavelsavara
Copy link
Member

* We will need synchronous versions of these APIs for WASM.

I'm not sure about synchronous versions. Is it's worth it just for completeness sake ?

Synchronous versions would be only supported with the single-threaded runtime.

[JSImport] and [JSExport] are probably good enough alternative for wasm specific interop.
They also do not impose Json de/serialization cost on each call.

The main purpose of supporting functions is to be able to call them from .NET code

Are we also considering callbacks (implemented in C#) ?

When the function (and it's closure) is registered with a interop handle, when it would be collected ?

Sign up for free to join this conversation on GitHub. Already have an account? Sign in to comment
Labels
area-blazor Includes: Blazor, Razor Components enhancement This issue represents an ask for new feature or an enhancement to an existing one feature-blazor-jsinterop This issue is related to JSInterop in Blazor Pillar: Complete Blazor Web Priority:2 Work that is important, but not critical for the release
Projects
None yet
Development

No branches or pull requests