The world’s most popular IDE just got an upgrade.
Use .NET from any JavaScript app in .NET 7
.NET 7 provides improved support for running .NET on WebAssembly in JavaScript-based apps, including a rich JavaScript interop mechanism. The WebAssembly support in .NET 7 is the basis for Blazor WebAssembly apps but can be used independently of Blazor too. Existing JavaScript apps can use the expanded WebAssembly support in .NET 7 to reuse .NET libraries from JavaScript or to build completely novel .NET-based apps and frameworks. Blazor WebAssembly apps can also use the new JavaScript interop mechanism to optimize interactions with JavaScript and the web platform. In this post, we’ll take a look at the new JavaScript interop support in .NET 7 and use it to build the classic TodoMVC sample app. We’ll also look at using the new JavaScript interop app from a Blazor WebAssembly app.
TL;DR
Fork the samples:
- https://github.com/pavelsavara/dotnet-wasm-todo-mvc
- https://github.com/pavelsavara/blazor-wasm-hands-pose
- https://github.com/maraf/dotnet-wasm-react
The new JavaScript interop is controlled by attributes from theSystem.Runtime.InteropServices.JavaScript namespace.
Live demos:
- https://pavelsavara.github.io/dotnet-wasm-todo-mvc
- https://pavelsavara.github.io/blazor-wasm-hands-pose
- https://maraf.github.io/dotnet-wasm-react/
TodoMVC
TodoMVC is greatcommunity project which helps JavaScript developers compare the features of various UI frameworks.
To show how the new JS interop support in .NET 7 works, let’s create a C# port of TodoMVC based on thevanilla-es6 version. We won’t try to convert all the UI logic, just the app logic.
How-to
This post only shows interesting snippets, not the whole code.To follow along, please copy & paste from the samplerepo on github.
To get started, install the.NET 7 RC1 SDK (or later) and run the following commands:
dotnet workload install wasm-toolsdotnet workload install wasm-experimentaldotnet new wasmbrowserThewasm-experimental workload contains experimental project templates for getting started with .NET on WebAssembly in a browser app (WebAssembly Browser App) or in a Node.js based console app (WebAssembly Console App). Here we’re using the browser-based template. The developer experience for these project templates is still a work in progress, but the APIs used in them are fully supported in .NET 7.
Open the folder in your favorite code editor and change theProgram.cs to matchProgram.cs from the sample.
using System;using System.Runtime.InteropServices.JavaScript;using System.Threading.Tasks;namespace TodoMVC{ public partial class MainJS { static Controller? controller; public static async Task Main() { if (!OperatingSystem.IsBrowser()) { throw new PlatformNotSupportedException("This demo is expected to run on browser platform"); } await JSHost.ImportAsync("todoMVC/store.js", "./store.js"); await JSHost.ImportAsync("todoMVC/view.js", "./view.js"); var store = new Store(); var view = new View(new Template()); controller = new Controller(store, view); Console.WriteLine("Ready!"); } }}AddItem.cs,Controller.cs,Template.cs which are C# ports of the ES6 sample code. Also addhelpers.js as it is.
Store
Store.cs is also almost the same as the ES6 version, except thelocalStorage API is wrapped bystore.js
export function setLocalStorage(todosJson) { window.localStorage.setItem('dotnet-wasm-todomvc', todosJson);}export function getLocalStorage() { return window.localStorage.getItem('dotnet-wasm-todomvc') || '[]';};Bind these JavaScript functions to C# code usingJSImportAttribute inStore.cs
static partial class Interop{ [JSImport("setLocalStorage", "todoMVC/store.js")] internal static partial void _setLocalStorage(string json); [JSImport("getLocalStorage", "todoMVC/store.js")] internal static partial string _getLocalStorage();}The first parameter of the attribute"setLocalStorage" is a name of a JS function.The function is exported from theES6 module named"todoMVC/store.js".The module name needs to be unique for all libraries in the application. The prefixtodoMVC/ solves that.It also maps to the name used inJSHost.ImportAsync in theMain.cs.
View
The last and most complex part is the View. Most of the real implementation is left in JavaScript inview.js.Unlike with Blazor, the newwasmbrowser template doesn’t include any UI framework, so we’ll leave all the UI logic in JavaScript.
export function removeItem(id) { const elem = qs(`[data-id="${id}"]`); if (elem) { $todoList.removeChild(elem); }}export function bindAddItem(handler) { $on($newTodo, 'change', ({ target }) => { const title = target.value.trim(); if (title) { handler(title); } });}View.cs binds the JS functions and makes them callable from C#. A couple of these imported functions are shown below, but there are more in the full sample.
public static partial class Interop{ [JSImport("removeItem", "todoMVC/view.js")] public static partial void removeItem([JSMarshalAs<JSType.Number>] long id); [JSImport("bindAddItem", "todoMVC/view.js")] public static partial void bindAddItem( [JSMarshalAs<JSType.Function<JSType.String>>] Action<string> handler);}TheremoveItem is passingInt64 as an argument. The marshaller is configured to translate it asJSType.Number which can only represent a 52-bit integer.The alternative would be to marshal it asJSType.BigInt which is bit more expensive.Since the runtime doesn’t want to guess which conversion is preferred in the use case, the developer needs to annotate the method parameter with[JSMarshalAs<T>].
In C# 11, it’s now possible touse generic instances as custom attributes, such asJSMarshalAsAttribute<T> in the above code. Enable the preview language version with<LangVersion>preview</LangVersion> in the project file.
ThebindAddItem is even more interesting. It’s passing a strongly typed callback delegatehandler.An explicit definition for the marshaller also needs to be there.It’s aFunction with a first argument of typeString.This callback is registered by$on helper with the DOMchange event for the<input/> when we callbindAddItem function.
Main
Copy the contents ofindex.html from the sample. The interesting part is
<script type='module' src="./main.js"/>It’s loadingmain.js which will import./dotnet.js and start the .NET runtime.
Copy the contents ofmain.js. The interesting part is
import { dotnet } from './dotnet.js'await dotnet.run();Edit the project file and add
<Project Sdk="Microsoft.NET.Sdk"> <PropertyGroup> <TargetFramework>net7.0</TargetFramework> <WasmMainJSPath>main.js</WasmMainJSPath> <OutputType>Exe</OutputType> <Nullable>enable</Nullable> <AllowUnsafeBlocks>true</AllowUnsafeBlocks>+ <LangVersion>preview</LangVersion> </PropertyGroup> <ItemGroup> <WasmExtraFilesToDeploy Include="index.html" />- <WasmExtraFilesToDeploy Include="main.js" /> + <WasmExtraFilesToDeploy Include="*.js" />+ <WasmExtraFilesToDeploy Include="*.css" /> </ItemGroup></Project>JSExport
The code above demonstrates how to pass callback from C# to JavaScript so that events fired by the browser can be handled by managed code. There is also another possibility: static methods annotated withJSExportAttribute that are callable from JavaScript.
namespace TodoMVC{ public partial class MainJS { [JSExport] public static void OnHashchange(string url) { controller?.SetView(url); } }}Themain.js usesgetAssemblyExports JavaScript API to get all the exports of the assembly.
const exports = await getAssemblyExports(getConfig().mainAssemblyName);exports.TodoMVC.MainJS.OnHashchange(document.location.hash);The code usesgetConfig().mainAssemblyName so that"TodoMVC.dll" doesn’t have to be hard-coded.Theexports object contains the same namespaceTodoMVC, the same classMainJS and the same methodOnHashchange as the C# code.The parameters of the call will be marshalled using the same rules as for[JSImport].Marshaling in both directions is governed by the C# method signature and the[JSMarshalAs<T>] annotations on the parameters and return values.
Run the app
Start the app with the following command (*) :
dotnet run* This is unfortunately broken on Windows for RC1 and it should befixed in RC2
For RC1 on Windows you could run following commands instead with similar results:
dotnet tool update dotnet-serve --globaldotnet serve --directory bin\Debug\net7.0\browser-wasm\AppBundleYou should see output similar to this. You can click on one of the URLs and test the application in your browser.
WasmAppHost --runtime-config C:\Dev\dotnet-wasm-todo-mvc\bin\Debug\net7.0\browser-wasm\AppBundle\TodoMVC.runtimeconfig.jsonApp url: http://127.0.0.1:9000/index.htmlApp url: https://127.0.0.1:58139/index.htmlYou can see a live demo of the completed app athttps://pavelsavara.github.io/dotnet-wasm-todo-mvc.
Optimize the app
It’s possible to preload the filesdotnet.js andmono-config.json which are the early dependencies for the runtime to begin starting.
It’s also possible to prefetch the largest binary files.In a real production application you should measure the impact of these optimizations.Depending on the expected bandwidth and the latency of the target device’s connectivity, strike a good balance of what to prefetch.
<head> <script type='module' src="./dotnet.js"/> <link rel="preload" href="./mono-config.json" as="fetch" crossorigin="anonymous"> <link rel="prefetch" href="./dotnet.wasm" as="fetch" crossorigin="anonymous"> <link rel="prefetch" href="./icudt.dat" as="fetch" crossorigin="anonymous"> <link rel="prefetch" href="./managed/System.Private.CoreLib.dll" as="fetch" crossorigin="anonymous"></head>In the project file enable ahead-of-time (AOT) compilation to improve speed.Enable trimming of unused code to reduce download size.
<Project Sdk="Microsoft.NET.Sdk"> <PropertyGroup> <TargetFramework>net7.0</TargetFramework> <WasmMainJSPath>main.js</WasmMainJSPath> <OutputType>Exe</OutputType> <Nullable>enable</Nullable> <AllowUnsafeBlocks>true</AllowUnsafeBlocks>+ <PublishTrimmed>true</PublishTrimmed>+ <TrimMode>full</TrimMode>+ <RunAOTCompilation>true</RunAOTCompilation> </PropertyGroup></Project>Please note that while AOT code runs much faster than interpreted IL, it increases size of the binary download.
When trimming unused code, the components which are used dynamically (for example via reflection) need to be protected from trimming.
In order to publish with AOT compilation, install thewasm-tools workload and then publish the app.
dotnet workload install wasm-toolsdotnet publish -c ReleaseWe can use thedotnet-serve tool to host the published app locally. Using the correct MIME type for.wasm allows the browser to use streaming instantiation of the WebAssembly module.
dotnet tool install --global dotnet-servedotnet serve --mime .wasm=application/wasm --mime .js=text/javascript --mime .json=application/json --directory bin\Release\net7.0\browser-wasm\AppBundle\Compressing the binary assets is also good idea but it’s out of the scope of this article.
Interop performance
We test the performance of the new JS interop model by running a set of microbenchmarks regularly.For example, this graph shows 10000 calls to a trivial JavaScript method using the C# signature[JSImport] int Echo(int value).Currently we measure various build configurations and browsers on a small ODROID-N2+.
Marshalling of primitive numbers andIntPtr is fast.Note in the graph the difference AOT can make.Marshallingstring requires us to allocate memory and copy the bits, so it’s slower.MarshalingSpan<byte> is cheap but theMemoryView on JavaScript side is valid only for the duration of the call.Marshalling a proxy ofobject involves an object allocation,GCHandle, and two GCs.MarshalingTask,Exception and alsoArraySegment<byte> is similar as they also allocate aGCHandle.
Blazor WebAssembly
The new interop with[JSImport] and[JSExport] is also available in Blazor WebAssembly apps.It’s useful when you need to integrate with the browser on the client side only.When you need to call your JavaScript also from the server side or in a native hybrid app, please use the existingIJSRuntime interface, which does JSON serialization and a remote dispatch.
For a simple sample, please have a look at thehands demo of 3rd party video processing JavaScript library integrated into Blazor WASM.
You can see a live demo of the app is athttps://pavelsavara.github.io/blazor-wasm-hands-pose
Legacy interop
Prior to .NET 7, to perform low-level JavaScript interop in Blazor WebAssembly apps you may have used the undocumented APIs grouped in theMONO andBINDING JavaScript namespaces.Those APIs are still there in .NET 7 for backward compatibility reason, but please consider them deprecated.They expose the user to raw pointers to managed objects and are not protected from GC and WASM memory resize.In Blazor theIJSUnmarshalledRuntime interface has similar downsides and is now also deprecated.The new interop with[JSImport] and[JSExport] should be a faster and safer replacement.
If your use case can’t be implemented by the new API or if you found a bug, please let us know bycreating a new issue on GitHub.
Conclusion
We hope that these new features will allow developers to create better integration between the JavaScript ecosystem and .NET. With these new features .NET developers can now wrap and use existing JavaScript libraries within existing frameworks like Blazor or Uno, or use them directly, like in this demo.
Author
10 comments
Discussion is closed.Login to edit/delete existing comments.
Shawinder Sekhon I was playing with the ToDo app. Looks like it takes some time to load the assemblies and then goes into ready state. If you try to create a todo right away then nothing happens. Showing a loader while the browser is getting ready will solve the problem.
Alberto Monteiro Compressing the binary assets is also good idea but it’s out of the scope of this article.
Could you send some content that covers that topic?
Chris Hansen Unfortunately (for me), the wasm-experimental install failed with the following:
Workload ID wasm-experimental is not recognized.
Any decent workaround?

Jaromír “Kerray” Matýšek Wow!
Richard Collette Without digging into it, one might think that .NET is doing the heavy lifting for the hand recognition, but that isn’t the case. Interestingly, it is done with a JavaScript library. I would really like to see the performance difference of running the hand recognition with .NET WASM. However, there isn’t an official implementation of MediaPipe in .NET

pavelsavara
Read moreHi Richard, you are right that the heavy lifting of video processing is the done in the 3rd party library.
This article is about the 3rd party library integration and video processing is one of the nice things, which make sense to do on the client side.
BTW: The awesome MediaPipe is also implemented in WASM and on emscripten as we are.
I give them full credit in the app UI and in the git repo, it's even in the screenshot here.It would be cool if somebody tried to implement the same with ML.NET and port it to .NET WASM!
Read lessHi Richard, you are right that the heavy lifting of video processing is the done in the 3rd party library.
This article is about the 3rd party library integration and video processing is one of the nice things, which make sense to do on the client side.
BTW: The awesome MediaPipe is also implemented in WASM and on emscripten as we are.
I give them full credit in the app UI and in the git repo, it’s even in the screenshot here.It would be cool if somebody tried to implement the same with ML.NET and port it to .NET WASM!
Nuno Cruz I was studying this as an alternative to use clearscript to run SSR of JavaScript libraries on the Dotnet asp server (no blazor) but seems very hard to do it.
Shahid Roofi Khan This thing can eventually replace javascript completely
Ammar Shaukat this is great.
Tony Henrique This is very cool!







