Skip to content

[blazor][wasm] optimize async JS interop via async JSImport/JSExport#65912

Closed
pavelsavara wants to merge 2 commits intodotnet:mainfrom
pavelsavara:blazor_wasm_interop
Closed

[blazor][wasm] optimize async JS interop via async JSImport/JSExport#65912
pavelsavara wants to merge 2 commits intodotnet:mainfrom
pavelsavara:blazor_wasm_interop

Conversation

@pavelsavara
Copy link
Copy Markdown
Member

Replace begin/end callback pattern with direct Promise↔Task JS interop on WebAssembly

Summary

This PR replaces the BeginInvokeJS / EndInvokeJS and BeginInvokeDotNet / EndInvokeDotNet callback-based async interop pattern on WebAssembly with direct Promise↔Task marshalling via JSImport/JSExport. The server and WebView paths are unchanged.

Motivation

The existing async JS interop on WebAssembly uses a two-step callback pattern:

  1. .NET→JS: BeginInvokeJS dispatches a call with an asyncHandle, JS executes the function, then calls back into .NET via EndInvokeJS with the serialized result.
  2. JS→.NET: JS calls BeginInvokeDotNet with a callId, .NET executes the method, then calls back into JS via endInvokeDotNetFromJS with the result.

Each async interop call requires two cross-boundary transitions. On WebAssembly, where JS and .NET share a single thread and JSImport/JSExport natively support TaskPromise marshalling, this round-trip is unnecessary overhead.

Changes

.NET→JS async calls (InvokeAsync<T>)

  • Added JSRuntime.InvokeJSAsync(in JSInvocationInfo) — a new virtual method that returns Task<string?> directly. The default implementation returns null (meaning: fall back to BeginInvokeJS).
  • WebAssemblyJSRuntime overrides InvokeJSAsync to call a new [JSImport] InvokeJSJsonAsync that returns a Task<string?> marshalled from the JS Promise.
  • JSRuntime.InvokeAsync<T> uses OperatingSystem.IsBrowser() (a JIT intrinsic compiled away on each platform) to select the direct path on WASM or the begin/end path elsewhere.
  • On the JS side, invokeJSJsonAsync calls processJSCall, awaits the result, serializes it, and returns it — no asyncHandle bookkeeping needed.

JS→.NET async calls (invokeMethodAsync)

  • Added DotNetDispatcher.InvokeAsync — a new public method that invokes the target [JSInvokable] method and, if it returns a Task/ValueTask, awaits and serializes the result into a Task<string?>.
  • Added [JSExport] InvokeDotNetAsync on DefaultWebAssemblyJSRuntime that calls DotNetDispatcher.InvokeAsync and returns the Task<string?> directly to JS as a Promise.
  • On the JS side, when the dispatcher has invokeDotNetFromJSAsync available, invokeDotNetMethodAsync calls it directly and deserializes the resolved Promise value — no asyncCallId / endInvokeDotNetFromJS bookkeeping needed.
  • BeginInvokeDotNet / endInvokeDotNetFromJS are no longer called on WebAssembly. beginInvokeDotNetFromJS in MonoPlatform throws if called (defensive guard). endInvokeJSFromDotNet is a no-op.

Cleanup

  • Removed the asyncHandle parameter from InvokeJSJson (sync path only).
  • Removed InternalCalls.EndInvokeDotNetFromJS — replaced by InvokeJSJsonAsync.
  • WebAssemblyJSRuntime.EndInvokeDotNet is now a no-op (async JS→.NET results flow via Promise resolution).
  • WebAssemblyJSRuntime.BeginInvokeJS(long, string, string, ...) throws NotSupportedException (retained only because the base class declares it abstract).

Performance wins

  • Eliminates one cross-boundary round-trip per async interop call in both directions. Each async .NET→JS and JS→.NET call previously required two managed↔JS transitions (begin + end); now each requires only one.
  • Removes per-call dictionary bookkeeping: the _pendingAsyncCalls map in JS and _pendingTasks ConcurrentDictionary in .NET are no longer used on the WASM path. No allocation for the async handle, no dictionary insert/lookup/remove per call.
  • Reduces allocations: no TaskCompletionSource<T> or CancellationTokenRegistration created in JSRuntime for the WASM path — the JSImport marshaller provides the Task directly.
  • Removes string encoding overhead for call IDs: the old JS→.NET path packed assemblyName or dotNetObjectId into a single string parameter (workaround for the 4-arg JSExport limit) and parsed it back with long.Parse. The new path passes typed parameters directly.

Public API changes

// Microsoft.JSInterop
+static Microsoft.JSInterop.Infrastructure.DotNetDispatcher.InvokeAsync(
    Microsoft.JSInterop.JSRuntime jsRuntime,
    in Microsoft.JSInterop.Infrastructure.DotNetInvocationInfo invocationInfo,
    string argsJson) -> Task<string?>

+virtual Microsoft.JSInterop.JSRuntime.InvokeJSAsync(
    in Microsoft.JSInterop.Infrastructure.JSInvocationInfo invocationInfo) -> Task<string?>?

// Microsoft.AspNetCore.Components.WebAssembly.JSInterop
*REMOVED* override WebAssemblyJSRuntime.BeginInvokeJS(in JSInvocationInfo) -> void
+override WebAssemblyJSRuntime.InvokeJSAsync(in JSInvocationInfo) -> Task<string?>?

@pavelsavara pavelsavara added this to the .NET 11 Planning milestone Mar 23, 2026
@pavelsavara pavelsavara self-assigned this Mar 23, 2026
@pavelsavara pavelsavara added the area-blazor Includes: Blazor, Razor Components label Mar 23, 2026
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

Projects

None yet

Development

Successfully merging this pull request may close these issues.

1 participant