Skip to content

Add InvokeNew, GetValue & SetValue to JS interop API #61246

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

Merged
merged 29 commits into from
Apr 9, 2025
Merged
Show file tree
Hide file tree
Changes from 23 commits
Commits
Show all changes
29 commits
Select commit Hold shift + click to select a range
df3822f
WIP implementation with JSInvocationInfo
oroztocil Mar 18, 2025
bdea46d
WIP implementation for other platforms, fix tests
oroztocil Mar 18, 2025
8e51e9c
Fix function calls, add JS tests
oroztocil Mar 21, 2025
f227e09
Add JS interop tests
oroztocil Mar 24, 2025
9717446
Add new interop methods to InProcessRuntime
oroztocil Mar 25, 2025
1c525a4
Merge remote-tracking branch 'origin/main' into oroztocil/js-interop-…
oroztocil Mar 25, 2025
e653a00
WIP wasm & webview fixes
oroztocil Mar 25, 2025
4009275
WebView test fixes
oroztocil Mar 26, 2025
5e273e3
Add DotNetToJSInterop component to BasicTestApp
oroztocil Mar 26, 2025
0443be5
Merge remote-tracking branch 'origin/main' into oroztocil/js-interop-…
oroztocil Mar 27, 2025
951de51
WIP add E2E tests for new interop methods
oroztocil Mar 27, 2025
7056120
Add & fix E2E tests for interop
oroztocil Mar 28, 2025
200d736
Merge remote-tracking branch 'origin/main' into oroztocil/js-interop-…
oroztocil Mar 28, 2025
e7294ce
Add test for reading JS property from prototype
oroztocil Mar 28, 2025
42617e4
Remove debug prints and unwanted changes
oroztocil Mar 28, 2025
21caedd
Remove unwanted indentation changes in legacy code
oroztocil Mar 28, 2025
c7f49d6
Fix API, remove uneeded files
oroztocil Mar 31, 2025
93d3006
Add missing extension overloads, clean up code
oroztocil Mar 31, 2025
2508129
Rollback unwanted changes
oroztocil Mar 31, 2025
45260dd
Revert submodule to origin/main state
oroztocil Mar 31, 2025
5270dd8
Merge remote-tracking branch 'origin/main' into oroztocil/js-interop-…
oroztocil Mar 31, 2025
c6934f6
Rollback unwanted changes in submodule
oroztocil Mar 31, 2025
c13223b
Fix Microsoft.JSInterop tests
oroztocil Mar 31, 2025
2f84ea0
Code review fixes
oroztocil Apr 2, 2025
28c8d39
Minor code review changes
oroztocil Apr 3, 2025
1d8495c
Remove JSInvocationInfo from interop bridge communication
oroztocil Apr 4, 2025
b79f224
Refactor resolving JS object members, fix tests
oroztocil Apr 6, 2025
bd0ea2b
Remove unused overloads from PublicAPI.Unshipped.txt
oroztocil Apr 6, 2025
802f96a
Revert unwanted changes, fix style
oroztocil Apr 8, 2025
File filter

Filter by extension

Filter by extension


Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
25 changes: 24 additions & 1 deletion package-lock.json

Some generated files are not rendered by default. Learn more about how customized files appear on GitHub.

Original file line number Diff line number Diff line change
Expand Up @@ -14,4 +14,22 @@ public ValueTask<TValue> InvokeAsync<TValue>(string identifier, CancellationToke

ValueTask<TValue> IJSRuntime.InvokeAsync<TValue>(string identifier, object?[]? args)
=> throw new InvalidOperationException(Message);

public ValueTask<IJSObjectReference> InvokeNewAsync(string identifier, object?[]? args)
=> throw new InvalidOperationException(Message);

public ValueTask<IJSObjectReference> InvokeNewAsync(string identifier, CancellationToken cancellationToken, object?[]? args)
=> throw new InvalidOperationException(Message);

public ValueTask<TValue> GetValueAsync<TValue>(string identifier)
=> throw new InvalidOperationException(Message);

public ValueTask<TValue> GetValueAsync<TValue>(string identifier, CancellationToken cancellationToken)
=> throw new InvalidOperationException(Message);

public ValueTask SetValueAsync<TValue>(string identifier, TValue value)
=> throw new InvalidOperationException(Message);

public ValueTask SetValueAsync<TValue>(string identifier, TValue value, CancellationToken cancellationToken)
=> throw new InvalidOperationException(Message);
}
21 changes: 19 additions & 2 deletions src/Components/Server/src/Circuits/RemoteJSRuntime.cs
Original file line number Diff line number Diff line change
Expand Up @@ -105,6 +105,21 @@ protected override void SendByteArray(int id, byte[] data)
}

protected override void BeginInvokeJS(long asyncHandle, string identifier, string argsJson, JSCallResultType resultType, long targetInstanceId)
{
var invocationInfo = new JSInvocationInfo
{
AsyncHandle = asyncHandle,
TargetInstanceId = targetInstanceId,
Identifier = identifier,
CallType = JSCallType.FunctionCall,
ResultType = resultType,
ArgsJson = argsJson,
};

BeginInvokeJS(invocationInfo);
}

protected override void BeginInvokeJS(JSInvocationInfo invocationInfo)
{
if (_clientProxy is null)
{
Expand All @@ -123,9 +138,11 @@ protected override void BeginInvokeJS(long asyncHandle, string identifier, strin
}
}

Log.BeginInvokeJS(_logger, asyncHandle, identifier);
var invocationInfoJson = invocationInfo.ToJson();
Copy link
Member

@javiercn javiercn Apr 3, 2025

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

We shouldn't serialize things to a big string here.

We should keep _clientProxy.SendAsync (and the equivalent webassembly method) the same, unpacking the JSInvocationInfo and passing in the individual members.

Copy link
Member Author

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

So you want to keep passing individual arguments across the interop bridge? I.e. only add the new JSCallType value to the already long list of arguments?

Copy link
Member

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Yes, those are not part of the public API, so it's fine, we can change those whenever we want. We can't avoid serializing the arguments as a string, but we don't want to wrap that string into something else and serialize that too.


Log.BeginInvokeJS(_logger, invocationInfo.AsyncHandle, invocationInfo.Identifier);

_clientProxy.SendAsync("JS.BeginInvokeJS", asyncHandle, identifier, argsJson, (int)resultType, targetInstanceId);
_clientProxy.SendAsync("JS.BeginInvokeJS", invocationInfoJson);
}

protected override void ReceiveByteArray(int id, byte[] data)
Expand Down
41 changes: 38 additions & 3 deletions src/Components/Server/test/ProtectedBrowserStorageTest.cs
Original file line number Diff line number Diff line change
Expand Up @@ -352,19 +352,54 @@ private static string ProtectionPrefix(string purpose)

class TestJSRuntime : IJSRuntime
{
public List<(string Identifier, object[] Args)> Invocations { get; }
= new List<(string Identifier, object[] Args)>();
public List<(string Identifier, object[] Args, JSCallType CallType)> Invocations { get; } = [];

public object NextInvocationResult { get; set; }

public ValueTask<TValue> InvokeAsync<TValue>(string identifier, CancellationToken cancellationToken, object[] args)
{
Invocations.Add((identifier, args));
Invocations.Add((identifier, args, JSCallType.FunctionCall));
return (ValueTask<TValue>)NextInvocationResult;
}

public ValueTask<TValue> InvokeAsync<TValue>(string identifier, object[] args)
=> InvokeAsync<TValue>(identifier, cancellationToken: CancellationToken.None, args: args);

public ValueTask<IJSObjectReference> InvokeNewAsync(string identifier, object[] args)
{
Invocations.Add((identifier, args, JSCallType.NewCall));
return (ValueTask<IJSObjectReference>)NextInvocationResult;
}

public ValueTask<IJSObjectReference> InvokeNewAsync(string identifier, CancellationToken cancellationToken, object[] args)
{
Invocations.Add((identifier, args, JSCallType.NewCall));
return (ValueTask<IJSObjectReference>)NextInvocationResult;
}

public ValueTask<TValue> GetValueAsync<TValue>(string identifier)
{
Invocations.Add((identifier, [], JSCallType.GetValue));
return (ValueTask<TValue>)NextInvocationResult;
}

public ValueTask<TValue> GetValueAsync<TValue>(string identifier, CancellationToken cancellationToken)
{
Invocations.Add((identifier, [], JSCallType.GetValue));
return (ValueTask<TValue>)NextInvocationResult;
}

public ValueTask SetValueAsync<TValue>(string identifier, TValue value)
{
Invocations.Add((identifier, [value], JSCallType.SetValue));
return ValueTask.CompletedTask;
}

public ValueTask SetValueAsync<TValue>(string identifier, TValue value, CancellationToken cancellationToken)
{
Invocations.Add((identifier, [value], JSCallType.SetValue));
return ValueTask.CompletedTask;
}
}

class TestProtectedBrowserStorage : ProtectedBrowserStorage
Expand Down
10 changes: 6 additions & 4 deletions src/Components/Web.JS/src/Boot.WebAssembly.Common.ts
Original file line number Diff line number Diff line change
Expand Up @@ -14,6 +14,7 @@ import { addDispatchEventMiddleware } from './Rendering/WebRendererInteropMethod
import { WebAssemblyComponentDescriptor, WebAssemblyServerOptions, discoverWebAssemblyPersistedState } from './Services/ComponentDescriptorDiscovery';
import { receiveDotNetDataStream } from './StreamingInterop';
import { WebAssemblyComponentAttacher } from './Platform/WebAssemblyComponentAttacher';
import { DotNet } from '@microsoft/dotnet-js-interop';
import { MonoConfig } from '@microsoft/dotnet-runtime';
import { RootComponentManager } from './Services/RootComponentManager';
import { WebRendererId } from './Rendering/WebRendererId';
Expand Down Expand Up @@ -263,12 +264,13 @@ async function scheduleAfterStarted(operations: string): Promise<void> {
Blazor._internal.updateRootComponents(operations);
}

function invokeJSJson(identifier: string, targetInstanceId: number, resultType: number, argsJson: string, asyncHandle: number): string | null {
if (asyncHandle !== 0) {
dispatcher.beginInvokeJSFromDotNet(asyncHandle, identifier, argsJson, resultType, targetInstanceId);
function invokeJSJson(invocationInfoJson: string): string | null {
const invocationInfo: DotNet.JSInvocationInfo = JSON.parse(invocationInfoJson);
if (invocationInfo.asyncHandle !== 0) {
dispatcher.beginInvokeJSFromDotNet(invocationInfo);
return null;
} else {
return dispatcher.invokeJSFromDotNet(identifier, argsJson, resultType, targetInstanceId);
return dispatcher.invokeJSFromDotNet(invocationInfo);
}
}

Expand Down
2 changes: 1 addition & 1 deletion src/Components/Web.JS/src/GlobalExports.ts
Original file line number Diff line number Diff line change
Expand Up @@ -52,7 +52,7 @@ export interface IBlazor {
forceCloseConnection?: () => Promise<void>;
InputFile?: typeof InputFile;
NavigationLock: typeof NavigationLock;
invokeJSJson?: (identifier: string, targetInstanceId: number, resultType: number, argsJson: string, asyncHandle: number) => string | null;
invokeJSJson?: (invocationInfoString: string) => string | null;
endInvokeDotNetFromJS?: (callId: string, success: boolean, resultJsonOrErrorMessage: string) => void;
receiveByteArray?: (id: number, data: Uint8Array) => void;
getPersistedState?: () => string;
Expand Down
Original file line number Diff line number Diff line change
Expand Up @@ -127,7 +127,7 @@ export class CircuitManager implements DotNet.DotNetCallDispatcher {
const connection = connectionBuilder.build();

connection.on('JS.AttachComponent', (componentId, selector) => attachRootComponentToLogicalElement(WebRendererId.Server, this.resolveElement(selector), componentId, false));
connection.on('JS.BeginInvokeJS', this._dispatcher.beginInvokeJSFromDotNet.bind(this._dispatcher));
connection.on('JS.BeginInvokeJS', (invocationInfoJson: string) => this.beginInvokeJSJson(invocationInfoJson));
connection.on('JS.EndInvokeDotNet', this._dispatcher.endInvokeDotNetFromJS.bind(this._dispatcher));
connection.on('JS.ReceiveByteArray', this._dispatcher.receiveByteArray.bind(this._dispatcher));

Expand Down Expand Up @@ -232,6 +232,11 @@ export class CircuitManager implements DotNet.DotNetCallDispatcher {
return true;
}

private beginInvokeJSJson(invocationInfoJson: string) {
const invocationInfo: DotNet.JSInvocationInfo = JSON.parse(invocationInfoJson);
this._dispatcher.beginInvokeJSFromDotNet(invocationInfo);
}

// Implements DotNet.DotNetCallDispatcher
public beginInvokeDotNetFromJS(callId: number, assemblyName: string | null, methodIdentifier: string, dotNetObjectId: number | null, argsJson: string): void {
this.throwIfDispatchingWhenDisposed();
Expand Down
Original file line number Diff line number Diff line change
Expand Up @@ -33,7 +33,7 @@ export function startIpcReceiver(): void {
showErrorNotification();
},

'BeginInvokeJS': dispatcher.beginInvokeJSFromDotNet.bind(dispatcher),
'BeginInvokeJS': beginInvokeJSJson,

'EndInvokeDotNet': dispatcher.endInvokeDotNetFromJS.bind(dispatcher),

Expand Down Expand Up @@ -80,3 +80,8 @@ function base64ToArrayBuffer(base64: string): Uint8Array {
}
return result;
}

function beginInvokeJSJson(invocationInfoJson: string) {
const invocationInfo = JSON.parse(invocationInfoJson);
dispatcher.beginInvokeJSFromDotNet(invocationInfo);
}
Original file line number Diff line number Diff line change
Expand Up @@ -4,6 +4,7 @@
using System.ComponentModel;
using System.Diagnostics.CodeAnalysis;
using Microsoft.JSInterop;
using Microsoft.JSInterop.Infrastructure;

namespace Microsoft.AspNetCore.Components.Web.Internal;

Expand All @@ -17,4 +18,9 @@ public interface IInternalWebJSInProcessRuntime
/// For internal framework use only.
/// </summary>
string InvokeJS(string identifier, [StringSyntax(StringSyntaxAttribute.Json)] string? argsJson, JSCallResultType resultType, long targetInstanceId);

/// <summary>
/// For internal framework use only.
/// </summary>
string InvokeJS(JSInvocationInfo invocationInfo);
}
1 change: 1 addition & 0 deletions src/Components/Web/src/PublicAPI.Unshipped.txt
Original file line number Diff line number Diff line change
@@ -1,2 +1,3 @@
#nullable enable
Microsoft.AspNetCore.Components.Web.Internal.IInternalWebJSInProcessRuntime.InvokeJS(Microsoft.JSInterop.Infrastructure.JSInvocationInfo! invocationInfo) -> string!
virtual Microsoft.AspNetCore.Components.Routing.NavLink.ShouldMatch(string! uriAbsolute) -> bool
12 changes: 11 additions & 1 deletion src/Components/Web/src/WebRenderer.cs
Original file line number Diff line number Diff line change
Expand Up @@ -10,6 +10,7 @@
using Microsoft.Extensions.DependencyInjection;
using Microsoft.Extensions.Logging;
using Microsoft.JSInterop;
using Microsoft.JSInterop.Infrastructure;
using static Microsoft.AspNetCore.Internal.LinkerFlags;

namespace Microsoft.AspNetCore.Components.RenderTree;
Expand Down Expand Up @@ -130,7 +131,16 @@ private void AttachWebRendererInterop(IJSRuntime jsRuntime, JsonSerializerOption
newJsonOptions.TypeInfoResolverChain.Add(WebRendererSerializerContext.Default);
newJsonOptions.TypeInfoResolverChain.Add(JsonConverterFactoryTypeInfoResolver<DotNetObjectReference<WebRendererInteropMethods>>.Instance);
var argsJson = JsonSerializer.Serialize(args, newJsonOptions);
inProcessRuntime.InvokeJS(JSMethodIdentifier, argsJson, JSCallResultType.JSVoidResult, 0);
var invocationInfo = new JSInvocationInfo
{
AsyncHandle = 0,
TargetInstanceId = 0,
Identifier = JSMethodIdentifier,
CallType = JSCallType.FunctionCall,
ResultType = JSCallResultType.JSVoidResult,
ArgsJson = argsJson,
Copy link
Member

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Including the arguments here is going to be a problem. As far as I can tell, JSInvocationInfo gets json serialized, so this means ArgsJson gets "doubly" serialized which is going to result in a bunch of additional unescaped characters.

I think we should keep the arguments outside of this struct for that reason. I believe the other implementation does it this way, doesn't it?

Copy link
Member

@pavelsavara pavelsavara Apr 1, 2025

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Could this be JsonObject or JsonArray ?

Copy link
Member Author

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Regarding DotNetInvocationInfo: It is not comparable because that DTO is only used internally on the .NET side. It is not sent over the interop bridge (in neither SignalR, Wasm interop, nor WebView IPC). Rather, individual arguments are transferred and only then packed into DotNetInvocationInfo.

Meanwhile the JSInvocationInfo is created by the .NET side, sent over the respective bridge, and read by the JS side. The motivation for this is to set up this communication once and then be able to modify JSInvocationInfo without needing to go around and update all the layers of all the bridge implementations again.

Copy link
Member Author

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

I will check if passing args as a JsonObject or JsonArray works well (and what does the over-the-wire message looks like). If there are issues with that, then I will pass it as a separate string argument that is serialized once separately.

};
inProcessRuntime.InvokeJS(invocationInfo);
}
else
{
Expand Down
7 changes: 1 addition & 6 deletions src/Components/WebAssembly/JSInterop/src/InternalCalls.cs
Original file line number Diff line number Diff line change
Expand Up @@ -18,12 +18,7 @@ internal static partial class InternalCalls
public static extern TRes InvokeJS<T0, T1, T2, TRes>(out string exception, ref JSCallInfo callInfo, [AllowNull] T0 arg0, [AllowNull] T1 arg1, [AllowNull] T2 arg2);

[JSImport("Blazor._internal.invokeJSJson", "blazor-internal")]
public static partial string InvokeJSJson(
string identifier,
[JSMarshalAs<JSType.Number>] long targetInstanceId,
int resultType,
string argsJson,
[JSMarshalAs<JSType.Number>] long asyncHandle);
public static partial string InvokeJSJson(string invocationInfoString);
Copy link
Member

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

We shouldn't stringify things here, we need to keep this API the same, including the additional info and passing down the individual members.

Copy link
Member Author

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

What is the point of JSInvocationInfo that you made me implement then?

Copy link
Member

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

The point is that whenever we need to add things to the public API we don't need to keep adding overloads to JSRuntime and other public classes.

Copy link
Member Author

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

I see, that makes sense. However, I thought it was also to not having to rewrite the three different transport channels whenever we add some parameter to the interop (which based on my recent experience is the part that takes the most work to do).


[JSImport("Blazor._internal.endInvokeDotNetFromJS", "blazor-internal")]
public static partial void EndInvokeDotNetFromJS(
Expand Down
Original file line number Diff line number Diff line change
@@ -1 +1,3 @@
#nullable enable
override Microsoft.JSInterop.WebAssembly.WebAssemblyJSRuntime.BeginInvokeJS(Microsoft.JSInterop.Infrastructure.JSInvocationInfo! invocationInfo) -> void
override Microsoft.JSInterop.WebAssembly.WebAssemblyJSRuntime.InvokeJS(Microsoft.JSInterop.Infrastructure.JSInvocationInfo! invocationInfo) -> string?
Original file line number Diff line number Diff line change
Expand Up @@ -25,9 +25,27 @@ protected WebAssemblyJSRuntime()
/// <inheritdoc />
protected override string InvokeJS(string identifier, [StringSyntax(StringSyntaxAttribute.Json)] string? argsJson, JSCallResultType resultType, long targetInstanceId)
{
var invocationInfo = new JSInvocationInfo
{
AsyncHandle = 0,
TargetInstanceId = targetInstanceId,
Identifier = identifier,
CallType = JSCallType.FunctionCall,
ResultType = resultType,
ArgsJson = argsJson,
};

return InvokeJS(invocationInfo) ?? "";
}

/// <inheritdoc />
protected override string? InvokeJS(JSInvocationInfo invocationInfo)
{
var invocationInfoJson = invocationInfo.ToJson();

try
{
return InternalCalls.InvokeJSJson(identifier, targetInstanceId, (int)resultType, argsJson ?? "[]", 0);
return InternalCalls.InvokeJSJson(invocationInfoJson);
}
catch (Exception ex)
{
Expand All @@ -38,7 +56,24 @@ protected override string InvokeJS(string identifier, [StringSyntax(StringSyntax
/// <inheritdoc />
protected override void BeginInvokeJS(long asyncHandle, string identifier, [StringSyntax(StringSyntaxAttribute.Json)] string? argsJson, JSCallResultType resultType, long targetInstanceId)
{
InternalCalls.InvokeJSJson(identifier, targetInstanceId, (int)resultType, argsJson ?? "[]", asyncHandle);
var invocationInfo = new JSInvocationInfo
{
AsyncHandle = asyncHandle,
TargetInstanceId = targetInstanceId,
Identifier = identifier,
CallType = JSCallType.FunctionCall,
ResultType = resultType,
ArgsJson = argsJson,
};

BeginInvokeJS(invocationInfo);
}

/// <inheritdoc />
protected override void BeginInvokeJS(JSInvocationInfo invocationInfo)
{
var invocationInfoJson = invocationInfo.ToJson();
InternalCalls.InvokeJSJson(invocationInfoJson);
}

/// <inheritdoc />
Expand Down
Original file line number Diff line number Diff line change
Expand Up @@ -543,6 +543,36 @@ public ValueTask<TValue> InvokeAsync<TValue>(string identifier, CancellationToke
return new ValueTask<TValue>((TValue)GetInvocationResult(identifier));
}

public ValueTask<TValue> GetValueAsync<TValue>(string identifier)
{
throw new NotImplementedException();
}

public ValueTask<TValue> GetValueAsync<TValue>(string identifier, CancellationToken cancellationToken)
{
throw new NotImplementedException();
}

public ValueTask<IJSObjectReference> InvokeNewAsync(string identifier, object[] args)
{
throw new NotImplementedException();
}

public ValueTask<IJSObjectReference> InvokeNewAsync(string identifier, CancellationToken cancellationToken, object[] args)
{
throw new NotImplementedException();
}

public ValueTask SetValueAsync<TValue>(string identifier, TValue value)
{
throw new NotImplementedException();
}

public ValueTask SetValueAsync<TValue>(string identifier, TValue value, CancellationToken cancellationToken)
{
throw new NotImplementedException();
}

private object GetInvocationResult(string identifier)
{
switch (identifier)
Expand Down
Loading
Loading